diff --git a/apps/ai-game-creator-shell/src-tauri/capabilities/main.json b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json index 2c0510b8c..63679f90d 100644 --- a/apps/ai-game-creator-shell/src-tauri/capabilities/main.json +++ b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json @@ -1,11 +1,12 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "main", - "description": "AI 游戏创作主窗口允许读取系统剪贴板图片,用于粘贴素材附件。", + "description": "AI 游戏创作主窗口允许读取系统剪贴板内容以粘贴素材,并写入 UI 设计 JSON。", "windows": ["client"], "permissions": [ "clipboard-manager:allow-read-image", "clipboard-manager:allow-read-text", + "clipboard-manager:allow-write-text", "core:image:allow-rgba", "core:image:allow-size", "core:resources:allow-close", 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 43add8bbc..7270a1355 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx @@ -1,5 +1,6 @@ +import { writeText } from '@tauri-apps/plugin-clipboard-manager'; import { ChevronLeft } from 'lucide-react'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { ThemedModal } from '../../components/modal/ThemedModal'; import { @@ -48,10 +49,29 @@ export default function UiEditorPage({ const session = useUiEditorSession(projectPath, resourceId, resolvedStore); const [saveWarningOpen, setSaveWarningOpen] = useState(false); const [saveAfterReturn, setSaveAfterReturn] = useState(false); + const [saveAndCopyJson, setSaveAndCopyJson] = useState(false); + const [copyFeedback, setCopyFeedback] = useState<'copied' | 'failed' | null>( + null, + ); const [returnConfirmOpen, setReturnConfirmOpen] = useState(false); - async function save(afterReturn = false) { - if (await session.save.save()) { + useEffect(() => { + if (copyFeedback === null) return; + const timeoutId = window.setTimeout(() => setCopyFeedback(null), 2000); + return () => window.clearTimeout(timeoutId); + }, [copyFeedback]); + + async function save(afterReturn = false, copyJson = false) { + const savedSnapshot = await session.save.save(); + if (savedSnapshot) { + if (copyJson) { + try { + await writeText(JSON.stringify(savedSnapshot, null, 2)); + setCopyFeedback('copied'); + } catch { + setCopyFeedback('failed'); + } + } if (afterReturn) { setReturnConfirmOpen(false); onBack?.(); @@ -59,16 +79,17 @@ export default function UiEditorPage({ } } - function requestSave(afterReturn = false) { + function requestSave(afterReturn = false, copyJson = false) { if (!resourceId || session.save.isSaving || session.workflow.isAiRunning) { return; } setSaveAfterReturn(afterReturn); + setSaveAndCopyJson(copyJson); if (session.save.hasWarnings()) { setSaveWarningOpen(true); return; } - void save(afterReturn); + void save(afterReturn, copyJson); } function requestBack() { @@ -92,20 +113,40 @@ export default function UiEditorPage({ 返回资源 {resourceLabel ?? 'UI 设计'} - +
+ + +
) : null} {session.save.saveError ? ( @@ -116,6 +157,14 @@ export default function UiEditorPage({ {session.save.saveError} ) : null} + {copyFeedback === 'failed' ? ( +
+ 保存成功,但复制失败。 +
+ ) : null} { setSaveWarningOpen(false); - void save(saveAfterReturn); + void save(saveAfterReturn, saveAndCopyJson); }} > 仍然保存 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 18bf00366..076ff0f64 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 @@ -997,7 +997,7 @@ export function useUiEditorSession( persistedRevision === null || editor.isLocked ) { - return false; + return null; } setSaveError(null); setIsSaving(true); @@ -1011,16 +1011,16 @@ export function useUiEditorSession( ); if (result.status === 'conflict') { setSaveError('资源已在别处更新;请重新加载后再保存。'); - return false; + return null; } setPersistedRevision(result.revision); if (JSON.stringify(editor.state) === snapshotSignature) { setSavedStateSignature(snapshotSignature); } - return true; + return snapshot; } catch (error) { setSaveError(error instanceof Error ? error.message : String(error)); - return false; + return null; } finally { setIsSaving(false); } diff --git a/apps/ai-game-creator-shell/tests/tauri-plugin-clipboard-manager.mock.ts b/apps/ai-game-creator-shell/tests/tauri-plugin-clipboard-manager.mock.ts index 51c4c8558..b525221bc 100644 --- a/apps/ai-game-creator-shell/tests/tauri-plugin-clipboard-manager.mock.ts +++ b/apps/ai-game-creator-shell/tests/tauri-plugin-clipboard-manager.mock.ts @@ -5,3 +5,5 @@ export async function readImage(): Promise { export async function readText(): Promise { return ''; } + +export async function writeText(_text: string): Promise {} diff --git a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts index 425862c39..9656e5d99 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts @@ -13,6 +13,10 @@ import { describe, expect, it, vi } from 'vitest'; vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() })); +vi.mock('@tauri-apps/plugin-clipboard-manager', () => ({ + writeText: vi.fn(), +})); + vi.mock('../src/components/AssetImporter', () => ({ AssetImporter: ({ open }: { open: boolean }) => open ? createElement('div', { role: 'dialog' }, '素材导入器') : null, @@ -24,6 +28,7 @@ vi.mock('../src/components/modal/ThemedModal', () => ({ })); import { invoke } from '@tauri-apps/api/core'; +import { writeText } from '@tauri-apps/plugin-clipboard-manager'; import type { Node as UiNode } from '../src/features/ui-editor/types/Node'; import type { State } from '../src/features/ui-editor/types/State'; @@ -206,6 +211,95 @@ describe('UiEditorPage', () => { }); }); + it('saves the warning-confirmed snapshot before copying its formatted JSON', async () => { + vi.mocked(writeText).mockClear(); + const state = structuredClone(EMPTY_SNAPSHOT.state); + const stateStore: UiDesignStateStore = { + load: vi.fn().mockResolvedValue({ revision: 0, state }), + save: vi.fn().mockResolvedValue({ + status: 'saved', + state, + revision: 1, + committedProjectRevision: 1, + }), + }; + vi.mocked(writeText).mockResolvedValue(); + render( + createElement(UiEditorPage, { + projectPath: '/tmp/ui-editor', + resourceId: 'ui-resource', + stateStore, + }), + ); + + fireEvent.click( + await screen.findByRole('button', { name: '保存并复制 JSON 结构' }), + ); + fireEvent.click(await screen.findByRole('button', { name: '仍然保存' })); + + await waitFor(() => { + expect(stateStore.save).toHaveBeenCalledWith('ui-resource', 0, state); + expect(writeText).toHaveBeenCalledWith(JSON.stringify(state, null, 2)); + }); + expect(screen.getByRole('button', { name: '已保存并复制' })).toBeTruthy(); + }); + + it('does not copy JSON when saving fails', async () => { + vi.mocked(writeText).mockClear(); + const stateStore: UiDesignStateStore = { + load: vi.fn().mockResolvedValue(structuredClone(EMPTY_SNAPSHOT)), + save: vi.fn().mockRejectedValue(new Error('临时存储不可用')), + }; + render( + createElement(UiEditorPage, { + projectPath: '/tmp/ui-editor', + resourceId: 'ui-resource', + stateStore, + }), + ); + + fireEvent.click( + await screen.findByRole('button', { name: '保存并复制 JSON 结构' }), + ); + fireEvent.click(await screen.findByRole('button', { name: '仍然保存' })); + + await screen.findByRole('alert'); + expect(writeText).not.toHaveBeenCalled(); + }); + + it('keeps a successful save when JSON copying fails', async () => { + vi.mocked(writeText).mockClear(); + const state = structuredClone(EMPTY_SNAPSHOT.state); + const stateStore: UiDesignStateStore = { + load: vi.fn().mockResolvedValue({ revision: 0, state }), + save: vi.fn().mockResolvedValue({ + status: 'saved', + state, + revision: 1, + committedProjectRevision: 1, + }), + }; + vi.mocked(writeText).mockRejectedValue(new Error('剪贴板不可用')); + render( + createElement(UiEditorPage, { + projectPath: '/tmp/ui-editor', + resourceId: 'ui-resource', + stateStore, + }), + ); + + fireEvent.click( + await screen.findByRole('button', { name: '保存并复制 JSON 结构' }), + ); + fireEvent.click(await screen.findByRole('button', { name: '仍然保存' })); + + await waitFor(() => expect(stateStore.save).toHaveBeenCalledTimes(1)); + expect((await screen.findByRole('status')).textContent).toContain( + '保存成功,但复制失败。', + ); + expect(screen.queryByRole('alert')).toBeNull(); + }); + it('renders split input tools over a real empty State', () => { render(createElement(UiEditorPage, { projectPath: '/tmp/ui-editor' })); diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 4e1ec8e29..fa191a68d 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1157,7 +1157,7 @@ game-project/ - UI 编辑器复用现有 manifest `kind: "UI"`、`mediaType: "application/json"` 资源,不增加平行 asset kind。资源文件固定为严格 `game-creator-ui-design-state.v1` JSON envelope:`projectId`、`assetId`、每资源 `revision` 和 Rust 唯一源 `State`;旧空对象、未知字段、身份错配、超限、无效内部引用和不安全相对路径均失败关闭。内部引用校验同时覆盖 `Image.target_graphic -> sprite_assets` 与 `Text.font -> font_assets`,可选引用非空时必须命中同一 State 内已登记资源。 - Tauri 专用 load/save command 只接受项目路径、期望项目 ID、manifest asset ID 和(保存时)资源 revision;Rust 按 manifest 解析受控本地路径并在项目写锁内做 CAS。相同 `State` 返回 unchanged 且不推进 project revision;不同内容安装并回读一致后才推进 revision,后续推进失败返回 `reconciliation-required`,不伪装为完整保存。 - UI State 原子安装保留最近一个可解析、canonical 的 `.previous` 恢复候选,作为最佳努力恢复来源;写入主文件前不把完整 State 语义校验重复执行一遍。主文件损坏时,恢复候选仍必须通过同一严格 schema、project/asset identity、revision、引用和 State 校验后才能安装;恢复安装与保存共用项目写锁,并在持锁后重新读取主文件,已有并发保存的有效新版本时直接返回而不安装旧副本。任一候选均不可信则停在加载错误,前端禁编辑和保存。新建 UI 资源先登记并安装合法 envelope,任一步失败补偿 manifest/文件,避免把空 JSON 留给资源卡。 -- 图片路径只需是安全项目相对路径,不要求外部图片仍存在或已登记为 manifest asset;缺失媒体只导致 preview 占位。`imageOrder`、当前选择、缩放、面板开关和 preview URL 不写入 State,加载后由 State 派生。保存冻结提交快照,保存期间的新编辑继续保持 dirty;AI state lock 和加载期间禁保存。 +- 图片路径只需是安全项目相对路径,不要求外部图片仍存在或已登记为 manifest asset;缺失媒体只导致 preview 占位。`imageOrder`、当前选择、缩放、面板开关和 preview URL 不写入 State,加载后由 State 派生。保存冻结提交快照,保存期间的新编辑继续保持 dirty;AI state lock 和加载期间禁保存。资源编辑页的“保存并复制 JSON 结构”仅在同一冻结快照成功保存后,通过 Tauri 原生剪贴板复制 2 空格缩进的纯 `State` JSON;复制失败不回滚保存,并明确提示保存成功、复制失败。 ## 2026-08-18 UI Editor 新建节点组件状态