修复 UI 编辑器导出与预览一致性
Project CI / Repository checks (pull_request) Successful in 3m5s
Project CI / Frontend tests (pull_request) Successful in 3m40s
Project CI / Backend tests (pull_request) Successful in 7m32s
Project CI / Native shell tests (pull_request) Failing after 15m42s

修复保存按钮在 AI 运行期间的禁用状态与生成成功提示失效

加强字体资源路径校验并复用生成代码字体 CSS

让布局错误显式传播并为生成文件名追加 asset ID 摘要

统一前端与 Rust 的 Radial90 顺时针填充方向

补充代码生成不推进项目 revision 的技术方案与决策记录
This commit is contained in:
2026-09-01 18:54:49 +08:00
parent bce3cbfb0f
commit e708e09c91
10 changed files with 90 additions and 63 deletions
@@ -23,7 +23,7 @@ pub(super) fn asset_url(path: &str) -> Result<String, String> {
|| 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}"));
@@ -102,7 +102,7 @@ fn fill_clip_path(method: &FillMethod, amount: f32) -> Option<String> {
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,
@@ -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<String, String> {
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<String, String> {
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<String, String> {
@@ -57,6 +57,15 @@ pub(crate) fn render_ui_design_state_js(
state: &State,
) -> Result<(String, Vec<String>, 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::<Result<Vec<_>, _>>()?
.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::<Result<Vec<_>, _>>()?
.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",
@@ -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();
@@ -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)`;
@@ -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<string | null>(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({
<button
type="button"
className="rounded-lg bg-orange-600 px-3 py-1.5 text-sm font-semibold text-white disabled:opacity-60"
disabled={
session.save.isSaving ||
session.save.isGenerating ||
session.save.isLoading ||
Boolean(session.save.loadError) ||
session.save.persistedRevision === null ||
session.save.isLocked
}
disabled={saveDisabled}
onClick={() => requestSave()}
>
{session.save.isSaving ? '保存中…' : '保存'}
@@ -166,14 +171,7 @@ export default function UiEditorPage({
<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
}
disabled={saveDisabled}
onClick={requestSaveAndGenerate}
>
{saveAndGenerateLabel(
@@ -280,13 +278,7 @@ export default function UiEditorPage({
<button
type="button"
className="rounded-lg bg-orange-600 px-3 py-2 text-xs font-semibold text-white disabled:opacity-60"
disabled={
session.save.isSaving ||
session.save.isGenerating ||
Boolean(session.save.loadError) ||
session.save.persistedRevision === null ||
session.save.isLocked
}
disabled={saveDisabled}
onClick={() => requestSave(true)}
>
{session.save.isSaving ? '保存中…' : '保存并返回'}