新增 UI 设计文档创建入口 create_ui_design_doc_from_images
- 新增 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 模块与关键命令列表,测试改为覆盖新入口的编号避让
This commit is contained in:
@@ -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<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 (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,
|
||||
|
||||
@@ -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::EnsureUiDesignResourceForPrototypeResult, String> {
|
||||
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::UiDesignDocCreated, String> {
|
||||
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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<String>,
|
||||
pub(crate) path: Option<String>,
|
||||
}
|
||||
|
||||
#[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<UiDesignImageReference>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub(crate) committed_project_revision: u64,
|
||||
}
|
||||
|
||||
/// 每次调用都新建一份 UI 设计文档:不做「原型 → 已存在文档」的复用查找。
|
||||
pub(crate) fn create_ui_design_doc_from_images(
|
||||
input: CreateUiDesignDocFromImagesInput,
|
||||
) -> Result<UiDesignDocCreated, String> {
|
||||
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<UiDesignDocumentImage>, Vec<String>), 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>), 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<std::path::PathBuf, String> {
|
||||
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())?;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mod creation;
|
||||
|
||||
pub(crate) use creation::{
|
||||
create_ui_design_doc_from_images, next_ui_design_path, CreateUiDesignDocFromImagesInput,
|
||||
UiDesignDocCreated, UiDesignImageReference,
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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<EnsureUiDesignResourceForPrototypeResult, String> {
|
||||
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::<Vec<_>>();
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 = <T>(
|
||||
export type UiDesignDocCreated = {
|
||||
asset: GameCreationAppAssetManifestEntry;
|
||||
manifest: GameCreationAppManifest;
|
||||
relativePath: string;
|
||||
imageIds: Array<string>;
|
||||
committedProjectRevision: number;
|
||||
};
|
||||
|
||||
export type UiDesignDocInvoke = <T>(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
) => Promise<T>;
|
||||
|
||||
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<UiDesignResourceBridgeResult> {
|
||||
const normalizedPrototypeAssetId = prototypeAssetId.trim();
|
||||
if (!normalizedPrototypeAssetId) {
|
||||
throw new Error('UI 原型资产身份不能为空');
|
||||
expectedProjectId: string;
|
||||
images: Array<UiDesignDocImageReference>;
|
||||
invoke: UiDesignDocInvoke;
|
||||
}): Promise<UiDesignDocCreated> {
|
||||
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<UiDesignResourceBridgeResult>(
|
||||
'ensure_ui_design_resource_for_prototype',
|
||||
{
|
||||
input: {
|
||||
projectPath,
|
||||
expectedProjectId: manifest.projectId,
|
||||
prototypeAssetId: normalizedPrototypeAssetId,
|
||||
},
|
||||
},
|
||||
);
|
||||
return invoke<UiDesignDocCreated>('create_ui_design_doc_from_images', {
|
||||
input: { projectPath, expectedProjectId, images },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<UiEditorRoute | null>(
|
||||
null,
|
||||
);
|
||||
const autoOpenedUiWorkflowResourceRef = useRef<string | null>(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);
|
||||
|
||||
@@ -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-<stem>-<digest>.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`(表现与编排)
|
||||
|
||||
Reference in New Issue
Block a user