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 地址不正确。 排查步骤:

  1. 打开浏览器控制台

  2. 切换到画布的 iframe 上下文(在 Elements 面板中找到 iframe 标签,切换到它的 context)

  3. 查看 Network 面板中加载失败的请求

  4. 手动在浏览器地址栏打开失败的 CDN 地址,确认是否可访问

  5. 如果 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 的数据格式之间进行转换。 如果你的业务系统数据格式与示例不同,只需修改 convertCustomWidgetToTinyEngineconvertTinyEngineToLightForm 两个函数。

文件位置: 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();

七、编辑器入口页面

⭐ 这是宿主系统中最重要的文件。它负责:

  1. 从宿主系统的后端 API 获取业务数据

  2. 转换为 TinyEngine 格式存入 sessionStorage

  3. 初始化/销毁 TinyEngine 设计器

  4. 监听保存事件并调用后端 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
组件规范
  1. 使用 Options APIexport default { ... }),不要使用 <script setup>,因为构建工具可能不支持

  2. 组件名必须与目录名一致

  3. 组件通过 props 接收配置(在 JSON 中定义的属性)

  4. 构建时会从外部排除 vueechartsant-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()onUnmountedonDeactivated 中都调用了

  • 确保 clearTinyEngineGlobalState() 清空了物料、画布、区块数据

  • 必要时在容器 DOM 上调用 innerHTML = '' 彻底清理

13.3 自定义组件在物料面板中不显示

排查清单

  1. ✅ 是否已运行 pnpm run build:materials 构建组件?

  2. public/components/ 目录下是否存在 .esm.js.json 文件?

  3. engine.config.tsmaterial 数组中是否配置了该组件的 JSON 路径?

  4. ✅ 组件 JSON 中的 component 字段值是否与组件 Vue 文件的 name 一致?

  5. ✅ 组件 JSON 中是否配置了 npm.script 路径指向构建产物?

13.4 保存失败

排查清单

  1. http.ts/pages/update/ 的拦截逻辑是否正确?

  2. ✅ BroadcastChannel 通道名称是否一致(lowcode_save_channel)?

  3. ✅ 入口页面的 handleSave 方法是否正确调用了后端 API?

  4. ✅ 后端 API 返回的数据格式是否正常?

13.5 预览按钮打不开 / 白屏

排查清单

  1. registry.ts 中的 previewUrl 路径是否正确?

    • 开发环境:/src/lowcode/preview/preview.html

    • 生产环境:打包后的路径

  2. vite.config.tsbuild.rollupOptions.input 是否配置了 preview 入口?

  3. preview.ts 中是否 import 'virtual:svg-icons-register'

  4. ✅ 预览页面是否正确导入了 registry

13.6 构建报内存不足

# 设置更大的内存限制
export NODE_OPTIONS=--max-old-space-size=16384
pnpm run build

13.7 组件样式错乱

  • 确保 @opentiny/vue 本地版本与 CDN 中 TinyVue 运行时版本一致

  • 确保 engine.config.tsenableTailwindCSS: 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 步:添加自定义插件(可选)