diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs index e59d90fb1..4585eee85 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs @@ -35,7 +35,9 @@ const SYSTEM_PROMPT: &str = r#" * 对每个 Component,直接完整返回其全部参数. * 有任何困难或者不确定把状态设为 NeedReview,说明中文原因。 * 纯结构节点可以返回空数组并标为 NoProblem。 +* 容器背景等推荐使用Simple + preserve_aspect: false 实现与node大小一致 * 面向用户的 reason 使用中文。 + "#; #[derive(Clone, Debug, Deserialize, JsonSchema)] diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/treeUtils.ts b/apps/ai-game-creator-shell/src/features/ui-editor/treeUtils.ts index 9e044efb8..52d0fa137 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/treeUtils.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/treeUtils.ts @@ -14,3 +14,20 @@ export function collectUiNodeIds(root: UiNode): Set { visitUiNodes(root, (node) => ids.add(node.id)); return ids; } + +export function findUiNode(root: UiNode, nodeId: NodeId): UiNode | null { + if (root.id === nodeId) return root; + for (const child of root.children) { + const found = findUiNode(child, nodeId); + if (found) return found; + } + return null; +} + +export function countUiNodeDescendants(root: UiNode): number { + let count = 0; + for (const child of root.children) { + count += 1 + countUiNodeDescendants(child); + } + return count; +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/UiNodeDeleteConfirmModal.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiNodeDeleteConfirmModal.tsx new file mode 100644 index 000000000..08e5362d3 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiNodeDeleteConfirmModal.tsx @@ -0,0 +1,56 @@ +import { ThemedModal } from '../../../components/modal/ThemedModal'; +import type { NodeId } from '../../../features/ui-editor/types/NodeId'; + +export type UiNodeDeleteRequest = { + nodeId: NodeId; + nodeLabel: string; + descendantCount: number; +}; + +type UiNodeDeleteConfirmModalProps = { + request: UiNodeDeleteRequest | null; + onCancel: () => void; + onConfirm: (nodeId: NodeId) => void; + disabled?: boolean; +}; + +export function UiNodeDeleteConfirmModal({ + request, + onCancel, + onConfirm, + disabled = false, +}: UiNodeDeleteConfirmModalProps) { + return ( + +

确认删除节点?

+

+ 将删除「{request?.nodeLabel ?? ''}」及其 {request?.descendantCount ?? 0}{' '} + 个后代节点,是否继续? +

