diff --git a/.changeset/desktop-ui-studio.md b/.changeset/desktop-ui-studio.md new file mode 100644 index 00000000..229813ac --- /dev/null +++ b/.changeset/desktop-ui-studio.md @@ -0,0 +1,10 @@ +--- +"@sfmc-bds/desktop": minor +"@sfmc-bds/sdk": patch +--- + +Desktop 在「开发工具」中内置 UI Studio,编辑器随客户端打包,无需连接实例即可离线使用;切换页面或实例时保留编辑状态,支持命令面板与 Ctrl+9 入口。 + +UI Studio 改用编辑器内的输入、确认和提示弹窗,修复 Electron 中新建工程、重命名页面与自定义绑定无法使用的问题。 + +将桌面的 SnowUI 令牌、字体、Phosphor 图标与 Base UI 控件提取为内部公共 UI 层。UI Studio 复用同一套按钮、输入、选择器、菜单和对话框;嵌入时跟随桌面外观,并按 app/pages/components/lib/store/styles 分层整理界面代码。 diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 34225b6c..f21e1026 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -5,6 +5,7 @@ on: branches: [main] paths: - "packages/desktop/**" + - "packages/ui/**" - "packages/management/**" - "packages/cli/**" - "modules/sdk/**" @@ -54,6 +55,8 @@ jobs: - run: pnpm --filter @sfmc-bds/management typecheck - run: pnpm --filter @sfmc-bds/cli typecheck - run: pnpm --filter @sfmc-bds/desktop typecheck + - run: pnpm --filter @sfmc-bds/ui typecheck + - run: node packages/tools/tsc7.mjs --noEmit -p modules/sdk/@sfmc-sdk/ui-studio-web/tsconfig.json - name: 核对桌面标签与平台版本 if: startsWith(github.ref, 'refs/tags/') shell: pwsh diff --git a/LICENSES.md b/LICENSES.md index b25e1237..ae1fb25d 100644 --- a/LICENSES.md +++ b/LICENSES.md @@ -22,12 +22,15 @@ | `@sfmc-bds/qq-bridge` | AGPL-3.0-only | 见仓库根 `LICENSE` | | `@sfmc-bds/bds-tools` | AGPL-3.0-only | 见仓库根 `LICENSE` | | `@sfmc-bds/tools` | AGPL-3.0-only(**不发 npm**,仓内脚本) | 见仓库根 `LICENSE` | +| `@sfmc-bds/ui`(内部公共界面包) | AGPL-3.0-only(**不发 npm**) | 见仓库根 `LICENSE` | | `@sfmc-bds/sfmc`(meta) | AGPL-3.0-only | 见仓库根 `LICENSE` | | `@sfmc-bds/devkit` | AGPL-3.0-only | 见仓库根 `LICENSE` | | `sfmc-extension`(VS Code) | AGPL-3.0-only | 见仓库根 `LICENSE` | ## 变更记录 +UI Studio 的浏览器应用复用桌面公共 UI,界面应用随静态产物附带 AGPL 许可证与源码入口。SDK 的模块运行时、契约、校验器和作者向 API 仍按 ISC 分发;使用这些 API 开发模块的许可边界保持一致。 + - 统一 `cli` 的 SPDX 拼写为 `AGPL-3.0-only`(原 `AGPL-v3.0-only` 无效) - `meta` 由 `GPL-3.0-only` 改为 `AGPL-3.0-only`(与聚合的 AGPL 服务一致) - `qq-bridge`、`bds-tools` 由 ISC 改为 AGPL-3.0-only(平台网络/运维组件) diff --git a/docs/zh/dev/architecture.md b/docs/zh/dev/architecture.md index a9d06980..ac24583b 100644 --- a/docs/zh/dev/architecture.md +++ b/docs/zh/dev/architecture.md @@ -176,3 +176,9 @@ ScriptsForMinecraftServer/ - `packages/*`:平台基础包与核心工具,均独立发布至 npm。 - `modules/packages/*`:运行时的业务模块目录,与平台核心代码相互独立。 ::: + +## 桌面端与 UI Studio 的界面分层 + +`packages/ui` 提供共享的 SnowUI 令牌与字体、Phosphor 图标和 Base UI 控件/浮层。Desktop 的外观提供者负责 Monaco 与原生窗口配色;Studio 独立运行时保存自己的外观偏好,内嵌时跟随 Desktop。基础视觉和交互只维护一份,领域逻辑由各应用承担。 + +UI Studio 浏览器源码按 `app`(路由与宿主)、`pages`(工程列表与编辑器)、`components`(工程树、属性面板与画布)、`lib`(草稿/导入导出/预览派生)、`store`(工程格式与 IndexedDB)、`styles`(编辑器布局)组织。Desktop 复用 SDK 的 Vite 配置打包 Studio,内置入口在「开发工具」;切页隐藏文档并保留编辑状态,不依赖实例握手或 CLI 服务。 diff --git a/docs/zh/guide/desktop.md b/docs/zh/guide/desktop.md index 62ef0571..a6dfd079 100644 --- a/docs/zh/guide/desktop.md +++ b/docs/zh/guide/desktop.md @@ -69,6 +69,14 @@ gh attestation verify '.\SFMC-Desktop-0.1.2-x64.exe' --repo DogeLakeDev/ScriptsF **配置** 编辑服务和模块配置,保存后根据提示重载或重启相关服务。**控制台** 查看不同服务日志、发送 BDS 命令;**任务** 查看操作进度、失败原因与备份编号。 +## 内置 UI Studio(下个桌面版本) + +左侧栏的 **开发工具 → UI Studio** 打开声明式界面的可视化编辑器,也可用 `Ctrl+9` 或命令面板搜索「UI Studio」。无需添加或连接实例,也无需执行 `sfmc ui studio`;编辑器随桌面客户端打包,可离线使用。控件、图标、字体与桌面端统一,外观跟随桌面顶栏的浅色 / 深色 / 跟随系统设置。 + +支持新建工程、导入文件夹或 ZIP、拖放组件、编辑属性、预览和导出 ZIP。切换桌面页面或实例后返回,当前工程、选中项与撤销记录仍保留。工程自动保存在当前电脑的客户端数据中,跨电脑使用时先导出 ZIP。 + +浏览器中的 Studio 工程与桌面客户端分别保存;已有浏览器工程请先导出 ZIP,再导入内置编辑器。导出的工程需按模块开发流程放回模块中,编辑器不会自动修改或部署服务器文件。 + ## 建议的首次验收 1. 先在独立测试目录完成接入;已有正式部署优先选择 **仅查看**。 diff --git a/modules/sdk/@sfmc-sdk/package.json b/modules/sdk/@sfmc-sdk/package.json index 5991f634..93bf87b1 100644 --- a/modules/sdk/@sfmc-sdk/package.json +++ b/modules/sdk/@sfmc-sdk/package.json @@ -134,16 +134,15 @@ "typecheck": "tsc7 --noEmit -p tsconfig.types.json" }, "devDependencies": { - "@headlessui/react": "^2.2.10", "@sfmc-bds/tools": "workspace:*", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", "@vitejs/plugin-react": "^5.0.0", "fflate": "^0.8.3", - "lucide-react": "^1.45.0", - "react": "^19.2.8", - "react-dom": "^19.2.8", - "vite": "^7.0.0" + "react": "19.3.0", + "react-dom": "19.3.0", + "vite": "^7.0.0", + "@sfmc-bds/ui": "workspace:*" }, "peerDependencies": { "@minecraft/diagnostics": "1.0.0-beta.1.26.40-preview.30", diff --git a/modules/sdk/@sfmc-sdk/ui-studio-web/src/app/App.tsx b/modules/sdk/@sfmc-sdk/ui-studio-web/src/app/App.tsx new file mode 100644 index 00000000..ccf7892b --- /dev/null +++ b/modules/sdk/@sfmc-sdk/ui-studio-web/src/app/App.tsx @@ -0,0 +1,57 @@ +/** 应用外壳:路由、公共 UI 提供者、宿主外观同步与弹窗出口。 */ +import { TooltipProvider } from "@sfmc-bds/ui/tooltip"; +import { useCallback, useEffect, useState } from "react"; +import { StudioDialogHost } from "../components/StudioDialogs"; +import { App as EditorPage } from "../pages/EditorPage"; +import { ProjectList } from "../pages/ProjectList"; +import { embedded } from "./host"; +import { applyResolvedTheme } from "./theme"; + +function parseRoute(): { name: "list" } | { name: "editor"; projectId: string } { + const match = /^#\/p\/([^/]+)/.exec(window.location.hash); + if (match) { + try { + return { name: "editor", projectId: decodeURIComponent(match[1]!) }; + } catch { + /* 无效路由回到列表 */ + } + } + return { name: "list" }; +} + +export function App() { + const [route, setRoute] = useState(parseRoute); + useEffect(() => { + const onHashChange = () => setRoute(parseRoute()); + window.addEventListener("hashchange", onHashChange); + return () => window.removeEventListener("hashchange", onHashChange); + }, []); + useEffect(() => { + if (!embedded) return; + document.documentElement.dataset.embedded = "true"; + const initial = window.parent.document.documentElement.dataset.theme; + if (initial === "light" || initial === "dark") applyResolvedTheme(initial); + const onAppearance = (event: Event) => { + const detail = (event as CustomEvent<{ dark?: unknown }>).detail; + if (typeof detail?.dark === "boolean") applyResolvedTheme(detail.dark ? "dark" : "light"); + }; + window.addEventListener("sfmc:appearance", onAppearance); + return () => window.removeEventListener("sfmc:appearance", onAppearance); + }, []); + const openProject = useCallback((id: string) => { + window.location.hash = `#/p/${encodeURIComponent(id)}`; + }, []); + const goHome = useCallback(() => { + window.location.hash = "#/"; + }, []); + return ( + + {route.name === "editor" ? ( + + ) : ( + + )} + + + ); +} diff --git a/modules/sdk/@sfmc-sdk/ui-studio-web/src/app/host.ts b/modules/sdk/@sfmc-sdk/ui-studio-web/src/app/host.ts new file mode 100644 index 00000000..24bccbf1 --- /dev/null +++ b/modules/sdk/@sfmc-sdk/ui-studio-web/src/app/host.ts @@ -0,0 +1,2 @@ +/** 内嵌时由 desktop 外壳提供标题栏与外观控制。 */ +export const embedded = new URLSearchParams(window.location.search).get("embedded") === "1"; diff --git a/modules/sdk/@sfmc-sdk/ui-studio-web/src/theme.ts b/modules/sdk/@sfmc-sdk/ui-studio-web/src/app/theme.ts similarity index 91% rename from modules/sdk/@sfmc-sdk/ui-studio-web/src/theme.ts rename to modules/sdk/@sfmc-sdk/ui-studio-web/src/app/theme.ts index 7611805e..e39f0d25 100644 --- a/modules/sdk/@sfmc-sdk/ui-studio-web/src/theme.ts +++ b/modules/sdk/@sfmc-sdk/ui-studio-web/src/app/theme.ts @@ -5,6 +5,7 @@ * 偏好写入 localStorage,与 IndexedDB 一样绑在固定 origin(127.0.0.1:3003)。 * index.html 内联脚本用同一 STORAGE_KEY,避免首屏闪深色。 */ +import { PALETTES, applyCssVariables, registerPunctuationFont } from "@sfmc-bds/ui/theme"; export type ThemePref = "system" | "light" | "dark"; export type ResolvedTheme = "light" | "dark"; @@ -44,7 +45,10 @@ export function systemPrefersDark(): boolean { /** 把解析结果写到 。 */ export function applyResolvedTheme(theme: ResolvedTheme): void { + registerPunctuationFont(); + applyCssVariables(PALETTES[theme]); document.documentElement.setAttribute("data-theme", theme); + document.documentElement.style.colorScheme = theme; } /** 按偏好解析并应用到文档。 */ diff --git a/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/Canvas.tsx b/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/Canvas.tsx index 94342ff0..fd9d034e 100644 --- a/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/Canvas.tsx +++ b/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/Canvas.tsx @@ -1,3 +1,4 @@ +import { Button } from "@sfmc-bds/ui/controls"; /** * Canvas.tsx — 语义预览画布。 * @@ -12,12 +13,8 @@ */ import { useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from "react"; -import { RotateCcw, Trash2 } from "lucide-react"; -import type { - UiNode, - UiScreenDocument, - UiTrigger, -} from "../../../src/contracts/ui-document.js"; +import type { UiNode, UiScreenDocument, UiTrigger } from "../../../src/contracts/ui-document.js"; +import { effectiveConfirmChallenge } from "../../../src/ui-studio/shared/confirm-challenge.js"; import { evaluateCondition, readPath, @@ -27,16 +24,16 @@ import { toDisplayText, type UiEvaluateScope, } from "../../../src/ui-studio/shared/evaluate.js"; -import { effectiveConfirmChallenge } from "../../../src/ui-studio/shared/confirm-challenge.js"; -import { parseNodeLocation, uniqueNodeId, type InsertAddress } from "../model"; -import type { PreviewFixture, Selection } from "../model"; -import { buildScope, type PreviewSession } from "../scope"; -import { afterNativeDrag } from "../after-drag"; -import { createLatestFrameQueue } from "../latest-frame"; +import { afterNativeDrag } from "../lib/after-drag"; +import { createLatestFrameQueue } from "../lib/latest-frame"; +import type { PreviewFixture, Selection } from "../lib/model"; +import { parseNodeLocation, uniqueNodeId, type InsertAddress } from "../lib/model"; +import { buildScope, type PreviewSession } from "../lib/scope"; +import type { ActionPreview } from "../pages/EditorPage"; +import { ContextMenu, useContextMenu } from "./ContextMenu"; +import { RotateCcw, Trash2 } from "./icons"; import { createPaletteNode, MOVE_MIME, PALETTE_MIME } from "./Palette"; import { PreviewText } from "./PreviewText"; -import { ContextMenu, useContextMenu } from "./ContextMenu"; -import type { ActionPreview } from "../App"; /** 拖放落点:兄弟节点前/后、容器槽位末尾、或页面 body 末尾。 */ type DropHint = @@ -124,10 +121,7 @@ export function Canvas(props: CanvasProps) { const [refreshTick, setRefreshTick] = useState(0); const [dragging, setDragging] = useState(false); const [hint, setHint] = useState(null); - const scope = useMemo( - () => buildScope(screen, fixture, session), - [screen, fixture, session], - ); + const scope = useMemo(() => buildScope(screen, fixture, session), [screen, fixture, session]); // 组件库在画布之外,拖拽开始/结束通过 window 事件同步,用于显隐槽位落点。 // dragend 用 capture:performDrop 会 stopPropagation,且空 body 同步改 DOM 时 Chrome 可能吞掉冒泡。 @@ -210,9 +204,7 @@ export function Canvas(props: CanvasProps) { const rect = event.currentTarget.getBoundingClientRect(); const pos = event.clientY < rect.top + rect.height / 2 ? "before" : "after"; setHint((prev) => - prev?.kind === "sibling" && prev.path === path && prev.pos === pos - ? prev - : { kind: "sibling", path, pos }, + prev?.kind === "sibling" && prev.path === path && prev.pos === pos ? prev : { kind: "sibling", path, pos } ); event.dataTransfer.dropEffect = event.dataTransfer.types.includes(MOVE_MIME) ? "move" : "copy"; }, @@ -226,7 +218,7 @@ export function Canvas(props: CanvasProps) { prev.slotKey === slotKey && prev.index === index ? prev - : { kind: "slot", containerPath, slotKey, index }, + : { kind: "slot", containerPath, slotKey, index } ); event.dataTransfer.dropEffect = event.dataTransfer.types.includes(MOVE_MIME) ? "move" : "copy"; }, @@ -258,9 +250,9 @@ export function Canvas(props: CanvasProps) {

页面已关闭(close 触发)

- +
); @@ -312,16 +304,11 @@ interface PreviewNodeProps { function PreviewNode({ node, path, scope, props, drag, onNodeContextMenu }: PreviewNodeProps) { const { selection, onSelectNode } = props; // 编辑器始终画出节点:条件失败只降透明度,避免 when 收成一条细线无法点选。 - const hiddenByVisibleWhen = - node.visibleWhen !== undefined && !evaluateCondition(node.visibleWhen, scope); - const whenInactive = - node.type === "when" && !evaluateCondition(node.condition, scope); + const hiddenByVisibleWhen = node.visibleWhen !== undefined && !evaluateCondition(node.visibleWhen, scope); + const whenInactive = node.type === "when" && !evaluateCondition(node.condition, scope); const selected = - selection?.kind === "screen" && - selection.nodePath === path && - selection.screenId === props.screen.id; - const hintHere = - drag.hint?.kind === "sibling" && drag.hint.path === path ? drag.hint.pos : null; + selection?.kind === "screen" && selection.nodePath === path && selection.screenId === props.screen.id; + const hintHere = drag.hint?.kind === "sibling" && drag.hint.path === path ? drag.hint.pos : null; const className = `pv-node pv-${node.type}${selected ? " pv-selected" : ""}` + (hiddenByVisibleWhen || whenInactive ? " pv-inactive" : "") + @@ -341,14 +328,7 @@ function PreviewNode({ node, path, scope, props, drag, onNodeContextMenu }: Prev onDragOver={(event) => drag.overNode(event, path)} onDrop={(event) => drag.dropOnNode(event, path)} > - + ); } @@ -368,10 +348,7 @@ function SlotDropZone({ drag: DragState; }) { if (!drag.dragging) return null; - const hover = - drag.hint?.kind === "slot" && - drag.hint.containerPath === path && - drag.hint.slotKey === slotKey; + const hover = drag.hint?.kind === "slot" && drag.hint.containerPath === path && drag.hint.slotKey === slotKey; return (
)) )} - + ); } @@ -481,8 +452,7 @@ function ButtonPreview({ scope: UiEvaluateScope; props: CanvasProps; }) { - const disabled = - node.disabledWhen !== undefined && evaluateCondition(node.disabledWhen, scope); + const disabled = node.disabledWhen !== undefined && evaluateCondition(node.disabledWhen, scope); return ( - ), - )} -
- ); +export function ContextMenu({ state, onClose }: { state: ContextMenuState; onClose(): void }) { + return ; } diff --git a/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/Diagnostics.tsx b/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/Diagnostics.tsx index a887d2ac..f02c093f 100644 --- a/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/Diagnostics.tsx +++ b/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/Diagnostics.tsx @@ -5,8 +5,8 @@ * 点击可定位到对应页面与组件(解析 JSON Pointer 路径)。 */ -import { AlertTriangle, CheckCircle2 } from "lucide-react"; -import { locateIssue, type ProjectView, type Selection } from "../model"; +import { locateIssue, type ProjectView, type Selection } from "../lib/model"; +import { AlertTriangle, CheckCircle2 } from "./icons"; interface DiagnosticsProps { view: ProjectView; @@ -18,11 +18,7 @@ export function Diagnostics({ view, onLocate }: DiagnosticsProps) { return (
0 ? " has-issues" : ""}`}> - {issues.length > 0 ? ( - - ) : ( - - )} + {issues.length > 0 ? : } 问题:{issues.length} {issues.length === 0 ? ( diff --git a/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/DragHandle.tsx b/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/DragHandle.tsx index 5eafb57b..0234e482 100644 --- a/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/DragHandle.tsx +++ b/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/DragHandle.tsx @@ -6,8 +6,8 @@ * 当前用于组件库条目与组件树节点。 */ -import { GripVertical } from "lucide-react"; import type { DragEvent } from "react"; +import { GripVertical } from "./icons"; /** 侧栏六点抓手:仅此元素发起 HTML5 拖放。 */ export function DragHandle({ @@ -34,12 +34,12 @@ export function DragHandle({ event.dataTransfer.setDragImage( ghost, Math.max(0, event.clientX - rect.left), - Math.max(0, event.clientY - rect.top), + Math.max(0, event.clientY - rect.top) ); } }} > - + ); } diff --git a/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/HeaderMenu.tsx b/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/HeaderMenu.tsx index d7b8e152..baec1f43 100644 --- a/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/HeaderMenu.tsx +++ b/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/HeaderMenu.tsx @@ -1,18 +1,11 @@ -/** - * HeaderMenu.tsx — 顶栏下拉菜单(基于 Headless UI Menu)。 - * - * MenuItems 通过 anchor 属性由 floating-ui 定位到按钮下方; - * 焦点/禁用状态经 data-focus / data-disabled 属性注入,样式在 styles.css 中定义。 - */ +/** 顶栏数据驱动菜单,复用 desktop 的 Base UI 菜单与 SnowUI 浮层。 */ +import { Button } from "@sfmc-bds/ui/controls"; +import { Menu, type MenuEntry } from "@sfmc-bds/ui/overlays"; +import type { UiIcon } from "./icons"; -import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react"; -import type { LucideIcon } from "lucide-react"; - -/** 菜单项;"separator" 表示分隔线。 */ export interface HeaderMenuItem { - icon?: LucideIcon; + icon?: UiIcon; label: string; - /** 右侧展示的快捷键提示(仅展示,不绑定)。 */ shortcut?: string; danger?: boolean; disabled?: boolean; @@ -20,38 +13,21 @@ export interface HeaderMenuItem { } export type HeaderMenuEntry = HeaderMenuItem | "separator"; -export function HeaderMenu({ - label, - entries, -}: { - label: string; - entries: HeaderMenuEntry[]; -}) { - return ( - - {label} - - {entries.map((entry, index) => - entry === "separator" ? ( -
- ) : ( - - - - ), - )} - -
+export const menuEntries = (entries: HeaderMenuEntry[]): MenuEntry[] => + entries.map((entry, index) => + entry === "separator" + ? { type: "separator", key: String(index) } + : { + key: String(index), + label: entry.label, + icon: entry.icon?.iconName, + hint: entry.shortcut, + danger: entry.danger, + disabled: entry.disabled, + onSelect: entry.onClick, + } ); + +export function HeaderMenu({ label, entries }: { label: string; entries: HeaderMenuEntry[] }) { + return {label}} items={menuEntries(entries)} align="start" />; } diff --git a/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/Inspector.tsx b/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/Inspector.tsx index ac11c975..0e48d1f1 100644 --- a/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/Inspector.tsx +++ b/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/Inspector.tsx @@ -11,10 +11,7 @@ * 所有编辑通过 onEdit 进入草稿与撤销栈;未知字段不展示但随文档保留。 */ -import type { - UiNode, - UiScreenDocument, -} from "../../../src/contracts/ui-document.js"; +import type { UiNode, UiScreenDocument } from "../../../src/contracts/ui-document.js"; import { collectBindPaths, FEATURE_FILE, @@ -25,9 +22,8 @@ import { type PreviewFixture, type ProjectView, type Selection, -} from "../model"; +} from "../lib/model"; // 基础字段控件与结构化编辑器分别收敛在 fields.tsx / editors.tsx。 -import { BindField, JsonField, NumberField, SelectField, TextField } from "./fields"; import { ActionsEditor, DerivedEditor, @@ -40,6 +36,7 @@ import { StateEditor, TriggerField, } from "./editors"; +import { BindField, JsonField, NumberField, SelectField, TextField } from "./fields"; interface InspectorProps { view: ProjectView; @@ -170,10 +167,7 @@ const NODE_FIELDS: Record = { disabledWhen, ], when: [expr("condition", "何时显示里面的内容")], - each: [ - bind("source", "数据源 source"), - text("as", "条目名 as", { required: true }), - ], + each: [bind("source", "数据源 source"), text("as", "条目名 as", { required: true })], }; /** 页面级可编辑字段(params/state/load/derived/actions 为结构化编辑器,单独渲染)。 */ @@ -190,8 +184,7 @@ const SCREEN_FIELDS: FieldDef[] = [ export function Inspector({ view, selection, fixture, onEdit, onReplaceFile }: InspectorProps) { // 页面 id 清单(navigate/replace 的 to、entries 的 target 自动补全来源)。 - const screenIds = - view.browse.feature?.screens.map((screen) => screen.id) ?? Object.keys(view.browse.screens); + const screenIds = view.browse.feature?.screens.map((screen) => screen.id) ?? Object.keys(view.browse.screens); if (!selection) { return
在左侧选择页面、组件或文件
; @@ -291,7 +284,7 @@ export function Inspector({ view, selection, fixture, onEdit, onReplaceFile }: I mutateNodeAtPath(doc, selection.nodePath, (target) => { if (value === undefined) delete target[key]; else target[key] = value; - }), + }) ) } /> @@ -389,17 +382,8 @@ function ScreenForm({ ))} commit("params", v)} /> commit("state", v)} /> - commit("load", v)} - /> - commit("derived", v)} - /> + commit("load", v)} /> + commit("derived", v)} /> ))} commit("entries", v)} /> -
- 页面清单(screens)由左侧「页面」分区的文件操作自动维护,当前 {screens} 个。 -
+
页面清单(screens)由左侧「页面」分区的文件操作自动维护,当前 {screens} 个。
); } @@ -473,12 +455,7 @@ function FieldControl({ return commit(field.key, v)} />; case "select": return ( - commit(field.key, v)} - /> + commit(field.key, v)} /> ); case "trigger": return ( @@ -513,12 +490,6 @@ function FieldControl({ case "stringList": return commit(field.key, v)} />; case "options": - return ( - commit(field.key, v)} - /> - ); + return commit(field.key, v)} />; } } diff --git a/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/Palette.tsx b/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/Palette.tsx index fc3912eb..73c1a6ea 100644 --- a/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/Palette.tsx +++ b/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/Palette.tsx @@ -1,5 +1,5 @@ -import { nodeTypeIcon, type NodeIcon } from "./node-icons"; import { DragHandle } from "./DragHandle"; +import { nodeTypeIcon, type NodeIcon } from "./node-icons"; /** 组件库拖拽的 dataTransfer 类型;画布内移动另用 MOVE_MIME。 */ export const PALETTE_MIME = "application/x-sfmc-palette"; @@ -22,7 +22,18 @@ const PALETTE_ITEMS: PaletteItem[] = [ { type: "button", label: "按钮", defaults: { label: "按钮", trigger: { type: "refresh" } } }, { type: "textField", label: "输入框", defaults: { label: "输入框", bind: "state.input", placeholder: "请输入…" } }, { type: "toggle", label: "开关", defaults: { label: "开关", bind: "state.enabled" } }, - { type: "dropdown", label: "下拉", defaults: { label: "下拉", bind: "state.choice", options: [{ value: "a", label: "选项 A" }, { value: "b", label: "选项 B" }] } }, + { + type: "dropdown", + label: "下拉", + defaults: { + label: "下拉", + bind: "state.choice", + options: [ + { value: "a", label: "选项 A" }, + { value: "b", label: "选项 B" }, + ], + }, + }, { type: "slider", label: "滑杆", defaults: { label: "滑杆", bind: "state.level", min: 0, max: 100 } }, // when 默认用常量条件:引用未声明的 state 会让页面立即失验而从画布消失。 { type: "when", label: "条件", defaults: { condition: { value: true }, content: [] } }, @@ -52,7 +63,7 @@ export function Palette() { event.dataTransfer.effectAllowed = "copy"; }} /> - + {item.label}
); diff --git a/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/ProjectTree.tsx b/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/ProjectTree.tsx index 548f257a..b337327c 100644 --- a/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/ProjectTree.tsx +++ b/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/ProjectTree.tsx @@ -9,7 +9,20 @@ * - 其他文件:未登记为页面的 JSON(隐藏 .ui-studio/ 元数据)。 */ +import { useEffect, useRef, useState, type DragEvent, type MouseEvent as ReactMouseEvent } from "react"; import type { UiNode } from "../../../src/contracts/ui-document.js"; +import { + FEATURE_FILE, + nodeChildSlots, + nodeDisplayParts, + parseNodeLocation, + type InsertAddress, + type ProjectView, + type Selection, +} from "../lib/model"; +import { screenRefs } from "../store/project"; +import { ContextMenu, useContextMenu } from "./ContextMenu"; +import { DragHandle } from "./DragHandle"; import { AppWindow, ChevronDown, @@ -22,22 +35,9 @@ import { Pencil, Plus, Trash2, -} from "lucide-react"; -import { DragHandle } from "./DragHandle"; -import { useEffect, useRef, useState, type DragEvent, type MouseEvent as ReactMouseEvent } from "react"; +} from "./icons"; import { nodeTypeIcon } from "./node-icons"; -import { - FEATURE_FILE, - nodeChildSlots, - nodeDisplayParts, - parseNodeLocation, - type InsertAddress, - type ProjectView, - type Selection, -} from "../model"; import { MOVE_MIME } from "./Palette"; -import { screenRefs } from "../store/project"; -import { ContextMenu, useContextMenu } from "./ContextMenu"; interface ProjectTreeProps { view: ProjectView; @@ -69,12 +69,7 @@ export function ProjectTree({ const declared = new Set(screenRefs(view.files).map((ref) => ref.file)); // .ui-studio/ 为 Studio 元数据(含旧场景 fixture),不在树里展示。 const otherFiles = Object.keys(view.files) - .filter( - (file) => - file !== FEATURE_FILE && - !file.startsWith(".ui-studio/") && - !declared.has(file), - ) + .filter((file) => file !== FEATURE_FILE && !file.startsWith(".ui-studio/") && !declared.has(file)) .sort(); // 右键菜单状态:页面行与其他文件行共用。 const { menu, open: openMenu, close: closeMenu } = useContextMenu(); @@ -111,9 +106,7 @@ export function ProjectTree({ event.dataTransfer.dropEffect = "none"; return; } - setDropHint((prev) => - prev?.path === hint.path && prev.pos === hint.pos ? prev : hint, - ); + setDropHint((prev) => (prev?.path === hint.path && prev.pos === hint.pos ? prev : hint)); event.dataTransfer.dropEffect = "move"; }, drop(event, path, node) { @@ -137,9 +130,7 @@ export function ProjectTree({ // 从画布选中嵌套节点时,自动展开其祖先,避免折叠后找不到当前项。 useEffect(() => { if (selection?.kind !== "screen" || !selection.nodePath) return; - const keys = ancestorNodePaths(selection.nodePath).map( - (path) => `${selection.screenId}:${path}`, - ); + const keys = ancestorNodePaths(selection.nodePath).map((path) => `${selection.screenId}:${path}`); setCollapsed((prev) => { let changed = false; const next = new Set(prev); @@ -171,9 +162,7 @@ export function ProjectTree({ - @@ -504,9 +487,7 @@ function TreeNode({ screenId, node, path, selection, collapsed, onToggle, onSele ? null : slots.map((slot) => (
    - {showSlotLabels ? ( -
  • {SLOT_LABELS[slot.key] ?? slot.key}
  • - ) : null} + {showSlotLabels ?
  • {SLOT_LABELS[slot.key] ?? slot.key}
  • : null} {slot.nodes.map((child, index) => ( void>(); +const snapshot = () => queue[0] ?? null; +const subscribe = (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}; +const notify = () => { + for (const listener of listeners) listener(); +}; + +function request(kind: DialogRequest["kind"], message: string, initialValue = ""): Promise { + return new Promise((resolve) => { + queue.push({ id: nextId++, kind, message, initialValue, resolve }); + notify(); + }); +} + +export const promptText = (message: string, initialValue = "") => request("prompt", message, initialValue); +export const confirmDialog = async (message: string) => (await request("confirm", message)) !== null; +export const alertDialog = async (message: string) => { + await request("alert", message); +}; + +function finish(value: string | null) { + queue.shift()?.resolve(value); + notify(); +} + +function StudioDialog({ entry }: { entry: DialogRequest }) { + const [value, setValue] = useState(entry.initialValue); + return ( + finish(null)} + size="sm" + title={entry.kind === "prompt" ? "输入" : entry.kind === "confirm" ? "确认操作" : "提示"} + description={entry.message} + onSubmit={() => finish(entry.kind === "prompt" ? value : "")} + > + {entry.kind === "prompt" && ( + event.currentTarget.select()} + onChange={setValue} + /> + )} +
    + {entry.kind !== "alert" && ( + + )} + +
    +
    + ); +} + +/** 在路由根部挂载一次,切换列表与编辑器时保留弹窗队列。 */ +export function StudioDialogHost() { + const entry = useSyncExternalStore(subscribe, snapshot); + // 队列推进时重新挂载输入组件,避免上一个弹窗的输入值进入下一次操作。 + return entry ? : null; +} diff --git a/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/ThemeToggle.tsx b/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/ThemeToggle.tsx index f21f863c..fe5b2f0d 100644 --- a/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/ThemeToggle.tsx +++ b/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/ThemeToggle.tsx @@ -1,9 +1,10 @@ +import { Button } from "@sfmc-bds/ui/controls"; /** * ThemeToggle.tsx — 顶栏外观切换:跟随系统 / 浅色 / 深色循环。 */ import { useEffect, useState } from "react"; -import { Monitor, Moon, Sun } from "lucide-react"; +import { embedded } from "../app/host"; import { cycleThemePref, readThemePref, @@ -11,7 +12,8 @@ import { syncDocumentTheme, THEME_PREF_LABEL, type ThemePref, -} from "../theme"; +} from "../app/theme"; +import { Monitor, Moon, Sun } from "./icons"; const ICONS = { system: Monitor, @@ -34,11 +36,12 @@ export function ThemeToggle() { const Icon = ICONS[pref]; const label = THEME_PREF_LABEL[pref]; + if (embedded) return null; return ( - + ); } diff --git a/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/editors.tsx b/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/editors.tsx index 88f8a786..e14dbbc2 100644 --- a/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/editors.tsx +++ b/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/editors.tsx @@ -1,3 +1,4 @@ +import { Button } from "@sfmc-bds/ui/controls"; /** * editors.tsx — 结构化属性编辑器(替代手打 JSON)。 * @@ -10,10 +11,11 @@ * 作为一步撤销提交整体字段值(undefined 表示删除该字段)。 */ -import { ArrowDown, ArrowUp, ChevronDown, ChevronRight, Plus, Trash2 } from "lucide-react"; import { useEffect, useState, type ReactNode } from "react"; -import type { BindGroup } from "../model"; +import type { BindGroup } from "../lib/model"; import { BindField, BooleanField, CheckField, JsonField, NumberField, SelectField, TextField } from "./fields"; +import { ArrowDown, ArrowUp, ChevronDown, ChevronRight, Plus, Trash2 } from "./icons"; +import { alertDialog } from "./StudioDialogs"; // --------------------------------------------------------------------------- // 通用:宽松解析 / 唯一键 / 提交约定 @@ -164,7 +166,7 @@ export function DictEditor({ defaultOpen={key === addedKey} onRename={(next) => { if (next in dict) { - window.alert(`键名已存在:${next}`); + void alertDialog(`键名已存在:${next}`); return; } // 保持键顺序重建。 @@ -183,8 +185,8 @@ export function DictEditor({ {renderEntry(dict[key]!, (next) => commitDict({ ...dict, [key]: next }))} ))} - + ); } @@ -243,15 +245,15 @@ export function ListEditor({ {renderEntry(entry, (next) => commitList(list.map((item, i) => (i === index ? next : item))), index)} ))} - + ); } @@ -466,9 +468,9 @@ function OpEditor({ ))} {arity === "variadic" ? ( - + ) : null} @@ -1358,4 +1360,3 @@ export function TriggerField({ ); } - diff --git a/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/fields.tsx b/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/fields.tsx index dca2ed61..2c8b71e6 100644 --- a/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/fields.tsx +++ b/modules/sdk/@sfmc-sdk/ui-studio-web/src/components/fields.tsx @@ -7,10 +7,10 @@ * 供 Inspector 与结构化编辑器(editors.tsx)共用。 */ +import { Checkbox, Select, TextInput, type SelectOption } from "@sfmc-bds/ui/controls"; import { useEffect, useId, useState } from "react"; -import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from "@headlessui/react"; -import { Check, ChevronsUpDown } from "lucide-react"; -import type { BindGroup } from "../model"; +import type { BindGroup } from "../lib/model"; +import { promptText } from "./StudioDialogs"; export function TextField({ label, @@ -41,12 +41,11 @@ export function TextField({ return (
) : ( - @@ -59,12 +90,21 @@ function InstanceList({ collapsed }: { collapsed: boolean }) { } /** 左侧栏 */ -export function Sidebar({ collapsed, onToggleSidebar, onOpenAbout }: { collapsed: boolean; onToggleSidebar: () => void; onOpenAbout: () => void }) { +export function Sidebar({ + collapsed, + onToggleSidebar, + onOpenAbout, +}: { + collapsed: boolean; + onToggleSidebar: () => void; + onOpenAbout: () => void; +}) { const { model, page, setPage, desktopUpdate } = useDesktop(); const hasDesktopUpdate = desktopUpdate.info?.available === true; const running = activeTasks(model).length; const groups = [...new Set(NAV.map((item) => item.group))]; - const disabled = (item: NavItem) => Boolean(model.handshake && !model.handshake.capabilities.includes(item.capability)); + const disabled = (item: NavItem) => + Boolean(item.capability && model.handshake && !model.handshake.capabilities.includes(item.capability)); return (