实现UI编辑器状态持久化
新增 UI State 严格文件契约、CAS 保存与恢复副本 接入 Tauri 加载保存与项目资源创建事务 完善编辑器加载失败、保存冲突和预览恢复行为 补充持久化测试与工程约定文档
This commit is contained in:
@@ -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"), ¤t)?;
|
||||
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 和加载期间禁保存。
|
||||
|
||||
Reference in New Issue
Block a user