接入项目 UI 设计资源
新增 UI 设计资源的 manifest 登记与资源画布入口 将 UI 编辑器接入项目工作台并提供保存返回交互 补充 UI State mock 存储、资源投影与定向测试
This commit is contained in:
@@ -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<CreateUiDesignResourceResult, String> {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { State } from './types/State';
|
||||
import { EMPTY_UI_EDITOR_STATE } from './useUiEditorState';
|
||||
|
||||
export type UiDesignStateStore = {
|
||||
load(resourceId: string): Promise<State>;
|
||||
save(resourceId: string, state: State): Promise<void>;
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -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<ProjectResourceEditCapability, { route: 'derive' }>;
|
||||
};
|
||||
|
||||
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<AssetCanvasRoute | null>(null);
|
||||
const [resourceEditorRoute, setResourceEditorRoute] =
|
||||
useState<ResourceEditorRoute | null>(null);
|
||||
const [uiEditorRoute, setUiEditorRoute] = useState<UiEditorRoute | null>(
|
||||
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<CreateUiDesignResourceResult>(
|
||||
'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 (
|
||||
<section
|
||||
className="launcher-page launcher-project-development game-project-workbench"
|
||||
@@ -2915,17 +2989,7 @@ export default function ProjectDevelopmentView({
|
||||
<section
|
||||
className="game-workbench-stage"
|
||||
aria-label="项目主视窗"
|
||||
data-resource-view-state={
|
||||
mode === 'resources'
|
||||
? assetCanvasRoute
|
||||
? `resources.asset-canvas.${assetCanvasRoute.scope.intent}`
|
||||
: resourceEditorRoute
|
||||
? `resources.editor.${resourceEditorRoute.capability.editKind}`
|
||||
: focusedResource
|
||||
? `resources.focused.${focusedResource.category}`
|
||||
: 'resources.list'
|
||||
: undefined
|
||||
}
|
||||
data-resource-view-state={resourceViewState}
|
||||
>
|
||||
<div className="game-workbench-toolbar">
|
||||
<div className="game-workbench-tabs" role="tablist">
|
||||
@@ -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({
|
||||
<Sparkles size={15} aria-hidden="true" />
|
||||
新增资源
|
||||
</button>
|
||||
{/*// TODO this is only for debug usage, should integrate to the new asset above later*/}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void createUiDesignResource()}
|
||||
>
|
||||
<Sparkles size={15} aria-hidden="true" />
|
||||
新增 UI 设计
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={sortMode === 'dependency' ? 'is-active' : ''}
|
||||
@@ -3016,7 +3089,8 @@ export default function ProjectDevelopmentView({
|
||||
{!runAvailable &&
|
||||
!focusedResource &&
|
||||
!assetCanvasRoute &&
|
||||
!resourceEditorRoute ? (
|
||||
!resourceEditorRoute &&
|
||||
!uiEditorRoute ? (
|
||||
<p
|
||||
id="run-unavailable-hint"
|
||||
className="game-run-unavailable"
|
||||
@@ -3026,7 +3100,16 @@ export default function ProjectDevelopmentView({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{mode === 'resources' && assetCanvasRoute ? (
|
||||
{/* TODO(ui-design-surface): decide whether the workbench toolbar is
|
||||
hidden for the UI editor surface as part of a dedicated layout pass. */}
|
||||
{mode === 'resources' && uiEditorRoute ? (
|
||||
<UiEditorPage
|
||||
projectPath={projectPath}
|
||||
resourceId={uiEditorRoute.resourceId}
|
||||
resourceLabel={uiEditorRoute.resourceLabel}
|
||||
onBack={() => setUiEditorRoute(null)}
|
||||
/>
|
||||
) : mode === 'resources' && assetCanvasRoute ? (
|
||||
<AssetCanvasSurface
|
||||
host={assetCanvasRoute.host}
|
||||
scope={assetCanvasRoute.scope}
|
||||
|
||||
@@ -107,6 +107,12 @@ const videoExtension = /\.(mp4|webm|mov)$/iu;
|
||||
export function projectResourceCardPreviewKind(
|
||||
resource: ProjectResource,
|
||||
): ProjectResourceCardPreviewKind {
|
||||
// TODO(ui-design-preview): render the persisted UI Tree once UI State is
|
||||
// stored at the resource path; the registered resource currently uses the
|
||||
// normal placeholder visual.
|
||||
if (resource.subtype === 'UI') {
|
||||
return 'placeholder';
|
||||
}
|
||||
if (resource.category === 'version') {
|
||||
return 'version';
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ export function classifyProjectedResource(input: {
|
||||
return 'audio';
|
||||
}
|
||||
if (
|
||||
normalizedKind === 'ui' ||
|
||||
normalizedMediaType.startsWith('image/') ||
|
||||
normalizedMediaType.startsWith('video/') ||
|
||||
artExtension.test(normalizedPath) ||
|
||||
|
||||
@@ -26,6 +26,54 @@ export function InputSidebar({
|
||||
|
||||
return (
|
||||
<aside className="min-h-0 overflow-y-auto border-r border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) p-3">
|
||||
<div className="mb-3 grid gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-violet-600 px-3 py-2 text-left text-xs font-semibold text-white disabled:cursor-wait disabled:opacity-60"
|
||||
onClick={() => void controller.mergeUi()}
|
||||
disabled={
|
||||
controller.isMerging ||
|
||||
controller.editor.isLocked ||
|
||||
controller.editor.state.ui_trees.length === 0
|
||||
}
|
||||
title="调试:合并 UI 树"
|
||||
>
|
||||
{controller.isMerging ? '界面树合并中…' : '合并 UI 树'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-blue-600 px-3 py-2 text-left text-xs font-semibold text-white disabled:cursor-wait disabled:opacity-60"
|
||||
onClick={() => void controller.recognizeUi()}
|
||||
disabled={controller.isRecognizing || controller.editor.isLocked}
|
||||
title="调试:识别 UI 结构"
|
||||
>
|
||||
{controller.isRecognizing ? '结构识别中…' : '识别 UI 结构'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-orange-500 px-3 py-2 text-left text-xs font-semibold text-white disabled:cursor-wait disabled:opacity-60"
|
||||
onClick={() => void controller.suggestUiDesignSemantics()}
|
||||
disabled={controller.isSuggesting || controller.editor.isLocked}
|
||||
title="调试:识别参考图语义"
|
||||
>
|
||||
{controller.isSuggesting ? '识别中…' : '识别参考图语义'}
|
||||
</button>
|
||||
{controller.mergeStatus ? (
|
||||
<p className="m-0 rounded-lg border border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) px-3 py-2 text-xs">
|
||||
{controller.mergeStatus}
|
||||
</p>
|
||||
) : null}
|
||||
{controller.recognitionStatus ? (
|
||||
<p className="m-0 rounded-lg border border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) px-3 py-2 text-xs">
|
||||
{controller.recognitionStatus}
|
||||
</p>
|
||||
) : null}
|
||||
{controller.suggestionStatus ? (
|
||||
<p className="m-0 rounded-lg border border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) px-3 py-2 text-xs">
|
||||
{controller.suggestionStatus}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<section className="rounded-xl border border-(--platform-subpanel-border) bg-white/35 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
|
||||
@@ -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<string | null>(
|
||||
null,
|
||||
);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [returnConfirmOpen, setReturnConfirmOpen] = useState(false);
|
||||
const loadedResourceIdRef = useRef<string | undefined>(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 (
|
||||
<main className="flex h-[calc(100vh-42px)] min-h-0 min-w-5xl flex-col overflow-hidden bg-(--platform-body-fill) pt-12 text-(--platform-text-strong)">
|
||||
<main className="flex h-full min-h-0 min-w-5xl flex-col overflow-hidden bg-(--platform-body-fill) text-(--platform-text-strong)">
|
||||
{resourceId ? (
|
||||
<header className="flex shrink-0 items-center justify-between border-b border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) px-4 py-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-lg px-2 py-1.5 text-sm hover:bg-black/4"
|
||||
onClick={requestBack}
|
||||
>
|
||||
<ChevronLeft size={17} aria-hidden="true" />
|
||||
返回资源
|
||||
</button>
|
||||
<strong className="text-sm">{resourceLabel ?? 'UI 设计'}</strong>
|
||||
<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}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{isSaving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</header>
|
||||
) : null}
|
||||
<ToolNavigation
|
||||
activeTool={controller.activeTool}
|
||||
onChange={controller.selectTool}
|
||||
@@ -24,6 +116,46 @@ export default function UiEditorPage({
|
||||
<InspectorSidebar controller={controller} />
|
||||
</div>
|
||||
<EditorDialogs controller={controller} />
|
||||
<ThemedModal
|
||||
open={returnConfirmOpen}
|
||||
onClose={() => setReturnConfirmOpen(false)}
|
||||
ariaLabel="确认返回资源"
|
||||
panelClassName="w-[420px] rounded-2xl p-5"
|
||||
>
|
||||
<h2 className="m-0 text-base font-semibold">返回资源?</h2>
|
||||
{saveError ? (
|
||||
<p className="mt-3 text-sm text-red-600" role="alert">
|
||||
{saveError}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg border border-(--platform-subpanel-border) px-3 py-2 text-xs"
|
||||
onClick={() => setReturnConfirmOpen(false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg border border-(--platform-subpanel-border) px-3 py-2 text-xs"
|
||||
onClick={() => {
|
||||
setReturnConfirmOpen(false);
|
||||
onBack?.();
|
||||
}}
|
||||
>
|
||||
不保存
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-orange-600 px-3 py-2 text-xs font-semibold text-white disabled:opacity-60"
|
||||
disabled={isSaving}
|
||||
onClick={() => void saveAndReturn()}
|
||||
>
|
||||
{isSaving ? '保存中…' : '保存并返回'}
|
||||
</button>
|
||||
</div>
|
||||
</ThemedModal>
|
||||
<div className="fixed bottom-4 right-4 z-30 flex max-w-xs flex-col items-end gap-2">
|
||||
{controller.suggestionStatus ? (
|
||||
<p className="rounded-lg border border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) px-3 py-2 text-xs shadow-lg">
|
||||
|
||||
@@ -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<UiEditorToolId>('input');
|
||||
const [imageOrder, setImageOrder] = useState<UIDesignImageId[]>([]);
|
||||
const [activeImageId, setActiveImageId] = useState<UIDesignImageId | null>(
|
||||
@@ -74,6 +83,35 @@ export function useUiEditorPage(projectPath: string) {
|
||||
const [isBinding, setIsBinding] = useState(false);
|
||||
const [bindingStatus, setBindingStatus] = useState<string | null>(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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user