diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 01e49d91f..16679b08f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1252,6 +1252,64 @@ pub(crate) fn register_local_asset( ) } +#[tauri::command] +pub(crate) fn create_ui_design_resource( + project_path: String, + expected_project_id: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + let _lock = acquire_project_write_lock(root, "asset.register")?; + let manifest = read_existing_manifest_for_project(root)?; + if manifest.project_id != expected_project_id.trim() { + return Err("project-identity-conflict".to_string()); + } + let next_index = manifest + .assets + .iter() + .filter(|asset| asset.kind == "UI") + .count() + + 1; + let resource_name = format!("UI 设计 {next_index}"); + let relative_path = format!("ui/{resource_name}.json"); + let absolute_path = resolve_local_project_path(root, &relative_path)?; + if let Some(parent) = absolute_path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("创建 UI 资源目录失败:{}: {error}", parent.display()))?; + } + if !absolute_path.exists() { + fs::write(&absolute_path, "{}") + .map_err(|error| format!("创建 UI 资源失败:{}: {error}", absolute_path.display()))?; + } + advance_agent_runtime_project_revision_locked(root)?; + let asset = register_local_asset_at( + root, + &relative_path, + "UI", + "application/json", + "generated", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Generated, + canvas_project_id: None, + resource_id: Some(format!("ui:{next_index}")), + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + )?; + let manifest = read_existing_manifest_for_project(root)?; + let revision = read_game_creator_agent_runtime_project_revision(root)?.revision; + Ok(CreateUiDesignResourceResult { + asset, + manifest, + committed_project_revision: revision, + }) +} + #[tauri::command] pub(crate) async fn derive_local_project_resource( input: DeriveLocalProjectResourceInput, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 5418d1929..ada7c08ee 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -932,6 +932,14 @@ struct UploadLocalAssetResult { manifest_path: String, } +#[derive(Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct CreateUiDesignResourceResult { + asset: UploadLocalAssetResult, + manifest: GameCreationAppManifest, + committed_project_revision: u64, +} + #[derive(Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct ImportCanvasExportResult { @@ -2261,6 +2269,7 @@ fn main() { read_game_creator_mcp_catalog, upload_local_asset, register_local_asset, + create_ui_design_resource, derive_local_project_resource, list_pending_local_project_resource_edits, resume_local_project_resource_edit, diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/uiDesignStateStore.ts b/apps/ai-game-creator-shell/src/features/ui-editor/uiDesignStateStore.ts new file mode 100644 index 000000000..79ba85235 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/uiDesignStateStore.ts @@ -0,0 +1,32 @@ +import type { State } from './types/State'; +import { EMPTY_UI_EDITOR_STATE } from './useUiEditorState'; + +export type UiDesignStateStore = { + load(resourceId: string): Promise; + save(resourceId: string, state: State): Promise; +}; + +function emptyState(): State { + return { + ui_trees: [], + ui_design_images: {}, + sprite_assets: {}, + font_assets: {}, + }; +} + +/** + * TODO(ui-design-persistence): replace this adapter with project-file backed + * State serialization at the UI resource's registered localPath. + */ +export const mockUiDesignStateStore: UiDesignStateStore = { + async load(_resourceId) { + return emptyState(); + }, + async save(_resourceId, _state) { + // The resource and its manifest entry are durable. UI State persistence is + // deliberately deferred until the file format is ready. + }, +}; + +export const EMPTY_UI_DESIGN_STATE = EMPTY_UI_EDITOR_STATE; diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index c74750bc6..3726dd0d8 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -61,6 +61,7 @@ import { LocalGamePreviewFrame, resolveEmbeddedPreviewUrl, } from '../../features/project-workspace/LocalGamePreviewFrame'; +import UiEditorPage from '../ui-editor'; import { type ProjectManifestSnapshotMetadata, resolveResourceFocusIntent, @@ -152,6 +153,17 @@ type ResourceEditorRoute = { capability: Extract; }; +type UiEditorRoute = { + resourceId: string; + resourceLabel: string; +}; + +type CreateUiDesignResourceResult = { + asset: { id: string }; + manifest: GameCreationAppManifest; + committedProjectRevision: number; +}; + type DeriveLocalProjectResourceResult = { operationId: string; sourceResourceId: string; @@ -693,6 +705,9 @@ export default function ProjectDevelopmentView({ useState(null); const [resourceEditorRoute, setResourceEditorRoute] = useState(null); + const [uiEditorRoute, setUiEditorRoute] = useState( + null, + ); const [assetCanvasNotice, setAssetCanvasNotice] = useState(''); const [pendingResourceEdits, setPendingResourceEdits] = useState< PendingLocalProjectResourceEdit[] @@ -1586,6 +1601,7 @@ export default function ProjectDevelopmentView({ pendingResourceFocusRef.current = null; setAssetCanvasRoute(null); setResourceEditorRoute(null); + setUiEditorRoute(null); resourceEditorRevisionRef.current.clear(); setHiddenCommittedResourceId(null); setPendingResourceEdits([]); @@ -1973,6 +1989,14 @@ export default function ProjectDevelopmentView({ const handleResourceSelect = useCallback( (resourceId: string) => { + const resource = resources.find((entry) => entry.id === resourceId); + if (resource?.subtype === 'UI' && resource.manifestAssetId !== null) { + setUiEditorRoute({ + resourceId: resource.manifestAssetId, + resourceLabel: resource.label, + }); + return; + } advanceFocusGeneration(); stopActiveCardMedia(); captureResourceSectionScrollPositions(); @@ -1987,10 +2011,39 @@ export default function ProjectDevelopmentView({ advanceFocusGeneration, captureResourceListScrollPosition, captureResourceSectionScrollPositions, + resources, stopActiveCardMedia, ], ); + const createUiDesignResource = useCallback(async () => { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setAssetCanvasNotice('UI 设计资源需要在客户端内创建'); + return; + } + setAssetCanvasNotice('正在创建 UI 设计…'); + try { + const result = await invoke( + 'create_ui_design_resource', + { projectPath, expectedProjectId: manifest.projectId }, + ); + if (result.manifest.projectId !== manifest.projectId) { + throw new Error('UI 设计资源登记结果与当前项目不一致'); + } + onManifestChange?.(projectPath, result.manifest, { + projectId: result.manifest.projectId, + revision: result.committedProjectRevision, + source: 'asset-command', + }); + setAssetCanvasNotice('UI 设计已创建,正在同步资源与布局…'); + } catch (error) { + setAssetCanvasNotice( + error instanceof Error ? error.message : String(error), + ); + } + }, [manifest.projectId, onManifestChange, projectPath]); + function showResourceSortMode(nextMode: ResourceSortMode) { if (nextMode === sortMode) { return; @@ -2906,6 +2959,27 @@ export default function ProjectDevelopmentView({ setMode('run'); } + const resourceViewState = (() => { + switch (mode) { + case 'resources': + if (assetCanvasRoute) { + return `resources.asset-canvas.${assetCanvasRoute.scope.intent}`; + } + if (resourceEditorRoute) { + return `resources.editor.${resourceEditorRoute.capability.editKind}`; + } + if (uiEditorRoute) { + return 'resources.ui-editor'; + } + if (focusedResource) { + return `resources.focused.${focusedResource.category}`; + } + return 'resources.list'; + case 'run': + return undefined; + } + })(); + return (
@@ -2959,7 +3023,8 @@ export default function ProjectDevelopmentView({ {mode === 'resources' && !focusedResource && !assetCanvasRoute && - !resourceEditorRoute ? ( + !resourceEditorRoute && + !uiEditorRoute ? ( <> {pendingResourceEdits.length > 0 || pendingResourceEditsLoadState === 'failed' ? ( @@ -2983,6 +3048,14 @@ export default function ProjectDevelopmentView({
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 ec917ae8c..80fc1b5fe 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,3 +1,11 @@ +import { ChevronLeft } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; + +import { ThemedModal } from '../../components/modal/ThemedModal'; +import { + mockUiDesignStateStore, + type UiDesignStateStore, +} from '../../features/ui-editor/uiDesignStateStore'; import { EditorDialogs } from './components/EditorDialogs'; import { InputSidebar } from './components/InputSidebar'; import { InspectorSidebar } from './components/Inspector/InspectorSidebar'; @@ -7,13 +15,97 @@ import { useUiEditorPage } from './useUiEditorPage'; export default function UiEditorPage({ projectPath = '/tmp/ui_editor', + resourceId, + resourceLabel, + onBack, + stateStore = mockUiDesignStateStore, }: { projectPath?: string; + resourceId?: string; + resourceLabel?: string; + onBack?: () => void; + stateStore?: UiDesignStateStore; }) { - const controller = useUiEditorPage(projectPath); + const controller = useUiEditorPage(projectPath, resourceId, stateStore); + const [savedStateSignature, setSavedStateSignature] = useState( + null, + ); + const [saveError, setSaveError] = useState(null); + const [isSaving, setIsSaving] = useState(false); + const [returnConfirmOpen, setReturnConfirmOpen] = useState(false); + const loadedResourceIdRef = useRef(undefined); + const stateSignature = JSON.stringify(controller.editor.state); + + useEffect(() => { + if (!resourceId || controller.isLoading) return; + if (loadedResourceIdRef.current !== resourceId) { + loadedResourceIdRef.current = resourceId; + setSavedStateSignature(stateSignature); + setSaveError(null); + } + }, [controller.isLoading, resourceId, stateSignature]); + + const isDirty = + resourceId !== undefined && + savedStateSignature !== null && + savedStateSignature !== stateSignature; + + async function save() { + if (!resourceId || isSaving) return; + setSaveError(null); + setIsSaving(true); + try { + await stateStore.save(resourceId, controller.editor.state); + setSavedStateSignature(JSON.stringify(controller.editor.state)); + } catch (error) { + setSaveError(error instanceof Error ? error.message : String(error)); + throw error; + } finally { + setIsSaving(false); + } + } + + async function saveAndReturn() { + try { + await save(); + setReturnConfirmOpen(false); + onBack?.(); + } catch { + // The save error stays visible in the editor. + } + } + + function requestBack() { + if (isDirty) { + setReturnConfirmOpen(true); + return; + } + onBack?.(); + } return ( -
+
+ {resourceId ? ( +
+ + {resourceLabel ?? 'UI 设计'} + +
+ ) : null}
+ setReturnConfirmOpen(false)} + ariaLabel="确认返回资源" + panelClassName="w-[420px] rounded-2xl p-5" + > +

返回资源?

+ {saveError ? ( +

+ {saveError} +

+ ) : null} +
+ + + +
+
{controller.suggestionStatus ? (

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 e65d32121..eab962fb7 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 @@ -1,18 +1,18 @@ import { invoke } from '@tauri-apps/api/core'; -import { useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import type { ImportedAsset } from '../../components/AssetImporter'; import { prepareDesignImageBatch, prepareSpriteAssetBatch, } from '../../features/ui-editor/importAdapter'; +import { applyMergeResult } from '../../features/ui-editor/merge'; import { type UiEditorPrerequisiteIssue, validateAssetRecognitionPrerequisites, validateComponentRecognitionPrerequisites, validateLayoutReviewPrerequisites, } from '../../features/ui-editor/prerequisites'; -import { applyMergeResult } from '../../features/ui-editor/merge'; import { applyRecognitionResult } from '../../features/ui-editor/recognition'; import { applyBindingResult } from '../../features/ui-editor/binding'; import type { BindingDTO } from '../../features/ui-editor/types/BindingDTO'; @@ -26,6 +26,10 @@ import type { SpriteBorder } from '../../features/ui-editor/types/SpriteBorder'; import type { UIDesignImageId } from '../../features/ui-editor/types/UIDesignImageId'; import type { UIDesignImageRole } from '../../features/ui-editor/types/UIDesignImageRole'; import type { UIDesignSuggestionTreeNode } from '../../features/ui-editor/types/UIDesignSuggestionTreeNode'; +import { + mockUiDesignStateStore, + type UiDesignStateStore, +} from '../../features/ui-editor/uiDesignStateStore'; import { applyUiDesignSuggestions } from '../../features/ui-editor/uiDesignSuggestions'; import { EMPTY_UI_EDITOR_STATE, @@ -43,8 +47,13 @@ import { const ASSET_BATCH_SIZE = 5; -export function useUiEditorPage(projectPath: string) { +export function useUiEditorPage( + projectPath: string, + resourceId?: string, + stateStore: UiDesignStateStore = mockUiDesignStateStore, +) { const editor = useUiEditorState(EMPTY_UI_EDITOR_STATE); + const [isLoading, setIsLoading] = useState(Boolean(resourceId)); const [activeTool, setActiveTool] = useState('input'); const [imageOrder, setImageOrder] = useState([]); const [activeImageId, setActiveImageId] = useState( @@ -74,6 +83,35 @@ export function useUiEditorPage(projectPath: string) { const [isBinding, setIsBinding] = useState(false); const [bindingStatus, setBindingStatus] = useState(null); + useEffect(() => { + if (!resourceId) { + setIsLoading(false); + return; + } + let cancelled = false; + setIsLoading(true); + void stateStore + .load(resourceId) + .then((state) => { + if (!cancelled) { + editor.replaceState(state); + } + }) + .catch((cause: unknown) => { + if (!cancelled) { + setStatus(cause instanceof Error ? cause.message : String(cause)); + } + }) + .finally(() => { + if (!cancelled) { + setIsLoading(false); + } + }); + return () => { + cancelled = true; + }; + }, [editor.replaceState, resourceId, stateStore]); + const images = editor.state.ui_design_images; const sprites = editor.state.sprite_assets; const activeImage = activeImageId ? images[activeImageId] : null; @@ -111,7 +149,7 @@ export function useUiEditorPage(projectPath: string) { ? editor.state.ui_trees.find((tree) => tree.src_ui_design === activeImageId) : null; - function findNodeContext( + const findNodeContext = useCallback(function findNodeContext( node: UiNode, nodeId: NodeId, parentSize: { width: number; height: number }, @@ -132,7 +170,7 @@ export function useUiEditorPage(projectPath: string) { if (found) return found; } return null; - } + }, []); const selectedNodeContext = useMemo(() => { if (!activeImage || !treeForActiveImage || !selectedNodeId) return null; @@ -144,7 +182,7 @@ export function useUiEditorPage(projectPath: string) { width, height, }); - }, [activeImage, selectedNodeId, treeForActiveImage]); + }, [activeImage, findNodeContext, selectedNodeId, treeForActiveImage]); async function importAssets(imported: ImportedAsset[]) { if (!importKind || !projectPath) return; @@ -550,6 +588,8 @@ export function useUiEditorPage(projectPath: string) { return { projectPath, + resourceId, + isLoading, editor, activeTool, activeToolLabel, diff --git a/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts b/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts index 13f929ef2..d9025049e 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts +++ b/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts @@ -182,6 +182,33 @@ describe('项目资源投影', () => { expect(first.map(({ id }) => id)).toEqual(second.map(({ id }) => id)); }); + it('把 manifest 中的 UI 设计登记为美术资源', () => { + const manifest = createGameCreationAppManifest( + 'ui-resource', + 'UI 资源测试', + ); + manifest.assets = [ + { + id: 'ui-1', + kind: 'UI', + mediaType: 'application/json', + localPath: 'ui/UI 设计 1.json', + source: { kind: 'generated', resourceId: 'ui:1' }, + }, + ]; + + expect(projectResourcesFromReadModels(manifest, [], [])).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'asset:ui-1', + category: 'art', + subtype: 'UI', + manifestAssetId: 'ui-1', + }), + ]), + ); + }); + it('只从 manifest 投影版本并保留直接父子关系', () => { const manifest = createGameCreationAppManifest( 'version-projection', diff --git a/apps/ai-game-creator-shell/tests/uiDesignStateStore.test.ts b/apps/ai-game-creator-shell/tests/uiDesignStateStore.test.ts new file mode 100644 index 000000000..0a15d0305 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/uiDesignStateStore.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { mockUiDesignStateStore } from '../src/features/ui-editor/uiDesignStateStore'; + +describe('UI 设计 State mock store', () => { + it('每次 load 返回一个新的空 State,save 仍按成功完成', async () => { + const first = await mockUiDesignStateStore.load('ui-1'); + first.ui_trees.push({} as never); + await expect( + mockUiDesignStateStore.save('ui-1', first), + ).resolves.toBeUndefined(); + + await expect(mockUiDesignStateStore.load('ui-1')).resolves.toEqual({ + ui_trees: [], + ui_design_images: {}, + sprite_assets: {}, + font_assets: {}, + }); + }); +});