From e708e09c91f4601b16e7bbbb634370b102b40239 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 1 Sep 2026 18:54:49 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20UI=20=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E5=AF=BC=E5=87=BA=E4=B8=8E=E9=A2=84=E8=A7=88=E4=B8=80?= =?UTF-8?q?=E8=87=B4=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复保存按钮在 AI 运行期间的禁用状态与生成成功提示失效 加强字体资源路径校验并复用生成代码字体 CSS 让布局错误显式传播并为生成文件名追加 asset ID 摘要 统一前端与 Rust 的 Radial90 顺时针填充方向 补充代码生成不推进项目 revision 的技术方案与决策记录 --- .../src/ui_editor/html_renderer/assets.rs | 2 +- .../html_renderer/component/image.rs | 2 +- .../src/ui_editor/html_renderer/container.rs | 27 +++++++------ .../src/ui_editor/html_renderer/mod.rs | 28 +++++++------ .../src-tauri/src/ui_editor/persistence.rs | 21 ++++++++-- .../ui-editor/utils/componentToCss.ts | 24 +++++++---- .../src/view/ui-editor/index.tsx | 40 ++++++++----------- .../shared-memory/decision-log.md | 6 +++ ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 1 + ...】UI工作流资源桥接与Runtime执行-2026-08-24.md | 2 + 10 files changed, 90 insertions(+), 63 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/assets.rs index 12e9a5178..15c99ce95 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/assets.rs @@ -23,7 +23,7 @@ pub(super) fn asset_url(path: &str) -> Result { || path.contains('\\') || path.split('/').any(|part| part == "..") || path.chars().any(|character| { - character.is_control() || matches!(character, '\'' | '"' | '`' | '(' | ')') + character.is_control() || matches!(character, '<' | '>' | '\'' | '"' | '`' | '(' | ')') }) { return Err(format!("资源路径无效:{path}")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/image.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/image.rs index 6dcbb8d28..5af248dfa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/image.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/image.rs @@ -102,7 +102,7 @@ fn fill_clip_path(method: &FillMethod, amount: f32) -> Option { trim_float((1.0 - amount) * 100.0) )), FillMethod::Radial90 { origin, clockwise } => Some(conic_mask( - radial_origin_angle_90(*origin), + radial_origin_angle_90(*origin) - if *clockwise { 90.0 } else { 0.0 }, *clockwise, amount, 90.0, diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/container.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/container.rs index 1d6948fd5..c4b2dcb9d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/container.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/container.rs @@ -3,21 +3,24 @@ use crate::ui_editor::layout::control_layout::{Container, ControlLayout}; const UI_SCALE: &str = "var(--ui-scale, 1)"; -pub(super) fn container_style(container: &Container) -> String { - match container { +pub(super) fn container_style(container: &Container) -> Result { + Ok(match container { Container::None => String::new(), - Container::HBox { alignment, separation } => format!("display:flex;flex-direction:row;justify-content:{};gap:{};min-width:0;min-height:0;", alignment_css(*alignment), scaled_px(*separation).unwrap_or_else(|_| "0px".to_string())), - Container::VBox { alignment, separation } => format!("display:flex;flex-direction:column;justify-content:{};gap:{};min-width:0;min-height:0;", alignment_css(*alignment), scaled_px(*separation).unwrap_or_else(|_| "0px".to_string())), - Container::Grid { columns, h_separation, v_separation } => format!("display:grid;grid-template-columns:repeat({},minmax(0,1fr));column-gap:{};row-gap:{};min-width:0;min-height:0;", columns, scaled_px(*h_separation).unwrap_or_default(), scaled_px(*v_separation).unwrap_or_default()), - Container::Margin { margin_left, margin_top, margin_right, margin_bottom } => format!("display:grid;padding-left:{};padding-top:{};padding-right:{};padding-bottom:{};min-width:0;min-height:0;", scaled_px(*margin_left).unwrap_or_default(), scaled_px(*margin_top).unwrap_or_default(), scaled_px(*margin_right).unwrap_or_default(), scaled_px(*margin_bottom).unwrap_or_default()), + Container::HBox { alignment, separation } => format!("display:flex;flex-direction:row;justify-content:{};gap:{};min-width:0;min-height:0;", alignment_css(*alignment), scaled_px(*separation)?), + Container::VBox { alignment, separation } => format!("display:flex;flex-direction:column;justify-content:{};gap:{};min-width:0;min-height:0;", alignment_css(*alignment), scaled_px(*separation)?), + Container::Grid { columns, h_separation, v_separation } => format!("display:grid;grid-template-columns:repeat({},minmax(0,1fr));column-gap:{};row-gap:{};min-width:0;min-height:0;", columns, scaled_px(*h_separation)?, scaled_px(*v_separation)?), + Container::Margin { margin_left, margin_top, margin_right, margin_bottom } => format!("display:grid;padding-left:{};padding-top:{};padding-right:{};padding-bottom:{};min-width:0;min-height:0;", scaled_px(*margin_left)?, scaled_px(*margin_top)?, scaled_px(*margin_right)?, scaled_px(*margin_bottom)?), Container::Center { .. } => "display:grid;place-items:center;min-width:0;min-height:0;".to_string(), - } + }) } -pub(super) fn child_container_style(layout: &ControlLayout, parent: &Container) -> String { - let min_w = scaled_px(layout.custom_minimum_size.x).unwrap_or_default(); - let min_h = scaled_px(layout.custom_minimum_size.y).unwrap_or_default(); - match parent { +pub(super) fn child_container_style( + layout: &ControlLayout, + parent: &Container, +) -> Result { + let min_w = scaled_px(layout.custom_minimum_size.x)?; + let min_h = scaled_px(layout.custom_minimum_size.y)?; + Ok(match parent { Container::HBox { .. } => format!( "min-width:{min_w};min-height:{min_h};{}align-self:{};", flex_grow( @@ -44,7 +47,7 @@ pub(super) fn child_container_style(layout: &ControlLayout, parent: &Container) Container::Grid { .. } | Container::None => { format!("min-width:{min_w};min-height:{min_h};") } - } + }) } fn scaled_px(value: f32) -> Result { diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs index e86bfff7f..e0c8199e7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs @@ -57,6 +57,15 @@ pub(crate) fn render_ui_design_state_js( state: &State, ) -> Result<(String, Vec, usize), String> { // 生成阶段只渲染已由保存流程 validate_state 校验过的 State;不重复执行领域校验。 + let font_faces = 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::, _>>()? + .join(""); 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; @@ -80,15 +89,6 @@ pub(crate) fn render_ui_design_state_js( }), ) .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::, _>>()? - .join(""); let root = render_node_with_scale( state, &tree.root, @@ -99,8 +99,10 @@ pub(crate) fn render_ui_design_state_js( 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()); + if !font_faces.is_empty() { + fragment.push_str( + &html! { style data-ui-fonts { (PreEscaped(font_faces.as_str())) } }.into_string(), + ); } fragment.push_str(&root); let escaped = escape_template_literal(&pretty_html_fragment(&fragment)); @@ -178,9 +180,9 @@ fn render_node_with_scale( )); } style.push_str("border:0;outline:0;background:transparent;overflow:visible;"); - style.push_str(&container_style(&node.layout.container)); + style.push_str(&container_style(&node.layout.container)?); if let Some(parent) = parent_container { - style.push_str(&child_container_style(&node.layout, parent)); + style.push_str(&child_container_style(&node.layout, parent)?); } let comment = html_comment( "genarrative-ui-node", diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs index 52f39ca88..e6c513f11 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs @@ -10,6 +10,7 @@ use crate::ui_editor::utils::UIDesignImageId; use crate::*; use nalgebra::Vector2; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::collections::HashSet; use std::fs::{self, File}; use std::io::{Read, Write}; @@ -204,11 +205,14 @@ fn generated_file_stem(asset_id: &str) -> String { stem.push('_'); } } - if stem.is_empty() { - "ui-design".to_string() + let readable_stem = if stem.is_empty() { + "ui-design" } else { - stem - } + stem.as_str() + }; + // 保留可读前缀,并追加摘要以避免不同 ID 映射到同一路径。 + let digest = format!("{:x}", Sha256::digest(asset_id.as_bytes())); + format!("{readable_stem}-{}", &digest[..16]) } pub(crate) fn save_ui_design_state_at( @@ -1001,6 +1005,15 @@ mod tests { ); } + #[test] + fn generated_file_stem_keeps_distinct_asset_ids_distinct() { + let first = generated_file_stem("a.b"); + let second = generated_file_stem("a/b"); + assert_ne!(first, second); + assert!(first.starts_with("a_b-")); + assert_eq!(first.len(), "a_b-".len() + 16); + } + #[test] fn preserves_sprite_asset_when_saving_a_dragged_node_transform() { let (directory, asset_id) = fixture(); diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/utils/componentToCss.ts b/apps/ai-game-creator-shell/src/features/ui-editor/utils/componentToCss.ts index 2b80c682d..69ea58362 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/utils/componentToCss.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/utils/componentToCss.ts @@ -80,14 +80,22 @@ function fillClipPath(method: FillMethod, amount: number): string | undefined { const sweep = amount * maxSweep; const origin = config.origin; const originAngle = - origin === 'Top' || origin === 'TopLeft' || origin === 'TopRight' - ? 0 - : origin === 'Right' || origin === 'BottomRight' - ? 90 - : origin === 'Bottom' || origin === 'BottomLeft' - ? 180 - : 270; - const start = config.clockwise ? originAngle : originAngle - sweep; + kind === 'Radial90' + ? origin === 'TopLeft' + ? 0 + : origin === 'TopRight' + ? 90 + : origin === 'BottomRight' + ? 180 + : 270 + : origin === 'Top' + ? 0 + : origin === 'Right' + ? 90 + : origin === 'Bottom' + ? 180 + : 270; + const start = originAngle - (config.clockwise ? 90 : sweep); // CSS conic gradients provide a deterministic browser preview for radial // fills. Exact engine parity is intentionally deferred. return `conic-gradient(from ${start}deg, #000 0deg ${sweep}deg, transparent ${sweep}deg 360deg)`; diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx index b6f298eed..1a4f3fdaa 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx @@ -1,5 +1,5 @@ import { ChevronLeft } from 'lucide-react'; -import { type ReactNode, useMemo, useState } from 'react'; +import { type ReactNode, useEffect, useMemo, useState } from 'react'; import { ThemedModal } from '../../components/modal/ThemedModal'; import { @@ -69,6 +69,18 @@ export default function UiEditorPage({ const [generateAfterWarning, setGenerateAfterWarning] = useState(false); const [generateSuccess, setGenerateSuccess] = useState(null); const [returnConfirmOpen, setReturnConfirmOpen] = useState(false); + const saveDisabled = + session.save.isSaving || + session.save.isGenerating || + session.save.isLoading || + session.workflow.isAiRunning || + Boolean(session.save.loadError) || + session.save.persistedRevision === null || + session.save.isLocked; + + useEffect(() => { + if (session.save.isDirty) setGenerateSuccess(null); + }, [session.save.isDirty]); async function save(afterReturn = false) { if (await session.save.save()) { @@ -151,14 +163,7 @@ export default function UiEditorPage({