生成 UI 设计代码并接入编辑器按钮
新增 State 到 JS 模块的生成与原子写入 接入保存并生成代码按钮及状态反馈 统一树节点元数据与 ui-node-id 标记 暂置旧 UI HTML 工具为空实现
This commit is contained in:
@@ -2177,9 +2177,7 @@ fn direct_registered_taonier_slice_paths(root: &Path) -> Vec<String> {
|
||||
fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec<String> {
|
||||
let sources = direct_codex_game_outputs(root)
|
||||
.into_iter()
|
||||
.filter_map(|(relative_path, _, _)| {
|
||||
std::fs::read_to_string(root.join(relative_path)).ok()
|
||||
})
|
||||
.filter_map(|(relative_path, _, _)| std::fs::read_to_string(root.join(relative_path)).ok())
|
||||
.collect::<Vec<_>>();
|
||||
let mut available_paths = Vec::new();
|
||||
if direct_taonier_art_base_is_valid(root) {
|
||||
@@ -2267,9 +2265,7 @@ fn direct_browser_evidence_needs_art_repair(
|
||||
fn direct_game_output_completion_error(root: &Path) -> Option<String> {
|
||||
let entry = agent_runtime_game_entry_relative_path(root);
|
||||
if !root.join(entry).is_file() {
|
||||
return Some(format!(
|
||||
"Codex 返回后未找到 {entry},项目未进入可运行状态"
|
||||
));
|
||||
return Some(format!("Codex 返回后未找到 {entry},项目未进入可运行状态"));
|
||||
}
|
||||
if !direct_game_sources_reference_taonier_art_package(root) {
|
||||
return Some(
|
||||
@@ -3660,6 +3656,7 @@ fn build_direct_codex_system_prompt_with_search(
|
||||
"工作区边界:只在当前项目目录内工作;不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径。遇到阻断必须说明具体原因、文件和下一步,不要声称未验证的成功。".to_string(),
|
||||
"AGC 工具授权边界:DirectProject 的 agc_tools 由当前客户端桥接到 AGC 后端,使用客户端已有登录会话和受控凭据完成授权。用户不需要、也不得向你提供、配置、粘贴或创建 API Key、Token、Cookie、URL 或 .env。工具返回 401/403 时,只说明 AGC 客户端登录或权限状态异常并停止,不要索要凭据、猜测外部 API,也不要暴露内部 URL。".to_string(),
|
||||
DIRECT_AGC_ENGINEERING_GUIDANCE.to_string(),
|
||||
"UI 设计 HTML 转换工具 `agc_tools.agc_ui_design_to_html` 当前暂未实现。".to_string(),
|
||||
"工程执行要求:优先复用现有结构;按需读取真实文件,不依赖客户端预注入源码快照;修改后运行与改动相关的本地验证。不要创建 Supervisor、专业 Agent 或平行项目。".to_string(),
|
||||
format!("提示词与技能:{skill_index}"),
|
||||
];
|
||||
@@ -4576,6 +4573,7 @@ mod tests {
|
||||
assert!(prompt.contains("agc_tools.taonier_prepare_game_art"));
|
||||
assert!(prompt.contains("agc_tools.agc_generate_image"));
|
||||
assert!(prompt.contains("agc_tools.agc_browser_playtest"));
|
||||
assert!(prompt.contains("agc_tools.agc_ui_design_to_html"));
|
||||
assert!(prompt.contains("DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill"));
|
||||
assert!(!prompt.contains("客户端会在系统上下文提供有界的当前游戏文件快照"));
|
||||
assert!(prompt.contains("Codex 不直接保存或伪造项目版本"));
|
||||
|
||||
@@ -356,6 +356,23 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value {
|
||||
"additionalProperties": false
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"name": "agc_ui_design_to_html",
|
||||
"description": "UI 设计 HTML 转换工具当前暂未实现。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"assetId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 160,
|
||||
"description": "当前项目 manifest 中 kind=UI 的 JSON 设计资源 assetId"
|
||||
}
|
||||
},
|
||||
"required": ["assetId"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}),
|
||||
];
|
||||
let mut tools = tools;
|
||||
if controlled_web_search {
|
||||
@@ -997,6 +1014,15 @@ async fn call_agc_browser_playtest(arguments: &Value) -> Value {
|
||||
call_client_tool_bridge("agc_browser_playtest", arguments).await
|
||||
}
|
||||
|
||||
fn call_agc_ui_design_to_html(root: &Path, arguments: &Value) -> Value {
|
||||
let _ = (root, arguments);
|
||||
mcp_tool_result(
|
||||
"agc_ui_design_to_html 暂未实现".to_string(),
|
||||
Vec::new(),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
async fn call_agc_web_search(arguments: &Value) -> Value {
|
||||
if !controlled_web_search_enabled() {
|
||||
return mcp_tool_result("AGC 受控联网搜索未启用".to_string(), Vec::new(), true);
|
||||
@@ -1017,7 +1043,7 @@ async fn call_agc_web_search(arguments: &Value) -> Value {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn handle_direct_tools_mcp_request(_root: &Path, request: Value) -> Option<Value> {
|
||||
async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option<Value> {
|
||||
let id = request.get("id").cloned();
|
||||
let method = request.get("method").and_then(Value::as_str)?;
|
||||
if id.is_none() {
|
||||
@@ -1068,6 +1094,7 @@ async fn handle_direct_tools_mcp_request(_root: &Path, request: Value) -> Option
|
||||
}
|
||||
"agc_remove_background" => call_agc_remove_background(&arguments).await,
|
||||
"agc_browser_playtest" => call_agc_browser_playtest(&arguments).await,
|
||||
"agc_ui_design_to_html" => call_agc_ui_design_to_html(root, &arguments),
|
||||
"agc_web_search" => call_agc_web_search(&arguments).await,
|
||||
_ => mcp_tool_result("未知或未审核的 AGC 工具".to_string(), Vec::new(), true),
|
||||
};
|
||||
@@ -1178,8 +1205,7 @@ mod tests {
|
||||
#[test]
|
||||
fn tool_catalog_preserves_reviewed_resource_contracts() {
|
||||
assert!(
|
||||
DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES
|
||||
> DIRECT_TOOLS_MCP_MAX_WRITE_CONTENT_BYTES + 1024,
|
||||
DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES > DIRECT_TOOLS_MCP_MAX_WRITE_CONTENT_BYTES + 1024,
|
||||
"MCP request envelope must fit the advertised file-write payload"
|
||||
);
|
||||
let specs = direct_tools_mcp_specs();
|
||||
@@ -1203,7 +1229,8 @@ mod tests {
|
||||
"agc_import_account_assets",
|
||||
"agc_create_or_derive_resource",
|
||||
"agc_remove_background",
|
||||
"agc_browser_playtest"
|
||||
"agc_browser_playtest",
|
||||
"agc_ui_design_to_html"
|
||||
]
|
||||
);
|
||||
let serialized = specs.to_string();
|
||||
|
||||
@@ -1,57 +1,14 @@
|
||||
use super::*;
|
||||
use crate::ui_editor::html_renderer::render_ui_design_state_html;
|
||||
use crate::ui_editor::persistence::{load_ui_design_state_at, LoadUiDesignStateInput};
|
||||
use serde_json::Value;
|
||||
|
||||
pub(in crate::agent) fn observe_agent_runtime_ui_design_to_html(
|
||||
root: &Path,
|
||||
input: &Value,
|
||||
) -> AgentRuntimeToolObservation {
|
||||
let asset_id = input
|
||||
.get("assetId")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let Some(asset_id) = asset_id else {
|
||||
return AgentRuntimeToolObservation {
|
||||
tool: "ui.design.to_html".to_string(),
|
||||
status: "rejected".to_string(),
|
||||
summary: "ui.design.to_html 缺少 assetId".to_string(),
|
||||
detail: None,
|
||||
};
|
||||
};
|
||||
let project_id = match game_creator_agent_runtime_context_project_id(root) {
|
||||
Ok(project_id) => project_id,
|
||||
Err(error) => return ui_html_error(root, error),
|
||||
};
|
||||
let snapshot = match load_ui_design_state_at(LoadUiDesignStateInput {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
expected_project_id: project_id,
|
||||
asset_id: asset_id.to_string(),
|
||||
}) {
|
||||
Ok(snapshot) => snapshot,
|
||||
Err(error) => return ui_html_error(root, error),
|
||||
};
|
||||
match render_ui_design_state_html(&snapshot.state) {
|
||||
Ok(html) => AgentRuntimeToolObservation {
|
||||
tool: "ui.design.to_html".to_string(),
|
||||
status: "ok".to_string(),
|
||||
summary: format!(
|
||||
"已生成 UI HTML(revision {},{} 字节)",
|
||||
snapshot.revision,
|
||||
html.len()
|
||||
),
|
||||
detail: Some(html),
|
||||
},
|
||||
Err(error) => ui_html_error(root, error),
|
||||
}
|
||||
}
|
||||
|
||||
fn ui_html_error(root: &Path, error: String) -> AgentRuntimeToolObservation {
|
||||
AgentRuntimeToolObservation {
|
||||
tool: "ui.design.to_html".to_string(),
|
||||
status: "error".to_string(),
|
||||
summary: redact_agent_runtime_project_paths(root, &error, 500),
|
||||
summary: "ui.design.to_html 暂未实现".to_string(),
|
||||
detail: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1392,7 +1392,7 @@ fn runtime_tool_description(tool: &str) -> &'static str {
|
||||
"先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-prototype 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。"
|
||||
}
|
||||
"ui.design.to_html" => {
|
||||
"只读读取指定 UI 设计, 生成HTML片段;"
|
||||
"UI 设计 HTML 转换工具当前暂未实现。"
|
||||
}
|
||||
"blackboard.write" => "向项目级共享黑板追加稳定结论。",
|
||||
"agent.message" => "向一个目标 Agent 写入定向上下文消息。",
|
||||
|
||||
@@ -160,6 +160,15 @@ fn save_ui_design_state(
|
||||
ui_editor::persistence::save_ui_design_state_at(input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn generate_ui_design_code(
|
||||
input: ui_editor::persistence::GenerateUiDesignCodeInput,
|
||||
) -> Result<ui_editor::persistence::GenerateUiDesignCodeResult, String> {
|
||||
let root = Path::new(input.project_path.trim());
|
||||
enforce_project_permission_policy(root, "file.write")?;
|
||||
ui_editor::persistence::generate_ui_design_code_at(input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn ensure_ui_design_resource_for_prototype(
|
||||
input: ui_editor::resource_bridge::EnsureUiDesignResourceForPrototypeInput,
|
||||
@@ -2226,6 +2235,7 @@ fn main() {
|
||||
bind_components,
|
||||
load_ui_design_state,
|
||||
save_ui_design_state,
|
||||
generate_ui_design_code,
|
||||
ensure_ui_design_resource_for_prototype,
|
||||
generate_platform_art_asset,
|
||||
open_canvas_project,
|
||||
|
||||
@@ -9,10 +9,10 @@ pub(super) fn font_face_rule(
|
||||
}
|
||||
|
||||
pub(super) fn html_comment(label: &str, value: serde_json::Value) -> Markup {
|
||||
let text = serde_json::to_string(&value)
|
||||
let text = serde_json::to_string_pretty(&value)
|
||||
.unwrap_or_else(|_| "{}".to_string())
|
||||
.replace("--", "- -");
|
||||
PreEscaped(format!("<!-- {label}: {text} -->"))
|
||||
PreEscaped(format!("<!-- {label}:\n{text}\n-->"))
|
||||
}
|
||||
|
||||
pub(super) fn asset_url(path: &str) -> Result<String, String> {
|
||||
|
||||
@@ -29,12 +29,12 @@ pub(super) fn render_component(state: &State, component: &Component) -> Result<M
|
||||
let family = format!("ui-editor-font-{}", hex_id(font_id.as_str()));
|
||||
style.push_str(&format!("font-family:'{family}';"));
|
||||
return Ok(html! {
|
||||
div data-component-kind="Text" style=(style) {
|
||||
div style=(style) {
|
||||
(text.content)
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(html! { div data-component-kind="Text" style=(style) { (text.content) } })
|
||||
Ok(html! { div style=(style) { (text.content) } })
|
||||
}
|
||||
Component::Image(image) => {
|
||||
let sprite_id = image
|
||||
@@ -49,11 +49,11 @@ pub(super) fn render_component(state: &State, component: &Component) -> Result<M
|
||||
let (style, image_style) = image_styles(&image.image_type, sprite, &src)?;
|
||||
Ok(match image_style {
|
||||
Some(image_style) => html! {
|
||||
div data-component-kind="Image" style=(style) {
|
||||
div style=(style) {
|
||||
img src=(src) alt="" aria-hidden="true" draggable="false" style=(image_style);
|
||||
}
|
||||
},
|
||||
None => html! { div data-component-kind="Image" style=(style) {} },
|
||||
None => html! { div style=(style) {} },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ mod component;
|
||||
mod container;
|
||||
mod node;
|
||||
|
||||
use self::assets::{font_face_rule, hex_id, html_comment};
|
||||
use self::assets::{font_face_rule, hex_id, html_comment, trim_float};
|
||||
use self::component::render_component;
|
||||
use self::container::{child_container_style, container_style};
|
||||
use self::node::{is_container, transform_style};
|
||||
@@ -59,21 +59,86 @@ pub(crate) fn render_ui_design_state_html(state: &State) -> Result<String, Strin
|
||||
Ok(fragment)
|
||||
}
|
||||
|
||||
pub(crate) fn render_ui_design_state_js(
|
||||
state: &State,
|
||||
) -> Result<(String, Vec<String>, usize), String> {
|
||||
let mut exports = Vec::with_capacity(state.ui_trees.len());
|
||||
let mut modules = Vec::with_capacity(state.ui_trees.len());
|
||||
let mut node_count = 0usize;
|
||||
for tree in &state.ui_trees {
|
||||
let export_name = tree_export_name(tree.src_ui_design.as_str());
|
||||
exports.push(export_name.clone());
|
||||
let image = state
|
||||
.ui_design_images
|
||||
.get(&tree.src_ui_design)
|
||||
.ok_or_else(|| format!("UITree 缺少 src_ui_design:{}", tree.src_ui_design.as_str()))?;
|
||||
let tree_comment = html_comment(
|
||||
"genarrative-ui-tree",
|
||||
json!({
|
||||
"srcUiDesign": tree.src_ui_design.as_str(),
|
||||
"name": image.metadata.name,
|
||||
"description": image.metadata.description,
|
||||
}),
|
||||
)
|
||||
.into_string();
|
||||
let fonts = state
|
||||
.font_assets
|
||||
.values()
|
||||
.map(|font| {
|
||||
let family = format!("ui-editor-font-{}", hex_id(font.asset_id.as_str()));
|
||||
font_face_rule(font, &family)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
.join("");
|
||||
let root = render_node_with_scale(
|
||||
state,
|
||||
&tree.root,
|
||||
None,
|
||||
Some((image.pixel_size.x, image.pixel_size.y)),
|
||||
)?
|
||||
.into_string();
|
||||
node_count += count_nodes(&tree.root);
|
||||
let mut fragment = String::new();
|
||||
fragment.push_str(&tree_comment);
|
||||
if !fonts.is_empty() {
|
||||
fragment.push_str(&html! { style data-ui-fonts { (PreEscaped(fonts)) } }.into_string());
|
||||
}
|
||||
fragment.push_str(&root);
|
||||
let escaped = escape_template_literal(&pretty_html_fragment(&fragment));
|
||||
modules.push(format!("export const {export_name} = `\n{escaped}\n`;"));
|
||||
}
|
||||
let mut output = String::from(
|
||||
"// UI 设计生成模块:仅导出 HTML 片段,不执行任何注入逻辑。\n\
|
||||
// 重新生成会覆盖本文件中的手动修改。\n\
|
||||
// 可通过宿主页面设置 --ui-scale 调整整体像素缩放,例如:\n\
|
||||
// document.documentElement.style.setProperty('--ui-scale', '0.85');\n\
|
||||
// 注入示例(仅供 Agent 按需修改):\n\
|
||||
// import { tree_example } from './generated-xxx.js';\n\
|
||||
// document.body.insertAdjacentHTML('beforeend', tree_example);\n\
|
||||
// const node = document.querySelector('[ui-node-id=\"...\"]');\n\n",
|
||||
);
|
||||
output.push_str(&modules.join("\n\n"));
|
||||
if !output.ends_with('\n') {
|
||||
output.push('\n');
|
||||
}
|
||||
Ok((output, exports, node_count))
|
||||
}
|
||||
|
||||
fn render_tree(state: &State, tree: &UITree) -> Result<Markup, String> {
|
||||
state
|
||||
let image = state
|
||||
.ui_design_images
|
||||
.get(&tree.src_ui_design)
|
||||
.ok_or_else(|| format!("UITree 缺少 src_ui_design:{}", tree.src_ui_design.as_str()))?;
|
||||
let tree_comment = html_comment(
|
||||
"genarrative-ui-tree",
|
||||
json!({"srcUiDesign": tree.src_ui_design.as_str()}),
|
||||
json!({
|
||||
"srcUiDesign": tree.src_ui_design.as_str(),
|
||||
}),
|
||||
);
|
||||
let style = "position:relative;width:100%;height:100%;min-height:0;";
|
||||
Ok(html! {
|
||||
(tree_comment)
|
||||
div data-ui-tree=(tree.src_ui_design.as_str()) style=(style) {
|
||||
(render_node(state, &tree.root, None)?)
|
||||
}
|
||||
div style=(style) { (render_node(state, &tree.root, None)?) }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -81,8 +146,27 @@ fn render_node(
|
||||
state: &State,
|
||||
node: &Node,
|
||||
parent_container: Option<&Container>,
|
||||
) -> Result<Markup, String> {
|
||||
render_node_with_scale(state, node, parent_container, None)
|
||||
}
|
||||
|
||||
fn render_node_with_scale(
|
||||
state: &State,
|
||||
node: &Node,
|
||||
parent_container: Option<&Container>,
|
||||
root_scale: Option<(f32, f32)>,
|
||||
) -> Result<Markup, String> {
|
||||
let mut style = transform_style(&node.layout, parent_container.is_some())?;
|
||||
if let Some((width, height)) = root_scale {
|
||||
if !width.is_finite() || width <= 0.0 || !height.is_finite() || height <= 0.0 {
|
||||
return Err("UI 设计尺寸必须为正有限数值".to_string());
|
||||
}
|
||||
style.push_str(&format!(
|
||||
"--ui-scale:min(calc(100vw / {}px),calc(100vh / {}px));",
|
||||
trim_float(width),
|
||||
trim_float(height)
|
||||
));
|
||||
}
|
||||
style.push_str("border:0;outline:0;background:transparent;overflow:visible;");
|
||||
style.push_str(&container_style(&node.layout.container));
|
||||
if let Some(parent) = parent_container {
|
||||
@@ -115,10 +199,11 @@ fn render_node(
|
||||
.children
|
||||
.iter()
|
||||
.map(|child| {
|
||||
render_node(
|
||||
render_node_with_scale(
|
||||
state,
|
||||
child,
|
||||
is_container(&node.layout.container).then_some(&node.layout.container),
|
||||
None,
|
||||
)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
@@ -128,9 +213,62 @@ fn render_node(
|
||||
Ok(html! {
|
||||
(comment)
|
||||
@if let Some(group_comment) = exclusive_comment { (group_comment) }
|
||||
div data-node-id=(node.id.as_str()) style=(style) {
|
||||
div ui-node-id=(node.id.as_str()) style=(style) {
|
||||
(PreEscaped(components.concat()))
|
||||
(PreEscaped(children.concat()))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn count_nodes(node: &Node) -> usize {
|
||||
1 + node.children.iter().map(count_nodes).sum::<usize>()
|
||||
}
|
||||
|
||||
fn tree_export_name(id: &str) -> String {
|
||||
let mut name = String::from("tree_");
|
||||
for character in id.chars() {
|
||||
if character.is_ascii_alphanumeric() || character == '_' {
|
||||
name.push(character);
|
||||
} else {
|
||||
name.push('_');
|
||||
}
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
fn escape_template_literal(value: &str) -> String {
|
||||
value
|
||||
.replace('\\', "\\\\")
|
||||
.replace('`', "\\`")
|
||||
.replace("${", "\\${")
|
||||
}
|
||||
|
||||
fn pretty_html_fragment(value: &str) -> String {
|
||||
let mut depth = 0usize;
|
||||
let mut lines = Vec::new();
|
||||
for raw in value.replace("><", ">\n<").lines() {
|
||||
let line = raw.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let closing = line.starts_with("</");
|
||||
if closing {
|
||||
depth = depth.saturating_sub(1);
|
||||
}
|
||||
lines.push(format!("{}{}", " ".repeat(depth), line));
|
||||
if line.starts_with('<')
|
||||
&& !closing
|
||||
&& !line.starts_with("<!--")
|
||||
&& !line.starts_with("<!")
|
||||
&& !line.ends_with("/>")
|
||||
&& !line.starts_with("<img")
|
||||
&& !line.starts_with("<input")
|
||||
&& !line.starts_with("<br")
|
||||
&& !line.starts_with("<hr")
|
||||
&& !line.contains("</")
|
||||
{
|
||||
depth += 1;
|
||||
}
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::ui_editor::component::text::FontSource;
|
||||
use crate::ui_editor::component::Component;
|
||||
use crate::ui_editor::html_renderer::render_ui_design_state_js;
|
||||
use crate::ui_editor::layout::node::Node;
|
||||
use crate::ui_editor::resource::ui_design_image::{
|
||||
UIDesignImage, UIDesignImageMetadata, UIDesignImageRole,
|
||||
@@ -43,6 +44,23 @@ pub(crate) struct LoadUiDesignStateInput {
|
||||
pub(crate) asset_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub(crate) struct GenerateUiDesignCodeInput {
|
||||
pub(crate) project_path: String,
|
||||
pub(crate) expected_project_id: String,
|
||||
pub(crate) asset_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GenerateUiDesignCodeResult {
|
||||
pub(crate) relative_path: String,
|
||||
pub(crate) tree_exports: Vec<String>,
|
||||
pub(crate) tree_count: usize,
|
||||
pub(crate) node_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub(crate) struct SaveUiDesignStateInput {
|
||||
@@ -149,6 +167,44 @@ pub(crate) fn load_ui_design_state_at(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn generate_ui_design_code_at(
|
||||
input: GenerateUiDesignCodeInput,
|
||||
) -> Result<GenerateUiDesignCodeResult, String> {
|
||||
let root = Path::new(input.project_path.trim());
|
||||
let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?;
|
||||
let asset_id = required_identifier(&input.asset_id, "assetId")?;
|
||||
let _lock = acquire_project_write_lock(root, "ui_design.code_generate")?;
|
||||
let asset = ui_design_asset(root, &expected_project_id, &asset_id)?;
|
||||
let document =
|
||||
read_ui_design_document_locked(root, &asset.local_path, &expected_project_id, &asset_id)?;
|
||||
let (content, tree_exports, node_count) = render_ui_design_state_js(&document.state)?;
|
||||
let relative_path = format!("ui/generated-{}.js", generated_file_stem(&asset_id));
|
||||
let path = resolve_local_project_path(root, &relative_path)?;
|
||||
write_ui_design_raw_file(&path, "UI 设计生成代码", content.as_bytes())?;
|
||||
Ok(GenerateUiDesignCodeResult {
|
||||
relative_path,
|
||||
tree_count: tree_exports.len(),
|
||||
tree_exports,
|
||||
node_count,
|
||||
})
|
||||
}
|
||||
|
||||
fn generated_file_stem(asset_id: &str) -> String {
|
||||
let mut stem = String::new();
|
||||
for character in asset_id.chars() {
|
||||
if character.is_ascii_alphanumeric() || matches!(character, '_' | '-') {
|
||||
stem.push(character);
|
||||
} else {
|
||||
stem.push('_');
|
||||
}
|
||||
}
|
||||
if stem.is_empty() {
|
||||
"ui-design".to_string()
|
||||
} else {
|
||||
stem
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn save_ui_design_state_at(
|
||||
input: SaveUiDesignStateInput,
|
||||
) -> Result<SaveUiDesignStateResult, String> {
|
||||
|
||||
@@ -14,6 +14,13 @@ export type UiDesignStateSaveResult =
|
||||
}
|
||||
| { status: 'conflict'; current: UiDesignStateSnapshot };
|
||||
|
||||
export type UiDesignCodeGenerationResult = {
|
||||
relativePath: string;
|
||||
treeExports: string[];
|
||||
treeCount: number;
|
||||
nodeCount: number;
|
||||
};
|
||||
|
||||
export type IUiDesignStateStore = {
|
||||
load(assetId: string): Promise<UiDesignStateSnapshot>;
|
||||
save(
|
||||
@@ -21,6 +28,7 @@ export type IUiDesignStateStore = {
|
||||
expectedRevision: number,
|
||||
state: State,
|
||||
): Promise<UiDesignStateSaveResult>;
|
||||
generateCode(assetId: string): Promise<UiDesignCodeGenerationResult>;
|
||||
};
|
||||
|
||||
export function createTauriUiDesignStateStore(
|
||||
@@ -44,6 +52,11 @@ export function createTauriUiDesignStateStore(
|
||||
},
|
||||
});
|
||||
},
|
||||
generateCode(assetId) {
|
||||
return invoke<UiDesignCodeGenerationResult>('generate_ui_design_code', {
|
||||
input: { projectPath, expectedProjectId, assetId },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,6 +89,14 @@ export const uiDesignStateStore: IUiDesignStateStore = {
|
||||
committedProjectRevision: 0,
|
||||
};
|
||||
},
|
||||
async generateCode() {
|
||||
return {
|
||||
relativePath: 'ui/generated-ui-design.js',
|
||||
treeExports: [],
|
||||
treeCount: 0,
|
||||
nodeCount: 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const EMPTY_UI_DESIGN_STATE = EMPTY_UI_EDITOR_STATE;
|
||||
|
||||
@@ -60,6 +60,8 @@ export default function UiEditorPage({
|
||||
);
|
||||
const [saveWarningOpen, setSaveWarningOpen] = useState(false);
|
||||
const [saveAfterReturn, setSaveAfterReturn] = useState(false);
|
||||
const [generateAfterWarning, setGenerateAfterWarning] = useState(false);
|
||||
const [generateSuccess, setGenerateSuccess] = useState<string | null>(null);
|
||||
const [returnConfirmOpen, setReturnConfirmOpen] = useState(false);
|
||||
|
||||
async function save(afterReturn = false) {
|
||||
@@ -71,11 +73,23 @@ export default function UiEditorPage({
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAndGenerate() {
|
||||
setGenerateSuccess(null);
|
||||
if (await session.save.save()) {
|
||||
const result = await session.save.generateCode();
|
||||
if (result) {
|
||||
setGenerateAfterWarning(false);
|
||||
setGenerateSuccess(`代码已生成:${result.relativePath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function requestSave(afterReturn = false) {
|
||||
if (!resourceId || session.save.isSaving || session.workflow.isAiRunning) {
|
||||
return;
|
||||
}
|
||||
setSaveAfterReturn(afterReturn);
|
||||
setGenerateAfterWarning(false);
|
||||
if (session.save.hasWarnings()) {
|
||||
setSaveWarningOpen(true);
|
||||
return;
|
||||
@@ -83,6 +97,25 @@ export default function UiEditorPage({
|
||||
void save(afterReturn);
|
||||
}
|
||||
|
||||
function requestSaveAndGenerate() {
|
||||
if (
|
||||
!resourceId ||
|
||||
session.save.isSaving ||
|
||||
session.save.isGenerating ||
|
||||
session.workflow.isAiRunning
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setSaveAfterReturn(false);
|
||||
setGenerateSuccess(null);
|
||||
setGenerateAfterWarning(true);
|
||||
if (session.save.hasWarnings()) {
|
||||
setSaveWarningOpen(true);
|
||||
return;
|
||||
}
|
||||
void saveAndGenerate();
|
||||
}
|
||||
|
||||
function requestBack() {
|
||||
if (session.save.isDirty) {
|
||||
setReturnConfirmOpen(true);
|
||||
@@ -122,6 +155,25 @@ export default function UiEditorPage({
|
||||
>
|
||||
{session.save.isSaving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg border border-orange-600 px-3 py-1.5 text-sm font-semibold text-orange-700 disabled:opacity-60"
|
||||
disabled={
|
||||
session.save.isSaving ||
|
||||
session.save.isGenerating ||
|
||||
session.save.isLoading ||
|
||||
Boolean(session.save.loadError) ||
|
||||
session.save.persistedRevision === null ||
|
||||
session.save.isLocked
|
||||
}
|
||||
onClick={requestSaveAndGenerate}
|
||||
>
|
||||
{session.save.isSaving
|
||||
? '保存中…'
|
||||
: session.save.isGenerating
|
||||
? '生成中…'
|
||||
: '保存并生成代码'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
) : null}
|
||||
@@ -133,6 +185,22 @@ export default function UiEditorPage({
|
||||
<span>{session.save.saveError}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{session.save.generateError ? (
|
||||
<div
|
||||
className="mx-4 mt-3 flex shrink-0 items-center justify-between gap-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-800"
|
||||
role="alert"
|
||||
>
|
||||
<span>代码生成失败:{session.save.generateError}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{generateSuccess ? (
|
||||
<div
|
||||
className="mx-4 mt-3 shrink-0 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs text-emerald-800"
|
||||
role="status"
|
||||
>
|
||||
{generateSuccess}
|
||||
</div>
|
||||
) : null}
|
||||
<ToolNavigation
|
||||
activeStep={session.workflow.activeStep}
|
||||
furthestStepIndex={session.workflow.furthestStepIndex}
|
||||
@@ -273,7 +341,11 @@ export default function UiEditorPage({
|
||||
className="rounded-lg bg-orange-600 px-3 py-2 text-xs font-semibold text-white"
|
||||
onClick={() => {
|
||||
setSaveWarningOpen(false);
|
||||
void save(saveAfterReturn);
|
||||
if (generateAfterWarning) {
|
||||
void saveAndGenerate();
|
||||
} else {
|
||||
void save(saveAfterReturn);
|
||||
}
|
||||
}}
|
||||
>
|
||||
仍然保存
|
||||
|
||||
@@ -186,6 +186,8 @@ export function useUiEditorSession(
|
||||
);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [generateError, setGenerateError] = useState<string | null>(null);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const normalizedInitialStepIndex =
|
||||
initialStep === 'reference-analysis'
|
||||
? 0
|
||||
@@ -1071,6 +1073,28 @@ export function useUiEditorSession(
|
||||
}
|
||||
}
|
||||
|
||||
async function generateCode() {
|
||||
if (
|
||||
!resourceId ||
|
||||
isGenerating ||
|
||||
isLoading ||
|
||||
loadError ||
|
||||
editor.isLocked
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
setGenerateError(null);
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
return await stateStore.generateCode(resourceId);
|
||||
} catch (cause) {
|
||||
setGenerateError(cause instanceof Error ? cause.message : String(cause));
|
||||
return null;
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
input: {
|
||||
projectPath,
|
||||
@@ -1237,9 +1261,12 @@ export function useUiEditorSession(
|
||||
isSaving,
|
||||
isDirty,
|
||||
saveError,
|
||||
isGenerating,
|
||||
generateError,
|
||||
hasWarnings: () => postCheckIssuesForSave(editor.state).length > 0,
|
||||
warnings: () => postCheckIssuesForSave(editor.state),
|
||||
save,
|
||||
generateCode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user