From 648ead2150169caeaa4a0d7a7d7b17b6604e3cba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 23 Sep 2026 18:14:15 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20UI=20=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E5=88=9B=E5=BB=BA=E5=85=A5=E5=8F=A3=20create?= =?UTF-8?q?=5Fui=5Fdesign=5Fdoc=5Ffrom=5Fimages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ui_editor/design_doc/creation.rs:由一至四张设计图新建文档,未登记路径顺带登记成 ui-design 图片资源,文档内设计图身份取图片 assetId - persistence.rs 把单图初始化泛化为 initialize_ui_design_state_with_images_at,支持多张设计图并拒绝重复 - 删除 resource_bridge.rs 与 create_ui_design_resource、ensure_ui_design_resource_for_prototype 两个命令,取消「原型 → 已存在文档」的幂等查找 - main.rs 注册 create_ui_design_doc_from_images 并移除两个旧命令注册项 - 前端 uiDesignResourceBridge 改为调用新命令,project-development 去掉 ui-workflow.completed 的自动打开与阶段跳转分支 - 代码地图同步 design_doc 模块与关键命令列表,测试改为覆盖新入口的编号避让 --- .../src-tauri/src/commands.rs | 116 ------ .../src-tauri/src/main.rs | 19 +- .../src-tauri/src/tests/project.rs | 56 ++- .../src/ui_editor/design_doc/creation.rs | 321 +++++++++++++++++ .../src-tauri/src/ui_editor/design_doc/mod.rs | 6 + .../src-tauri/src/ui_editor/mod.rs | 2 +- .../src-tauri/src/ui_editor/persistence.rs | 63 ++-- .../src/ui_editor/resource_bridge.rs | 339 ------------------ .../ui-editor/uiDesignResourceBridge.ts | 133 ++----- .../src/view/project-development/index.tsx | 69 +--- ...–¹案】UI编辑器代码地图与模块职责-2026-09-23.md | 6 +- 11 files changed, 456 insertions(+), 674 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/design_doc/creation.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/design_doc/mod.rs delete mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource_bridge.rs 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 fa4163a4d..064663420 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -2140,122 +2140,6 @@ 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 (resource_name, relative_path) = - crate::ui_editor::resource_bridge::next_ui_design_path(root, &manifest)?; - let absolute_path = resolve_local_project_path(root, &relative_path)?; - if let Some(parent) = absolute_path.parent() { - ensure_game_creator_private_directory_tree(parent, "UI 资源目录")?; - prepare_game_creator_private_path_for_read(parent, true, "UI 资源目录")?; - } - if prepare_game_creator_private_path_for_read(&absolute_path, false, "UI 资源")? { - return Err("UI 设计资源路径已存在,拒绝覆盖".to_string()); - } - let mut options = fs::OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(windows)] - { - use std::os::windows::fs::OpenOptionsExt; - options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); - } - let mut file = options - .open(&absolute_path) - .map_err(|error| format!("创建 UI 资源失败:{}: {error}", absolute_path.display()))?; - if let Err(error) = harden_new_game_creator_private_path(&absolute_path, false, "UI 资源") { - drop(file); - let _ = fs::remove_file(&absolute_path); - return Err(error); - } - file.sync_all() - .map_err(|error| format!("同步 UI 资源失败:{}: {error}", absolute_path.display()))?; - drop(file); - let asset = match register_local_asset_at( - root, - &relative_path, - GameCreationAppAssetKind::UiDesignDoc, - crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE, - "generated", - GameCreationAppAssetSource { - kind: GameCreationAppAssetSourceKind::Generated, - canvas_project_id: None, - resource_id: Some(format!( - "ui:{}", - resource_name.trim_start_matches("UI 设计 ") - )), - asset_object_id: None, - task_id: None, - prompt: None, - model: None, - generation_route: None, - 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 { - asset, - manifest, - committed_project_revision: revision, - }) -} - #[tauri::command] pub(crate) fn update_local_project_resource_classification( input: UpdateLocalProjectResourceClassificationInput, 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 bc6ec611e..e3b42b5cc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -268,10 +268,10 @@ fn generate_ui_design_code( } #[tauri::command] -fn ensure_ui_design_resource_for_prototype( - input: ui_editor::resource_bridge::EnsureUiDesignResourceForPrototypeInput, -) -> Result { - ui_editor::resource_bridge::ensure_ui_design_resource_for_prototype(input) +fn create_ui_design_doc_from_images( + input: ui_editor::design_doc::CreateUiDesignDocFromImagesInput, +) -> Result { + ui_editor::design_doc::create_ui_design_doc_from_images(input) } #[derive(Debug, Eq, PartialEq, Serialize)] @@ -1077,14 +1077,6 @@ 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 { @@ -2626,7 +2618,7 @@ fn main() { discover_game_creator_llm_models, upload_local_asset, register_local_asset, - create_ui_design_resource, + create_ui_design_doc_from_images, update_local_project_resource_classification, add_local_project_resource_tags, derive_local_project_resource, @@ -2651,7 +2643,6 @@ fn main() { load_ui_design_state, save_ui_design_state, generate_ui_design_code, - ensure_ui_design_resource_for_prototype, generate_platform_art_asset, generate_local_project_asset, start_local_project_asset_generation, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 6e9abb9e8..647c22b93 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -2823,7 +2823,7 @@ fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() { /// 写侧 → 分类的端到端口径:现役写入侧直接写出的 canonical kind 必须落进明确栏目。 /// /// 这里的字面量与写入侧逐字一致:UI 设计资产是 -/// `ui_editor/resource_bridge.rs` / `workflow.rs` / `persistence.rs` 的 +/// `ui_editor/design_doc/creation.rs` / `persistence.rs` 的 /// `register_local_asset_at` 使用 UI 文档 kind/media 常量, /// 字体是 `commands.rs` 字体上传的 `register_local_asset_entry(root, path, "font", ...)`。 #[test] @@ -2894,32 +2894,56 @@ fn register_local_asset_derives_category_from_real_write_side_kinds() { fs::remove_dir_all(root).ok(); } -/// 已存在的 `ui/UI 设计 N.json` 不能让新建 UI 设计资源直接失败。 +/// 已存在的 `ui/UI 设计 N.json` 不能让新建 UI 设计文档直接失败。 /// /// 编号按已登记的 UI 文档数推导,旧项目里 kind 已被收口为 `unknown` 的文档不再计入, -/// 只按计数取名就会撞上仍然存在的同名文件并报「路径已存在」。两个写入侧必须共用 -/// `ui_editor::resource_bridge` 的「第一个空闲编号」规则,既不改名既有文件也不覆盖。 +/// 只按计数取名就会撞上仍然存在的同名文件并报「路径已存在」。文档创建入口必须用 +/// `ui_editor::design_doc::next_ui_design_path` 的「第一个空闲编号」规则, +/// 既不改名既有文件也不覆盖。 #[test] -fn create_ui_design_resource_skips_a_taken_ui_design_path() { +fn create_ui_design_doc_from_images_skips_a_taken_ui_design_path() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "UI 设计编号避让").expect("project init"); write_local_project_file_at(&root, "ui/UI 设计 1.json", "{}").expect("ui asset file"); - - let result = crate::commands::create_ui_design_resource( - root.to_string_lossy().to_string(), - "project-1".to_string(), + let mut bytes = Vec::new(); + image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 64, + 48, + image::Rgba([10, 20, 30, 255]), + )) + .write_to( + &mut std::io::Cursor::new(&mut bytes), + image::ImageFormat::Png, ) - .expect("create UI design resource"); + .expect("encode design image"); + fs::create_dir_all(root.join("assets")).expect("assets dir"); + fs::write(root.join("assets/page.png"), bytes).expect("write design image"); - assert_eq!(result.asset.local_path, "ui/UI 设计 2.json"); - let registered = result + let created = crate::ui_editor::design_doc::create_ui_design_doc_from_images( + crate::ui_editor::design_doc::CreateUiDesignDocFromImagesInput { + project_path: root.to_string_lossy().to_string(), + expected_project_id: "project-1".to_string(), + images: vec![crate::ui_editor::design_doc::UiDesignImageReference { + asset_id: None, + path: Some("assets/page.png".to_string()), + }], + }, + ) + .expect("create UI design document"); + + assert_eq!(created.relative_path, "ui/UI 设计 2.json"); + assert_eq!(created.image_ids.len(), 1); + assert_eq!(created.asset.local_path, "ui/UI 设计 2.json"); + assert_eq!(created.asset.source.resource_id.as_deref(), Some("ui:2")); + assert_eq!(created.asset.kind, GameCreationAppAssetKind::UiDesignDoc); + let registered_image = created .manifest .assets .iter() - .find(|asset| asset.id == result.asset.id) - .expect("registered UI design asset"); - assert_eq!(registered.source.resource_id.as_deref(), Some("ui:2")); - assert_eq!(registered.kind, GameCreationAppAssetKind::UiDesignDoc); + .find(|asset| asset.id == created.image_ids[0]) + .expect("registered design image asset"); + assert_eq!(registered_image.local_path, "assets/page.png"); + assert_eq!(registered_image.media_type, "image/png"); assert!(root.join("ui/UI 设计 1.json").is_file()); fs::remove_dir_all(root).ok(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/design_doc/creation.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/design_doc/creation.rs new file mode 100644 index 000000000..6b2dacc91 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/design_doc/creation.rs @@ -0,0 +1,321 @@ +use crate::ui_editor::persistence::{ + initialize_ui_design_state_with_images_at, UiDesignDocumentImage, UI_DESIGN_DOC_MEDIA_TYPE, + UI_DESIGN_STATE_MAX_IMAGES, +}; +use crate::{ + acquire_project_write_lock, advance_agent_runtime_project_revision_locked, + enforce_project_permission_policy, ensure_game_creator_private_directory_tree, + harden_new_game_creator_private_path, normalize_relative_path, + prepare_game_creator_private_path_for_read, read_existing_manifest_for_project, + register_local_asset_at, resolve_local_project_path, write_manifest, GameCreationAppAssetKind, + GameCreationAppAssetManifestEntry, GameCreationAppAssetSource, GameCreationAppAssetSourceKind, + GameCreationAppManifest, +}; +use image::GenericImageView; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; + +/// 一张设计图的输入引用:给已登记资源的 `assetId`,或给项目内相对路径。 +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct UiDesignImageReference { + pub(crate) asset_id: Option, + pub(crate) path: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct CreateUiDesignDocFromImagesInput { + pub(crate) project_path: String, + pub(crate) expected_project_id: String, + pub(crate) images: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UiDesignDocCreated { + pub(crate) asset: GameCreationAppAssetManifestEntry, + pub(crate) manifest: GameCreationAppManifest, + pub(crate) relative_path: String, + pub(crate) image_ids: Vec, + pub(crate) committed_project_revision: u64, +} + +/// 每次调用都新建一份 UI 设计文档:不做「原型 → 已存在文档」的复用查找。 +pub(crate) fn create_ui_design_doc_from_images( + input: CreateUiDesignDocFromImagesInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + let expected_project_id = input.expected_project_id.trim(); + enforce_project_permission_policy(root, "asset.register")?; + if input.images.is_empty() { + return Err("至少需要一张设计图".to_string()); + } + if input.images.len() > UI_DESIGN_STATE_MAX_IMAGES { + return Err(format!( + "一份 UI 设计文档最多 {UI_DESIGN_STATE_MAX_IMAGES} 张设计图" + )); + } + let _lock = acquire_project_write_lock(root, "asset.register")?; + let manifest = read_existing_manifest_for_project(root)?; + if manifest.project_id != expected_project_id { + return Err("project-identity-conflict".to_string()); + } + let (images, registered_image_ids) = prepare_design_images(root, &manifest, &input.images)?; + let (resource_name, relative_path) = next_ui_design_path(root, &manifest)?; + let absolute_path = create_ui_design_document_file(root, &relative_path)?; + let asset = match register_local_asset_at( + root, + &relative_path, + GameCreationAppAssetKind::UiDesignDoc, + UI_DESIGN_DOC_MEDIA_TYPE, + "generated", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Generated, + canvas_project_id: None, + resource_id: Some(format!( + "ui:{}", + resource_name.trim_start_matches("UI 设计 ") + )), + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + ) { + Ok(asset) => asset, + Err(error) => { + let _ = fs::remove_file(&absolute_path); + return Err(error); + } + }; + if let Err(error) = + initialize_ui_design_state_with_images_at(root, expected_project_id, &asset.id, &images) + { + return match rollback_created_document(root, &asset.id, ®istered_image_ids, &absolute_path) + { + Ok(()) => Err(error), + Err(rollback_error) => Err(format!( + "UI 设计文档初始化失败:{error};reconciliation-required: 回滚未完成:{rollback_error}" + )), + }; + } + let committed_project_revision = + 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 asset = manifest + .assets + .iter() + .find(|entry| entry.id == asset.id) + .cloned() + .ok_or_else(|| "UI 设计文档创建后无法从 manifest 回读".to_string())?; + Ok(UiDesignDocCreated { + asset, + manifest, + relative_path, + image_ids: images.into_iter().map(|image| image.image_id).collect(), + committed_project_revision, + }) +} + +/// 解析并登记设计图,返回要装进文档的图片与本次调用新登记的图片资源 id。 +fn prepare_design_images( + root: &Path, + manifest: &GameCreationAppManifest, + references: &[UiDesignImageReference], +) -> Result<(Vec, Vec), String> { + let mut images = Vec::with_capacity(references.len()); + let mut registered_ids = Vec::new(); + let mut seen_ids = BTreeSet::new(); + for reference in references { + let (image, registered_id) = prepare_design_image(root, manifest, reference)?; + if let Some(id) = registered_id { + registered_ids.push(id); + } + if !seen_ids.insert(image.image_id.clone()) { + return Err("同一次调用不能重复登记同一张设计图".to_string()); + } + images.push(image); + } + Ok((images, registered_ids)) +} + +fn prepare_design_image( + root: &Path, + manifest: &GameCreationAppManifest, + reference: &UiDesignImageReference, +) -> Result<(UiDesignDocumentImage, Option), String> { + let (asset_id, relative_path, registered_id) = + match (reference.asset_id.as_deref(), reference.path.as_deref()) { + (Some(asset_id), None) => { + let asset_id = asset_id.trim(); + let asset = manifest + .assets + .iter() + .find(|asset| asset.id == asset_id) + .ok_or_else(|| format!("设计图资源不存在:{asset_id}"))?; + require_image_media_type(&asset.media_type)?; + (asset.id.clone(), asset.local_path.clone(), None) + } + (None, Some(path)) => { + let normalized_path = normalize_relative_path(path.trim())?; + match manifest + .assets + .iter() + .find(|asset| asset.local_path == normalized_path) + { + // 已登记图片复用原资源身份,不因为本次调用改写它的 kind 与分类。 + Some(existing) => { + require_image_media_type(&existing.media_type)?; + (existing.id.clone(), existing.local_path.clone(), None) + } + None => { + let absolute_path = resolve_local_project_path(root, &normalized_path)?; + let media_type = design_image_media_type(&absolute_path)?; + let registered = register_local_asset_at( + root, + &normalized_path, + GameCreationAppAssetKind::UiDesign, + media_type, + "ui-design", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Uploaded, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + )?; + (registered.id.clone(), normalized_path, Some(registered.id)) + } + } + } + _ => return Err("每张设计图必须且只能给 assetId 或 path".to_string()), + }; + let absolute_path = resolve_local_project_path(root, &relative_path)?; + let dimensions = image::open(&absolute_path) + .map_err(|error| format!("读取设计图失败:{relative_path}:{error}"))? + .dimensions(); + if dimensions.0 == 0 || dimensions.1 == 0 { + return Err(format!("设计图尺寸无效:{relative_path}")); + } + Ok(( + UiDesignDocumentImage { + image_id: asset_id, + path: relative_path, + pixel_size: dimensions, + }, + registered_id, + )) +} + +fn require_image_media_type(media_type: &str) -> Result<(), String> { + if media_type.to_ascii_lowercase().starts_with("image/") { + Ok(()) + } else { + Err("设计图必须是图片资源".to_string()) + } +} + +fn design_image_media_type(absolute_path: &Path) -> Result<&'static str, String> { + let format = image::ImageFormat::from_path(absolute_path) + .map_err(|error| format!("无法识别设计图格式:{}:{error}", absolute_path.display()))?; + match format { + image::ImageFormat::Png => Ok("image/png"), + image::ImageFormat::Jpeg => Ok("image/jpeg"), + image::ImageFormat::Gif => Ok("image/gif"), + image::ImageFormat::WebP => Ok("image/webp"), + image::ImageFormat::Bmp => Ok("image/bmp"), + _ => Err("设计图只支持 png/jpeg/gif/webp/bmp".to_string()), + } +} + +fn create_ui_design_document_file( + root: &Path, + relative_path: &str, +) -> Result { + let absolute_path = resolve_local_project_path(root, relative_path)?; + if let Some(parent) = absolute_path.parent() { + ensure_game_creator_private_directory_tree(parent, "UI 资源目录")?; + prepare_game_creator_private_path_for_read(parent, true, "UI 资源目录")?; + } + if prepare_game_creator_private_path_for_read(&absolute_path, false, "UI 资源")? { + return Err("UI 设计资源路径已存在,拒绝覆盖".to_string()); + } + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } + let file = options + .open(&absolute_path) + .map_err(|error| format!("创建 UI 资源失败:{}:{error}", absolute_path.display()))?; + if let Err(error) = harden_new_game_creator_private_path(&absolute_path, false, "UI 资源") { + drop(file); + let _ = fs::remove_file(&absolute_path); + return Err(error); + } + drop(file); + Ok(absolute_path) +} + +fn rollback_created_document( + root: &Path, + asset_id: &str, + registered_image_ids: &[String], + absolute_path: &Path, +) -> Result<(), String> { + let mut manifest = read_existing_manifest_for_project(root)?; + manifest + .assets + .retain(|entry| entry.id != asset_id && !registered_image_ids.contains(&entry.id)); + write_manifest(&root.join(".agent/manifest.json"), &manifest)?; + fs::remove_file(absolute_path).map_err(|error| format!("删除未完成 UI 设计资源失败:{error}")) +} + +/// UI 设计文档的确定性命名:从已登记文档数 + 1 起找第一个既未被占用、 +/// 也未登记进 manifest 的 `ui/UI 设计 N.json`,不覆盖任何既有文件。 +pub(crate) fn next_ui_design_path( + root: &Path, + manifest: &GameCreationAppManifest, +) -> Result<(String, String), String> { + let mut index = manifest + .assets + .iter() + .filter(|asset| { + asset.kind == GameCreationAppAssetKind::UiDesignDoc + && asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE + }) + .count() + + 1; + loop { + let resource_name = format!("UI 设计 {index}"); + let relative_path = format!("ui/{resource_name}.json"); + let path = resolve_local_project_path(root, &relative_path)?; + if !path.exists() + && !manifest + .assets + .iter() + .any(|asset| asset.local_path == relative_path) + { + return Ok((resource_name, relative_path)); + } + index = index + .checked_add(1) + .ok_or_else(|| "UI 设计资源编号已达到上限".to_string())?; + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/design_doc/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/design_doc/mod.rs new file mode 100644 index 000000000..c47123540 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/design_doc/mod.rs @@ -0,0 +1,6 @@ +mod creation; + +pub(crate) use creation::{ + create_ui_design_doc_from_images, next_ui_design_path, CreateUiDesignDocFromImagesInput, + UiDesignDocCreated, UiDesignImageReference, +}; diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/mod.rs index 96a8f66a1..8a76456a6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/mod.rs @@ -1,9 +1,9 @@ pub mod commands; pub mod component; +pub(crate) mod design_doc; pub(crate) mod html_renderer; pub mod layout; pub mod persistence; pub mod resource; -pub mod resource_bridge; pub mod state; mod utils; diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs index 454fbc9f9..06c443a9e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs @@ -23,7 +23,7 @@ pub(crate) use shared_contracts::game_creation_app::{ }; const UI_DESIGN_STATE_MAX_BYTES: usize = 2 * 1024 * 1024; const UI_DESIGN_CODE_MAX_BYTES: usize = UI_DESIGN_STATE_MAX_BYTES * 8; -const UI_DESIGN_STATE_MAX_IMAGES: usize = 4; +pub(crate) const UI_DESIGN_STATE_MAX_IMAGES: usize = 4; const UI_DESIGN_STATE_MAX_SPRITES: usize = 1_024; pub(crate) const UI_DESIGN_STATE_MAX_NODES: usize = 10_000; const UI_DESIGN_STATE_MAX_DEPTH: usize = 128; @@ -112,36 +112,53 @@ pub(crate) fn initialize_ui_design_state_at( Ok(()) } -/// Initializes a newly bridged UI design with the source prototype as its first -/// design image. The bridge holds the project write lock, so this helper only -/// installs revision zero and never advances the project revision itself. -pub(crate) fn initialize_ui_design_state_with_source_image_at( +/// 新建文档时要装进文档的设计图:文档内身份取图片在 manifest 里的 assetId。 +pub(crate) struct UiDesignDocumentImage { + pub(crate) image_id: String, + pub(crate) path: String, + pub(crate) pixel_size: (u32, u32), +} + +/// Installs the given design images into a brand new UI design document at +/// revision zero. Callers hold the project write lock, so this helper never +/// advances the project revision itself. +pub(crate) fn initialize_ui_design_state_with_images_at( root: &Path, project_id: &str, asset_id: &str, - source_image_id: &str, - source_image_path: &str, - pixel_size: (u32, u32), + images: &[UiDesignDocumentImage], ) -> Result<(), String> { let asset = ui_design_asset(root, project_id, asset_id)?; - let source_image_id = required_identifier(source_image_id, "sourceImageId")?; - let source_image_path = normalize_relative_path(source_image_path.trim())?; - if pixel_size.0 == 0 || pixel_size.1 == 0 { - return Err("源 UI 原型图片尺寸无效".to_string()); + if images.is_empty() { + return Err("UI 设计文档至少需要一张设计图".to_string()); } let pixels_per_unit = - StrictlyPositiveFinite::new(1.0).map_err(|_| "UI 原型图片像素比例无效".to_string())?; + StrictlyPositiveFinite::new(1.0).map_err(|_| "UI 设计图像素比例无效".to_string())?; let mut document = empty_document(project_id, asset_id); - let source_image_id = UIDesignImageId::new(source_image_id) - .map_err(|error| format!("sourceImageId 无效:{error}"))?; - document.state.ui_design_images.insert( - source_image_id, - UIDesignImage { - path: source_image_path, - pixel_size: Vector2::new(pixel_size.0 as f32, pixel_size.1 as f32), - pixels_per_unit, - }, - ); + for image in images { + let image_id = required_identifier(&image.image_id, "designImageId")?; + let path = normalize_relative_path(image.path.trim())?; + if image.pixel_size.0 == 0 || image.pixel_size.1 == 0 { + return Err("UI 设计图尺寸无效".to_string()); + } + let image_id = UIDesignImageId::new(image_id) + .map_err(|error| format!("designImageId 无效:{error}"))?; + if document + .state + .ui_design_images + .insert( + image_id, + UIDesignImage { + path, + pixel_size: Vector2::new(image.pixel_size.0 as f32, image.pixel_size.1 as f32), + pixels_per_unit, + }, + ) + .is_some() + { + return Err("UI 设计文档的设计图不能重复".to_string()); + } + } write_ui_design_document(root, &asset.local_path, &document)?; let installed = read_ui_design_document(root, &asset.local_path, project_id, asset_id)?; validate_document(&installed, project_id, asset_id)?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource_bridge.rs deleted file mode 100644 index dc76ce37c..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource_bridge.rs +++ /dev/null @@ -1,339 +0,0 @@ -use crate::ui_editor::persistence::{ - initialize_ui_design_state_with_source_image_at, UI_DESIGN_DOC_ASSET_KIND, - UI_DESIGN_DOC_MEDIA_TYPE, -}; -use crate::{ - acquire_project_write_lock, advance_agent_runtime_project_revision_locked, - enforce_project_permission_policy, read_existing_manifest_for_project, - read_game_creator_agent_runtime_project_revision, register_local_asset_at, - resolve_local_project_path, write_manifest, GameCreationAppAssetManifestEntry, - GameCreationAppAssetSource, GameCreationAppAssetSourceKind, GameCreationAppManifest, -}; -use image::GenericImageView; -use serde::{Deserialize, Serialize}; -use shared_contracts::game_creation_app::GameCreationAppAssetKind; -use std::fs; -use std::path::Path; - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct EnsureUiDesignResourceForPrototypeInput { - pub(crate) project_path: String, - pub(crate) expected_project_id: String, - pub(crate) prototype_asset_id: String, -} - -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct EnsureUiDesignResourceForPrototypeResult { - pub(crate) asset: GameCreationAppAssetManifestEntry, - pub(crate) manifest: GameCreationAppManifest, - pub(crate) committed_project_revision: u64, - pub(crate) created: bool, -} - -pub(crate) fn ensure_ui_design_resource_for_prototype( - input: EnsureUiDesignResourceForPrototypeInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - let expected_project_id = input.expected_project_id.trim(); - let prototype_asset_id = input.prototype_asset_id.trim(); - if expected_project_id.is_empty() || prototype_asset_id.is_empty() { - return Err("UI 原型桥接参数不能为空".to_string()); - } - 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 { - return Err("project-identity-conflict".to_string()); - } - let prototype = manifest - .assets - .iter() - .find(|asset| asset.id == prototype_asset_id) - .ok_or_else(|| "UI 原型资产不存在".to_string())?; - if prototype.kind != GameCreationAppAssetKind::UiDesign - || !prototype - .media_type - .to_ascii_lowercase() - .starts_with("image/") - { - return Err("目标资产不是可桥接的 UI 原型图片".to_string()); - } - let source_path = prototype.local_path.clone(); - let source_reference_ids = [ - Some(prototype_asset_id), - prototype.source.resource_id.as_deref(), - prototype.source.asset_object_id.as_deref(), - ] - .into_iter() - .flatten() - .filter(|reference| !reference.trim().is_empty()) - .collect::>(); - let association_reference_id = prototype - .source - .resource_id - .as_deref() - .filter(|reference| !reference.trim().is_empty()) - .or_else(|| { - prototype - .source - .asset_object_id - .as_deref() - .filter(|reference| !reference.trim().is_empty()) - }) - .unwrap_or(prototype_asset_id) - .to_string(); - let source_absolute_path = resolve_local_project_path(root, &source_path)?; - let dimensions = image::open(&source_absolute_path) - .map_err(|error| format!("读取 UI 原型图片失败:{error}"))? - .dimensions(); - if dimensions.0 == 0 || dimensions.1 == 0 { - return Err("UI 原型图片尺寸无效".to_string()); - } - - if let Some(asset) = manifest.assets.iter().find(|asset| { - asset.kind == UI_DESIGN_DOC_ASSET_KIND - && asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE - && asset.source.reference_resource_ids.iter().any(|reference| { - source_reference_ids - .iter() - .any(|expected| reference == expected) - }) - }) { - let revision = read_game_creator_agent_runtime_project_revision(root)?.revision; - return Ok(EnsureUiDesignResourceForPrototypeResult { - asset: asset.clone(), - manifest, - committed_project_revision: revision, - created: false, - }); - } - - let (resource_name, relative_path) = next_ui_design_path(root, &manifest)?; - 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()))?; - } - fs::write(&absolute_path, "") - .map_err(|error| format!("创建 UI 资源失败:{}: {error}", absolute_path.display()))?; - - let asset = match register_local_asset_at( - root, - &relative_path, - GameCreationAppAssetKind::UiDesignDoc, - UI_DESIGN_DOC_MEDIA_TYPE, - "generated", - GameCreationAppAssetSource { - kind: GameCreationAppAssetSourceKind::Generated, - canvas_project_id: None, - resource_id: Some(format!( - "ui:{}", - resource_name.trim_start_matches("UI 设计 ") - )), - asset_object_id: None, - task_id: None, - prompt: None, - model: None, - generation_route: None, - generation_kind: None, - reference_resource_ids: vec![association_reference_id], - }, - ) { - Ok(result) => result, - Err(error) => { - let _ = fs::remove_file(&absolute_path); - return Err(error); - } - }; - - if let Err(error) = initialize_ui_design_state_with_source_image_at( - root, - expected_project_id, - &asset.id, - prototype_asset_id, - &source_path, - dimensions, - ) { - 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}")) - })(); - return match rollback { - Ok(()) => Err(error), - Err(rollback_error) => Err(format!( - "UI 设计资源初始化失败:{error};reconciliation-required: 回滚未完成:{rollback_error}" - )), - }; - } - - let committed_project_revision = - 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 asset = manifest - .assets - .iter() - .find(|entry| entry.id == asset.id) - .cloned() - .ok_or_else(|| "UI 设计资源创建后无法从 manifest 回读".to_string())?; - Ok(EnsureUiDesignResourceForPrototypeResult { - asset, - manifest, - committed_project_revision, - created: true, - }) -} - -/// UI 设计文档的确定性命名:从已登记文档数 + 1 起找第一个既未被占用、 -/// 也未登记进 manifest 的 `ui/UI 设计 N.json`,不覆盖任何既有文件。 -pub(crate) fn next_ui_design_path( - root: &Path, - manifest: &GameCreationAppManifest, -) -> Result<(String, String), String> { - let mut index = manifest - .assets - .iter() - .filter(|asset| { - asset.kind == UI_DESIGN_DOC_ASSET_KIND && asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE - }) - .count() - + 1; - loop { - let resource_name = format!("UI 设计 {index}"); - let relative_path = format!("ui/{resource_name}.json"); - let path = resolve_local_project_path(root, &relative_path)?; - if !path.exists() - && !manifest - .assets - .iter() - .any(|asset| asset.local_path == relative_path) - { - return Ok((resource_name, relative_path)); - } - index = index - .checked_add(1) - .ok_or_else(|| "UI 设计资源编号已达到上限".to_string())?; - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::ui_editor::persistence::{load_ui_design_state_at, LoadUiDesignStateInput}; - use crate::ui_editor::utils::UIDesignImageId; - use crate::{init_local_game_project_at, register_local_asset_entry}; - use shared_contracts::game_creation_app::GameCreationAppAssetCategory; - use std::io::Cursor; - - fn fixture() -> tempfile::TempDir { - let directory = tempfile::tempdir().expect("create UI bridge project"); - init_local_game_project_at(directory.path(), "ui-bridge-project", "UI bridge") - .expect("init project"); - let source_path = directory.path().join("assets/ui-prototype.png"); - fs::create_dir_all(source_path.parent().expect("source parent")) - .expect("create source parent"); - let mut bytes = Vec::new(); - image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( - 320, - 180, - image::Rgba([255, 128, 64, 255]), - )) - .write_to(&mut Cursor::new(&mut bytes), image::ImageFormat::Png) - .expect("encode source image bytes"); - fs::write(&source_path, bytes).expect("write source image"); - register_local_asset_entry( - directory.path(), - "assets/ui-prototype.png", - GameCreationAppAssetKind::UiDesign, - "image/png", - "canvas", - GameCreationAppAssetSource { - kind: GameCreationAppAssetSourceKind::Canvas, - canvas_project_id: Some("canvas-project".to_string()), - resource_id: Some("prototype-resource".to_string()), - asset_object_id: None, - task_id: Some("design-foundation".to_string()), - prompt: None, - model: None, - generation_route: None, - generation_kind: Some("ui-design".to_string()), - reference_resource_ids: Vec::new(), - }, - ) - .expect("register source asset"); - directory - } - - #[test] - fn bridge_is_idempotent_and_installs_source_image() { - let directory = fixture(); - let root = directory.path(); - let manifest = read_existing_manifest_for_project(root).expect("manifest"); - let source_id = manifest - .assets - .iter() - .find(|asset| asset.kind == GameCreationAppAssetKind::UiDesign) - .expect("source asset") - .id - .clone(); - let input = EnsureUiDesignResourceForPrototypeInput { - project_path: root.to_string_lossy().into_owned(), - expected_project_id: "ui-bridge-project".to_string(), - prototype_asset_id: source_id.clone(), - }; - let first = ensure_ui_design_resource_for_prototype(input.clone()).expect("bridge"); - assert!(first.created); - assert_eq!(first.asset.kind, UI_DESIGN_DOC_ASSET_KIND); - // 写侧 → 分类的端到端断言:这条路径走的是与 - // 写侧与 persistence/workflow 共用 UI 文档 kind/media 常量。 - assert_eq!( - first.asset.category, - GameCreationAppAssetCategory::UiInteraction - ); - assert_eq!( - first.asset.source.reference_resource_ids, - vec!["prototype-resource".to_string()] - ); - let snapshot = load_ui_design_state_at(LoadUiDesignStateInput { - project_path: root.to_string_lossy().into_owned(), - expected_project_id: "ui-bridge-project".to_string(), - asset_id: first.asset.id.clone(), - }) - .expect("load bridged state"); - let source_image_id = UIDesignImageId::new(source_id.clone()).expect("source image id"); - let image = snapshot - .state - .ui_design_images - .get(&source_image_id) - .expect("source image"); - assert_eq!(image.path, "assets/ui-prototype.png"); - assert_eq!(image.pixel_size.x, 320.0); - assert_eq!(image.pixel_size.y, 180.0); - - let second = ensure_ui_design_resource_for_prototype(input).expect("idempotent bridge"); - assert!(!second.created); - assert_eq!(second.asset.id, first.asset.id); - assert_eq!( - second.committed_project_revision, - first.committed_project_revision - ); - assert_eq!( - second - .manifest - .assets - .iter() - .filter(|asset| { - asset.kind == UI_DESIGN_DOC_ASSET_KIND - && asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE - }) - .count(), - 1 - ); - } -} diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/uiDesignResourceBridge.ts b/apps/ai-game-creator-shell/src/features/ui-editor/uiDesignResourceBridge.ts index 39168801f..d53c19ab2 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/uiDesignResourceBridge.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/uiDesignResourceBridge.ts @@ -2,120 +2,45 @@ import type { GameCreationAppAssetManifestEntry, GameCreationAppManifest, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; -import { - GAME_CREATION_APP_UI_DESIGN_ASSET_KIND, - isGameCreationAppUiDesignDocAsset, - parseGameCreationAppAssetKind, -} from '../../../../../packages/shared/src/contracts/gameCreationApp'; -export type UiDesignResourceBridgeResult = { - asset: GameCreationAppAssetManifestEntry; - manifest: GameCreationAppManifest; - committedProjectRevision: number; - created: boolean; +/** 一张设计图的输入引用:给已登记资源的 assetId,或给项目内相对路径。 */ +export type UiDesignDocImageReference = { + assetId?: string; + path?: string; }; -export type UiDesignResourceBridgeInvoke = ( +export type UiDesignDocCreated = { + asset: GameCreationAppAssetManifestEntry; + manifest: GameCreationAppManifest; + relativePath: string; + imageIds: Array; + committedProjectRevision: number; +}; + +export type UiDesignDocInvoke = ( command: string, args?: Record, ) => Promise; -function uiWorkflowStagePriority( - generationKind: string | null | undefined, -): number { - switch (generationKind) { - case 'ui-workflow.completed': - return 3; - case 'ui-workflow.binding-ready': - return 2; - case 'ui-workflow.reference-ready': - return 1; - default: - return 0; - } -} - -export function findLinkedUiDesignResource( - manifest: GameCreationAppManifest, - prototypeAssetId: string, -) { - const normalizedId = prototypeAssetId.trim(); - if (!normalizedId) return null; - const prototype = manifest.assets.find((asset) => asset.id === normalizedId); - const referenceIds = new Set( - [ - normalizedId, - prototype?.source.resourceId, - prototype?.source.assetObjectId, - ].filter((value): value is string => Boolean(value?.trim())), - ); - return ( - manifest.assets - .filter( - (asset) => - isGameCreationAppUiDesignDocAsset(asset) && - asset.source.referenceResourceIds?.some((reference) => - referenceIds.has(reference), - ), - ) - .sort( - (left, right) => - uiWorkflowStagePriority(right.source.generationKind) - - uiWorkflowStagePriority(left.source.generationKind), - )[0] ?? null - ); -} - -export async function ensureUiDesignResourceForPrototype({ +/** + * 新建一份 UI 设计文档。每次调用都新建,不做「原型 → 已存在文档」的复用查找; + * 未登记的相对路径由 Rust 侧顺带登记成图片资源。 + */ +export async function createUiDesignDocFromImages({ projectPath, - manifest, - prototypeAssetId, + expectedProjectId, + images, invoke, }: { projectPath: string; - manifest: GameCreationAppManifest; - prototypeAssetId: string; - invoke: UiDesignResourceBridgeInvoke; -}): Promise { - const normalizedPrototypeAssetId = prototypeAssetId.trim(); - if (!normalizedPrototypeAssetId) { - throw new Error('UI 原型资产身份不能为空'); + expectedProjectId: string; + images: Array; + invoke: UiDesignDocInvoke; +}): Promise { + if (images.length === 0) { + throw new Error('请至少选择一张界面图'); } - const prototype = manifest.assets.find( - (asset) => asset.id === normalizedPrototypeAssetId, - ); - if ( - !prototype || - parseGameCreationAppAssetKind( - prototype.kind, - 'ui-design-resource-bridge.prototype', - ) !== GAME_CREATION_APP_UI_DESIGN_ASSET_KIND - ) { - throw new Error('目标资源不是 UI 原型图片'); - } - if (!prototype.mediaType.toLowerCase().startsWith('image/')) { - throw new Error('UI 原型资源必须是图片'); - } - const linked = findLinkedUiDesignResource( - manifest, - normalizedPrototypeAssetId, - ); - if (linked) { - return { - asset: linked, - manifest, - committedProjectRevision: 0, - created: false, - }; - } - return invoke( - 'ensure_ui_design_resource_for_prototype', - { - input: { - projectPath, - expectedProjectId: manifest.projectId, - prototypeAssetId: normalizedPrototypeAssetId, - }, - }, - ); + return invoke('create_ui_design_doc_from_images', { + input: { projectPath, expectedProjectId, images }, + }); } 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 61e32e9c4..340579590 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 @@ -263,7 +263,7 @@ import { replaceVersionResource, } from '../../features/resource-canvas/resourceVersionReplacementTransport'; import { useResourceCanvasGenerationPlaceholders } from '../../features/resource-canvas/useResourceCanvasGenerationPlaceholders'; -import { ensureUiDesignResourceForPrototype } from '../../features/ui-editor/uiDesignResourceBridge'; +import { createUiDesignDocFromImages } from '../../features/ui-editor/uiDesignResourceBridge'; import { currentPlatformSessionGeneration, requestPlatformSessionRefresh, @@ -2182,7 +2182,6 @@ export default function ProjectDevelopmentView({ const [uiEditorRoute, setUiEditorRoute] = useState( null, ); - const autoOpenedUiWorkflowResourceRef = useRef(null); const [resourceWorkbenchNotice, setResourceWorkbenchNotice] = useState(''); /** * 打开项目时读盘归并旧分区 / 跳过无法对齐的坐标都是当场写回 sidecar 的副作用, @@ -6322,10 +6321,10 @@ export default function ProjectDevelopmentView({ setUiEditorRoute(null); setResourceWorkbenchNotice('正在准备 UI 编辑资源…'); try { - const result = await ensureUiDesignResourceForPrototype({ + const result = await createUiDesignDocFromImages({ projectPath, - manifest, - prototypeAssetId, + expectedProjectId: manifest.projectId, + images: [{ assetId: prototypeAssetId }], invoke, }); if (canvasOpenEpochRef.current !== openEpoch) return; @@ -6335,26 +6334,18 @@ export default function ProjectDevelopmentView({ ) { throw new Error('UI 编辑资源结果与当前项目不一致'); } - if (result.created) { - onManifestChange?.(projectPath, result.manifest, { - projectId: result.manifest.projectId, - revision: result.committedProjectRevision, - source: 'asset-command', - commitId: `ui-design-bridge:${result.asset.id}`, - }); - } + onManifestChange?.(projectPath, result.manifest, { + projectId: result.manifest.projectId, + revision: result.committedProjectRevision, + source: 'asset-command', + commitId: `ui-design-doc:${result.asset.id}`, + }); setResourceWorkbenchNotice(''); setUiEditorRoute({ resourceId: result.asset.id, resourceLabel: - result.asset.localPath.split(/[\\/]/u).filter(Boolean).pop() ?? + result.relativePath.split(/[\\/]/u).filter(Boolean).pop() ?? resource.label, - ...(result.asset.source.generationKind === 'ui-workflow.completed' - ? { - initialStep: 'asset-separation', - initialFurthestStepIndex: 1, - } - : {}), }); } catch (error) { if (canvasOpenEpochRef.current === openEpoch) { @@ -6398,19 +6389,10 @@ export default function ProjectDevelopmentView({ setUiEditorRoute({ resourceId: resource.manifestAssetId, resourceLabel: resource.label, - ...(manifest.assets.find( - (asset) => asset.id === resource.manifestAssetId, - )?.source.generationKind === 'ui-workflow.completed' - ? { - initialStep: 'asset-separation' as const, - initialFurthestStepIndex: 1, - } - : {}), }); }, [ advanceFocusGeneration, - manifest.assets, openUiDesignEditor, resourceCardPreviews.identityByResourceId, resourceCardPreviews.previews, @@ -6483,35 +6465,6 @@ export default function ProjectDevelopmentView({ resources, ]); - useEffect(() => { - if (uiEditorRoute) return; - const completed = manifest.assets.find( - (asset) => - isGameCreationAppUiDesignDocAsset(asset) && - asset.source.generationKind === 'ui-workflow.completed', - ); - if ( - !completed || - autoOpenedUiWorkflowResourceRef.current === completed.id - ) { - return; - } - autoOpenedUiWorkflowResourceRef.current = completed.id; - canvasOpenEpochRef.current += 1; - advanceFocusGeneration(); - activeFocusFlowIdRef.current = null; - pendingResourceFocusRef.current = null; - setResourceWorkbenchNotice(''); - setUiEditorRoute({ - resourceId: completed.id, - resourceLabel: - completed.localPath.split(/[\\/]/u).filter(Boolean).pop() ?? - 'UI 设计资源', - initialStep: 'asset-separation', - initialFurthestStepIndex: 1, - }); - }, [advanceFocusGeneration, manifest.assets, uiEditorRoute]); - const beginPendingResourceEditAction = useCallback((operationId: string) => { if (pendingResourceEditActionIdsRef.current.has(operationId)) return false; const next = new Set(pendingResourceEditActionIdsRef.current); diff --git a/docs/technical/【技术方案】UI编辑器代码地图与模块职责-2026-09-23.md b/docs/technical/【技术方案】UI编辑器代码地图与模块职责-2026-09-23.md index f1fa67d6b..eae334588 100644 --- a/docs/technical/【技术方案】UI编辑器代码地图与模块职责-2026-09-23.md +++ b/docs/technical/【技术方案】UI编辑器代码地图与模块职责-2026-09-23.md @@ -27,12 +27,12 @@ view/ui-editor (页面/组件) | `component/` | 组件枚举 `Component::{Image, Text}`、`NodeComponent`(LLM 工具载荷的 `PureNode`/`WithComponent` 判别式) | | `resource/` | 界面图(`path` / `pixel_size` / `pixels_per_unit`)、sprite(含 `SpriteBorder` 九宫格)、字体(格式/媒体类型/CSS format)资源描述 | | `persistence.rs` | 文档读写、`revision` 乐观并发保存、领域校验(重复 ID、树/资源引用、组件状态)、代码生成写盘 | -| `resource_bridge.rs` | 原型图 → `ui_design` 资源的桥接建项(持项目写锁,装 revision 0,仅首个设计图) | +| `design_doc/` | UI 设计文档入口:`creation.rs` 用一至四张设计图新建文档(登记未登记图片、按 `ui/UI 设计 N.json` 取号、持项目写锁装 revision 0、失败回滚) | | `html_renderer/` | 由 State 生成 HTML 片段与 JS(maud + 布局/组件 CSS 映射),供预览与 `ui/generated-*.js` | | `commands/` | LLM 工具链:`recognition`(结构识别)、`separation/`(自动切分素材)、`utils.rs`(LLM 请求、重试、`required_tool_arguments`) | | `commands/separation/` | 切分批处理、截图/预切、sidecar 恢复(inspect / finalize / discard)、patch 回写 | -关键命令(`main.rs` 注册):`load_ui_design_state`、`save_ui_design_state`、`generate_ui_design_code`、`ensure_ui_design_resource_for_prototype`、`recognize_ui`、`separate_ui`、`inspect_separation_recovery`、`finalize_separation`、`discard_separation_recovery`。 +关键命令(`main.rs` 注册):`load_ui_design_state`、`save_ui_design_state`、`generate_ui_design_code`、`create_ui_design_doc_from_images`、`recognize_ui`、`separate_ui`、`inspect_separation_recovery`、`finalize_separation`、`discard_separation_recovery`。 保存语义(`save_ui_design_state_at`):`Saved` / `Unchanged` / `Conflict`(返回当前快照)三态;先 `validate_state` 再持锁重读比对 `expected_revision`,成功后推进项目 revision。代码生成只接受已保存的 revision,产物路径为 `ui/generated--.js`。 @@ -46,7 +46,7 @@ view/ui-editor (页面/组件) - 结果应用 seam:`recognition.ts`、`separationStatus.ts`(问题节点 → `NeedReview`)。 - 概览 projection:`stageStatusOverview.ts`、`separationOverview.ts`。 - 前置校验:`requisites.ts` 在发起 LLM 操作前检查必需资源与结果完整性。 -- 适配器:`importAdapter.ts`(图片解码、批量导入、字体准备)、`uiDesignResourceBridge.ts`(原型桥接)、`useUiEditorFontFaces.ts`(私有字体族加载)、`spriteBorder.ts`。 +- 适配器:`importAdapter.ts`(图片解码、批量导入、字体准备)、`uiDesignResourceBridge.ts`(调用 `create_ui_design_doc_from_images` 新建文档)、`useUiEditorFontFaces.ts`(私有字体族加载)、`spriteBorder.ts`。 - `utils/`:State → CSS 映射(`componentToCss`、`controlLayoutToCss`、`textStyleToCss`、`transform/tf2css`)、`treeUtils`。 ## 前端:`src/view/ui-editor`(表现与编排)