UI编辑器节点改为单组件模型
Project CI / Repository checks (pull_request) Failing after 14s
Project CI / Backend tests (pull_request) Failing after 14s
Project CI / Frontend tests (pull_request) Successful in 2m43s
Project CI / Native shell tests (pull_request) Failing after 14m29s

修改 Node、Binding DTO、识别与分离链路为 component 单字段

为识别和绑定 LLM 工具引入 PureNode/WithComponent 显式载荷

移除前端组件索引、排序和组件栈操作

同步生成 ts-rs TypeScript 类型、状态校验、测试与专题文档
This commit is contained in:
2026-09-10 13:08:29 +08:00
parent d6c720ecdd
commit 51f629b2f9
39 changed files with 488 additions and 728 deletions
@@ -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,41 +325,26 @@ 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!(
if matches!(
component,
Component::Text(crate::ui_editor::component::text::TextComponent {
font: crate::ui_editor::component::text::FontSource::Bound(_),
..
})
)
}) {
) {
return Err("识别阶段不能返回已绑定的字体素材".to_string());
}
}
if let Confidence::UnSure(reason) = &node.confidence {
if reason.trim().is_empty() {
return Err("UnSure 必须包含审阅原因".to_string());
@@ -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 {
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),
};
@@ -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"
>
{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={() =>
setExpandedIndexes((current) => {
const next = new Set(current);
if (next.has(index)) next.delete(index);
else next.add(index);
return next;
})
}
onClick={() => setExpanded((current) => !current)}
aria-expanded={expanded}
>
<ExpandIcon expanded={expanded} />
{expanded ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
)}
<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="除组件"
onClick={() => setComponent(null)}
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,
)}
{renderComponentEditor(component, props, readOnly, setComponent)}
</div>
)}
</div>
);
})}
</div>
)}
{components.length === 0 && (
) : (
<p className="m-0 text-xs text-(--platform-text-soft)">
</p>
@@ -248,57 +116,31 @@ export function ComponentPanel(props: ComponentPanelProps) {
}
function componentKind(component: Component): string {
switch (true) {
case 'Image' in component:
return '图片';
case 'Text' in component:
return '文本';
default:
if ('Image' in component) return '图片';
if ('Text' in component) return '文本';
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} />;
}
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:
if ('Image' in component) {
return (
<ImagePanel
component={component.Image}
sprites={props.sprites}
previewUrls={props.previewUrls}
readOnly={readOnly}
onChange={(next) => updateComponent(index, { Image: next })}
onChange={(next) => updateComponent({ Image: next })}
/>
);
case 'Text' in component:
}
if ('Text' in component) {
return (
<TextPanel
component={component.Text}
@@ -306,14 +148,13 @@ function renderComponentEditor(
fontFaces={props.fontFaces}
projectPath={props.projectPath}
readOnly={readOnly}
onChange={(next) => updateComponent(index, { Text: next })}
onChange={(next) => updateComponent({ Text: next })}
/>
);
default:
}
return (
<p className="m-0 text-xs text-(--platform-text-soft)">
</p>
);
}
}
@@ -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;
};
@@ -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