TinyEngine核心功能——低代码编辑器(画布)集成到VUE3项目胎教级教程
TinyEngine 低代码引擎集成指南(完整版)
文档版本: v2.0 适用范围: 将 TinyEngine 低代码引擎集成到任何 Vue 3 + Vite 项目 核心思路: 以最小侵入方式集成 TinyEngine,通过 Mock HTTP 服务 + 数据转换器实现与现有业务系统对接 本文档特点: 每个代码文件均提供完整可复制代码
目录
一、概述
1.1 TinyEngine 是什么
TinyEngine 是华为 OpenTiny 开源的一套低代码引擎,提供:
-
低代码设计器:拖拽式页面搭建,包含物料面板、画布、属性面板等
-
画布渲染:在 iframe 中实时预览组件效果
-
自定义物料:接入自己的 Vue 组件
-
自定义插件:扩展编辑器功能面板
-
页面预览:独立 HTML 页面渲染设计好的页面
1.2 核心设计思路
本方案的集成策略如下:
| 思路 | 说明 |
|---|---|
| Mock 数据层 | 通过 http.ts 拦截 TinyEngine 所有 API 请求,返回本地 mock 数据,无需搭建后端服务 |
| 数据桥接 | 通过 sessionStorage 传递数据:宿主页面写入,http.ts 从中读取返回给 TinyEngine |
| 保存通信 | 通过 BroadcastChannel 将保存事件从编辑器内部传递到宿主页面,宿主再调用自己的后端 API |
| 多页面入口 | 编辑器主入口 + 独立预览页面是两个独立的 HTML 入口,通过 Vite 多页面配置实现 |
| CDN 资源加载 | 画布 iframe 无法访问宿主 node_modules,必须通过 importMap 配置 CDN 加载路径 |
1.3 完整数据流
宿主页面 → ①获取业务数据 → ②转换格式 → ③存入 sessionStorage │ ▼ TinyEngine 初始化 → http.ts 拦截到 API 请求 ←──┐ │ │ 从 sessionStorage 读取 ─────┘ │ 返回格式正确的数据给 TinyEngine │ ▼ 用户拖拽编辑 → 用户点击保存 │ ▼ http.ts 拦截保存请求 → BroadcastChannel │ ▼ 宿主页面收到保存消息 → 调用后端 API
二、环境准备与依赖安装
2.1 前提条件
| 工具 | 版本要求 |
|---|---|
| Node.js | >= 18.x |
| pnpm | 推荐 8.x+ |
| Vue | 3.x |
| Vite | 5.x |
2.2 安装核心依赖
# ===== TinyEngine 核心包 ===== pnpm add @opentiny/tiny-engine@^2.10.0 pnpm add @opentiny/tiny-engine-common@^2.10.0 pnpm add @opentiny/tiny-engine-meta-register@^2.10.0 pnpm add @opentiny/tiny-engine-utils@^2.10.0 pnpm add @opentiny/tiny-engine-mock@^2.10.0 # ===== TinyEngine Vite 工具(开发依赖)===== pnpm add -D @opentiny/tiny-engine-vite-config@^2.10.0 pnpm add -D @opentiny/tiny-engine-vite-plugin-meta-comments@^2.10.0 # ===== TinyVue 组件库(画布内置组件需要,版本必须匹配)===== pnpm add @opentiny/vue@~3.20.0 pnpm add @opentiny/vue-icon@~3.20.0 pnpm add @opentiny/vue-locale@~3.20.0 pnpm add @opentiny/vue-renderless@3.20.0 pnpm add @opentiny/vue-theme@~3.20.0 pnpm add @opentiny/vue-design-smb@~3.20.0 # ===== SVG 图标插件(开发依赖)===== pnpm add -D vite-plugin-svg-icons # ===== Node polyfill 插件(开发依赖,解决 TinyEngine 依赖的 Node.js 模块)===== pnpm add -D @esbuild-plugins/node-globals-polyfill pnpm add -D @esbuild-plugins/node-modules-polyfill pnpm add -D rollup-plugin-polyfill-node # ===== 并行执行工具(开发依赖,用于同时启动 mock 和 dev server)===== pnpm add -D concurrently
2.3 package.json 完整配置
{
"name": "your-project-name",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "cross-env NODE_OPTIONS=--max-old-space-size=8192 vite --mode dev",
"dev:lowcode": "cross-env NODE_OPTIONS=--max-old-space-size=8192 concurrently \"pnpm:serve:mock\" \"pnpm:dev\"",
"serve:mock": "cross-env NODE_OPTIONS=--max-old-space-size=4096 node node_modules/@opentiny/tiny-engine-mock/dist/app.js",
"build": "cross-env NODE_OPTIONS=--max-old-space-size=16384 rimraf dist && cross-env NODE_ENV=production vite build --mode prod",
"build:materials": "cross-env NODE_OPTIONS=--max-old-space-size=8192 node scripts/build/build.materials.js",
"clean:bundle": "npx rimraf public/components"
},
"dependencies": {
"@opentiny/tiny-engine": "^2.10.0",
"@opentiny/tiny-engine-common": "^2.10.0",
"@opentiny/tiny-engine-meta-register": "^2.10.0",
"@opentiny/tiny-engine-utils": "^2.10.0",
"@opentiny/tiny-engine-mock": "^2.10.0",
"@opentiny/vue": "~3.20.0",
"@opentiny/vue-icon": "~3.20.0",
"@opentiny/vue-locale": "~3.20.0",
"@opentiny/vue-renderless": "3.20.0",
"@opentiny/vue-theme": "~3.20.0",
"@opentiny/vue-design-smb": "~3.20.0",
"vue": "^3.4.27",
"vue-router": "~4.3.2",
"pinia": "~2.1.7",
"axios": "^1.17.0"
},
"devDependencies": {
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
"@esbuild-plugins/node-modules-polyfill": "^0.2.2",
"@opentiny/tiny-engine-vite-config": "^2.10.0",
"@opentiny/tiny-engine-vite-plugin-meta-comments": "^2.10.0",
"@vitejs/plugin-vue": "^5.0.4",
"@vitejs/plugin-vue-jsx": "^3.1.0",
"concurrently": "^8.2.0",
"cross-env": "^7.0.3",
"rimraf": "^5.0.7",
"rollup-plugin-polyfill-node": "^0.13.0",
"vite": "^5.2.11",
"vite-plugin-svg-icons": "^2.0.1",
"typescript": "^5.5.2",
"sass": "1.77.2"
}
}
三、环境变量配置
3.1 env/.env(全局默认 - 所有环境共享)
# 项目名称 VITE_APP_TITLE = 你的项目名称 # 公共基础路径, 详见: https://cn.vitejs.dev/guide/build.html#public-base-path VITE_BASE_URL = /
3.2 env/.env.dev(开发环境)
# 只在开发模式中被载入 ENV = 'dev' # 公共基础路径, 详见: https://cn.vitejs.dev/guide/build.html#public-base-path VITE_BASE_URL = / # 是否开启请求代理 VITE_HTTP_PROXY = true # 是否删除console VITE_DROP_CONSOLE = false # TinyEngine Mock 模式 VITE_API_MOCK = true # ⭐ TinyEngine 画布 CDN 配置(关键!) # 画布 iframe 中加载依赖的 CDN 地址,推荐使用 npmmirror VITE_CDN_DOMAIN=https://registry.npmmirror.com VITE_CDN_TYPE=npmmirror
3.3 env/.env.prod(生产环境)
# 只在生产模式中被载入 ENV = 'prod' # 公共基础路径, 详见: https://cn.vitejs.dev/guide/build.html#public-base-path VITE_BASE_URL = / # 是否删除console VITE_DROP_CONSOLE = true
3.4 env/.env.test(测试环境)
# 只在测试模式中被载入 ENV = 'test' # 公共基础路径, 详见: https://cn.vitejs.dev/guide/build.html#public-base-path VITE_BASE_URL = / # 是否删除console VITE_DROP_CONSOLE = false
3.5 TypeScript 环境变量类型声明
创建 src/typings/env.d.ts:
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_TITLE: string;
readonly VITE_BASE_URL: string;
readonly VITE_HTTP_PROXY: string;
readonly VITE_DROP_CONSOLE: string;
readonly VITE_API_MOCK: string;
readonly VITE_CDN_DOMAIN: string;
readonly VITE_CDN_TYPE: string;
readonly MODE: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
四、Vite 配置
4.1 vite.config.ts(完整文件)
import dayjs from 'dayjs';
import { resolve } from 'node:path';
import type { ConfigEnv, UserConfig } from 'vite';
import { loadEnv } from 'vite';
import { createVitePlugins } from './build/plugins';
import { createViteProxy } from './build/proxy';
import pkg from './package.json';
import { NodeGlobalsPolyfillPlugin } from '@esbuild-plugins/node-globals-polyfill';
import { NodeModulesPolyfillPlugin } from '@esbuild-plugins/node-modules-polyfill';
import nodePolyfill from 'rollup-plugin-polyfill-node';
// ===== ⭐ 多页面应用入口配置 =====
// TinyEngine 的预览页面是一个独立的 HTML 入口
const multiPageInput = {
index: resolve(__dirname, 'index.html'), // 主应用入口
preview: resolve(__dirname, 'src/lowcode/preview/preview.html') // 预览页面入口
};
const __APP_INFO__ = {
pkg,
lastBuildTime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
};
export default ({ mode }: ConfigEnv): UserConfig => {
// 加载环境变量(从 ./env 目录加载)
const env = loadEnv(mode, './env', '') as ImportMetaEnv;
return {
// ===== 基础配置 =====
envDir: './env',
base: env.VITE_BASE_URL,
// ===== 全局常量定义 =====
define: {
__APP_INFO__: JSON.stringify(__APP_INFO__),
'process.env': {},
},
// ===== 路径别名 =====
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
// ===== Vite 插件 =====
plugins: [...createVitePlugins()],
// ===== CSS 预处理器 =====
css: {
preprocessorOptions: {
scss: {
additionalData: `
@import "@/styles/vars.scss";
@import "@/styles/mixin.scss";
`,
},
},
},
// ===== ⭐ 开发服务器配置 =====
server: {
host: '0.0.0.0',
port: 9980,
open: true,
// ⭐ 代理 TinyEngine API 到本地 mock 服务
proxy: {
'/app-center': {
target: 'http://localhost:9090',
changeOrigin: true,
},
'/material-center': {
target: 'http://localhost:9090',
changeOrigin: true,
},
'/platform-center': {
target: 'http://localhost:9090',
changeOrigin: true,
},
},
// ⭐ 提前转换和缓存预热
warmup: {
clientFiles: [
'./index.html',
'./src/lowcode/preview/preview.html',
'./src/{components,api}/*',
],
},
},
// ===== ⭐ 依赖优化配置 =====
optimizeDeps: {
include: [
'lodash-es',
'@opentiny/tiny-engine',
'@opentiny/tiny-engine-common',
'@opentiny/tiny-engine-meta-register',
],
exclude: ['@vue/repl'],
esbuildOptions: {
plugins: [
NodeGlobalsPolyfillPlugin({
process: true,
buffer: true,
}),
NodeModulesPolyfillPlugin(),
],
},
},
// ===== esbuild 配置 =====
esbuild: {
pure: env.VITE_DROP_CONSOLE === 'true' ? ['console.log', 'debugger'] : [],
supported: {
'top-level-await': true,
},
},
// ===== ⭐ 构建配置 =====
build: {
minify: 'esbuild',
cssTarget: 'chrome89',
chunkSizeWarningLimit: 2000,
commonjsOptions: {
transformMixedEsModules: true,
},
rollupOptions: {
plugins: [nodePolyfill({ include: null })],
// ⭐ 多页面入口(预览页面需要)
input: multiPageInput,
output: {
manualChunks(id) {
if (id.includes('node_modules/ant-design-vue/')) {
return 'antdv';
} else if (/node_modules\/(vue|vue-router|pinia)\//.test(id)) {
return 'vue';
}
},
},
onwarn(warning, rollupWarn) {
if (
warning.code === 'CYCLIC_CROSS_CHUNK_REEXPORT' &&
warning.exporter?.includes('src/api/')
) {
return;
}
rollupWarn(warning);
},
},
},
};
};
4.2 build/proxy.ts(代理配置)
import { mapEntries } from 'radash';
import type { ProxyOptions } from 'vite';
export function generateProxyPattern(envConfig: Record<string, string>) {
return mapEntries(envConfig, (key, value) => {
return [
key,
{
value,
proxy: `/${key}`,
},
];
});
}
/**
* 生成 vite 代理字段
*/
export function createViteProxy(serverConfig: Record<string, string>) {
const proxyMap = generateProxyPattern(serverConfig);
return mapEntries(proxyMap, (key, value) => {
return [
value.proxy,
{
target: value.value,
changeOrigin: true,
rewrite: (path: string) => path.replace(new RegExp(`^${value.proxy}`), ''),
},
];
}) as Record<string, string | ProxyOptions>;
}
五、Build 插件配置
5.1 build/plugins.ts(完整文件)
import vue from '@vitejs/plugin-vue';
import vueJsx from '@vitejs/plugin-vue-jsx';
import { resolve } from 'node:path';
import Unocss from 'unocss/vite';
import AutoImport from 'unplugin-auto-import/vite';
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers';
import Components from 'unplugin-vue-components/vite';
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons';
import VueSetupExtend from 'vite-plugin-vue-setup-extend';
const CWD = process.cwd();
/**
* ⭐ 设置 vite 插件配置
*
* createSvgIconsPlugin 是 TinyEngine 编辑器图标显示的关键插件
* 它负责将 SVG 文件注册为 SVG Sprite,TinyEngine 通过 icon-[name] 引用
*/
export function createVitePlugins() {
return [
// Vue SFC 编译
vue(),
// UnoCSS(原子化 CSS)
Unocss(),
// 允许在 <script setup> 中使用 name 属性
VueSetupExtend(),
// Vue JSX 支持
vueJsx({}),
// ⭐ SVG 图标插件(TinyEngine 编辑器图标依赖于此!)
createSvgIconsPlugin({
iconDirs: [
resolve(CWD, 'src/assets/lowcode_svg'), // 自定义物料组件图标
resolve(CWD, 'src/assets/icon'), // 项目图标
resolve(CWD, 'node_modules/@opentiny/tiny-engine/assets'), // TinyEngine 内置图标
],
symbolId: 'icon-[name]', // SVG 符号 ID 格式
inject: 'body-last', // 注入方式
}),
// API 自动导入
AutoImport({
imports: ['vue', 'vue-router', 'pinia', '@vueuse/core'],
include: [/\.[tj]sx?$/, /\.vue$/, /\.vue\?vue/, /\.md$/],
dts: 'types/auto-imports.d.ts',
}),
// 组件自动导入
Components({
dts: 'types/components.d.ts',
types: [
{
from: './src/components/basic/button/',
names: ['AButton'],
},
{
from: 'vue-router',
names: ['RouterLink', 'RouterView'],
},
],
resolvers: [
AntDesignVueResolver({
importStyle: false,
exclude: ['Button'],
}),
],
}),
];
}
六、TinyEngine 核心代码
6.1 engine.config.ts
⭐ 最关键的文件!importMap 配置决定了画布能否正常渲染。 画布运行在 iframe 中,无法访问宿主项目的 node_modules,必须通过 importMap 告诉它从 CDN 加载。
文件位置: src/lowcode/engine.config.ts
/**
* TinyEngine 引擎配置
*
* ⭐ importMap 是画布正常渲染的关键!
* 因为 TinyEngine 作为 npm 包引入时,其内部的 VITE_CDN_DOMAIN 等环境变量是 undefined,
* 所以必须在 engine.config 中显式配置 importMap,指定画布 iframe 加载依赖的 CDN 地址。
*
* CDN 地址格式(npmmirror):
* https://registry.npmmirror.com/{包名}/{版本}/files/{文件路径}
*
* 查找步骤:
* 1. 访问 https://registry.npmmirror.com/{包名}/ 查看可用版本
* 2. 访问 https://registry.npmmirror.com/{包名}/{版本}/files/ 查看文件列表
* 3. 找到 ESM 格式的入口文件路径
*/
// CDN 基础配置(使用 npmmirror CDN)
const CDN_DOMAIN = 'https://registry.npmmirror.com';
// ===== ⭐ import-map 配置:定义画布 iframe 中模块加载的 CDN 地址 =====
const IMPORT_MAP = {
imports: {
// ---- Vue 核心 ----
vue: `${CDN_DOMAIN}/vue/3.4.23/files/dist/vue.runtime.esm-browser.js`,
'vue/server-renderer': `${CDN_DOMAIN}/@vue/server-renderer/3.4.23/files/dist/server-renderer.esm-browser.js`,
'vue-i18n': `${CDN_DOMAIN}/vue-i18n/9.9.0/files/dist/vue-i18n.esm-browser.js`,
'vue-router': `${CDN_DOMAIN}/vue-router/4.0.16/files/dist/vue-router.esm-browser.js`,
// ---- Vue 相关工具 ----
'@vue/devtools-api': `${CDN_DOMAIN}/@vue/devtools-api/6.5.1/files/lib/esm/index.js`,
'@vueuse/core': `${CDN_DOMAIN}/@vueuse/core/9.6.0/files/index.mjs`,
'@vueuse/shared': `${CDN_DOMAIN}/@vueuse/shared/9.6.0/files/index.mjs`,
'vue-demi': `${CDN_DOMAIN}/vue-demi/0.13.11/files/lib/index.mjs`,
pinia: `${CDN_DOMAIN}/pinia/2.0.22/files/dist/pinia.esm-browser.js`,
// ---- ⭐ TinyVue 组件库(画布内置组件需要)----
'@opentiny/vue': `${CDN_DOMAIN}/@opentiny/vue-runtime/~3.20/files/dist3/tiny-vue-pc.mjs`,
'@opentiny/vue-icon': `${CDN_DOMAIN}/@opentiny/vue-runtime/~3.20/files/dist3/tiny-vue-icon.mjs`,
'@opentiny/vue-common': `${CDN_DOMAIN}/@opentiny/vue-runtime/~3.20/files/dist3/tiny-vue-common.mjs`,
'@opentiny/vue-locale': `${CDN_DOMAIN}/@opentiny/vue-runtime/~3.20/files/dist3/tiny-vue-locale.mjs`,
'@opentiny/vue-renderless/': `${CDN_DOMAIN}/@opentiny/vue-renderless/~3.20/files/`,
'@opentiny/vue-huicharts': `${CDN_DOMAIN}/@opentiny/vue-runtime/~3.22/files/dist3/tiny-vue-huicharts.mjs`,
'@opentiny/tiny-engine-i18n-host': `${CDN_DOMAIN}/@opentiny/tiny-engine-i18n-host/^2/files/dist/lowcode-design-i18n-host.es.js`,
'@opentiny/tiny-engine-builtin-component': `${CDN_DOMAIN}/@opentiny/tiny-engine-builtin-component/^2.10.0/files/dist/index.mjs`,
// ---- 其他依赖 ----
axios: `${CDN_DOMAIN}/axios/1.0.0/files/dist/esm/axios.js`,
echarts: `${CDN_DOMAIN}/echarts/5.4.1/files/dist/echarts.esm.js`,
}
};
export default {
id: 'engine.config',
theme: 'light',
enableLogin: false,
// ⭐ 核心:自定义 importMap,解决画布加载失败问题
importMap: IMPORT_MAP,
// ⭐ 物料组件 JSON 路径列表(用于加载到组件面板)
material: [
'/mock/bundle.json', // TinyVue 内置组件的物料数据
'/components/QrCode/QrCode.json', // 自定义组件(如果存在)
'/components/DataTable/DataTable.json', // 自定义组件(如果存在)
'/components/DynamicFieldInput/DynamicFieldInput.json' // 自定义组件(如果存在)
],
scripts: [],
styles: [],
// ⭐ 暂时禁用 TailwindCSS,避免 CDN 路径错误导致画布空白
enableTailwindCSS: false,
enableStructuredCss: false
};
importMap 调试方法
如果画布空白,90% 的原因是 importMap 中的 CDN 地址不正确。 排查步骤:
打开浏览器控制台
切换到画布的 iframe 上下文(在 Elements 面板中找到 iframe 标签,切换到它的 context)
查看 Network 面板中加载失败的请求
手动在浏览器地址栏打开失败的 CDN 地址,确认是否可访问
如果 404,到
https://registry.npmmirror.com/{包名}/确认可用的版本号
6.2 componentsMap.ts
用途:定义组件名称到 npm 包的映射关系。 TinyEngine 在预览和代码生成时,需要知道每个组件的 npm 包名和导出方式。
文件位置: src/lowcode/componentsMap.ts
/**
* ComponentsMap 配置文件
*
* 使用方法:
* 只需在本文件的 customComponents 数组中添加自定义组件映射即可。
* TinyVue 内置组件和 Huicharts 图表组件的映射表已预置。
*/
// ===== ⭐ 第一步:定义自定义组件映射 =====
//
// 组件映射字段说明:
// - componentName: 组件名称(在 JSON schema 中使用的名称)
// - package: npm 包名(自定义组件用 @local/ 前缀,构建时通过 external 排除)
// - exportName: 组件导出的名称
// - destructuring: true=具名导出(import { X } from 'pkg'),false=默认导出(import X from 'pkg')
// - version: 版本号
export const customComponents = [
{
componentName: 'QrCode',
package: '@local/qrcode',
exportName: 'QrCode',
destructuring: false, // 使用默认导出
version: '1.0.0'
},
{
componentName: 'DataTable',
package: '@local/data-table',
exportName: 'DataTable',
destructuring: false, // 使用默认导出
version: '1.0.0'
},
{
componentName: 'DynamicFieldInput',
package: '@local/dynamic-field-input',
exportName: 'DynamicFieldInput',
destructuring: false, // 使用默认导出
version: '1.0.0'
}
// 在此添加更多自定义组件...
];
// ===== TinyEngine 默认 TinyVue 组件映射 =====
// 这些是 TinyEngine 画布内置支持的组件,直接从 @opentiny/vue 中具名导出
export const defaultTinyVueComponents = [
{ componentName: 'TinyActionMenu', package: '@opentiny/vue', exportName: 'TinyActionMenu', destructuring: true, version: '' },
{ componentName: 'TinyBadge', package: '@opentiny/vue', exportName: 'TinyBadge', destructuring: true, version: '' },
{ componentName: 'TinyBreadcrumb', package: '@opentiny/vue', exportName: 'Breadcrumb', destructuring: true, version: '' },
{ componentName: 'TinyBreadcrumbItem', package: '@opentiny/vue', exportName: 'BreadcrumbItem', destructuring: true, version: '' },
{ componentName: 'TinyButton', package: '@opentiny/vue', exportName: 'Button', destructuring: true, version: '' },
{ componentName: 'TinyButtonGroup', package: '@opentiny/vue', exportName: 'ButtonGroup', destructuring: true, version: '' },
{ componentName: 'TinyCalendar', package: '@opentiny/vue', exportName: 'TinyCalendar', destructuring: true, version: '' },
{ componentName: 'TinyCard', package: '@opentiny/vue', exportName: 'TinyCard', destructuring: true, version: '' },
{ componentName: 'TinyCarousel', package: '@opentiny/vue', exportName: 'Carousel', destructuring: true, version: '' },
{ componentName: 'TinyCarouselItem', package: '@opentiny/vue', exportName: 'CarouselItem', destructuring: true, version: '' },
{ componentName: 'TinyCascader', package: '@opentiny/vue', exportName: 'TinyCascader', destructuring: true, version: '' },
{ componentName: 'TinyCheckbox', package: '@opentiny/vue', exportName: 'Checkbox', destructuring: true, version: '' },
{ componentName: 'TinyCheckboxButton', package: '@opentiny/vue', exportName: 'CheckboxButton', destructuring: true, version: '' },
{ componentName: 'TinyCheckboxGroup', package: '@opentiny/vue', exportName: 'CheckboxGroup', destructuring: true, version: '' },
{ componentName: 'TinyCol', package: '@opentiny/vue', exportName: 'Col', destructuring: true, version: '' },
{ componentName: 'TinyCollapse', package: '@opentiny/vue', exportName: 'Collapse', destructuring: true, version: '' },
{ componentName: 'TinyCollapseItem', package: '@opentiny/vue', exportName: 'CollapseItem', destructuring: true, version: '' },
{ componentName: 'TinyDatePicker', package: '@opentiny/vue', exportName: 'DatePicker', destructuring: true, version: '' },
{ componentName: 'TinyDialogBox', package: '@opentiny/vue', exportName: 'DialogBox', destructuring: true, version: '' },
{ componentName: 'TinyDropdown', package: '@opentiny/vue', exportName: 'TinyDropdown', destructuring: true, version: '' },
{ componentName: 'TinyForm', package: '@opentiny/vue', exportName: 'Form', destructuring: true, version: '' },
{ componentName: 'TinyFormItem', package: '@opentiny/vue', exportName: 'FormItem', destructuring: true, version: '' },
{ componentName: 'TinyGrid', package: '@opentiny/vue', exportName: 'Grid', destructuring: true, version: '' },
{ componentName: 'TinyGridColumn', package: '@opentiny/vue', exportName: 'TinyGridColumn', destructuring: true, version: '' },
{ componentName: 'TinyInput', package: '@opentiny/vue', exportName: 'Input', destructuring: true, version: '' },
{ componentName: 'TinyLayout', package: '@opentiny/vue', exportName: 'Layout', destructuring: true, version: '3.20.0' },
{ componentName: 'TinyNumeric', package: '@opentiny/vue', exportName: 'Numeric', destructuring: true, version: '' },
{ componentName: 'TinyPager', package: '@opentiny/vue', exportName: 'Pager', destructuring: true, version: '' },
{ componentName: 'TinyPopeditor', package: '@opentiny/vue', exportName: 'Popeditor', destructuring: true, version: '' },
{ componentName: 'TinyPopover', package: '@opentiny/vue', exportName: 'Popover', destructuring: true, version: '' },
{ componentName: 'TinyProgress', package: '@opentiny/vue', exportName: 'TinyProgress', destructuring: true, version: '' },
{ componentName: 'TinyRadio', package: '@opentiny/vue', exportName: 'Radio', destructuring: true, version: '' },
{ componentName: 'TinyRadioGroup', package: '@opentiny/vue', exportName: 'TinyRadioGroup', destructuring: true, version: '' },
{ componentName: 'TinyRate', package: '@opentiny/vue', exportName: 'TinyRate', destructuring: true, version: '' },
{ componentName: 'TinyRow', package: '@opentiny/vue', exportName: 'Row', destructuring: true, version: '' },
{ componentName: 'TinySearch', package: '@opentiny/vue', exportName: 'Search', destructuring: true, version: '' },
{ componentName: 'TinySelect', package: '@opentiny/vue', exportName: 'Select', destructuring: true, version: '' },
{ componentName: 'TinySkeleton', package: '@opentiny/vue', exportName: 'TinySkeleton', destructuring: true, version: '' },
{ componentName: 'TinySlider', package: '@opentiny/vue', exportName: 'TinySlider', destructuring: true, version: '' },
{ componentName: 'TinyStatistic', package: '@opentiny/vue', exportName: 'TinyStatistic', destructuring: true, version: '' },
{ componentName: 'TinySteps', package: '@opentiny/vue', exportName: 'TinySteps', destructuring: true, version: '' },
{ componentName: 'TinySwitch', package: '@opentiny/vue', exportName: 'Switch', destructuring: true, version: '' },
{ componentName: 'TinyTabItem', package: '@opentiny/vue', exportName: 'TabItem', destructuring: true, version: '' },
{ componentName: 'TinyTabs', package: '@opentiny/vue', exportName: 'Tabs', destructuring: true, version: '' },
{ componentName: 'TinyTag', package: '@opentiny/vue', exportName: 'TinyTag', destructuring: true, version: '' },
{ componentName: 'TinyTimeLine', package: '@opentiny/vue', exportName: 'TimeLine', destructuring: true, version: '' },
{ componentName: 'TinyTooltip', package: '@opentiny/vue', exportName: 'Tooltip', destructuring: true, version: '' },
{ componentName: 'TinyTransfer', package: '@opentiny/vue', exportName: 'TinyTransfer', destructuring: true, version: '' },
{ componentName: 'TinyTree', package: '@opentiny/vue', exportName: 'Tree', destructuring: true, version: '' },
{ componentName: 'TinyTreeMenu', package: '@opentiny/vue', exportName: 'TinyTreeMenu', destructuring: true, version: '' },
];
// ===== 默认图表组件映射(Huicharts)=====
export const defaultHuichartsComponents = [
{ componentName: 'TinyHuichartsBar', package: '@opentiny/vue-huicharts', exportName: 'TinyHuichartsBar', destructuring: true, version: '3.22.0' },
{ componentName: 'TinyHuichartsFunnel', package: '@opentiny/vue-huicharts', exportName: 'TinyHuichartsFunnel', destructuring: true, version: '3.22.0' },
{ componentName: 'TinyHuichartsGauge', package: '@opentiny/vue-huicharts', exportName: 'TinyHuichartsGauge', destructuring: true, version: '3.22.0' },
{ componentName: 'TinyHuichartsGraph', package: '@opentiny/vue-huicharts', exportName: 'TinyHuichartsGraph', destructuring: true, version: '3.22.0' },
{ componentName: 'TinyHuichartsHistogram', package: '@opentiny/vue-huicharts', exportName: 'TinyHuichartsHistogram', destructuring: true, version: '3.22.0' },
{ componentName: 'TinyHuichartsLine', package: '@opentiny/vue-huicharts', exportName: 'TinyHuichartsLine', destructuring: true, version: '3.22.0' },
{ componentName: 'TinyHuichartsPie', package: '@opentiny/vue-huicharts', exportName: 'TinyHuichartsPie', destructuring: true, version: '3.22.0' },
{ componentName: 'TinyHuichartsProcess', package: '@opentiny/vue-huicharts', exportName: 'TinyHuichartsProcess', destructuring: true, version: '3.22.0' },
{ componentName: 'TinyHuichartsRadar', package: '@opentiny/vue-huicharts', exportName: 'TinyHuichartsRadar', destructuring: true, version: '3.22.0' },
{ componentName: 'TinyHuichartsRing', package: '@opentiny/vue-huicharts', exportName: 'TinyHuichartsRing', destructuring: true, version: '3.22.0' },
{ componentName: 'TinyHuichartsScatter', package: '@opentiny/vue-huicharts', exportName: 'TinyHuichartsScatter', destructuring: true, version: '3.22.0' },
{ componentName: 'TinyHuichartsWaterfall', package: '@opentiny/vue-huicharts', exportName: 'TinyHuichartsWaterfall', destructuring: true, version: '3.22.0' },
];
/**
* 获取完整的 componentsMap
* 合并用户自定义组件和默认组件映射
*/
export function getComponentsMap(): any[] {
return [
...customComponents,
...defaultTinyVueComponents,
...defaultHuichartsComponents,
];
}
/**
* ComponentsMap 类型定义
*/
export interface ComponentMapItem {
componentName: string; // 组件名称(在 schema 中使用的名称)
package: string; // npm 包名
exportName: string; // 导出名称
destructuring: boolean; // 是否使用解构导入 (import { X } from 'package')
version: string; // 版本号
}
export default getComponentsMap();
6.3 http.ts
⭐ 核心文件:拦截 TinyEngine 的所有 API 请求,返回 Mock 数据。 不需要任何后端服务就能运行低代码编辑器。
拦截的 API 列表:
| TinyEngine 请求路径 | 返回内容 | 数据来源 |
|---|---|---|
/platform-center/api/user/me |
当前用户信息 | 静态 mock |
/app-center/api/apps/detail/ |
当前应用信息 | 静态 mock |
/app-center/v1/api/apps/schema/ |
应用完整 Schema | sessionStorage |
/app-center/api/pages/list/ |
页面列表 | sessionStorage |
/app-center/api/pages/detail/ |
单个页面详情 | sessionStorage |
/app-center/api/pages/update/ |
保存页面(触发 BroadcastChannel) | → 宿主页面 |
/app-center/api/sources/list/ |
数据源列表 | 空数组 |
/material-center/api/blocks |
区块列表 | 空数组 |
文件位置: src/lowcode/http.ts
/**
* ⭐ TinyEngine HTTP 服务拦截器
*
* 统一拦截所有 API 请求,返回 mock 数据或 sessionStorage 数据。
*
* 核心思路:
* 1. 用户信息、应用信息等使用默认 mock 数据
* 2. 页面数据从 sessionStorage 获取(由入口页面 views/lowcode/index.vue 设置)
* 3. 物料请求放行到本地 /mock/bundle.json
* 4. componentsMap 从 componentsMap.ts 配置文件获取
* 5. 保存请求通过 BroadcastChannel 通知宿主页面
*/
import { getAdapter } from 'axios';
import { HttpService } from '@opentiny/tiny-engine';
import { getComponentsMap } from './componentsMap';
// ============ 一、Mock 数据定义 ============
/** 默认用户信息 */
const MOCK_USER = {
id: 'local-user',
username: '设计师',
email: 'designer@lowcode.com',
is_admin: true,
tenant: { id: '1' },
tenants: [{ id: 1, tenant_id: 'public', name_cn: '公共租户' }]
};
/** 默认应用信息 */
const MOCK_APP = {
id: 'default-app',
name: '表单设计器',
description: 'LightForm 表单设计器',
platform: { id: 897, name: 'portal-platform' },
published: false,
state: null,
tenant: 1,
global_state: [],
data_handler: { type: 'JSFunction', value: 'function dataHandler(res){ return res; }' }
};
/** 默认应用 Schema */
const getMockAppSchema = () => ({
meta: {
name: '表单设计器',
tenant: 1,
global_state: [],
is_demo: false
},
dataSource: { list: [] },
i18n: { zh_CN: {}, en_US: {} },
componentsTree: [], // 页面树由页面列表构建
componentsMap: getComponentsMap(), // 从配置文件获取组件映射
utils: [],
bridge: []
});
// ============ 二、SessionStorage 数据获取 ============
/** sessionStorage 中存储的数据结构 */
interface StoredFormData {
formId: string;
appItemId: string;
appId: string;
mode: string;
pageData: {
id: string;
name: string;
route: string;
page_content: any;
};
}
/** 从 sessionStorage 获取存储的表单数据 */
const getStoredFormData = (): StoredFormData | null => {
const stored = sessionStorage.getItem('lowcode_current_form');
if (!stored) return null;
try {
return JSON.parse(stored);
} catch {
return null;
}
};
/** 构建页面列表数据(从 sessionStorage 读取) */
const buildPageList = () => {
const stored = getStoredFormData();
if (!stored?.pageData?.id) {
return [];
}
const pageList = [{
id: stored.pageData.id,
name: stored.pageData.name || '未命名表单',
fileName: stored.pageData.name || '未命名表单',
parentId: '0',
group: 'staticPages',
route: stored.pageData.route || `/form/${stored.pageData.id}`,
meta: {
id: stored.pageData.id,
group: 'staticPages',
isHome: true,
isPage: true,
route: stored.pageData.route || `/form/${stored.pageData.id}`,
rootElement: 'Page',
occupier: null
},
page_content: stored.pageData.page_content
}];
return pageList;
};
/** 构建单个页面详情数据 */
const buildPageDetail = (pageId: string) => {
const stored = getStoredFormData();
if (!stored?.pageData || String(stored.pageData.id) !== String(pageId)) {
return null;
}
const detail = {
id: stored.pageData.id,
name: stored.pageData.name || '未命名表单',
fileName: stored.pageData.name || '未命名表单',
parentId: '0',
group: 'staticPages',
route: stored.pageData.route || `/form/${stored.pageData.id}`,
meta: {
id: stored.pageData.id,
group: 'staticPages',
isHome: true,
isPage: true,
route: stored.pageData.route || `/form/${stored.pageData.id}`,
rootElement: 'Page',
occupier: null
},
page_content: stored.pageData.page_content
};
return detail;
};
// ============ 三、Mock Adapter(核心拦截逻辑)============
const createMockAdapter = (defaultAdapter: any) => async (config: any) => {
const url: string = config.url || '';
// 1. 用户信息
if (url.match(/\/platform-center\/api\/user\/me/)) {
return {
data: { data: MOCK_USER },
status: 200,
statusText: 'OK',
config,
headers: {}
};
}
// 2. 应用详情
if (url.match(/\/app-center\/api\/apps\/detail\//)) {
return {
data: { data: MOCK_APP },
status: 200,
statusText: 'OK',
config,
headers: {}
};
}
// 3. ⭐ 应用 Schema(核心数据结构)
// TinyEngine 初始化时会请求此接口获取应用配置和页面树
if (url.match(/\/app-center\/v1\/api\/apps\/schema\//)) {
const schema = getMockAppSchema();
schema.componentsTree = buildPageList(); // 从 sessionStorage 获取页面树
return {
data: { data: schema },
status: 200,
statusText: 'OK',
config,
headers: {}
};
}
// 4. 页面列表
if (url.match(/\/app-center\/api\/pages\/list\//)) {
const pageList = buildPageList();
return {
data: { data: pageList },
status: 200,
statusText: 'OK',
config,
headers: {}
};
}
// 5. 页面详情
if (url.match(/\/app-center\/api\/pages\/detail\//)) {
const pageId = url.split('/').pop();
const detail = buildPageDetail(pageId);
return {
data: { data: detail },
status: 200,
statusText: 'OK',
config,
headers: {}
};
}
// 6. ⭐ 页面保存(核心:拦截保存请求,通过 BroadcastChannel 通知宿主页面)
if (url.match(/\/app-center\/api\/pages\/update\//)) {
// 从请求体中提取页面数据
let pageContent = null;
// 方式1: config.data 是对象
if (config.data && typeof config.data === 'object') {
pageContent = config.data.page_content || config.data;
}
// 方式2: config.data 是 JSON 字符串
if (config.data && typeof config.data === 'string') {
try {
const parsedData = JSON.parse(config.data);
pageContent = parsedData.page_content || parsedData;
} catch (e) {
// JSON 解析失败
}
}
// 方式3: 从 params 获取
if (!pageContent && config.params?.page_content) {
pageContent = config.params.page_content;
}
// 更新 sessionStorage 并触发保存事件
try {
const stored = getStoredFormData();
if (stored && pageContent) {
// 存储最新的页面数据到 sessionStorage
const updatedData = {
...stored,
pageData: {
...stored.pageData,
page_content: pageContent
}
};
sessionStorage.setItem('lowcode_current_form', JSON.stringify(updatedData));
// ⭐ 通过 BroadcastChannel 通知宿主页面执行保存
const channel = new BroadcastChannel('lowcode_save_channel');
channel.postMessage({
type: 'save',
formId: stored.formId,
appItemId: stored.appItemId,
appId: stored.appId,
pageContent: pageContent,
timestamp: Date.now()
});
channel.close();
}
} catch (e) {
console.error('[Lowcode HTTP] 处理保存数据失败:', e);
}
return {
data: { data: { success: true, message: '页面已保存' } },
status: 200,
statusText: 'OK',
config,
headers: {}
};
}
// 7. 数据源列表(返回空)
if (url.match(/\/app-center\/api\/sources\/list\//)) {
return {
data: { data: [] },
status: 200,
statusText: 'OK',
config,
headers: {}
};
}
// 8. 区块列表(返回空)
if (url.match(/\/material-center\/api\/blocks/)) {
return {
data: { data: [] },
status: 200,
statusText: 'OK',
config,
headers: {}
};
}
// 其他请求走默认 adapter(如物料 bundle.json 等)
return defaultAdapter(config);
};
// ============ 四、HTTP 服务初始化 ============
const customizeHttpService = () => {
const isDevelopEnv = import.meta.env.MODE?.includes('dev');
const getTenant = () => new URLSearchParams(location.search).get('tenant') || '1';
const defaultAdapter = getAdapter(['xhr', 'fetch', 'http']);
const options = {
axiosConfig: {
baseURL: '',
// ⭐ 使用自定义 adapter 拦截所有请求
adapter: createMockAdapter(defaultAdapter),
withCredentials: isDevelopEnv,
headers: {
...(isDevelopEnv && { 'x-lowcode-mode': 'develop' }),
'x-lowcode-org': getTenant()
}
},
interceptors: {
request: [
// 处理本地物料请求(/mock/, /components/ 开头的请求不走 mock)
(config: any) => {
if (config.url.startsWith('/mock/') || config.url.startsWith('/components/')) {
config.baseURL = '';
}
return config;
}
],
response: [
[
// 成功响应拦截器
(res: any) => {
if (res.data?.error) {
return Promise.reject(res.data.error);
}
// 物料请求返回原始数据
if (res.data?.data?.materials) {
return res.data.data;
}
// 返回 res.data.data(TinyEngine 期望 data 包裹格式)
return res.data?.data || res.data;
},
// 错误响应拦截器
(error: any) => {
const { response } = error;
if (response?.status === 401) {
console.warn('[Lowcode] 登录已过期');
}
return response?.data?.error
? Promise.reject(response.data.error)
: Promise.reject(error.message);
}
]
]
}
};
HttpService.apis.setOptions(options);
return HttpService;
};
export default customizeHttpService();
6.4 registry.ts
作用:TinyEngine 的注册中心,连接所有配置(HTTP 服务、引擎配置、自定义插件、布局配置、预览配置)。
文件位置: src/lowcode/registry.ts
/**
* ⭐ TinyEngine 注册配置
*
* 将所有配置注册到 TinyEngine 引擎:
* 1. 自定义 HttpService 拦截请求
* 2. 引擎配置(importMap + 物料路径)
* 3. 自定义插件
* 4. 布局配置(控制编辑器界面布局)
* 5. 预览配置
*/
import { META_APP, META_SERVICE } from '@opentiny/tiny-engine-meta-register';
import engineConfig from './engine.config';
import customizeHttpService from './http';
// 导入自定义插件
import MyDatasourcePlugin from './plugins/custom-datasource';
import MyTableBindingPlugin from './plugins/custom-table-binding';
import MyExportPagePlugin from './plugins/custom-exportPage';
// 插件唯一标识
const MY_DATASOURCE = 'engine.plugins.my-datasource';
const MY_TABLE_BINDING = 'engine.plugins.my-table-binding';
const MY_EXPORTPAGE = 'engine.plugins.my-exportpage';
const baseURL = import.meta.env.BASE_URL || '.';
const baseURLWithoutSlash = baseURL.replace(/\/$/, '');
export default {
// ===== 1. HTTP 服务 - 拦截所有 API 请求 =====
[META_SERVICE.Http]: customizeHttpService,
// ===== 2. 引擎配置(importMap + 物料路径)=====
'engine.config': engineConfig,
// ===== 3. 自定义插件 =====
[MyDatasourcePlugin.id]: MyDatasourcePlugin,
[MyTableBindingPlugin.id]: MyTableBindingPlugin,
[MyExportPagePlugin.id]: MyExportPagePlugin,
// ===== 4. 物料面板布局 =====
// 指定在物料面板中显示哪些组件/插件
[META_APP.Materials]: {
options: {
displayComponentIds: [
'engine.plugins.materials.component', // 内置组件
MY_TABLE_BINDING // 自定义关联表插件
]
}
},
// ===== 5. ⭐ 布局配置(重中之重)=====
// 控制编辑器界面的:左侧面板、右侧面板、顶部工具栏、底部折叠栏
[META_APP.Layout]: {
options: {
// 隐藏工作台(应用管理页面)
isShowWorkspace: false,
layoutConfig: {
// 左侧和右侧面板
plugins: {
left: {
top: [
META_APP.Materials, // 物料面板(组件列表)
META_APP.OutlineTree, // 大纲树(页面结构)
MY_EXPORTPAGE, // 导出页面 Schema(开发调试用)
],
bottom: [META_APP.Schema, META_APP.Help]
},
right: {
top: [META_APP.Props, META_APP.Styles, META_APP.Event]
}
},
// 顶部工具栏
toolbars: {
left: [],
center: [META_APP.Media],
right: [
[META_APP.Lock, META_APP.RedoUndo, META_APP.Clean],
[META_APP.Preview],
[META_APP.Save]
],
collapse: [
[META_APP.Refresh, META_APP.Fullscreen],
[META_APP.Lang],
[META_APP.ViewSetting]
]
}
}
}
},
// ===== 6. ⭐ 预览配置 =====
// 配置预览页面的 URL
[META_APP.Preview]: {
options: {
previewUrl: ['prod', 'alpha'].includes(import.meta.env.MODE)
? `${baseURLWithoutSlash}/preview.html` // 生产环境:打包后的路径
: '/src/lowcode/preview/preview.html' // 开发环境:源码路径
}
},
// ===== 7. 禁用不需要的功能 =====
[META_APP.AppManage]: { options: { disabled: true } },
[META_APP.BlockManage]: { options: { disabled: true } }
};
布局示意图
┌──────────────────────────────────────────────────────────────┐ │ Toolbar (工具栏) │ │ ← left │ center │ right → │ │ │ [Media] │ [Lock][Redo] │ │ │ │ [Preview] │ │ │ │ [Save] │ ├──────────┼──────────────────────────────────┼──────────────┤ │ 左侧面板 │ │ 右侧面板 │ │ │ 画布区域 │ │ │ [Materials] (组件拖拽编辑区) │ [Props] │ │ [OutlineTree] │ [Styles] │ │ [Schema] │ [Event] │ │ [Help] │ │ ├──────────┴──────────────────────────────────┴──────────────┤ │ Collapse: [Refresh] [Fullscreen] [Lang] [ViewSetting] │ └──────────────────────────────────────────────────────────────┘
6.5 widgetConverter.ts
作用:在宿主业务系统的数据格式和 TinyEngine 的数据格式之间进行转换。 如果你的业务系统数据格式与示例不同,只需修改
convertCustomWidgetToTinyEngine和convertTinyEngineToLightForm两个函数。
文件位置: src/lowcode/utils/widgetConverter.ts
/**
* WidgetJson 数据转换器
*
* 功能:
* 1. 将宿主业务系统数据 → TinyEngine 页面数据(加载时)
* 2. 将 TinyEngine 页面数据 → 宿主业务系统数据(保存时)
*
* 如果宿主系统已经有 widgetJson,且其中数据结构包含 componentName: 'Page',
* 说明已经是 TinyEngine 格式,直接使用即可。
* 否则需要实现 convertCustomWidgetToTinyEngine 进行自定义转换。
*/
import type { LightFormData } from '@/api/lightForm/model/lightFormModel';
/**
* TinyEngine 页面内容结构
*/
export interface TinyEnginePageContent {
componentName: string;
props?: Record<string, any>;
state?: Record<string, any>;
methods?: Record<string, any>;
css?: string;
children?: any[];
dataSource?: {
list: any[];
};
lifeCycles?: Record<string, any>;
inputs?: any[];
outputs?: any[];
utils?: any[];
bridge?: any[];
}
/**
* TinyEngine 页面详情结构
*/
export interface TinyEnginePageData {
id: string;
name: string;
route?: string;
app?: string;
isHome?: boolean;
parentId?: string;
group?: string;
isPage?: boolean;
page_content: TinyEnginePageContent;
occupier?: null;
}
// ============ 默认页面结构 ============
/**
* 获取默认的空页面结构
* 当没有数据时,返回一个空的 Page 组件
*/
export const getDefaultPageContent = (): TinyEnginePageContent => ({
componentName: 'Page',
props: {},
state: {},
methods: {},
css: '',
children: [],
dataSource: { list: [] },
lifeCycles: {},
inputs: [],
outputs: [],
utils: [],
bridge: []
});
/**
* 确保 pageContent 所有数组字段不为 null
* TinyEngine 对某些字段要求必须是数组,不能是 null
*/
export const ensureValidPageContent = (pageContent: any): TinyEnginePageContent => {
const defaultContent = getDefaultPageContent();
return {
componentName: pageContent?.componentName || defaultContent.componentName,
props: pageContent?.props || defaultContent.props,
state: pageContent?.state || defaultContent.state,
methods: pageContent?.methods || defaultContent.methods,
css: pageContent?.css || defaultContent.css,
children: pageContent?.children || defaultContent.children,
dataSource: pageContent?.dataSource || defaultContent.dataSource,
lifeCycles: pageContent?.lifeCycles || defaultContent.lifeCycles,
inputs: pageContent?.inputs || defaultContent.inputs,
outputs: pageContent?.outputs || defaultContent.outputs,
utils: pageContent?.utils || defaultContent.utils,
bridge: pageContent?.bridge || defaultContent.bridge
};
};
// ============ 格式判断 ============
/**
* 判断数据是否已经是 TinyEngine 格式
*/
export const isTinyEngineFormat = (data: any): boolean => {
if (!data || typeof data !== 'object') return false;
return data.componentName === 'Page';
};
// ============ ⭐ 核心转换函数 ============
/**
* ⭐ 将业务系统数据转换为 TinyEngine 页面数据
*
* 流程:
* 1. 解析 widgetJson(JSON 字符串)
* 2. 判断是否是 TinyEngine 格式
* 3. 如果是,直接使用
* 4. 如果不是,调用 convertCustomWidgetToTinyEngine 转换
*/
export const convertLightFormToTinyEngine = (form: LightFormData): TinyEnginePageData => {
let pageContent: TinyEnginePageContent;
// 解析 widgetJson
if (form.widgetJson) {
try {
const widgetData = JSON.parse(form.widgetJson);
// 如果已经是 TinyEngine 格式,直接使用
if (isTinyEngineFormat(widgetData)) {
pageContent = ensureValidPageContent(widgetData);
} else {
// 否则需要自定义转换
pageContent = convertCustomWidgetToTinyEngine(widgetData);
}
} catch (error) {
console.error('解析 widgetJson 失败:', error);
pageContent = getDefaultPageContent();
}
} else {
pageContent = getDefaultPageContent();
}
return {
id: form.id,
name: form.formName,
route: `/form/${form.formCode}`,
app: form.appId,
isHome: false,
parentId: '0',
group: 'staticPages',
isPage: true,
page_content: pageContent,
occupier: null
};
};
/**
* ⭐ 自定义 Widget 格式转换为 TinyEngine 格式
*
* 如果宿主系统的 widgetJson 不是标准 TinyEngine 格式,
* 需要在此函数中实现自定义转换逻辑。
*
* 当前实现:支持包含 componentName 的标准格式和包含 type 的简化格式
*/
export const convertCustomWidgetToTinyEngine = (widgetData: any): TinyEnginePageContent => {
const pageContent = getDefaultPageContent();
// 如果 widgetData 有 children 字段,转换组件结构
if (widgetData.children && Array.isArray(widgetData.children)) {
pageContent.children = convertComponents(widgetData.children);
}
// 如果 widgetData 有 props 字段
if (widgetData.props) {
pageContent.props = widgetData.props;
}
// 如果 widgetData 有 state 字段
if (widgetData.state) {
pageContent.state = widgetData.state;
}
// 如果 widgetData 有 methods 字段
if (widgetData.methods) {
pageContent.methods = widgetData.methods;
}
// 如果 widgetData 有 css/style 字段
if (widgetData.css || widgetData.style) {
pageContent.css = widgetData.css || widgetData.style;
}
return pageContent;
};
// ============ 组件树转换工具 ============
/**
* 转换组件列表
*/
const convertComponents = (components: any[]): any[] => {
return components.map(comp => convertComponent(comp));
};
/**
* 转换单个组件
* 支持两种格式:
* 1. 标准格式:{ componentName: 'xxx', props: {...}, children: [...] }
* 2. 简化格式:{ type: 'xxx', config: {...}, children: [...] }
*/
const convertComponent = (component: any): any => {
// 标准格式(包含 componentName)
if (component.componentName) {
const converted: any = {
componentName: normalizeComponentName(component.componentName),
props: component.props || {},
id: component.id || generateId()
};
// 处理子组件
if (component.children && Array.isArray(component.children)) {
converted.children = convertComponents(component.children);
}
return converted;
}
// 简化格式(包含 type)
if (component.type) {
return {
componentName: normalizeComponentName(component.type),
props: component.config || component.props || {},
id: component.id || generateId(),
children: component.children ? convertComponents(component.children) : []
};
}
// 无法识别的格式,返回 div 占位
return {
componentName: 'div',
props: {},
id: generateId(),
children: []
};
};
/**
* 规范化组件名称
* 将自定义组件名称映射到 TinyEngine 标准组件名称
*/
const normalizeComponentName = (name: string): string => {
const nameMap: Record<string, string> = {
'input': 'TinyInput',
'Input': 'TinyInput',
'button': 'TinyButton',
'Button': 'TinyButton',
'select': 'TinySelect',
'Select': 'TinySelect',
'table': 'TinyGrid',
'Table': 'TinyGrid',
'form': 'TinyForm',
'Form': 'TinyForm',
'form-item': 'TinyFormItem',
'FormItem': 'TinyFormItem',
'date-picker': 'TinyDatePicker',
'DatePicker': 'TinyDatePicker',
'modal': 'TinyDialogBox',
'Modal': 'TinyDialogBox',
'tabs': 'TinyTabs',
'Tabs': 'TinyTabs',
'tree': 'TinyTree',
'Tree': 'TinyTree',
// 自定义组件保持原名
'DataTable': 'DataTable',
'QrCode': 'QrCode',
'DynamicFieldInput': 'DynamicFieldInput'
};
return nameMap[name] || name;
};
/**
* 生成唯一 ID
*/
const generateId = (): string => {
return Math.random().toString(36).substring(2, 10);
};
// ============ 保存转换 ============
/**
* ⭐ 将 TinyEngine 页面数据转换回宿主业务系统的格式
*
* @param pageData TinyEngine 页面数据
* @returns 序列化后的 JSON 字符串,存入业务系统的 widgetJson 字段
*/
export const convertTinyEngineToLightForm = (pageData: TinyEnginePageData): string => {
return JSON.stringify(pageData.page_content);
};
/**
* 创建空的表单 WidgetJson
* 用于新建表单时的初始化
*/
export const createEmptyWidgetJson = (): string => {
return JSON.stringify(getDefaultPageContent());
};
6.6 preview 预览页面
预览页面是独立的 HTML 入口,需要在 vite.config.ts 中配置多页面入口。
preview.html
文件位置: src/lowcode/preview/preview.html
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <link rel="icon" href="/favicon.ico" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>低代码预览</title> </head> <body> <div id="app"></div> <script type="module" src="./preview.ts"></script> </body> </html>
preview.ts
文件位置: src/lowcode/preview/preview.ts
/**
* 低代码预览入口
*
* 功能:
* 1. 初始化 TinyEngine 预览应用
* 2. 注册必要的 hook(环境变量)
* 3. 使用与编辑器相同的 registry 配置
*
* 注意:
* - 必须 import 'virtual:svg-icons-register',否则图标不显示
* - 预览页面的路径在 registry.ts 的 previewUrl 中配置
*/
import 'virtual:svg-icons-register';
import { initPreview, initHook, HOOK_NAME } from '@opentiny/tiny-engine';
import registry from '../registry';
async function startPreviewApp() {
// 在应用创建前注册环境变量 hook
const beforeAppCreate = () => {
initHook(HOOK_NAME.useEnv, import.meta.env);
};
// 初始化预览应用
initPreview({
registry, // ⭐ 使用与编辑器相同的注册中心
lifeCycles: {
beforeAppCreate // 应用创建前的生命周期钩子
}
});
}
startPreviewApp();
七、编辑器入口页面
⭐ 这是宿主系统中最重要的文件。它负责:
从宿主系统的后端 API 获取业务数据
转换为 TinyEngine 格式存入 sessionStorage
初始化/销毁 TinyEngine 设计器
监听保存事件并调用后端 API 持久化
7.1 页面组件
文件位置: src/views/lowcode/index.vue
/**
* ⭐ 低代码编辑器入口页面
*
* 完整工作流程:
* 1. 从宿主系统的 API 加载表单数据
* 2. 转换数据格式并存储到 sessionStorage
* 3. 初始化 TinyEngine 编辑器(http.ts 会自动从 sessionStorage 读取数据)
* 4. 通过 BroadcastChannel 接收保存事件
* 5. 销毁时清空全局状态(防止重复进入时报错)
*
* 关键设计:
* - 每次进入编辑器时,先销毁旧实例并清空全局状态
* - 通过 sessionStorage + http.ts 的 mock 拦截实现数据传递
* - 通过 BroadcastChannel 实现编辑器内部到宿主页面的保存通信
*/
<template>
<div ref="containerRef" class="lowcode-editor-page">
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<a-spin size="large" tip="正在加载表单数据..." />
</div>
<!-- 错误状态 -->
<div v-if="error" class="error-container">
<a-result status="error" title="加载失败" :sub-title="error">
<template #extra>
<a-button type="primary" @click="goBack">返回</a-button>
<a-button @click="retryLoad">重新加载</a-button>
</template>
</a-result>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, onUnmounted, onDeactivated, watch, nextTick } from 'vue';
import { init } from '@opentiny/tiny-engine';
import { message } from 'ant-design-vue';
import {
useMaterial,
useBlock,
useCanvas,
usePage
} from '@opentiny/tiny-engine-meta-register';
import registry from '@/lowcode/registry';
import {
convertLightFormToTinyEngine,
convertTinyEngineToLightForm
} from '@/lowcode/utils/widgetConverter';
// ===== 请注意:以下导入语句替换为你的实际 API 路径 =====
// import { getLightFormDetailApi, updateLightFormApi } from '@/api/lightForm/lightForm';
// import type { LightFormData } from '@/api/lightForm/model/lightFormModel';
defineOptions({
name: 'LowcodeEditor',
inheritAttrs: false
});
// ===== 一、Props 定义 =====
const props = defineProps<{
formId: string; // ⭐ 表单/页面 ID(必须)
mode?: 'design' | 'preview'; // 模式
appItemId?: string; // 可选的额外参数
appId?: string;
}>();
// ===== 二、事件定义 =====
const emit = defineEmits<{
(e: 'close'): void;
}>();
// ===== 三、响应式状态 =====
const containerRef = ref<HTMLElement>();
const loading = ref(true);
const error = ref<string>('');
const currentForm = ref<any>(null);
const formId = ref<string>(props.formId || '');
const mode = ref<string>(props.mode || 'design');
const isSaving = ref(false);
const tinyEngineApp = ref<any>(null);
let saveChannel: BroadcastChannel | null = null;
// ===== 四、工具函数 =====
/**
* 更新 URL 的 pageid 参数
* TinyEngine 需要从 URL 查询参数中读取当前页面 ID
*/
const updateUrlPageId = (pageId: string) => {
const url = new URL(window.location.href);
url.searchParams.set('pageid', pageId);
window.history.replaceState({}, '', url);
};
/**
* 清除 URL 的 pageid 参数
*/
const clearUrlPageId = () => {
const url = new URL(window.location.href);
url.searchParams.delete('pageid');
url.searchParams.delete('blockid');
url.searchParams.delete('previewid');
window.history.replaceState({}, '', url);
};
/**
* 注册页面数据到 treeDataMapping
* TinyEngine 的 CanvasRouteBar 组件需要从 pageSettingState.treeDataMapping 获取页面信息
*/
const registerPageToTreeMapping = (form: any, pageData: any) => {
try {
const pageApi = usePage();
if (pageApi && pageApi.pageSettingState) {
const { pageSettingState } = pageApi;
pageSettingState.treeDataMapping[form.id] = {
id: form.id,
name: form.formName,
route: pageData.route || `/form/${form.id}`,
isPage: true,
parentId: '0',
group: 'staticPages'
};
// 确保 ROOT_ID 存在
if (!pageSettingState.treeDataMapping['0']) {
pageSettingState.treeDataMapping['0'] = {
id: '0',
parentId: '',
children: []
};
}
// 将当前页面添加到 ROOT 的 children 中
const rootNode = pageSettingState.treeDataMapping['0'];
if (!rootNode.children) {
rootNode.children = [];
}
const existingIndex = rootNode.children.findIndex((child: any) => child.id === form.id);
if (existingIndex > -1) {
rootNode.children.splice(existingIndex, 1);
}
rootNode.children.push(pageSettingState.treeDataMapping[form.id]);
}
} catch (e) {
console.warn('[Lowcode] 注册页面到 treeDataMapping 失败:', e);
}
};
// ===== 五、⭐ 销毁与清理(关键!)=====
/**
* ⭐ 清空 TinyEngine 全局状态
*
* 这是解决"物料重复渲染"和"工具重复注册"报错的关键!
* 多次进入编辑器时,如果不清理全局状态,会导致:
* - 物料面板组件重复
* - 画布渲染异常
* - 控制台报错:工具/服务已注册
*
* 必须在 Vue app 销毁后调用,避免渲染过程中数据变化
*/
const clearTinyEngineGlobalState = () => {
try {
// 1. 清空画布状态(pageState, nodesMap)
const canvasApi = useCanvas();
if (canvasApi) {
canvasApi.clearCurrentState?.();
canvasApi.clearNodes?.();
}
// 2. 清空物料数据
const materialApi = useMaterial();
if (materialApi && typeof materialApi.clearMaterials === 'function') {
materialApi.clearMaterials();
}
// 3. 清空区块数据
const blockApi = useBlock();
if (blockApi && typeof blockApi.clearBlockResources === 'function') {
blockApi.clearBlockResources();
}
} catch (e) {
console.warn('[Lowcode] 清空全局状态时出错:', e);
}
};
/**
* 销毁 TinyEngine 实例
* 在离开编辑器或重新进入前调用
*/
const destroyTinyEngine = async () => {
// 1. 关闭 BroadcastChannel
if (saveChannel) {
saveChannel.close();
saveChannel = null;
}
// 2. 销毁 Vue app 实例
if (tinyEngineApp.value) {
try {
tinyEngineApp.value.unmount();
tinyEngineApp.value = null;
} catch (e) {
console.warn('[Lowcode] 销毁 Vue app 失败:', e);
}
}
// 3. 清理 DOM 容器
const container = containerRef.value;
if (container) {
while (container.firstChild) {
container.removeChild(container.firstChild);
}
container.id = 'tiny-engine-app';
}
// 4. 清理 sessionStorage
sessionStorage.removeItem('lowcode_current_form');
// 5. 清除 URL 的 pageid 参数
clearUrlPageId();
// 6. ⭐ 清空全局状态(关键!)
clearTinyEngineGlobalState();
};
// ===== 六、⭐ 加载与初始化(核心流程)=====
/**
* ⭐ 加载表单数据并初始化编辑器
*
* 完整流程:
* ① 销毁旧实例
* ② 更新 URL 参数
* ③ 调用后端 API 获取业务数据
* ④ 转换为 TinyEngine 格式
* ⑤ 存入 sessionStorage
* ⑥ 初始化 TinyEngine 设计器
* ⑦ 设置保存事件监听
*/
const loadFormData = async () => {
if (!formId.value) {
error.value = '缺少表单ID参数';
loading.value = false;
return;
}
loading.value = true;
error.value = '';
try {
// ① ⭐ 先销毁旧实例并清空全局状态
await destroyTinyEngine();
// ② 等待 DOM 更新
await nextTick();
// ③ 更新 URL 的 pageid 参数(TinyEngine 需要从 URL 读取)
updateUrlPageId(formId.value);
// ④ ⭐ 替换为你的实际 API 调用
// 从宿主系统后端获取表单/页面数据
const formData = await getFormData(formId.value);
currentForm.value = formData;
// ⑤ 转换为 TinyEngine 格式
const tinyEngineData = convertLightFormToTinyEngine(formData);
// ⑥ ⭐ 存入 sessionStorage(http.ts 会从中读取)
sessionStorage.setItem('lowcode_current_form', JSON.stringify({
formId: formId.value,
appItemId: props.appItemId || '',
appId: props.appId || 'default-app',
mode: mode.value,
pageData: tinyEngineData
}));
// ⑦ 确保 DOM 容器存在
const container = containerRef.value;
if (!container) {
throw new Error('DOM 容器不存在');
}
container.id = 'tiny-engine-app';
// ⑧ ⭐ 初始化 TinyEngine 设计器
const app = await init({
selector: '#tiny-engine-app',
registry: [registry],
createAppSignal: ['global_service_init_finish']
});
tinyEngineApp.value = app;
// ⑨ 注册页面数据到 treeDataMapping
registerPageToTreeMapping(formData, tinyEngineData);
// ⑩ 设置保存事件监听
setupSaveChannel();
} catch (err: any) {
console.error('加载表单失败:', err);
error.value = err.message || '加载表单数据失败';
} finally {
loading.value = false;
}
};
/**
* ⭐ 替换为你的实际 API 调用
*
* 从宿主系统的后端 API 获取表单/页面数据。
* 返回的数据需要包含 id、formName、widgetJson 等字段。
*/
async function getFormData(id: string): Promise<any> {
// ===== 替换为你的实际 API 调用 =====
// const response = await getLightFormDetailApi({ id });
// return response;
// 示例:模拟 API 返回
return {
id: id,
formName: '示例表单',
formCode: 'demo',
appId: 'default-app',
widgetJson: JSON.stringify({
componentName: 'Page',
props: {},
children: []
})
};
}
// ===== 七、保存逻辑 =====
/**
* 设置保存事件监听(通过 BroadcastChannel)
* http.ts 拦截到保存请求后,会通过 BroadcastChannel 发送消息
*/
const setupSaveChannel = () => {
saveChannel = new BroadcastChannel('lowcode_save_channel');
saveChannel.onmessage = async (event) => {
const { type, formId: savedFormId, pageContent } = event.data;
if (type === 'save' && savedFormId === formId.value) {
await handleSave(pageContent);
}
};
};
/**
* ⭐ 处理保存
*
* 流程:
* 1. 获取 TinyEngine 编辑器中的 page_content(页面组件树)
* 2. 转换为宿主系统格式(JSON 字符串)
* 3. 调用宿主系统后端 API 保存
*/
const handleSave = async (pageContent?: any) => {
if (isSaving.value) return;
if (!currentForm.value) return;
isSaving.value = true;
try {
// 如果没有传入 pageContent,从 sessionStorage 获取
if (!pageContent) {
const stored = sessionStorage.getItem('lowcode_current_form');
if (stored) {
const parsed = JSON.parse(stored);
pageContent = parsed.pageData?.page_content;
}
}
if (!pageContent) {
throw new Error('无法获取页面数据');
}
// 转换为宿主系统格式
const widgetJson = convertTinyEngineToLightForm({
id: formId.value,
name: currentForm.value.formName,
page_content: pageContent
});
// ⭐ 替换为你的实际 API 调用
await saveFormData(formId.value, widgetJson);
// 更新本地缓存
currentForm.value = {
...currentForm.value,
widgetJson: widgetJson
};
// 更新 sessionStorage
sessionStorage.setItem('lowcode_current_form', JSON.stringify({
formId: formId.value,
appItemId: props.appItemId || '',
appId: props.appId || 'default-app',
mode: mode.value,
pageData: {
id: formId.value,
name: currentForm.value.formName,
route: `/form/${formId.value}`,
page_content: pageContent
}
}));
message.success('表单保存成功');
} catch (err: any) {
console.error('[Lowcode] 保存失败:', err);
message.error('保存失败: ' + (err.message || '未知错误'));
} finally {
isSaving.value = false;
}
};
/**
* ⭐ 替换为你的实际保存 API
*/
async function saveFormData(id: string, widgetJson: string): Promise<void> {
// ===== 替换为你的实际 API 调用 =====
// await updateLightFormApi({ id, widgetJson });
// 模拟保存
console.log('[Lowcode] 保存数据:', { id, widgetJson });
}
// ===== 八、生命周期 =====
const goBack = () => emit('close');
const retryLoad = () => loadFormData();
// 监听 formId 变化(支持路由参数变化时重新加载)
watch(
() => props.formId,
(newId, oldId) => {
if (newId && newId !== oldId) {
formId.value = newId;
loadFormData();
}
}
);
onMounted(() => loadFormData());
onUnmounted(() => destroyTinyEngine());
onDeactivated(() => destroyTinyEngine());
// 暴露方法给父组件
defineExpose({
save: () => handleSave(),
isSaving,
destroy: destroyTinyEngine
});
</script>
<style lang="scss" scoped>
.lowcode-editor-page {
width: 100%;
height: 100vh;
overflow: hidden;
position: relative;
.loading-container,
.error-container {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.error-container {
background: #fff;
}
}
</style>
7.2 接入后端 API 的配置说明
在入口页面中,有两处需要替换为你的实际 API:
// ===== 1. 导入你的 API =====
// 在文件开头的 import 部分,替换为你的实际 API
import { getYourDataApi, updateYourDataApi } from '@/api/yourModule';
// ===== 2. 加载函数(替换 getFormData)=====
async function getFormData(id: string): Promise<YourDataType> {
// 调用你的后端 API 获取数据
const response = await getYourDataApi({ id });
// 确保返回的数据包含必要的字段
return {
id: response.id,
formName: response.name,
formCode: response.code,
appId: response.appId || 'default-app',
widgetJson: response.widgetJson || response.pageContent
};
}
// ===== 3. 保存函数(替换 saveFormData)=====
async function saveFormData(id: string, widgetJson: string): Promise<void> {
// 调用你的后端 API 保存数据
await updateYourDataApi({
id,
widgetJson: widgetJson
});
}
八、自定义物料组件
8.1 完整组件示例
组件目录结构
src/lowcode/components/
├── QrCode/ # 组件目录(名称与组件名一致)
│ ├── QrCode.vue # Vue SFC 源码
│ └── QrCode.json # 物料配置元数据
├── DataTable/
│ ├── DataTable.vue
│ └── DataTable.json
└── DynamicFieldInput/
├── DynamicFieldInput.vue
└── DynamicFieldInput.json
组件规范
-
使用 Options API(
export default { ... }),不要使用<script setup>,因为构建工具可能不支持 -
组件名必须与目录名一致
-
组件通过
props接收配置(在 JSON 中定义的属性) -
构建时会从外部排除
vue、echarts、ant-design-vue等公共库,不要重复打包
示例:QrCode 组件
QrCode/QrCode.vue:
<template>
<div class="qr-code">
<canvas ref="canvasRef"></canvas>
<div v-if="error" class="qr-code-error">{{ error }}</div>
</div>
</template>
<script lang="ts">
import { ref, watch, onMounted } from 'vue'
import QRCode from 'qrcode'
export default {
name: 'QrCode',
props: {
value: {
type: String,
default: 'https://opentiny.design'
},
size: {
type: Number,
default: 200
},
margin: {
type: Number,
default: 2
},
darkColor: {
type: String,
default: '#000000'
},
lightColor: {
type: String,
default: '#ffffff'
},
errorCorrectionLevel: {
type: String,
default: 'M'
}
},
setup(props) {
const canvasRef = ref<HTMLCanvasElement | null>(null)
const error = ref<string | null>(null)
const renderQRCode = async () => {
if (!props.value) {
error.value = '请输入二维码内容'
return
}
error.value = null
try {
if (canvasRef.value) {
await QRCode.toCanvas(canvasRef.value, props.value, {
width: props.size,
margin: props.margin,
color: {
dark: props.darkColor,
light: props.lightColor
},
errorCorrectionLevel: props.errorCorrectionLevel
})
}
} catch (err: any) {
error.value = '生成二维码失败: ' + err.message
}
}
watch(() => [
props.value, props.size, props.margin,
props.darkColor, props.lightColor, props.errorCorrectionLevel
], () => {
renderQRCode()
})
onMounted(() => {
renderQRCode()
})
return {
canvasRef,
error
}
}
}
</script>
<style scoped>
.qr-code {
display: inline-block;
}
.qr-code canvas {
display: block;
}
.qr-code-error {
color: #f56c6c;
font-size: 14px;
padding: 10px;
border: 1px solid #f56c6c;
border-radius: 4px;
background: #fef0f0;
}
</style>
QrCode/QrCode.json(物料元数据):
{
"name": {
"zh_CN": "二维码"
},
"component": "QrCode",
"icon": "qrcode",
"description": "可配置的二维码生成组件,支持自定义尺寸、颜色和容错级别",
"docUrl": "",
"screenshot": "",
"tags": "qrcode,二维码,扫码",
"keywords": "二维码,QR码,扫码",
"devMode": "proCode",
"npm": {
"package": "@local/qrcode",
"exportName": "QrCode",
"destructuring": false,
"version": "1.0.0",
"script": "/components/QrCode/QrCode.esm.js",
"css": "/components/QrCode/QrCode.style.css"
},
"group": "component",
"category": "自定义组件",
"priority": 2,
"schema": {
"properties": [
{
"name": "0",
"label": {
"zh_CN": "基础配置"
},
"content": [
{
"cols": 24,
"type": "string",
"label": {
"text": {
"zh_CN": "二维码内容"
}
},
"widget": {
"component": "StringConfigurator"
},
"property": "value",
"defaultValue": "https://opentiny.design"
},
{
"cols": 12,
"type": "number",
"label": {
"text": {
"zh_CN": "尺寸"
}
},
"widget": {
"component": "NumberConfigurator"
},
"property": "size",
"defaultValue": 200,
"min": 50,
"max": 1000
},
{
"cols": 12,
"type": "number",
"label": {
"text": {
"zh_CN": "边距"
}
},
"widget": {
"component": "NumberConfigurator"
},
"property": "margin",
"defaultValue": 2,
"min": 0,
"max": 10
},
{
"cols": 12,
"type": "color",
"label": {
"text": {
"zh_CN": "前景色"
}
},
"widget": {
"component": "ColorConfigurator"
},
"property": "darkColor",
"defaultValue": "#000000"
},
{
"cols": 12,
"type": "color",
"label": {
"text": {
"zh_CN": "背景色"
}
},
"widget": {
"component": "ColorConfigurator"
},
"property": "lightColor",
"defaultValue": "#ffffff"
},
{
"cols": 12,
"type": "string",
"label": {
"text": {
"zh_CN": "容错级别"
}
},
"widget": {
"component": "SelectConfigurator"
},
"property": "errorCorrectionLevel",
"defaultValue": "M",
"options": [
{ "label": "低 (L)", "value": "L" },
{ "label": "中 (M)", "value": "M" },
{ "label": "高 (Q)", "value": "Q" },
{ "label": "最高 (H)", "value": "H" }
]
}
]
}
]
}
}
8.2 注册组件到设计器
组件创建后,需要做三件事才能在编辑器中使用:
第 1 步:构建组件(将 .vue 文件编译为 ESM 格式)
pnpm run build:materials
构建成功后,public/components/QrCode/ 目录会生成:
-
QrCode.esm.js— 编译后的 ESM 模块 -
QrCode.style.css— 提取的样式 -
QrCode.json— 完整物料包格式
第 2 步:在 engine.config.ts 中添加物料路径
material: [ '/mock/bundle.json', '/components/QrCode/QrCode.json', // ← 添加 '/components/DataTable/DataTable.json', '/components/DynamicFieldInput/DynamicFieldInput.json' ]
第 3 步:在 componentsMap.ts 中添加组件映射
export const customComponents = [
{
componentName: 'QrCode',
package: '@local/qrcode',
exportName: 'QrCode',
destructuring: false,
version: '1.0.0'
},
];
九、自定义插件
9.1 插件目录结构
src/lowcode/plugins/ ├── custom-datasource/ # 数据源插件 │ ├── index.ts # 插件入口 │ ├── meta.ts # 插件元信息 │ └── src/ │ ├── Main.vue # 插件 UI 主组件 │ ├── composable/ │ │ ├── index.ts │ │ └── useDataSource.ts │ └── styles/ │ └── vars.less ├── custom-table-binding/ # 关联表插件 │ ├── index.ts │ ├── meta.ts │ └── src/ │ ├── Main.vue │ └── composable/ ├── custom-exportPage/ # Schema 导出插件(开发调试用) │ ├── index.ts │ ├── meta.ts │ └── src/ │ ├── Main.vue │ ├── icon/ │ │ └── ExportIcon.vue │ ├── components/ │ │ ├── JsonNode.vue │ │ └── JsonViewer.vue │ └── composable/
9.2 插件各文件详解
meta.ts(插件元信息)
/**
* 描述该插件的相关元信息
*/
export default {
id: 'engine.plugins.my-exportpage', // ⭐ 唯一标识,在整个引擎中必须唯一
title: '页面Schema结构', // 面板标题
type: 'plugins', // 插件类型
align: 'left', // 面板位置:left | right | top
icon: ExportIcon // 图标组件(可选)
};
| 字段 | 说明 |
|---|---|
id |
插件唯一 ID,格式建议 engine.plugins.xxx |
title |
面板显示标题 |
type |
固定为 'plugins' |
align |
显示位置:'left'=左侧面板,'right'=右侧面板 |
icon |
可选,Vue 组件形式的图标 |
index.ts(插件入口)
/**
* 插件入口文件
* 组装 meta、entry、metas 并导出
*/
import component from './src/Main.vue'
import metaData from './meta'
import { PluginDemoService } from './src/composable'
export default {
...metaData, // 展开元信息
apis: {}, // 插件提供的 API(可选)
entry: component, // ⭐ 插件 UI 组件
metas: [PluginDemoService] // 插件注册的服务
}
Main.vue(插件界面组件)
<!--
插件 UI 主组件
可以使用 @opentiny/tiny-engine-common 中的 PluginPanel 组件
可以通过 useCanvas() 等 API 获取画布状态
-->
<template>
<plugin-panel title="页面Schema结构">
<template #header>
结构预览
</template>
<template #content>
<div class="content">
<div class="toolbar">
<input
type="text"
v-model="inputValue"
placeholder="请输入页面Schema"
@change="handleInput()"
/>
<button class="copy-btn" @click="handleCopy" :disabled="copyDisabled">
{{ copyText }}
</button>
</div>
<JsonViewer :data="state.pageSchemaJson" :max-depth="10" :default-expand-depth="2" />
</div>
</template>
</plugin-panel>
</template>
<script setup lang="ts">
import { PluginPanel } from '@opentiny/tiny-engine-common'
import { onMounted, watch, ref } from 'vue'
import { useCanvas } from '@opentiny/tiny-engine-meta-register'
import JsonViewer from './components/JsonViewer.vue'
// 获取页面状态
const { pageState } = useCanvas()
const pageSchema = useCanvas().exportSchema()
const pageSchemaJson = useCanvas().getSchema()
const inputValue = ref('')
const copyText = ref('复制')
const copyDisabled = ref(false)
const state = ref({
pageSchema: '',
pageSchemaJson: {} as any,
})
const handleInput = () => {
useCanvas().importSchema(inputValue.value)
}
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(state.value.pageSchema)
copyText.value = '已复制'
copyDisabled.value = true
setTimeout(() => {
copyText.value = '复制'
copyDisabled.value = false
}, 2000)
} catch (err) {
console.error('复制失败:', err)
copyText.value = '复制失败'
setTimeout(() => {
copyText.value = '复制'
}, 2000)
}
}
// 监听页面 Schema 变化
watch(() => pageState.currentSchema, () => {
state.value.pageSchema = useCanvas().exportSchema()
state.value.pageSchemaJson = useCanvas().getSchema()
})
onMounted(() => {
state.value.pageSchemaJson = pageSchemaJson
state.value.pageSchema = pageSchema
})
</script>
<style lang="less" scoped>
.content {
padding: 12px;
height: 100%;
overflow: auto;
}
.toolbar {
display: flex;
gap: 8px;
margin-bottom: 12px;
input {
flex: 1;
padding: 6px 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 12px;
&:focus {
outline: none;
border-color: #409eff;
}
}
}
.copy-btn {
padding: 6px 16px;
background: #409eff;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
white-space: nowrap;
transition: all 0.2s;
&:hover:not(:disabled) {
background: #66b1ff;
}
&:disabled {
background: #67c23a;
cursor: default;
}
}
</style>
服务(composable)
// src/composable/index.ts
// 可选:定义插件提供的服务
export const PluginDemoService = {
id: 'engine.service.plugin-demo',
type: 'service',
services: () => ({
// 在此定义服务方法
sayHello: () => console.log('Hello from plugin demo')
})
};
9.3 插件注册到设计器
在 registry.ts 中注册:
// 1. 导入插件
import MyExportPagePlugin from './plugins/custom-exportPage';
// 2. 注册
export default {
[MyExportPagePlugin.id]: MyExportPagePlugin,
// 3. 在布局中显示
[META_APP.Layout]: {
options: {
layoutConfig: {
plugins: {
left: {
top: [
META_APP.Materials,
META_APP.OutlineTree,
'engine.plugins.my-exportpage', // ← 插件 ID
]
}
}
}
}
}
};
十、物料构建脚本
10.1 完整构建脚本
文件位置: scripts/build/build.materials.ts
/**
* ⭐ TinyEngine 物料组件构建工具
*
* 功能:
* 1. 扫描 src/lowcode/components/ 目录下的所有组件
* 2. 使用 Vite 的 lib 模式逐个构建为 ESM 格式
* 3. 输出到 public/components/ 目录
* 4. 自动生成完整物料包格式的 JSON 文件
*
* 运行方式:
* pnpm run build:materials
*/
import { build } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import fs from 'node:fs'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const projectRoot = path.resolve(__dirname, '../..')
const componentsSrcDir = path.resolve(projectRoot, 'src/lowcode/components')
const componentsOutDir = path.resolve(projectRoot, 'public/components')
// ⭐ 外部依赖列表(这些库不会打包到组件中,而是从 CDN 或宿主项目加载)
const EXTERNAL_LIBS = [
'vue', 'echarts', 'element-plus', '@opentiny/vue',
'ant-design-vue', 'dayjs', 'xlsx'
]
const MAX_DEPTH = 3
const BUILD_MARKER = '__tinyengine_building__'
/**
* 安全检查:确保输出路径在允许范围内
*/
function isSafeOutputPath(outputBase: string, targetPath: string) {
const resolved = path.resolve(targetPath)
const normalizedBase = path.resolve(outputBase)
return resolved.startsWith(normalizedBase + path.sep) || resolved === normalizedBase
}
/**
* 扫描组件目录,递归查找所有可构建的组件
*/
function scanComponents(srcDir: string, depth = 0): any[] {
if (depth > MAX_DEPTH) {
console.warn(`⚠️ 达到最大扫描深度 ${MAX_DEPTH},停止扫描: ${srcDir}`)
return []
}
const components: any[] = []
if (!fs.existsSync(srcDir)) return components
const items = fs.readdirSync(srcDir, { withFileTypes: true })
for (const item of items) {
if (!item.isDirectory()) continue
const componentDir = path.join(srcDir, item.name)
const markerPath = path.join(componentDir, BUILD_MARKER)
// 跳过正在构建的目录
if (fs.existsSync(markerPath)) {
console.warn(`⚠️ 检测到构建标记文件,跳过可能正在构建的目录: ${item.name}`)
continue
}
const namedVue = path.join(componentDir, `${item.name}.vue`)
const indexVue = path.join(componentDir, 'index.vue')
const indexJs = path.join(componentDir, 'index.js')
const hasNamedVue = fs.existsSync(namedVue)
const hasIndexVue = fs.existsSync(indexVue)
const hasIndexJs = fs.existsSync(indexJs)
if (hasNamedVue || hasIndexVue || hasIndexJs) {
const entry = hasNamedVue ? namedVue : hasIndexVue ? indexVue : indexJs
components.push({
name: item.name,
entry,
srcDir: componentDir
})
} else {
// 递归扫描子目录
const nested = scanComponents(componentDir, depth + 1)
components.push(...nested)
}
}
return components
}
/**
* 查找组件目录中的物料 JSON 文件
*/
function findMaterialJson(srcDir: string, componentName: string) {
const candidates = [
path.join(srcDir, `${componentName}.json`),
path.join(srcDir, 'material.json'),
path.join(srcDir, 'index.json')
]
for (const candidate of candidates) {
if (fs.existsSync(candidate)) return candidate
}
return null
}
/**
* 生成 snippet(拖拽到画布时的默认配置)
*/
function generateSnippet(componentMeta: any) {
const componentName = componentMeta.component
const propsList = componentMeta.schema?.properties || []
const defaultProps: Record<string, any> = {}
propsList.forEach((group: any) => {
group.content?.forEach((prop: any) => {
if (prop.defaultValue !== undefined) {
defaultProps[prop.property] = prop.defaultValue
}
})
})
const snippetName = componentMeta.name?.zh_CN || componentName
const icon = componentMeta.icon || 'component'
return {
name: { zh_CN: snippetName },
icon,
screenshot: componentMeta.screenshot || '',
snippetName: componentName,
schema: {
componentName: componentName,
props: defaultProps,
children: []
}
}
}
/**
* 生成 package 配置
*/
function generatePackageConfig(componentMeta: any, componentName: string) {
const npm = componentMeta.npm || {}
return {
name: componentMeta.name?.zh_CN || componentName,
package: npm.package || `@local/${componentName.toLowerCase()}`,
version: npm.version || '1.0.0',
destructuring: npm.destructuring || false,
script: npm.script || `/components/${componentName}/${componentName}.esm.js`,
css: npm.css || `/components/${componentName}/${componentName}.style.css`
}
}
/**
* 转换为完整物料包格式
*/
function transformToFullMaterialPackage(componentMeta: any, componentName: string) {
const snippet = generateSnippet(componentMeta)
const packageConfig = generatePackageConfig(componentMeta, componentName)
return {
data: {
materials: {
components: [componentMeta],
snippets: [
{
group: 'custom',
label: { zh_CN: '自定义组件' },
children: [snippet]
}
],
packages: [packageConfig]
}
}
}
}
/**
* 生成完整物料包 JSON 文件
*/
function writeMaterialJson(srcDir: string, outDir: string, componentName: string) {
const jsonPath = findMaterialJson(srcDir, componentName)
if (!jsonPath) {
console.warn(`⚠️ ${componentName}: 未找到物料 JSON 文件,跳过 JSON 生成`)
return false
}
try {
const metaContent = fs.readFileSync(jsonPath, 'utf-8')
const componentMeta = JSON.parse(metaContent)
const fullPackage = transformToFullMaterialPackage(componentMeta, componentName)
const destPath = path.join(outDir, `${componentName}.json`)
fs.writeFileSync(destPath, JSON.stringify(fullPackage, null, 2), 'utf-8')
console.log(`📋 生成完整物料包: ${componentName}.json`)
return true
} catch (error: any) {
console.error(`❌ ${componentName}: JSON 处理失败`, error.message)
return false
}
}
/**
* 构建单个组件
*/
async function buildComponent(component: any, outputBase: string) {
const { name, entry, srcDir } = component
const componentOutDir = path.join(outputBase, name)
if (!isSafeOutputPath(outputBase, componentOutDir)) {
console.error(`❌ 安全检查失败: 输出路径 ${componentOutDir} 不在允许范围内`)
return false
}
if (!fs.existsSync(componentOutDir)) {
fs.mkdirSync(componentOutDir, { recursive: true })
}
// 创建构建标记
const markerPath = path.join(componentOutDir, BUILD_MARKER)
fs.writeFileSync(markerPath, String(Date.now()))
try {
console.log(`📦 正在构建: ${name}`)
// ⭐ 使用 Vite lib 模式构建
await build({
configFile: false,
plugins: [vue()],
publicDir: false,
define: {
'process.env.NODE_ENV': JSON.stringify('production'),
'process.env': JSON.stringify({})
},
build: {
lib: {
entry,
formats: ['es'],
fileName: () => `${name}.esm.js`
},
outDir: componentOutDir,
emptyOutDir: true,
sourcemap: false,
minify: false,
cssFileName: name,
rollupOptions: {
// ⭐ 外部化公共依赖(不打包到组件中)
external: EXTERNAL_LIBS,
output: {
globals: Object.fromEntries(
EXTERNAL_LIBS.map(lib => [lib, lib.replace(/[\/\-\@]/g, '')])
),
assetFileNames: (assetInfo: any) => {
if (assetInfo.names && assetInfo.names.some(n => n.endsWith('.css'))) {
return `${name}.style.css`
}
return `${name}.[ext]`
}
}
}
}
})
// 生成物料 JSON
writeMaterialJson(srcDir, componentOutDir, name)
// 验证构建产物
const esmPath = path.join(componentOutDir, `${name}.esm.js`)
if (!fs.existsSync(esmPath)) {
console.error(`❌ ${name}: ESM 文件未生成`)
return false
}
console.log(`✅ 构建成功: ${name}/`)
console.log(` ├── ${name}.esm.js`)
const cssFiles = fs.readdirSync(componentOutDir).filter(f => f.endsWith('.css'))
cssFiles.forEach(f => console.log(` ├── ${f}`))
const jsonFile = path.join(componentOutDir, `${name}.json`)
if (fs.existsSync(jsonFile)) {
console.log(` └── ${name}.json`)
}
return true
} catch (error: any) {
console.error(`❌ 构建失败: ${name}`, error.message)
return false
} finally {
// 清理构建标记
if (fs.existsSync(markerPath)) {
fs.unlinkSync(markerPath)
}
}
}
/**
* 构建所有组件
*/
async function buildAllComponents() {
console.log('🚀 TinyEngine 物料组件构建工具 (完整物料包模式)')
console.log('━'.repeat(50))
console.log(`源码目录: ${componentsSrcDir}`)
console.log(`输出目录: ${componentsOutDir}`)
console.log('━'.repeat(50))
if (!fs.existsSync(componentsSrcDir)) {
console.error('❌ 组件源码目录不存在:', componentsSrcDir)
process.exit(1)
}
if (!fs.existsSync(componentsOutDir)) {
fs.mkdirSync(componentsOutDir, { recursive: true })
}
const components = scanComponents(componentsSrcDir)
if (components.length === 0) {
console.log('⚠️ 未发现任何组件')
return
}
console.log(`\n🔍 发现 ${components.length} 个组件: ${components.map(c => c.name).join(', ')}\n`)
let successCount = 0
let failCount = 0
for (const component of components) {
const ok = await buildComponent(component, componentsOutDir)
if (ok) successCount++
else failCount++
}
console.log('\n' + '━'.repeat(50))
console.log(`🎉 构建完成! 成功: ${successCount}, 失败: ${failCount}`)
console.log('━'.repeat(50))
if (failCount > 0) {
process.exitCode = 1
}
}
buildAllComponents()
10.2 运行构建
# 1. 构建所有物料组件 pnpm run build:materials # 2. 清理构建产物 pnpm run clean:bundle # 3. 完整流程:先清理再构建 pnpm run clean:bundle && pnpm run build:materials
十一、路由与导航
11.1 路由配置
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
// ... 你的其他路由
// ⭐ 低代码编辑器路由
{
path: '/lowcode/:formId', // 动态路由传参
name: 'LowcodeEditor',
component: () => import('@/views/lowcode/index.vue'),
props: true, // 将路由 params 作为 props 传给组件
meta: {
title: '低代码编辑器'
}
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router
11.2 从其他页面导航
<template>
<!-- 按钮跳转到低代码编辑器 -->
<a-button type="primary" @click="openLowcodeEditor(formId)">
打开低代码设计器
</a-button>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
const openLowcodeEditor = (formId: string) => {
// 当前窗口跳转
router.push({
name: 'LowcodeEditor',
params: { formId }
})
}
// 或使用新窗口打开
const openInNewWindow = (formId: string) => {
const url = router.resolve({
name: 'LowcodeEditor',
params: { formId }
}).href
window.open(url, '_blank')
}
</script>
十二、完整目录结构
project-root/ │ ├── env/ # 环境变量配置 │ ├── .env # 全局默认 │ ├── .env.dev # 开发环境(含 CDN 配置) │ ├── .env.prod # 生产环境 │ └── .env.test # 测试环境 │ ├── build/ # Vite 构建配置 │ ├── plugins.ts # Vite 插件配置(SVG 图标等) │ └── proxy.ts # 代理配置 │ ├── scripts/build/ # 物料构建脚本 │ └── build.materials.ts # TinyEngine 物料组件构建 │ ├── public/ # 静态资源 │ ├── mock/ │ │ └── bundle.json # TinyVue 内置组件物料数据 │ ├── components/ # 构建后的物料组件产物 │ │ ├── QrCode/ │ │ │ ├── QrCode.esm.js │ │ │ ├── QrCode.style.css │ │ │ └── QrCode.json │ │ ├── DataTable/ │ │ │ ├── DataTable.esm.js │ │ │ ├── DataTable.style.css │ │ │ └── DataTable.json │ │ └── DynamicFieldInput/ │ │ ├── DynamicFieldInput.esm.js │ │ ├── DynamicFieldInput.style.css │ │ └── DynamicFieldInput.json │ ├── favicon.ico │ └── index.html │ ├── src/ │ ├── assets/ │ │ ├── lowcode_svg/ # 物料组件 SVG 图标 │ │ │ └── qrcode.svg │ │ ├── icon/ # 项目图标 │ │ └── svg/ # 其他 SVG 资源 │ │ │ ├── lowcode/ # ⭐ TinyEngine 集成核心目录 │ │ ├── components/ # 自定义物料组件源码 │ │ │ ├── QrCode/ │ │ │ │ ├── QrCode.vue │ │ │ │ └── QrCode.json │ │ │ ├── DataTable/ │ │ │ │ ├── DataTable.vue │ │ │ │ └── DataTable.json │ │ │ └── DynamicFieldInput/ │ │ │ ├── DynamicFieldInput.vue │ │ │ └── DynamicFieldInput.json │ │ │ │ │ ├── docs/ # 文档 │ │ │ └── TinyEngine集成指南.md │ │ │ │ │ ├── plugins/ # 自定义插件 │ │ │ ├── custom-datasource/ │ │ │ │ ├── index.ts │ │ │ │ ├── meta.ts │ │ │ │ └── src/ │ │ │ │ ├── Main.vue │ │ │ │ ├── DataSourceForm.vue │ │ │ │ ├── DataSourceList.vue │ │ │ │ ├── DataSourceRecordForm.vue │ │ │ │ ├── DataSourceGlobalDataHandler.vue │ │ │ │ ├── DataSourceSettingRemoteResult.vue │ │ │ │ ├── composable/ │ │ │ │ │ └── index.ts │ │ │ │ ├── js/ │ │ │ │ │ └── http.ts │ │ │ │ └── styles/ │ │ │ │ └── vars.less │ │ │ ├── custom-table-binding/ │ │ │ │ ├── index.ts │ │ │ │ ├── meta.ts │ │ │ │ └── src/ │ │ │ │ ├── Main.vue │ │ │ │ └── composable/ │ │ │ │ └── index.ts │ │ │ └── custom-exportPage/ │ │ │ ├── index.ts │ │ │ ├── meta.ts │ │ │ └── src/ │ │ │ ├── Main.vue │ │ │ ├── icon/ │ │ │ │ └── ExportIcon.vue │ │ │ ├── components/ │ │ │ │ ├── JsonNode.vue │ │ │ │ └── JsonViewer.vue │ │ │ └── composable/ │ │ │ └── index.ts │ │ │ │ │ ├── preview/ # 预览页面 │ │ │ ├── preview.html │ │ │ └── preview.ts │ │ │ │ │ ├── utils/ │ │ │ └── widgetConverter.ts # 数据格式转换器 │ │ │ │ │ ├── componentsMap.ts # 组件映射配置 │ │ ├── engine.config.ts # ⭐ 引擎核心配置(importMap) │ │ ├── http.ts # ⭐ HTTP 服务拦截器 │ │ └── registry.ts # ⭐ 注册中心 │ │ │ └── views/ │ └── lowcode/ │ └── index.vue # ⭐ 编辑器入口页面 │ ├── package.json ├── vite.config.ts └── tsconfig.json
十三、常见问题与排查
13.1 ⭐ 画布空白 / 加载不出来(最常遇到)
原因:90% 的情况是 importMap 中的 CDN 地址不正确或网络不可达。
排查步骤:
1. 打开浏览器开发者工具(F12)
2. 切换到画布的 iframe 上下文
- 在 Elements 面板中找到 <iframe> 标签
- 右键 → 在 iframe 中打开 / 切换到对应 context
3. 查看 Console 面板,看是否有模块加载失败的错误
4. 查看 Network 面板,找到加载失败的请求
5. 复制失败的 CDN URL 到浏览器地址栏,手动测试是否可访问
6. 如果 404,到 https://registry.npmmirror.com/{包名}/ 确认版本号
修复:修正 engine.config.ts 中 importMap 对应的 CDN 地址。
13.2 多次进入编辑器后报错:工具/服务重复注册
原因:离开编辑器时没有正确清理 TinyEngine 的全局状态。
解决方案:
-
确保
destroyTinyEngine()在onUnmounted和onDeactivated中都调用了 -
确保
clearTinyEngineGlobalState()清空了物料、画布、区块数据 -
必要时在容器 DOM 上调用
innerHTML = ''彻底清理
13.3 自定义组件在物料面板中不显示
排查清单:
-
✅ 是否已运行
pnpm run build:materials构建组件? -
✅
public/components/目录下是否存在.esm.js和.json文件? -
✅
engine.config.ts的material数组中是否配置了该组件的 JSON 路径? -
✅ 组件 JSON 中的
component字段值是否与组件 Vue 文件的name一致? -
✅ 组件 JSON 中是否配置了
npm.script路径指向构建产物?
13.4 保存失败
排查清单:
-
✅
http.ts中/pages/update/的拦截逻辑是否正确? -
✅ BroadcastChannel 通道名称是否一致(
lowcode_save_channel)? -
✅ 入口页面的
handleSave方法是否正确调用了后端 API? -
✅ 后端 API 返回的数据格式是否正常?
13.5 预览按钮打不开 / 白屏
排查清单:
-
✅
registry.ts中的previewUrl路径是否正确?-
开发环境:
/src/lowcode/preview/preview.html -
生产环境:打包后的路径
-
-
✅
vite.config.ts的build.rollupOptions.input是否配置了preview入口? -
✅
preview.ts中是否import 'virtual:svg-icons-register'? -
✅ 预览页面是否正确导入了
registry?
13.6 构建报内存不足
# 设置更大的内存限制 export NODE_OPTIONS=--max-old-space-size=16384 pnpm run build
13.7 组件样式错乱
-
确保
@opentiny/vue本地版本与 CDN 中 TinyVue 运行时版本一致 -
确保
engine.config.ts中enableTailwindCSS: false
13.8 文件创建顺序(快速开始)
第 1 步:安装依赖 第 2 步:配置 env 环境变量(VITE_CDN_DOMAIN) 第 3 步:配置 vite.config.ts(多页面入口 + proxy + optimizeDeps) 第 4 步:配置 build/plugins.ts(SVG 图标插件) 第 5 步:创建 src/lowcode/engine.config.ts(importMap!) 第 6 步:创建 src/lowcode/componentsMap.ts 第 7 步:创建 src/lowcode/http.ts 第 8 步:创建 src/lowcode/registry.ts 第 9 步:创建 src/lowcode/utils/widgetConverter.ts 第 10 步:创建 src/lowcode/preview/(预览页面) 第 11 步:创建 src/views/lowcode/index.vue(编辑器入口) 第 12 步:配置路由 第 13 步:添加自定义物料组件(可选) 第 14 步:添加自定义插件(可选)
所有评论(0)