实现UI编辑器状态持久化

新增 UI State 严格文件契约、CAS 保存与恢复副本

接入 Tauri 加载保存与项目资源创建事务

完善编辑器加载失败、保存冲突和预览恢复行为

补充持久化测试与工程约定文档
This commit is contained in:
2026-08-17 17:38:07 +08:00
parent 6ac2931bb2
commit 37b15ffe9c
11 changed files with 1062 additions and 73 deletions
@@ -1277,12 +1277,12 @@ pub(crate) fn create_ui_design_resource(
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()))?;
if absolute_path.exists() {
return Err("UI 设计资源路径已存在,拒绝覆盖".to_string());
}
advance_agent_runtime_project_revision_locked(root)?;
let asset = register_local_asset_at(
fs::write(&absolute_path, "")
.map_err(|error| format!("创建 UI 资源失败:{}: {error}", absolute_path.display()))?;
let asset = match register_local_asset_at(
root,
&relative_path,
"UI",
@@ -1300,7 +1300,52 @@ pub(crate) fn create_ui_design_resource(
generation_kind: None,
reference_resource_ids: Vec::new(),
},
)?;
) {
Ok(asset) => asset,
Err(error) => {
let rollback = (|| {
write_manifest(&root.join(".agent/manifest.json"), &manifest)?;
fs::remove_file(&absolute_path).map_err(|remove_error| {
format!(
"删除未完成 UI 设计资源失败:{}: {remove_error}",
absolute_path.display()
)
})
})();
return match rollback {
Ok(()) => Err(error),
Err(rollback_error) => Err(format!(
"UI 设计资源登记失败:{error};reconciliation-required: 回滚未完成:{rollback_error}"
)),
};
}
};
if let Err(error) = ui_editor::persistence::initialize_ui_design_state_at(
root,
expected_project_id.trim(),
&asset.id,
) {
let rollback = (|| {
let mut current = read_existing_manifest_for_project(root)?;
current.assets.retain(|entry| entry.id != asset.id);
write_manifest(&root.join(".agent/manifest.json"), &current)?;
fs::remove_file(&absolute_path).map_err(|remove_error| {
format!(
"删除未完成 UI 设计资源失败:{}: {remove_error}",
absolute_path.display()
)
})
})();
return match rollback {
Ok(()) => Err(error),
Err(rollback_error) => Err(format!(
"UI 设计资源初始化失败:{error};reconciliation-required: 回滚未完成:{rollback_error}"
)),
};
}
advance_agent_runtime_project_revision_locked(root).map_err(|error| {
format!("reconciliation-required: UI 设计资源已创建,但项目 revision 未能推进:{error}")
})?;
let manifest = read_existing_manifest_for_project(root)?;
let revision = read_game_creator_agent_runtime_project_revision(root)?.revision;
Ok(CreateUiDesignResourceResult {
@@ -140,6 +140,24 @@ async fn bind_components(
ui_editor::commands::bind_components_impl(project_path, state, sprite_ids).await
}
#[tauri::command]
fn load_ui_design_state(
input: ui_editor::persistence::LoadUiDesignStateInput,
) -> Result<ui_editor::persistence::UiDesignStateSnapshot, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.list")?;
ui_editor::persistence::load_ui_design_state_at(input)
}
#[tauri::command]
fn save_ui_design_state(
input: ui_editor::persistence::SaveUiDesignStateInput,
) -> Result<ui_editor::persistence::SaveUiDesignStateResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
ui_editor::persistence::save_ui_design_state_at(input)
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct InitLocalProjectResult {
@@ -2286,6 +2304,8 @@ fn main() {
recognize_ui,
merge_ui,
bind_components,
load_ui_design_state,
save_ui_design_state,
generate_platform_art_asset,
open_canvas_project,
get_game_creation_agent_capabilities,
@@ -1,6 +1,7 @@
pub mod commands;
pub mod component;
pub mod layout;
pub mod persistence;
pub mod resource;
pub mod state;
mod utils;
File diff suppressed because it is too large Load Diff
@@ -30,6 +30,7 @@
- 节点布局与组件是独立通道:`layout_status` / `components_status` 分别记录阶段状态,`allow_llm_edit_layout` / `allow_llm_edit_component` 分别授权 LLM 修改;人工编辑不隐式改变状态或授权。页面根节点可承载组件,但其 Transform 不可修改。
- “组件结构”阶段显示“识别概览”,递归统计 `State.ui_trees` 中全部 Node,不按 `source` 过滤;“已识别 Node”为全部节点数,“待用户检查”为 `layout_status` 是 `NeedReview` 或 `Blocked` 的数量,“已通过”为 `Passed` 数量,“必须修复”为 `Blocked` 数量。`components_status` 的阶段概览属于后续“绑定概览”。
- “待用户检查”和“必须修复”分别按 `ui_trees` 顺序及树的前序遍历循环定位下一项;各自游标只属于概览组件的临时 UI 状态,树变化后重置,不写入正式 `State`。
- 概览定位 Node 时必须统一完成所属界面图激活、节点选择、Inspector 展示、UI Tree 祖先展开与滚动,以及预览画布对节点的带留白适配;状态查询与定位行为应可复用,不在统计卡点击处理器内复制树遍历和跨面板协调。
- UI Editor 的项目资源持久化复用 manifest `kind: "UI"` 和 `application/json`,正式文件为严格的 `game-creator-ui-design-state.v1` envelope,绑定 `projectId`、`assetId`、资源 revision 与 Rust `State`;旧 `{}` 文件、未知字段、身份错误、超限或无效引用均失败关闭。
- load/save 只能经专用 Tauri command 按 `expectedProjectId + manifest assetId` 解析受控 `localPath`;保存使用每资源 revision CAS。内容相同不推进项目 revision;安装、回读成功后才推进项目 revision,推进失败返回 `reconciliation-required`。
- 每次非空保存保留一个最近有效的 `.previous` 恢复副本。主文件损坏时只从该副本恢复,外部图片丢失或未登记不使 `State` 无效;预览缺失显示占位且不阻断编辑。
- `imageOrder`、当前选择、面板开关、缩放和 preview URL 仍是临时 React 状态,加载后从 `State` 派生;加载失败时禁编辑与保存。持久化代码必须覆盖 round-trip、CAS、严格 schema、恢复和缺失外部素材的定向测试。
@@ -1,31 +1,63 @@
import { invoke } from '@tauri-apps/api/core';
import type { State } from './types/State';
import { EMPTY_UI_EDITOR_STATE } from './useUiEditorState';
export type UiDesignStateSnapshot = { revision: number; state: State };
export type UiDesignStateSaveResult =
| {
status: 'saved' | 'unchanged';
state: State;
revision: number;
committedProjectRevision: number;
}
| { status: 'conflict'; current: UiDesignStateSnapshot };
export type UiDesignStateStore = {
load(resourceId: string): Promise<State>;
save(resourceId: string, state: State): Promise<void>;
load(assetId: string): Promise<UiDesignStateSnapshot>;
save(
assetId: string,
expectedRevision: number,
state: State,
): Promise<UiDesignStateSaveResult>;
};
function emptyState(): State {
export function createTauriUiDesignStateStore(
projectPath: string,
expectedProjectId: string,
): UiDesignStateStore {
return {
ui_trees: [],
ui_design_images: {},
sprite_assets: {},
font_assets: {},
load(assetId) {
return invoke<UiDesignStateSnapshot>('load_ui_design_state', {
input: { projectPath, expectedProjectId, assetId },
});
},
save(assetId, expectedRevision, state) {
return invoke<UiDesignStateSaveResult>('save_ui_design_state', {
input: {
projectPath,
expectedProjectId,
assetId,
expectedRevision,
state,
},
});
},
};
}
/**
* 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 load(_assetId) {
return { revision: 0, state: structuredClone(EMPTY_UI_EDITOR_STATE) };
},
async save(_resourceId, _state) {
// The resource and its manifest entry are durable. UI State persistence is
// deliberately deferred until the file format is ready.
async save(_assetId, _expectedRevision, state) {
return {
status: 'saved',
state: structuredClone(state),
revision: 1,
committedProjectRevision: 0,
};
},
};
@@ -3106,6 +3106,7 @@ export default function ProjectDevelopmentView({
<UiEditorPage
projectPath={projectPath}
resourceId={uiEditorRoute.resourceId}
projectId={manifest.projectId}
resourceLabel={uiEditorRoute.resourceLabel}
onBack={() => setUiEditorRoute(null)}
/>
@@ -1,8 +1,9 @@
import { ChevronLeft } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { ThemedModal } from '../../components/modal/ThemedModal';
import {
createTauriUiDesignStateStore,
mockUiDesignStateStore,
type UiDesignStateStore,
} from '../../features/ui-editor/uiDesignStateStore';
@@ -16,17 +17,27 @@ import { useUiEditorPage } from './useUiEditorPage';
export default function UiEditorPage({
projectPath = '/tmp/ui_editor',
resourceId,
projectId,
resourceLabel,
onBack,
stateStore = mockUiDesignStateStore,
stateStore,
}: {
projectPath?: string;
resourceId?: string;
projectId?: string;
resourceLabel?: string;
onBack?: () => void;
stateStore?: UiDesignStateStore;
}) {
const controller = useUiEditorPage(projectPath, resourceId, stateStore);
const resolvedStore = useMemo(
() =>
stateStore ??
(resourceId && projectId
? createTauriUiDesignStateStore(projectPath, projectId)
: mockUiDesignStateStore),
[projectId, projectPath, resourceId, stateStore],
);
const controller = useUiEditorPage(projectPath, resourceId, resolvedStore);
const [savedStateSignature, setSavedStateSignature] = useState<string | null>(
null,
);
@@ -37,13 +48,13 @@ export default function UiEditorPage({
const stateSignature = JSON.stringify(controller.editor.state);
useEffect(() => {
if (!resourceId || controller.isLoading) return;
if (!resourceId || controller.isLoading || controller.loadError) return;
if (loadedResourceIdRef.current !== resourceId) {
loadedResourceIdRef.current = resourceId;
setSavedStateSignature(stateSignature);
setSaveError(null);
}
}, [controller.isLoading, resourceId, stateSignature]);
}, [controller.isLoading, controller.loadError, resourceId, stateSignature]);
const isDirty =
resourceId !== undefined &&
@@ -51,27 +62,47 @@ export default function UiEditorPage({
savedStateSignature !== stateSignature;
async function save() {
if (!resourceId || isSaving) return;
if (
!resourceId ||
isSaving ||
controller.isLoading ||
controller.loadError ||
controller.persistedRevision === null ||
controller.editor.isLocked
) {
return false;
}
setSaveError(null);
setIsSaving(true);
const snapshot = structuredClone(controller.editor.state);
const snapshotSignature = JSON.stringify(snapshot);
try {
await stateStore.save(resourceId, controller.editor.state);
setSavedStateSignature(JSON.stringify(controller.editor.state));
const result = await resolvedStore.save(
resourceId,
controller.persistedRevision,
snapshot,
);
if (result.status === 'conflict') {
setSaveError('资源已在别处更新;请重新加载后再保存。');
return false;
}
controller.setPersistedRevision(result.revision);
if (JSON.stringify(controller.editor.state) === snapshotSignature) {
setSavedStateSignature(snapshotSignature);
}
return true;
} catch (error) {
setSaveError(error instanceof Error ? error.message : String(error));
throw error;
return false;
} finally {
setIsSaving(false);
}
}
async function saveAndReturn() {
try {
await save();
if (await save()) {
setReturnConfirmOpen(false);
onBack?.();
} catch {
// The save error stays visible in the editor.
}
}
@@ -99,23 +130,42 @@ export default function UiEditorPage({
<button
type="button"
className="rounded-lg bg-orange-600 px-3 py-1.5 text-sm font-semibold text-white disabled:opacity-60"
disabled={isSaving || controller.isLoading}
disabled={
isSaving ||
controller.isLoading ||
Boolean(controller.loadError) ||
controller.persistedRevision === null ||
controller.editor.isLocked
}
onClick={() => void save()}
>
{isSaving ? '保存中…' : '保存'}
</button>
</header>
) : null}
<ToolNavigation
activeTool={controller.activeTool}
onChange={controller.selectTool}
/>
<div className="grid min-h-0 flex-1 grid-cols-[300px_minmax(360px,1fr)_400px] overflow-hidden">
<InputSidebar controller={controller} />
<PreviewWorkspace controller={controller} />
<InspectorSidebar controller={controller} />
<div
className={
controller.isLoading || controller.loadError
? 'pointer-events-none flex min-h-0 flex-1 flex-col opacity-60'
: 'flex min-h-0 flex-1 flex-col'
}
>
<ToolNavigation
activeTool={controller.activeTool}
onChange={controller.selectTool}
/>
<div className="grid min-h-0 flex-1 grid-cols-[300px_minmax(360px,1fr)_400px] overflow-hidden">
<InputSidebar controller={controller} />
<PreviewWorkspace controller={controller} />
<InspectorSidebar controller={controller} />
</div>
<EditorDialogs controller={controller} />
</div>
<EditorDialogs controller={controller} />
{saveError || controller.loadError ? (
<p className="m-0 px-4 py-2 text-xs text-red-700" role="alert">
{saveError ?? `无法加载 UI 设计:${controller.loadError}`}
</p>
) : null}
<ThemedModal
open={returnConfirmOpen}
onClose={() => setReturnConfirmOpen(false)}
@@ -149,7 +199,12 @@ export default function UiEditorPage({
<button
type="button"
className="rounded-lg bg-orange-600 px-3 py-2 text-xs font-semibold text-white disabled:opacity-60"
disabled={isSaving}
disabled={
isSaving ||
Boolean(controller.loadError) ||
controller.persistedRevision === null ||
controller.editor.isLocked
}
onClick={() => void saveAndReturn()}
>
{isSaving ? '保存中…' : '保存并返回'}
@@ -37,6 +37,10 @@ import {
type NodeTransformOptions,
useUiEditorState,
} from '../../features/ui-editor/useUiEditorState';
import {
createProjectResourcePreviewRequestId,
createProjectResourcePreviewScopeId,
} from '../../services/projectResourcePreviewTransport';
import {
type PendingResourceRemoval,
removalHasDownstreamReferences,
@@ -44,7 +48,6 @@ import {
uiEditorOperationError,
type UiEditorToolId,
} from './model';
import { useUiEditorNodeFocus } from './useUiEditorNodeFocus';
const ASSET_BATCH_SIZE = 5;
@@ -54,7 +57,12 @@ export function useUiEditorPage(
stateStore: UiDesignStateStore = mockUiDesignStateStore,
) {
const editor = useUiEditorState(EMPTY_UI_EDITOR_STATE);
const { replaceState } = editor;
const [isLoading, setIsLoading] = useState(Boolean(resourceId));
const [loadError, setLoadError] = useState<string | null>(null);
const [persistedRevision, setPersistedRevision] = useState<number | null>(
resourceId ? null : 0,
);
const [activeTool, setActiveTool] = useState<UiEditorToolId>('input');
const [imageOrder, setImageOrder] = useState<UIDesignImageId[]>([]);
const [activeImageId, setActiveImageId] = useState<UIDesignImageId | null>(
@@ -63,11 +71,6 @@ export function useUiEditorPage(
const [selectedSpriteId, setSelectedSpriteId] =
useState<SpriteAssetId | null>(null);
const [selectedNodeId, setSelectedNodeId] = useState<NodeId | null>(null);
const { focusRequest, focusNode, clearFocusRequest } = useUiEditorNodeFocus({
activateImage: setActiveImageId,
clearSpriteSelection: () => setSelectedSpriteId(null),
selectNode: setSelectedNodeId,
});
const [keepChildrenUnchanged, setKeepChildrenUnchanged] = useState(false);
const [importKind, setImportKind] = useState<UiEditorImportKind | null>(null);
const [previewUrls, setPreviewUrls] = useState<Record<string, string>>({});
@@ -88,25 +91,81 @@ export function useUiEditorPage(
const [mergeStatus, setMergeStatus] = useState<string | null>(null);
const [isBinding, setIsBinding] = useState(false);
const [bindingStatus, setBindingStatus] = useState<string | null>(null);
const { replaceState } = editor;
useEffect(() => {
if (!resourceId) {
setIsLoading(false);
setLoadError(null);
setPersistedRevision(0);
return;
}
let cancelled = false;
setIsLoading(true);
setLoadError(null);
setPersistedRevision(null);
void stateStore
.load(resourceId)
.then((state) => {
.then(({ state, revision }) => {
if (!cancelled) {
replaceState(state);
const ids = Object.keys(
state.ui_design_images,
).sort() as UIDesignImageId[];
setImageOrder(ids);
setActiveImageId(ids[0] ?? null);
setSelectedNodeId(null);
setSelectedSpriteId(null);
setPreviewUrls({});
setPersistedRevision(revision);
if (projectPath) {
const scopeId = createProjectResourcePreviewScopeId();
const previewResources = [
...Object.entries(state.ui_design_images).map(([id, image]) => ({
id,
path: image.path,
})),
...Object.entries(state.sprite_assets).map(([id, sprite]) => ({
id,
path: sprite.path,
})),
];
void Promise.all(
previewResources.map(async ({ id, path }) => {
try {
const preview = await invoke<{ dataUrl: string }>(
'read_local_project_image_preview',
{
projectPath,
relativePath: path,
scopeId,
requestId: createProjectResourcePreviewRequestId(),
},
);
return [id, preview.dataUrl] as [string, string];
} catch {
return null;
}
}),
).then((entries) => {
if (!cancelled) {
setPreviewUrls(
Object.fromEntries(
entries.filter(
(entry): entry is [string, string] => entry !== null,
),
),
);
}
});
}
}
})
.catch((cause: unknown) => {
if (!cancelled) {
setStatus(cause instanceof Error ? cause.message : String(cause));
const message =
cause instanceof Error ? cause.message : String(cause);
setStatus(message);
setLoadError(message);
}
})
.finally(() => {
@@ -117,7 +176,7 @@ export function useUiEditorPage(
return () => {
cancelled = true;
};
}, [replaceState, resourceId, stateStore]);
}, [projectPath, replaceState, resourceId, stateStore]);
const images = editor.state.ui_design_images;
const sprites = editor.state.sprite_assets;
@@ -296,7 +355,6 @@ export function useUiEditorPage(
}
function clearState() {
clearFocusRequest();
editor.clearState();
setImageOrder([]);
setActiveImageId(null);
@@ -308,26 +366,22 @@ export function useUiEditorPage(
}
function selectTool(tool: UiEditorToolId) {
clearFocusRequest();
setActiveTool(tool);
setIssues(null);
}
function selectDesignImage(id: UIDesignImageId) {
clearFocusRequest();
setActiveImageId(id);
setSelectedSpriteId(null);
setSelectedNodeId(null);
}
function selectNode(id: NodeId) {
clearFocusRequest();
setSelectedSpriteId(null);
setSelectedNodeId(id);
}
function clearNodeSelection() {
clearFocusRequest();
setSelectedNodeId(null);
}
@@ -453,7 +507,6 @@ export function useUiEditorPage(
}
function selectSprite(id: SpriteAssetId) {
clearFocusRequest();
setSelectedNodeId(null);
setSelectedSpriteId(id);
}
@@ -604,6 +657,9 @@ export function useUiEditorPage(
projectPath,
resourceId,
isLoading,
loadError,
persistedRevision,
setPersistedRevision,
editor,
activeTool,
activeToolLabel,
@@ -611,7 +667,6 @@ export function useUiEditorPage(
activeImageId,
selectedSpriteId,
selectedNodeId,
focusRequest,
importKind,
previewUrls,
status,
@@ -633,7 +688,6 @@ export function useUiEditorPage(
selectDesignImage,
selectSprite,
selectNode,
focusNode,
clearNodeSelection,
setNodeTransform,
updateNodeTransform,
@@ -1,20 +1,75 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { mockUiDesignStateStore } from '../src/features/ui-editor/uiDesignStateStore';
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }));
import { invoke } from '@tauri-apps/api/core';
import {
createTauriUiDesignStateStore,
mockUiDesignStateStore,
} from '../src/features/ui-editor/uiDesignStateStore';
describe('UI 设计 State mock store', () => {
it('每次 load 返回一个新的空 State,save 仍按成功完成', async () => {
it('每次 load 返回一个新的空 State,并按新快照契约保存', async () => {
const first = await mockUiDesignStateStore.load('ui-1');
first.ui_trees.push({} as never);
first.state.ui_trees.push({} as never);
await expect(
mockUiDesignStateStore.save('ui-1', first),
).resolves.toBeUndefined();
mockUiDesignStateStore.save('ui-1', first.revision, first.state),
).resolves.toMatchObject({
status: 'saved',
revision: 1,
});
await expect(mockUiDesignStateStore.load('ui-1')).resolves.toEqual({
revision: 0,
state: {
ui_trees: [],
ui_design_images: {},
sprite_assets: {},
font_assets: {},
},
});
});
it('通过专用 Tauri 命令提交项目身份和资源 CAS revision', async () => {
const store = createTauriUiDesignStateStore('/tmp/project', 'project-1');
const state = {
ui_trees: [],
ui_design_images: {},
sprite_assets: {},
font_assets: {},
};
vi.mocked(invoke).mockResolvedValueOnce({ revision: 3, state });
await expect(store.load('asset-1')).resolves.toEqual({
revision: 3,
state,
});
expect(invoke).toHaveBeenLastCalledWith('load_ui_design_state', {
input: {
projectPath: '/tmp/project',
expectedProjectId: 'project-1',
assetId: 'asset-1',
},
});
vi.mocked(invoke).mockResolvedValueOnce({
status: 'saved',
state,
revision: 4,
committedProjectRevision: 18,
});
await expect(store.save('asset-1', 3, state)).resolves.toMatchObject({
status: 'saved',
revision: 4,
});
expect(invoke).toHaveBeenLastCalledWith('save_ui_design_state', {
input: {
projectPath: '/tmp/project',
expectedProjectId: 'project-1',
assetId: 'asset-1',
expectedRevision: 3,
state,
},
});
});
});
@@ -1073,3 +1073,10 @@ game-project/
- 所有静态 delegate、ready child 和 isolated child 都只读继承根合同与当前 Acceptance Graph;isolated child 还必须实际收到自己的 `acceptanceCriteria / expectedArtifacts / writeScopes`。继承上下文不扩大工具、目录、写入权限或 expectedArtifacts。专业 Agent 只能报告局部结果、证据和剩余风险,不能修改根合同、根验收图或宣布用户总目标完成。
- Acceptance Graph 节点由 Supervisor 针对当前任务动态生成,不来自玩法模板。节点记录 required/optional、依赖、状态、证据引用与摘要;只有同一可信根 Supervisor 能调用 `agent.acceptance_update`,且该动作必须独占一轮。failed 与 not-observed 节点形成下一轮定向返工集合,未提交的 passed 节点保持不变。
- 完成门在普通收束、finalization 和 prepared 恢复中统一消费 Acceptance Graph。任一 required 节点为 failed、not-observed 或缺失都会阻止最终回复;工具成功、计划 completed、文件存在、smoke 或 preview 单独通过均不代表用户目标完成。项目 revision 改变后,整张图先进入待 Supervisor 确认状态;Supervisor 只更新它判断为受影响的节点并确认 current revision,未提交的 passed 节点及其证据保持不变。requiredEvidence 必须命中 Runtime 明确允许的持久证据工具集合,拼错、控制面、纯协调工具和依赖动态 catalog 的 MCP 调用不能形成不可完成合同。动作回执记录执行边界的 `projectRevisionBefore / projectRevisionAfter`:非 mutation evidence 的 before、after 都必须等于验收时 current revision;mutation evidence 的 after 必须等于 current revision。旧 revision、跨 revision 只读结果或执行后延迟落账的回执都不能重放为 passed。
## 2026-08-17 UI Editor 项目资源 State 持久化
- UI 编辑器复用现有 manifest `kind: "UI"`、`mediaType: "application/json"` 资源,不增加平行 asset kind。资源文件固定为严格 `game-creator-ui-design-state.v1` JSON envelope:`projectId`、`assetId`、每资源 `revision` 和 Rust 唯一源 `State`;旧空对象、未知字段、身份错配、超限、无效内部引用和不安全相对路径均失败关闭。
- Tauri 专用 load/save command 只接受项目路径、期望项目 ID、manifest asset ID 和(保存时)资源 revision;Rust 按 manifest 解析受控本地路径并在项目写锁内做 CAS。相同 `State` 返回 unchanged 且不推进 project revision;不同内容安装并回读一致后才推进 revision,后续推进失败返回 `reconciliation-required`,不伪装为完整保存。
- UI State 原子安装保留最近一个有效 `.previous` 恢复副本。主文件损坏时仅在恢复副本通过同一严格校验后恢复;任一候选均不可信则停在加载错误,前端禁编辑和保存。新建 UI 资源先登记并安装合法 envelope,任一步失败补偿 manifest/文件,避免把空 JSON 留给资源卡。
- 图片路径只需是安全项目相对路径,不要求外部图片仍存在或已登记为 manifest asset;缺失媒体只导致 preview 占位。`imageOrder`、当前选择、缩放、面板开关和 preview URL 不写入 State,加载后由 State 派生。保存冻结提交快照,保存期间的新编辑继续保持 dirty;AI state lock 和加载期间禁保存。