+
+ + +
+
+ ); +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx index 5974e3ef7..14fde9869 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx @@ -1,7 +1,10 @@ -import { ChevronDown, Eye, EyeOff } from 'lucide-react'; +import { ChevronDown, Eye, EyeOff, Puzzle, Trash2 } from 'lucide-react'; import { + forwardRef, + type HTMLAttributes, type MouseEvent as ReactMouseEvent, useEffect, + useMemo, useRef, useState, } from 'react'; @@ -9,16 +12,25 @@ import { adjustMoveIndex, type MoveHandler, type NodeRendererProps, + type RowRendererProps, Tree, type TreeApi, } from 'react-arborist'; +import { + countUiNodeDescendants, + findUiNode, +} from '../../../features/ui-editor/treeUtils'; import type { Node as UiNode } from '../../../features/ui-editor/types/Node'; import type { NodeId } from '../../../features/ui-editor/types/NodeId'; import type { UIDesignImageId } from '../../../features/ui-editor/types/UIDesignImageId'; import type { UiNodeMoveRequest } from '../../../features/ui-editor/types/UiNodeMoveRequest'; import type { UiEditorNodeFocusRequest } from '../model'; import { UiNodeContextMenu } from './UiNodeContextMenu'; +import { + UiNodeDeleteConfirmModal, + type UiNodeDeleteRequest, +} from './UiNodeDeleteConfirmModal'; type UiTreePanelProps = { root: UiNode | null; @@ -36,6 +48,38 @@ type UiTreePanelProps = { className?: string; }; +const TreeListOuter = forwardRef< + HTMLDivElement, + HTMLAttributes +>(function TreeListOuter({ style, ...props }, ref) { + return ( +
+ ); +}); + +function TreeRowContainer({ + node, + innerRef, + attrs, + children, +}: RowRendererProps) { + return ( +
event.stopPropagation()} + onClick={node.handleClick} + > + {children} +
+ ); +} + function TreeRow({ node, style, @@ -44,11 +88,17 @@ function TreeRow({ isNodeVisible, onToggleNodeVisibility, onOpenContextMenu, + onRequestDelete, + canDelete, + deleteDisabled, }: NodeRendererProps & { onSelectNode: (id: NodeId) => void; isNodeVisible: (nodeId: NodeId) => boolean; onToggleNodeVisibility: (id: NodeId) => void; onOpenContextMenu: (event: ReactMouseEvent, id: NodeId) => void; + onRequestDelete: (id: NodeId) => void; + canDelete: boolean; + deleteDisabled: boolean; }) { const data = node.data; const nodeLabel = data.metadata.name || '未命名节点'; @@ -56,8 +106,8 @@ function TreeRow({ return (
{ @@ -88,23 +138,53 @@ function TreeRow({ /> ) : null} - {nodeLabel} - - {data.components.length} + + {nodeLabel} - + + + + +
); } @@ -127,11 +207,15 @@ export function UiTreePanel({ const treeApiRef = useRef | undefined>(undefined); const treeContainerRef = useRef(null); const [treeHeight, setTreeHeight] = useState(0); + const [treeWidth, setTreeWidth] = useState(0); const [contextMenu, setContextMenu] = useState<{ nodeId: NodeId; x: number; y: number; } | null>(null); + const [pendingDelete, setPendingDelete] = useState< + (UiNodeDeleteRequest & { treeId: UIDesignImageId }) | null + >(null); useEffect(() => { if (!focusRequest) return; @@ -150,6 +234,41 @@ export function UiTreePanel({ return () => resizeObserver.disconnect(); }, []); + const treeRows = useMemo(() => { + if (!root) return []; + const rows: Array<{ node: UiNode; depth: number }> = []; + const visit = (node: UiNode, depth: number) => { + rows.push({ node, depth }); + node.children.forEach((child) => visit(child, depth + 1)); + }; + visit(root, 0); + return rows; + }, [root]); + + const treeContentWidth = useMemo( + () => + treeRows.reduce((width, { node, depth }) => { + const labelWidth = Math.max( + 56, + (node.metadata.name || '未命名节点').length * 8, + ); + return Math.max(width, 16 + depth * 16 + 24 + labelWidth + 42 + 52); + }, 0), + [treeRows], + ); + + useEffect(() => { + const container = treeContainerRef.current; + if (!container) return; + const updateWidth = () => { + setTreeWidth(Math.max(treeContentWidth, container.clientWidth)); + }; + updateWidth(); + const resizeObserver = new ResizeObserver(updateWidth); + resizeObserver.observe(container); + return () => resizeObserver.disconnect(); + }, [treeContentWidth]); + const handleMove: MoveHandler = ({ dragIds, parentId, @@ -184,21 +303,55 @@ export function UiTreePanel({ const pageRootIds = new Set(root?.children.map((child) => child.id) ?? []); const contextTreeId = contextMenu ? treeIdForNode(contextMenu.nodeId) : null; + const requestDelete = (nodeId: NodeId) => { + if (!root || isLocked || nodeId === root.id || pageRootIds.has(nodeId)) { + return; + } + const treeId = treeIdForNode(nodeId); + const node = findUiNode(root, nodeId); + if (!treeId || !node) return; + const descendantCount = countUiNodeDescendants(node); + if (descendantCount === 0) { + onDeleteNode(treeId, nodeId); + return; + } + setPendingDelete({ + treeId, + nodeId, + nodeLabel: node.metadata.name || '未命名节点', + descendantCount, + }); + }; + + const confirmDelete = (nodeId: NodeId) => { + if (!pendingDelete || isLocked || pendingDelete.nodeId !== nodeId) { + return; + } + const { treeId } = pendingDelete; + setPendingDelete(null); + onDeleteNode(treeId, nodeId); + }; + return (
event.preventDefault()} > -
+
{root && treeHeight > 0 ? ( ref={treeApiRef} data={[root]} - width="100%" + width={treeWidth > 0 ? treeWidth : '100%'} + outerElementType={TreeListOuter} height={treeHeight} rowHeight={34} indent={16} openByDefault + renderRow={TreeRowContainer} selection={selectedNodeId ?? undefined} onSelect={(nodes) => { const selected = nodes[0]?.data; @@ -224,6 +377,12 @@ export function UiTreePanel({ }} isNodeVisible={isNodePreviewVisible} onToggleNodeVisibility={onToggleNodeVisibility} + onRequestDelete={requestDelete} + canDelete={ + props.node.data.id !== root.id && + !pageRootIds.has(props.node.data.id) + } + deleteDisabled={isLocked} onOpenContextMenu={(event, nodeId) => nodeId === root.id ? undefined @@ -252,9 +411,15 @@ export function UiTreePanel({ onClose={() => setContextMenu(null)} onInsertChild={(nodeId) => onInsertNode(contextTreeId, nodeId)} onInsertSibling={(nodeId) => onInsertNodeAfter(contextTreeId, nodeId)} - onDelete={(nodeId) => onDeleteNode(contextTreeId, nodeId)} + onDelete={requestDelete} /> ) : null} + setPendingDelete(null)} + onConfirm={confirmDelete} + disabled={isLocked} + />
); } diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowCompletionModal.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowCompletionModal.tsx new file mode 100644 index 000000000..fbda3f214 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowCompletionModal.tsx @@ -0,0 +1,40 @@ +import { ThemedModal } from '../../../components/modal/ThemedModal'; +import type { WorkflowCompletionNotice } from './workflowCompletionNotice'; +import { workflowStepLabel } from './workflowCompletionNotice'; + +export function WorkflowCompletionModal({ + notice, + onClose, +}: { + notice: WorkflowCompletionNotice | null; + onClose: () => void; +}) { + if (!notice) return null; + const stepLabel = workflowStepLabel(notice.step); + const outcomeLabel = notice.outcome === 'success' ? '完成' : '失败'; + return ( + +

+ {stepLabel} + {outcomeLabel} +

+

+ {notice.message} +

+
+ +
+
+ ); +} 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 75a1efce3..c923ca875 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,6 @@ import { + CANVAS_ZOOM_IN_FACTOR, + CANVAS_ZOOM_OUT_FACTOR, type CanvasViewport, createPanDragState, type DragState, @@ -28,8 +30,14 @@ import type { Node } from '../../../../features/ui-editor/types/Node'; import type { NodeId } from '../../../../features/ui-editor/types/NodeId'; import type { UiEditorCanvasProjection } from '../../useUiEditorPage'; import { UiNodeContextMenu } from '../UiNodeContextMenu'; +import { + handlePreviewZoomKeyDown, + isPreviewZoomInteractiveTarget, + previewZoomUsesMetaModifier, +} from './previewZoomKeyboard'; import { type UiEditorRenderMode, UiTreeRenderer } from './UiTreeRenderer'; import { useNodeTransformInteraction } from './useNodeTransformInteraction'; +import { ZoomPercentageInput } from './ZoomPercentageInput'; export function PreviewWorkspace({ canvas, @@ -41,6 +49,8 @@ export function PreviewWorkspace({ const viewportRef = useRef({ x: 0, y: 0, scale: 0.5 }); const handledFocusRequestIdRef = useRef(null); const panRef = useRef | null>(null); + const previewFocusedRef = useRef(false); + const previewHoveredRef = useRef(false); const [viewport, setViewportState] = useState( viewportRef.current, ); @@ -150,6 +160,34 @@ export function PreviewWorkspace({ ); }, [logicalSize, setViewport]); + const scaleViewportFromCenter = useCallback( + (nextScale: number) => { + const element = viewportElementRef.current; + const width = element?.clientWidth || canvasSize.width; + const height = element?.clientHeight || canvasSize.height; + setViewport( + scaleViewportFromScreenPoint({ + viewport: viewportRef.current, + nextScale, + screenPoint: { x: width / 2, y: height / 2 }, + }), + ); + }, + [canvasSize.height, canvasSize.width, setViewport], + ); + + const resetToActualSize = useCallback(() => { + scaleViewportFromCenter(1); + }, [scaleViewportFromCenter]); + + const zoomIn = useCallback(() => { + scaleViewportFromCenter(viewportRef.current.scale * CANVAS_ZOOM_IN_FACTOR); + }, [scaleViewportFromCenter]); + + const zoomOut = useCallback(() => { + scaleViewportFromCenter(viewportRef.current.scale * CANVAS_ZOOM_OUT_FACTOR); + }, [scaleViewportFromCenter]); + useEffect(() => { const element = viewportElementRef.current; if (!element) return; @@ -224,6 +262,9 @@ export function PreviewWorkspace({ ), ); }; + const usesMetaModifier = previewZoomUsesMetaModifier( + window.navigator.platform, + ); const onKeyDown = (event: KeyboardEvent) => { if ( event.code === 'Space' && @@ -232,23 +273,21 @@ export function PreviewWorkspace({ ) { setSpaceHeld(true); } - if (!event.ctrlKey && !event.metaKey) return; - if (event.key === '0') { - event.preventDefault(); - fitToCanvas(); - } else if (event.key === '1' && logicalSize) { - event.preventDefault(); - const element = viewportElementRef.current; - const width = element?.clientWidth ?? 900; - const height = element?.clientHeight ?? 640; - setViewport( - scaleViewportFromScreenPoint({ - viewport: viewportRef.current, - nextScale: 1, - screenPoint: { x: width / 2, y: height / 2 }, - }), - ); - } + handlePreviewZoomKeyDown( + event, + { + hasZoomableViewport: logicalSize !== null, + isFocused: previewFocusedRef.current, + isHovered: previewHoveredRef.current, + usesMetaModifier, + }, + { + fit: fitToCanvas, + resetToActualSize, + zoomIn, + zoomOut, + }, + ); }; const onKeyUp = (event: KeyboardEvent) => { if (event.code === 'Space') setSpaceHeld(false); @@ -262,9 +301,12 @@ export function PreviewWorkspace({ window.removeEventListener('keyup', onKeyUp); window.removeEventListener('blur', onWindowBlur); }; - }, [fitToCanvas, logicalSize, setViewport]); + }, [fitToCanvas, logicalSize, resetToActualSize, zoomIn, zoomOut]); const handlePointerDown = (event: ReactPointerEvent) => { + if (event.button === 0 && !isPreviewZoomInteractiveTarget(event.target)) { + event.currentTarget.focus({ preventScroll: true }); + } if (event.button === 1 || (event.button === 0 && spaceHeld)) { event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); @@ -371,7 +413,27 @@ export function PreviewWorkspace({
{ + previewHoveredRef.current = true; + }} + onPointerLeave={() => { + previewHoveredRef.current = false; + }} + onFocus={() => { + previewFocusedRef.current = true; + }} + onBlur={(event) => { + if ( + !(event.relatedTarget instanceof Node) || + !event.currentTarget.contains(event.relatedTarget) + ) { + previewFocusedRef.current = false; + } + }} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} @@ -448,21 +510,20 @@ export function PreviewWorkspace({ { - const element = viewportElementRef.current; - const width = element?.clientWidth ?? canvasSize.width; - const height = element?.clientHeight ?? canvasSize.height; - setViewport( - scaleViewportFromScreenPoint({ - viewport: viewportRef.current, - nextScale, - screenPoint: { x: width / 2, y: height / 2 }, - }), - ); - }} + onScaleFromCenter={scaleViewportFromCenter} > {(actions) => ( -
+
{ + if ( + event.target instanceof Element && + event.target.closest('button') + ) { + event.preventDefault(); + } + }} + > + + actions.zoomToDisplayScale(Number(event.target.value) / 100) + } + /> + + actions.zoomToDisplayScale(percent / 100) + } + />
+ setReturnConfirmOpen(false)} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/uiEditorKeyboardShortcuts.ts b/apps/ai-game-creator-shell/src/view/ui-editor/uiEditorKeyboardShortcuts.ts new file mode 100644 index 000000000..90657f5b4 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/uiEditorKeyboardShortcuts.ts @@ -0,0 +1,82 @@ +import type { NodeId } from '../../features/ui-editor/types/NodeId'; +import type { UIDesignImageId } from '../../features/ui-editor/types/UIDesignImageId'; + +type DeleteResult = { ok: boolean } | undefined; + +export type UiEditorKeyboardActions = { + selectedNodeId: NodeId | null; + activeImageId: UIDesignImageId | null; + deleteNode: (nodeId: NodeId, treeId: UIDesignImageId) => DeleteResult; + historyUndo: () => boolean; + historyRedo: () => boolean; +}; + +function isEditableTarget(target: EventTarget | null) { + const element = target instanceof HTMLElement ? target : null; + return Boolean( + element?.isContentEditable || + element?.closest('input, textarea, select, [contenteditable="true"]'), + ); +} + +function isInteractiveTarget(target: EventTarget | null) { + const element = target instanceof Element ? target : null; + return Boolean( + (target instanceof HTMLElement && target.isContentEditable) || + element?.closest( + 'button, a, input, textarea, select, [contenteditable="true"], [role="button"], [role="dialog"], [aria-modal="true"]', + ), + ); +} + +function isModalTarget(target: EventTarget | null) { + const element = target instanceof Element ? target : null; + return Boolean(element?.closest('[role="dialog"], [aria-modal="true"]')); +} + +export function handleUiEditorKeyDown( + event: KeyboardEvent, + actions: UiEditorKeyboardActions, +) { + if (isModalTarget(event.target)) return; + + if ( + (event.key === 'Delete' || event.key === 'Backspace') && + !event.repeat && + !event.defaultPrevented && + !event.ctrlKey && + !event.metaKey && + !event.altKey && + !event.shiftKey && + !isInteractiveTarget(event.target) + ) { + if (actions.selectedNodeId && actions.activeImageId) { + const result = actions.deleteNode( + actions.selectedNodeId, + actions.activeImageId, + ); + if (result?.ok) { + event.preventDefault(); + event.stopPropagation(); + return; + } + } + } + if ( + event.repeat || + event.defaultPrevented || + isEditableTarget(event.target) || + (!event.ctrlKey && !event.metaKey) + ) { + return; + } + const isUndo = event.key.toLowerCase() === 'z' && !event.shiftKey; + const isRedo = + (event.key.toLowerCase() === 'z' && event.shiftKey) || + (event.ctrlKey && event.key.toLowerCase() === 'y'); + if (isUndo && actions.historyUndo()) { + event.preventDefault(); + } else if (isRedo && actions.historyRedo()) { + event.preventDefault(); + } +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts index b2c98484b..a580a652d 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts @@ -10,7 +10,10 @@ import { } from '../../features/ui-editor/importAdapter'; import { applyMergeResult } from '../../features/ui-editor/merge'; import { applyRecognitionResult } from '../../features/ui-editor/recognition'; -import type { StageStatusField } from '../../features/ui-editor/stageStatusOverview'; +import { + getStageStatusOverview, + type StageStatusField, +} from '../../features/ui-editor/stageStatusOverview'; import { collectUiNodeIds } from '../../features/ui-editor/treeUtils'; import type { BindingDTO } from '../../features/ui-editor/types/BindingDTO'; import type { ChildrenDisplayMode } from '../../features/ui-editor/types/ChildrenDisplayMode'; @@ -54,6 +57,10 @@ import { prerequisiteIssuesForStep, type UiEditorPrerequisiteIssue, } from './components/WorkflowChecks'; +import { + appendWorkflowCheckPrompt, + type WorkflowCompletionNotice, +} from './components/workflowCompletionNotice'; import { type PendingResourceRemoval, removalHasDownstreamReferences, @@ -175,6 +182,8 @@ export function useUiEditorSession( initialFurthestStepIndex = 0, ) { const editor = useUiEditorState(EMPTY_UI_EDITOR_STATE); + const editorDeleteNode = editor.deleteNode; + const editorUiTrees = editor.state.ui_trees; const replaceEditorState = editor.replaceState; const [isLoading, setIsLoading] = useState(Boolean(resourceId)); const [loadError, setLoadError] = useState(null); @@ -246,6 +255,19 @@ export function useUiEditorSession( const [hasSuggested, setHasSuggested] = useState(false); const [hasRecognized, setHasRecognized] = useState(false); const [hasBound, setHasBound] = useState(false); + const [completionNotice, setCompletionNotice] = + useState(null); + + function reportWorkflowCompletion( + step: UiEditorStepId, + outcome: WorkflowCompletionNotice['outcome'], + rawMessage: string, + setStatus: (message: string) => void, + ) { + const message = appendWorkflowCheckPrompt(rawMessage); + setStatus(message); + setCompletionNotice({ step, outcome, message }); + } useEffect(() => { setActiveStep(initialStep); @@ -437,6 +459,9 @@ export function useUiEditorSession( ); return next.size === current.size ? current : next; }); + setSelectedNodeId((current) => + current && !validNodeIds.has(current) ? null : current, + ); }, [editor.state.ui_trees]); const isNodePreviewVisible = useCallback( @@ -879,16 +904,26 @@ export function useUiEditorSession( return result; } - function deleteNode(nodeId: NodeId, treeId = activeImageId) { - if (!treeId) return; - const result = editor.deleteNode(treeId, nodeId); - if (!result.ok) { - setStatus('无法删除该节点。'); + const deleteNode = useCallback( + (nodeId: NodeId, treeId = activeImageId) => { + if (!treeId) return; + const tree = editorUiTrees.find( + (candidate) => candidate.src_ui_design === treeId, + ); + const location = tree ? findUiNodeLocation(tree.root, nodeId) : null; + const deletedNodeIds = location ? collectUiNodeIds(location.node) : null; + const result = editorDeleteNode(treeId, nodeId); + if (!result.ok) { + setStatus('无法删除该节点。'); + return result; + } + if (selectedNodeId !== null && deletedNodeIds?.has(selectedNodeId)) { + setSelectedNodeId(null); + } return result; - } - if (selectedNodeId === nodeId) setSelectedNodeId(null); - return result; - } + }, + [activeImageId, editorDeleteNode, editorUiTrees, selectedNodeId], + ); function selectSprite(id: SpriteAssetId) { setSelectedNodeId(null); @@ -939,6 +974,7 @@ export function useUiEditorSession( async function suggestUiDesignSemantics() { if (isSuggesting || isWorkflowBusy) return; + setCompletionNotice(null); setSuggestionStatus(null); setIsSuggesting(true); try { @@ -949,11 +985,19 @@ export function useUiEditorSession( ); editor.replaceState(applyUiDesignSuggestions(snapshot, suggestions)); setHasSuggested(true); - setSuggestionStatus(`已应用 ${suggestions.length} 条参考图语义建议。`); + reportWorkflowCompletion( + 'reference-analysis', + 'success', + `参考图分析完成:已应用 ${suggestions.length} 条参考图语义建议`, + setSuggestionStatus, + ); }); } catch (cause) { - setSuggestionStatus( + reportWorkflowCompletion( + 'reference-analysis', + 'failure', cause instanceof Error ? cause.message : String(cause), + setSuggestionStatus, ); } finally { setIsSuggesting(false); @@ -962,6 +1006,7 @@ export function useUiEditorSession( async function recognizeUi() { if (isRecognizing || isWorkflowBusy) return; + setCompletionNotice(null); setRecognitionStatus(null); setIsRecognizing(true); try { @@ -970,14 +1015,27 @@ export function useUiEditorSession( projectPath, state: snapshot, }); - editor.replaceState(applyRecognitionResult(snapshot, result)); + const nextState = applyRecognitionResult(snapshot, result); + editor.replaceState(nextState); setHasRecognized(true); setSelectedNodeId(null); - setRecognitionStatus(`已替换 ${result.ui_trees.length} 棵界面树。`); + const overview = getStageStatusOverview( + nextState.ui_trees, + 'layout_status', + ); + reportWorkflowCompletion( + 'structure-recognition', + 'success', + `界面结构识别完成:已替换 ${result.ui_trees.length} 棵界面树,待检查 ${overview.needsAttention} 项(必须修复 ${overview.blocked} 项)`, + setRecognitionStatus, + ); }); } catch (cause) { - setRecognitionStatus( + reportWorkflowCompletion( + 'structure-recognition', + 'failure', cause instanceof Error ? cause.message : String(cause), + setRecognitionStatus, ); } finally { setIsRecognizing(false); @@ -1005,6 +1063,7 @@ export function useUiEditorSession( async function bindComponents() { if (isBinding || isWorkflowBusy) return; + setCompletionNotice(null); setBindingStatus(null); setIsBinding(true); try { @@ -1032,13 +1091,21 @@ export function useUiEditorSession( history: index < batches.length - 1 ? 'skip' : 'record', }); } - setBindingStatus( - `组件绑定完成(${batches.length}/${batches.length})。`, + reportWorkflowCompletion( + 'visual-binding', + 'success', + `视觉素材绑定完成:已处理 ${batches.length}/${batches.length} 个批次`, + setBindingStatus, ); setHasBound(true); }); } catch (cause) { - setBindingStatus(cause instanceof Error ? cause.message : String(cause)); + reportWorkflowCompletion( + 'visual-binding', + 'failure', + cause instanceof Error ? cause.message : String(cause), + setBindingStatus, + ); } finally { setIsBinding(false); } @@ -1303,6 +1370,7 @@ export function useUiEditorSession( isBinding, hasBound, bindingStatus, + completionNotice, bindComponents, requestStepChange, continueToNextStep: () => { @@ -1310,6 +1378,7 @@ export function useUiEditorSession( }, confirmStepChange, cancelStepChange: () => setPendingWorkflowStepChange(null), + dismissCompletionNotice: () => setCompletionNotice(null), }, dialogs: { projectPath, diff --git a/apps/ai-game-creator-shell/tests/ZoomPercentageInput.test.tsx b/apps/ai-game-creator-shell/tests/ZoomPercentageInput.test.tsx new file mode 100644 index 000000000..f02a71e3d --- /dev/null +++ b/apps/ai-game-creator-shell/tests/ZoomPercentageInput.test.tsx @@ -0,0 +1,67 @@ +// @vitest-environment jsdom + +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { ZoomPercentageInput } from '../src/view/ui-editor/components/preview/ZoomPercentageInput'; + +describe('ZoomPercentageInput', () => { + it('edits the displayed percentage and commits on blur without fitting', () => { + const onCommit = vi.fn(); + render(); + + const input = screen.getByRole('spinbutton', { name: /画布缩放/ }); + expect((input as HTMLInputElement).value).toBe('50'); + + fireEvent.focus(input); + fireEvent.change(input, { target: { value: '125' } }); + expect(onCommit).not.toHaveBeenCalled(); + fireEvent.blur(input); + + expect(onCommit).toHaveBeenCalledWith(125); + expect((input as HTMLInputElement).value).toBe('125'); + }); + + it.each([ + { value: '0', expected: 25 }, + { value: '999', expected: 200 }, + ])('clamps $value to $expected on blur', ({ value, expected }) => { + const onCommit = vi.fn(); + render(); + + const input = screen.getByRole('spinbutton', { name: /画布缩放/ }); + fireEvent.focus(input); + fireEvent.change(input, { target: { value } }); + fireEvent.blur(input); + + expect(onCommit).toHaveBeenCalledWith(expected); + expect((input as HTMLInputElement).value).toBe(String(expected)); + }); + + it('restores the current percentage when the draft is invalid', () => { + const onCommit = vi.fn(); + render(); + + const input = screen.getByRole('spinbutton', { name: /画布缩放/ }); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: '' } }); + fireEvent.blur(input); + + expect(onCommit).not.toHaveBeenCalled(); + expect((input as HTMLInputElement).value).toBe('80'); + }); + + it('tracks viewport updates while not editing', () => { + const onCommit = vi.fn(); + const view = render( + , + ); + const input = screen.getByRole('spinbutton', { name: /画布缩放/ }); + + view.rerender( + , + ); + + expect((input as HTMLInputElement).value).toBe('140'); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx b/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx new file mode 100644 index 000000000..908086f1d --- /dev/null +++ b/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx @@ -0,0 +1,203 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Node as UiNode } from '../src/features/ui-editor/types/Node'; +import type { UIDesignImage } from '../src/features/ui-editor/types/UIDesignImage'; +import { PreviewWorkspace } from '../src/view/ui-editor/components/preview/PreviewWorkspace'; +import type { UiEditorCanvasProjection } from '../src/view/ui-editor/useUiEditorPage'; + +class TestResizeObserver { + observe() {} + disconnect() {} + unobserve() {} +} + +const root: UiNode = { + id: 'root', + 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: '根节点', + description: '', + layout_status: 'NoProblem', + components_status: 'NoProblem', + allow_llm_edit_layout: true, + allow_llm_edit_component: true, + source: 'System', + }, + components: [], + children_display_mode: 'Stack', + children: [], +}; + +const activeImage: UIDesignImage = { + metadata: { + name: '测试界面', + description: '', + role: null, + slave_to: null, + }, + path: 'assets/page.png', + pixel_size: [1200, 800], + pixels_per_unit: 1, +}; + +function createCanvas( + overrides: Partial = {}, +): UiEditorCanvasProjection { + return { + isLocked: false, + activeImage, + activeImageId: 'page-1', + previewUrls: { 'page-1': 'data:image/png;base64,' }, + images: { 'page-1': activeImage }, + sprites: {}, + fontFaces: {}, + tree: { src_ui_design: 'page-1', root }, + selectedNode: null, + selectedNodeId: null, + keepChildrenUnchanged: false, + hiddenNodeIds: new Set(), + focusRequest: null, + status: null, + isNodePreviewVisible: vi.fn(() => true), + toggleNodePreviewVisibility: vi.fn(), + selectExclusiveChild: vi.fn(), + selectNode: vi.fn(), + clearNodeSelection: vi.fn(), + updateNodeTransform: vi.fn(), + insertNode: vi.fn(), + insertNodeAfter: vi.fn(), + deleteNode: vi.fn(), + openClearDialog: vi.fn(), + ...overrides, + }; +} + +function renderedScale(container: HTMLElement) { + const transform = ( + container.querySelector('.genarrative-image-canvas__world') as HTMLElement + ).style.transform; + const scale = /scale\(([^)]+)\)/.exec(transform)?.[1]; + if (!scale) throw new Error(`无法从 ${transform} 读取缩放值`); + return Number(scale); +} + +beforeEach(() => { + vi.stubGlobal('ResizeObserver', TestResizeObserver); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('PreviewWorkspace quick zoom', () => { + it('uses the toolbar zoom step while the preview is hovered', () => { + const rendered = render(); + const preview = screen.getByRole('region', { name: 'UI 预览画布' }); + const before = renderedScale(rendered.container); + + fireEvent.pointerEnter(preview); + const wasNotCancelled = fireEvent.keyDown(window, { + key: '=', + code: 'Equal', + ctrlKey: true, + cancelable: true, + }); + + expect(wasNotCancelled).toBe(false); + expect(renderedScale(rendered.container)).toBeCloseTo(before * 1.16); + }); + + it('requires hover or focus and ignores interactive targets', () => { + const rendered = render(); + const preview = screen.getByRole('region', { name: 'UI 预览画布' }); + const zoomButton = screen.getByRole('button', { name: '放大画布' }); + const initial = renderedScale(rendered.container); + + expect( + fireEvent.keyDown(window, { + key: '=', + ctrlKey: true, + cancelable: true, + }), + ).toBe(true); + expect(renderedScale(rendered.container)).toBe(initial); + + fireEvent.pointerEnter(preview); + expect( + fireEvent.keyDown(zoomButton, { + key: '=', + ctrlKey: true, + cancelable: true, + }), + ).toBe(true); + expect(renderedScale(rendered.container)).toBe(initial); + + fireEvent.pointerLeave(preview); + fireEvent.focus(preview); + fireEvent.keyDown(window, { + key: '-', + code: 'Minus', + ctrlKey: true, + cancelable: true, + }); + expect(renderedScale(rendered.container)).toBeCloseTo(initial * 0.86); + }); + + it('keeps actual-size and fit shortcuts within the preview scope', () => { + const rendered = render(); + const preview = screen.getByRole('region', { name: 'UI 预览画布' }); + const fitted = renderedScale(rendered.container); + + fireEvent.focus(preview); + fireEvent.keyDown(window, { + key: '1', + ctrlKey: true, + cancelable: true, + }); + expect(renderedScale(rendered.container)).toBe(1); + + fireEvent.keyDown(window, { + key: '0', + ctrlKey: true, + cancelable: true, + }); + expect(renderedScale(rendered.container)).toBe(fitted); + }); + + it('leaves browser zoom untouched when the preview has no content', () => { + render( + , + ); + + expect( + fireEvent.keyDown(window, { + key: '=', + ctrlKey: true, + cancelable: true, + }), + ).toBe(true); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/previewZoomKeyboard.test.ts b/apps/ai-game-creator-shell/tests/previewZoomKeyboard.test.ts new file mode 100644 index 000000000..5ef72b7db --- /dev/null +++ b/apps/ai-game-creator-shell/tests/previewZoomKeyboard.test.ts @@ -0,0 +1,176 @@ +// @vitest-environment jsdom + +import { fireEvent } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + handlePreviewZoomKeyDown, + type PreviewZoomKeyboardActions, + type PreviewZoomKeyboardContext, + previewZoomUsesMetaModifier, +} from '../src/view/ui-editor/components/preview/previewZoomKeyboard'; + +function createActions(): PreviewZoomKeyboardActions { + return { + fit: vi.fn(), + resetToActualSize: vi.fn(), + zoomIn: vi.fn(), + zoomOut: vi.fn(), + }; +} + +function dispatchShortcut({ + actions = createActions(), + context = {}, + event, + target, +}: { + actions?: PreviewZoomKeyboardActions; + context?: Partial; + event: KeyboardEventInit; + target?: HTMLElement; +}) { + const resolvedTarget = target ?? document.createElement('div'); + document.body.append(resolvedTarget); + const handler = vi.fn((keyboardEvent: KeyboardEvent) => + handlePreviewZoomKeyDown( + keyboardEvent, + { + hasZoomableViewport: true, + isFocused: false, + isHovered: true, + usesMetaModifier: false, + ...context, + }, + actions, + ), + ); + resolvedTarget.addEventListener('keydown', handler); + fireEvent.keyDown(resolvedTarget, event); + const keyboardEvent = handler.mock.calls[0]?.[0]; + return { actions, handler, keyboardEvent }; +} + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe('preview zoom keyboard shortcuts', () => { + it.each([ + { key: '+', code: 'Equal' }, + { key: '=', code: 'Equal' }, + { key: '+', code: 'NumpadAdd' }, + ])('zooms in for the supported main and numpad keys (%o)', (event) => { + const { actions, keyboardEvent } = dispatchShortcut({ + event: { ...event, ctrlKey: true, cancelable: true, repeat: true }, + }); + + expect(actions.zoomIn).toHaveBeenCalledTimes(1); + expect(keyboardEvent?.defaultPrevented).toBe(true); + }); + + it.each([ + { key: '-', code: 'Minus' }, + { key: '-', code: 'NumpadSubtract' }, + ])('zooms out for the supported main and numpad keys (%o)', (event) => { + const { actions, keyboardEvent } = dispatchShortcut({ + event: { ...event, ctrlKey: true, cancelable: true }, + }); + + expect(actions.zoomOut).toHaveBeenCalledTimes(1); + expect(keyboardEvent?.defaultPrevented).toBe(true); + }); + + it('does not treat underscore as zoom out', () => { + const { actions, keyboardEvent } = dispatchShortcut({ + event: { + key: '_', + code: 'Minus', + ctrlKey: true, + shiftKey: true, + cancelable: true, + }, + }); + + expect(actions.zoomOut).not.toHaveBeenCalled(); + expect(keyboardEvent?.defaultPrevented).toBe(false); + }); + + it.each([ + { key: '0', action: 'fit' as const }, + { key: '1', action: 'resetToActualSize' as const }, + ])('keeps the existing $key shortcut', ({ key, action }) => { + const { actions } = dispatchShortcut({ + event: { key, ctrlKey: true, cancelable: true }, + }); + + expect(actions[action]).toHaveBeenCalledTimes(1); + }); + + it('uses Cmd on Apple platforms and Ctrl elsewhere', () => { + expect(previewZoomUsesMetaModifier('MacIntel')).toBe(true); + expect(previewZoomUsesMetaModifier('iPad')).toBe(true); + expect(previewZoomUsesMetaModifier('Win32')).toBe(false); + + const appleActions = createActions(); + dispatchShortcut({ + actions: appleActions, + context: { usesMetaModifier: true }, + event: { key: '=', ctrlKey: true, cancelable: true }, + }); + expect(appleActions.zoomIn).not.toHaveBeenCalled(); + dispatchShortcut({ + actions: appleActions, + context: { usesMetaModifier: true }, + event: { key: '=', metaKey: true, cancelable: true }, + }); + expect(appleActions.zoomIn).toHaveBeenCalledTimes(1); + }); + + it.each([ + { isHovered: false, isFocused: false, hasZoomableViewport: true }, + { isHovered: true, isFocused: false, hasZoomableViewport: false }, + ])('leaves inactive preview shortcuts to the host (%o)', (context) => { + const { actions, keyboardEvent } = dispatchShortcut({ + context, + event: { key: '=', ctrlKey: true, cancelable: true }, + }); + + expect(actions.zoomIn).not.toHaveBeenCalled(); + expect(keyboardEvent?.defaultPrevented).toBe(false); + }); + + it('works while the preview is focused without being hovered', () => { + const { actions } = dispatchShortcut({ + context: { isFocused: true, isHovered: false }, + event: { key: '=', ctrlKey: true, cancelable: true }, + }); + + expect(actions.zoomIn).toHaveBeenCalledTimes(1); + }); + + it.each(['input', 'button', 'a'])('ignores interactive %s targets', (tag) => { + const target = document.createElement(tag); + if (target instanceof HTMLAnchorElement) target.href = '#preview'; + const { actions, keyboardEvent } = dispatchShortcut({ + target, + event: { key: '=', ctrlKey: true, cancelable: true }, + }); + + expect(actions.zoomIn).not.toHaveBeenCalled(); + expect(keyboardEvent?.defaultPrevented).toBe(false); + }); + + it('does not override an event already handled by another control', () => { + const target = document.createElement('div'); + target.addEventListener('keydown', (event) => event.preventDefault(), { + once: true, + }); + const { actions } = dispatchShortcut({ + target, + event: { key: '=', ctrlKey: true, cancelable: true }, + }); + + expect(actions.zoomIn).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/uiEditorKeyboardShortcuts.test.ts b/apps/ai-game-creator-shell/tests/uiEditorKeyboardShortcuts.test.ts new file mode 100644 index 000000000..f21c284db --- /dev/null +++ b/apps/ai-game-creator-shell/tests/uiEditorKeyboardShortcuts.test.ts @@ -0,0 +1,109 @@ +// @vitest-environment jsdom + +import { fireEvent } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { handleUiEditorKeyDown } from '../src/view/ui-editor/uiEditorKeyboardShortcuts'; + +describe('ui editor keyboard shortcuts', () => { + afterEach(() => { + document.body.replaceChildren(); + }); + + it.each([ + { key: 'z', ctrlKey: true }, + { key: 'z', ctrlKey: true, shiftKey: true }, + { key: 'y', ctrlKey: true }, + ])('does not change history from inside a modal (%o)', (shortcut) => { + const dialog = document.createElement('div'); + dialog.setAttribute('role', 'dialog'); + const button = document.createElement('button'); + dialog.append(button); + document.body.append(dialog); + const historyUndo = vi.fn(() => true); + const historyRedo = vi.fn(() => true); + const listener = (event: KeyboardEvent) => + handleUiEditorKeyDown(event, { + selectedNodeId: null, + activeImageId: null, + deleteNode: vi.fn(), + historyUndo, + historyRedo, + }); + window.addEventListener('keydown', listener); + + fireEvent.keyDown(button, shortcut); + + expect(historyUndo).not.toHaveBeenCalled(); + expect(historyRedo).not.toHaveBeenCalled(); + window.removeEventListener('keydown', listener); + }); + + it('keeps undo available from a non-modal button', () => { + const button = document.createElement('button'); + document.body.append(button); + const historyUndo = vi.fn(() => true); + const historyRedo = vi.fn(() => true); + const listener = (event: KeyboardEvent) => + handleUiEditorKeyDown(event, { + selectedNodeId: null, + activeImageId: null, + deleteNode: vi.fn(), + historyUndo, + historyRedo, + }); + window.addEventListener('keydown', listener); + + fireEvent.keyDown(button, { key: 'z', ctrlKey: true }); + + expect(historyUndo).toHaveBeenCalledTimes(1); + window.removeEventListener('keydown', listener); + }); + + it.each(['Delete', 'Backspace'])( + 'does not delete a node when the zoom slider has focus (%s)', + (key) => { + const slider = document.createElement('input'); + slider.type = 'range'; + document.body.append(slider); + const deleteNode = vi.fn(() => ({ ok: true })); + const listener = (event: KeyboardEvent) => + handleUiEditorKeyDown(event, { + selectedNodeId: 'node-1', + activeImageId: 'page-1', + deleteNode, + historyUndo: vi.fn(() => true), + historyRedo: vi.fn(() => true), + }); + window.addEventListener('keydown', listener); + + fireEvent.keyDown(slider, { key }); + + expect(deleteNode).not.toHaveBeenCalled(); + window.removeEventListener('keydown', listener); + }, + ); + + it('keeps node deletion available after zoom button focus is released', () => { + const zoomControls = document.createElement('div'); + const zoomIn = document.createElement('button'); + zoomControls.append(zoomIn); + document.body.append(zoomControls); + const deleteNode = vi.fn(() => ({ ok: true })); + const listener = (event: KeyboardEvent) => + handleUiEditorKeyDown(event, { + selectedNodeId: 'node-1', + activeImageId: 'page-1', + deleteNode, + historyUndo: vi.fn(() => true), + historyRedo: vi.fn(() => true), + }); + window.addEventListener('keydown', listener); + + fireEvent.click(zoomIn); + fireEvent.keyDown(window, { key: 'Delete' }); + + expect(deleteNode).toHaveBeenCalledWith('node-1', 'page-1'); + window.removeEventListener('keydown', listener); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts index 6c21bc416..2c4206167 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts @@ -473,6 +473,75 @@ describe('UiEditorPage', () => { expect(result.current.canvas.hiddenNodeIds.size).toBe(0); }); + it('clears selection when deleting a node removes the selected descendant', async () => { + const { result } = await renderLoadedSession(stateWithPages(['page'])); + const rootId = 'page-root'; + let parentId: string | undefined; + let childId: string | undefined; + act(() => { + parentId = result.current.input.insertNode(rootId, 'page')?.value; + childId = result.current.input.insertNode(parentId!, 'page')?.value; + result.current.input.selectNode(childId!); + }); + + act(() => result.current.input.deleteNode(parentId!, 'page')); + + expect(result.current.canvas.selectedNodeId).toBeNull(); + expect(result.current.history.canUndo).toBe(true); + }); + + it('deletes the selected node from the page with Delete', async () => { + const state = stateWithPages(['page']); + state.ui_trees[0]!.root.children = [node('page-child')]; + const stateStore: IUiDesignStateStore = { + load: vi.fn().mockResolvedValue({ revision: 0, state }), + save: vi.fn(), + generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')), + }; + + render( + createElement(UiEditorPage, { + projectPath: '/tmp/ui-editor-delete-keyboard', + resourceId: 'ui-resource', + stateStore, + }), + ); + + const child = await screen.findByText('page-child'); + fireEvent.click(child); + fireEvent.keyDown(window, { key: 'Delete' }); + + await waitFor(() => expect(screen.queryByText('page-child')).toBeNull()); + }); + + it('does not delete a selected node when Delete originates inside a dialog', async () => { + const state = stateWithPages(['page']); + state.ui_trees[0]!.root.children = [node('page-child')]; + const stateStore: IUiDesignStateStore = { + load: vi.fn().mockResolvedValue({ revision: 0, state }), + save: vi.fn(), + generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')), + }; + + render( + createElement(UiEditorPage, { + projectPath: '/tmp/ui-editor-delete-dialog', + resourceId: 'ui-resource', + stateStore, + }), + ); + + const child = await screen.findByText('page-child'); + fireEvent.click(child); + const dialog = document.createElement('div'); + dialog.setAttribute('role', 'dialog'); + document.body.appendChild(dialog); + fireEvent.keyDown(dialog, { key: 'Delete' }); + + expect(screen.queryAllByText('page-child').length).toBeGreaterThan(0); + dialog.remove(); + }); + it('keeps Inspector status highlighting separate from node navigation', async () => { const { result } = await renderLoadedSession(stateWithPages(['page'])); const rootId = 'page-root'; diff --git a/apps/ai-game-creator-shell/tests/uiTreeUtils.test.ts b/apps/ai-game-creator-shell/tests/uiTreeUtils.test.ts new file mode 100644 index 000000000..196077400 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/uiTreeUtils.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { + countUiNodeDescendants, + findUiNode, +} from '../src/features/ui-editor/treeUtils'; +import type { Node } from '../src/features/ui-editor/types/Node'; + +function node(id: string, children: Node[] = []): Node { + return { + id, + layout: {} as Node['layout'], + metadata: {} as Node['metadata'], + components: [], + children_display_mode: 'Stack', + children, + }; +} + +describe('ui tree utilities', () => { + it('finds a nested node and counts all descendants', () => { + const nested = node('nested', [node('leaf')]); + const root = node('root', [node('page'), nested]); + + expect(findUiNode(root, 'nested')).toBe(nested); + expect(findUiNode(root, 'missing')).toBeNull(); + expect(countUiNodeDescendants(nested)).toBe(1); + expect(countUiNodeDescendants(root)).toBe(3); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/workflowCompletionNotice.test.ts b/apps/ai-game-creator-shell/tests/workflowCompletionNotice.test.ts new file mode 100644 index 000000000..01016c4ae --- /dev/null +++ b/apps/ai-game-creator-shell/tests/workflowCompletionNotice.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { + appendWorkflowCheckPrompt, + workflowStepLabel, +} from '../src/view/ui-editor/components/workflowCompletionNotice'; + +describe('workflow completion notice helpers', () => { + it('appends the review prompt to a terminal status', () => { + expect(appendWorkflowCheckPrompt('已应用 3 条建议。')).toBe( + '已应用 3 条建议,请检查。', + ); + }); + + it('maps every workflow step to a user-facing label', () => { + expect(workflowStepLabel('reference-analysis')).toBe('分析参考图'); + expect(workflowStepLabel('structure-recognition')).toBe('识别界面结构'); + expect(workflowStepLabel('visual-binding')).toBe('绑定视觉素材'); + }); +}); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 00fe2141a..8dcc07aa3 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1507,6 +1507,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 背景:主站与图片画板的泥点余额入口、余额明细和充值弹窗存在不同实现,旧充值口径仍展示六档泥点、首充双倍和会员购买 / 升级入口,容易让展示、商品资格与后端余额真相发生漂移。 - 决策:主站与图片画板统一复用公共泥点资产入口,收起态展示总额与充值,展开态只展示不限时泥点、每日免费泥点和使用详情;充值中心 BFF 继续统一下发总额、三桶余额、限时到期时间、每日免费基础重置额及下次重置时间,前端不得自行相减推算,但会员周期限时泥点仅用于存量兼容和后端结算,当前版本不在前台展示。钱包明细每次展开都重新读取充值中心 BFF,打开期间实时总额变化时继续补读;图片画板的生成扣费或退款完成后同时刷新总额与充值中心拆分。充值中心读请求必须使用 revision 门禁,支付创建、到账确认等权威响应写入时使旧读失效,避免旧响应覆盖新的每日免费 / 不限时明细。默认泥点商品收敛为 `60 / ¥6`、`180 + 90 / ¥18`、`300 + 150 / ¥30`、`680 + 340 / ¥68` 四档,`60` 档无赠送,后三档按现有 `user_id + product_id` 独立资格规则首次购买加赠 `50%`。当前版本关闭会员购买页签、会员商品和购买 / 升级入口。 - 2026-07-17 追加:主站、图片画板与 AI 游戏创作独立 App 的泥点账单统一复用 `packages/shared/src/components/PlatformProfileWalletLedgerModal`。共享组件只依赖 `ProfileWalletLedgerResponse`,承接来源 label、金额正负号、UTC 日期、余额兜底和 loading / empty / error 展示;`/api/profile/wallet-ledger` 请求、鉴权、打开状态与重试生命周期继续由各宿主持有,不把账户事实或后端副作用下沉到共享 UI。 +- 2026-09-07 追加:资产扣费在既有钱包流水 metadata 中记录服务端确定的 `assetKind`,`GET /api/profile/wallet-ledger` 只把白名单类型映射为可选用户文案 `reason`,不暴露原始 metadata、资源 ID、任务 ID 或未知内部枚举。共享账单组件优先展示非空 `reason`;历史、未知和空 metadata 继续按 `sourceType` 回退为“资产操作消耗”,不得由客户端猜测业务类型。 - 影响范围:`profile_recharge_product_config` 默认商品、充值中心 read model、共享前后端契约、主站与图片画板泥点资产入口、充值弹窗、后台充值商品默认值。 - 验证方式:充值与统一入口定向前端测试、`npm run typecheck`、充值商品定向 Rust 测试、`cargo check -p spacetime-module -p spacetime-client -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`。 - 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 7c4089302..a46b980e3 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -58,6 +58,10 @@ Runtime 确认卡与普通聊天确认卡必须共用“信息区 + 固定操作 ## 2026-08-19 UI Editor 节点右键菜单 +## 2026-09-05 UI Editor 节点树快捷删除 + +左侧 `UI Tree` 每个可删除节点行在右侧提供桌面端快捷删除按钮,眼睛与删除按钮组成固定宽度的右侧动作列;整棵树保留横向滚动,深层节点的缩进、完整名称和组件数随树内容一起滚动,不使用省略号截断。沿用右键菜单的页面根节点禁删规则。快捷删除与右键删除共用页面级 `requestDelete` 入口:叶子节点直接调用现有删除命令;包含后代节点时先打开模态确认,正文显示节点名称及将同时删除的后代节点数量,按钮为“取消 / 删除”。确认期间使用现有 `ThemedModal` 的模态行为,取消或完成后关闭弹窗。底层 `deleteNode` 命令继续保持无确认,以兼容键盘 `Delete` 及已有状态测试;锁定态下快捷按钮和右键删除均不可执行。 + ## 2026-08-20 UI Editor 最终预览互斥子节点 最终预览中,选中一个 `Exclusive` 父节点时,它的子节点切换条必须在该父节点自身的预览坐标空间内、紧贴节点上方悬浮;不得固定在预览容器左上角,也不得另行按屏幕坐标换算。点击 tab 必须显式选中对应子节点,不能按通用“切换可见性”语义把当前分支隐藏。`Exclusive` 父节点首次加载且尚未发生可见性操作时,必须默认且仅显示第一个直接子节点;用户手动隐藏全部直接子节点后必须保持全部隐藏,不得再次回退显示第一个子节点;空容器不显示子节点。切换条始终按内容宽度展开并允许溢出节点边界,不设最大宽度或内部滚动区域。从 `Exclusive` 切回 `Stack` 时必须清除全部直接子节点因互斥选择产生的隐藏状态并立即显示所有子节点,后代节点自身的独立隐藏状态保持不变。切换条仅改变现有子节点可见性状态,不能触发参考图重读、视口重新适配或预览树的异步重建。 diff --git a/docs/technical/【设计】UI编辑器工作流完成通知弹窗-2026-09-04.md b/docs/technical/【设计】UI编辑器工作流完成通知弹窗-2026-09-04.md new file mode 100644 index 000000000..5cdc52832 --- /dev/null +++ b/docs/technical/【设计】UI编辑器工作流完成通知弹窗-2026-09-04.md @@ -0,0 +1,40 @@ +# UI 编辑器工作流完成通知弹窗 + +## 目标 + +UI 编辑器的“分析参考图”“识别界面结构”“绑定视觉素材”三个工作流动作在每次运行结束后,用独立的阻塞通知弹窗明确反馈结果,避免仅依赖卡片内一行状态文本而被忽略。 + +## 交互约定 + +- 三个动作的每次运行在终态(成功或失败)时自动弹出一次通知。 +- 弹窗打开期间遮挡并阻塞工作台底层交互;关闭后恢复当前步骤,不自动切换步骤、不自动重跑。 +- 使用现有 `ThemedModal` 的普通关闭行为(遮罩、Esc 和关闭按钮均可关闭)。 +- 弹窗仅承载通知,不提供“继续”“重试”或其他业务操作。 +- 关闭弹窗后,工作流卡片继续显示同一条结果状态;重新运行产生新的终态时再次通知。 +- 绑定动作包含多个批次时,只在最终批次结束后通知一次。 + +## 文案 + +弹窗标题由步骤名和结果态组成,例如“识别界面结构完成”或“绑定视觉素材失败”。正文复用卡片状态文本,并逐条扩展结果信息,统一以“请检查”收尾。 + +成功状态的基线文案: + +- 分析参考图:保留已应用的语义建议数量;若现有状态可可靠取得问题/待确认数量,则一并展示。 +- 识别界面结构:保留替换的界面树数量,并展示识别结果中的待检查/必须修复数量(若可取得)。 +- 绑定视觉素材:保留现有 `B/B` 批次计数,改为用户可读的绑定结果。 + +失败状态保留实际错误文本,仅在弹窗标题中补充步骤和失败上下文,正文同样以“请检查”收尾。 + +## 实现边界 + +- 新增独立的工作流通知弹窗组件文件,组件只负责展示和关闭,不包含工作流领域规则或后端副作用。 +- 在 UI 编辑器页面/会话投影中维护临时通知状态,并在三个异步动作的成功与失败终态写入。 +- 不新增后端字段或公开契约;数量只能使用当前前端已有且可靠的数据。 + +## 验收 + +1. 三个动作成功和失败终态各弹出一次通知;绑定批次只弹最终一次。 +2. 弹窗打开时底层工作台不可操作,且无继续/重试等业务按钮。 +3. 弹窗可通过标准关闭方式退出;关闭后卡片状态仍可见。 +4. 每条成功文案保留原有数量信息并增加可用的检查数量,所有文案包含“请检查”。 +5. 重新运行后新的终态会再次弹出通知。 diff --git a/docs/【UI编辑器】撤销重做规范-2026-09-03.md b/docs/【UI编辑器】撤销重做规范-2026-09-03.md index f3912e000..86d7e3cd9 100644 --- a/docs/【UI编辑器】撤销重做规范-2026-09-03.md +++ b/docs/【UI编辑器】撤销重做规范-2026-09-03.md @@ -48,13 +48,19 @@ UI 编辑器支持撤销最近一次或多次作品编辑,并支持重做被 - 桌面端页面工具栏提供撤销和重做按钮。 - 非文本编辑目标聚焦编辑器时支持 `Cmd/Ctrl+Z` 撤销、`Cmd/Ctrl+Shift+Z` 和 `Ctrl+Y` 重做。 -- `input`、`textarea`、`select`、`contenteditable` 以及按钮/链接等控件交给浏览器原生行为,不拦截文本撤销。 +- `input`、`textarea`、`select`、`contenteditable` 等文本编辑控件交给浏览器原生行为,不拦截文本撤销;按钮/链接等非文本控件仍允许编辑器撤销/重做快捷键生效。 +- UI Editor 页面内选中节点后,支持不带修饰键的 `Delete` 删除节点及其子节点;快捷键复用 Inspector、树面板和右键菜单共用的 `deleteNode` 命令,因此沿用根节点/锁定禁删、单条历史记录、dirty 标记和后续保存语义。 +- `Delete` / `Backspace` 在 `input`、`textarea`、`select`、`contenteditable`、按钮和链接等交互控件聚焦时交给浏览器原生行为;无选中、根节点、锁定或目标不存在时不执行删除。长按重复事件不重复删除,成功处理后阻止默认行为和事件冒泡。 +- `Delete` / `Backspace` 在打开的对话框(`role="dialog"` 或 `aria-modal="true"`)内聚焦时交给对话框处理,不删除对话框背后的节点。 +- `Cmd/Ctrl+Z`、`Cmd/Ctrl+Shift+Z` 和 `Ctrl+Y` 在打开的对话框(`role="dialog"` 或 `aria-modal="true"`)内聚焦时暂停编辑器级撤销/重做,由对话框或浏览器原生行为处理。 - 无可撤销或重做记录时按钮禁用,并提供可访问名称。 ## 脏状态与选择 撤销和重做恢复的 State 继续参与现有 dirty 判定、保存和后端持久化。历史快照不包含当前设计图、节点选择、隐藏集合或视口;恢复后若当前选择已不存在,页面清理无效选择并保持安全空态。 +删除节点后,若当前选中节点是被删除节点或其任意后代,页面将选区清空为 `null`;删除成功不强制抢占焦点。该选择清理属于 UI 临时状态,不写入撤销历史。 + ## 验收标准 1. 单次字段编辑可撤销和重做。 @@ -67,3 +73,4 @@ UI 编辑器支持撤销最近一次或多次作品编辑,并支持重做被 8. 工具栏按钮、禁用态和桌面快捷键可用,文本控件保留原生撤销。 9. no-op、锁定、校验失败和不存在目标不进入历史。 10. 颜色选择器和九宫格边界拖动不会按每个 pointer move 写入 State。 +11. `Delete` / `Backspace` 仅在非交互控件聚焦且存在可删除选中节点时生效;删除整棵子树、可撤销、长按只处理一次,并清理被删除子树内的无效选区。 diff --git a/docs/【交互设计】预览画布缩放滑杆-2026-09-05.md b/docs/【交互设计】预览画布缩放滑杆-2026-09-05.md new file mode 100644 index 000000000..198d7788a --- /dev/null +++ b/docs/【交互设计】预览画布缩放滑杆-2026-09-05.md @@ -0,0 +1,26 @@ +# 预览画布缩放滑杆 + +## 交付目标 + +将 UI 编辑器预览区现有的缩放按钮替换为桌面端可拖动滑杆,同时保留快速缩小、放大和适配画布操作。 + +## 交互约定 + +- 滑杆选择范围为 25%–200%,步进 5%;快捷加减继续使用核心视口的现有缩放边界。 +- 滑杆位于预览区右下角固定工具栏;不提供移动端或窄窗口 fallback。 +- 保留 `−` 与 `+` 按钮。点击轨道可跳转,拖动滑块实时更新画布和百分比。 +- 缩放以视口中心为中心,继续支持 Ctrl/Cmd + 滚轮缩放。 +- 滑杆支持方向键、Home、End;百分比文本可点击编辑并在失焦时限制到有效范围。 +- 百分比文本使用数字输入框呈现;输入提交后同步视口缩放,适配画布保持独立按钮,点击百分比不会触发适配。 +- 缩放滑杆获得焦点时,Delete/Backspace 不触发 UI 节点快捷删除;缩放按钮鼠标点击不改变快捷键焦点。 +- Windows/Linux 使用 `Ctrl`,macOS 使用 `Cmd`;`+`、`=` 与 `NumpadAdd` 放大,`-` 与 `NumpadSubtract` 缩小,允许按键重复时连续缩放。 +- 快捷键直接复用现有 `+`/`−` 按钮动作:以视口中心为锚点,按 `×1.16`/`×0.86` 改变缩放。 +- 鼠标悬停在预览画布上,或预览画布已获得键盘焦点时,快捷键生效。预览画布需提供可聚焦语义与无障碍名称。 +- 事件来自输入框、文本编辑区、按钮、链接、缩放工具栏或其他可操作控件时不拦截;不向 iframe 子文档注入监听。 +- 仅在存在可缩放视口且确认命中快捷键时调用 `preventDefault()` 与 `stopPropagation()`,防止浏览器页面同时缩放。 +- 保留现有 `Ctrl/Cmd+0` 适配画布与 `Ctrl/Cmd+1` 恢复 100% 行为,不增加其他重置快捷键。缩放仍是当前预览实例的临时 UI 状态。 +- 保留快捷键与缩放焦点边界的组件级回归测试,不扩展端到端测试。 + +## 验收 + +桌面端可通过按钮、轨道点击、拖动和键盘改变缩放;百分比与画布同步,边界不会越界。组件级测试覆盖平台修饰键、主键盘与小键盘变体、按键重复、悬停/焦点作用域、可编辑与可操作目标放行、无内容放行、浏览器默认行为拦截和现有 `0`/`1` 回归;另运行前端定向类型检查、编码检查和 `git diff --check`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 8cae77d2d..6e8f7cb5a 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -251,6 +251,7 @@ npm run check:server-rs-ddd 10. 已有静态图片的 `POST /api/editor/images/pixel-art-snaps` 是免费 inline 派生操作,不调用外部 provider、不创建 `external_generation_job`、不读写泥点 ledger,也不进入任务侧栏。免费不放宽 owner、稳定引用、输入上限、持久化或处理阶段零持久化门禁。 11. 主站编辑器生成队列使用同一次前端请求稳定复用的 `x-request-id`,按 namespace + owner + job kind + request id 生成唯一 `dedupe_key`;首次请求已入队但响应丢失时,重试必须返回原任务。同一幂等键携带不同 payload 返回 `409`,不得创建第二个任务或串到旧结果。外部 v1 的 `Idempotency-Key` 使用独立 namespace,不能与主站请求标识碰撞。幂等 payload 比较只对本次已迁移 sanitizer 的图片生成、图片修改、去背景、图标图集和 UI 提取任务,兼容“升级前旧任务仍含客户端 `generationInputs.references`、当前请求已删除该字段”的单向形状;当前请求仍含 references,或 job kind 属于音频 / 视频 / 角色动作等未迁移任务时必须完整比较,其余请求字段始终完全一致。 12. `generationInputs.references` 是最终资产的服务端权威行引用,不接受客户端自报 provenance。图片生成类请求入队、完美像素及直接创建资源 / 素材时删除客户端 references;worker 和 inline 路径按本次真实参考图、当前 owner 的项目资源 / 素材记录重建 `refType/refId` 后再持久化。仅能证明 owned objectKey、但找不到对应资源或素材行时可以参与生成,不得制造虚假行引用;`title/label` 只作为展示快照,不提升为资源身份。完美像素为兼容升级前的未知结果重放,可继续用旧版 canonical 客户端输入计算 operation fingerprint;新操作持久化元数据只能使用服务端重建值。owner-scoped 项目快照发现同一 operation 的稳定 result `resourceId` 时,HTTP 路径必须在来源解析、OSS 下载、规整、preflight 和 PUT 前直接返回 `409`,携带 `operationResultAlreadyExists=true` 与 `resultResourceId`,客户端仅以 GET-only 项目对账判定权威结果,不复用既存 metadata 作 exact compare-and-return。 +13. 资产操作扣费必须在既有 `profile_wallet_ledger.metadata_json` 中记录服务端确定的 `assetKind`;外部生成任务继续同时记录 `externalGenerationJobId` 与 `externalGenerationClaimAttempt`。公开 `GET /api/profile/wallet-ledger` 不返回原始 metadata、资源 ID 或任务 ID,只为 `asset_operation_consume` 下发可选的用户可见 `reason`:图片、图标图集、美术规范、UI 设计和发布素材等图片生成统一显示“生成美术素材”,图片修改显示“编辑美术素材”,UI 素材提取显示“提取美术素材”,角色动画、视频、音效和背景音乐分别显示对应生成原因。未知、空值和未携带新 metadata 的历史流水不猜测业务含义,不下发 `reason`,共享前端继续回退到来源类型文案“资产操作消耗”。该展示投影不得改变定价、扣费顺序、幂等 ledger、失败退款或余额结算语义。 ## 外部服务与资产 diff --git a/packages/image-canvas-core/src/model.ts b/packages/image-canvas-core/src/model.ts index 3deeac6f8..b6b6c3430 100644 --- a/packages/image-canvas-core/src/model.ts +++ b/packages/image-canvas-core/src/model.ts @@ -4,6 +4,8 @@ export const CANVAS_WORLD_SIZE = 12000; export const CANVAS_WORLD_ORIGIN = CANVAS_WORLD_SIZE / 2; export const MIN_SCALE = 0.025; export const MAX_SCALE = 3.2; +export const CANVAS_ZOOM_IN_FACTOR = 1.16; +export const CANVAS_ZOOM_OUT_FACTOR = 0.86; export const CANVAS_DISPLAY_SCALE_BASE = 0.5; export const DEFAULT_CANVAS_SIZE = { width: 900, height: 640 }; export const FIT_VIEW_PADDING = 10; diff --git a/packages/image-canvas-react/src/ZoomControls.tsx b/packages/image-canvas-react/src/ZoomControls.tsx index e9f853c26..53e88ccec 100644 --- a/packages/image-canvas-react/src/ZoomControls.tsx +++ b/packages/image-canvas-react/src/ZoomControls.tsx @@ -1,4 +1,6 @@ import { + CANVAS_ZOOM_IN_FACTOR, + CANVAS_ZOOM_OUT_FACTOR, canvasDisplayScaleToViewportScale, type CanvasViewport, formatCanvasDisplayScalePercent, @@ -27,8 +29,8 @@ export function ZoomControls({ const actions: CanvasZoomActions = { displayPercent: formatCanvasDisplayScalePercent(viewport.scale), fit: onFit, - zoomIn: () => onScaleFromCenter(viewport.scale * 1.16), - zoomOut: () => onScaleFromCenter(viewport.scale * 0.86), + zoomIn: () => onScaleFromCenter(viewport.scale * CANVAS_ZOOM_IN_FACTOR), + zoomOut: () => onScaleFromCenter(viewport.scale * CANVAS_ZOOM_OUT_FACTOR), zoomToDisplayScale: (displayScale) => onScaleFromCenter(canvasDisplayScaleToViewportScale(displayScale)), }; diff --git a/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx b/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx index a2d15aea6..2c3369106 100644 --- a/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx +++ b/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx @@ -131,4 +131,45 @@ test('builds wallet ledger presentation with stable source fallbacks', () => { }, ], }); + + for (const reason of [ + '生成美术素材', + '编辑美术素材', + '提取美术素材', + '生成角色动画', + '生成视频素材', + '生成音效素材', + '生成背景音乐', + ]) { + expect( + buildWalletLedgerPresentation( + { + entries: [ + buildLedgerEntry({ + amountDelta: -12, + sourceType: 'asset_operation_consume', + reason, + }), + ], + }, + 12, + ).entries[0]?.sourceLabel, + `unexpected source label for ${reason}`, + ).toBe(reason); + } + + expect( + buildWalletLedgerPresentation( + { + entries: [ + buildLedgerEntry({ + amountDelta: -12, + sourceType: 'asset_operation_consume', + reason: ' ', + }), + ], + }, + 12, + ).entries[0]?.sourceLabel, + ).toBe('资产操作消耗'); }); diff --git a/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts b/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts index 40bd70e41..a18adce79 100644 --- a/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts +++ b/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts @@ -103,7 +103,8 @@ export function buildWalletLedgerPresentation( createdAtLabel: formatWalletLedgerDate(entry.createdAt), id: entry.id, isIncome: entry.amountDelta > 0, - sourceLabel: getWalletLedgerSourceLabel(entry.sourceType), + sourceLabel: + entry.reason?.trim() || getWalletLedgerSourceLabel(entry.sourceType), })), }; } diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index 1fa899e4d..eee7978ed 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -84,6 +84,7 @@ export type ProfileWalletLedgerEntry = { | 'puzzle_author_incentive_claim' | 'daily_task_reward'; createdAt: string; + reason?: string; }; export type ProfileWalletLedgerResponse = { diff --git a/server-rs/crates/api-server/src/asset_billing.rs b/server-rs/crates/api-server/src/asset_billing.rs index e0ce18d6d..f9eb99573 100644 --- a/server-rs/crates/api-server/src/asset_billing.rs +++ b/server-rs/crates/api-server/src/asset_billing.rs @@ -202,6 +202,7 @@ where points_cost, &billing_plan.current.consume_ledger_id, wallet_metadata_json( + asset_kind, billing_plan.external_generation_job_id.as_deref(), billing_plan.external_generation_claim_attempt, ), @@ -642,9 +643,10 @@ fn resolve_asset_operation_points_cost(configured_points_cost: u64) -> u64 { } #[cfg(test)] -fn current_wallet_metadata_json() -> String { +fn current_wallet_metadata_json(asset_kind: &str) -> String { let billing_context = current_external_generation_billing_context(); wallet_metadata_json( + asset_kind, billing_context .as_ref() .map(|context| context.job_id.as_str()), @@ -655,21 +657,21 @@ fn current_wallet_metadata_json() -> String { } fn wallet_metadata_json( + asset_kind: &str, external_generation_job_id: Option<&str>, external_generation_claim_attempt: Option, ) -> String { - let Some(external_generation_job_id) = external_generation_job_id + let mut metadata = json!({ + "assetKind": asset_kind.trim(), + }); + if let Some(external_generation_job_id) = external_generation_job_id .map(str::trim) .filter(|value| !value.is_empty()) - else { - return module_runtime::PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string(); - }; - - json!({ - "externalGenerationJobId": external_generation_job_id, - "externalGenerationClaimAttempt": external_generation_claim_attempt, - }) - .to_string() + { + metadata["externalGenerationJobId"] = json!(external_generation_job_id); + metadata["externalGenerationClaimAttempt"] = json!(external_generation_claim_attempt); + } + metadata.to_string() } pub(crate) fn map_asset_operation_wallet_error(error: SpacetimeClientError) -> AppError { @@ -971,13 +973,20 @@ mod tests { async { ( resolve_asset_operation_points_cost(99), - current_wallet_metadata_json(), + current_wallet_metadata_json("editor_generated_image"), ) }, ) .await; assert_eq!(points_cost, 37); + assert_eq!( + serde_json::from_str::(&metadata_json) + .expect("worker metadata should be valid JSON") + .get("assetKind") + .and_then(serde_json::Value::as_str), + Some("editor_generated_image") + ); assert_eq!( serde_json::from_str::(&metadata_json) .expect("worker metadata should be valid JSON") @@ -994,8 +1003,11 @@ mod tests { ); assert_eq!(resolve_asset_operation_points_cost(99), 99); assert_eq!( - current_wallet_metadata_json(), - module_runtime::PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON + serde_json::from_str::(¤t_wallet_metadata_json( + "editor_image_edit" + )) + .expect("ordinary metadata should be valid JSON"), + json!({"assetKind": "editor_image_edit"}) ); } diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index b7413f45a..db27bb20f 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -2064,15 +2064,46 @@ fn build_redeem_profile_reward_code_response( fn build_profile_wallet_ledger_entry_response( record: module_runtime::RuntimeProfileWalletLedgerEntryRecord, ) -> ProfileWalletLedgerEntryResponse { + let reason = profile_wallet_ledger_reason(record.source_type, &record.metadata_json); ProfileWalletLedgerEntryResponse { id: record.wallet_ledger_id, amount_delta: record.amount_delta, balance_after: record.balance_after, source_type: format_profile_wallet_ledger_source_type(record.source_type).to_string(), created_at: record.created_at, + reason, } } +fn profile_wallet_ledger_reason( + source_type: RuntimeProfileWalletLedgerSourceType, + metadata_json: &str, +) -> Option { + if source_type != RuntimeProfileWalletLedgerSourceType::AssetOperationConsume { + return None; + } + let metadata = serde_json::from_str::(metadata_json).ok()?; + let asset_kind = metadata.get("assetKind")?.as_str()?.trim(); + let reason = match asset_kind { + "editor_scene_image" + | "editor_character_image" + | "editor_spec_image" + | "editor_quick_edit_image" + | "editor_ui_design_image" + | "editor_publication_material" + | "editor_generated_image" + | "editor_icon_spritesheet" => "生成美术素材", + "editor_image_edit" => "编辑美术素材", + "editor_ui_design_asset_extraction" => "提取美术素材", + "editor_character_animation" => "生成角色动画", + "editor_video" => "生成视频素材", + "editor_sound_effect" => "生成音效素材", + "editor_background_music" => "生成背景音乐", + _ => return None, + }; + Some(reason.to_string()) +} + fn build_profile_task_center_response( record: RuntimeProfileTaskCenterRecord, ) -> ProfileTaskCenterResponse { @@ -2433,7 +2464,8 @@ mod tests { format_profile_wallet_ledger_source_type, is_wechat_profile_recharge_order_terminal_for_confirmation, map_runtime_profile_client_error, normalize_admin_invite_code_metadata, - parse_admin_profile_code_time_field, should_notify_virtual_payment_goods_delivery, + parse_admin_profile_code_time_field, profile_wallet_ledger_reason, + should_notify_virtual_payment_goods_delivery, }; #[test] @@ -2501,7 +2533,7 @@ mod tests { use platform_auth::{ AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token, }; - use serde_json::Value; + use serde_json::{Value, json}; use spacetime_client::SpacetimeClientError; use std::time::Duration; use time::OffsetDateTime; @@ -2619,6 +2651,60 @@ mod tests { ); } + #[test] + fn profile_wallet_ledger_reason_only_exposes_known_asset_operations() { + for (asset_kind, expected) in [ + ("editor_scene_image", "生成美术素材"), + ("editor_character_image", "生成美术素材"), + ("editor_spec_image", "生成美术素材"), + ("editor_quick_edit_image", "生成美术素材"), + ("editor_ui_design_image", "生成美术素材"), + ("editor_publication_material", "生成美术素材"), + ("editor_generated_image", "生成美术素材"), + ("editor_icon_spritesheet", "生成美术素材"), + ("editor_image_edit", "编辑美术素材"), + ("editor_ui_design_asset_extraction", "提取美术素材"), + ("editor_character_animation", "生成角色动画"), + ("editor_video", "生成视频素材"), + ("editor_sound_effect", "生成音效素材"), + ("editor_background_music", "生成背景音乐"), + ] { + assert_eq!( + profile_wallet_ledger_reason( + RuntimeProfileWalletLedgerSourceType::AssetOperationConsume, + &json!({"assetKind": asset_kind}).to_string(), + ) + .as_deref(), + Some(expected), + "unexpected reason for {asset_kind}", + ); + } + + for metadata in [ + "{}", + "not-json", + r#"{"assetKind":""}"#, + r#"{"assetKind":" "}"#, + r#"{"assetKind":"unknown_internal_operation"}"#, + r#"{"assetKind":123}"#, + ] { + assert_eq!( + profile_wallet_ledger_reason( + RuntimeProfileWalletLedgerSourceType::AssetOperationConsume, + metadata, + ), + None, + ); + } + assert_eq!( + profile_wallet_ledger_reason( + RuntimeProfileWalletLedgerSourceType::AssetOperationRefund, + r#"{"assetKind":"editor_generated_image"}"#, + ), + None, + ); + } + #[tokio::test] async fn profile_dashboard_requires_authentication() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index d46843ff3..704a5d444 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -225,6 +225,8 @@ pub struct ProfileWalletLedgerEntryResponse { pub balance_after: u64, pub source_type: String, pub created_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -1430,6 +1432,7 @@ mod tests { source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_NEW_USER_REGISTRATION_REWARD .to_string(), created_at: "2026-04-22T09:59:00Z".to_string(), + reason: None, }, ProfileWalletLedgerEntryResponse { id: "ledger-2".to_string(), @@ -1437,6 +1440,7 @@ mod tests { balance_after: 80, source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC.to_string(), created_at: "2026-04-22T10:00:00Z".to_string(), + reason: None, }, ProfileWalletLedgerEntryResponse { id: "ledger-3".to_string(), @@ -1445,6 +1449,7 @@ mod tests { source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD .to_string(), created_at: "2026-04-22T10:01:00Z".to_string(), + reason: None, }, ProfileWalletLedgerEntryResponse { id: "ledger-4".to_string(), @@ -1453,6 +1458,7 @@ mod tests { source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD .to_string(), created_at: "2026-04-22T10:02:00Z".to_string(), + reason: None, }, ProfileWalletLedgerEntryResponse { id: "ledger-5".to_string(), @@ -1460,6 +1466,7 @@ mod tests { balance_after: 200, source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE.to_string(), created_at: "2026-04-22T10:03:00Z".to_string(), + reason: None, }, ProfileWalletLedgerEntryResponse { id: "ledger-6".to_string(), @@ -1468,6 +1475,7 @@ mod tests { source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME .to_string(), created_at: "2026-04-22T10:04:00Z".to_string(), + reason: Some("生成美术素材".to_string()), }, ProfileWalletLedgerEntryResponse { id: "ledger-7".to_string(), @@ -1476,6 +1484,7 @@ mod tests { source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND .to_string(), created_at: "2026-04-22T10:05:00Z".to_string(), + reason: None, }, ProfileWalletLedgerEntryResponse { id: "ledger-8".to_string(), @@ -1484,6 +1493,7 @@ mod tests { source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM .to_string(), created_at: "2026-04-22T10:06:00Z".to_string(), + reason: None, }, ProfileWalletLedgerEntryResponse { id: "ledger-9".to_string(), @@ -1491,6 +1501,7 @@ mod tests { balance_after: 212, source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD.to_string(), created_at: "2026-04-22T10:07:00Z".to_string(), + reason: None, }, ], }) @@ -1522,6 +1533,8 @@ mod tests { payload["entries"][5]["sourceType"], json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME) ); + assert_eq!(payload["entries"][5]["reason"], json!("生成美术素材")); + assert!(payload["entries"][0].get("reason").is_none()); assert_eq!( payload["entries"][6]["sourceType"], json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND)