重构UI编辑器引导流程
将四个平级工具调整为三步软进度导航 增加步骤切换和保存时的非阻塞检查确认 保持画布、资源和组件树在所有步骤可编辑 将实验性UI树合并标记为非正式流程能力
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
-7
@@ -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,
|
||||
};
|
||||
}
|
||||
+1
-3
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user