修复 UI 编辑器导出与预览一致性
修复保存按钮在 AI 运行期间的禁用状态与生成成功提示失效 加强字体资源路径校验并复用生成代码字体 CSS 让布局错误显式传播并为生成文件名追加 asset ID 摘要 统一前端与 Rust 的 Radial90 顺时针填充方向 补充代码生成不推进项目 revision 的技术方案与决策记录
This commit is contained in:
@@ -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 和(保存时)资源 revision;Rust 按 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
|
||||
|
||||
Reference in New Issue
Block a user