正式合并UI识别与组件绑定

删除旧 recognition 与 binding 命令及前端 DTO

新增 recognition_and_binding 单次 workflow 与联合概览

移除 debug 超时特判并直达 binding-ready
This commit is contained in:
2026-09-05 11:55:47 +08:00
parent f630c518f3
commit 62793ac445
19 changed files with 284 additions and 1325 deletions
@@ -321,11 +321,11 @@ async fn suggest_ui_design_semantic(
}
#[tauri::command]
async fn recognize_ui(
async fn recognition_and_binding(
project_path: String,
state: ui_editor::state::State,
) -> Result<ui_editor::commands::RecognitionDTO, String> {
ui_editor::commands::recognize_ui_impl(project_path, state).await
) -> Result<ui_editor::commands::RecognitionAndBindingDTO, String> {
ui_editor::commands::recognition_and_binding_impl(project_path, state).await
}
#[tauri::command]
@@ -333,15 +333,6 @@ async fn merge_ui(state: ui_editor::state::State) -> Result<ui_editor::commands:
ui_editor::commands::merge_ui_impl(state).await
}
#[tauri::command]
async fn bind_components(
project_path: String,
state: ui_editor::state::State,
sprite_ids: Vec<String>,
) -> Result<ui_editor::commands::BindingDTO, String> {
ui_editor::commands::bind_components_impl(project_path, state, sprite_ids).await
}
#[tauri::command]
fn load_ui_design_state(
input: ui_editor::persistence::LoadUiDesignStateInput,
@@ -2540,9 +2531,8 @@ fn main() {
read_ui_editor_font_bytes,
check_ui_editor_font_glyph_coverage,
suggest_ui_design_semantic,
recognize_ui,
recognition_and_binding,
merge_ui,
bind_components,
load_ui_design_state,
save_ui_design_state,
generate_ui_design_code,
File diff suppressed because it is too large Load Diff
@@ -1,14 +1,13 @@
pub mod binding;
pub mod merge;
pub mod recognition;
pub mod recognition_and_binding;
pub mod ui_design_suggestion;
pub mod utils;
pub use binding::BindingDTO;
pub(crate) use binding::{bind_components_impl, bind_components_impl_with_provider};
pub(crate) use merge::merge_ui_impl;
pub use merge::MergeDTO;
pub(crate) use merge::{merge_ui_impl, merge_ui_impl_with_provider};
pub use recognition::RecognitionDTO;
pub(crate) use recognition::{recognize_ui_impl, recognize_ui_impl_with_provider};
pub use recognition_and_binding::RecognitionAndBindingDTO;
pub(crate) use recognition_and_binding::{
recognition_and_binding_impl, recognition_and_binding_impl_with_provider,
};
pub(crate) use ui_design_suggestion::suggest_ui_design_semantic_impl;
pub use ui_design_suggestion::UIDesignSuggestionTreeNode;
@@ -4,6 +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::layout::children_display_mode::ChildrenDisplayMode;
use crate::ui_editor::layout::control_layout::ControlLayout;
use crate::ui_editor::layout::dimension::UIRect;
@@ -18,15 +19,14 @@ use platform_llm::{
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::collections::HashSet;
use std::path::Path;
use ts_rs::TS;
const MAX_REFERENCES: usize = 4;
const MAX_RECOGNITION_TREE_NODES: usize = 512;
const MAX_RECOGNITION_TREE_DEPTH: usize = 32;
const SYSTEM_PROMPT: &str = r#"
const LEGACY_RECOGNITION_SYSTEM_PROMPT: &str = r#"
UI
@@ -49,6 +49,19 @@ const SYSTEM_PROMPT: &str = r#"
"#;
const RECOGNITION_AND_BINDING_SYSTEM_PROMPT: &str = "";
const LEGACY_BINDING_SYSTEM_PROMPT: &str = r#"
UI UI
* changes
* change components
* Component.
* NeedReview
* NoProblem
* reason 使
"#;
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
enum HorizontalAnchor {
@@ -109,6 +122,8 @@ struct RecognitionNode {
description: String,
children: Vec<RecognitionNode>,
confidence: Confidence,
#[serde(default)]
components: Option<Vec<Component>>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, JsonSchema)]
@@ -126,7 +141,7 @@ struct RecognitionResponse {
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub struct RecognitionDTO {
pub struct RecognitionAndBindingDTO {
pub ui_trees: Vec<UITree>,
}
@@ -315,7 +330,7 @@ fn convert_node(
// V1 有意把识别结果限定为“结构草稿”:组件绑定属于后续独立阶段。
// 因此空组件不是丢失数据,而是等待 visual-binding 阶段补齐 Image/Text。
// 约定见 docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md。
components: Vec::new(),
components: source.components.clone().unwrap_or_default(),
children_display_mode: ChildrenDisplayMode::Stack,
children,
})
@@ -387,6 +402,7 @@ mod tests {
description: String::new(),
children: Vec::new(),
confidence: Confidence::Confident,
components: Some(Vec::new()),
}
}
@@ -490,6 +506,7 @@ mod tests {
description: String::new(),
children: Vec::new(),
confidence: Confidence::UnSure(String::new()),
components: Some(Vec::new()),
};
assert!(validate_confidence(&[node]).is_err());
}
@@ -560,227 +577,124 @@ mod tests {
}
}
fn recognition_root_image_ids(state: &State) -> Vec<UIDesignImageId> {
state
.ui_design_images
.iter()
.filter_map(|(id, image)| {
let is_page = image.metadata.role
== Some(crate::ui_editor::resource::ui_design_image::UIDesignImageRole::Page);
let is_direct_slave_of_page = image.metadata.slave_to.as_ref().is_some_and(|parent| {
state
.ui_design_images
.get(parent)
.and_then(|parent_image| parent_image.metadata.role)
== Some(crate::ui_editor::resource::ui_design_image::UIDesignImageRole::Page)
});
(is_page || !is_direct_slave_of_page).then(|| id.clone())
})
.collect()
pub(crate) async fn recognition_and_binding_impl(
project_path: String,
state: State,
) -> Result<RecognitionAndBindingDTO, String> {
recognition_and_binding_impl_with_provider(project_path, state, None).await
}
fn slave_image_ids(state: &State, root_id: &UIDesignImageId) -> Vec<UIDesignImageId> {
state
.ui_design_images
.iter()
.filter_map(|(id, image)| {
(image.metadata.slave_to.as_ref() == Some(root_id)).then(|| id.clone())
})
.collect()
}
pub(crate) async fn recognize_ui_impl_with_provider(
pub(crate) async fn recognition_and_binding_impl_with_provider(
project_path: String,
state: State,
provider_identity: Option<(&str, &str)>,
) -> Result<RecognitionDTO, String> {
) -> Result<RecognitionAndBindingDTO, String> {
if state.ui_design_images.is_empty() {
app_log!("ui_recognition.error stage=validate reason=no_images");
return Err("请先导入界面图".to_string());
}
if state.ui_design_images.len() > MAX_REFERENCES {
app_log!(
"ui_recognition.error stage=validate reason=too_many_images count={}",
state.ui_design_images.len()
);
return Err("界面图最多 4 张".to_string());
}
let root_ids = recognition_root_image_ids(&state);
if root_ids.is_empty() {
app_log!("ui_recognition.error stage=validate reason=no_root_image");
return Err("至少需要一张可作为识别上下文根的界面图".to_string());
let context_ids = state.ui_design_images.keys().cloned().collect::<Vec<_>>();
let root = Path::new(project_path.trim());
let mut parts =
Vec::with_capacity(state.ui_design_images.len() * 2 + state.sprite_assets.len() * 2 + 2);
for (index, image_id) in context_ids.iter().enumerate() {
let image = state
.ui_design_images
.get(image_id)
.ok_or_else(|| "缺少界面图资源".to_string())?;
let absolute = crate::project::resolve_local_project_path(root, &image.path)?;
let image_url = read_ui_reference_image_data_url(absolute)
.await
.map_err(|error| format!("读取界面图失败:{error}"))?;
parts.push(LlmMessageContentPart::InputText {
text: format!(
"UI_REFERENCE {} id={} pixel_size={:?}",
if index == 0 { "ROOT" } else { "REFERENCE" },
image_id.as_str(),
image.pixel_size
),
});
parts.push(LlmMessageContentPart::InputImage { image_url });
}
for (sprite_id, sprite) in &state.sprite_assets {
let absolute = crate::project::resolve_local_project_path(root, &sprite.path)?;
let image_url = read_ui_reference_image_data_url(absolute)
.await
.map_err(|error| format!("读取独立素材失败:{error}"))?;
parts.push(LlmMessageContentPart::InputText {
text: format!(
"SPRITE id={} name={} asset_type={} pixel_size={:?}",
sprite_id.as_str(),
sprite.metadata.name,
sprite.metadata.asset_type,
sprite.pixel_size
),
});
parts.push(LlmMessageContentPart::InputImage { image_url });
}
parts.push(LlmMessageContentPart::InputText { text: "请直接返回完整最终 UI 树;每个节点可包含 components,空数组表示无组件。不要输出 node_id。".to_string() });
let (llm, client) = if provider_identity.is_none() {
let llm = load_game_creator_app_config()
.map_err(|error| {
app_log!("ui_recognition.error stage=build_client error={error}");
error
})?
.llm;
let client =
build_game_creator_llm_client_from_llm_config(&llm, "llm").map_err(|error| {
eprintln!("ui_recognition.error stage=build_client error={error}");
error
})?;
let llm = load_game_creator_app_config()?.llm;
let client = build_game_creator_llm_client_from_llm_config(&llm, "llm")
.map_err(|error| error.to_string())?;
(Some(llm), Some(client))
} else {
(None, None)
};
let schema = recognition_json_schema().map_err(|error| {
app_log!("ui_recognition.error stage=build_schema error={error}");
error
})?;
let root = Path::new(project_path.trim());
let mut ui_trees = Vec::with_capacity(root_ids.len());
for root_id in root_ids {
let mut context_ids = vec![root_id.clone()];
context_ids.extend(slave_image_ids(&state, &root_id));
// Page 仍可把直接 slave 参考图放在同一识别上下文;没有 Page
// 归属关系的其它界面图各自作为独立上下文根。
let mut parts = Vec::with_capacity(context_ids.len() * 2);
for (index, context_id) in context_ids.iter().enumerate() {
let image = state
.ui_design_images
.get(context_id)
.ok_or_else(|| "缺少识别上下文界面图资源".to_string())?;
let absolute =
crate::project::resolve_local_project_path(root, &image.path).map_err(|error| {
app_log!(
"ui_recognition.error stage=resolve_image root={} image={} error={error}",
root_id.as_str(),
context_id.as_str()
);
error
})?;
let image_url = read_ui_reference_image_data_url(absolute)
.await
.map_err(|error| {
app_log!(
"ui_recognition.error stage=read_image root={} image={} error={error}",
root_id.as_str(),
context_id.as_str()
);
error
})?;
parts.push(LlmMessageContentPart::InputText {
text: format!(
"{} id={} pixel_size={:?}",
if index == 0 { "ROOT" } else { "SLAVE" },
context_id.as_str(),
image.pixel_size
),
});
parts.push(LlmMessageContentPart::InputImage { image_url });
}
let tool = LlmFunctionTool::new(
"recognize_ui_structure",
"识别当前界面图上下文,并为每张输入图片各返回一棵 UI 树",
schema.clone(),
let tool = LlmFunctionTool::new(
"recognition_and_binding",
"一次性识别界面结构并绑定全部视觉素材,返回完整 UI 树",
recognition_json_schema()?,
)
.with_strict(true);
let request = LlmRunRequest::new(vec![
LlmMessage::system(RECOGNITION_AND_BINDING_SYSTEM_PROMPT),
LlmMessage::user_multimodal(parts),
])
.with_function_tools(vec![tool])
.with_tool_choice(LlmToolChoice::Required);
let response = if let Some((agent_id, run_id)) = provider_identity {
crate::agent::request_game_creator_ui_editor_llm_at(
root,
agent_id,
run_id,
"ui-editor-recognition-and-binding",
request,
)
.with_strict(true);
let request = LlmRunRequest::new(vec![
LlmMessage::system(SYSTEM_PROMPT),
LlmMessage::user_multimodal(parts),
])
.with_function_tools(vec![tool])
.with_tool_choice(LlmToolChoice::Required);
let response = if let Some((agent_id, run_id)) = provider_identity {
crate::agent::request_game_creator_ui_editor_llm_at(
root,
agent_id,
run_id,
"ui-editor-recognize",
request,
)
.await
.map_err(platform_llm::LlmError::InvalidRequest)
} else {
request_ui_editor_llm(
client
.as_ref()
.expect("provider client exists without runtime identity"),
llm.as_ref()
.expect("LLM config exists without runtime identity"),
request,
)
.await
}
.map_err(|error| {
app_log!(
"ui_recognition.error stage=llm_request root={} error={error}",
root_id.as_str()
);
format!("UI 结构识别失败(根界面图 {}):{error}", root_id.as_str())
})?;
app_log!(
"ui_recognition.llm_output root={} text_present={} tool_call_count={}",
root_id.as_str(),
!response.text.trim().is_empty(),
response.tool_calls.len()
);
let call = response
.tool_calls
.await
.map_err(platform_llm::LlmError::InvalidRequest)
} else {
request_ui_editor_llm(client.as_ref().unwrap(), llm.as_ref().unwrap(), request).await
}
.map_err(|error| format!("UI 编辑器单次计算失败:{error}"))?;
let call = response
.tool_calls
.iter()
.find(|call| call.name == "recognition_and_binding")
.ok_or_else(|| "LLM 未返回 recognition_and_binding 工具调用".to_string())?;
let arguments = parse_limited_llm_tool_arguments(&call.arguments)?;
validate_recognition_response_shape(&arguments)?;
let parsed = serde_json::from_value::<RecognitionResponse>(arguments)
.map_err(|error| format!("联合结果参数无效:{error}"))?;
validate_tree_image_ids(&parsed.trees, &context_ids)?;
let mut ui_trees = Vec::with_capacity(parsed.trees.len());
for tree in parsed.trees {
let image = state
.ui_design_images
.get(&tree.src_ui_design_image_id)
.ok_or_else(|| "缺少界面图资源".to_string())?;
let size = image_layout_size(image)?;
let root_rect = UIRect::new(Point2::origin(), size);
let children = tree
.children
.iter()
.find(|call| call.name == "recognize_ui_structure")
.ok_or_else(|| {
app_log!(
"ui_recognition.error stage=parse_tool_call root={} reason=missing_tool_call",
root_id.as_str()
);
format!(
"根界面图 {} 的 LLM 未返回 recognize_ui_structure 工具调用",
root_id.as_str()
)
})?;
let arguments = parse_limited_llm_tool_arguments(&call.arguments).map_err(|error| {
app_log!(
"ui_recognition.error stage=parse_arguments root={} error={error}",
root_id.as_str()
);
format!("根界面图 {} 的识别工具参数无效:{error}", root_id.as_str())
})?;
validate_recognition_response_shape(&arguments).map_err(|error| {
app_log!(
"ui_recognition.error stage=validate_arguments root={} error={error}",
root_id.as_str()
);
format!("根界面图 {} 的识别工具参数无效:{error}", root_id.as_str())
})?;
let parsed = serde_json::from_value::<RecognitionResponse>(arguments).map_err(|error| {
app_log!(
"ui_recognition.error stage=parse_arguments root={} error={error}",
root_id.as_str()
);
format!("根界面图 {} 的识别工具参数无效:{error}", root_id.as_str())
})?;
validate_tree_image_ids(&parsed.trees, &context_ids).map_err(|error| {
app_log!(
"ui_recognition.error stage=validate_trees root={} error={error}",
root_id.as_str()
);
error
})?;
let mut parsed_trees = parsed
.trees
.into_iter()
.map(|tree| (tree.src_ui_design_image_id.clone(), tree))
.collect::<HashMap<_, _>>();
for image_id in context_ids {
let image = state
.ui_design_images
.get(&image_id)
.ok_or_else(|| "缺少识别上下文界面图资源".to_string())?;
let tree = parsed_trees
.remove(&image_id)
.ok_or_else(|| format!("缺少界面图 {} 的识别树", image_id.as_str()))?;
let size = image_layout_size(image)?;
let root_rect = UIRect::new(Point2::origin(), size);
let children = tree
.children
.iter()
.map(|node| convert_node(node, &image_id, image, root_rect))
.collect::<Result<Vec<_>, _>>()?;
let root = LayoutNode {
.map(|node| convert_node(node, &tree.src_ui_design_image_id, image, root_rect))
.collect::<Result<Vec<_>, _>>()?;
ui_trees.push(UITree {
src_ui_design: tree.src_ui_design_image_id,
root: LayoutNode {
id: random_node_id()?,
layout: ControlLayout::with_transform(Transform::stretch()),
metadata: NodeMetadata {
@@ -790,25 +704,19 @@ pub(crate) async fn recognize_ui_impl_with_provider(
components_status: StageStatus::NoProblem,
allow_llm_edit_layout: true,
allow_llm_edit_component: true,
source: NodeSource::System,
source: NodeSource::Llm,
},
components: Vec::new(),
children_display_mode: ChildrenDisplayMode::Stack,
children,
};
// Tree identity and root identity are assigned by Rust, never chosen by the model.
ui_trees.push(UITree {
src_ui_design: image_id,
root,
});
}
},
});
}
Ok(RecognitionDTO { ui_trees })
}
pub(crate) async fn recognize_ui_impl(
project_path: String,
state: State,
) -> Result<RecognitionDTO, String> {
recognize_ui_impl_with_provider(project_path, state, None).await
app_log!(
"ui_editor.compute.completed requests=1 ui_images={} sprites={} trees={}",
state.ui_design_images.len(),
state.sprite_assets.len(),
ui_trees.len()
);
Ok(RecognitionAndBindingDTO { ui_trees })
}
@@ -1,8 +1,4 @@
use crate::ui_editor::commands::binding::BindingChange;
use crate::ui_editor::commands::{
bind_components_impl_with_provider, merge_ui_impl_with_provider,
recognize_ui_impl_with_provider,
};
use crate::ui_editor::commands::recognition_and_binding_impl_with_provider;
use crate::ui_editor::layout::node::{Node, StageStatus};
use crate::ui_editor::persistence::{
initialize_ui_design_state_at, load_ui_design_state_at, save_ui_design_state_at,
@@ -96,7 +92,6 @@ struct UiWorkflowPageDeclaration {
#[serde(rename_all = "kebab-case")]
pub(crate) enum UiWorkflowPageStage {
ReferenceReady,
StructureReady,
BindingReady,
ApplicationReady,
Completed,
@@ -258,7 +253,7 @@ pub(crate) async fn run_ui_workflow_at_with_provider(
// binding commands as the editor. There is intentionally no
// deterministic fallback here: a missing provider or malformed
// response is returned to the caller and leaves the durable stage
// at reference-ready/structure-ready rather than claiming UI
// at reference-ready rather than claiming UI
// semantics were recognized.
recognize_page_semantics(root, &manifest.project_id, page, provider_identity).await?;
}
@@ -924,10 +919,9 @@ fn install_page_component_assets(
/// Runs the provider-backed editor pipeline for one workflow page.
///
/// `recognize_ui_impl` owns multimodal semantic recognition and strict tool
/// response validation. `bind_components_impl` owns visual component binding
/// and its allowlisted sprite validation. This wrapper only persists their
/// DTOs under the UI State revision gate; it never manufactures a tree when a
/// `recognition_and_binding_impl` owns the single multimodal request, strict
/// response validation, and allowlisted sprite validation. This wrapper only
/// persists its DTO under the UI State revision gate; it never manufactures a tree when a
/// provider is unavailable or returns an invalid result.
async fn recognize_page_semantics(
root: &Path,
@@ -942,225 +936,47 @@ async fn recognize_page_semantics(
expected_project_id: project_id.to_string(),
asset_id: page.ui_asset.id.clone(),
})?;
let mut stage = page
.ui_asset
.source
.generation_kind
.as_deref()
.unwrap_or("ui-workflow")
.to_string();
let has_page_tree = snapshot
.state
.ui_trees
.iter()
.filter(|tree| tree.src_ui_design == image_id)
.count()
== 1;
// Every provider-backed phase is persisted independently. A retry resumes
// from the latest truthful manifest stage instead of repeating completed
// calls or manufacturing fallback output.
if !matches!(
stage.as_str(),
"ui-workflow.structure-ready" | "ui-workflow.merge-ready" | "ui-workflow.binding-ready"
) {
let project_path = root.to_string_lossy().into_owned();
let recognition = recognize_ui_impl_with_provider(
project_path,
snapshot.state.clone(),
provider_identity,
)
.await
.map_err(|error| format!("页面 {} UI 语义识别失败:{error}", page.input.page_id))?;
if recognition.ui_trees.len() != 1 || recognition.ui_trees[0].src_ui_design != image_id {
return Err(format!(
"页面 {} UI 语义识别返回的树与页面设计图不匹配",
page.input.page_id
));
}
let mut state = snapshot.state;
state.ui_trees = recognition.ui_trees;
match save_ui_design_state_at(SaveUiDesignStateInput {
project_path: root.to_string_lossy().into_owned(),
expected_project_id: project_id.to_string(),
asset_id: page.ui_asset.id.clone(),
expected_revision: snapshot.revision,
state,
})? {
SaveUiDesignStateResult::Saved { .. } | SaveUiDesignStateResult::Unchanged { .. } => {}
SaveUiDesignStateResult::Conflict { .. } => {
return Err(format!(
"页面 {} UI 语义识别保存 revision 冲突,请重试",
page.input.page_id
));
}
}
update_page_manifest_stage(root, &page.ui_asset.id, "structure-ready")?;
stage = "ui-workflow.structure-ready".to_string();
} else if !has_page_tree {
let result = recognition_and_binding_impl_with_provider(
root.to_string_lossy().into_owned(),
snapshot.state.clone(),
provider_identity,
)
.await
.map_err(|error| format!("页面 {} UI 联合识别绑定失败:{error}", page.input.page_id))?;
if result.ui_trees.len() != 1 || result.ui_trees[0].src_ui_design != image_id {
return Err(format!(
"页面 {} manifest 已记录语义识别阶段,但 UI State 缺少唯一结构树",
"页面 {} UI 联合识别绑定返回的树与页面设计图不匹配",
page.input.page_id
));
}
if stage == "ui-workflow.binding-ready" {
return Ok(());
}
if stage == "ui-workflow.structure-ready" {
let merge_snapshot = load_ui_design_state_at(LoadUiDesignStateInput {
project_path: root.to_string_lossy().into_owned(),
expected_project_id: project_id.to_string(),
asset_id: page.ui_asset.id.clone(),
})?;
let merged = merge_ui_impl_with_provider(
root.to_string_lossy().into_owned(),
merge_snapshot.state.clone(),
provider_identity,
)
.await
.map_err(|error| format!("页面 {} UI 多树合并失败:{error}", page.input.page_id))?;
if merged.ui_tree.src_ui_design != image_id {
return Err(format!(
"页面 {} UI 多树合并结果未绑定主页面设计图",
page.input.page_id
));
}
let mut state = merge_snapshot.state;
state.ui_trees = vec![merged.ui_tree];
match save_ui_design_state_at(SaveUiDesignStateInput {
project_path: root.to_string_lossy().into_owned(),
expected_project_id: project_id.to_string(),
asset_id: page.ui_asset.id.clone(),
expected_revision: merge_snapshot.revision,
state,
})? {
SaveUiDesignStateResult::Saved { .. } | SaveUiDesignStateResult::Unchanged { .. } => {}
SaveUiDesignStateResult::Conflict { .. } => {
return Err(format!(
"页面 {} UI 多树合并保存 revision 冲突,请重试",
page.input.page_id
));
}
}
update_page_manifest_stage(root, &page.ui_asset.id, "merge-ready")?;
stage = "ui-workflow.merge-ready".to_string();
}
if stage != "ui-workflow.merge-ready" {
if !state_has_renderable_component(&crate::ui_editor::state::State {
ui_trees: result.ui_trees.clone(),
..snapshot.state.clone()
}) {
return Err(format!(
"页面 {} UI workflow 阶段无法进入组件绑定:{stage}",
"页面 {} UI 联合识别绑定未形成可渲染组件",
page.input.page_id
));
}
let mut binding_snapshot = load_ui_design_state_at(LoadUiDesignStateInput {
let mut state = snapshot.state;
state.ui_trees = result.ui_trees;
match save_ui_design_state_at(SaveUiDesignStateInput {
project_path: root.to_string_lossy().into_owned(),
expected_project_id: project_id.to_string(),
asset_id: page.ui_asset.id.clone(),
})?;
let mut sprite_ids = binding_snapshot
.state
.sprite_assets
.keys()
.map(|id| id.as_str().to_string())
.collect::<Vec<_>>();
sprite_ids.sort();
if sprite_ids.is_empty() {
return Err(format!(
"页面 {} UI 语义识别已完成,但没有可用于组件绑定的页面素材",
page.input.page_id
));
}
let mut changed_nodes = 0usize;
for batch in sprite_ids.chunks(crate::ui_editor::commands::binding::ASSET_BATCH_SIZE) {
let binding = bind_components_impl_with_provider(
root.to_string_lossy().into_owned(),
binding_snapshot.state.clone(),
batch.to_vec(),
provider_identity,
)
.await
.map_err(|error| format!("页面 {} UI 组件语义绑定失败:{error}", page.input.page_id))?;
if binding.changes.is_empty() {
continue;
expected_revision: snapshot.revision,
state,
})? {
SaveUiDesignStateResult::Saved { .. } | SaveUiDesignStateResult::Unchanged { .. } => {}
SaveUiDesignStateResult::Conflict { .. } => {
return Err(format!(
"页面 {} UI 联合识别绑定保存 revision 冲突,请重试",
page.input.page_id
));
}
let mut state = binding_snapshot.state.clone();
let changes = binding
.changes
.into_iter()
.map(|change| (change.node_id.clone(), change))
.collect::<HashMap<_, _>>();
changed_nodes += apply_binding_changes(&mut state.ui_trees, &changes);
binding_snapshot = match save_ui_design_state_at(SaveUiDesignStateInput {
project_path: root.to_string_lossy().into_owned(),
expected_project_id: project_id.to_string(),
asset_id: page.ui_asset.id.clone(),
expected_revision: binding_snapshot.revision,
state,
})? {
SaveUiDesignStateResult::Saved {
state, revision, ..
}
| SaveUiDesignStateResult::Unchanged {
state, revision, ..
} => crate::ui_editor::persistence::UiDesignStateSnapshot { state, revision },
SaveUiDesignStateResult::Conflict { .. } => {
return Err(format!(
"页面 {} UI 组件绑定保存 revision 冲突,请重试",
page.input.page_id
));
}
};
}
if changed_nodes == 0 || !state_has_renderable_component(&binding_snapshot.state) {
return Err(format!(
"页面 {} UI 组件语义绑定未形成可渲染组件,拒绝进入 binding-ready",
page.input.page_id
));
}
let mut binding_blockers = Vec::new();
let mut component_count = 0usize;
for tree in &binding_snapshot.state.ui_trees {
collect_binding_blockers(&tree.root, &mut component_count, &mut binding_blockers);
}
if !binding_blockers.is_empty() {
// Keep the provider result available for review, but do not claim the
// binding stage. A subsequent recognize operation can retry binding
// from the durable structure-ready state.
return Ok(());
}
update_page_manifest_stage(root, &page.ui_asset.id, "binding-ready")
}
fn apply_binding_changes(
trees: &mut [crate::ui_editor::state::UITree],
changes: &HashMap<crate::ui_editor::utils::NodeId, BindingChange>,
) -> usize {
fn apply_node(
node: &mut Node,
changes: &HashMap<crate::ui_editor::utils::NodeId, BindingChange>,
) -> 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();
changed += 1;
}
for child in &mut node.children {
changed += apply_node(child, changes);
}
changed
}
trees
.iter_mut()
.map(|tree| apply_node(&mut tree.root, changes))
.sum()
}
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)
@@ -1207,11 +1023,9 @@ fn update_page_manifest_stage(root: &Path, asset_id: &str, stage: &str) -> Resul
fn workflow_stage_rank(kind: &str) -> Option<u8> {
match kind {
"ui-workflow.reference-ready" => Some(1),
"ui-workflow.structure-ready" => Some(2),
"ui-workflow.merge-ready" => Some(3),
"ui-workflow.binding-ready" => Some(4),
"ui-workflow.application-ready" => Some(5),
"ui-workflow.completed" => Some(6),
"ui-workflow.binding-ready" => Some(2),
"ui-workflow.application-ready" => Some(3),
"ui-workflow.completed" => Some(4),
_ => None,
}
}
@@ -1268,7 +1082,6 @@ fn derive_page_status(
if matching_trees.len() != 1 {
blockers.push("尚未形成唯一的页面 UI 结构树".to_string());
} else {
stage = UiWorkflowPageStage::StructureReady;
let mut component_count = 0usize;
collect_binding_blockers(&matching_trees[0].root, &mut component_count, &mut blockers);
if component_count == 0 {
@@ -1,21 +0,0 @@
import type { BindingDTO } from './types/BindingDTO';
import type { Node } from './types/Node';
import type { State } from './types/State';
function applyChanges(node: Node, result: BindingDTO): void {
const change = result.changes.find(
(candidate) => candidate.node_id === node.id,
);
if (change) {
node.components = structuredClone(change.components);
node.metadata.components_status = structuredClone(change.components_status);
}
for (const child of node.children) applyChanges(child, result);
}
/** Applies only explicit component changes; omitted nodes remain untouched. */
export function applyBindingResult(state: State, result: BindingDTO): State {
const next = structuredClone(state);
for (const tree of next.ui_trees) applyChanges(tree.root, result);
return next;
}
@@ -1,16 +0,0 @@
import type { RecognitionDTO } from './types/RecognitionDTO';
import type { State } from './types/State';
/**
* 识别结果是整棵结构草稿树的替换结果,不与旧树逐节点合并。
* 识别阶段有意不携带视觉组件;组件绑定由后续 visual-binding 阶段完成。
*/
export function applyRecognitionResult(
state: State,
result: RecognitionDTO,
): State {
return {
...structuredClone(state),
ui_trees: structuredClone(result.ui_trees),
};
}
@@ -0,0 +1,15 @@
import type { RecognitionAndBindingDTO } from './types/RecognitionAndBindingDTO';
import type { State } from './types/State';
/**
* 联合识别绑定结果是整棵最终树的替换结果,不与旧树逐节点合并。
*/
export function applyRecognitionAndBindingResult(
state: State,
result: RecognitionAndBindingDTO,
): State {
return {
...structuredClone(state),
ui_trees: structuredClone(result.ui_trees),
};
}
@@ -1,6 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Component } from "./Component";
import type { NodeId } from "./NodeId";
import type { StageStatus } from "./StageStatus";
export type BindingChange = { node_id: NodeId, components: Array<Component>, components_status: StageStatus, };
@@ -1,4 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { BindingChange } from "./BindingChange";
export type BindingDTO = { changes: Array<BindingChange>, };
@@ -1,4 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { UITree } from "./UITree";
export type RecognitionDTO = { ui_trees: Array<UITree>, };
export type RecognitionAndBindingDTO = { ui_trees: Array<UITree>, };
@@ -5,13 +5,18 @@ import {
nodeHasBlockedComponents,
nodeNeedsComponentReview,
} from '../../../features/ui-editor/bindingOverview';
import {
getStageStatusOverview,
isBlocked,
isNeedReview,
} from '../../../features/ui-editor/stageStatusOverview';
import type { NodeId } from '../../../features/ui-editor/types/NodeId';
import type { SpriteAsset } from '../../../features/ui-editor/types/SpriteAsset';
import type { UIDesignImageId } from '../../../features/ui-editor/types/UIDesignImageId';
import type { UITree } from '../../../features/ui-editor/types/UITree';
import { useUiTreeNodeCycle } from '../useUiTreeNodeCycle';
export function BindingOverview({
export function RecognitionAndBindingOverview({
uiTrees,
sprites,
onFocusStatusNode,
@@ -20,16 +25,33 @@ export function BindingOverview({
sprites: Record<string, SpriteAsset>;
onFocusStatusNode: (treeId: UIDesignImageId, nodeId: NodeId) => void;
}) {
const overview = useMemo(
const recognition = useMemo(
() => getStageStatusOverview(uiTrees, 'layout_status'),
[uiTrees],
);
const binding = useMemo(
() => getBindingOverview(uiTrees, sprites),
[sprites, uiTrees],
);
const attentionCycle = useUiTreeNodeCycle({
const layoutAttentionCycle = useUiTreeNodeCycle({
uiTrees,
onFocusNode: onFocusStatusNode,
matches: ({ node }) => {
const status = node.metadata.layout_status;
return isBlocked(status) || isNeedReview(status);
},
});
const layoutBlockedCycle = useUiTreeNodeCycle({
uiTrees,
onFocusNode: onFocusStatusNode,
matches: ({ node }) => isBlocked(node.metadata.layout_status),
});
const componentAttentionCycle = useUiTreeNodeCycle({
uiTrees,
onFocusNode: onFocusStatusNode,
matches: nodeNeedsComponentReview,
});
const blockedCycle = useUiTreeNodeCycle({
const componentBlockedCycle = useUiTreeNodeCycle({
uiTrees,
onFocusNode: onFocusStatusNode,
matches: nodeHasBlockedComponents,
@@ -38,37 +60,53 @@ export function BindingOverview({
return (
<section
className="shrink-0 border-b border-l border-(--platform-subpanel-border) bg-white/35 px-4 pb-3"
aria-label="绑定概览"
aria-label="识别与绑定概览"
>
<span className="text-[10px] font-semibold tracking-wider text-(--platform-text-soft) uppercase">
Overview
</span>
<h2 className="m-0 text-sm font-semibold"></h2>
<h2 className="m-0 text-sm font-semibold"></h2>
<div className="mt-3 grid grid-cols-2 gap-2">
<OverviewValue label="已识别 Node" value={recognition.total} />
<OverviewValue label="无问题 Node" value={recognition.noProblem} />
<OverviewAction
label="结构待检查"
value={recognition.needsAttention}
tone="warning"
disabled={recognition.needsAttention === 0}
onClick={layoutAttentionCycle.focusNext}
/>
<OverviewAction
label="结构必须修复"
value={recognition.blocked}
tone="danger"
disabled={recognition.blocked === 0}
onClick={layoutBlockedCycle.focusNext}
/>
<OverviewValue
label="需要素材的组件"
value={overview.componentsNeedingAssets}
value={binding.componentsNeedingAssets}
/>
<OverviewValue label="素材槽位" value={overview.assetSlots} />
<OverviewValue label="已绑定" value={overview.boundSlots} />
<OverviewValue label="待处理" value={overview.pendingSlots} />
<OverviewValue label="素材槽位" value={binding.assetSlots} />
<OverviewValue label="已绑定" value={binding.boundSlots} />
<OverviewValue label="待处理" value={binding.pendingSlots} />
<OverviewAction
label="待用户检查"
value={overview.needsAttention}
label="组件待检查"
value={binding.needsAttention}
tone="warning"
disabled={overview.needsAttention === 0}
onClick={attentionCycle.focusNext}
disabled={binding.needsAttention === 0}
onClick={componentAttentionCycle.focusNext}
/>
<OverviewAction
label="必须修复"
value={overview.blocked}
label="组件必须修复"
value={binding.blocked}
tone="danger"
disabled={overview.blocked === 0}
onClick={blockedCycle.focusNext}
disabled={binding.blocked === 0}
onClick={componentBlockedCycle.focusNext}
/>
<OverviewValue
label="独立素材"
value={overview.independentAssets}
value={binding.independentAssets}
wide
/>
</div>
@@ -1,112 +0,0 @@
import { useMemo } from 'react';
import {
getStageStatusOverview,
isBlocked,
isNeedReview,
} from '../../../features/ui-editor/stageStatusOverview';
import type { NodeId } from '../../../features/ui-editor/types/NodeId';
import type { UIDesignImageId } from '../../../features/ui-editor/types/UIDesignImageId';
import type { UITree } from '../../../features/ui-editor/types/UITree';
import { useUiTreeNodeCycle } from '../useUiTreeNodeCycle';
export function RecognitionOverview({
uiTrees,
onFocusStatusNode,
}: {
uiTrees: UITree[];
onFocusStatusNode: (treeId: UIDesignImageId, nodeId: NodeId) => void;
}) {
const overview = useMemo(
() => getStageStatusOverview(uiTrees, 'layout_status'),
[uiTrees],
);
const attentionCycle = useUiTreeNodeCycle({
uiTrees,
onFocusNode: onFocusStatusNode,
matches: ({ node }) => {
const status = node.metadata.layout_status;
return isBlocked(status) || isNeedReview(status);
},
});
const blockedCycle = useUiTreeNodeCycle({
uiTrees,
onFocusNode: onFocusStatusNode,
matches: ({ node }) => isBlocked(node.metadata.layout_status),
});
return (
<section
className="shrink-0 border-b border-l border-(--platform-subpanel-border) bg-white/35 px-4 pb-3"
aria-label="识别概览"
>
<span className="text-[10px] font-semibold tracking-wider text-(--platform-text-soft) uppercase">
Overview
</span>
<h2 className="m-0 text-sm font-semibold"></h2>
<div className="mt-3 grid grid-cols-2 gap-2">
<OverviewValue label="已识别 Node" value={overview.total} />
<OverviewAction
label="待用户检查"
value={overview.needsAttention}
tone="warning"
disabled={overview.needsAttention === 0}
onClick={attentionCycle.focusNext}
/>
<OverviewValue label="无问题" value={overview.noProblem} />
<OverviewAction
label="必须修复"
value={overview.blocked}
tone="danger"
disabled={overview.blocked === 0}
onClick={blockedCycle.focusNext}
/>
</div>
</section>
);
}
function OverviewValue({ label, value }: { label: string; value: number }) {
return (
<div className="rounded-lg border border-(--platform-subpanel-border) bg-white/45 p-2 text-center">
<strong className="block text-base font-semibold text-(--platform-text-strong)">
{value}
</strong>
<span className="block text-[10px] text-(--platform-text-soft)">
{label}
</span>
</div>
);
}
function OverviewAction({
label,
value,
tone,
disabled,
onClick,
}: {
label: string;
value: number;
tone: 'warning' | 'danger';
disabled: boolean;
onClick: () => void;
}) {
const toneClass =
tone === 'danger'
? 'border-red-200 bg-red-50 text-red-800 hover:border-red-300 hover:bg-red-100'
: 'border-amber-200 bg-amber-50 text-amber-900 hover:border-amber-300 hover:bg-amber-100';
return (
<button
type="button"
className={`rounded-lg border p-2 text-center transition disabled:cursor-default disabled:opacity-45 ${toneClass}`}
disabled={disabled}
onClick={onClick}
aria-label={`${label} ${value},定位下一项`}
>
<strong className="block text-base font-semibold">{value}</strong>
<span className="block text-[10px]">{label}</span>
</button>
);
}
@@ -13,7 +13,7 @@ export function ToolNavigation({
}) {
return (
<nav
className="grid shrink-0 grid-cols-3 gap-2 border-b border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) p-3"
className="grid shrink-0 grid-cols-2 gap-2 border-b border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) p-3"
aria-label="UI 编辑流程"
>
{UI_EDITOR_STEPS.map((step, index) => {
@@ -9,13 +9,12 @@ export function WorkflowActionCard({
const running =
(workflow.activeStep === 'reference-analysis' && workflow.isSuggesting) ||
(workflow.activeStep === 'structure-recognition' &&
workflow.isRecognizing) ||
(workflow.activeStep === 'visual-binding' && workflow.isBinding);
(workflow.isRecognizing || workflow.isBinding));
const status =
workflow.activeStep === 'reference-analysis'
? workflow.suggestionStatus
: workflow.activeStep === 'structure-recognition'
? workflow.recognitionStatus
? workflow.bindingStatus || workflow.recognitionStatus
: workflow.bindingStatus;
const hasRun =
workflow.activeStep === 'reference-analysis'
@@ -70,14 +69,14 @@ function getStepAction(workflow: UiEditorWorkflowProjection) {
}
if (workflow.activeStep === 'structure-recognition') {
return {
label: '识别界面结构',
runningLabel: '识别中…',
action: workflow.recognizeUi,
label: '一键识别并绑定',
runningLabel: '计算中…',
action: workflow.bindComponents,
};
}
return {
label: '绑定视觉素材',
runningLabel: '绑定中…',
label: '一键识别并绑定',
runningLabel: '计算中…',
action: workflow.bindComponents,
};
}
@@ -7,13 +7,12 @@ import {
type IUiDesignStateStore,
uiDesignStateStore,
} from '../../features/ui-editor/uiDesignStateStore';
import { BindingOverview } from './components/BindingOverview';
import { EditorDialogs } from './components/EditorDialogs';
import { ImportOverview } from './components/ImportOverview';
import { InputSidebar } from './components/InputSidebar';
import { InspectorSidebar } from './components/Inspector/InspectorSidebar';
import { PreviewWorkspace } from './components/preview/PreviewWorkspace';
import { RecognitionOverview } from './components/RecognitionOverview';
import { RecognitionAndBindingOverview } from './components/RecognitionAndBindingOverview';
import { ToolNavigation } from './components/ToolNavigation';
import { WorkflowActionCard } from './components/WorkflowActionCard';
import { UI_EDITOR_STEPS, type UiEditorStepId } from './model';
@@ -270,21 +269,14 @@ export default function UiEditorPage({
<PreviewWorkspace canvas={session.canvas} />
</div>
<div className="flex min-h-0 min-w-0 flex-col overflow-hidden">
{session.input.activeStep === 'structure-recognition' ? (
<RecognitionOverview
uiTrees={session.input.uiTrees}
onFocusStatusNode={(treeId, nodeId) => {
session.input.focusNode(treeId, nodeId);
session.input.highlightStatusField(nodeId, 'layout_status');
}}
/>
) : session.input.activeStep === 'visual-binding' ? (
<BindingOverview
{session.input.activeStep === 'structure-recognition' ||
session.input.activeStep === 'visual-binding' ? (
<RecognitionAndBindingOverview
uiTrees={session.input.uiTrees}
sprites={session.input.sprites}
onFocusStatusNode={(treeId, nodeId) => {
session.input.focusNode(treeId, nodeId);
session.input.highlightStatusField(nodeId, 'components_status');
session.input.highlightStatusField(nodeId, 'layout_status');
}}
/>
) : (
@@ -26,8 +26,7 @@ export const UI_EDITOR_STEPS: Array<{
label: string;
}> = [
{ id: 'reference-analysis', label: '分析参考图' },
{ id: 'structure-recognition', label: '识别界面结构' },
{ id: 'visual-binding', label: '绑定视觉素材' },
{ id: 'structure-recognition', label: '一键识别并绑定' },
];
export const UI_DESIGN_IMAGE_ROLES: Array<{
@@ -2,24 +2,22 @@ import { invoke } from '@tauri-apps/api/core';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ImportedAsset } from '../../components/AssetImporter';
import { applyBindingResult } from '../../features/ui-editor/binding';
import {
prepareDesignImageBatch,
prepareFontAssetBatch,
prepareSpriteAssetBatch,
} from '../../features/ui-editor/importAdapter';
import { applyMergeResult } from '../../features/ui-editor/merge';
import { applyRecognitionResult } from '../../features/ui-editor/recognition';
import { applyRecognitionAndBindingResult } from '../../features/ui-editor/recognitionAndBinding';
import type { StageStatusField } from '../../features/ui-editor/stageStatusOverview';
import { collectUiNodeIds } from '../../features/ui-editor/treeUtils';
import type { BindingDTO } from '../../features/ui-editor/types/BindingDTO';
import type { ChildrenDisplayMode } from '../../features/ui-editor/types/ChildrenDisplayMode';
import type { Component } from '../../features/ui-editor/types/Component';
import type { FontAssetId } from '../../features/ui-editor/types/FontAssetId';
import type { MergeDTO } from '../../features/ui-editor/types/MergeDTO';
import type { Node as UiNode } from '../../features/ui-editor/types/Node';
import type { NodeId } from '../../features/ui-editor/types/NodeId';
import type { RecognitionDTO } from '../../features/ui-editor/types/RecognitionDTO';
import type { RecognitionAndBindingDTO } from '../../features/ui-editor/types/RecognitionAndBindingDTO';
import type { SpriteAssetId } from '../../features/ui-editor/types/SpriteAssetId';
import type { SpriteBorder } from '../../features/ui-editor/types/SpriteBorder';
import type { State } from '../../features/ui-editor/types/State';
@@ -63,8 +61,6 @@ import {
} from './model';
import { useUiEditorNodeFocus } from './useUiEditorNodeFocus';
const ASSET_BATCH_SIZE = 5;
type StatusFieldHighlight = {
nodeId: NodeId;
field: StageStatusField;
@@ -193,10 +189,10 @@ export function useUiEditorSession(
? 0
: initialStep === 'structure-recognition'
? 1
: 2;
: 1;
const normalizedInitialFurthestStepIndex = Math.max(
normalizedInitialStepIndex,
Math.min(2, Math.max(0, Math.trunc(initialFurthestStepIndex))),
Math.min(1, Math.max(0, Math.trunc(initialFurthestStepIndex))),
);
const [activeStep, setActiveStep] = useState<UiEditorStepId>(initialStep);
const [furthestStepIndex, setFurthestStepIndex] = useState(
@@ -380,11 +376,7 @@ export function useUiEditorSession(
savedStateSignature !== null &&
savedStateSignature !== stateSignature;
const nextStep: UiEditorStepId | null =
activeStep === 'reference-analysis'
? 'structure-recognition'
: activeStep === 'structure-recognition'
? 'visual-binding'
: null;
activeStep === 'reference-analysis' ? 'structure-recognition' : null;
const spriteReferenceCounts = useMemo(() => {
const counts: Record<string, number> = {};
@@ -662,7 +654,7 @@ export function useUiEditorSession(
? 0
: step === 'structure-recognition'
? 1
: 2;
: 1;
setFurthestStepIndex((current) => Math.max(current, index));
}
@@ -966,11 +958,14 @@ export function useUiEditorSession(
setIsRecognizing(true);
try {
await editor.runWithStateLocked(async (snapshot) => {
const result = await invoke<RecognitionDTO>('recognize_ui', {
projectPath,
state: snapshot,
});
editor.replaceState(applyRecognitionResult(snapshot, result));
const result = await invoke<RecognitionAndBindingDTO>(
'recognition_and_binding',
{
projectPath,
state: snapshot,
},
);
editor.replaceState(applyRecognitionAndBindingResult(snapshot, result));
setHasRecognized(true);
setSelectedNodeId(null);
setRecognitionStatus(`已替换 ${result.ui_trees.length} 棵界面树。`);
@@ -1009,32 +1004,16 @@ export function useUiEditorSession(
setIsBinding(true);
try {
await editor.runWithStateLocked(async (snapshot) => {
const allSpriteIds = Object.keys(snapshot.sprite_assets);
const batches: string[][] = [];
for (
let index = 0;
index < allSpriteIds.length;
index += ASSET_BATCH_SIZE
) {
batches.push(allSpriteIds.slice(index, index + ASSET_BATCH_SIZE));
}
if (batches.length === 0) batches.push([]);
let current = snapshot;
for (const [index, spriteIds] of batches.entries()) {
setBindingStatus(`绑定组件中(${index + 1}/${batches.length})…`);
const result = await invoke<BindingDTO>('bind_components', {
setBindingStatus('计算 UI 中…');
const result = await invoke<RecognitionAndBindingDTO>(
'recognition_and_binding',
{
projectPath,
state: current,
spriteIds,
});
current = applyBindingResult(current, result);
editor.replaceState(current, {
history: index < batches.length - 1 ? 'skip' : 'record',
});
}
setBindingStatus(
`组件绑定完成(${batches.length}/${batches.length})。`,
state: snapshot,
},
);
editor.replaceState(applyRecognitionAndBindingResult(snapshot, result));
setBindingStatus('组件绑定完成。');
setHasBound(true);
});
} catch (cause) {
@@ -46,8 +46,6 @@ UI 编辑器“生成代码”只把导出的 `ui/generated-*.js` 写入用户
```text
ui-workflow.reference-ready
ui-workflow.structure-ready
ui-workflow.merge-ready
ui-workflow.binding-ready
ui-workflow.application-ready
ui-workflow.completed