修复 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 ? '保存中…' : '保存并返回'}
@@ -7880,3 +7880,9 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- `autonomous-game-build` 中,manifest `dependencies` 只作为上下文,不阻塞 ready;代码、设计、美术、音频和发布任务允许并行启动,child 不依赖固定回执顺序或固定 run 身份才能推进。
- 任务最终状态不再提前绑定平台画布、preview、static smoke 或发布产物检查;这些内容不参与该档位的完成判定,也不会因缺失而重置已完成任务。父 run 在任务图进入终态后直接收束并回复。
- 本档位仍沿用现有项目根和工具权限边界;本次调整只解除流程编排与平台产物验收前置,不新增第二套任务系统。
## 2026-09-01 UI 编辑器代码导出与填充预览边界
- UI 编辑器导出的 `ui/generated-*.js` 是派生本地产物。代码生成只写文件,绝不推进项目 revision、UI State revision、manifest 阶段或 Runtime 验证门;写入失败只返回生成错误,不能把生成文件写入冒充项目 mutation。
- 生成文件名保留可读清洗前缀,并追加 asset ID 的 SHA-256 摘要前缀以避免不同 ID 碰撞;不迁移既有旧路径,调用方需在采用新命名后使用新返回路径。
- Radial90 的前端预览与 Rust 导出统一使用角点映射和顺时针起始角规则,顺时针填充从角点前一条边开始,避免两端渲染偏移。
@@ -1199,6 +1199,7 @@ game-project/
- UI 编辑器复用现有 manifest `kind: "UI"``mediaType: "application/json"` 资源,不增加平行 asset kind。资源文件固定为严格 `game-creator-ui-design-state.v1` JSON envelope`projectId``assetId`、每资源 `revision` 和 Rust 唯一源 `State`;旧空对象、未知字段、身份错配、超限、无效内部引用和不安全相对路径均失败关闭。内部引用校验同时覆盖 `Image.target_graphic -> sprite_assets``Text.font -> font_assets`,可选引用非空时必须命中同一 State 内已登记资源。
- Tauri 专用 load/save command 只接受项目路径、期望项目 ID、manifest asset ID 和(保存时)资源 revisionRust 按 manifest 解析受控本地路径并在项目写锁内做 CAS。相同 `State` 返回 unchanged 且不推进 project revision;不同内容安装并回读一致后才推进 revision,后续推进失败返回 `reconciliation-required`,不伪装为完整保存。
- UI 编辑器代码导出仅写入用户项目目录下的 `ui/generated-*.js` 派生文件,绝不推进项目 revision、UI State revision、manifest 阶段或 Runtime 验证门;写入失败只返回生成错误,不得把生成文件写入冒充为项目 mutation。
- UI State 原子安装保留最近一个可解析、canonical 的 `.previous` 恢复候选,作为最佳努力恢复来源;写入主文件前不把完整 State 语义校验重复执行一遍。主文件损坏时,恢复候选仍必须通过同一严格 schema、project/asset identity、revision、引用和 State 校验后才能安装;恢复安装与保存共用项目写锁,并在持锁后重新读取主文件,已有并发保存的有效新版本时直接返回而不安装旧副本。任一候选均不可信则停在加载错误,前端禁编辑和保存。新建 UI 资源先登记并安装合法 envelope,任一步失败补偿 manifest/文件,避免把空 JSON 留给资源卡。
- 图片路径只需是安全项目相对路径,不要求外部图片仍存在或已登记为 manifest asset;缺失媒体只导致 preview 占位。`imageOrder`、当前选择、缩放、面板开关和 preview URL 不写入 State,加载后由 State 派生。保存冻结提交快照,保存期间的新编辑继续保持 dirty;AI state lock 和加载期间禁保存。
@@ -42,6 +42,8 @@ Agent 通过白名单工具 `ui.workflow.run` 发起工作流。项目路径由
每次 State 或 manifest 阶段变化都推进项目 revision。Runtime 回执带有 `revisionAdvanceCount`,用于并发项目 revision 门禁;manifest 资产的 `source.generationKind` 依次记录:
UI 编辑器“生成代码”只把导出的 `ui/generated-*.js` 写入用户项目目录,**绝不推进项目 revision**,也不改变 UI State revision、manifest 阶段或 Runtime 验证门。生成文件属于派生本地产物;若写入失败,仅返回生成错误,不得通过 revision 变化制造 mutation 证据。
```text
ui-workflow.reference-ready
ui-workflow.structure-ready