自定义组件与设计器面板
本文档提供了通过自定义组件和设计器面板扩展 VTJ Designer 的全面指南。组件系统作为将自定义功能集成到设计器 UI 的架构基础,使开发者能够创建针对特定工作流定制的专用工具、面板和界面。
组件系统架构
VTJ Designer 采用基于区域的组件架构,其中组件被组织到设计器布局中的指定区域内。这种设计促进了模块化,允许灵活的 UI 组合,并支持运行时动态组件注册。
WidgetManager (widget.ts#L8-L103) 作为中央注册表,管理所有组件注册并提供动态组件操作的方法。每个组件都与 RegionType 枚举 (types.ts#L40-L54) 中定义的特定区域相关联,从而确定其出现在设计器界面的哪个位置。
组件类型和配置
该框架支持三种主要的组件类型,每种类型服务于设计器环境中的不同 UI 集成模式。
基础组件
Widget 接口 (types.ts#L60-L96) 定义了所有组件的核心配置结构:
| 属性 | 类型 | 必填 | 描述 |
|---|---|---|---|
name |
string |
是 | 组件的唯一标识符 |
region |
RegionType |
是 | 组件放置的目标区域 |
component |
VueComponent |
是 | Vue 组件实现 |
props |
Record<string, any> |
否 | 传递给组件的默认属性 |
invisible |
boolean |
否 | 控制组件的可见性 |
group |
string |
否 | 用于过滤的逻辑分组 |
order |
number |
否 | 区域内的显示顺序 |
remote |
boolean |
否 | 指示远程资源依赖 |
AppWidget
AppWidget 类型 (types.ts#L102-L131) 扩展了基础组件,用于 Apps 区域中的应用程序样式面板:
| 附加属性 | 类型 | 描述 |
|---|---|---|
type |
'app' |
标识为应用程序组件 |
icon |
VueComponent |
组件的显示图标 |
label |
string |
人类可读的标签 |
openType |
`'panel' | 'link' |
url |
string |
'link' 类型组件的 URL |
cache |
boolean |
启用 KeepAlive 缓存 |
内置示例包括 Pages、Blocks、Components 和 Apis 组件 (widgets.ts#L39-L98)。
TabWidget
TabWidget 类型 (types.ts#L137-L153) 在 Workspace 区域中创建选项卡界面:
| 附加属性 | 类型 | 描述 |
|---|---|---|
type |
'tab' |
标识为选项卡组件 |
label |
string |
选项卡显示名称 |
icon |
VueComponent |
可选的选项卡图标 |
closable |
boolean |
选项卡是否可关闭 |
actions |
any[] |
附加操作按钮 |
示例包括 Designer、Properties、Events、CSS 和 Style 组件 (widgets.ts#L181-L247)。
区域系统概述
设计器界面被组织成七个预定义区域,每个区域在用户体验中都有特定用途。
Logo & Switcher] T[Toolbar
Action Buttons] A[Actions
Global Actions] end subgraph MainBody[Main Body] L[Apps Region
Left Panel
Panel-type Widgets] W[Workspace Region
Center
Tab-type Widgets] R[Settings Region
Right Panel
Panel-type Widgets] end subgraph Footer[Footer Section] S[Status Region
Status Bar] end
区域描述
| 区域 | 用途 | 组件类型 | 示例组件 |
|---|---|---|---|
| Brand | Logo 和品牌标识 | Standard | Logo, Switcher |
| Toolbar | 主要操作 | Standard | Toolbar |
| Actions | 全局操作 | Standard | Actions |
| Apps | 左侧面板导航 | AppWidget | Pages, Blocks, Components, Outline, History, Apis |
| Workspace | 中央工作区 | TabWidget | Designer, Properties, Events, CSS, Style, Directives |
| Settings | 右侧面板内容 | AppWidget | Deps, Globals, I18n, Env |
| Status | 状态信息 | Standard | Status |
每个区域都由专用的 Vue 组件 (regions/index.ts) 渲染,并由处理动态组件解析的 RegionWrapper 组件 (region.ts) 包装。
创建自定义组件
第 1 步:创建组件
将组件开发为 Vue 组件,通常放置在 packages/designer/src/components/widgets/ 中。组件可以通过可组合 Hooks 访问设计器上下文。
html
<template>
<div class="v-custom-widget">
<h3>{{ title }}</h3>
<p>{{ description }}</p>
<!-- 组件内容 -->
</div>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import { useEngine, useCurrent } from "../hooks";
const engine = useEngine();
const { current, context } = useCurrent();
const title = ref("My Custom Widget");
const description = ref("This is a custom designer widget");
defineOptions({
name: "CustomWidget",
});
</script>
可用的设计器 Hooks (hooks/index.ts) 包括:
useEngine- 访问设计器引擎实例useCurrent- 当前项目和页面上下文useSelected- 当前选中的节点信息useNodeProps- 节点属性管理useDesigner- 设计器交互工具useRegion- 特定区域的组件管理useWorkspace- 工作区选项卡管理useHistory- 历史/撤销重做操作- 以及 18 个以上的其他专用 Hooks
第 2 步:定义组件配置
按照相应的接口类型创建组件定义。
对于 Apps 区域面板:
typescript
import { type AppWidget } from "../framework";
import CustomWidget from "./widgets/custom-widget/index.vue";
export const customAppWidget: AppWidget = {
name: "CustomTool",
region: "Apps",
component: CustomWidget,
type: "app",
openType: "panel",
icon: VtjIconTool, // Import from @vtj/icons
label: "Custom Tool",
cache: true, // 可选:保持组件存活
order: 100, // 显示顺序
};
对于 Workspace 区域选项卡:
typescript
import { type TabWidget } from "../framework";
import CustomTab from "./widgets/custom-tab/index.vue";
export const customTabWidget: TabWidget = {
name: "CustomTab",
region: "Workspace",
component: CustomTab,
type: "tab",
label: "Custom Tab",
icon: VtjIconTab,
closable: true,
order: 50,
};
第 3 步:注册组件
向 WidgetManager 注册你的组件:
typescript
import { widgetManager } from "../managers";
import { customAppWidget, customTabWidget } from "./custom-widgets";
widgetManager.register(customAppWidget);
widgetManager.register(customTabWidget);
WidgetManager (widget.ts#L27-L36) 提供了多种注册和管理方法:
| 方法 | 参数 | 描述 |
|---|---|---|
register(widget) |
Widget |
注册新组件 |
get(name) |
string |
按名称检索组件 |
set(name, widget) |
string, Partial<Widget> |
更新现有组件 |
unregister(name) |
string |
从注册表中删除组件 |
getWidgets(region, group) |
RegionType?, string? |
获取过滤后的组件列表 |
getRemoteWidgets() |
- | 获取带有 remote 标记的组件 |
removeRemoteWidgets() |
- | 删除所有远程组件 |
组件集成模式
Apps 区域集成
Apps 区域 (apps.vue) 实现了一个双面板界面,具有基于图标的导航和可折叠的内容面板。
关键集成点:
- 面板组件 (
openType: 'panel') - 在可折叠面板中渲染 - 链接组件 (
openType: 'link') - 打开外部 URL - 对话框组件 (
openType: 'dialog') - 作为模态对话框打开
💡 Apps 区域支持通过
cache: true组件属性进行 KeepAlive 缓存。这在工具之间切换时保留组件状态,对于具有复杂状态(如表单输入或选择)的组件至关重要。缓存使用 Vue 的 KeepAlive 组件实现 (apps.vue#L57-L63)。
Apps 区域根据 openType 属性自动将组件分离为"面板组件"(顶部图标)和"其他组件"(底部图标)(apps.vue#L67-L82)。
Workspace 区域集成
Workspace 区域 (workspace.vue) 为作用于当前选中节点或页面的设计器工具提供了选项卡界面。
Workspace 功能:
- 多选项卡界面 - 可以同时打开多个工具
- 选项卡管理 - 关闭单个选项卡,关闭全部,关闭其他
- 上下文感知 - 组件通过 Hooks 接收当前上下文
- 操作菜单 - 选项卡级别的操作和菜单
- 可检查选项卡 - 基于切换的选项卡激活
Workspace 使用 useWorkspace hook (hooks/useWorkspace.ts) 来管理选项卡状态,该状态与设计器的文件系统和选择模型集成。
Settings 区域集成
Settings 区域托管配置和管理面板。虽然它遵循与 Apps 区域相同的组件注册模式,但它位于设计器的右侧,通常包含修改项目级别设置或依赖项的组件。
Settings 组件示例:
- Deps - 依赖项管理
- Globals - 应用程序设置
- I18n - 国际化资源
- Env - 环境变量
组件通信和上下文
设计器引擎访问
所有组件都可以通过 useEngine hook 访问设计器引擎:
typescript
import { useEngine } from "../hooks";
const engine = useEngine();
// 访问引擎状态
const isPreview = engine.state.previewMode;
const streaming = engine.state.streaming;
// 访问当前项目
const project = engine.project;
// 访问页面上下文
const page = engine.page;
引擎 (engine.ts) 为所有设计器操作提供了中央协调点。
当前上下文 Hook
useCurrent hook 提供对当前项目和页面上下文的访问:
typescript
import { useCurrent } from "../hooks";
const { current, context } = useCurrent();
// 当前项目和页面
current.value.project; // ProjectModel
current.value.page; // PageModel
// 渲染上下文(用于表达式)
context.value; // Context object
选择管理
useSelected hook 管理节点选择状态:
typescript
import { useSelected } from "../hooks";
const { selected } = useSelected();
// 当前选中的节点
const node = selected.value; // NodeModel | null
// 响应选择变化
watch(selected, (newNode) => {
console.log("Selected node:", newNode);
});
属性管理
useNodeProps hook 为选中节点提供属性管理:
typescript
import { useNodeProps } from "../hooks";
const {
node,
commonProps,
componentProps,
customProps,
change,
addCustom,
removeCustom,
} = useNodeProps(selected);
// 修改属性
const handleChange = (key: string, value: any) => {
change(key, value);
};
// 添加自定义属性
const addNewProp = (name: string) => {
addCustom(name);
};
这个 hook 被 Properties 组件 (properties/index.vue) 广泛使用。
高级组件模式
条件可见性
通过 invisible 属性控制组件可见性:
typescript
export const conditionalWidget: AppWidget = {
name: "ConditionalTool",
region: "Apps",
component: ConditionalTool,
type: "app",
openType: "panel",
icon: VtjIconTool,
label: "Conditional Tool",
invisible: true, // 初始隐藏
};
// 稍后,根据条件使其可见
widgetManager.set("ConditionalTool", { invisible: false });
动态组件注册
根据项目配置或运行时条件动态注册组件:
typescript
// 仅在特定环境中注册
if (engine.env === "development") {
widgetManager.register(devToolsWidget);
}
// 根据项目类型注册
if (project?.type === "uniapp") {
widgetManager.register(uniConfigWidget);
}
组件分组
使用 group 属性组织组件以便进行过滤:
typescript
export const groupWidget: Widget = {
name: "GroupedTool",
region: "Apps",
component: GroupedTool,
group: "advanced", // 自定义组名
// ...
};
// 仅检索特定组
const advancedWidgets = widgetManager.getWidgets("Apps", "advanced");
远程组件支持
将需要服务器端资源的组件标记为 remote: true:
typescript
export const remoteWidget: AppWidget = {
name: "RemoteTool",
region: "Apps",
component: RemoteTool,
type: "app",
openType: "panel",
remote: true, // 需要远程服务
icon: VtjIconCloud,
label: "Remote Tool",
};
// 离线时删除远程组件
widgetManager.removeRemoteWidgets();
组件样式和主题
SCSS 结构
组件样式遵循 packages/designer/src/style/widgets/ 中的结构化模式:
scss
// packages/designer/src/style/widgets/custom-tool.scss
.v-custom-tool {
&__header {
// Header styles
}
&__content {
// Content styles
}
// 使用 BEM 方法论
&--active {
// Active state styles
}
}
在主组件样式索引中导入组件样式:
scss
// packages/designer/src/style/widgets/index.scss
@import "./custom-tool.scss";
主题集成
访问主题变量以保持样式一致:
scss
.v-custom-tool {
color: var(--vtj-text-color);
background: var(--vtj-bg-color);
border: 1px solid var(--vtj-border-color);
&:hover {
background: var(--vtj-bg-color-hover);
}
}
主题变量定义在 packages/designer/src/style/core/_vars.scss 中。
组件包装组件
WidgetWrapper (widget.ts) 为所有组件提供渲染层,将默认 props 与运行时 props 和 attrs 合并:
typescript
// WidgetWrapper 渲染实现
render() {
const { $props = {}, $attrs = {} } = this as any;
return h(this.widget.component, {
...this.widget.props, // 组件默认 props
...$props, // 运行时 props
...$attrs, // HTML 属性
ref: 'widgetRef' // 组件引用
});
}
此包装器确保组件既接收其配置的默认 props,也接收从父组件或区域传递的任何附加 props。
💡 在设计组件时,请记住 WidgetWrapper 会自动将组件 props 与运行时 props 合并。如果你的组件组件定义了自己的 props,它们将按以下顺序合并:widget.props → <math xmlns="http://www.w3.org/1998/Math/MathML"> p r o p s → props → </math>props→attrs。这允许灵活的 prop 注入,同时保持默认值。
最佳实践和指南
组件命名约定
- 组件名称使用 PascalCase:
CustomTool.vue - 组件名称使用 PascalCase:
CustomTool - 添加描述性前缀以便组织:
MyCompanyCustomTool
组件组织
在 packages/designer/src/components/widgets/ 目录中按功能组织组件:
vbnet
widgets/
├── custom-tools/
│ ├── index.vue
│ ├── sub-component.vue
│ └── types.ts
├── custom-tabs/
│ ├── index.vue
│ └── helpers.ts
性能考虑
- 谨慎使用
cache: true,仅用于初始化成本高的组件 - 实现
defineExpose以仅向父组件公开必要的方法 - 对派生状态使用计算属性以避免不必要的重新计算
- 利用 Vue 的 Composition API 以获得更好的代码组织和 tree-shaking
无障碍指南
- 确保所有交互元素都可以通过键盘访问
- 使用语义 HTML 元素
- 为自定义组件提供适当的 ARIA 标签
- 保持足够的颜色对比度
- 支持屏幕阅读器公告以显示重要的状态变化
错误处理
实现适当的错误边界和用户反馈:
vue
<script lang="ts" setup>
import { notify } from "../utils";
const handleAction = async () => {
try {
await performAction();
} catch (error) {
notify("Operation failed: " + error.message, "error");
// 记录错误以进行调试
console.error("Widget action error:", error);
}
};
</script>
完整示例:自定义分析组件
此示例演示如何创建一个用于分析页面结构的综合自定义组件。
组件实现
html
<template>
<XContainer direction="column" fit class="v-analysis-widget">
<div class="v-analysis-widget__header">
<h3>Page Analysis</h3>
<XButton type="primary" size="small" @click="analyze"> Analyze </XButton>
</div>
<div class="v-analysis-widget__content">
<div v-if="loading" class="loading">
<ElIcon class="is-loading"><Loading /></ElIcon>
Analyzing...
</div>
<div v-else-if="results" class="results">
<div class="result-item">
<span class="label">Total Components:</span>
<span class="value">{{ results.total }}</span>
</div>
<div class="result-item">
<span class="label">Nesting Depth:</span>
<span class="value">{{ results.depth }}</span>
</div>
<div class="result-item">
<span class="label">Complexity Score:</span>
<span class="value" :class="getScoreClass(results.score)">
{{ results.score }}
</span>
</div>
</div>
<div v-else class="empty">Click "Analyze" to begin</div>
</div>
</XContainer>
</template>
<script lang="ts" setup>
import { ref, computed } from "vue";
import { XContainer, XButton } from "@vtj/ui";
import { ElIcon, Loading } from "element-plus";
import { useCurrent, useSelected } from "../../hooks";
const { current } = useCurrent();
const { selected } = useSelected();
const loading = ref(false);
const results = ref<any>(null);
const analyze = async () => {
if (!current.value?.page) {
notify("No page selected", "warning");
return;
}
loading.value = true;
try {
// 执行分析逻辑
const page = current.value.page;
const analysis = analyzePage(page);
results.value = analysis;
} catch (error) {
notify("Analysis failed: " + error.message, "error");
} finally {
loading.value = false;
}
};
const analyzePage = (page: any) => {
// 页面分析实现
const total = countComponents(page);
const depth = calculateDepth(page);
const score = calculateScore(total, depth);
return { total, depth, score };
};
const getScoreClass = (score: number) => {
if (score < 50) return "good";
if (score < 80) return "warning";
return "danger";
};
defineOptions({
name: "AnalysisWidget",
});
</script>
<style lang="scss" scoped>
@import "../../../style/widgets/analysis.scss";
</style>
组件注册
typescript
// packages/designer/src/managers/built-in/custom-widgets.ts
import { type AppWidget } from "../../framework";
import { VtjIconChart } from "@vtj/icons";
import AnalysisWidget from "../../components/widgets/analysis/index.vue";
export const analysisWidget: AppWidget = {
name: "Analysis",
region: "Apps",
component: AnalysisWidget,
type: "app",
openType: "panel",
icon: VtjIconChart,
label: "Page Analysis",
order: 60,
};
在组件管理器中注册:
typescript
import { analysisWidget } from "./custom-widgets";
import { builtInWidgets } from "./widgets";
// 添加到内置组件
builtInWidgets.push(analysisWidget);
常见问题故障排除
组件未出现
问题:已注册的组件未显示在设计器中。
解决方案:
- 验证
region属性是否与有效的 RegionType 匹配 - 检查
invisible是否未设置为true - 确保组件已向 widgetManager 正确注册
- 确认组件已从 widgets/index.ts 导出
- 检查浏览器控制台是否有 Vue 警告或错误
组件 Props 未合并
问题:组件 props 未正确传递给组件。
解决方案:
- 验证 props 是否在组件配置中定义
- 检查是否正在使用 WidgetWrapper 进行渲染
- 确保 props 不与保留的 prop 名称冲突
- 查看 Vue 组件定义中的 prop 类型
切换时组件状态丢失
问题:在 Apps 区域面板之间切换时,组件状态丢失。
解决方案:
- 将
cache: true添加到组件配置中 - 确保组件实现了适当的状态管理
- 检查组件是否未意外卸载
- 验证 KeepAlive 是否正常工作
性能问题
问题:自定义组件导致设计器变慢。
解决方案:
- 在 onUnmounted 中实现适当的清理
- 尽可能使用计算属性而不是侦听器
- 对昂贵的操作进行防抖
- 通过正确定义响应式依赖来避免过度重新渲染
- 使用 Vue DevTools 分析组件性能
相关文档
要更深入地了解相关系统,请探索以下文档部分:
- 创建自定义物料组件 → - 了解如何创建与设计器集成的可重用 UI 组件
- 自定义 Setters 和属性编辑器 → - 了解如何创建用于 Properties 组件的自定义属性编辑器
- 物料模式配置 → - 了解如何为自定义组件定义物料模式
- 插件系统开发 → - 发现如何将自定义组件打包为插件进行分发
- 引擎 API 参考 → - 设计器引擎及其 API 的详细文档
参考资料
- 开源代码仓库:gitee.com/newgateway/...