UI编辑器节点改为单组件模型
修改 Node、Binding DTO、识别与分离链路为 component 单字段 为识别和绑定 LLM 工具引入 PureNode/WithComponent 显式载荷 移除前端组件索引、排序和组件栈操作 同步生成 ts-rs TypeScript 类型、状态校验、测试与专题文档
This commit is contained in:
@@ -5,11 +5,9 @@ use crate::ui_editor::commands::utils::{
|
||||
strict_json_schema,
|
||||
};
|
||||
use crate::ui_editor::component::text::FontSource;
|
||||
use crate::ui_editor::component::Component;
|
||||
use crate::ui_editor::component::{Component, NodeComponent};
|
||||
use crate::ui_editor::layout::node::{Node, StageStatus};
|
||||
use crate::ui_editor::persistence::{
|
||||
UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE, UI_DESIGN_STATE_MAX_NODES,
|
||||
};
|
||||
use crate::ui_editor::persistence::UI_DESIGN_STATE_MAX_NODES;
|
||||
use crate::ui_editor::state::State;
|
||||
use crate::ui_editor::utils::{FontAssetId, NodeId, SpriteAssetId};
|
||||
use platform_llm::{
|
||||
@@ -31,10 +29,10 @@ const SYSTEM_PROMPT: &str = r#"
|
||||
你是游戏 UI 组件绑定器。你会看到全部 UI 参考图、可编辑节点说明,以及本批独立素材的真实像素。
|
||||
|
||||
* 只对视觉上确实需要改变组件的节点返回 changes;
|
||||
* 每个 change 的 components 是该节点完整的新渲染栈,空数组表示明确清空。数组顺序从底到顶渲染。
|
||||
* 对每个 Component,直接完整返回其全部参数.
|
||||
* 每个 change 的 component 是该节点完整的新组件;纯结构节点返回 "PureNode",有组件返回 {"WithComponent": <完整 Component>}。
|
||||
* 对 Component,直接完整返回其全部参数.
|
||||
* 有任何困难或者不确定把状态设为 NeedReview,说明中文原因。
|
||||
* 纯结构节点可以返回空数组并标为 NoProblem。
|
||||
* 纯结构节点可以返回 "PureNode" 并标为 NoProblem。
|
||||
* 容器背景等推荐使用Simple + preserve_aspect: false 实现与node大小一致
|
||||
* 面向用户的 reason 使用中文。
|
||||
|
||||
@@ -53,8 +51,8 @@ enum DraftStatus {
|
||||
#[schemars(deny_unknown_fields)]
|
||||
struct BindingChangeDraft {
|
||||
node_id: NodeId,
|
||||
components: Vec<Component>,
|
||||
components_status: DraftStatus,
|
||||
component: NodeComponent,
|
||||
component_status: DraftStatus,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema)]
|
||||
@@ -68,8 +66,8 @@ struct BindingResponse {
|
||||
#[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,
|
||||
pub component: NodeComponent,
|
||||
pub component_status: StageStatus,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
|
||||
@@ -83,7 +81,7 @@ struct EditableNodeContext<'a> {
|
||||
node_id: &'a NodeId,
|
||||
name: &'a str,
|
||||
description: &'a str,
|
||||
components: &'a [Component],
|
||||
component: Option<&'a Component>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -106,7 +104,7 @@ fn collect_editable_nodes<'a>(node: &'a Node, output: &mut Vec<EditableNodeConte
|
||||
node_id: &node.id,
|
||||
name: &node.metadata.name,
|
||||
description: &node.metadata.description,
|
||||
components: &node.components,
|
||||
component: node.component.as_ref(),
|
||||
});
|
||||
}
|
||||
for child in &node.children {
|
||||
@@ -172,14 +170,11 @@ fn validate_binding_response_shape(
|
||||
return Err(format!("组件绑定 changes 不能超过 {max_changes} 条"));
|
||||
}
|
||||
for change in changes {
|
||||
let components = change
|
||||
.get("components")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.ok_or_else(|| "组件绑定 change 缺少 components 数组".to_string())?;
|
||||
if components.len() > UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE {
|
||||
return Err(format!(
|
||||
"单个组件绑定栈不能超过 {UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE} 个组件"
|
||||
));
|
||||
if !change
|
||||
.as_object()
|
||||
.is_some_and(|object| object.contains_key("component"))
|
||||
{
|
||||
return Err("组件绑定 change 缺少 component 字段".to_string());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -212,7 +207,7 @@ fn validate_and_materialize(
|
||||
if !changed_ids.insert(change.node_id.clone()) {
|
||||
return Err(format!("组件绑定重复返回节点:{}", change.node_id.as_str()));
|
||||
}
|
||||
for component in &change.components {
|
||||
if let NodeComponent::WithComponent(component) = &change.component {
|
||||
match component {
|
||||
Component::Image(image) => {
|
||||
if image
|
||||
@@ -232,18 +227,22 @@ fn validate_and_materialize(
|
||||
}
|
||||
}
|
||||
}
|
||||
let components = change.components;
|
||||
let components_status = match change.components_status {
|
||||
let component_status = match change.component_status {
|
||||
DraftStatus::NoProblem => StageStatus::NoProblem,
|
||||
DraftStatus::NeedReview(reason) if reason.trim().is_empty() => {
|
||||
return Err("组件待审状态必须包含原因".to_string())
|
||||
}
|
||||
DraftStatus::NeedReview(reason) => StageStatus::NeedReview(reason),
|
||||
DraftStatus::NeedReview(reason) => {
|
||||
if matches!(&change.component, NodeComponent::PureNode) {
|
||||
return Err("纯结构节点不能标记为组件待审".to_string());
|
||||
}
|
||||
StageStatus::NeedReview(reason)
|
||||
}
|
||||
};
|
||||
materialized.push(BindingChange {
|
||||
node_id: change.node_id,
|
||||
components,
|
||||
components_status,
|
||||
component: change.component,
|
||||
component_status,
|
||||
});
|
||||
}
|
||||
Ok(BindingDTO {
|
||||
@@ -440,8 +439,8 @@ mod tests {
|
||||
]);
|
||||
let unapproved = BindingChangeDraft {
|
||||
node_id: id("other"),
|
||||
components: Vec::new(),
|
||||
components_status: DraftStatus::NoProblem,
|
||||
component: NodeComponent::PureNode,
|
||||
component_status: DraftStatus::NoProblem,
|
||||
};
|
||||
assert!(
|
||||
validate_and_materialize(vec![unapproved], &editable, &known, &HashSet::new()).is_err()
|
||||
@@ -450,7 +449,7 @@ mod tests {
|
||||
// References to sprites from another batch are allowed once they exist in the project.
|
||||
let other_batch = BindingChangeDraft {
|
||||
node_id: id("editable"),
|
||||
components: vec![Component::Image(
|
||||
component: NodeComponent::WithComponent(Component::Image(
|
||||
crate::ui_editor::component::image::ImageComponent {
|
||||
target_graphic: Some(
|
||||
SpriteAssetId::new("other-batch-sprite").expect("valid sprite"),
|
||||
@@ -459,8 +458,8 @@ mod tests {
|
||||
preserve_aspect: false,
|
||||
},
|
||||
},
|
||||
)],
|
||||
components_status: DraftStatus::NoProblem,
|
||||
)),
|
||||
component_status: DraftStatus::NoProblem,
|
||||
};
|
||||
assert!(
|
||||
validate_and_materialize(vec![other_batch], &editable, &known, &HashSet::new()).is_ok()
|
||||
@@ -469,15 +468,15 @@ mod tests {
|
||||
// References to sprites that do not exist in the project at all are still rejected.
|
||||
let unknown = BindingChangeDraft {
|
||||
node_id: id("editable"),
|
||||
components: vec![Component::Image(
|
||||
component: NodeComponent::WithComponent(Component::Image(
|
||||
crate::ui_editor::component::image::ImageComponent {
|
||||
target_graphic: Some(SpriteAssetId::new("unknown").expect("valid sprite")),
|
||||
image_type: crate::ui_editor::component::image::ImageType::Simple {
|
||||
preserve_aspect: false,
|
||||
},
|
||||
},
|
||||
)],
|
||||
components_status: DraftStatus::NoProblem,
|
||||
)),
|
||||
component_status: DraftStatus::NoProblem,
|
||||
};
|
||||
assert!(
|
||||
validate_and_materialize(vec![unknown], &editable, &known, &HashSet::new()).is_err()
|
||||
@@ -491,8 +490,8 @@ mod tests {
|
||||
text.font = FontSource::Bound(FontAssetId::new("unknown-font").expect("valid font"));
|
||||
let change = BindingChangeDraft {
|
||||
node_id: id("editable"),
|
||||
components: vec![Component::Text(text)],
|
||||
components_status: DraftStatus::NoProblem,
|
||||
component: NodeComponent::WithComponent(Component::Text(text)),
|
||||
component_status: DraftStatus::NoProblem,
|
||||
};
|
||||
|
||||
let error = validate_and_materialize(
|
||||
@@ -506,13 +505,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn materialization_preserves_changed_only_empty_component_lists() {
|
||||
fn materialization_preserves_pure_node_change() {
|
||||
let editable = HashSet::from([id("editable")]);
|
||||
let result = validate_and_materialize(
|
||||
vec![BindingChangeDraft {
|
||||
node_id: id("editable"),
|
||||
components: Vec::new(),
|
||||
components_status: DraftStatus::NoProblem,
|
||||
component: NodeComponent::PureNode,
|
||||
component_status: DraftStatus::NoProblem,
|
||||
}],
|
||||
&editable,
|
||||
&HashSet::new(),
|
||||
@@ -520,8 +519,28 @@ mod tests {
|
||||
)
|
||||
.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::NoProblem);
|
||||
assert!(matches!(
|
||||
result.changes[0].component,
|
||||
NodeComponent::PureNode
|
||||
));
|
||||
assert_eq!(result.changes[0].component_status, StageStatus::NoProblem);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn materialization_rejects_problematic_pure_node() {
|
||||
let editable = HashSet::from([id("editable")]);
|
||||
let error = validate_and_materialize(
|
||||
vec![BindingChangeDraft {
|
||||
node_id: id("editable"),
|
||||
component: NodeComponent::PureNode,
|
||||
component_status: DraftStatus::NeedReview("缺少可确认的组件".to_string()),
|
||||
}],
|
||||
&editable,
|
||||
&HashSet::new(),
|
||||
&HashSet::new(),
|
||||
)
|
||||
.expect_err("pure node cannot carry a component review status");
|
||||
assert!(error.contains("纯结构节点"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -575,20 +594,26 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binding_response_bounds_changes_and_each_component_stack() {
|
||||
fn binding_response_bounds_changes_and_uses_single_component_shape() {
|
||||
let too_many_changes = serde_json::json!({
|
||||
"changes": [{"components": []}, {"components": []}]
|
||||
"changes": [{"component": "PureNode"}, {"component": "PureNode"}]
|
||||
});
|
||||
assert!(validate_binding_response_shape(&too_many_changes, 1).is_err());
|
||||
|
||||
let too_many_components = serde_json::json!({
|
||||
let one_component = serde_json::json!({
|
||||
"changes": [{
|
||||
"components": (0..=UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE)
|
||||
.map(|_| serde_json::Value::Null)
|
||||
.collect::<Vec<_>>()
|
||||
"node_id": "editable",
|
||||
"component": "PureNode",
|
||||
"component_status": "NoProblem"
|
||||
}]
|
||||
});
|
||||
assert!(validate_binding_response_shape(&too_many_components, 1).is_err());
|
||||
assert!(validate_binding_response_shape(&one_component, 1).is_ok());
|
||||
let parsed = parse_binding_response(&one_component.to_string(), 1)
|
||||
.expect("explicit PureNode payload should parse");
|
||||
assert!(matches!(
|
||||
parsed.changes[0].component,
|
||||
NodeComponent::PureNode
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -295,12 +295,12 @@ mod materialize {
|
||||
name: container_name,
|
||||
description: container_description,
|
||||
layout_status: StageStatus::NoProblem,
|
||||
components_status: StageStatus::NoProblem,
|
||||
component_status: StageStatus::NoProblem,
|
||||
allow_llm_edit_layout: true,
|
||||
allow_llm_edit_component: true,
|
||||
source: NodeSource::Llm,
|
||||
},
|
||||
components: Vec::new(),
|
||||
component: None,
|
||||
children_display_mode: ChildrenDisplayMode::Exclusive,
|
||||
children: members.into_iter().map(|member| member.node).collect(),
|
||||
},
|
||||
@@ -542,12 +542,12 @@ mod tests {
|
||||
name: id.to_string(),
|
||||
description: String::new(),
|
||||
layout_status: StageStatus::NoProblem,
|
||||
components_status: StageStatus::NoProblem,
|
||||
component_status: StageStatus::NoProblem,
|
||||
allow_llm_edit_layout: true,
|
||||
allow_llm_edit_component: true,
|
||||
source: NodeSource::Human,
|
||||
},
|
||||
components: Vec::<Component>::new(),
|
||||
component: None,
|
||||
children_display_mode: ChildrenDisplayMode::Stack,
|
||||
children,
|
||||
}
|
||||
@@ -586,7 +586,7 @@ mod tests {
|
||||
ChildrenDisplayMode::Exclusive
|
||||
);
|
||||
assert_eq!(
|
||||
result.root.metadata.components_status,
|
||||
result.root.metadata.component_status,
|
||||
StageStatus::NoProblem
|
||||
);
|
||||
assert_eq!(result.root.children.len(), 2);
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::ui_editor::commands::utils::{
|
||||
parse_limited_llm_tool_arguments, read_ui_reference_image_data_url, request_ui_editor_llm,
|
||||
strict_json_schema,
|
||||
};
|
||||
use crate::ui_editor::component::Component;
|
||||
use crate::ui_editor::component::{Component, NodeComponent};
|
||||
use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
|
||||
use crate::ui_editor::layout::control_layout::ControlLayout;
|
||||
use crate::ui_editor::layout::dimension::UIRect;
|
||||
@@ -44,11 +44,11 @@ const SYSTEM_PROMPT: &str = r#"
|
||||
* 由于每个截图未必是完整的, 可能是局部的, 每棵树描述清楚每个截图上UI的层次结构即可
|
||||
* 不同树的共用框架/层次/...请使用使用相同的名称描述. 不同状态/变体名称使用相同的前缀, 用后缀区别
|
||||
* 粒度要求: 尽可能细致, 以可交互,方便程序化控制的最小单位为准. 包括不限于: icon, 进度条的底槽、填充和外框; slider的底槽, dragger等.
|
||||
* 为每个节点直接返回完整 components.
|
||||
无背景的逻辑容器返回空数组.
|
||||
* 为每个节点直接返回 component.
|
||||
无背景的逻辑容器返回 "PureNode",不要返回 null.
|
||||
有背景的容器推荐使用Simple+不锁定宽高比的Image component.
|
||||
目前我们只做识别, 不要求图片字体参数.
|
||||
每个节点当前最多返回一个 Image component 和一个 Text component。
|
||||
每个节点最多返回一个 component;需要多个视觉层时拆成多个节点。
|
||||
文字组件要求: 艺术字等作为图片组件, 其余正常文字要作为单独的节点识别.
|
||||
|
||||
"#;
|
||||
@@ -113,7 +113,7 @@ struct RecognitionNode {
|
||||
description: String,
|
||||
children: Vec<RecognitionNode>,
|
||||
confidence: Confidence,
|
||||
components: Vec<Component>,
|
||||
component: NodeComponent,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, JsonSchema)]
|
||||
@@ -312,12 +312,12 @@ fn convert_node(
|
||||
name: source.name.clone(),
|
||||
description: source.description.clone(),
|
||||
layout_status: status,
|
||||
components_status: StageStatus::NoProblem,
|
||||
component_status: StageStatus::NoProblem,
|
||||
allow_llm_edit_layout: true,
|
||||
allow_llm_edit_component: true,
|
||||
source: NodeSource::Llm,
|
||||
},
|
||||
components: source.components.clone(),
|
||||
component: source.component.clone().into_option(),
|
||||
children_display_mode: ChildrenDisplayMode::Stack,
|
||||
children,
|
||||
})
|
||||
@@ -325,40 +325,25 @@ fn convert_node(
|
||||
|
||||
fn validate_confidence(nodes: &[RecognitionNode]) -> Result<(), String> {
|
||||
for node in nodes {
|
||||
let image_count = node
|
||||
.components
|
||||
.iter()
|
||||
.filter(|component| matches!(component, Component::Image(_)))
|
||||
.count();
|
||||
let text_count = node
|
||||
.components
|
||||
.iter()
|
||||
.filter(|component| matches!(component, Component::Text(_)))
|
||||
.count();
|
||||
if image_count > 1 || text_count > 1 {
|
||||
return Err("单个节点当前最多包含一个 Image 和一个 Text component".to_string());
|
||||
}
|
||||
if node.components.iter().any(|component| {
|
||||
matches!(
|
||||
if let NodeComponent::WithComponent(component) = &node.component {
|
||||
if matches!(
|
||||
component,
|
||||
Component::Image(crate::ui_editor::component::image::ImageComponent {
|
||||
target_graphic: Some(_),
|
||||
..
|
||||
})
|
||||
)
|
||||
}) {
|
||||
return Err("识别阶段不能返回已绑定的 SpriteAssetId".to_string());
|
||||
}
|
||||
if node.components.iter().any(|component| {
|
||||
matches!(
|
||||
) {
|
||||
return Err("识别阶段不能返回已绑定的 SpriteAssetId".to_string());
|
||||
}
|
||||
if matches!(
|
||||
component,
|
||||
Component::Text(crate::ui_editor::component::text::TextComponent {
|
||||
font: crate::ui_editor::component::text::FontSource::Bound(_),
|
||||
..
|
||||
})
|
||||
)
|
||||
}) {
|
||||
return Err("识别阶段不能返回已绑定的字体素材".to_string());
|
||||
) {
|
||||
return Err("识别阶段不能返回已绑定的字体素材".to_string());
|
||||
}
|
||||
}
|
||||
if let Confidence::UnSure(reason) = &node.confidence {
|
||||
if reason.trim().is_empty() {
|
||||
@@ -424,7 +409,7 @@ mod tests {
|
||||
description: String::new(),
|
||||
children: Vec::new(),
|
||||
confidence: Confidence::Confident,
|
||||
components: Vec::new(),
|
||||
component: NodeComponent::PureNode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,7 +513,7 @@ mod tests {
|
||||
description: String::new(),
|
||||
children: Vec::new(),
|
||||
confidence: Confidence::UnSure(String::new()),
|
||||
components: Vec::new(),
|
||||
component: NodeComponent::PureNode,
|
||||
};
|
||||
assert!(validate_confidence(&[node]).is_err());
|
||||
}
|
||||
@@ -552,7 +537,7 @@ mod tests {
|
||||
description: String::new(),
|
||||
children: Vec::new(),
|
||||
confidence: Confidence::Confident,
|
||||
components: vec![Component::Text(text)],
|
||||
component: NodeComponent::WithComponent(Component::Text(text)),
|
||||
};
|
||||
assert!(validate_confidence(&[node]).is_err());
|
||||
}
|
||||
@@ -569,7 +554,7 @@ mod tests {
|
||||
converted.layout.transform.resolve(&root_rect),
|
||||
UIRect::new(Point2::new(50.0, 25.0), Vector2::new(100.0, 50.0)),
|
||||
);
|
||||
assert_eq!(converted.metadata.components_status, StageStatus::NoProblem);
|
||||
assert_eq!(converted.metadata.component_status, StageStatus::NoProblem);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -850,12 +835,12 @@ pub(crate) async fn recognize_ui_impl_with_provider(
|
||||
name: "页面根节点".to_string(),
|
||||
description: String::new(),
|
||||
layout_status: StageStatus::NoProblem,
|
||||
components_status: StageStatus::NoProblem,
|
||||
component_status: StageStatus::NoProblem,
|
||||
allow_llm_edit_layout: true,
|
||||
allow_llm_edit_component: true,
|
||||
source: NodeSource::System,
|
||||
},
|
||||
components: Vec::new(),
|
||||
component: None,
|
||||
children_display_mode: ChildrenDisplayMode::Stack,
|
||||
children,
|
||||
};
|
||||
|
||||
@@ -16,8 +16,8 @@ pub(crate) use workflow::separate_ui_impl;
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ui_editor::component::image::{ImageComponent, ImageType};
|
||||
use crate::ui_editor::component::Component;
|
||||
use crate::ui_editor::component::text::TextComponent;
|
||||
use crate::ui_editor::component::Component;
|
||||
use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
|
||||
use crate::ui_editor::layout::control_layout::ControlLayout;
|
||||
use crate::ui_editor::layout::node::Node;
|
||||
@@ -30,7 +30,7 @@ mod tests {
|
||||
use std::path::Path;
|
||||
use typed_floats::tf32::StrictlyPositiveFinite;
|
||||
|
||||
fn node(id: &str, components: Vec<Component>, children: Vec<Node>) -> Node {
|
||||
fn node(id: &str, component: Option<Component>, children: Vec<Node>) -> Node {
|
||||
Node {
|
||||
id: NodeId::new(id).unwrap(),
|
||||
layout: ControlLayout::default(),
|
||||
@@ -38,12 +38,12 @@ mod tests {
|
||||
name: id.to_string(),
|
||||
description: String::new(),
|
||||
layout_status: StageStatus::NoProblem,
|
||||
components_status: StageStatus::NoProblem,
|
||||
component_status: StageStatus::NoProblem,
|
||||
allow_llm_edit_layout: true,
|
||||
allow_llm_edit_component: true,
|
||||
source: NodeSource::Llm,
|
||||
},
|
||||
components,
|
||||
component,
|
||||
children_display_mode: ChildrenDisplayMode::Stack,
|
||||
children,
|
||||
}
|
||||
@@ -83,11 +83,11 @@ mod tests {
|
||||
});
|
||||
let root = node(
|
||||
"root",
|
||||
vec![],
|
||||
None,
|
||||
vec![node(
|
||||
"container",
|
||||
vec![],
|
||||
vec![node("image", vec![image], vec![])],
|
||||
None,
|
||||
vec![node("image", Some(image), vec![])],
|
||||
)],
|
||||
);
|
||||
let result = construct_separation_state(&state(root));
|
||||
@@ -102,7 +102,7 @@ mod tests {
|
||||
preserve_aspect: false,
|
||||
},
|
||||
});
|
||||
let root = node("root-image", vec![image], vec![]);
|
||||
let root = node("root-image", Some(image), vec![]);
|
||||
let result = construct_separation_state(&state(root));
|
||||
let tree = &result.trees[0];
|
||||
assert_eq!(tree.root.id.as_str(), "root-image");
|
||||
@@ -120,11 +120,11 @@ mod tests {
|
||||
let text = Component::Text(TextComponent::new("按钮"));
|
||||
let root = node(
|
||||
"root",
|
||||
vec![],
|
||||
None,
|
||||
vec![node(
|
||||
"outer-image",
|
||||
vec![image.clone()],
|
||||
vec![node("text", vec![text.clone()], vec![])],
|
||||
Some(image.clone()),
|
||||
vec![node("text", Some(text.clone()), vec![])],
|
||||
)],
|
||||
);
|
||||
let result = construct_separation_state(&state(root));
|
||||
@@ -135,14 +135,14 @@ mod tests {
|
||||
|
||||
let nested_root = node(
|
||||
"root",
|
||||
vec![],
|
||||
None,
|
||||
vec![node(
|
||||
"outer-image",
|
||||
vec![image.clone()],
|
||||
Some(image.clone()),
|
||||
vec![node(
|
||||
"inner-image",
|
||||
vec![image],
|
||||
vec![node("text", vec![text], vec![])],
|
||||
Some(image),
|
||||
vec![node("text", Some(text), vec![])],
|
||||
)],
|
||||
)],
|
||||
);
|
||||
@@ -156,15 +156,15 @@ mod tests {
|
||||
fn root_image_receives_text_mask() {
|
||||
let root = node(
|
||||
"root-image",
|
||||
vec![Component::Image(ImageComponent {
|
||||
Some(Component::Image(ImageComponent {
|
||||
target_graphic: None,
|
||||
image_type: ImageType::Simple {
|
||||
preserve_aspect: false,
|
||||
},
|
||||
})],
|
||||
})),
|
||||
vec![node(
|
||||
"text",
|
||||
vec![Component::Text(TextComponent::new("标题"))],
|
||||
Some(Component::Text(TextComponent::new("标题"))),
|
||||
vec![],
|
||||
)],
|
||||
);
|
||||
@@ -218,8 +218,8 @@ mod tests {
|
||||
});
|
||||
let mut separation = construct_separation_state(&state(node(
|
||||
"root",
|
||||
vec![],
|
||||
vec![node("image", vec![image], vec![])],
|
||||
None,
|
||||
vec![node("image", Some(image), vec![])],
|
||||
)));
|
||||
let id = NodeId::new("image").unwrap();
|
||||
let paths = HashMap::new();
|
||||
@@ -302,8 +302,8 @@ mod tests {
|
||||
});
|
||||
let mut state = construct_separation_state(&state(node(
|
||||
"root",
|
||||
vec![],
|
||||
vec![node("image", vec![image], vec![])],
|
||||
None,
|
||||
vec![node("image", Some(image), vec![])],
|
||||
)));
|
||||
let id = NodeId::new("image").unwrap();
|
||||
let decisions = vec![BindingDecision::Ok {
|
||||
|
||||
@@ -7,27 +7,21 @@ use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
|
||||
fn is_unbound_image(node: &Node) -> bool {
|
||||
node.components.iter().any(|component| {
|
||||
matches!(
|
||||
component,
|
||||
Component::Image(ImageComponent {
|
||||
target_graphic: None,
|
||||
..
|
||||
})
|
||||
)
|
||||
})
|
||||
matches!(
|
||||
node.component.as_ref(),
|
||||
Some(Component::Image(ImageComponent {
|
||||
target_graphic: None,
|
||||
..
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
fn has_image_component(node: &Node) -> bool {
|
||||
node.components
|
||||
.iter()
|
||||
.any(|component| matches!(component, Component::Image(_)))
|
||||
matches!(node.component.as_ref(), Some(Component::Image(_)))
|
||||
}
|
||||
|
||||
fn has_text_component(node: &Node) -> bool {
|
||||
node.components
|
||||
.iter()
|
||||
.any(|component| matches!(component, Component::Text(_)))
|
||||
matches!(node.component.as_ref(), Some(Component::Text(_)))
|
||||
}
|
||||
|
||||
fn node_pixel_rect(
|
||||
|
||||
@@ -9,3 +9,27 @@ pub enum Component {
|
||||
Image(image::ImageComponent),
|
||||
Text(text::TextComponent),
|
||||
}
|
||||
|
||||
/// LLM 工具返回的节点组件载荷。
|
||||
///
|
||||
/// 这里不能直接使用 `Option<Component>`:部分模型在严格工具 schema 下不会稳定地产生
|
||||
/// `null`。用显式的 `PureNode` / `WithComponent` 外部枚举表达两种情况,既保留纯结构节点
|
||||
/// 的语义,也让工具调用始终返回一个可判别的对象;落入编辑器 `Node` 时再映射为
|
||||
/// `Option<Component>`。
|
||||
#[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 NodeComponent {
|
||||
PureNode,
|
||||
WithComponent(Component),
|
||||
}
|
||||
|
||||
impl NodeComponent {
|
||||
pub fn into_option(self) -> Option<Component> {
|
||||
match self {
|
||||
Self::PureNode => None,
|
||||
Self::WithComponent(component) => Some(component),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,14 +199,13 @@ fn render_node_with_scale(
|
||||
json!({"childrenDisplayMode": "Exclusive", "childrenRendered": "all"}),
|
||||
)
|
||||
});
|
||||
let components = node
|
||||
.components
|
||||
.iter()
|
||||
let component = node
|
||||
.component
|
||||
.as_ref()
|
||||
.map(|component| render_component(state, component))
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
.into_iter()
|
||||
.transpose()?
|
||||
.map(|fragment| fragment.into_string())
|
||||
.collect::<Vec<_>>();
|
||||
.unwrap_or_default();
|
||||
let children = node
|
||||
.children
|
||||
.iter()
|
||||
@@ -226,7 +225,7 @@ fn render_node_with_scale(
|
||||
(comment)
|
||||
@if let Some(group_comment) = exclusive_comment { (group_comment) }
|
||||
div ui-node-id=(node.id.as_str()) style=(style) {
|
||||
(PreEscaped(components.concat()))
|
||||
(PreEscaped(component))
|
||||
(PreEscaped(children.concat()))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -11,7 +11,7 @@ pub struct Node {
|
||||
pub id: NodeId,
|
||||
pub layout: ControlLayout,
|
||||
pub metadata: NodeMetadata,
|
||||
pub components: Vec<Component>,
|
||||
pub component: Option<Component>,
|
||||
pub children_display_mode: ChildrenDisplayMode,
|
||||
pub children: Vec<Node>,
|
||||
}
|
||||
@@ -50,7 +50,7 @@ pub struct NodeMetadata {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub layout_status: StageStatus,
|
||||
pub components_status: StageStatus,
|
||||
pub component_status: StageStatus,
|
||||
pub allow_llm_edit_layout: bool,
|
||||
pub allow_llm_edit_component: bool,
|
||||
pub source: NodeSource,
|
||||
|
||||
@@ -25,7 +25,6 @@ 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;
|
||||
pub(crate) const UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE: usize = 64;
|
||||
const UI_DESIGN_STATE_MAX_SAFE_REVISION: u64 = 9_007_199_254_740_991;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
@@ -747,12 +746,8 @@ fn validate_node(
|
||||
}
|
||||
}
|
||||
}
|
||||
if node.components.len() > UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE {
|
||||
return Err(format!(
|
||||
"单个 UI 节点最多支持 {UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE} 个组件"
|
||||
));
|
||||
}
|
||||
for component in &node.components {
|
||||
validate_component_status(node.component.as_ref(), &node.metadata.component_status)?;
|
||||
if let Some(component) = &node.component {
|
||||
match component {
|
||||
Component::Image(image) => {
|
||||
if image
|
||||
@@ -778,6 +773,22 @@ fn validate_node(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_component_status(
|
||||
component: Option<&Component>,
|
||||
status: &crate::ui_editor::layout::node::StageStatus,
|
||||
) -> Result<(), String> {
|
||||
if component.is_none()
|
||||
&& matches!(
|
||||
status,
|
||||
crate::ui_editor::layout::node::StageStatus::NeedReview(_)
|
||||
| crate::ui_editor::layout::node::StageStatus::Blocked(_)
|
||||
)
|
||||
{
|
||||
return Err("纯结构节点的 component_status 必须为 NoProblem".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_id(value: &str, label: &str) -> Result<(), String> {
|
||||
if value.is_empty() || value.trim() != value || value.chars().any(char::is_control) {
|
||||
return Err(format!("{label} 无效"));
|
||||
@@ -881,12 +892,12 @@ mod tests {
|
||||
"name": "页面根节点",
|
||||
"description": "",
|
||||
"layout_status": "NoProblem",
|
||||
"components_status": "NoProblem",
|
||||
"component_status": "NoProblem",
|
||||
"allow_llm_edit_layout": true,
|
||||
"allow_llm_edit_component": true,
|
||||
"source": "System"
|
||||
},
|
||||
"components": [],
|
||||
"component": null,
|
||||
"children_display_mode": "Stack",
|
||||
"children": [{
|
||||
"id": "dragged-node",
|
||||
@@ -907,17 +918,17 @@ mod tests {
|
||||
"name": "拖拽节点",
|
||||
"description": "",
|
||||
"layout_status": "NoProblem",
|
||||
"components_status": "NoProblem",
|
||||
"component_status": "NoProblem",
|
||||
"allow_llm_edit_layout": true,
|
||||
"allow_llm_edit_component": true,
|
||||
"source": "Human"
|
||||
},
|
||||
"components": [{
|
||||
"component": {
|
||||
"Image": {
|
||||
"target_graphic": "spirit",
|
||||
"image_type": { "Simple": { "preserve_aspect": false } }
|
||||
}
|
||||
}],
|
||||
},
|
||||
"children_display_mode": "Stack",
|
||||
"children": []
|
||||
}]
|
||||
@@ -1095,12 +1106,12 @@ mod tests {
|
||||
"name": "根节点",
|
||||
"description": "",
|
||||
"layout_status": "NoProblem",
|
||||
"components_status": "NoProblem",
|
||||
"component_status": "NoProblem",
|
||||
"allow_llm_edit_layout": true,
|
||||
"allow_llm_edit_component": true,
|
||||
"source": "System"
|
||||
},
|
||||
"components": [],
|
||||
"component": null,
|
||||
"children_display_mode": "Stack",
|
||||
"children": []
|
||||
}
|
||||
@@ -1296,12 +1307,12 @@ mod tests {
|
||||
"name": "根节点",
|
||||
"description": "",
|
||||
"layout_status": "NoProblem",
|
||||
"components_status": "NoProblem",
|
||||
"component_status": "NoProblem",
|
||||
"allow_llm_edit_layout": false,
|
||||
"allow_llm_edit_component": false,
|
||||
"source": "System"
|
||||
},
|
||||
"components": [{
|
||||
"component": {
|
||||
"Text": {
|
||||
"content": "标题",
|
||||
"font": {"Bound": "missing-font"},
|
||||
@@ -1313,7 +1324,7 @@ mod tests {
|
||||
"vertical_overflow": "Truncate",
|
||||
"line_spacing": 1.0
|
||||
}
|
||||
}],
|
||||
},
|
||||
"children_display_mode": "Stack",
|
||||
"children": []
|
||||
}
|
||||
@@ -1340,4 +1351,30 @@ mod tests {
|
||||
.expect_err("missing Text font reference must be rejected");
|
||||
assert!(error.contains("Text 组件引用了不存在的字体素材"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_status_matrix_keeps_pure_nodes_unproblematic() {
|
||||
use crate::ui_editor::component::image::ImageComponent;
|
||||
|
||||
assert!(validate_component_status(
|
||||
None,
|
||||
&crate::ui_editor::layout::node::StageStatus::NoProblem,
|
||||
)
|
||||
.is_ok());
|
||||
assert!(validate_component_status(
|
||||
None,
|
||||
&crate::ui_editor::layout::node::StageStatus::NeedReview("原因".to_string()),
|
||||
)
|
||||
.is_err());
|
||||
assert!(validate_component_status(
|
||||
None,
|
||||
&crate::ui_editor::layout::node::StageStatus::Blocked("原因".to_string()),
|
||||
)
|
||||
.is_err());
|
||||
assert!(validate_component_status(
|
||||
Some(&Component::Image(ImageComponent::new())),
|
||||
&crate::ui_editor::layout::node::StageStatus::NeedReview("等待素材".to_string()),
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1145,8 +1145,8 @@ fn apply_binding_changes(
|
||||
) -> usize {
|
||||
let mut changed = 0;
|
||||
if let Some(change) = changes.get(&node.id) {
|
||||
node.components = change.components.clone();
|
||||
node.metadata.components_status = change.components_status.clone();
|
||||
node.component = change.component.clone().into_option();
|
||||
node.metadata.component_status = change.component_status.clone();
|
||||
changed += 1;
|
||||
}
|
||||
for child in &mut node.children {
|
||||
@@ -1163,7 +1163,7 @@ fn apply_binding_changes(
|
||||
|
||||
fn state_has_renderable_component(state: &crate::ui_editor::state::State) -> bool {
|
||||
fn has_component(node: &Node) -> bool {
|
||||
!node.components.is_empty() || node.children.iter().any(has_component)
|
||||
node.component.is_some() || node.children.iter().any(has_component)
|
||||
}
|
||||
state.ui_trees.iter().any(|tree| has_component(&tree.root))
|
||||
}
|
||||
@@ -1315,14 +1315,14 @@ fn derive_page_status(
|
||||
}
|
||||
|
||||
fn collect_binding_blockers(node: &Node, component_count: &mut usize, blockers: &mut Vec<String>) {
|
||||
*component_count += node.components.len();
|
||||
*component_count += usize::from(node.component.is_some());
|
||||
if let StageStatus::NeedReview(reason) | StageStatus::Blocked(reason) =
|
||||
&node.metadata.layout_status
|
||||
{
|
||||
blockers.push(format!("{} 布局未通过:{reason}", node.metadata.name));
|
||||
}
|
||||
if let StageStatus::NeedReview(reason) | StageStatus::Blocked(reason) =
|
||||
&node.metadata.components_status
|
||||
&node.metadata.component_status
|
||||
{
|
||||
blockers.push(format!("{} 组件未通过:{reason}", node.metadata.name));
|
||||
}
|
||||
|
||||
@@ -7,8 +7,10 @@ function applyChanges(node: Node, result: BindingDTO): void {
|
||||
(candidate) => candidate.node_id === node.id,
|
||||
);
|
||||
if (change) {
|
||||
node.components = structuredClone(change.components);
|
||||
node.metadata.components_status = structuredClone(change.components_status);
|
||||
node.component = structuredClone(
|
||||
change.component === 'PureNode' ? null : change.component.WithComponent,
|
||||
);
|
||||
node.metadata.component_status = structuredClone(change.component_status);
|
||||
}
|
||||
for (const child of node.children) applyChanges(child, result);
|
||||
}
|
||||
|
||||
@@ -90,13 +90,13 @@ export function getBindingOverview(
|
||||
};
|
||||
|
||||
for (const { node } of collectUiTreeNodeTargets(uiTrees)) {
|
||||
const status = node.metadata.components_status;
|
||||
const status = node.metadata.component_status;
|
||||
if (isBlocked(status)) overview.blocked += 1;
|
||||
if (isBlocked(status) || isNeedReview(status)) {
|
||||
overview.needsAttention += 1;
|
||||
}
|
||||
for (const component of node.components) {
|
||||
addBindingCounts(overview, getComponentBindingCounts(component));
|
||||
if (node.component) {
|
||||
addBindingCounts(overview, getComponentBindingCounts(node.component));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,16 +104,17 @@ export function getBindingOverview(
|
||||
}
|
||||
|
||||
export function nodeHasPendingBinding(target: UiTreeNodeTarget): boolean {
|
||||
return target.node.components.some(
|
||||
(component) => getComponentBindingCounts(component).pendingSlots > 0,
|
||||
return (
|
||||
target.node.component !== null &&
|
||||
getComponentBindingCounts(target.node.component).pendingSlots > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function nodeNeedsComponentReview(target: UiTreeNodeTarget): boolean {
|
||||
const status = target.node.metadata.components_status;
|
||||
const status = target.node.metadata.component_status;
|
||||
return isBlocked(status) || isNeedReview(status);
|
||||
}
|
||||
|
||||
export function nodeHasBlockedComponents(target: UiTreeNodeTarget): boolean {
|
||||
return isBlocked(target.node.metadata.components_status);
|
||||
return isBlocked(target.node.metadata.component_status);
|
||||
}
|
||||
|
||||
@@ -106,7 +106,8 @@ export function validateLayoutGenerationPrerequisites(
|
||||
const issues = validateAssetRecognitionPrerequisites(state);
|
||||
const visit = (nodes: State['ui_trees'][number]['root']['children']) => {
|
||||
for (const node of nodes) {
|
||||
for (const component of node.components) {
|
||||
const component = node.component;
|
||||
if (component) {
|
||||
if (
|
||||
'Image' in component &&
|
||||
component.Image.target_graphic !== null &&
|
||||
@@ -219,10 +220,10 @@ export function validateVisualBindingResult(
|
||||
const issues: UiEditorPrerequisiteIssue[] = [];
|
||||
for (const tree of state.ui_trees) {
|
||||
visitNodes([tree.root], (node) => {
|
||||
if (node.components.length == 0) {
|
||||
if (node.component === null) {
|
||||
return;
|
||||
}
|
||||
const issue = componentStatusIssue(node.metadata.components_status);
|
||||
const issue = componentStatusIssue(node.metadata.component_status);
|
||||
if (issue) issues.push(issue);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { UITree } from './types/UITree';
|
||||
|
||||
export type StageStatusField = Extract<
|
||||
keyof NodeMetadata,
|
||||
'layout_status' | 'components_status'
|
||||
'layout_status' | 'component_status'
|
||||
>;
|
||||
|
||||
export type UiTreeNodeTarget = {
|
||||
|
||||
@@ -1,6 +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 { NodeComponent } from "./NodeComponent";
|
||||
import type { NodeId } from "./NodeId";
|
||||
import type { StageStatus } from "./StageStatus";
|
||||
|
||||
export type BindingChange = { node_id: NodeId, components: Array<Component>, components_status: StageStatus, };
|
||||
export type BindingChange = { node_id: NodeId, component: NodeComponent, component_status: StageStatus, };
|
||||
|
||||
@@ -5,4 +5,4 @@ import type { ControlLayout } from "./ControlLayout";
|
||||
import type { NodeId } from "./NodeId";
|
||||
import type { NodeMetadata } from "./NodeMetadata";
|
||||
|
||||
export type Node = { id: NodeId, layout: ControlLayout, metadata: NodeMetadata, components: Array<Component>, children_display_mode: ChildrenDisplayMode, children: Array<Node>, };
|
||||
export type Node = { id: NodeId, layout: ControlLayout, metadata: NodeMetadata, component: Component | null, children_display_mode: ChildrenDisplayMode, children: Array<Node>, };
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// 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";
|
||||
|
||||
/**
|
||||
* LLM 工具返回的节点组件载荷。
|
||||
*
|
||||
* 这里不能直接使用 `Option<Component>`:部分模型在严格工具 schema 下不会稳定地产生
|
||||
* `null`。用显式的 `PureNode` / `WithComponent` 外部枚举表达两种情况,既保留纯结构节点
|
||||
* 的语义,也让工具调用始终返回一个可判别的对象;落入编辑器 `Node` 时再映射为
|
||||
* `Option<Component>`。
|
||||
*/
|
||||
export type NodeComponent = "PureNode" | { "WithComponent": Component };
|
||||
@@ -2,4 +2,4 @@
|
||||
import type { NodeSource } from "./NodeSource";
|
||||
import type { StageStatus } from "./StageStatus";
|
||||
|
||||
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, };
|
||||
export type NodeMetadata = { name: string, description: string, layout_status: StageStatus, component_status: StageStatus, allow_llm_edit_layout: boolean, allow_llm_edit_component: boolean, source: NodeSource, };
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* UI 布局在父级坐标系中的轴对齐矩形。
|
||||
*
|
||||
* `min` 是矩形的最小角,`size` 是沿两个坐标轴的尺寸。这里不规定 Y 轴方向,
|
||||
* 因而既能用于 Y 轴向上的游戏坐标,也能用于 Y 轴向下的画布坐标。
|
||||
*/
|
||||
export type UIRect = { min: [number, number], size: [number, number], };
|
||||
@@ -64,14 +64,12 @@ export type NodeMetadataPatch = Partial<
|
||||
| 'name'
|
||||
| 'description'
|
||||
| 'layout_status'
|
||||
| 'components_status'
|
||||
| 'component_status'
|
||||
| 'allow_llm_edit_layout'
|
||||
| 'allow_llm_edit_component'
|
||||
>
|
||||
>;
|
||||
|
||||
export type ComponentIndex = number;
|
||||
|
||||
export type NodeTransformOptions = {
|
||||
keepChildrenUnchanged?: boolean;
|
||||
};
|
||||
@@ -106,6 +104,12 @@ function visitNodes(node: Node, visit: (node: Node) => void): void {
|
||||
for (const child of node.children) visitNodes(child, visit);
|
||||
}
|
||||
|
||||
function isProblematicComponentStatus(
|
||||
status: NodeMetadata['component_status'],
|
||||
): boolean {
|
||||
return typeof status !== 'string';
|
||||
}
|
||||
|
||||
function existingNodeIds(state: State): Set<NodeId> {
|
||||
const ids = new Set<NodeId>();
|
||||
for (const tree of state.ui_trees) {
|
||||
@@ -144,12 +148,12 @@ function createPageRoot(state: State): Node {
|
||||
name: '页面根节点',
|
||||
description: '',
|
||||
layout_status: 'NoProblem',
|
||||
components_status: 'NoProblem',
|
||||
component_status: 'NoProblem',
|
||||
allow_llm_edit_layout: true,
|
||||
allow_llm_edit_component: true,
|
||||
source: 'System',
|
||||
},
|
||||
components: [],
|
||||
component: null,
|
||||
children_display_mode: 'Stack',
|
||||
children: [],
|
||||
};
|
||||
@@ -175,12 +179,12 @@ function createHumanNode(state: State): Node {
|
||||
name: '新节点',
|
||||
description: '',
|
||||
layout_status: 'NoProblem',
|
||||
components_status: 'NoProblem',
|
||||
component_status: 'NoProblem',
|
||||
allow_llm_edit_layout: true,
|
||||
allow_llm_edit_component: true,
|
||||
source: 'Human',
|
||||
},
|
||||
components: [],
|
||||
component: null,
|
||||
children_display_mode: 'Stack',
|
||||
children: [],
|
||||
};
|
||||
@@ -283,9 +287,7 @@ function sameResource<T>(
|
||||
|
||||
function visitComponents(nodes: Node[], visit: (component: Component) => void) {
|
||||
for (const node of nodes) {
|
||||
for (const component of node.components) {
|
||||
visit(component);
|
||||
}
|
||||
if (node.component) visit(node.component);
|
||||
visitComponents(node.children, visit);
|
||||
}
|
||||
}
|
||||
@@ -1121,15 +1123,15 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
|
||||
[commit, guard],
|
||||
);
|
||||
|
||||
const setNodeComponents = useCallback(
|
||||
const setNodeComponent = useCallback(
|
||||
(
|
||||
treeId: UIDesignImageId,
|
||||
nodeId: NodeId,
|
||||
components: Component[],
|
||||
component: Component | null,
|
||||
): UiEditorOperationResult => {
|
||||
const blocked = guard();
|
||||
if (blocked) return blocked;
|
||||
if (!components.every(isValidComponent)) {
|
||||
if (component && !isValidComponent(component)) {
|
||||
return { ok: false, reason: 'invalid' };
|
||||
}
|
||||
const current = stateRef.current;
|
||||
@@ -1143,126 +1145,9 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
|
||||
const nextTree = next.ui_trees.find(
|
||||
(candidate) => candidate.src_ui_design === treeId,
|
||||
)!;
|
||||
findNodeLocation(nextTree.root, nodeId)!.node.components =
|
||||
structuredClone(components);
|
||||
commit(next);
|
||||
return { ok: true, value: undefined };
|
||||
},
|
||||
[commit, guard],
|
||||
);
|
||||
|
||||
const insertComponent = useCallback(
|
||||
(
|
||||
treeId: UIDesignImageId,
|
||||
nodeId: NodeId,
|
||||
index: ComponentIndex,
|
||||
component: Component,
|
||||
): UiEditorOperationResult => {
|
||||
const blocked = guard();
|
||||
if (blocked) return blocked;
|
||||
if (
|
||||
!isValidComponent(component) ||
|
||||
!Number.isInteger(index) ||
|
||||
index < 0
|
||||
) {
|
||||
return { ok: false, reason: 'invalid' };
|
||||
}
|
||||
const current = stateRef.current;
|
||||
const tree = current.ui_trees.find(
|
||||
(candidate) => candidate.src_ui_design === treeId,
|
||||
);
|
||||
if (!tree) return { ok: false, reason: 'missing' };
|
||||
const location = findNodeLocation(tree.root, nodeId);
|
||||
if (!location) return { ok: false, reason: 'missing' };
|
||||
if (index > location.node.components.length) {
|
||||
return { ok: false, reason: 'invalid' };
|
||||
}
|
||||
const next = cloneState(current);
|
||||
const nextNode = findNodeLocation(
|
||||
next.ui_trees.find((candidate) => candidate.src_ui_design === treeId)!
|
||||
.root,
|
||||
nodeId,
|
||||
)!.node;
|
||||
nextNode.components.splice(index, 0, structuredClone(component));
|
||||
commit(next);
|
||||
return { ok: true, value: undefined };
|
||||
},
|
||||
[commit, guard],
|
||||
);
|
||||
|
||||
const deleteComponent = useCallback(
|
||||
(
|
||||
treeId: UIDesignImageId,
|
||||
nodeId: NodeId,
|
||||
index: ComponentIndex,
|
||||
): UiEditorOperationResult => {
|
||||
const blocked = guard();
|
||||
if (blocked) return blocked;
|
||||
if (!Number.isInteger(index) || index < 0)
|
||||
return { ok: false, reason: 'invalid' };
|
||||
const current = stateRef.current;
|
||||
const tree = current.ui_trees.find(
|
||||
(candidate) => candidate.src_ui_design === treeId,
|
||||
);
|
||||
if (!tree) return { ok: false, reason: 'missing' };
|
||||
const location = findNodeLocation(tree.root, nodeId);
|
||||
if (!location) return { ok: false, reason: 'missing' };
|
||||
if (index >= location.node.components.length) {
|
||||
return { ok: false, reason: 'invalid' };
|
||||
}
|
||||
const next = cloneState(current);
|
||||
const nextNode = findNodeLocation(
|
||||
next.ui_trees.find((candidate) => candidate.src_ui_design === treeId)!
|
||||
.root,
|
||||
nodeId,
|
||||
)!.node;
|
||||
nextNode.components.splice(index, 1);
|
||||
commit(next);
|
||||
return { ok: true, value: undefined };
|
||||
},
|
||||
[commit, guard],
|
||||
);
|
||||
|
||||
const moveComponent = useCallback(
|
||||
(
|
||||
treeId: UIDesignImageId,
|
||||
nodeId: NodeId,
|
||||
fromIndex: ComponentIndex,
|
||||
toIndex: ComponentIndex,
|
||||
): UiEditorOperationResult => {
|
||||
const blocked = guard();
|
||||
if (blocked) return blocked;
|
||||
if (
|
||||
!Number.isInteger(fromIndex) ||
|
||||
!Number.isInteger(toIndex) ||
|
||||
fromIndex < 0 ||
|
||||
toIndex < 0
|
||||
) {
|
||||
return { ok: false, reason: 'invalid' };
|
||||
}
|
||||
const current = stateRef.current;
|
||||
const tree = current.ui_trees.find(
|
||||
(candidate) => candidate.src_ui_design === treeId,
|
||||
);
|
||||
if (!tree) return { ok: false, reason: 'missing' };
|
||||
const location = findNodeLocation(tree.root, nodeId);
|
||||
if (!location) return { ok: false, reason: 'missing' };
|
||||
if (
|
||||
fromIndex >= location.node.components.length ||
|
||||
toIndex >= location.node.components.length
|
||||
) {
|
||||
return { ok: false, reason: 'invalid' };
|
||||
}
|
||||
if (fromIndex === toIndex) return { ok: true, value: undefined };
|
||||
const next = cloneState(current);
|
||||
const nextNode = findNodeLocation(
|
||||
next.ui_trees.find((candidate) => candidate.src_ui_design === treeId)!
|
||||
.root,
|
||||
nodeId,
|
||||
)!.node;
|
||||
const [component] = nextNode.components.splice(fromIndex, 1);
|
||||
if (!component) return { ok: false, reason: 'invalid' };
|
||||
nextNode.components.splice(toIndex, 0, component);
|
||||
const nextNode = findNodeLocation(nextTree.root, nodeId)!.node;
|
||||
nextNode.component = structuredClone(component);
|
||||
nextNode.metadata.component_status = 'NoProblem';
|
||||
commit(next);
|
||||
return { ok: true, value: undefined };
|
||||
},
|
||||
@@ -1290,13 +1175,20 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
|
||||
.root,
|
||||
nodeId,
|
||||
)!.node;
|
||||
if (
|
||||
patch.component_status !== undefined &&
|
||||
node.component === null &&
|
||||
isProblematicComponentStatus(patch.component_status)
|
||||
) {
|
||||
return { ok: false, reason: 'invalid' };
|
||||
}
|
||||
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.component_status !== undefined)
|
||||
node.metadata.component_status = patch.component_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)
|
||||
@@ -1575,10 +1467,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
|
||||
deleteNode,
|
||||
setNodeTransform,
|
||||
setNodeLayout,
|
||||
setNodeComponents,
|
||||
insertComponent,
|
||||
deleteComponent,
|
||||
moveComponent,
|
||||
setNodeComponent,
|
||||
setNodeMetadata,
|
||||
setNodeChildrenDisplayMode,
|
||||
moveNode,
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { UITree } from '../../../features/ui-editor/types/UITree';
|
||||
function countUiComponents(nodes: UiNode[]): number {
|
||||
return nodes.reduce(
|
||||
(total, node) =>
|
||||
total + node.components.length + countUiComponents(node.children),
|
||||
total + (node.component ? 1 : 0) + countUiComponents(node.children),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,12 +62,12 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) {
|
||||
name: 'UI Trees',
|
||||
description: '',
|
||||
layout_status: 'NoProblem',
|
||||
components_status: 'NoProblem',
|
||||
component_status: 'NoProblem',
|
||||
allow_llm_edit_layout: false,
|
||||
allow_llm_edit_component: false,
|
||||
source: 'System',
|
||||
},
|
||||
components: [],
|
||||
component: null,
|
||||
children_display_mode: 'Stack',
|
||||
children: uiTrees.map((tree) => tree.root),
|
||||
};
|
||||
|
||||
+84
-243
@@ -1,12 +1,5 @@
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Plus, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { Component } from '../../../../../features/ui-editor/types/Component';
|
||||
import type { UiEditorOperationResult } from '../../../../../features/ui-editor/useUiEditorState';
|
||||
@@ -18,111 +11,30 @@ import { TextPanel } from './TextPanel';
|
||||
import { createDefaultTextComponent } from './TextPanelDefaults';
|
||||
|
||||
export function ComponentPanel(props: ComponentPanelProps) {
|
||||
const {
|
||||
components,
|
||||
readOnly: propReadOnly,
|
||||
onSetComponents,
|
||||
onInsertComponent,
|
||||
onDeleteComponent,
|
||||
onMoveComponent,
|
||||
} = props;
|
||||
const { component, readOnly: propReadOnly, onSetComponent } = props;
|
||||
const inspectorReadOnly = useInspectorReadOnly();
|
||||
const readOnly = propReadOnly || inspectorReadOnly;
|
||||
const [addKind, setAddKind] = useState<'Image' | 'Text'>('Image');
|
||||
const [expandedIndexes, setExpandedIndexes] = useState<Set<number>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [expanded, setExpanded] = useState(Boolean(component));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setExpandedIndexes((current) => {
|
||||
const next = new Set(
|
||||
[...current].filter((index) => index >= 0 && index < components.length),
|
||||
);
|
||||
if (next.size === current.size) return current;
|
||||
return next;
|
||||
});
|
||||
}, [components.length]);
|
||||
|
||||
const updateComponent = (index: number, next: Component) => {
|
||||
function setComponent(next: Component | null) {
|
||||
if (readOnly) return undefined;
|
||||
const nextComponents = components.slice();
|
||||
nextComponents[index] = next;
|
||||
const result = onSetComponents(nextComponents);
|
||||
if (result && !result.ok) setError('组件字段无效,更新未应用。');
|
||||
const result = onSetComponent(next);
|
||||
if (result && !result.ok) setError('组件更新失败。');
|
||||
else setError(null);
|
||||
return result;
|
||||
};
|
||||
|
||||
function addComponent() {
|
||||
if (readOnly) return;
|
||||
let component: Component;
|
||||
switch (addKind) {
|
||||
case 'Image':
|
||||
component = { Image: createDefaultImageComponent() };
|
||||
break;
|
||||
case 'Text':
|
||||
component = { Text: createDefaultTextComponent() };
|
||||
break;
|
||||
}
|
||||
const result = onInsertComponent(components.length, component);
|
||||
if (result?.ok) {
|
||||
setExpandedIndexes((current) => new Set(current).add(components.length));
|
||||
setError(null);
|
||||
} else if (result) {
|
||||
setError('组件新增失败。');
|
||||
}
|
||||
}
|
||||
|
||||
function deleteComponentAt(index: number) {
|
||||
if (readOnly) return;
|
||||
const result = onDeleteComponent(index);
|
||||
if (result?.ok) {
|
||||
setExpandedIndexes((current) => {
|
||||
const next = new Set<number>();
|
||||
for (const expanded of current) {
|
||||
if (expanded === index) continue;
|
||||
if (expanded > index) next.add(expanded - 1);
|
||||
else next.add(expanded);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setError(null);
|
||||
} else if (result) {
|
||||
setError('组件删除失败。');
|
||||
}
|
||||
function createComponent(): Component {
|
||||
return addKind === 'Image'
|
||||
? { Image: createDefaultImageComponent() }
|
||||
: { Text: createDefaultTextComponent() };
|
||||
}
|
||||
|
||||
function moveComponent(index: number, direction: 'up' | 'down') {
|
||||
if (readOnly) return;
|
||||
// Components are rendered in array order. The last item therefore sits
|
||||
// visually at the top of the stack.
|
||||
let nextIndex: number;
|
||||
switch (direction) {
|
||||
case 'up':
|
||||
nextIndex = index + 1;
|
||||
break;
|
||||
case 'down':
|
||||
nextIndex = index - 1;
|
||||
break;
|
||||
}
|
||||
if (nextIndex < 0 || nextIndex >= components.length) return;
|
||||
const result = onMoveComponent(index, nextIndex);
|
||||
if (result?.ok) {
|
||||
setExpandedIndexes((current) => {
|
||||
const next = new Set(current);
|
||||
const wasCurrentExpanded = next.has(index);
|
||||
const wasTargetExpanded = next.has(nextIndex);
|
||||
next.delete(index);
|
||||
next.delete(nextIndex);
|
||||
if (wasCurrentExpanded) next.add(nextIndex);
|
||||
if (wasTargetExpanded) next.add(index);
|
||||
return next;
|
||||
});
|
||||
setError(null);
|
||||
} else if (result) {
|
||||
setError('组件顺序更新失败。');
|
||||
}
|
||||
function replaceComponent() {
|
||||
const result = setComponent(createComponent());
|
||||
if (result?.ok) setExpanded(true);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -130,7 +42,7 @@ export function ComponentPanel(props: ComponentPanelProps) {
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="m-0 text-xs font-semibold">组件</h3>
|
||||
<span className="text-[10px] text-(--platform-text-soft)">
|
||||
{components.length} 个
|
||||
{component ? componentKind(component) : '无'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -142,7 +54,7 @@ export function ComponentPanel(props: ComponentPanelProps) {
|
||||
onChange={(event) =>
|
||||
setAddKind(event.target.value as 'Image' | 'Text')
|
||||
}
|
||||
aria-label="新增组件类型"
|
||||
aria-label="组件类型"
|
||||
>
|
||||
<option value="Image">图片</option>
|
||||
<option value="Text">文本</option>
|
||||
@@ -151,93 +63,49 @@ export function ComponentPanel(props: ComponentPanelProps) {
|
||||
type="button"
|
||||
className="flex h-8 items-center gap-1 rounded-lg bg-blue-600 px-3 text-xs font-semibold text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||
disabled={readOnly}
|
||||
onClick={addComponent}
|
||||
aria-label="新增组件"
|
||||
title="新增组件"
|
||||
onClick={replaceComponent}
|
||||
aria-label={component ? '替换组件' : '新增组件'}
|
||||
title={component ? '替换组件' : '新增组件'}
|
||||
>
|
||||
{componentKindLabel(addKind)}
|
||||
{component ? '替换' : '新增'}
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{components.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{[...components].reverse().map((component, reverseIndex) => {
|
||||
const index = components.length - reverseIndex - 1;
|
||||
const expanded = expandedIndexes.has(index);
|
||||
return (
|
||||
<div
|
||||
key={`${index}-${componentKind(component)}`}
|
||||
className="overflow-hidden rounded-xl border border-(--platform-subpanel-border) bg-white/35"
|
||||
>
|
||||
<div className="flex items-center gap-1 p-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-2 rounded-lg px-2 py-2 text-left text-xs font-semibold hover:bg-white/50"
|
||||
onClick={() =>
|
||||
setExpandedIndexes((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(index)) next.delete(index);
|
||||
else next.add(index);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<ExpandIcon expanded={expanded} />
|
||||
<span>{componentKind(component)}</span>
|
||||
<span className="ml-auto text-[10px] font-normal text-(--platform-text-soft)">
|
||||
{componentLayerLabel(index, components.length)}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-7 place-items-center rounded-lg border border-(--platform-subpanel-border) disabled:cursor-not-allowed disabled:opacity-35"
|
||||
disabled={readOnly || index === components.length - 1}
|
||||
onClick={() => moveComponent(index, 'up')}
|
||||
aria-label="组件上移"
|
||||
title="上移(渲染到更上层)"
|
||||
>
|
||||
<ArrowUp size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-7 place-items-center rounded-lg border border-(--platform-subpanel-border) disabled:cursor-not-allowed disabled:opacity-35"
|
||||
disabled={readOnly || index === 0}
|
||||
onClick={() => moveComponent(index, 'down')}
|
||||
aria-label="组件下移"
|
||||
title="下移(渲染到更下层)"
|
||||
>
|
||||
<ArrowDown size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-7 place-items-center rounded-lg border border-red-200 bg-red-50 text-red-700 disabled:cursor-not-allowed disabled:opacity-35"
|
||||
disabled={readOnly}
|
||||
onClick={() => deleteComponentAt(index)}
|
||||
aria-label="删除组件"
|
||||
title="删除组件"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="border-t border-(--platform-subpanel-border) p-3">
|
||||
{renderComponentEditor(
|
||||
component,
|
||||
index,
|
||||
props,
|
||||
readOnly,
|
||||
updateComponent,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{component ? (
|
||||
<div className="overflow-hidden rounded-xl border border-(--platform-subpanel-border) bg-white/35">
|
||||
<div className="flex items-center gap-1 p-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-2 rounded-lg px-2 py-2 text-left text-xs font-semibold hover:bg-white/50"
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown size={14} />
|
||||
) : (
|
||||
<ChevronRight size={14} />
|
||||
)}
|
||||
<span>{componentKind(component)}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-7 place-items-center rounded-lg border border-red-200 bg-red-50 text-red-700 disabled:cursor-not-allowed disabled:opacity-35"
|
||||
disabled={readOnly}
|
||||
onClick={() => setComponent(null)}
|
||||
aria-label="移除组件"
|
||||
title="移除组件"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="border-t border-(--platform-subpanel-border) p-3">
|
||||
{renderComponentEditor(component, props, readOnly, setComponent)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{components.length === 0 && (
|
||||
) : (
|
||||
<p className="m-0 text-xs text-(--platform-text-soft)">
|
||||
当前节点没有组件。
|
||||
</p>
|
||||
@@ -248,72 +116,45 @@ export function ComponentPanel(props: ComponentPanelProps) {
|
||||
}
|
||||
|
||||
function componentKind(component: Component): string {
|
||||
switch (true) {
|
||||
case 'Image' in component:
|
||||
return '图片';
|
||||
case 'Text' in component:
|
||||
return '文本';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
}
|
||||
|
||||
function componentKindLabel(kind: 'Image' | 'Text'): string {
|
||||
switch (kind) {
|
||||
case 'Image':
|
||||
return '图片';
|
||||
case 'Text':
|
||||
return '文本';
|
||||
}
|
||||
}
|
||||
|
||||
function componentLayerLabel(index: number, count: number): string {
|
||||
if (index === count - 1) return '顶部';
|
||||
return `层级 ${index + 1}`;
|
||||
}
|
||||
|
||||
function ExpandIcon({ expanded }: { expanded: boolean }) {
|
||||
if (expanded) return <ChevronDown size={14} />;
|
||||
return <ChevronRight size={14} />;
|
||||
if ('Image' in component) return '图片';
|
||||
if ('Text' in component) return '文本';
|
||||
return '未知';
|
||||
}
|
||||
|
||||
function renderComponentEditor(
|
||||
component: Component,
|
||||
index: number,
|
||||
props: ComponentPanelProps,
|
||||
readOnly: boolean,
|
||||
updateComponent: (
|
||||
index: number,
|
||||
next: Component,
|
||||
next: Component | null,
|
||||
) => UiEditorOperationResult | undefined,
|
||||
) {
|
||||
switch (true) {
|
||||
case 'Image' in component:
|
||||
return (
|
||||
<ImagePanel
|
||||
component={component.Image}
|
||||
sprites={props.sprites}
|
||||
previewUrls={props.previewUrls}
|
||||
readOnly={readOnly}
|
||||
onChange={(next) => updateComponent(index, { Image: next })}
|
||||
/>
|
||||
);
|
||||
case 'Text' in component:
|
||||
return (
|
||||
<TextPanel
|
||||
component={component.Text}
|
||||
fonts={props.fonts}
|
||||
fontFaces={props.fontFaces}
|
||||
projectPath={props.projectPath}
|
||||
readOnly={readOnly}
|
||||
onChange={(next) => updateComponent(index, { Text: next })}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<p className="m-0 text-xs text-(--platform-text-soft)">
|
||||
当前组件类型暂不支持编辑。
|
||||
</p>
|
||||
);
|
||||
if ('Image' in component) {
|
||||
return (
|
||||
<ImagePanel
|
||||
component={component.Image}
|
||||
sprites={props.sprites}
|
||||
previewUrls={props.previewUrls}
|
||||
readOnly={readOnly}
|
||||
onChange={(next) => updateComponent({ Image: next })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if ('Text' in component) {
|
||||
return (
|
||||
<TextPanel
|
||||
component={component.Text}
|
||||
fonts={props.fonts}
|
||||
fontFaces={props.fontFaces}
|
||||
projectPath={props.projectPath}
|
||||
readOnly={readOnly}
|
||||
onChange={(next) => updateComponent({ Text: next })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<p className="m-0 text-xs text-(--platform-text-soft)">
|
||||
当前组件类型暂不支持编辑。
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
+3
-12
@@ -7,24 +7,15 @@ import type { UiEditorFontFaceState } from '../../../../../features/ui-editor/us
|
||||
import type { UiEditorOperationResult } from '../../../../../features/ui-editor/useUiEditorState';
|
||||
|
||||
export type ComponentPanelProps = {
|
||||
components: Component[];
|
||||
component: Component | null;
|
||||
sprites: Record<string, SpriteAsset>;
|
||||
previewUrls: Record<string, string>;
|
||||
fonts: Record<string, FontAsset>;
|
||||
fontFaces: Record<string, UiEditorFontFaceState>;
|
||||
projectPath: string;
|
||||
readOnly: boolean;
|
||||
onSetComponents: (
|
||||
components: Component[],
|
||||
) => UiEditorOperationResult | undefined;
|
||||
onInsertComponent: (
|
||||
index: number,
|
||||
component: Component,
|
||||
) => UiEditorOperationResult | undefined;
|
||||
onDeleteComponent: (index: number) => UiEditorOperationResult | undefined;
|
||||
onMoveComponent: (
|
||||
fromIndex: number,
|
||||
toIndex: number,
|
||||
onSetComponent: (
|
||||
component: Component | null,
|
||||
) => UiEditorOperationResult | undefined;
|
||||
};
|
||||
|
||||
|
||||
+11
-23
@@ -115,10 +115,7 @@ export function InspectorSidebar({
|
||||
fonts={inspector.fonts}
|
||||
fontFaces={inspector.fontFaces}
|
||||
projectPath={inspector.projectPath}
|
||||
onSetComponents={inspector.setNodeComponents}
|
||||
onInsertComponent={inspector.insertNodeComponent}
|
||||
onDeleteComponent={inspector.deleteNodeComponent}
|
||||
onMoveComponent={inspector.moveNodeComponent}
|
||||
onSetComponent={inspector.setNodeComponent}
|
||||
onDeleteNode={() => inspector.deleteNode(view.node.id)}
|
||||
deleteDisabled={
|
||||
inspector.isLocked || view.node.id === inspector.tree?.root.id
|
||||
@@ -293,10 +290,7 @@ function NodeInspector({
|
||||
fonts,
|
||||
fontFaces,
|
||||
projectPath,
|
||||
onSetComponents,
|
||||
onInsertComponent,
|
||||
onDeleteComponent,
|
||||
onMoveComponent,
|
||||
onSetComponent,
|
||||
onDeleteNode,
|
||||
deleteDisabled,
|
||||
}: {
|
||||
@@ -323,10 +317,7 @@ function NodeInspector({
|
||||
fonts: UiEditorInspectorProjection['fonts'];
|
||||
fontFaces: UiEditorInspectorProjection['fontFaces'];
|
||||
projectPath: string;
|
||||
onSetComponents: UiEditorInspectorProjection['setNodeComponents'];
|
||||
onInsertComponent: UiEditorInspectorProjection['insertNodeComponent'];
|
||||
onDeleteComponent: UiEditorInspectorProjection['deleteNodeComponent'];
|
||||
onMoveComponent: UiEditorInspectorProjection['moveNodeComponent'];
|
||||
onSetComponent: UiEditorInspectorProjection['setNodeComponent'];
|
||||
onDeleteNode: () => void;
|
||||
deleteDisabled: boolean;
|
||||
}) {
|
||||
@@ -414,7 +405,7 @@ function NodeInspector({
|
||||
来源:{node.metadata.source}
|
||||
</div>
|
||||
<div className="rounded-lg bg-black/4 p-2">
|
||||
组件:{node.components.length}
|
||||
组件:{node.component ? 1 : 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -434,18 +425,18 @@ function NodeInspector({
|
||||
}}
|
||||
/>
|
||||
<NodeStageSelect
|
||||
key={`components-status-${node.id}-${highlightedStatusField?.requestId ?? 'idle'}`}
|
||||
key={`component-status-${node.id}-${highlightedStatusField?.requestId ?? 'idle'}`}
|
||||
label="组件状态"
|
||||
value={node.metadata.components_status}
|
||||
value={node.metadata.component_status}
|
||||
disabled={isReadOnly}
|
||||
highlight={
|
||||
highlightedStatusField?.nodeId === node.id &&
|
||||
highlightedStatusField.field === 'components_status'
|
||||
highlightedStatusField.field === 'component_status'
|
||||
? highlightedStatusField
|
||||
: null
|
||||
}
|
||||
onChange={(components_status) => {
|
||||
if (!isReadOnly) onMetadataChange({ components_status });
|
||||
onChange={(component_status) => {
|
||||
if (!isReadOnly) onMetadataChange({ component_status });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -498,17 +489,14 @@ function NodeInspector({
|
||||
onChange={onLayoutChange}
|
||||
/>
|
||||
<ComponentPanel
|
||||
components={node.components}
|
||||
component={node.component}
|
||||
sprites={sprites}
|
||||
previewUrls={previewUrls}
|
||||
fonts={fonts}
|
||||
fontFaces={fontFaces}
|
||||
projectPath={projectPath}
|
||||
readOnly={isReadOnly}
|
||||
onSetComponents={onSetComponents}
|
||||
onInsertComponent={onInsertComponent}
|
||||
onDeleteComponent={onDeleteComponent}
|
||||
onMoveComponent={onMoveComponent}
|
||||
onSetComponent={onSetComponent}
|
||||
/>
|
||||
<ResourceId value={node.id} />
|
||||
<DeleteResourceButton
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user