重构UI编辑器引导流程

将四个平级工具调整为三步软进度导航

增加步骤切换和保存时的非阻塞检查确认

保持画布、资源和组件树在所有步骤可编辑

将实验性UI树合并标记为非正式流程能力
This commit is contained in:
2026-08-17 17:02:14 +08:00
parent 5c031f2590
commit b09510e083
10 changed files with 481 additions and 291 deletions
@@ -1,36 +0,0 @@
- 本文件采用 append-only:只在末尾追加新的共同原则,不改写或删除既有内容。
- UI Editor 保持桌面端专用,当前不实现持久化。
- 本阶段不新增 UI 测试,不修改或新增其它文档。
- `demo/` 是设计师提供的旧 mock。只复用设计目的、页面布局和组件种类,不复用其数据结构或静态假数据实现。
- 原始需求位于仓库根目录 `req_{n}.txt`;本文件记录已经确认的工程解释。两者冲突时先停止编码并向用户确认。
- 正式状态是`State`
- 核心布局结果 `State.ui_trees`
- Rust 是 UI Editor 领域类型的唯一源。并通过 `ts-rs` 生成
`src/features/ui-editor/types/` 下的 TypeScript 文件。
- React 只保存选择项、缩放、面板开关等临时 UI 状态。组件树、节点内容、审阅状态和人工锁定状态必须体现在同一个内存 `State` 中。
- 纯结构容器可使用空 `components`;视觉节点使用 `Image`;文本和动态数值使用 `Text`。动态数值的语义可写入节点描述,本期不引入独立 Number 组件。
- AI 识别由 Tauri/Rust 中的专用命令实现,入口放在
`src-tauri/src/ui_editor/commands/mod.rs`。前端不得直接调用模型。
- 复用项目现有 LLM 配置和 provider 能力;前端不得传递或持有 API Key。
- 最多四张 UI 参考图必须作为真实多模态像素输入发送。Rust 读取图片并使用
`data:<mime>;base64,...` 形式交给现有 LLM provider,不得只把文件路径、文件名或尺寸写进文字提示词。
- base64 图片正文不得写入日志、错误详情、State、测试快照或其它持久记录。
- 模型输出先进入命令内部识别 DTO,经结构校验和标准化后再构造正式 `UITree``Node``Component``Transform`;模型输出 DTO 不是公开 State 契约。
- 模型使用的临时父子引用不得直接成为正式实体身份;正式 `NodeId` 由 Rust 校验和分配,并保证 State 内唯一。
- `container``button``tab``modal``image``text` 等名称只用于帮助理解 UI,不是正式数据类型,也不规定节点与 `Component` 的映射。正式能力只由当前 Rust 类型表达。
- 本期只保留当前正式渲染组件 `Component::Image``Component::Text`。按钮、页签、弹窗、头像、进度条等更多组件类型以后再设计,不在本阶段预建枚举或 variant。
- 节点数量、待审阅数量及其它概览信息只通过递归查询和过滤 `State.ui_trees` 得出,不向 `State` 添加重复统计或阶段确认字段。
- 当前范围不实现UI交互关系、圆形识别框、复制、粘贴或手工新增节点,也不为这些概念预建状态。
- 各 AI 阶段使用独立的输入/输出契约;后续阶段显式消费前一阶段结果,不从隐含的临时上下文推断前置事实。
- AI 阶段的结果通过命令返回并更新同一个内存 `State`;阶段内部 DTO 只用于当前调用,不成为额外持久状态或跨阶段隐式参数。
- 阶段命令可以只返回该阶段的 DTO;调用方负责将 DTO 显式合并到同一个内存 `State`。仅用于调试的验证入口与正式工作流分离。
- 节点布局与组件是独立通道:`layout_status` / `components_status` 分别记录阶段状态,`allow_llm_edit_layout` / `allow_llm_edit_component` 分别授权 LLM 修改;人工编辑不隐式改变状态或授权。页面根节点可承载组件,但其 Transform 不可修改。
- UI Editor 的项目资源持久化复用 manifest `kind: "UI"``application/json`,正式文件为严格的 `game-creator-ui-design-state.v1` envelope,绑定 `projectId``assetId`、资源 revision 与 Rust `State`;旧 `{}` 文件、未知字段、身份错误、超限或无效引用均失败关闭。
- load/save 只能经专用 Tauri command 按 `expectedProjectId + manifest assetId` 解析受控 `localPath`;保存使用每资源 revision CAS。内容相同不推进项目 revision;安装、回读成功后才推进项目 revision,推进失败返回 `reconciliation-required`
- 每次非空保存保留一个最近有效的 `.previous` 恢复副本。主文件损坏时只从该副本恢复,外部图片丢失或未登记不使 `State` 无效;预览缺失显示占位且不阻断编辑。
- `imageOrder`、当前选择、面板开关、缩放和 preview URL 仍是临时 React 状态,加载后从 `State` 派生;加载失败时禁编辑与保存。持久化代码必须覆盖 round-trip、CAS、严格 schema、恢复和缺失外部素材的定向测试。
@@ -1,3 +1,4 @@
import type { StageStatus } from './types/StageStatus';
import type { State } from './types/State';
import type { UIDesignImageId } from './types/UIDesignImageId';
@@ -117,16 +118,15 @@ export function validateLayoutGenerationPrerequisites(
resourceId: component.Image.target_graphic,
});
}
if (
'Text' in component &&
typeof component.Text.font !== 'string' &&
!(component.Text.font.Bound in state.font_assets)
) {
issues.push({
code: 'missing-font',
message: '文本组件引用的字体不存在',
resourceId: component.Text.font.Bound,
});
if ('Text' in component) {
const font = component.Text.font;
if (typeof font !== 'string' && !(font.Bound in state.font_assets)) {
issues.push({
code: 'missing-font',
message: '文本组件引用的字体不存在',
resourceId: font.Bound,
});
}
}
}
visit(node.children);
@@ -141,3 +141,86 @@ export function validateLayoutReviewPrerequisites(
): UiEditorPrerequisiteIssue[] {
return validateLayoutGenerationPrerequisites(state);
}
function statusIssue(
status: StageStatus,
stage: 'layout' | 'components',
): UiEditorPrerequisiteIssue | null {
if (status === 'Pending') {
return {
code: `${stage}-pending`,
message:
stage === 'layout'
? '存在尚未识别的节点布局'
: '存在尚未绑定的节点组件',
};
}
if (status === 'Blocked') {
return { code: `${stage}-blocked`, message: '存在被阻塞的节点结果' };
}
if (typeof status === 'object' && 'NeedReview' in status) {
return {
code: `${stage}-needs-review`,
message: status.NeedReview || '存在需要人工审阅的节点结果',
};
}
return null;
}
function visitNodes(
nodes: State['ui_trees'][number]['root']['children'],
visit: (node: State['ui_trees'][number]['root']) => void,
) {
for (const node of nodes) {
visit(node);
visitNodes(node.children, visit);
}
}
// 结果检查与进入步骤的前置条件保持分离;调用方只把结果作为非阻塞提示。
export function validateReferenceAnalysisResult(
state: State,
): UiEditorPrerequisiteIssue[] {
const images = Object.values(state.ui_design_images);
if (images.length === 0) {
return [{ code: 'missing-analysis-input', message: '尚未导入参考图' }];
}
if (images.every((image) => image.metadata.role === null)) {
return [
{
code: 'missing-reference-analysis',
message: '参考图尚未设置界面用途',
},
];
}
return [];
}
export function validateStructureRecognitionResult(
state: State,
): UiEditorPrerequisiteIssue[] {
if (state.ui_trees.length === 0) {
return [{ code: 'missing-ui-tree', message: '尚未生成界面结构' }];
}
const issues: UiEditorPrerequisiteIssue[] = [];
for (const tree of state.ui_trees) {
visitNodes([tree.root], (node) => {
const issue = statusIssue(node.metadata.layout_status, 'layout');
if (issue) issues.push(issue);
});
}
return issues;
}
export function validateVisualBindingResult(
state: State,
): UiEditorPrerequisiteIssue[] {
const issues: UiEditorPrerequisiteIssue[] = [];
for (const tree of state.ui_trees) {
visitNodes([tree.root], (node) => {
const issue = statusIssue(node.metadata.components_status, 'components');
if (issue) issues.push(issue);
});
}
return issues;
}
@@ -1,9 +1,7 @@
import { Image as ImageIcon, Plus, ScanSearch } from 'lucide-react';
import { Image as ImageIcon, Plus } from 'lucide-react';
import type { UiEditorPageController } from '../useUiEditorPage';
import { BindingOverview } from './BindingOverview';
import { ImportOverview } from './ImportOverview';
import { RecognitionOverview } from './RecognitionOverview';
import { UiTreePanel } from './UiTreePanel';
export function InputSidebar({
@@ -21,82 +19,13 @@ export function InputSidebar({
images,
sprites,
spriteReferenceCounts,
activeTool,
treeForActiveImage,
selectedNodeId,
focusRequest,
} = controller;
return (
<aside className="min-h-0 overflow-y-auto border-r border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) p-3">
<div className="mb-3 grid gap-2">
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-lg bg-orange-500 px-3 py-2 text-left text-xs font-semibold text-white"
onClick={controller.checkPrerequisites}
>
<ScanSearch size={14} />
</button>
<button
type="button"
className="rounded-lg bg-emerald-600 px-3 py-2 text-left text-xs font-semibold text-white disabled:cursor-wait disabled:opacity-60"
onClick={() => void controller.bindComponents()}
disabled={controller.isBinding || controller.editor.isLocked}
title="调试:绑定组件"
>
{controller.isBinding ? '组件绑定中…' : '绑定组件'}
</button>
<button
type="button"
className="rounded-lg bg-violet-600 px-3 py-2 text-left text-xs font-semibold text-white disabled:cursor-wait disabled:opacity-60"
onClick={() => void controller.mergeUi()}
disabled={
controller.isMerging ||
controller.editor.isLocked ||
controller.editor.state.ui_trees.length === 0
}
title="调试:合并 UI 树"
>
{controller.isMerging ? '界面树合并中…' : '合并 UI 树'}
</button>
<button
type="button"
className="rounded-lg bg-blue-600 px-3 py-2 text-left text-xs font-semibold text-white disabled:cursor-wait disabled:opacity-60"
onClick={() => void controller.recognizeUi()}
disabled={controller.isRecognizing || controller.editor.isLocked}
title="调试:识别 UI 结构"
>
{controller.isRecognizing ? '结构识别中…' : '识别 UI 结构'}
</button>
<button
type="button"
className="rounded-lg bg-orange-500 px-3 py-2 text-left text-xs font-semibold text-white disabled:cursor-wait disabled:opacity-60"
onClick={() => void controller.suggestUiDesignSemantics()}
disabled={controller.isSuggesting || controller.editor.isLocked}
title="调试:识别参考图语义"
>
{controller.isSuggesting ? '识别中…' : '识别参考图语义'}
</button>
{controller.mergeStatus ? (
<p className="m-0 rounded-lg border border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) px-3 py-2 text-xs">
{controller.mergeStatus}
</p>
) : null}
{controller.recognitionStatus ? (
<p className="m-0 rounded-lg border border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) px-3 py-2 text-xs">
{controller.recognitionStatus}
</p>
) : null}
{controller.suggestionStatus ? (
<p className="m-0 rounded-lg border border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) px-3 py-2 text-xs">
{controller.suggestionStatus}
</p>
) : null}
{controller.bindingStatus ? (
<p className="m-0 rounded-lg border border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) px-3 py-2 text-xs">
{controller.bindingStatus}
</p>
) : null}
</div>
<section className="rounded-xl border border-(--platform-subpanel-border) bg-white/35 p-3">
<div className="flex items-center justify-between">
<div>
@@ -206,52 +135,37 @@ export function InputSidebar({
</div>
</section>
{activeTool === 'components' ? (
<RecognitionOverview
uiTrees={editor.state.ui_trees}
onFocusNode={controller.focusNode}
/>
) : activeTool === 'assets' ? (
<BindingOverview
uiTrees={editor.state.ui_trees}
sprites={sprites}
onFocusNode={controller.focusNode}
/>
) : activeTool === 'input' ? (
<ImportOverview
images={images}
sprites={sprites}
uiTrees={editor.state.ui_trees}
/>
) : null}
<ImportOverview
images={images}
sprites={sprites}
uiTrees={editor.state.ui_trees}
/>
{activeTool !== 'input' ? (
<UiTreePanel
treeId={activeImageId}
root={treeForActiveImage?.root ?? null}
selectedNodeId={selectedNodeId}
focusRequest={controller.focusRequest}
onSelectNode={controller.selectNode}
onInsertNode={(parentId) => {
controller.insertNode(parentId);
}}
onInsertNodeAfter={(nodeId) => {
controller.insertNodeAfter(nodeId);
}}
onDeleteNode={(nodeId) => {
controller.deleteNode(nodeId);
}}
onMoveNode={(nodeId, targetParentId, targetIndex) => {
if (!activeImageId) return;
controller.moveNode(
activeImageId,
nodeId,
targetParentId,
targetIndex,
);
}}
/>
) : null}
<UiTreePanel
treeId={activeImageId}
root={treeForActiveImage?.root ?? null}
selectedNodeId={selectedNodeId}
focusRequest={focusRequest}
onSelectNode={controller.selectNode}
onInsertNode={(parentId) => {
controller.insertNode(parentId);
}}
onInsertNodeAfter={(nodeId) => {
controller.insertNodeAfter(nodeId);
}}
onDeleteNode={(nodeId) => {
controller.deleteNode(nodeId);
}}
onMoveNode={(nodeId, targetParentId, targetIndex) => {
if (!activeImageId) return;
controller.moveNode(
activeImageId,
nodeId,
targetParentId,
targetIndex,
);
}}
/>
</aside>
);
}
@@ -10,7 +10,6 @@ import type { UIDesignImageId } from '../../../../features/ui-editor/types/UIDes
import type { UIDesignImageRole } from '../../../../features/ui-editor/types/UIDesignImageRole';
import { UI_DESIGN_IMAGE_ROLES } from '../../model';
import type { UiEditorPageController } from '../../useUiEditorPage';
import { PrerequisiteIssues } from '../PrerequisiteIssues';
import { ComponentPanel } from './Components/ComponentPanel';
import { SpriteBorderEditor } from './SpriteBorder/SpriteBorderEditor';
import TransformEditor from './Transform/TransformEditor';
@@ -143,12 +142,6 @@ export function InspectorSidebar({
) : null}
</div>
{inspectorContent}
{controller.issues ? (
<div className="mt-5">
<PrerequisiteIssues issues={controller.issues} />
</div>
) : null}
</aside>
);
}
@@ -1,35 +1,40 @@
import { UI_EDITOR_TOOLS, type UiEditorToolId } from '../model';
import { UI_EDITOR_STEPS, type UiEditorStepId } from '../model';
export function ToolNavigation({
activeTool,
activeStep,
furthestStepIndex,
disabled,
onChange,
}: {
activeTool: UiEditorToolId;
onChange: (tool: UiEditorToolId) => void;
activeStep: UiEditorStepId;
furthestStepIndex: number;
disabled: boolean;
onChange: (step: UiEditorStepId) => void;
}) {
return (
<nav
className="grid shrink-0 grid-cols-4 gap-2 border-b border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) p-3"
aria-label="UI 编辑工具"
className="grid shrink-0 grid-cols-3 gap-2 border-b border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) p-3"
aria-label="UI 编辑流程"
>
{UI_EDITOR_TOOLS.map((tool) => {
const active = tool.id === activeTool;
{UI_EDITOR_STEPS.map((step, index) => {
const active = step.id === activeStep;
const visited = index <= furthestStepIndex;
return (
<button
key={tool.id}
key={step.id}
type="button"
className={`min-w-0 rounded-xl border px-3 py-2 text-left transition ${
disabled={disabled}
className={`min-w-0 rounded-xl border px-3 py-2 text-left transition disabled:cursor-wait disabled:opacity-55 ${
active
? 'border-orange-300 bg-orange-50 text-orange-950 shadow-sm'
: 'border-transparent text-(--platform-text-strong) hover:bg-black/4'
: visited
? 'border-orange-100 bg-orange-50/45 text-orange-900 hover:bg-orange-50'
: 'border-transparent text-(--platform-text-strong) hover:bg-black/4'
}`}
onClick={() => onChange(tool.id)}
onClick={() => onChange(step.id)}
>
<span className="block truncate text-xs font-semibold">
{tool.label}
</span>
<span className="mt-0.5 block truncate text-[10px] text-(--platform-text-soft)">
{tool.hint}
{index + 1}. {step.label}
</span>
</button>
);
@@ -0,0 +1,80 @@
import type { UiEditorPageController } from '../useUiEditorPage';
export function WorkflowActionCard({
controller,
}: {
controller: UiEditorPageController;
}) {
const action = getStepAction(controller);
const running =
(controller.activeStep === 'reference-analysis' &&
controller.isSuggesting) ||
(controller.activeStep === 'structure-recognition' &&
controller.isRecognizing) ||
(controller.activeStep === 'visual-binding' && controller.isBinding);
const status =
controller.activeStep === 'reference-analysis'
? controller.suggestionStatus
: controller.activeStep === 'structure-recognition'
? controller.recognitionStatus
: controller.bindingStatus;
const hasRun =
controller.activeStep === 'reference-analysis'
? controller.hasSuggested
: controller.activeStep === 'structure-recognition'
? controller.hasRecognized
: controller.hasBound;
return (
<section className="shrink-0 border-b border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) px-4 py-3">
<div className="flex items-center gap-2">
<h2 className="m-0 text-sm font-semibold">{action.label}</h2>
<button
type="button"
className="rounded-lg bg-orange-600 px-3 py-1.5 text-xs font-semibold text-white disabled:cursor-wait disabled:opacity-60"
disabled={controller.isAiRunning}
onClick={() => void action.action()}
>
{running ? action.runningLabel : hasRun ? '重新运行' : action.label}
</button>
{controller.nextStep ? (
<button
type="button"
className="rounded-lg border border-(--platform-subpanel-border) px-3 py-1.5 text-xs disabled:cursor-wait disabled:opacity-60"
disabled={controller.isAiRunning}
onClick={controller.continueToNextStep}
>
</button>
) : null}
{status ? (
<span className="min-w-0 truncate text-xs text-(--platform-text-soft)">
{status}
</span>
) : null}
</div>
</section>
);
}
function getStepAction(controller: UiEditorPageController) {
if (controller.activeStep === 'reference-analysis') {
return {
label: '分析参考图',
runningLabel: '分析中…',
action: controller.suggestUiDesignSemantics,
};
}
if (controller.activeStep === 'structure-recognition') {
return {
label: '识别界面结构',
runningLabel: '识别中…',
action: controller.recognizeUi,
};
}
return {
label: '绑定视觉素材',
runningLabel: '绑定中…',
action: controller.bindComponents,
};
}
@@ -273,9 +273,7 @@ export function PreviewWorkspace({
return (
<section className="flex min-h-0 min-w-0 flex-col overflow-hidden bg-(--platform-body-fill)">
<header className="flex h-12 shrink-0 items-center justify-between border-b border-(--platform-subpanel-border) px-4">
<h1 className="m-0 text-sm font-semibold">
{controller.activeToolLabel}
</h1>
<h1 className="m-0 text-sm font-semibold">UI布局编辑器</h1>
<div className="flex items-center gap-3">
<div className="flex items-center rounded-lg border border-(--platform-subpanel-border) bg-white/70 p-0.5 text-[10px]">
<button
@@ -1,9 +1,8 @@
import { ChevronLeft } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { ThemedModal } from '../../components/modal/ThemedModal';
import {
createTauriUiDesignStateStore,
mockUiDesignStateStore,
type UiDesignStateStore,
} from '../../features/ui-editor/uiDesignStateStore';
@@ -12,56 +11,53 @@ import { InputSidebar } from './components/InputSidebar';
import { InspectorSidebar } from './components/Inspector/InspectorSidebar';
import { PreviewWorkspace } from './components/preview/PreviewWorkspace';
import { ToolNavigation } from './components/ToolNavigation';
import { useUiEditorPage } from './useUiEditorPage';
import { WorkflowActionCard } from './components/WorkflowActionCard';
import { UI_EDITOR_STEPS } from './model';
import {
type UiEditorPageController,
useUiEditorPage,
} from './useUiEditorPage';
export default function UiEditorPage({
projectPath = '/tmp/ui_editor',
resourceId,
projectId,
resourceLabel,
onBack,
stateStore,
stateStore = mockUiDesignStateStore,
}: {
projectPath?: string;
resourceId?: string;
projectId?: string;
resourceLabel?: string;
onBack?: () => void;
stateStore?: UiDesignStateStore;
}) {
const resolvedStore = useMemo(
() =>
stateStore ??
(resourceId && projectId
? createTauriUiDesignStateStore(projectPath, projectId)
: mockUiDesignStateStore),
[projectId, projectPath, resourceId, stateStore],
);
const controller = useUiEditorPage(projectPath, resourceId, resolvedStore);
const controller = useUiEditorPage(projectPath, resourceId, stateStore);
const [savedStateSignature, setSavedStateSignature] = useState<string | null>(
null,
);
const [saveError, setSaveError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [saveWarningOpen, setSaveWarningOpen] = useState(false);
const [saveAfterReturn, setSaveAfterReturn] = useState(false);
const [returnConfirmOpen, setReturnConfirmOpen] = useState(false);
const loadedResourceIdRef = useRef<string | undefined>(undefined);
const stateSignature = JSON.stringify(controller.editor.state);
useEffect(() => {
if (!resourceId || controller.isLoading || controller.loadError) return;
if (!resourceId || controller.isLoading) return;
if (loadedResourceIdRef.current !== resourceId) {
loadedResourceIdRef.current = resourceId;
setSavedStateSignature(stateSignature);
setSaveError(null);
}
}, [controller.isLoading, controller.loadError, resourceId, stateSignature]);
}, [controller.isLoading, resourceId, stateSignature]);
const isDirty =
resourceId !== undefined &&
savedStateSignature !== null &&
savedStateSignature !== stateSignature;
async function save() {
async function save(afterReturn = false) {
if (
!resourceId ||
isSaving ||
@@ -70,40 +66,46 @@ export default function UiEditorPage({
controller.persistedRevision === null ||
controller.editor.isLocked
) {
return false;
return;
}
setSaveError(null);
setIsSaving(true);
const snapshot = structuredClone(controller.editor.state);
const snapshotSignature = JSON.stringify(snapshot);
try {
const result = await resolvedStore.save(
const result = await stateStore.save(
resourceId,
controller.persistedRevision,
snapshot,
);
if (result.status === 'conflict') {
setSaveError('资源已在别处更新;请重新加载后再保存。');
return false;
return;
}
controller.setPersistedRevision(result.revision);
if (JSON.stringify(controller.editor.state) === snapshotSignature) {
setSavedStateSignature(snapshotSignature);
}
return true;
if (afterReturn) {
setReturnConfirmOpen(false);
onBack?.();
}
} catch (error) {
setSaveError(error instanceof Error ? error.message : String(error));
return false;
} finally {
setIsSaving(false);
}
}
async function saveAndReturn() {
if (await save()) {
setReturnConfirmOpen(false);
onBack?.();
function requestSave(afterReturn = false) {
if (!resourceId || isSaving || controller.isAiRunning) return;
setSaveAfterReturn(afterReturn);
if (controller.postCheckIssuesForSave().length > 0) {
if (afterReturn) setReturnConfirmOpen(false);
setSaveWarningOpen(true);
return;
}
void save(afterReturn);
}
function requestBack() {
@@ -137,35 +139,27 @@ export default function UiEditorPage({
controller.persistedRevision === null ||
controller.editor.isLocked
}
onClick={() => void save()}
onClick={() => requestSave()}
>
{isSaving ? '保存中…' : '保存'}
</button>
</header>
) : null}
<div
className={
controller.isLoading || controller.loadError
? 'pointer-events-none flex min-h-0 flex-1 flex-col opacity-60'
: 'flex min-h-0 flex-1 flex-col'
}
>
<ToolNavigation
activeTool={controller.activeTool}
onChange={controller.selectTool}
/>
<div className="grid min-h-0 flex-1 grid-cols-[300px_minmax(360px,1fr)_400px] overflow-hidden">
<InputSidebar controller={controller} />
<ToolNavigation
activeStep={controller.activeStep}
furthestStepIndex={controller.furthestStepIndex}
disabled={controller.isAiRunning}
onChange={controller.requestStepChange}
/>
<div className="grid min-h-0 flex-1 grid-cols-[300px_minmax(360px,1fr)_400px] overflow-hidden">
<InputSidebar controller={controller} />
<div className="flex min-h-0 min-w-0 flex-col">
<WorkflowActionCard controller={controller} />
<PreviewWorkspace controller={controller} />
<InspectorSidebar controller={controller} />
</div>
<EditorDialogs controller={controller} />
<InspectorSidebar controller={controller} />
</div>
{saveError || controller.loadError ? (
<p className="m-0 px-4 py-2 text-xs text-red-700" role="alert">
{saveError ?? `无法加载 UI 设计:${controller.loadError}`}
</p>
) : null}
<EditorDialogs controller={controller} />
<ThemedModal
open={returnConfirmOpen}
onClose={() => setReturnConfirmOpen(false)}
@@ -205,12 +199,101 @@ export default function UiEditorPage({
controller.persistedRevision === null ||
controller.editor.isLocked
}
onClick={() => void saveAndReturn()}
onClick={() => requestSave(true)}
>
{isSaving ? '保存中…' : '保存并返回'}
</button>
</div>
</ThemedModal>
<ThemedModal
open={controller.pendingWorkflowStepChange !== null}
onClose={controller.cancelStepChange}
ariaLabel="步骤检查"
panelClassName="w-[440px] rounded-2xl p-5"
>
<h2 className="m-0 text-base font-semibold"></h2>
<WorkflowIssues
title={`离开${stepLabel(controller.pendingWorkflowStepChange?.from)}`}
issues={controller.pendingWorkflowStepChange?.leavingIssues ?? []}
/>
<WorkflowIssues
title={`进入${stepLabel(controller.pendingWorkflowStepChange?.to)}`}
issues={controller.pendingWorkflowStepChange?.enteringIssues ?? []}
/>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
className="rounded-lg border border-(--platform-subpanel-border) px-3 py-2 text-xs"
onClick={controller.cancelStepChange}
>
</button>
<button
type="button"
className="rounded-lg bg-orange-600 px-3 py-2 text-xs font-semibold text-white"
onClick={controller.confirmStepChange}
>
</button>
</div>
</ThemedModal>
<ThemedModal
open={saveWarningOpen}
onClose={() => setSaveWarningOpen(false)}
ariaLabel="保存前检查"
panelClassName="w-[440px] rounded-2xl p-5"
>
<h2 className="m-0 text-base font-semibold"></h2>
<WorkflowIssues
title="保存当前编辑结果"
issues={controller.postCheckIssuesForSave()}
/>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
className="rounded-lg border border-(--platform-subpanel-border) px-3 py-2 text-xs"
onClick={() => setSaveWarningOpen(false)}
>
</button>
<button
type="button"
className="rounded-lg bg-orange-600 px-3 py-2 text-xs font-semibold text-white"
onClick={() => {
setSaveWarningOpen(false);
void save(saveAfterReturn);
}}
>
</button>
</div>
</ThemedModal>
</main>
);
}
function stepLabel(step: UiEditorPageController['activeStep'] | undefined) {
return UI_EDITOR_STEPS.find((item) => item.id === step)?.label ?? '当前步骤';
}
function WorkflowIssues({
title,
issues,
}: {
title: string;
issues: Array<{ code: string; message: string; resourceId?: string }>;
}) {
if (issues.length === 0) return null;
return (
<section className="mt-4 rounded-xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-900">
<strong>{title}</strong>
<ul className="mt-2 space-y-1 pl-4">
{issues.map((issue, index) => (
<li key={`${issue.code}-${issue.resourceId ?? index}`}>
{issue.message}
</li>
))}
</ul>
</section>
);
}
@@ -3,7 +3,8 @@ import type { UIDesignImageId } from '../../features/ui-editor/types/UIDesignIma
import type { UIDesignImageRole } from '../../features/ui-editor/types/UIDesignImageRole';
import type { RemovalImpact } from '../../features/ui-editor/useUiEditorState';
export type UiEditorToolId = 'input' | 'components' | 'assets' | 'layout';
export type UiEditorStepId =
'reference-analysis' | 'structure-recognition' | 'visual-binding';
export type UiEditorImportKind = 'design-image' | 'sprite';
export type UiEditorNodeFocusRequest = {
@@ -18,15 +19,13 @@ export type PendingResourceRemoval = {
impact: RemovalImpact;
};
export const UI_EDITOR_TOOLS: Array<{
id: UiEditorToolId;
export const UI_EDITOR_STEPS: Array<{
id: UiEditorStepId;
label: string;
hint: string;
}> = [
{ id: 'input', label: '导入并确认输入', hint: '界面图与独立素材' },
{ id: 'components', label: '组件结构', hint: '识别与校正' },
{ id: 'assets', label: '素材绑定', hint: '语义与槽位' },
{ id: 'layout', label: '布局验收', hint: '生成与微调' },
{ id: 'reference-analysis', label: '分析参考图' },
{ id: 'structure-recognition', label: '识别界面结构' },
{ id: 'visual-binding', label: '绑定视觉素材' },
];
export const UI_DESIGN_IMAGE_ROLES: Array<{
@@ -12,7 +12,9 @@ import {
type UiEditorPrerequisiteIssue,
validateAssetRecognitionPrerequisites,
validateComponentRecognitionPrerequisites,
validateLayoutReviewPrerequisites,
validateReferenceAnalysisResult,
validateStructureRecognitionResult,
validateVisualBindingResult,
} from '../../features/ui-editor/prerequisites';
import { applyRecognitionResult } from '../../features/ui-editor/recognition';
import type { BindingDTO } from '../../features/ui-editor/types/BindingDTO';
@@ -46,24 +48,34 @@ import {
removalHasDownstreamReferences,
type UiEditorImportKind,
uiEditorOperationError,
type UiEditorToolId,
type UiEditorStepId,
} from './model';
import { useUiEditorNodeFocus } from './useUiEditorNodeFocus';
const ASSET_BATCH_SIZE = 5;
export type PendingWorkflowStepChange = {
from: UiEditorStepId;
to: UiEditorStepId;
leavingIssues: UiEditorPrerequisiteIssue[];
enteringIssues: UiEditorPrerequisiteIssue[];
};
export function useUiEditorPage(
projectPath: string,
resourceId?: string,
stateStore: UiDesignStateStore = mockUiDesignStateStore,
) {
const editor = useUiEditorState(EMPTY_UI_EDITOR_STATE);
const { replaceState } = editor;
const replaceEditorState = editor.replaceState;
const [isLoading, setIsLoading] = useState(Boolean(resourceId));
const [loadError, setLoadError] = useState<string | null>(null);
const [persistedRevision, setPersistedRevision] = useState<number | null>(
resourceId ? null : 0,
);
const [activeTool, setActiveTool] = useState<UiEditorToolId>('input');
const [activeStep, setActiveStep] =
useState<UiEditorStepId>('reference-analysis');
const [furthestStepIndex, setFurthestStepIndex] = useState(0);
const [imageOrder, setImageOrder] = useState<UIDesignImageId[]>([]);
const [activeImageId, setActiveImageId] = useState<UIDesignImageId | null>(
null,
@@ -71,13 +83,17 @@ export function useUiEditorPage(
const [selectedSpriteId, setSelectedSpriteId] =
useState<SpriteAssetId | null>(null);
const [selectedNodeId, setSelectedNodeId] = useState<NodeId | null>(null);
const { focusRequest, focusNode } = useUiEditorNodeFocus({
activateImage: setActiveImageId,
clearSpriteSelection: () => setSelectedSpriteId(null),
selectNode: setSelectedNodeId,
});
const [keepChildrenUnchanged, setKeepChildrenUnchanged] = useState(false);
const [importKind, setImportKind] = useState<UiEditorImportKind | null>(null);
const [previewUrls, setPreviewUrls] = useState<Record<string, string>>({});
const [status, setStatus] = useState<string | null>(null);
const [issues, setIssues] = useState<UiEditorPrerequisiteIssue[] | null>(
null,
);
const [pendingWorkflowStepChange, setPendingWorkflowStepChange] =
useState<PendingWorkflowStepChange | null>(null);
const [clearOpen, setClearOpen] = useState(false);
const [pendingRemoval, setPendingRemoval] =
useState<PendingResourceRemoval | null>(null);
@@ -91,6 +107,9 @@ export function useUiEditorPage(
const [mergeStatus, setMergeStatus] = useState<string | null>(null);
const [isBinding, setIsBinding] = useState(false);
const [bindingStatus, setBindingStatus] = useState<string | null>(null);
const [hasSuggested, setHasSuggested] = useState(false);
const [hasRecognized, setHasRecognized] = useState(false);
const [hasBound, setHasBound] = useState(false);
useEffect(() => {
if (!resourceId) {
@@ -107,7 +126,7 @@ export function useUiEditorPage(
.load(resourceId)
.then(({ state, revision }) => {
if (!cancelled) {
replaceState(state);
replaceEditorState(state);
const ids = Object.keys(
state.ui_design_images,
).sort() as UIDesignImageId[];
@@ -119,7 +138,7 @@ export function useUiEditorPage(
setPersistedRevision(revision);
if (projectPath) {
const scopeId = createProjectResourcePreviewScopeId();
const previewResources = [
const resources = [
...Object.entries(state.ui_design_images).map(([id, image]) => ({
id,
path: image.path,
@@ -130,7 +149,7 @@ export function useUiEditorPage(
})),
];
void Promise.all(
previewResources.map(async ({ id, path }) => {
resources.map(async ({ id, path }) => {
try {
const preview = await invoke<{ dataUrl: string }>(
'read_local_project_image_preview',
@@ -176,7 +195,7 @@ export function useUiEditorPage(
return () => {
cancelled = true;
};
}, [projectPath, replaceState, resourceId, stateStore]);
}, [projectPath, replaceEditorState, resourceId, stateStore]);
const images = editor.state.ui_design_images;
const sprites = editor.state.sprite_assets;
@@ -185,14 +204,13 @@ export function useUiEditorPage(
const pageOptions = Object.entries(images).filter(
([, image]) => image.metadata.role === 'Page',
);
const activeToolLabel =
activeTool === 'input'
? '导入并确认输入'
: activeTool === 'components'
? '组件结构'
: activeTool === 'assets'
? '素材绑定'
: '布局验收';
const isAiRunning = isSuggesting || isRecognizing || isBinding || isMerging;
const nextStep: UiEditorStepId | null =
activeStep === 'reference-analysis'
? 'structure-recognition'
: activeStep === 'structure-recognition'
? 'visual-binding'
: null;
const spriteReferenceCounts = useMemo(() => {
const counts: Record<string, number> = {};
@@ -344,14 +362,63 @@ export function useUiEditorPage(
setPendingRemoval(null);
}
function checkPrerequisites() {
const validator =
activeTool === 'input' || activeTool === 'components'
? validateComponentRecognitionPrerequisites
: activeTool === 'assets'
? validateAssetRecognitionPrerequisites
: validateLayoutReviewPrerequisites;
setIssues(validator(editor.state));
function prerequisiteIssuesForStep(step: UiEditorStepId) {
if (step === 'reference-analysis') return [];
if (step === 'structure-recognition') {
return validateComponentRecognitionPrerequisites(editor.state);
}
return validateAssetRecognitionPrerequisites(editor.state);
}
function postCheckIssuesForStep(step: UiEditorStepId) {
if (step === 'reference-analysis') {
return validateReferenceAnalysisResult(editor.state);
}
if (step === 'structure-recognition') {
return validateStructureRecognitionResult(editor.state);
}
return validateVisualBindingResult(editor.state);
}
function enterStep(step: UiEditorStepId) {
setActiveStep(step);
const index =
step === 'reference-analysis'
? 0
: step === 'structure-recognition'
? 1
: 2;
setFurthestStepIndex((current) => Math.max(current, index));
}
function requestStepChange(step: UiEditorStepId) {
if (step === activeStep || isAiRunning) return;
const leavingIssues = postCheckIssuesForStep(activeStep);
const enteringIssues = prerequisiteIssuesForStep(step);
if (leavingIssues.length === 0 && enteringIssues.length === 0) {
enterStep(step);
return;
}
setPendingWorkflowStepChange({
from: activeStep,
to: step,
leavingIssues,
enteringIssues,
});
}
function confirmStepChange() {
if (!pendingWorkflowStepChange) return;
enterStep(pendingWorkflowStepChange.to);
setPendingWorkflowStepChange(null);
}
function postCheckIssuesForSave() {
return [
...validateReferenceAnalysisResult(editor.state),
...validateStructureRecognitionResult(editor.state),
...validateVisualBindingResult(editor.state),
];
}
function clearState() {
@@ -361,15 +428,9 @@ export function useUiEditorPage(
setSelectedSpriteId(null);
setSelectedNodeId(null);
setPreviewUrls({});
setIssues(null);
setClearOpen(false);
}
function selectTool(tool: UiEditorToolId) {
setActiveTool(tool);
setIssues(null);
}
function selectDesignImage(id: UIDesignImageId) {
setActiveImageId(id);
setSelectedSpriteId(null);
@@ -514,37 +575,31 @@ export function useUiEditorPage(
function setImageName(name: string) {
if (!activeImageId) return;
editor.setImageName(activeImageId, name);
setIssues(null);
}
function setImageDescription(description: string) {
if (!activeImageId) return;
editor.setImageDescription(activeImageId, description);
setIssues(null);
}
function setImageRole(role: UIDesignImageRole | null) {
if (!activeImageId) return;
editor.setImageRole(activeImageId, role);
setIssues(null);
}
function setImageSlaveTo(slaveTo: UIDesignImageId | null) {
if (!activeImageId) return;
editor.setImageSlaveTo(activeImageId, slaveTo);
setIssues(null);
}
function setSpriteName(name: string) {
if (!selectedSpriteId) return;
editor.setSpriteName(selectedSpriteId, name);
setIssues(null);
}
function setSpriteAssetType(assetType: string) {
if (!selectedSpriteId) return;
editor.setSpriteAssetType(selectedSpriteId, assetType);
setIssues(null);
}
function setSpriteBorder(border: SpriteBorder) {
@@ -563,6 +618,7 @@ export function useUiEditorPage(
{ projectPath, state: snapshot },
);
editor.replaceState(applyUiDesignSuggestions(snapshot, suggestions));
setHasSuggested(true);
setSuggestionStatus(`已应用 ${suggestions.length} 条参考图语义建议。`);
});
} catch (cause) {
@@ -585,6 +641,7 @@ export function useUiEditorPage(
state: snapshot,
});
editor.replaceState(applyRecognitionResult(snapshot, result));
setHasRecognized(true);
setSelectedNodeId(null);
setRecognitionStatus(`已替换 ${result.ui_trees.length} 棵界面树。`);
});
@@ -598,6 +655,7 @@ export function useUiEditorPage(
}
async function mergeUi() {
// TODO: This experimental operation is intentionally outside the formal workflow.
if (isMerging) return;
setMergeStatus(null);
setIsMerging(true);
@@ -645,6 +703,7 @@ export function useUiEditorPage(
setBindingStatus(
`组件绑定完成(${batches.length}/${batches.length})。`,
);
setHasBound(true);
});
} catch (cause) {
setBindingStatus(cause instanceof Error ? cause.message : String(cause));
@@ -661,16 +720,19 @@ export function useUiEditorPage(
persistedRevision,
setPersistedRevision,
editor,
activeTool,
activeToolLabel,
activeStep,
furthestStepIndex,
nextStep,
isAiRunning,
imageOrder,
activeImageId,
selectedSpriteId,
selectedNodeId,
focusRequest,
importKind,
previewUrls,
status,
issues,
pendingWorkflowStepChange,
clearOpen,
pendingRemoval,
images,
@@ -684,10 +746,17 @@ export function useUiEditorPage(
selectedNodeParentSize: selectedNodeContext?.parentSize,
keepChildrenUnchanged,
setKeepChildrenUnchanged,
selectTool,
requestStepChange,
continueToNextStep: () => {
if (nextStep) requestStepChange(nextStep);
},
confirmStepChange,
cancelStepChange: () => setPendingWorkflowStepChange(null),
postCheckIssuesForSave,
selectDesignImage,
selectSprite,
selectNode,
focusNode,
clearNodeSelection,
setNodeTransform,
updateNodeTransform,
@@ -707,7 +776,6 @@ export function useUiEditorPage(
requestSpriteRemoval,
confirmRemoval,
cancelRemoval: () => setPendingRemoval(null),
checkPrerequisites,
openClearDialog: () => setClearOpen(true),
closeClearDialog: () => setClearOpen(false),
clearState,
@@ -719,15 +787,18 @@ export function useUiEditorPage(
setSpriteAssetType,
setSpriteBorder,
isSuggesting,
hasSuggested,
suggestionStatus,
suggestUiDesignSemantics,
isRecognizing,
hasRecognized,
recognitionStatus,
recognizeUi,
isMerging,
mergeStatus,
mergeUi,
isBinding,
hasBound,
bindingStatus,
bindComponents,
};