实现 UI 组件智能绑定
新增 Rust 组件绑定命令,向 LLM 提供真实界面与素材像素并接受完整 Component 参数。 拆分节点布局与组件状态及 LLM 编辑权限,允许根节点承载组件并保持 Transform 只读。 接入五个素材一批的前端绑定循环、增量结果合并和调试入口。 生成更新后的 TypeScript 契约,并补齐 UI Editor 状态测试。
This commit is contained in:
@@ -131,6 +131,15 @@ async fn merge_ui(state: ui_editor::state::State) -> Result<ui_editor::commands:
|
||||
ui_editor::commands::merge_ui_impl(state).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn bind_components(
|
||||
project_path: String,
|
||||
state: ui_editor::state::State,
|
||||
sprite_ids: Vec<String>,
|
||||
) -> Result<ui_editor::commands::BindingDTO, String> {
|
||||
ui_editor::commands::bind_components_impl(project_path, state, sprite_ids).await
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct InitLocalProjectResult {
|
||||
@@ -2267,6 +2276,7 @@ fn main() {
|
||||
suggest_ui_design_semantic,
|
||||
recognize_ui,
|
||||
merge_ui,
|
||||
bind_components,
|
||||
generate_platform_art_asset,
|
||||
open_canvas_project,
|
||||
get_game_creation_agent_capabilities,
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
use crate::config::build_game_creator_llm_client_from_config;
|
||||
use crate::ui_editor::commands::utils::strict_json_schema;
|
||||
use crate::ui_editor::component::Component;
|
||||
use crate::ui_editor::layout::node::{Node, StageStatus};
|
||||
use crate::ui_editor::state::State;
|
||||
use crate::ui_editor::utils::{NodeId, SpriteAssetId};
|
||||
use base64::Engine as _;
|
||||
use platform_llm::{
|
||||
LlmFunctionTool, LlmMessage, LlmMessageContentPart, LlmRunRequest, LlmToolChoice,
|
||||
};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use ts_rs::TS;
|
||||
|
||||
pub const ASSET_BATCH_SIZE: usize = 5;
|
||||
|
||||
const SYSTEM_PROMPT: &str = r#"
|
||||
你是游戏 UI 组件绑定器。你会看到全部 UI 参考图、可编辑节点说明,以及本批独立素材的真实像素。
|
||||
|
||||
只对视觉上确实需要改变组件的节点返回 changes;未返回的节点必须保持不变。每个 change 的 components 是该节点完整的新渲染栈,空数组表示明确清空。数组顺序从底到顶渲染。
|
||||
|
||||
只能返回当前正式 Component 契约中的 Image 和 Text。对每个 Component,直接完整返回其全部参数;不要省略、默认化或由你之外的代码补齐参数。Image 的 target_graphic 必须是本批输入的素材 id;无法唯一匹配时返回 null,并把状态设为 NeedReview,说明中文原因。Text 写可见文本;动态数值也使用 Text。纯结构节点可以返回空数组并标为 Passed。
|
||||
|
||||
不得返回坐标、Transform、children、节点名称、权限或任何未定义字段。面向用户的 reason 使用中文。
|
||||
"#;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
enum DraftStatus {
|
||||
Passed,
|
||||
NeedReview(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
struct BindingChangeDraft {
|
||||
node_id: NodeId,
|
||||
components: Vec<Component>,
|
||||
components_status: DraftStatus,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
struct BindingResponse {
|
||||
changes: Vec<BindingChangeDraft>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||
pub struct BindingChange {
|
||||
pub node_id: NodeId,
|
||||
pub components: Vec<Component>,
|
||||
pub components_status: StageStatus,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||
pub struct BindingDTO {
|
||||
pub changes: Vec<BindingChange>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct EditableNodeContext<'a> {
|
||||
node_id: &'a NodeId,
|
||||
name: &'a str,
|
||||
description: &'a str,
|
||||
components: &'a [Component],
|
||||
}
|
||||
|
||||
fn binding_json_schema() -> Result<serde_json::Value, String> {
|
||||
strict_json_schema::<BindingResponse>()
|
||||
}
|
||||
|
||||
fn image_data_url(path: &Path, bytes: &[u8]) -> Result<String, String> {
|
||||
let mime = match path.extension().and_then(|value| value.to_str()) {
|
||||
Some("png") => "image/png",
|
||||
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||
Some("webp") => "image/webp",
|
||||
_ => return Err(format!("不支持的图片格式:{}", path.display())),
|
||||
};
|
||||
Ok(format!(
|
||||
"data:{mime};base64,{}",
|
||||
base64::engine::general_purpose::STANDARD.encode(bytes)
|
||||
))
|
||||
}
|
||||
|
||||
fn collect_editable_nodes<'a>(node: &'a Node, output: &mut Vec<EditableNodeContext<'a>>) {
|
||||
if node.metadata.allow_llm_edit_component {
|
||||
output.push(EditableNodeContext {
|
||||
node_id: &node.id,
|
||||
name: &node.metadata.name,
|
||||
description: &node.metadata.description,
|
||||
components: &node.components,
|
||||
});
|
||||
}
|
||||
for child in &node.children {
|
||||
collect_editable_nodes(child, output);
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_and_materialize(
|
||||
changes: Vec<BindingChangeDraft>,
|
||||
editable_ids: &HashSet<NodeId>,
|
||||
batch_sprite_ids: &HashSet<SpriteAssetId>,
|
||||
) -> Result<BindingDTO, String> {
|
||||
let mut changed_ids = HashSet::new();
|
||||
let mut materialized = Vec::with_capacity(changes.len());
|
||||
for change in changes {
|
||||
if !editable_ids.contains(&change.node_id) {
|
||||
return Err(format!(
|
||||
"组件绑定返回了未授权节点:{}",
|
||||
change.node_id.as_str()
|
||||
));
|
||||
}
|
||||
if !changed_ids.insert(change.node_id.clone()) {
|
||||
return Err(format!("组件绑定重复返回节点:{}", change.node_id.as_str()));
|
||||
}
|
||||
for component in &change.components {
|
||||
if let Component::Image(image) = component {
|
||||
if image
|
||||
.target_graphic
|
||||
.as_ref()
|
||||
.is_some_and(|id| !batch_sprite_ids.contains(id))
|
||||
{
|
||||
return Err("组件绑定引用了不属于当前批次的独立素材".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
let components = change.components;
|
||||
let components_status = match change.components_status {
|
||||
DraftStatus::Passed => StageStatus::Passed,
|
||||
DraftStatus::NeedReview(reason) if reason.trim().is_empty() => {
|
||||
return Err("组件待审状态必须包含原因".to_string())
|
||||
}
|
||||
DraftStatus::NeedReview(reason) => StageStatus::NeedReview(reason),
|
||||
};
|
||||
materialized.push(BindingChange {
|
||||
node_id: change.node_id,
|
||||
components,
|
||||
components_status,
|
||||
});
|
||||
}
|
||||
Ok(BindingDTO {
|
||||
changes: materialized,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn bind_components_impl(
|
||||
project_path: String,
|
||||
state: State,
|
||||
sprite_ids: Vec<String>,
|
||||
) -> Result<BindingDTO, String> {
|
||||
if sprite_ids.is_empty() {
|
||||
return Err("请先导入至少一个独立素材".to_string());
|
||||
}
|
||||
if sprite_ids.len() > ASSET_BATCH_SIZE {
|
||||
return Err(format!("单次组件绑定最多 {} 个独立素材", ASSET_BATCH_SIZE));
|
||||
}
|
||||
let sprite_ids = sprite_ids
|
||||
.into_iter()
|
||||
.map(SpriteAssetId::new)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|error| format!("独立素材 ID 无效:{error}"))?;
|
||||
let batch_sprite_ids = sprite_ids.iter().cloned().collect::<HashSet<_>>();
|
||||
if batch_sprite_ids.len() != sprite_ids.len() {
|
||||
return Err("独立素材批次包含重复 ID".to_string());
|
||||
}
|
||||
if state.ui_design_images.is_empty() || state.ui_design_images.len() > 4 {
|
||||
return Err("组件绑定需要 1 至 4 张界面图".to_string());
|
||||
}
|
||||
let mut editable_nodes = Vec::new();
|
||||
for tree in &state.ui_trees {
|
||||
if !state.ui_design_images.contains_key(&tree.src_ui_design) {
|
||||
return Err("UI 树引用的界面图不存在".to_string());
|
||||
}
|
||||
collect_editable_nodes(&tree.root, &mut editable_nodes);
|
||||
}
|
||||
if editable_nodes.is_empty() {
|
||||
return Err("当前没有允许 LLM 修改组件的节点".to_string());
|
||||
}
|
||||
let editable_ids = editable_nodes
|
||||
.iter()
|
||||
.map(|node| node.node_id.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
if editable_ids.len() != editable_nodes.len() {
|
||||
return Err("UI 树包含重复节点 ID".to_string());
|
||||
}
|
||||
let root = Path::new(project_path.trim());
|
||||
let mut parts = Vec::with_capacity(state.ui_design_images.len() * 2 + sprite_ids.len() * 2 + 1);
|
||||
let node_context = serde_json::to_string(&editable_nodes)
|
||||
.map_err(|error| format!("序列化可编辑节点失败:{error}"))?;
|
||||
parts.push(LlmMessageContentPart::InputText {
|
||||
text: format!("可编辑节点:{node_context}"),
|
||||
});
|
||||
for (id, image) in &state.ui_design_images {
|
||||
let absolute = crate::project::resolve_local_project_path(root, &image.path)?;
|
||||
let bytes = std::fs::read(&absolute).map_err(|error| format!("读取界面图失败:{error}"))?;
|
||||
parts.push(LlmMessageContentPart::InputText {
|
||||
text: format!(
|
||||
"UI_REFERENCE id={} pixel_size={:?}",
|
||||
id.as_str(),
|
||||
image.pixel_size
|
||||
),
|
||||
});
|
||||
parts.push(LlmMessageContentPart::InputImage {
|
||||
image_url: image_data_url(&absolute, &bytes)?,
|
||||
});
|
||||
}
|
||||
for id in &sprite_ids {
|
||||
let sprite = state
|
||||
.sprite_assets
|
||||
.get(id)
|
||||
.ok_or_else(|| format!("独立素材不存在:{}", id.as_str()))?;
|
||||
let absolute = crate::project::resolve_local_project_path(root, &sprite.path)?;
|
||||
let bytes =
|
||||
std::fs::read(&absolute).map_err(|error| format!("读取独立素材失败:{error}"))?;
|
||||
parts.push(LlmMessageContentPart::InputText {
|
||||
text: format!(
|
||||
"SPRITE id={} name={} asset_type={} pixel_size={:?}",
|
||||
id.as_str(),
|
||||
sprite.metadata.name,
|
||||
sprite.metadata.asset_type,
|
||||
sprite.pixel_size
|
||||
),
|
||||
});
|
||||
parts.push(LlmMessageContentPart::InputImage {
|
||||
image_url: image_data_url(&absolute, &bytes)?,
|
||||
});
|
||||
}
|
||||
let client = build_game_creator_llm_client_from_config()?;
|
||||
let tool = LlmFunctionTool::new(
|
||||
"bind_ui_components",
|
||||
"根据 UI 参考图和当前批次独立素材,返回需要修改的节点组件",
|
||||
binding_json_schema()?,
|
||||
)
|
||||
.with_strict(true);
|
||||
let response = client
|
||||
.run(
|
||||
LlmRunRequest::new(vec![
|
||||
LlmMessage::system(SYSTEM_PROMPT),
|
||||
LlmMessage::user_multimodal(parts),
|
||||
])
|
||||
.with_function_tools(vec![tool])
|
||||
.with_tool_choice(LlmToolChoice::Required),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| format!("组件绑定失败:{error}"))?;
|
||||
let call = response
|
||||
.tool_calls
|
||||
.iter()
|
||||
.find(|call| call.name == "bind_ui_components")
|
||||
.ok_or_else(|| "LLM 未返回 bind_ui_components 工具调用".to_string())?;
|
||||
let parsed = serde_json::from_str::<BindingResponse>(&call.arguments)
|
||||
.map_err(|error| format!("组件绑定工具参数无效:{error}"))?;
|
||||
let result = validate_and_materialize(parsed.changes, &editable_ids, &batch_sprite_ids)?;
|
||||
eprintln!(
|
||||
"ui_binding.completed ui_images={} sprites={} editable_nodes={} changes={}",
|
||||
state.ui_design_images.len(),
|
||||
sprite_ids.len(),
|
||||
editable_nodes.len(),
|
||||
result.changes.len()
|
||||
);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ts_rs::{Config, TS};
|
||||
|
||||
fn id(value: &str) -> NodeId {
|
||||
NodeId::new(value).expect("valid id")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn materialization_rejects_unapproved_node_and_foreign_sprite() {
|
||||
let editable = HashSet::from([id("editable")]);
|
||||
let sprites = HashSet::from([SpriteAssetId::new("sprite").expect("valid sprite")]);
|
||||
let unapproved = BindingChangeDraft {
|
||||
node_id: id("other"),
|
||||
components: Vec::new(),
|
||||
components_status: DraftStatus::Passed,
|
||||
};
|
||||
assert!(validate_and_materialize(vec![unapproved], &editable, &sprites).is_err());
|
||||
|
||||
let foreign = BindingChangeDraft {
|
||||
node_id: id("editable"),
|
||||
components: vec![Component::Image(
|
||||
crate::ui_editor::component::image::ImageComponent {
|
||||
target_graphic: Some(SpriteAssetId::new("foreign").expect("valid sprite")),
|
||||
image_type: crate::ui_editor::component::image::ImageType::Simple {
|
||||
preserve_aspect: false,
|
||||
},
|
||||
},
|
||||
)],
|
||||
components_status: DraftStatus::Passed,
|
||||
};
|
||||
assert!(validate_and_materialize(vec![foreign], &editable, &sprites).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn materialization_preserves_changed_only_empty_component_lists() {
|
||||
let editable = HashSet::from([id("editable")]);
|
||||
let result = validate_and_materialize(
|
||||
vec![BindingChangeDraft {
|
||||
node_id: id("editable"),
|
||||
components: Vec::new(),
|
||||
components_status: DraftStatus::Passed,
|
||||
}],
|
||||
&editable,
|
||||
&HashSet::new(),
|
||||
)
|
||||
.expect("valid changed-only clear");
|
||||
assert_eq!(result.changes.len(), 1);
|
||||
assert!(result.changes[0].components.is_empty());
|
||||
assert_eq!(result.changes[0].components_status, StageStatus::Passed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_ui_editor_types() {
|
||||
let config = Config::from_env();
|
||||
BindingDTO::export_all(&config).expect("BindingDTO TypeScript export succeeds");
|
||||
Node::export_all(&config).expect("Node TypeScript export succeeds");
|
||||
}
|
||||
}
|
||||
@@ -160,7 +160,7 @@ mod priority {
|
||||
mod materialize {
|
||||
use super::llm_contract::Node;
|
||||
use crate::ui_editor::layout::node::{
|
||||
Node as LayoutNode, NodeMetadata, NodeSource, NodeStatus,
|
||||
Node as LayoutNode, NodeMetadata, NodeSource, StageStatus,
|
||||
};
|
||||
use crate::ui_editor::layout::transform::Transform;
|
||||
use crate::ui_editor::state::{State, UITree};
|
||||
@@ -281,7 +281,10 @@ mod materialize {
|
||||
metadata: NodeMetadata {
|
||||
name: container_name,
|
||||
description: container_description,
|
||||
status: NodeStatus::Passed,
|
||||
layout_status: StageStatus::Passed,
|
||||
components_status: StageStatus::Pending,
|
||||
allow_llm_edit_layout: true,
|
||||
allow_llm_edit_component: true,
|
||||
source: NodeSource::Llm,
|
||||
},
|
||||
components: Vec::new(),
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
pub mod binding;
|
||||
pub mod merge;
|
||||
pub mod recognition;
|
||||
pub mod ui_design_suggestion;
|
||||
pub mod utils;
|
||||
pub mod binding;
|
||||
|
||||
pub(crate) use binding::bind_components_impl;
|
||||
pub use binding::BindingDTO;
|
||||
pub(crate) use merge::merge_ui_impl;
|
||||
pub use merge::MergeDTO;
|
||||
pub(crate) use recognition::recognize_ui_impl;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::config::build_game_creator_llm_client_from_config;
|
||||
use crate::ui_editor::commands::utils::strict_json_schema;
|
||||
use crate::ui_editor::layout::dimension::UIRect;
|
||||
use crate::ui_editor::layout::node::{Node as LayoutNode, NodeMetadata, NodeSource, NodeStatus};
|
||||
use crate::ui_editor::layout::node::{Node as LayoutNode, NodeMetadata, NodeSource, StageStatus};
|
||||
use crate::ui_editor::layout::transform::Transform;
|
||||
use crate::ui_editor::resource::ui_design_image::UIDesignImage;
|
||||
use crate::ui_editor::state::{State, UITree};
|
||||
@@ -252,11 +252,11 @@ fn convert_node(
|
||||
let mut transform = Transform::new(anchor_min, anchor_max, Vector2::zeros(), Vector2::zeros());
|
||||
transform.set_resolved_rect(&parent_rect, target_rect);
|
||||
let status = if blocked {
|
||||
NodeStatus::Blocked
|
||||
StageStatus::Blocked
|
||||
} else {
|
||||
match &source.confidence {
|
||||
Confidence::Confident => NodeStatus::Passed,
|
||||
Confidence::UnSure(reason) => NodeStatus::NeedReview(reason.clone()),
|
||||
Confidence::Confident => StageStatus::Passed,
|
||||
Confidence::UnSure(reason) => StageStatus::NeedReview(reason.clone()),
|
||||
}
|
||||
};
|
||||
let children = source
|
||||
@@ -270,7 +270,10 @@ fn convert_node(
|
||||
metadata: NodeMetadata {
|
||||
name: source.name.clone(),
|
||||
description: source.description.clone(),
|
||||
status,
|
||||
layout_status: status,
|
||||
components_status: StageStatus::Pending,
|
||||
allow_llm_edit_layout: true,
|
||||
allow_llm_edit_component: true,
|
||||
source: NodeSource::Llm,
|
||||
},
|
||||
// TODO: 等识别 DTO 增加 type 字段后,再映射 Image/Text Component。
|
||||
@@ -631,7 +634,10 @@ pub(crate) async fn recognize_ui_impl(
|
||||
metadata: NodeMetadata {
|
||||
name: "页面根节点".to_string(),
|
||||
description: String::new(),
|
||||
status: NodeStatus::Passed,
|
||||
layout_status: StageStatus::Passed,
|
||||
components_status: StageStatus::Pending,
|
||||
allow_llm_edit_layout: true,
|
||||
allow_llm_edit_component: true,
|
||||
source: NodeSource::System,
|
||||
},
|
||||
// TODO: root components can host page-level background content once component preview is supported.
|
||||
|
||||
@@ -5,25 +5,26 @@ use crate::ui_editor::layout::transform::Transform;
|
||||
use crate::ui_editor::resource::sprite::SpriteAsset;
|
||||
use crate::ui_editor::utils::SpriteAssetId;
|
||||
use nalgebra::Vector2;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
use typed_floats::tf32::StrictlyPositiveFinite;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)]
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, TS)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||
pub enum HorizontalFillOrigin {
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)]
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, TS)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||
pub enum VerticalFillOrigin {
|
||||
Bottom,
|
||||
Top,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)]
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, TS)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||
pub enum Radial90Origin {
|
||||
BottomLeft,
|
||||
@@ -32,7 +33,7 @@ pub enum Radial90Origin {
|
||||
BottomRight,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)]
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, TS)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||
pub enum Radial180Origin {
|
||||
Bottom,
|
||||
@@ -41,7 +42,7 @@ pub enum Radial180Origin {
|
||||
Right,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)]
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, TS)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||
pub enum Radial360Origin {
|
||||
Bottom,
|
||||
@@ -50,7 +51,7 @@ pub enum Radial360Origin {
|
||||
Left,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)]
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, TS)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||
pub enum FillMethod {
|
||||
Horizontal(HorizontalFillOrigin),
|
||||
@@ -79,7 +80,7 @@ fn validate_fill_amount(amount: f32) -> Result<(), ComponentValueError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize, TS)]
|
||||
#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, PartialEq, Serialize, TS)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||
pub enum ImageType {
|
||||
Simple {
|
||||
@@ -87,11 +88,13 @@ pub enum ImageType {
|
||||
},
|
||||
Sliced {
|
||||
fill_center: bool,
|
||||
#[schemars(with = "f32")]
|
||||
#[ts(as = "f32")]
|
||||
pixels_per_unit_multiplier: StrictlyPositiveFinite,
|
||||
},
|
||||
Tiled {
|
||||
fill_center: bool,
|
||||
#[schemars(with = "f32")]
|
||||
#[ts(as = "f32")]
|
||||
pixels_per_unit_multiplier: StrictlyPositiveFinite,
|
||||
},
|
||||
@@ -184,7 +187,7 @@ impl fmt::Display for SetNativeSizeError {
|
||||
impl Error for SetNativeSizeError {}
|
||||
|
||||
/// Image 渲染组件;布局由同一 Entity 上独立的 `Control` 提供。
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize, TS)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||
pub struct ImageComponent {
|
||||
pub target_graphic: Option<SpriteAssetId>,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
pub mod common;
|
||||
pub mod image;
|
||||
pub mod text;
|
||||
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)]
|
||||
#[derive(
|
||||
Clone, Debug, PartialEq, schemars::JsonSchema, serde::Deserialize, serde::Serialize, ts_rs::TS,
|
||||
)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||
pub enum Component {
|
||||
Image(image::ImageComponent),
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
use crate::ui_editor::component::common::error::ComponentValueError;
|
||||
use crate::ui_editor::resource::sprite::{Color, WHITE};
|
||||
use crate::ui_editor::utils::FontAssetId;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::num::NonZeroU32;
|
||||
use ts_rs::TS;
|
||||
use typed_floats::tf32::StrictlyPositiveFinite;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)]
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, TS)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||
pub enum FontStyle {
|
||||
Normal,
|
||||
@@ -15,7 +16,7 @@ pub enum FontStyle {
|
||||
BoldItalic,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)]
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, TS)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||
pub enum TextAlignment {
|
||||
UpperLeft,
|
||||
@@ -29,21 +30,21 @@ pub enum TextAlignment {
|
||||
LowerRight,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)]
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, TS)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||
pub enum HorizontalTextOverflow {
|
||||
Wrap,
|
||||
Overflow,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)]
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, TS)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||
pub enum VerticalTextOverflow {
|
||||
Truncate,
|
||||
Overflow,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize, TS)]
|
||||
#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, PartialEq, Serialize, TS)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||
pub struct BestFitFontSize {
|
||||
min: NonZeroU32,
|
||||
@@ -67,7 +68,7 @@ impl BestFitFontSize {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize, TS)]
|
||||
#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, PartialEq, Serialize, TS)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||
pub enum FontSizing {
|
||||
Fixed(NonZeroU32),
|
||||
@@ -81,7 +82,7 @@ impl Default for FontSizing {
|
||||
}
|
||||
|
||||
/// Text / Label 渲染组件;布局由同一 Entity 上独立的 `Control` 提供。
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize, TS)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||
pub struct TextComponent {
|
||||
pub content: String,
|
||||
@@ -93,6 +94,7 @@ pub struct TextComponent {
|
||||
pub alignment: TextAlignment,
|
||||
pub horizontal_overflow: HorizontalTextOverflow,
|
||||
pub vertical_overflow: VerticalTextOverflow,
|
||||
#[schemars(with = "f32")]
|
||||
#[ts(as = "f32")]
|
||||
pub line_spacing: StrictlyPositiveFinite,
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ pub enum NodeSource {
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||
pub enum NodeStatus {
|
||||
pub enum StageStatus {
|
||||
Pending,
|
||||
Passed,
|
||||
NeedReview(String), // reason inside
|
||||
Blocked,
|
||||
@@ -35,6 +36,9 @@ pub enum NodeStatus {
|
||||
pub struct NodeMetadata {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub status: NodeStatus,
|
||||
pub layout_status: StageStatus,
|
||||
pub components_status: StageStatus,
|
||||
pub allow_llm_edit_layout: bool,
|
||||
pub allow_llm_edit_component: bool,
|
||||
pub source: NodeSource,
|
||||
}
|
||||
|
||||
@@ -65,19 +65,19 @@ impl Default for SpriteBorder {
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||
pub struct SpriteAssetMetadata {
|
||||
name: String,
|
||||
pub(crate) name: String,
|
||||
// TODO
|
||||
asset_type: String,
|
||||
pub(crate) asset_type: String,
|
||||
}
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||
pub struct SpriteAsset {
|
||||
asset_id: SpriteAssetId,
|
||||
metadata: SpriteAssetMetadata,
|
||||
pub(crate) asset_id: SpriteAssetId,
|
||||
pub(crate) metadata: SpriteAssetMetadata,
|
||||
// TODO
|
||||
path: String,
|
||||
pub(crate) path: String,
|
||||
#[ts(as = "[f32; 2]")]
|
||||
pixel_size: Vector2<f32>,
|
||||
pub(crate) pixel_size: Vector2<f32>,
|
||||
#[ts(as = "f32")]
|
||||
pixels_per_unit: StrictlyPositiveFinite,
|
||||
border: SpriteBorder,
|
||||
|
||||
@@ -27,3 +27,5 @@
|
||||
- 各 AI 阶段使用独立的输入/输出契约;后续阶段显式消费前一阶段结果,不从隐含的临时上下文推断前置事实。
|
||||
- AI 阶段的结果通过命令返回并更新同一个内存 `State`;阶段内部 DTO 只用于当前调用,不成为额外持久状态或跨阶段隐式参数。
|
||||
- 阶段命令可以只返回该阶段的 DTO;调用方负责将 DTO 显式合并到同一个内存 `State`。仅用于调试的验证入口与正式工作流分离。
|
||||
|
||||
- 节点布局与组件是独立通道:`layout_status` / `components_status` 分别记录阶段状态,`allow_llm_edit_layout` / `allow_llm_edit_component` 分别授权 LLM 修改;人工编辑不隐式改变状态或授权。页面根节点可承载组件,但其 Transform 不可修改。
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { BindingDTO } from './types/BindingDTO';
|
||||
import type { Node } from './types/Node';
|
||||
import type { State } from './types/State';
|
||||
|
||||
function applyChanges(node: Node, result: BindingDTO): void {
|
||||
const change = result.changes.find((candidate) => candidate.node_id === node.id);
|
||||
if (change) {
|
||||
node.components = structuredClone(change.components);
|
||||
node.metadata.components_status = structuredClone(change.components_status);
|
||||
}
|
||||
for (const child of node.children) applyChanges(child, result);
|
||||
}
|
||||
|
||||
/** Applies only explicit component changes; omitted nodes remain untouched. */
|
||||
export function applyBindingResult(state: State, result: BindingDTO): State {
|
||||
const next = structuredClone(state);
|
||||
for (const tree of next.ui_trees) applyChanges(tree.root, result);
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { Component } from "./Component";
|
||||
import type { NodeId } from "./NodeId";
|
||||
import type { StageStatus } from "./StageStatus";
|
||||
|
||||
export type BindingChange = { node_id: NodeId, components: Array<Component>, components_status: StageStatus, };
|
||||
@@ -0,0 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { BindingChange } from "./BindingChange";
|
||||
|
||||
export type BindingDTO = { changes: Array<BindingChange>, };
|
||||
@@ -1,5 +1,5 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { NodeSource } from "./NodeSource";
|
||||
import type { NodeStatus } from "./NodeStatus";
|
||||
import type { StageStatus } from "./StageStatus";
|
||||
|
||||
export type NodeMetadata = { name: string, description: string, status: NodeStatus, source: NodeSource, };
|
||||
export type NodeMetadata = { name: string, description: string, layout_status: StageStatus, components_status: StageStatus, allow_llm_edit_layout: boolean, allow_llm_edit_component: boolean, source: NodeSource, };
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type NodeStatus = "Passed" | { "NeedReview": string } | "Blocked";
|
||||
export type StageStatus = "Pending" | "Passed" | { "NeedReview": string } | "Blocked";
|
||||
@@ -5,6 +5,7 @@ import type { Component } from './types/Component';
|
||||
import type { Node } from './types/Node';
|
||||
import type { NodeId } from './types/NodeId';
|
||||
import type { NodeMetadata } from './types/NodeMetadata';
|
||||
import type { StageStatus } from './types/StageStatus';
|
||||
import type { SpriteAsset } from './types/SpriteAsset';
|
||||
import type { SpriteAssetId } from './types/SpriteAssetId';
|
||||
import type { SpriteBorder } from './types/SpriteBorder';
|
||||
@@ -35,7 +36,15 @@ export type UiEditorOperationResult<T = undefined> =
|
||||
type UiEditorOperationFailure = Extract<UiEditorOperationResult, { ok: false }>;
|
||||
|
||||
export type NodeMetadataPatch = Partial<
|
||||
Pick<NodeMetadata, 'name' | 'description'>
|
||||
Pick<
|
||||
NodeMetadata,
|
||||
| 'name'
|
||||
| 'description'
|
||||
| 'layout_status'
|
||||
| 'components_status'
|
||||
| 'allow_llm_edit_layout'
|
||||
| 'allow_llm_edit_component'
|
||||
>
|
||||
>;
|
||||
|
||||
export type ComponentIndex = number;
|
||||
@@ -90,7 +99,10 @@ function createPageRoot(state: State): Node {
|
||||
metadata: {
|
||||
name: '页面根节点',
|
||||
description: '',
|
||||
status: 'Passed',
|
||||
layout_status: 'Passed',
|
||||
components_status: 'Pending',
|
||||
allow_llm_edit_layout: true,
|
||||
allow_llm_edit_component: true,
|
||||
source: 'System',
|
||||
},
|
||||
components: [],
|
||||
@@ -110,7 +122,10 @@ function createHumanNode(state: State): Node {
|
||||
metadata: {
|
||||
name: '新节点',
|
||||
description: '',
|
||||
status: { NeedReview: '手动新增,待编辑' },
|
||||
layout_status: 'Passed',
|
||||
components_status: 'Pending',
|
||||
allow_llm_edit_layout: true,
|
||||
allow_llm_edit_component: true,
|
||||
source: 'Human',
|
||||
},
|
||||
components: [],
|
||||
@@ -882,8 +897,6 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
|
||||
if (!tree) return { ok: false, reason: 'missing' };
|
||||
const location = findNodeLocation(tree.root, nodeId);
|
||||
if (!location) return { ok: false, reason: 'missing' };
|
||||
if (location.node.id === tree.root.id)
|
||||
return { ok: false, reason: 'invalid' };
|
||||
const next = cloneState(current);
|
||||
const nextTree = next.ui_trees.find(
|
||||
(candidate) => candidate.src_ui_design === treeId,
|
||||
@@ -919,10 +932,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
|
||||
if (!tree) return { ok: false, reason: 'missing' };
|
||||
const location = findNodeLocation(tree.root, nodeId);
|
||||
if (!location) return { ok: false, reason: 'missing' };
|
||||
if (
|
||||
location.node.id === tree.root.id ||
|
||||
index > location.node.components.length
|
||||
) {
|
||||
if (index > location.node.components.length) {
|
||||
return { ok: false, reason: 'invalid' };
|
||||
}
|
||||
const next = cloneState(current);
|
||||
@@ -955,10 +965,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
|
||||
if (!tree) return { ok: false, reason: 'missing' };
|
||||
const location = findNodeLocation(tree.root, nodeId);
|
||||
if (!location) return { ok: false, reason: 'missing' };
|
||||
if (
|
||||
location.node.id === tree.root.id ||
|
||||
index >= location.node.components.length
|
||||
) {
|
||||
if (index >= location.node.components.length) {
|
||||
return { ok: false, reason: 'invalid' };
|
||||
}
|
||||
const next = cloneState(current);
|
||||
@@ -999,7 +1006,6 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
|
||||
const location = findNodeLocation(tree.root, nodeId);
|
||||
if (!location) return { ok: false, reason: 'missing' };
|
||||
if (
|
||||
location.node.id === tree.root.id ||
|
||||
fromIndex >= location.node.components.length ||
|
||||
toIndex >= location.node.components.length
|
||||
) {
|
||||
@@ -1045,6 +1051,14 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
|
||||
if (patch.name !== undefined) node.metadata.name = patch.name;
|
||||
if (patch.description !== undefined)
|
||||
node.metadata.description = patch.description;
|
||||
if (patch.layout_status !== undefined)
|
||||
node.metadata.layout_status = patch.layout_status;
|
||||
if (patch.components_status !== undefined)
|
||||
node.metadata.components_status = patch.components_status;
|
||||
if (patch.allow_llm_edit_layout !== undefined)
|
||||
node.metadata.allow_llm_edit_layout = patch.allow_llm_edit_layout;
|
||||
if (patch.allow_llm_edit_component !== undefined)
|
||||
node.metadata.allow_llm_edit_component = patch.allow_llm_edit_component;
|
||||
commit(next);
|
||||
return { ok: true, value: undefined };
|
||||
},
|
||||
|
||||
+95
-4
@@ -67,7 +67,8 @@ export function InspectorSidebar({
|
||||
<NodeInspector
|
||||
node={view.node}
|
||||
parentSize={view.parentSize}
|
||||
readOnly={
|
||||
readOnly={controller.editor.isLocked}
|
||||
transformReadOnly={
|
||||
controller.editor.isLocked ||
|
||||
view.node.id === controller.treeForActiveImage?.root.id
|
||||
}
|
||||
@@ -189,6 +190,7 @@ function NodeInspector({
|
||||
node,
|
||||
parentSize,
|
||||
readOnly,
|
||||
transformReadOnly,
|
||||
keepChildrenUnchanged,
|
||||
onKeepChildrenUnchangedChange,
|
||||
onMetadataChange,
|
||||
@@ -203,6 +205,7 @@ function NodeInspector({
|
||||
node: SelectedNode;
|
||||
parentSize: UiEditorPageController['selectedNodeParentSize'];
|
||||
readOnly: boolean;
|
||||
transformReadOnly: boolean;
|
||||
keepChildrenUnchanged: boolean;
|
||||
onKeepChildrenUnchangedChange: (value: boolean) => void;
|
||||
onMetadataChange: UiEditorPageController['setNodeMetadata'];
|
||||
@@ -224,7 +227,7 @@ function NodeInspector({
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={!keepChildrenUnchanged}
|
||||
disabled={readOnly}
|
||||
disabled={transformReadOnly}
|
||||
onClick={() => onKeepChildrenUnchangedChange(false)}
|
||||
className={`flex-1 rounded-md px-2 py-1.5 transition ${!keepChildrenUnchanged ? 'bg-blue-600 text-white' : 'text-(--platform-text-soft) hover:bg-black/5'} disabled:cursor-not-allowed disabled:opacity-50`}
|
||||
>
|
||||
@@ -233,7 +236,7 @@ function NodeInspector({
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={keepChildrenUnchanged}
|
||||
disabled={readOnly}
|
||||
disabled={transformReadOnly}
|
||||
onClick={() => onKeepChildrenUnchangedChange(true)}
|
||||
className={`flex-1 rounded-md px-2 py-1.5 transition ${keepChildrenUnchanged ? 'bg-orange-500 text-white' : 'text-(--platform-text-soft) hover:bg-black/5'} disabled:cursor-not-allowed disabled:opacity-50`}
|
||||
>
|
||||
@@ -260,10 +263,52 @@ function NodeInspector({
|
||||
组件:{node.components.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<NodeStageSelect
|
||||
label="布局状态"
|
||||
value={node.metadata.layout_status}
|
||||
disabled={readOnly}
|
||||
onChange={(layout_status) => onMetadataChange({ layout_status })}
|
||||
/>
|
||||
<NodeStageSelect
|
||||
label="组件状态"
|
||||
value={node.metadata.components_status}
|
||||
disabled={readOnly}
|
||||
onChange={(components_status) =>
|
||||
onMetadataChange({ components_status })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<label className="flex items-center gap-2 rounded-lg border border-(--platform-subpanel-border) bg-white/35 p-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={node.metadata.allow_llm_edit_layout}
|
||||
disabled={readOnly}
|
||||
onChange={(event) =>
|
||||
onMetadataChange({ allow_llm_edit_layout: event.target.checked })
|
||||
}
|
||||
/>
|
||||
允许 LLM 修改布局
|
||||
</label>
|
||||
<label className="flex items-center gap-2 rounded-lg border border-(--platform-subpanel-border) bg-white/35 p-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={node.metadata.allow_llm_edit_component}
|
||||
disabled={readOnly}
|
||||
onChange={(event) =>
|
||||
onMetadataChange({
|
||||
allow_llm_edit_component: event.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
允许 LLM 修改组件
|
||||
</label>
|
||||
</div>
|
||||
<TransformEditor
|
||||
transform={node.transform}
|
||||
parentSize={parentSize}
|
||||
readOnly={readOnly}
|
||||
readOnly={transformReadOnly}
|
||||
onChange={onTransformChange}
|
||||
/>
|
||||
<ComponentPanel
|
||||
@@ -281,6 +326,52 @@ function NodeInspector({
|
||||
);
|
||||
}
|
||||
|
||||
function NodeStageSelect({
|
||||
label,
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: SelectedNode['metadata']['layout_status'];
|
||||
disabled: boolean;
|
||||
onChange: (value: SelectedNode['metadata']['layout_status']) => void;
|
||||
}) {
|
||||
const kind =
|
||||
typeof value === 'string'
|
||||
? value
|
||||
: 'NeedReview' in value
|
||||
? 'NeedReview'
|
||||
: 'Blocked';
|
||||
return (
|
||||
<label className="text-[10px] text-(--platform-text-soft)">
|
||||
{label}
|
||||
<select
|
||||
className={INSPECTOR_INPUT_CLASS_NAME}
|
||||
value={kind}
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
onChange(
|
||||
next === 'NeedReview'
|
||||
? { NeedReview: '待审' }
|
||||
: next === 'Blocked'
|
||||
? 'Blocked'
|
||||
: next === 'Pending'
|
||||
? 'Pending'
|
||||
: 'Passed',
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value="Pending">待处理</option>
|
||||
<option value="Passed">已通过</option>
|
||||
<option value="NeedReview">待审</option>
|
||||
<option value="Blocked">已阻塞</option>
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function SpriteInspector({
|
||||
sprite,
|
||||
spriteId,
|
||||
|
||||
@@ -40,6 +40,20 @@ export default function UiEditorPage({
|
||||
{controller.mergeStatus}
|
||||
</p>
|
||||
) : null}
|
||||
{controller.bindingStatus ? (
|
||||
<p className="rounded-lg border border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) px-3 py-2 text-xs shadow-lg">
|
||||
{controller.bindingStatus}
|
||||
</p>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full bg-emerald-600 px-4 py-3 text-sm font-semibold text-white shadow-lg transition hover:bg-emerald-700 disabled:cursor-wait disabled:opacity-60"
|
||||
onClick={() => void controller.bindComponents()}
|
||||
disabled={controller.isBinding || controller.editor.isLocked}
|
||||
title="调试:绑定组件"
|
||||
>
|
||||
{controller.isBinding ? '组件绑定中…' : '绑定组件'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full bg-violet-600 px-4 py-3 text-sm font-semibold text-white shadow-lg transition hover:bg-violet-700 disabled:cursor-wait disabled:opacity-60"
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
} from '../../features/ui-editor/prerequisites';
|
||||
import { applyMergeResult } from '../../features/ui-editor/merge';
|
||||
import { applyRecognitionResult } from '../../features/ui-editor/recognition';
|
||||
import { applyBindingResult } from '../../features/ui-editor/binding';
|
||||
import type { BindingDTO } from '../../features/ui-editor/types/BindingDTO';
|
||||
import type { Component } from '../../features/ui-editor/types/Component';
|
||||
import type { MergeDTO } from '../../features/ui-editor/types/MergeDTO';
|
||||
import type { Node as UiNode } from '../../features/ui-editor/types/Node';
|
||||
@@ -39,6 +41,8 @@ import {
|
||||
type UiEditorToolId,
|
||||
} from './model';
|
||||
|
||||
const ASSET_BATCH_SIZE = 5;
|
||||
|
||||
export function useUiEditorPage(projectPath: string) {
|
||||
const editor = useUiEditorState(EMPTY_UI_EDITOR_STATE);
|
||||
const [activeTool, setActiveTool] = useState<UiEditorToolId>('input');
|
||||
@@ -67,6 +71,8 @@ export function useUiEditorPage(projectPath: string) {
|
||||
);
|
||||
const [isMerging, setIsMerging] = useState(false);
|
||||
const [mergeStatus, setMergeStatus] = useState<string | null>(null);
|
||||
const [isBinding, setIsBinding] = useState(false);
|
||||
const [bindingStatus, setBindingStatus] = useState<string | null>(null);
|
||||
|
||||
const images = editor.state.ui_design_images;
|
||||
const sprites = editor.state.sprite_assets;
|
||||
@@ -504,6 +510,44 @@ export function useUiEditorPage(projectPath: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function bindComponents() {
|
||||
if (isBinding) return;
|
||||
setBindingStatus(null);
|
||||
setIsBinding(true);
|
||||
try {
|
||||
await editor.runWithStateLocked(async (snapshot) => {
|
||||
const allSpriteIds = Object.keys(snapshot.sprite_assets);
|
||||
const batches: string[][] = [];
|
||||
for (
|
||||
let index = 0;
|
||||
index < allSpriteIds.length;
|
||||
index += ASSET_BATCH_SIZE
|
||||
) {
|
||||
batches.push(
|
||||
allSpriteIds.slice(index, index + ASSET_BATCH_SIZE),
|
||||
);
|
||||
}
|
||||
if (batches.length === 0) batches.push([]);
|
||||
let current = snapshot;
|
||||
for (const [index, spriteIds] of batches.entries()) {
|
||||
setBindingStatus(`绑定组件中(${index + 1}/${batches.length})…`);
|
||||
const result = await invoke<BindingDTO>('bind_components', {
|
||||
projectPath,
|
||||
state: current,
|
||||
spriteIds,
|
||||
});
|
||||
current = applyBindingResult(current, result);
|
||||
editor.replaceState(current);
|
||||
}
|
||||
setBindingStatus(`组件绑定完成(${batches.length}/${batches.length})。`);
|
||||
});
|
||||
} catch (cause) {
|
||||
setBindingStatus(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setIsBinding(false);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
projectPath,
|
||||
editor,
|
||||
@@ -573,6 +617,9 @@ export function useUiEditorPage(projectPath: string) {
|
||||
isMerging,
|
||||
mergeStatus,
|
||||
mergeUi,
|
||||
isBinding,
|
||||
bindingStatus,
|
||||
bindComponents,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,15 @@ function nodeWithSprite(id: string): Node {
|
||||
offset_min: [0, 0],
|
||||
offset_max: [32, 32],
|
||||
},
|
||||
metadata: { name: 'Image', description: '', status: 'Passed', source: 'Llm' },
|
||||
metadata: {
|
||||
name: 'Image',
|
||||
description: '',
|
||||
layout_status: 'Passed',
|
||||
components_status: 'Pending',
|
||||
allow_llm_edit_layout: true,
|
||||
allow_llm_edit_component: true,
|
||||
source: 'Llm',
|
||||
},
|
||||
components: [
|
||||
{
|
||||
Image: {
|
||||
@@ -183,7 +191,10 @@ describe('useUiEditorState', () => {
|
||||
metadata: {
|
||||
name: '页面根节点',
|
||||
description: '',
|
||||
status: 'Passed',
|
||||
layout_status: 'Passed',
|
||||
components_status: 'Pending',
|
||||
allow_llm_edit_layout: true,
|
||||
allow_llm_edit_component: true,
|
||||
source: 'System',
|
||||
},
|
||||
components: [],
|
||||
|
||||
Reference in New Issue
Block a user