浏览代码

Merge pull request #1 from le5le-com/main

v
Alsmile 2 年之前
父节点
当前提交
6d6e0009dc
共有 89 个文件被更改,包括 16958 次插入878 次删除
  1. 2 1
      .env.base
  2. 4 0
      .env.trial
  3. 1 1
      .github/workflows/main.yml
  4. 15 4
      index.html
  5. 17 8
      package.json
  6. 317 241
      pnpm-lock.yaml
  7. 二进制
      public/data.xlsx
  8. 二进制
      public/img/logo.png
  9. 34 0
      public/js/echarts.min.js
  10. 8 0
      public/js/flv.min.js
  11. 19 0
      public/js/html2canvas.min.js
  12. 5 0
      public/js/marked.min.js
  13. 88 0
      public/js/mycharts.js
  14. 二进制
      public/logo.png
  15. 0 0
      public/png/废气治理/储罐.svg
  16. 0 0
      public/png/废气治理/单向阀.svg
  17. 0 0
      public/png/废气治理/过滤器.svg
  18. 0 0
      public/png/智慧城市/充电桩.svg
  19. 0 0
      public/png/智慧城市/草地.svg
  20. 0 0
      public/png/智慧港口/堆取料机.svg
  21. 二进制
      public/png/电信机房/服务器.gif
  22. 二进制
      public/png/电信机房/防火墙.gif
  23. 0 0
      public/png/采暖系统/板式换热器.svg
  24. 二进制
      public/png/采暖系统/采暖系统.gif
  25. 二进制
      public/rotate.cur
  26. 0 0
      public/svg/国家电网/V型开关OPEN.svg
  27. 0 0
      public/svg/国家电网/保安锁符.svg
  28. 0 0
      public/svg/国家电网/保电符.svg
  29. 0 0
      public/svg/电气工程/1-002.svg
  30. 0 0
      public/svg/电气工程/1-007.svg
  31. 0 0
      public/svg/电气工程/1-008.svg
  32. 二进制
      public/view/favicon.ico
  33. 18 0
      public/view/index.css
  34. 28 0
      public/view/index.html
  35. 36 0
      public/view/js/index.js
  36. 14 0
      public/view/js/lcjs.iife.js
  37. 5 0
      public/view/js/marked.min.js
  38. 0 0
      public/view/js/meta2d.js
  39. 20 0
      public/view/react/Meta2d.css
  40. 42 0
      public/view/react/Meta2d.jsx
  41. 68 0
      public/view/vue2/Meta2d.vue
  42. 58 0
      public/view/vue3/Meta2d.vue
  43. 75 0
      public/view/使用说明.md
  44. 二进制
      public/view/使用说明.pdf
  45. 1543 0
      src/assets/canvas2svg.js
  46. 9 0
      src/global.d.ts
  47. 4 45
      src/http.ts
  48. 5 2
      src/router/index.ts
  49. 63 0
      src/services/api.ts
  50. 438 0
      src/services/common.ts
  51. 29 0
      src/services/debouce.ts
  52. 2807 0
      src/services/defaults.ts
  53. 81 0
      src/services/excel.ts
  54. 55 5
      src/services/file.ts
  55. 48 0
      src/services/icons.ts
  56. 68 0
      src/services/png.ts
  57. 9 18
      src/services/register.ts
  58. 14 6
      src/services/selections.ts
  59. 53 0
      src/services/theme.ts
  60. 46 0
      src/services/transfrom.ts
  61. 805 0
      src/services/upgrade.ts
  62. 6 8
      src/services/user.ts
  63. 161 1
      src/services/utils.ts
  64. 292 5
      src/styles/app.css
  65. 248 39
      src/styles/props.css
  66. 227 102
      src/styles/tdesign.css
  67. 23 6
      src/styles/var.css
  68. 2 9
      src/views/Index.vue
  69. 85 0
      src/views/Preview.vue
  70. 550 0
      src/views/components/Actions.vue
  71. 594 0
      src/views/components/AnimateFrames.vue
  72. 182 0
      src/views/components/ChargeCloudPublish.vue
  73. 389 0
      src/views/components/ContextMenu.vue
  74. 301 42
      src/views/components/ElementTree.vue
  75. 129 62
      src/views/components/FileProps.vue
  76. 447 131
      src/views/components/Graphics.vue
  77. 836 92
      src/views/components/Header.vue
  78. 198 0
      src/views/components/Network.vue
  79. 432 0
      src/views/components/PenAnimates.vue
  80. 1006 0
      src/views/components/PenDatas.vue
  81. 159 0
      src/views/components/PenEvents.vue
  82. 1166 2
      src/views/components/PenProps.vue
  83. 916 2
      src/views/components/PensProps.vue
  84. 1277 34
      src/views/components/View.vue
  85. 104 0
      src/views/components/WechatPay.vue
  86. 163 0
      src/views/components/common/CodeEditor.vue
  87. 46 0
      src/views/components/pen.ts
  88. 0 1
      tsconfig.json
  89. 68 11
      vite.config.ts

+ 2 - 1
.env.base

@@ -1,3 +1,4 @@
 BASE_URL=/v/
 VITE_ROUTER_BASE=/v/
-VITE_MARKET=1
+VITE_BASEURL=1
+VITE_TRIAL=0

+ 4 - 0
.env.trial

@@ -0,0 +1,4 @@
+BASE_URL=/v/
+VITE_ROUTER_BASE=/v/
+VITE_BASEURL=1
+VITE_TRIAL=1

+ 1 - 1
.github/workflows/main.yml

@@ -39,7 +39,7 @@ jobs:
       - name: Build
         run:  |
           cd webs
-          pnpm i && pnpm build    
+          pnpm i && pnpm run prod    
 
       - name: 安装COS
         run: |

+ 15 - 4
index.html

@@ -14,10 +14,16 @@
       content="乐吾乐Le5le - 帮助企业快速搭建可视化或企业信息系统,提高开发效率,降低开发成本和运营成本。"
     />
 
-    <link
-      href="https://editor.yuque.com/ne-editor/lake-content-v1.css"
-      rel="stylesheet"
-    />
+    <style type="text/css">
+      .l-icon {
+        width: 1em;
+        height: 1em;
+        vertical-align: -0.15em;
+        fill: currentColor;
+        overflow: hidden;
+      }
+    </style>
+    <script src="//at.alicdn.com/t/c/font_4042197_yrikqthz1j.js"></script>
   </head>
   <body>
     <div id="app"></div>
@@ -27,5 +33,10 @@
         return this.replace(new RegExp(s1, 'gm'), s2);
       };
     </script>
+    <script defer src="/js/html2canvas.min.js"></script>
+    <script defer src="/js/flv.min.js"></script>
+    <script defer src="/js/marked.min.js"></script>
+    <script defer src="/js/echarts.min.js"></script>
+    <script defer src="/js/mycharts.js"></script>
   </body>
 </html>

+ 17 - 8
package.json

@@ -4,30 +4,39 @@
   "version": "0.0.1",
   "scripts": {
     "start": "vite --open --port 7000",
-    "build": "vue-tsc --noEmit && vite build --base=https://assets.le5lecdn.com/v/",
-    "app": "vue-tsc --noEmit && vite build --mode base --base=/v/",
+    "prod": "vue-tsc --noEmit && vite build --base=https://assets.le5lecdn.com/v/",
+    "build": "vue-tsc --noEmit && vite build --mode base --base=/v/",
+    "trial": "vue-tsc --noEmit && vite build --mode trial --base=/v/",
     "preview": "vite preview"
   },
   "dependencies": {
     "axios": "^0.26.0",
     "dayjs": "^1.11.5",
+    "exceljs": "^4.3.0",
+    "fast-xml-parser": "^4.0.1",
     "file-saver": "^2.0.5",
-    "tdesign-vue-next": "^1.3.0",
-    "vue": "^3.2.37",
-    "vue-router": "^4.1.3"
+    "jszip": "^3.10.0",
+    "localforage": "^1.10.0",
+    "monaco-editor": "^0.38.0",
+    "qrcode": "^1.5.3",
+    "tdesign-vue-next": "^1.3.5",
+    "vue": "^3.3.2",
+    "vue-router": "^4.2.0"
   },
   "devDependencies": {
     "@types/file-saver": "^2.0.5",
     "@types/node": "^18.6.4",
+    "@types/offscreencanvas": "^2019.7.0",
     "@types/qrcode": "^1.5.0",
-    "@vitejs/plugin-vue": "^4.0.0",
+    "@vitejs/plugin-vue": "^4.2.0",
     "@vitejs/plugin-vue-jsx": "^3.0.0",
     "autoprefixer": "^10.4.13",
+    "formidable": "^2.0.1",
     "postcss": "^8.4.6",
     "postcss-import": "^14.1.0",
     "postcss-nested": "^6.0.1",
     "typescript": "^4.7.4",
-    "vite": "^4.0.3",
-    "vue-tsc": "^1.0.5"
+    "vite": "^4.3.5",
+    "vue-tsc": "^1.4.4"
   }
 }

文件差异内容过多而无法显示
+ 317 - 241
pnpm-lock.yaml


二进制
public/data.xlsx


二进制
public/img/logo.png


文件差异内容过多而无法显示
+ 34 - 0
public/js/echarts.min.js


文件差异内容过多而无法显示
+ 8 - 0
public/js/flv.min.js


文件差异内容过多而无法显示
+ 19 - 0
public/js/html2canvas.min.js


文件差异内容过多而无法显示
+ 5 - 0
public/js/marked.min.js


+ 88 - 0
public/js/mycharts.js

@@ -0,0 +1,88 @@
+// 1. 编写图形绘画函数
+// 其中,calculative.worldRect为canvas的世界坐标。更多信息,参考 “架构” - “概要” 和 Pen 相关文档
+// 形参 ctx 仅仅在 downloadSvg 时有值
+function mytriangle(pen, ctx) {
+  const path = !ctx ? new Path2D() : ctx;
+  const { x, y, width, height } = pen.calculative.worldRect;
+  path.moveTo(x + width / 2, y);
+  path.lineTo(x + width, y + height);
+  path.lineTo(x, y + height);
+  path.lineTo(x + width / 2, y);
+
+  path.closePath();
+  if (path instanceof Path2D) return path;
+}
+// 2. 如果需要,编写锚点函数。通常,可以使用默认锚点,然后通过快捷键动态添加锚点
+// 注意,锚点左边为相对宽高的百分比小数(0-1之间的小数)
+function mytriangleAnchors(pen) {
+  const anchors = [];
+  anchors.push({
+    id: '0',
+    penId: pen.id,
+    x: 0.5,
+    y: 0,
+  });
+
+  anchors.push({
+    id: '1',
+    penId: pen.id,
+    x: 1,
+    y: 1,
+  });
+
+  anchors.push({
+    id: '2',
+    penId: pen.id,
+    x: 0,
+    y: 1,
+  });
+  pen.anchors = anchors;
+}
+
+// 需要时打开
+if (false) {
+  window.myCharts = [
+    {
+      name: '我的图形库文件夹名称',
+      list: [
+        {
+          image: '/img/logo.png',
+          name: '自定义图形',
+          penFn: mytriangle,
+          anchorsFn: mytriangleAnchors, // 可以不需要,使用缺省锚点
+          data: {
+            width: 100,
+            height: 100,
+          },
+        },
+      ],
+      show: true,
+    },
+  ];
+}
+
+function beforeSave(data) {
+  // 自己的保存业务逻辑
+  const query = window.location.search.substring(1).split("&");
+  const obj = {};
+  for (let i = 0; i < query.length; i++) {
+    const el = query[i];
+    const kvPairs = el.split("=");
+    obj[kvPairs[0]] = kvPairs[1];
+  }
+  if(obj.eqpickey){
+    if(!data.data){
+      data.data = {};
+    }
+    data.data.eqpickey = obj.eqpickey;
+  }
+  return true;
+}
+
+function afterSave(res){
+  // 保存图纸之后的钩子函数
+  // console.log('afterSave',res)
+}
+
+window.beforeSaveMeta2d = beforeSave;
+window.afterSaveMeta2d = afterSave;

二进制
public/logo.png


文件差异内容过多而无法显示
+ 0 - 0
public/png/废气治理/储罐.svg


文件差异内容过多而无法显示
+ 0 - 0
public/png/废气治理/单向阀.svg


文件差异内容过多而无法显示
+ 0 - 0
public/png/废气治理/过滤器.svg


文件差异内容过多而无法显示
+ 0 - 0
public/png/智慧城市/充电桩.svg


文件差异内容过多而无法显示
+ 0 - 0
public/png/智慧城市/草地.svg


文件差异内容过多而无法显示
+ 0 - 0
public/png/智慧港口/堆取料机.svg


二进制
public/png/电信机房/服务器.gif


二进制
public/png/电信机房/防火墙.gif


文件差异内容过多而无法显示
+ 0 - 0
public/png/采暖系统/板式换热器.svg


二进制
public/png/采暖系统/采暖系统.gif


二进制
public/rotate.cur


文件差异内容过多而无法显示
+ 0 - 0
public/svg/国家电网/V型开关OPEN.svg


文件差异内容过多而无法显示
+ 0 - 0
public/svg/国家电网/保安锁符.svg


文件差异内容过多而无法显示
+ 0 - 0
public/svg/国家电网/保电符.svg


文件差异内容过多而无法显示
+ 0 - 0
public/svg/电气工程/1-002.svg


文件差异内容过多而无法显示
+ 0 - 0
public/svg/电气工程/1-007.svg


文件差异内容过多而无法显示
+ 0 - 0
public/svg/电气工程/1-008.svg


二进制
public/view/favicon.ico


+ 18 - 0
public/view/index.css

@@ -0,0 +1,18 @@
+* {
+  padding: 0;
+  margin: 0;
+  outline: none;
+  box-sizing: border-box;
+  word-break: break-all;
+  word-break: break-word;
+}
+
+html, body {
+  width: 100%;
+  height: 100%;
+}
+
+#meta2d {
+  height: 100%;
+  overflow: hidden;
+}

+ 28 - 0
public/view/index.html

@@ -0,0 +1,28 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title i18n>meta2d</title>
+    <meta charset="UTF-8" />
+    <meta name="format-detection" content="telephone=no" />
+    <meta name="msapplication-tap-highlight" content="no" />
+    <meta
+      name="viewport"
+      content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width"
+    />
+
+    <link href="favicon.ico" rel="shortcut icon" type="image/x-icon" />
+    <link href="index.css" rel="stylesheet" />
+  </head>
+
+  <body>
+    <div class="meta2d" id="meta2d"></div>
+
+    <script src="js/meta2d.js"></script>
+    <script src="js/lcjs.iife.js"></script>
+    <script src="js/marked.min.js"></script>
+    <script crossorigin="anonymous" integrity="sha512-ppWbHq6q2f7HAwS481w6qikuC0XEeBnmkRg6KWnWg3zSIbJwWWBgsCDMAxzSB7SVqXzWwSYQ2s8TSJKjnaikMg==" src="https://lib.baomitu.com/echarts/5.1.2/echarts.min.js"></script>
+    <script src="http://cdn.hcharts.cn/highcharts/highcharts.js"></script>
+    <script src="http://cdn.hcharts.cn/highcharts/highcharts-more.js"></script>
+    <script src="js/index.js"></script>
+  </body>
+</html>

+ 36 - 0
public/view/js/index.js

@@ -0,0 +1,36 @@
+const meta2d = new Meta2d('meta2d');
+
+registerCommonDiagram();
+
+function getQueryVariable(variable) {
+  var query = window.location.search.substring(1);
+  var vars = query.split('&');
+  for (var i = 0; i < vars.length; i++) {
+    var pair = vars[i].split('=');
+    if (pair[0] == variable) {
+      return pair[1];
+    }
+  }
+  return false;
+}
+
+function _fetch(url, cb) {
+  var xhr = new XMLHttpRequest();
+  xhr.open('GET', url, true);
+  xhr.send();
+  xhr.onreadystatechange = function() {
+    if (xhr.readyState == 4 && xhr.status == 200) {
+      cb && cb(xhr.responseText);
+    }
+  };
+}
+
+var id = getQueryVariable('id');
+if (!id) {
+  id = 'data';
+}
+_fetch(id + '.json', function(text) {
+  var data = JSON.parse(text);
+  data.locked = 1;
+  meta2d.open(data);
+});

文件差异内容过多而无法显示
+ 14 - 0
public/view/js/lcjs.iife.js


文件差异内容过多而无法显示
+ 5 - 0
public/view/js/marked.min.js


文件差异内容过多而无法显示
+ 0 - 0
public/view/js/meta2d.js


+ 20 - 0
public/view/react/Meta2d.css

@@ -0,0 +1,20 @@
+html,
+body {
+  height: 100vh;
+  margin: 0;
+  padding: 0;
+}
+
+.content {
+  position: relative;
+  background-color: #f4f4f4;
+  height: 100vh;
+}
+
+#meta2d {
+  position: absolute !important;
+  width: 100%;
+  height: 100%;
+  touch-action: none;
+  overflow: hidden;
+}

+ 42 - 0
public/view/react/Meta2d.jsx

@@ -0,0 +1,42 @@
+import React from "react";
+import "./Meta2d.css";
+
+let meta2d = null;
+export default class Meta2d extends React.Component {
+  constructor(props) {
+    super(props);
+  }
+
+  componentDidMount() {
+    meta2d = new window.Meta2d("meta2d");
+    window.registerCommonDiagram();
+    this._fetch("/json/data.json", function (text) {
+      var data = JSON.parse(text);
+      data.locked = 1;
+      meta2d.open(data);
+    });
+  }
+
+  componentWillUnmount() {
+    meta2d?.destroy();
+  }
+
+  _fetch(url, cb) {
+    var xhr = new XMLHttpRequest();
+    xhr.open("GET", url, true);
+    xhr.send();
+    xhr.onreadystatechange = function () {
+      if (xhr.readyState == 4 && xhr.status == 200) {
+        cb && cb(xhr.responseText);
+      }
+    };
+  }
+
+  render() {
+    return (
+      <div className="content">
+        <div id="meta2d"></div>
+      </div>
+    );
+  }
+}

+ 68 - 0
public/view/vue2/Meta2d.vue

@@ -0,0 +1,68 @@
+<!--  -->
+<template>
+  <div class="content">
+    <div id="meta2d"></div>
+  </div>
+</template>
+
+<script>
+let meta2d = null;
+
+export default {
+  name: 'meta2d',
+  data() {
+    return {};
+  },
+  mounted() {
+    this.initMeta2d();
+  },
+  destroyed() {
+    meta2d?.destroy();
+  },
+  methods: {
+    initMeta2d() {
+      meta2d = new window.Meta2d("meta2d");
+      window.registerCommonDiagram();
+
+      this._fetch("/json/data.json", function (text) {
+        var data = JSON.parse(text);
+        data.locked = 1;
+        meta2d.open(data);
+      });
+    },
+
+    _fetch(url, cb) {
+      var xhr = new XMLHttpRequest();
+      xhr.open("GET", url, true);
+      xhr.send();
+      xhr.onreadystatechange = function () {
+        if (xhr.readyState == 4 && xhr.status == 200) {
+          cb && cb(xhr.responseText);
+        }
+      };
+    },
+  },
+};
+</script>
+<style>
+html,
+body {
+  height: 100vh;
+  margin: 0;
+  padding: 0;
+}
+
+.content {
+  position: relative;
+  background-color: #f4f4f4;
+  height: 100vh;
+}
+
+#meta2d {
+  position: absolute !important;
+  width: 100%;
+  height: 100%;
+  touch-action: none;
+  overflow: hidden;
+}
+</style>

+ 58 - 0
public/view/vue3/Meta2d.vue

@@ -0,0 +1,58 @@
+<template>
+  <div class="content">
+    <div id="meta2d"></div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted } from 'vue';
+let meta2d: any = null;
+
+onMounted(async () => {
+  meta2d = new (window as any).Meta2d('meta2d');
+  (window as any).registerCommonDiagram();
+
+  _fetch('/json/data.json', function (text) {
+    var data = JSON.parse(text);
+    data.locked = 1;
+    meta2d.open(data);
+  });
+});
+
+function _fetch(url, cb) {
+  var xhr = new XMLHttpRequest();
+  xhr.open('GET', url, true);
+  xhr.send();
+  xhr.onreadystatechange = function () {
+    if (xhr.readyState == 4 && xhr.status == 200) {
+      cb && cb(xhr.responseText);
+    }
+  };
+}
+
+onUnmounted(() => {
+  meta2d?.destroy();
+});
+</script>
+
+<style lang="scss" scoped>
+html,
+body {
+  height: 100vh;
+  margin: 0;
+  padding: 0;
+}
+
+.content {
+  position: relative;
+  background-color: #f4f4f4;
+  height: 100vh;
+  #meta2d {
+    position: absolute !important;
+    width: 100%;
+    height: 100%;
+    touch-action: none;
+    overflow: hidden;
+  }
+}
+</style>

+ 75 - 0
public/view/使用说明.md

@@ -0,0 +1,75 @@
+# meta2d-view
+
+le5le meta2d 预览页.
+
+# 如何使用
+## 下载为Vue2
+
+- 将zip文件解压;
+- 将vue2/meta2d.vue放到 vue2项目 的views文件夹(作为一个独立的页面);
+- 配置meta2d页面路由;
+- 将data.json文件放到 vue2项目 公共资源目录下public/json/;
+- 将js、image和png文件夹放到 vue2项目 public下;
+- 参考index.html文件,将script标签引入资源的代码复制到 vue2项目 的index.html中(src="js/index.js"除外);
+
+(如果js下没有核心包(meta2d.js),请用npm下载依赖包(npm install meta2d.js),拿到依赖包里面的meta2d.js文件并引入)
+
+
+## 下载为Vue3
+
+- 将zip文件解压;
+- 将vue3/meta2d.vue放到 vue3项目 的views文件夹(作为一个独立的页面);
+- 在 vue3项目 中配置meta2d页面路由,如下:
+
+```ts
+  {
+    path: '/meta2d',
+    component: () => import('@/views/Meta2d.vue'),
+  }
+```
+
+- 将data.json文件放到 vue3项目 公共资源目录下public/json/;
+- 将js、image和png文件夹放到 vue3项目 public下;
+- 参考index.html文件,将script标签引入资源的代码复制到 vue3项目 的index.html中(src="js/index.js"除外);
+
+
+(如果js下没有核心包(meta2d.js),请用npm下载依赖包(npm install meta2d.js),拿到依赖包里面的meta2d.js文件并引入)
+
+
+## 下载为React
+
+- 将zip文件解压;
+- 将react文件夹下的文件放到 react项目 的views文件夹(作为一个独立的页面);
+- 配置mata2d页面路由;
+- 将data.json文件放到 react项目 公共资源目录下public/json下;
+- 将js、image和png文件夹放到 react项目 public下;
+- 参考index.html文件,将script标签引入资源的代码复制到 react项目 的index.html中(src="js/index.js"除外);
+
+
+(如果js下没有核心包(meta2d.js),请用npm下载依赖包(npm install meta2d.js),拿到依赖包里面的meta2d.js文件并引入)
+
+
+# 生产环境
+
+用 nginx 静态代理即可,例如:
+
+```
+server {
+    listen       80;
+    server_name  域名.le5le.com;
+
+    #access_log  /var/log/nginx/host.access.log  main;
+
+    root /root/web/静态文件夹名;
+
+    location / {
+        index index.html;
+        rewrite ^/.*/$ / last;
+        rewrite ^([^.]*[^/])$ $1/ permanent;
+    }
+    error_page   500 502 503 504  /50x.html;
+    location = /50x.html {
+        root   /usr/share/nginx/html;
+    }
+}
+```

二进制
public/view/使用说明.pdf


+ 1543 - 0
src/assets/canvas2svg.js

@@ -0,0 +1,1543 @@
+/*!!
+ *  Canvas 2 Svg v1.0.19
+ *  A low level canvas to SVG converter. Uses a mock canvas context to build an SVG document.
+ *
+ *  Licensed under the MIT license:
+ *  http://www.opensource.org/licenses/mit-license.php
+ *
+ *  Author:
+ *  Kerry Liu
+ *
+ *  Copyright (c) 2014 Gliffy Inc.
+ */
+
+(function() {
+  'use strict';
+
+  var STYLES, ctx, CanvasGradient, CanvasPattern, namedEntities;
+
+  //helper function to format a string
+  function format(str, args) {
+    var keys = Object.keys(args),
+      i;
+    for (i = 0; i < keys.length; i++) {
+      str = str.replace(
+        new RegExp('\\{' + keys[i] + '\\}', 'gi'),
+        args[keys[i]]
+      );
+    }
+    return str;
+  }
+
+  //helper function that generates a random string
+  function randomString(holder) {
+    var chars, randomstring, i;
+    if (!holder) {
+      throw new Error(
+        'cannot create a random attribute name for an undefined object'
+      );
+    }
+    chars = 'ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz';
+    randomstring = '';
+    do {
+      randomstring = '';
+      for (i = 0; i < 12; i++) {
+        randomstring += chars[Math.floor(Math.random() * chars.length)];
+      }
+    } while (holder[randomstring]);
+    return randomstring;
+  }
+
+  //helper function to map named to numbered entities
+  function createNamedToNumberedLookup(items, radix) {
+    var i,
+      entity,
+      lookup = {},
+      base10,
+      base16;
+    items = items.split(',');
+    radix = radix || 10;
+    // Map from named to numbered entities.
+    for (i = 0; i < items.length; i += 2) {
+      entity = '&' + items[i + 1] + ';';
+      base10 = parseInt(items[i], radix);
+      lookup[entity] = '&#' + base10 + ';';
+    }
+    //FF and IE need to create a regex from hex values ie &nbsp; == \xa0
+    lookup['\\xa0'] = '&#160;';
+    return lookup;
+  }
+
+  //helper function to map canvas-textAlign to svg-textAnchor
+  function getTextAnchor(textAlign) {
+    //TODO: support rtl languages
+    var mapping = {
+      left: 'start',
+      right: 'end',
+      center: 'middle',
+      start: 'start',
+      end: 'end'
+    };
+    return mapping[textAlign] || mapping.start;
+  }
+
+  //helper function to map canvas-textBaseline to svg-dominantBaseline
+  function getDominantBaseline(textBaseline) {
+    //INFO: not supported in all browsers
+    var mapping = {
+      alphabetic: 'alphabetic',
+      hanging: 'hanging',
+      top: 'text-before-edge',
+      bottom: 'text-after-edge',
+      middle: 'central'
+    };
+    return mapping[textBaseline] || mapping.alphabetic;
+  }
+
+  // Unpack entities lookup where the numbers are in radix 32 to reduce the size
+  // entity mapping courtesy of tinymce
+  namedEntities = createNamedToNumberedLookup(
+    '50,nbsp,51,iexcl,52,cent,53,pound,54,curren,55,yen,56,brvbar,57,sect,58,uml,59,copy,' +
+      '5a,ordf,5b,laquo,5c,not,5d,shy,5e,reg,5f,macr,5g,deg,5h,plusmn,5i,sup2,5j,sup3,5k,acute,' +
+      '5l,micro,5m,para,5n,middot,5o,cedil,5p,sup1,5q,ordm,5r,raquo,5s,frac14,5t,frac12,5u,frac34,' +
+      '5v,iquest,60,Agrave,61,Aacute,62,Acirc,63,Atilde,64,Auml,65,Aring,66,AElig,67,Ccedil,' +
+      '68,Egrave,69,Eacute,6a,Ecirc,6b,Euml,6c,Igrave,6d,Iacute,6e,Icirc,6f,Iuml,6g,ETH,6h,Ntilde,' +
+      '6i,Ograve,6j,Oacute,6k,Ocirc,6l,Otilde,6m,Ouml,6n,times,6o,Oslash,6p,Ugrave,6q,Uacute,' +
+      '6r,Ucirc,6s,Uuml,6t,Yacute,6u,THORN,6v,szlig,70,agrave,71,aacute,72,acirc,73,atilde,74,auml,' +
+      '75,aring,76,aelig,77,ccedil,78,egrave,79,eacute,7a,ecirc,7b,euml,7c,igrave,7d,iacute,7e,icirc,' +
+      '7f,iuml,7g,eth,7h,ntilde,7i,ograve,7j,oacute,7k,ocirc,7l,otilde,7m,ouml,7n,divide,7o,oslash,' +
+      '7p,ugrave,7q,uacute,7r,ucirc,7s,uuml,7t,yacute,7u,thorn,7v,yuml,ci,fnof,sh,Alpha,si,Beta,' +
+      'sj,Gamma,sk,Delta,sl,Epsilon,sm,Zeta,sn,Eta,so,Theta,sp,Iota,sq,Kappa,sr,Lambda,ss,Mu,' +
+      'st,Nu,su,Xi,sv,Omicron,t0,Pi,t1,Rho,t3,Sigma,t4,Tau,t5,Upsilon,t6,Phi,t7,Chi,t8,Psi,' +
+      't9,Omega,th,alpha,ti,beta,tj,gamma,tk,delta,tl,epsilon,tm,zeta,tn,eta,to,theta,tp,iota,' +
+      'tq,kappa,tr,lambda,ts,mu,tt,nu,tu,xi,tv,omicron,u0,pi,u1,rho,u2,sigmaf,u3,sigma,u4,tau,' +
+      'u5,upsilon,u6,phi,u7,chi,u8,psi,u9,omega,uh,thetasym,ui,upsih,um,piv,812,bull,816,hellip,' +
+      '81i,prime,81j,Prime,81u,oline,824,frasl,88o,weierp,88h,image,88s,real,892,trade,89l,alefsym,' +
+      '8cg,larr,8ch,uarr,8ci,rarr,8cj,darr,8ck,harr,8dl,crarr,8eg,lArr,8eh,uArr,8ei,rArr,8ej,dArr,' +
+      '8ek,hArr,8g0,forall,8g2,part,8g3,exist,8g5,empty,8g7,nabla,8g8,isin,8g9,notin,8gb,ni,8gf,prod,' +
+      '8gh,sum,8gi,minus,8gn,lowast,8gq,radic,8gt,prop,8gu,infin,8h0,ang,8h7,and,8h8,or,8h9,cap,8ha,cup,' +
+      '8hb,int,8hk,there4,8hs,sim,8i5,cong,8i8,asymp,8j0,ne,8j1,equiv,8j4,le,8j5,ge,8k2,sub,8k3,sup,8k4,' +
+      'nsub,8k6,sube,8k7,supe,8kl,oplus,8kn,otimes,8l5,perp,8m5,sdot,8o8,lceil,8o9,rceil,8oa,lfloor,8ob,' +
+      'rfloor,8p9,lang,8pa,rang,9ea,loz,9j0,spades,9j3,clubs,9j5,hearts,9j6,diams,ai,OElig,aj,oelig,b0,' +
+      'Scaron,b1,scaron,bo,Yuml,m6,circ,ms,tilde,802,ensp,803,emsp,809,thinsp,80c,zwnj,80d,zwj,80e,lrm,' +
+      '80f,rlm,80j,ndash,80k,mdash,80o,lsquo,80p,rsquo,80q,sbquo,80s,ldquo,80t,rdquo,80u,bdquo,810,dagger,' +
+      '811,Dagger,81g,permil,81p,lsaquo,81q,rsaquo,85c,euro',
+    32
+  );
+
+  //Some basic mappings for attributes and default values.
+  STYLES = {
+    strokeStyle: {
+      svgAttr: 'stroke', //corresponding svg attribute
+      canvas: '#000000', //canvas default
+      svg: 'none', //svg default
+      apply: 'stroke' //apply on stroke() or fill()
+    },
+    fillStyle: {
+      svgAttr: 'fill',
+      canvas: '#000000',
+      svg: null, //svg default is black, but we need to special case this to handle canvas stroke without fill
+      apply: 'fill'
+    },
+    lineCap: {
+      svgAttr: 'stroke-linecap',
+      canvas: 'butt',
+      svg: 'butt',
+      apply: 'stroke'
+    },
+    lineJoin: {
+      svgAttr: 'stroke-linejoin',
+      canvas: 'miter',
+      svg: 'miter',
+      apply: 'stroke'
+    },
+    miterLimit: {
+      svgAttr: 'stroke-miterlimit',
+      canvas: 10,
+      svg: 4,
+      apply: 'stroke'
+    },
+    lineWidth: {
+      svgAttr: 'stroke-width',
+      canvas: 1,
+      svg: 1,
+      apply: 'stroke'
+    },
+    globalAlpha: {
+      svgAttr: 'opacity',
+      canvas: 1,
+      svg: 1,
+      apply: 'fill stroke'
+    },
+    font: {
+      //font converts to multiple svg attributes, there is custom logic for this
+      canvas: '12px Arial'
+    },
+    shadowColor: {
+      canvas: '#000000'
+    },
+    shadowOffsetX: {
+      canvas: 0
+    },
+    shadowOffsetY: {
+      canvas: 0
+    },
+    shadowBlur: {
+      canvas: 0
+    },
+    textAlign: {
+      canvas: 'start'
+    },
+    textBaseline: {
+      canvas: 'alphabetic'
+    },
+    lineDash: {
+      svgAttr: 'stroke-dasharray',
+      canvas: [],
+      svg: null,
+      apply: 'stroke'
+    }
+  };
+
+  /**
+   *
+   * @param gradientNode - reference to the gradient
+   * @constructor
+   */
+  CanvasGradient = function(gradientNode, ctx) {
+    this.__root = gradientNode;
+    this.__ctx = ctx;
+  };
+
+  /**
+   * Adds a color stop to the gradient root
+   */
+  CanvasGradient.prototype.addColorStop = function(offset, color) {
+    var stop = this.__ctx.__createElement('stop'),
+      regex,
+      matches;
+    stop.setAttribute('offset', offset);
+    if (color.indexOf('rgba') !== -1) {
+      //separate alpha value, since webkit can't handle it
+      regex = /rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d?\.?\d*)\s*\)/gi;
+      matches = regex.exec(color);
+      stop.setAttribute(
+        'stop-color',
+        format('rgb({r},{g},{b})', {
+          r: matches[1],
+          g: matches[2],
+          b: matches[3]
+        })
+      );
+      stop.setAttribute('stop-opacity', matches[4]);
+    } else {
+      stop.setAttribute('stop-color', color);
+    }
+    this.__root.appendChild(stop);
+  };
+
+  CanvasPattern = function(pattern, ctx) {
+    this.__root = pattern;
+    this.__ctx = ctx;
+  };
+
+  /**
+   * The mock canvas context
+   * @param o - options include:
+   * ctx - existing Context2D to wrap around
+   * width - width of your canvas (defaults to 500)
+   * height - height of your canvas (defaults to 500)
+   * enableMirroring - enables canvas mirroring (get image data) (defaults to false)
+   * document - the document object (defaults to the current document)
+   */
+  ctx = function(o) {
+    var defaultOptions = { width: 500, height: 500, enableMirroring: false },
+      options;
+
+    //keep support for this way of calling C2S: new C2S(width,height)
+    if (arguments.length > 1) {
+      options = defaultOptions;
+      options.width = arguments[0];
+      options.height = arguments[1];
+    } else if (!o) {
+      options = defaultOptions;
+    } else {
+      options = o;
+    }
+
+    if (!(this instanceof ctx)) {
+      //did someone call this without new?
+      return new ctx(options);
+    }
+
+    //setup options
+    this.width = options.width || defaultOptions.width;
+    this.height = options.height || defaultOptions.height;
+    this.enableMirroring =
+      options.enableMirroring !== undefined
+        ? options.enableMirroring
+        : defaultOptions.enableMirroring;
+
+    this.canvas = this; ///point back to this instance!
+    this.__document = options.document || document;
+
+    // allow passing in an existing context to wrap around
+    // if a context is passed in, we know a canvas already exist
+    if (options.ctx) {
+      this.__ctx = options.ctx;
+    } else {
+      this.__canvas = this.__document.createElement('canvas');
+      this.__ctx = this.__canvas.getContext('2d');
+    }
+
+    this.__setDefaultStyles();
+    this.__stack = [this.__getStyleState()];
+    this.__groupStack = [];
+
+    //the root svg element
+    this.__root = this.__document.createElementNS(
+      'http://www.w3.org/2000/svg',
+      'svg'
+    );
+    this.__root.setAttribute('version', 1.1);
+    this.__root.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
+    this.__root.setAttributeNS(
+      'http://www.w3.org/2000/xmlns/',
+      'xmlns:xlink',
+      'http://www.w3.org/1999/xlink'
+    );
+    this.__root.setAttribute('width', this.width);
+    this.__root.setAttribute('height', this.height);
+
+    //make sure we don't generate the same ids in defs
+    this.__ids = {};
+
+    //defs tag
+    this.__defs = this.__document.createElementNS(
+      'http://www.w3.org/2000/svg',
+      'defs'
+    );
+    this.__root.appendChild(this.__defs);
+
+    //also add a group child. the svg element can't use the transform attribute
+    this.__currentElement = this.__document.createElementNS(
+      'http://www.w3.org/2000/svg',
+      'g'
+    );
+    this.__root.appendChild(this.__currentElement);
+  };
+
+  /**
+   * Creates the specified svg element
+   * @private
+   */
+  ctx.prototype.__createElement = function(elementName, properties, resetFill) {
+    if (typeof properties === 'undefined') {
+      properties = {};
+    }
+
+    var element = this.__document.createElementNS(
+        'http://www.w3.org/2000/svg',
+        elementName
+      ),
+      keys = Object.keys(properties),
+      i,
+      key;
+    if (resetFill) {
+      //if fill or stroke is not specified, the svg element should not display. By default SVG's fill is black.
+      element.setAttribute('fill', 'none');
+      element.setAttribute('stroke', 'none');
+    }
+    for (i = 0; i < keys.length; i++) {
+      key = keys[i];
+      element.setAttribute(key, properties[key]);
+    }
+    return element;
+  };
+
+  /**
+   * Applies default canvas styles to the context
+   * @private
+   */
+  ctx.prototype.__setDefaultStyles = function() {
+    //default 2d canvas context properties see:http://www.w3.org/TR/2dcontext/
+    var keys = Object.keys(STYLES),
+      i,
+      key;
+    for (i = 0; i < keys.length; i++) {
+      key = keys[i];
+      this[key] = STYLES[key].canvas;
+    }
+  };
+
+  ctx.prototype.setAttrs = function(pen) {
+    if (!pen) {
+      return;
+    }
+
+    var currentElement = this.__currentElement;
+    currentElement.setAttribute('id', pen.id);
+    currentElement.setAttribute('name', pen.name);
+    pen.text && currentElement.setAttribute('text', pen.text);
+    pen.lineName && currentElement.setAttribute('line-name', pen.lineName);
+    // 连线起终点
+    if (pen.type == 1) {
+      const from = pen.anchors[0];
+      from.connectTo && currentElement.setAttribute('from-id', from.connectTo);
+      const to = pen.anchors[pen.anchors.length - 1];
+      to.connectTo && currentElement.setAttribute('to-id', to.connectTo);
+    }
+
+    // 业务数据
+    if (!Array.isArray(pen.form)) {
+      return;
+    }
+    pen.form.forEach(({key}) => {
+      // TODO: pen[key] 若是一个对象,待考虑
+      currentElement.setAttribute('data-' + key, pen[key]);
+    });
+  };
+
+  /**
+   * Applies styles on restore
+   * @param styleState
+   * @private
+   */
+  ctx.prototype.__applyStyleState = function(styleState) {
+    var keys = Object.keys(styleState),
+      i,
+      key;
+    for (i = 0; i < keys.length; i++) {
+      key = keys[i];
+      this[key] = styleState[key];
+    }
+  };
+
+  /**
+   * Gets the current style state
+   * @return {Object}
+   * @private
+   */
+  ctx.prototype.__getStyleState = function() {
+    var i,
+      styleState = {},
+      keys = Object.keys(STYLES),
+      key;
+    for (i = 0; i < keys.length; i++) {
+      key = keys[i];
+      styleState[key] = this[key];
+    }
+    return styleState;
+  };
+
+  /**
+   * Apples the current styles to the current SVG element. On "ctx.fill" or "ctx.stroke"
+   * @param type
+   * @private
+   */
+  ctx.prototype.__applyStyleToCurrentElement = function(type) {
+    var currentElement = this.__currentElement;
+    var currentStyleGroup = this.__currentElementsToStyle;
+    if (currentStyleGroup) {
+      currentElement.setAttribute(type, '');
+      currentElement = currentStyleGroup.element;
+      currentStyleGroup.children.forEach(function(node) {
+        node.setAttribute(type, '');
+      });
+    }
+
+    var keys = Object.keys(STYLES),
+      i,
+      style,
+      value,
+      id,
+      regex,
+      matches;
+    for (i = 0; i < keys.length; i++) {
+      style = STYLES[keys[i]];
+      value = this[keys[i]];
+      if (style.apply) {
+        //is this a gradient or pattern?
+        if (value instanceof CanvasPattern) {
+          //pattern
+          if (value.__ctx) {
+            //copy over defs
+            while (value.__ctx.__defs.childNodes.length) {
+              id = value.__ctx.__defs.childNodes[0].getAttribute('id');
+              this.__ids[id] = id;
+              this.__defs.appendChild(value.__ctx.__defs.childNodes[0]);
+            }
+          }
+          currentElement.setAttribute(
+            style.apply,
+            format('url(#{id})', { id: value.__root.getAttribute('id') })
+          );
+        } else if (value instanceof CanvasGradient) {
+          //gradient
+          currentElement.setAttribute(
+            style.apply,
+            format('url(#{id})', { id: value.__root.getAttribute('id') })
+          );
+        } else if (style.apply.indexOf(type) !== -1 && style.svg !== value) {
+          if (
+            (style.svgAttr === 'stroke' || style.svgAttr === 'fill') &&
+            value &&
+            value.indexOf('rgba') !== -1
+          ) {
+            //separate alpha value, since illustrator can't handle it
+            regex = /rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d?\.?\d*)\s*\)/gi;
+            matches = regex.exec(value);
+            currentElement.setAttribute(
+              style.svgAttr,
+              format('rgb({r},{g},{b})', {
+                r: matches[1],
+                g: matches[2],
+                b: matches[3]
+              })
+            );
+            //should take globalAlpha here
+            var opacity = matches[4];
+            var globalAlpha = this.globalAlpha;
+            if (globalAlpha != null) {
+              opacity *= globalAlpha;
+            }
+            currentElement.setAttribute(style.svgAttr + '-opacity', opacity);
+          } else {
+            var attr = style.svgAttr;
+            if (keys[i] === 'globalAlpha') {
+              attr = type + '-' + style.svgAttr;
+              if (currentElement.getAttribute(attr)) {
+                //fill-opacity or stroke-opacity has already been set by stroke or fill.
+                continue;
+              }
+            }
+            //otherwise only update attribute if right type, and not svg default
+            currentElement.setAttribute(attr, value);
+          }
+        }
+      }
+    }
+  };
+
+  /**
+   * Will return the closest group or svg node. May return the current element.
+   * @private
+   */
+  ctx.prototype.__closestGroupOrSvg = function(node) {
+    node = node || this.__currentElement;
+    if (node.nodeName === 'g' || node.nodeName === 'svg') {
+      return node;
+    } else {
+      return this.__closestGroupOrSvg(node.parentNode);
+    }
+  };
+
+  /**
+   * Returns the serialized value of the svg so far
+   * @param fixNamedEntities - Standalone SVG doesn't support named entities, which document.createTextNode encodes.
+   *                           If true, we attempt to find all named entities and encode it as a numeric entity.
+   * @return serialized svg
+   */
+  ctx.prototype.getSerializedSvg = function(fixNamedEntities) {
+    var serialized = new XMLSerializer().serializeToString(this.__root),
+      keys,
+      i,
+      key,
+      value,
+      regexp,
+      xmlns;
+
+    //IE search for a duplicate xmnls because they didn't implement setAttributeNS correctly
+    xmlns = /xmlns="http:\/\/www\.w3\.org\/2000\/svg".+xmlns="http:\/\/www\.w3\.org\/2000\/svg/gi;
+    if (xmlns.test(serialized)) {
+      serialized = serialized.replace(
+        'xmlns="http://www.w3.org/2000/svg',
+        'xmlns:xlink="http://www.w3.org/1999/xlink'
+      );
+    }
+
+    if (fixNamedEntities) {
+      keys = Object.keys(namedEntities);
+      //loop over each named entity and replace with the proper equivalent.
+      for (i = 0; i < keys.length; i++) {
+        key = keys[i];
+        value = namedEntities[key];
+        regexp = new RegExp(key, 'gi');
+        if (regexp.test(serialized)) {
+          serialized = serialized.replace(regexp, value);
+        }
+      }
+    }
+
+    return serialized;
+  };
+
+  /**
+   * Returns the root svg
+   * @return
+   */
+  ctx.prototype.getSvg = function() {
+    return this.__root;
+  };
+  /**
+   * Will generate a group tag.
+   */
+  ctx.prototype.save = function() {
+    var group = this.__createElement('g');
+    var parent = this.__closestGroupOrSvg();
+    this.__groupStack.push(parent);
+    parent.appendChild(group);
+    this.__currentElement = group;
+    this.__stack.push(this.__getStyleState());
+  };
+  /**
+   * Sets current element to parent, or just root if already root
+   */
+  ctx.prototype.restore = function() {
+    this.__currentElement = this.__groupStack.pop();
+    this.__currentElementsToStyle = null;
+    //Clearing canvas will make the poped group invalid, currentElement is set to the root group node.
+    if (!this.__currentElement) {
+      this.__currentElement = this.__root.childNodes[1];
+    }
+    var state = this.__stack.pop();
+    this.__applyStyleState(state);
+  };
+
+  /**
+   * Helper method to add transform
+   * @private
+   */
+  ctx.prototype.__addTransform = function(t) {
+    //if the current element has siblings, add another group
+    var parent = this.__closestGroupOrSvg();
+    if (parent.childNodes.length > 0) {
+      if (this.__currentElement.nodeName === 'path') {
+        if (!this.__currentElementsToStyle)
+          this.__currentElementsToStyle = { element: parent, children: [] };
+        this.__currentElementsToStyle.children.push(this.__currentElement);
+        this.__applyCurrentDefaultPath();
+      }
+
+      var group = this.__createElement('g');
+      parent.appendChild(group);
+      this.__currentElement = group;
+    }
+
+    var transform = this.__currentElement.getAttribute('transform');
+    if (transform) {
+      transform += ' ';
+    } else {
+      transform = '';
+    }
+    transform += t;
+    this.__currentElement.setAttribute('transform', transform);
+  };
+
+  /**
+   *  scales the current element
+   */
+  ctx.prototype.scale = function(x, y) {
+    if (y === undefined) {
+      y = x;
+    }
+    this.__addTransform(format('scale({x},{y})', { x: x, y: y }));
+  };
+
+  /**
+   * rotates the current element
+   */
+  ctx.prototype.rotate = function(angle) {
+    var degrees = (angle * 180) / Math.PI;
+    this.__addTransform(
+      format('rotate({angle},{cx},{cy})', { angle: degrees, cx: 0, cy: 0 })
+    );
+  };
+
+  /**
+   * translates the current element
+   */
+  ctx.prototype.translate = function(x, y) {
+    this.__addTransform(format('translate({x},{y})', { x: x, y: y }));
+  };
+
+  /**
+   * applies a transform to the current element
+   */
+  ctx.prototype.transform = function(a, b, c, d, e, f) {
+    this.__addTransform(
+      format('matrix({a},{b},{c},{d},{e},{f})', {
+        a: a,
+        b: b,
+        c: c,
+        d: d,
+        e: e,
+        f: f
+      })
+    );
+  };
+
+  /**
+   * Create a new Path Element
+   */
+  ctx.prototype.beginPath = function() {
+    var path, parent;
+
+    // Note that there is only one current default path, it is not part of the drawing state.
+    // See also: https://html.spec.whatwg.org/multipage/scripting.html#current-default-path
+    this.__currentDefaultPath = '';
+    this.__currentPosition = {};
+
+    path = this.__createElement('path', {}, true);
+    parent = this.__closestGroupOrSvg();
+    parent.appendChild(path);
+    this.__currentElement = path;
+  };
+
+  /**
+   * Helper function to apply currentDefaultPath to current path element
+   * @private
+   */
+  ctx.prototype.__applyCurrentDefaultPath = function() {
+    var currentElement = this.__currentElement;
+    if (currentElement.nodeName === 'path') {
+      currentElement.setAttribute('d', this.__currentDefaultPath);
+    } else {
+      console.error(
+        'Attempted to apply path command to node',
+        currentElement.nodeName
+      );
+    }
+  };
+
+  ctx.prototype.svgPath = function(text) {
+    this.__addPathCommand(text);
+  };
+
+  /**
+   * Helper function to add path command
+   * @private
+   */
+  ctx.prototype.__addPathCommand = function(command) {
+    this.__currentDefaultPath += ' ';
+    this.__currentDefaultPath += command;
+  };
+
+  /**
+   * Adds the move command to the current path element,
+   * if the currentPathElement is not empty create a new path element
+   */
+  ctx.prototype.moveTo = function(x, y) {
+    if (this.__currentElement.nodeName !== 'path') {
+      this.beginPath();
+    }
+
+    // creates a new subpath with the given point
+    this.__currentPosition = { x: x, y: y };
+    this.__addPathCommand(format('M {x} {y}', { x: x, y: y }));
+  };
+
+  /**
+   * Closes the current path
+   */
+  ctx.prototype.closePath = function() {
+    if (this.__currentDefaultPath) {
+      this.__addPathCommand('Z');
+    }
+  };
+
+  /**
+   * Adds a line to command
+   */
+  ctx.prototype.lineTo = function(x, y) {
+    this.__currentPosition = { x: x, y: y };
+    if (this.__currentDefaultPath.indexOf('M') > -1) {
+      this.__addPathCommand(format('L {x} {y}', { x: x, y: y }));
+    } else {
+      this.__addPathCommand(format('M {x} {y}', { x: x, y: y }));
+    }
+  };
+
+  /**
+   * Add a bezier command
+   */
+  ctx.prototype.bezierCurveTo = function(cp1x, cp1y, cp2x, cp2y, x, y) {
+    this.__currentPosition = { x: x, y: y };
+    this.__addPathCommand(
+      format('C {cp1x} {cp1y} {cp2x} {cp2y} {x} {y}', {
+        cp1x: cp1x,
+        cp1y: cp1y,
+        cp2x: cp2x,
+        cp2y: cp2y,
+        x: x,
+        y: y
+      })
+    );
+  };
+
+  /**
+   * Adds a quadratic curve to command
+   */
+  ctx.prototype.quadraticCurveTo = function(cpx, cpy, x, y) {
+    this.__currentPosition = { x: x, y: y };
+    this.__addPathCommand(
+      format('Q {cpx} {cpy} {x} {y}', { cpx: cpx, cpy: cpy, x: x, y: y })
+    );
+  };
+
+  /**
+   * Return a new normalized vector of given vector
+   */
+  var normalize = function(vector) {
+    var len = Math.sqrt(vector[0] * vector[0] + vector[1] * vector[1]);
+    return [vector[0] / len, vector[1] / len];
+  };
+
+  /**
+   * Adds the arcTo to the current path
+   *
+   * @see http://www.w3.org/TR/2015/WD-2dcontext-20150514/#dom-context-2d-arcto
+   */
+  ctx.prototype.arcTo = function(x1, y1, x2, y2, radius) {
+    // Let the point (x0, y0) be the last point in the subpath.
+    var x0 = this.__currentPosition && this.__currentPosition.x;
+    var y0 = this.__currentPosition && this.__currentPosition.y;
+
+    // First ensure there is a subpath for (x1, y1).
+    if (typeof x0 == 'undefined' || typeof y0 == 'undefined') {
+      return;
+    }
+
+    // Negative values for radius must cause the implementation to throw an IndexSizeError exception.
+    if (radius < 0) {
+      throw new Error(
+        'IndexSizeError: The radius provided (' + radius + ') is negative.'
+      );
+    }
+
+    // If the point (x0, y0) is equal to the point (x1, y1),
+    // or if the point (x1, y1) is equal to the point (x2, y2),
+    // or if the radius radius is zero,
+    // then the method must add the point (x1, y1) to the subpath,
+    // and connect that point to the previous point (x0, y0) by a straight line.
+    if ((x0 === x1 && y0 === y1) || (x1 === x2 && y1 === y2) || radius === 0) {
+      this.lineTo(x1, y1);
+      return;
+    }
+
+    // Otherwise, if the points (x0, y0), (x1, y1), and (x2, y2) all lie on a single straight line,
+    // then the method must add the point (x1, y1) to the subpath,
+    // and connect that point to the previous point (x0, y0) by a straight line.
+    var unit_vec_p1_p0 = normalize([x0 - x1, y0 - y1]);
+    var unit_vec_p1_p2 = normalize([x2 - x1, y2 - y1]);
+    if (
+      unit_vec_p1_p0[0] * unit_vec_p1_p2[1] ===
+      unit_vec_p1_p0[1] * unit_vec_p1_p2[0]
+    ) {
+      this.lineTo(x1, y1);
+      return;
+    }
+
+    // Otherwise, let The Arc be the shortest arc given by circumference of the circle that has radius radius,
+    // and that has one point tangent to the half-infinite line that crosses the point (x0, y0) and ends at the point (x1, y1),
+    // and that has a different point tangent to the half-infinite line that ends at the point (x1, y1), and crosses the point (x2, y2).
+    // The points at which this circle touches these two lines are called the start and end tangent points respectively.
+
+    // note that both vectors are unit vectors, so the length is 1
+    var cos =
+      unit_vec_p1_p0[0] * unit_vec_p1_p2[0] +
+      unit_vec_p1_p0[1] * unit_vec_p1_p2[1];
+    var theta = Math.acos(Math.abs(cos));
+
+    // Calculate origin
+    var unit_vec_p1_origin = normalize([
+      unit_vec_p1_p0[0] + unit_vec_p1_p2[0],
+      unit_vec_p1_p0[1] + unit_vec_p1_p2[1]
+    ]);
+    var len_p1_origin = radius / Math.sin(theta / 2);
+    var x = x1 + len_p1_origin * unit_vec_p1_origin[0];
+    var y = y1 + len_p1_origin * unit_vec_p1_origin[1];
+
+    // Calculate start angle and end angle
+    // rotate 90deg clockwise (note that y axis points to its down)
+    var unit_vec_origin_start_tangent = [-unit_vec_p1_p0[1], unit_vec_p1_p0[0]];
+    // rotate 90deg counter clockwise (note that y axis points to its down)
+    var unit_vec_origin_end_tangent = [unit_vec_p1_p2[1], -unit_vec_p1_p2[0]];
+    var getAngle = function(vector) {
+      // get angle (clockwise) between vector and (1, 0)
+      var x = vector[0];
+      var y = vector[1];
+      if (y >= 0) {
+        // note that y axis points to its down
+        return Math.acos(x);
+      } else {
+        return -Math.acos(x);
+      }
+    };
+    var startAngle = getAngle(unit_vec_origin_start_tangent);
+    var endAngle = getAngle(unit_vec_origin_end_tangent);
+
+    // Connect the point (x0, y0) to the start tangent point by a straight line
+    this.lineTo(
+      x + unit_vec_origin_start_tangent[0] * radius,
+      y + unit_vec_origin_start_tangent[1] * radius
+    );
+
+    // Connect the start tangent point to the end tangent point by arc
+    // and adding the end tangent point to the subpath.
+    this.arc(x, y, radius, startAngle, endAngle);
+  };
+
+  /**
+   * Sets the stroke property on the current element
+   */
+  ctx.prototype.stroke = function() {
+    if (this.__currentElement.nodeName === 'path') {
+      this.__currentElement.setAttribute('paint-order', 'fill stroke markers');
+    }
+    this.__applyCurrentDefaultPath();
+    this.__applyStyleToCurrentElement('stroke');
+  };
+
+  /**
+   * Sets fill properties on the current element
+   */
+  ctx.prototype.fill = function() {
+    if (this.__currentElement.nodeName === 'path') {
+      this.__currentElement.setAttribute('paint-order', 'stroke fill markers');
+    }
+    this.__applyCurrentDefaultPath();
+    this.__applyStyleToCurrentElement('fill');
+  };
+
+  /**
+   *  Adds a rectangle to the path.
+   */
+  ctx.prototype.rect = function(x, y, width, height) {
+    if (this.__currentElement.nodeName !== 'path') {
+      this.beginPath();
+    }
+    this.moveTo(x, y);
+    this.lineTo(x + width, y);
+    this.lineTo(x + width, y + height);
+    this.lineTo(x, y + height);
+    this.lineTo(x, y);
+    this.closePath();
+  };
+
+  /**
+   * adds a rectangle element
+   */
+  ctx.prototype.fillRect = function(x, y, width, height) {
+    var rect, parent;
+    rect = this.__createElement(
+      'rect',
+      {
+        x: x,
+        y: y,
+        width: width,
+        height: height
+      },
+      true
+    );
+    parent = this.__closestGroupOrSvg();
+    parent.appendChild(rect);
+    this.__currentElement = rect;
+    this.__applyStyleToCurrentElement('fill');
+  };
+
+  /**
+   * Draws a rectangle with no fill
+   * @param x
+   * @param y
+   * @param width
+   * @param height
+   */
+  ctx.prototype.strokeRect = function(x, y, width, height) {
+    var rect, parent;
+    rect = this.__createElement(
+      'rect',
+      {
+        x: x,
+        y: y,
+        width: width,
+        height: height
+      },
+      true
+    );
+    parent = this.__closestGroupOrSvg();
+    parent.appendChild(rect);
+    this.__currentElement = rect;
+    this.__applyStyleToCurrentElement('stroke');
+  };
+
+  /**
+   * Clear entire canvas:
+   * 1. save current transforms
+   * 2. remove all the childNodes of the root g element
+   */
+  ctx.prototype.__clearCanvas = function() {
+    var current = this.__closestGroupOrSvg(),
+      transform = current.getAttribute('transform');
+    var rootGroup = this.__root.childNodes[1];
+    var childNodes = rootGroup.childNodes;
+    for (var i = childNodes.length - 1; i >= 0; i--) {
+      if (childNodes[i]) {
+        rootGroup.removeChild(childNodes[i]);
+      }
+    }
+    this.__currentElement = rootGroup;
+    //reset __groupStack as all the child group nodes are all removed.
+    this.__groupStack = [];
+    if (transform) {
+      this.__addTransform(transform);
+    }
+  };
+
+  /**
+   * "Clears" a canvas by just drawing a white rectangle in the current group.
+   */
+  ctx.prototype.clearRect = function(x, y, width, height) {
+    //clear entire canvas
+    if (x === 0 && y === 0 && width === this.width && height === this.height) {
+      this.__clearCanvas();
+      return;
+    }
+    var rect,
+      parent = this.__closestGroupOrSvg();
+    rect = this.__createElement(
+      'rect',
+      {
+        x: x,
+        y: y,
+        width: width,
+        height: height,
+        fill: '#FFFFFF'
+      },
+      true
+    );
+    parent.appendChild(rect);
+  };
+
+  /**
+   * Adds a linear gradient to a defs tag.
+   * Returns a canvas gradient object that has a reference to it's parent def
+   */
+  ctx.prototype.createLinearGradient = function(x1, y1, x2, y2) {
+    var grad = this.__createElement(
+      'linearGradient',
+      {
+        id: randomString(this.__ids),
+        x1: x1 + 'px',
+        x2: x2 + 'px',
+        y1: y1 + 'px',
+        y2: y2 + 'px',
+        gradientUnits: 'userSpaceOnUse'
+      },
+      false
+    );
+    this.__defs.appendChild(grad);
+    return new CanvasGradient(grad, this);
+  };
+
+  /**
+   * Adds a radial gradient to a defs tag.
+   * Returns a canvas gradient object that has a reference to it's parent def
+   */
+  ctx.prototype.createRadialGradient = function(x0, y0, r0, x1, y1, r1) {
+    var grad = this.__createElement(
+      'radialGradient',
+      {
+        id: randomString(this.__ids),
+        cx: x1 + 'px',
+        cy: y1 + 'px',
+        r: r1 + 'px',
+        fx: x0 + 'px',
+        fy: y0 + 'px',
+        gradientUnits: 'userSpaceOnUse'
+      },
+      false
+    );
+    this.__defs.appendChild(grad);
+    return new CanvasGradient(grad, this);
+  };
+
+  /**
+   * Parses the font string and returns svg mapping
+   * @private
+   */
+  ctx.prototype.__parseFont = function() {
+    var fontPart = this.font.split(' ') || [];
+    if (fontPart[3] && fontPart[3].indexOf('/') > -1) {
+      fontPart[3] = fontPart[3].split('/')[0];
+    }
+    var family = '';
+    for (var i = 4; i < fontPart.length; ++i) {
+      family += fontPart[i] + ' ';
+    }
+
+    if (fontPart.length === 2) {
+      family = fontPart[1];
+      fontPart = ['normal', 'normal', 'normal', fontPart[0]];
+    }
+
+    var data = {
+      style: fontPart[0] || 'normal',
+      size: fontPart[3] || '12px',
+      family: family || 'Arial',
+      weight: fontPart[2] || 'normal',
+      decoration: fontPart[1] || 'normal',
+      href: null
+    };
+
+    //canvas doesn't support underline natively, but we can pass this attribute
+    if (this.__fontUnderline === 'underline') {
+      data.decoration = 'underline';
+    }
+
+    //canvas also doesn't support linking, but we can pass this as well
+    if (this.__fontHref) {
+      data.href = this.__fontHref;
+    }
+
+    return data;
+  };
+
+  /**
+   * Helper to link text fragments
+   * @param font
+   * @param element
+   * @return {*}
+   * @private
+   */
+  ctx.prototype.__wrapTextLink = function(font, element) {
+    if (font.href) {
+      var a = this.__createElement('a');
+      a.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', font.href);
+      a.appendChild(element);
+      return a;
+    }
+    return element;
+  };
+
+  /**
+   * Fills or strokes text
+   * @param text
+   * @param x
+   * @param y
+   * @param action - stroke or fill
+   * @private
+   */
+  ctx.prototype.__applyText = function(text, x, y, action) {
+    var font = this.__parseFont(),
+      parent = this.__closestGroupOrSvg(),
+      textElement = this.__createElement(
+        'text',
+        {
+          'font-family': font.family,
+          'font-size': font.size,
+          'font-style': font.style,
+          'font-weight': font.weight,
+          'text-decoration': font.decoration,
+          x: x,
+          y: y,
+          'text-anchor': getTextAnchor(this.textAlign),
+          'dominant-baseline': getDominantBaseline(this.textBaseline)
+        },
+        true
+      );
+
+    if (font.family === 'topology') {
+      text = '--le5le--' + (+text.charCodeAt()).toString(16) + ';';
+    }
+    textElement.appendChild(this.__document.createTextNode(text));
+    this.__currentElement = textElement;
+    this.__applyStyleToCurrentElement(action);
+    parent.appendChild(this.__wrapTextLink(font, textElement));
+  };
+
+  /**
+   * Creates a text element
+   * @param text
+   * @param x
+   * @param y
+   */
+  ctx.prototype.fillText = function(text, x, y) {
+    this.__applyText(text, x, y, 'fill');
+  };
+
+  /**
+   * Strokes text
+   * @param text
+   * @param x
+   * @param y
+   */
+  ctx.prototype.strokeText = function(text, x, y) {
+    this.__applyText(text, x, y, 'stroke');
+  };
+
+  /**
+   * No need to implement this for svg.
+   * @param text
+   * @return {TextMetrics}
+   */
+  ctx.prototype.measureText = function(text) {
+    this.__ctx.font = this.font;
+    return this.__ctx.measureText(text);
+  };
+
+  /**
+   *  Arc command!
+   */
+  ctx.prototype.arc = function(
+    x,
+    y,
+    radius,
+    startAngle,
+    endAngle,
+    counterClockwise
+  ) {
+    // in canvas no circle is drawn if no angle is provided.
+    if (startAngle === endAngle) {
+      return;
+    }
+
+    if (this.__currentElement.nodeName !== 'path') {
+      this.beginPath();
+    }
+
+    startAngle = startAngle % (2 * Math.PI);
+    endAngle = endAngle % (2 * Math.PI);
+    if (startAngle === endAngle) {
+      //circle time! subtract some of the angle so svg is happy (svg elliptical arc can't draw a full circle)
+      endAngle =
+        (endAngle + 2 * Math.PI - 0.001 * (counterClockwise ? -1 : 1)) %
+        (2 * Math.PI);
+    }
+    var endX = x + radius * Math.cos(endAngle),
+      endY = y + radius * Math.sin(endAngle),
+      startX = x + radius * Math.cos(startAngle),
+      startY = y + radius * Math.sin(startAngle),
+      sweepFlag = counterClockwise ? 0 : 1,
+      largeArcFlag = 0,
+      diff = endAngle - startAngle;
+
+    // https://github.com/gliffy/canvas2svg/issues/4
+    if (diff < 0) {
+      diff += 2 * Math.PI;
+    }
+
+    if (counterClockwise) {
+      largeArcFlag = diff > Math.PI ? 0 : 1;
+    } else {
+      largeArcFlag = diff > Math.PI ? 1 : 0;
+    }
+
+    this.lineTo(startX, startY);
+    this.__addPathCommand(
+      format(
+        'A {rx} {ry} {xAxisRotation} {largeArcFlag} {sweepFlag} {endX} {endY}',
+        {
+          rx: radius,
+          ry: radius,
+          xAxisRotation: 0,
+          largeArcFlag: largeArcFlag,
+          sweepFlag: sweepFlag,
+          endX: endX,
+          endY: endY
+        }
+      )
+    );
+
+    this.__currentPosition = { x: endX, y: endY };
+  };
+
+  /**
+   * Generates a ClipPath from the clip command.
+   */
+  ctx.prototype.clip = function() {
+    var group = this.__closestGroupOrSvg(),
+      clipPath = this.__createElement('clipPath'),
+      id = randomString(this.__ids),
+      newGroup = this.__createElement('g');
+
+    this.__applyCurrentDefaultPath();
+    group.removeChild(this.__currentElement);
+    clipPath.setAttribute('id', id);
+    clipPath.appendChild(this.__currentElement);
+
+    this.__defs.appendChild(clipPath);
+
+    //set the clip path to this group
+    group.setAttribute('clip-path', format('url(#{id})', { id: id }));
+
+    //clip paths can be scaled and transformed, we need to add another wrapper group to avoid later transformations
+    // to this path
+    group.appendChild(newGroup);
+
+    this.__currentElement = newGroup;
+  };
+
+  /**
+   * Draws a canvas, image or mock context to this canvas.
+   * Note that all svg dom manipulation uses node.childNodes rather than node.children for IE support.
+   * http://www.whatwg.org/specs/web-apps/current-work/multipage/the-canvas-element.html#dom-context-2d-drawimage
+   */
+  ctx.prototype.drawImage = function() {
+    //convert arguments to a real array
+    var args = Array.prototype.slice.call(arguments),
+      image = args[0],
+      dx,
+      dy,
+      dw,
+      dh,
+      sx = 0,
+      sy = 0,
+      sw,
+      sh,
+      parent,
+      svg,
+      defs,
+      group,
+      currentElement,
+      svgImage,
+      canvas,
+      context,
+      id;
+
+    if (args.length === 3) {
+      dx = args[1];
+      dy = args[2];
+      sw = image.width;
+      sh = image.height;
+      dw = sw;
+      dh = sh;
+    } else if (args.length === 5) {
+      dx = args[1];
+      dy = args[2];
+      dw = args[3];
+      dh = args[4];
+      sw = image.width;
+      sh = image.height;
+    } else if (args.length === 9) {
+      sx = args[1];
+      sy = args[2];
+      sw = args[3];
+      sh = args[4];
+      dx = args[5];
+      dy = args[6];
+      dw = args[7];
+      dh = args[8];
+    } else {
+      throw new Error(
+        'Invalid number of arguments passed to drawImage: ' + arguments.length
+      );
+    }
+
+    parent = this.__closestGroupOrSvg();
+    currentElement = this.__currentElement;
+    var translateDirective = 'translate(' + dx + ', ' + dy + ')';
+    if (image instanceof ctx) {
+      //canvas2svg mock canvas context. In the future we may want to clone nodes instead.
+      //also I'm currently ignoring dw, dh, sw, sh, sx, sy for a mock context.
+      svg = image.getSvg().cloneNode(true);
+      if (svg.childNodes && svg.childNodes.length > 1) {
+        defs = svg.childNodes[0];
+        while (defs.childNodes.length) {
+          id = defs.childNodes[0].getAttribute('id');
+          this.__ids[id] = id;
+          this.__defs.appendChild(defs.childNodes[0]);
+        }
+        group = svg.childNodes[1];
+        if (group) {
+          //save original transform
+          var originTransform = group.getAttribute('transform');
+          var transformDirective;
+          if (originTransform) {
+            transformDirective = originTransform + ' ' + translateDirective;
+          } else {
+            transformDirective = translateDirective;
+          }
+          group.setAttribute('transform', transformDirective);
+          parent.appendChild(group);
+        }
+      }
+    } else if (image.nodeName === 'CANVAS' || image.nodeName === 'IMG') {
+      //canvas or image
+      svgImage = this.__createElement('image');
+      svgImage.setAttribute('width', dw);
+      svgImage.setAttribute('height', dh);
+      svgImage.setAttribute('preserveAspectRatio', 'none');
+
+      if (sx || sy || sw !== image.width || sh !== image.height) {
+        //crop the image using a temporary canvas
+        canvas = this.__document.createElement('canvas');
+        canvas.width = dw;
+        canvas.height = dh;
+        context = canvas.getContext('2d');
+        context.drawImage(image, sx, sy, sw, sh, 0, 0, dw, dh);
+        image = canvas;
+      }
+      svgImage.setAttribute('transform', translateDirective);
+      var imgSrc = image.getAttribute('src');
+      if (imgSrc[0] === '/') {
+        imgSrc = location.protocol + '//' + location.host + imgSrc;
+      }
+      svgImage.setAttributeNS(
+        'http://www.w3.org/1999/xlink',
+        'xlink:href',
+        image.nodeName === 'CANVAS' ? image.toDataURL() : imgSrc
+      );
+      parent.appendChild(svgImage);
+    }
+  };
+
+  /**
+   * Generates a pattern tag
+   */
+  ctx.prototype.createPattern = function(image, repetition) {
+    var pattern = this.__document.createElementNS(
+        'http://www.w3.org/2000/svg',
+        'pattern'
+      ),
+      id = randomString(this.__ids),
+      img;
+    pattern.setAttribute('id', id);
+    pattern.setAttribute('width', image.width);
+    pattern.setAttribute('height', image.height);
+    if (image.nodeName === 'CANVAS' || image.nodeName === 'IMG') {
+      img = this.__document.createElementNS(
+        'http://www.w3.org/2000/svg',
+        'image'
+      );
+      img.setAttribute('width', image.width);
+      img.setAttribute('height', image.height);
+      img.setAttributeNS(
+        'http://www.w3.org/1999/xlink',
+        'xlink:href',
+        image.nodeName === 'CANVAS'
+          ? image.toDataURL()
+          : image.getAttribute('src')
+      );
+      pattern.appendChild(img);
+      this.__defs.appendChild(pattern);
+    } else if (image instanceof ctx) {
+      pattern.appendChild(image.__root.childNodes[1]);
+      this.__defs.appendChild(pattern);
+    }
+    return new CanvasPattern(pattern, this);
+  };
+
+  ctx.prototype.setLineDash = function(dashArray) {
+    if (dashArray && dashArray.length > 0) {
+      this.lineDash = dashArray.join(',');
+    } else {
+      this.lineDash = null;
+    }
+  };
+
+  /*
+   * Ellipse command
+   * @param x
+   * @param y
+   * @param radiusX
+   * @param radiusY
+   * @param startAngle
+   * @param endAngle
+   * @counterClockwise
+   */
+  ctx.prototype.ellipse = function(
+    x,
+    y,
+    radiusX,
+    radiusY,
+    rotation,
+    startAngle,
+    endAngle,
+    counterClockwise
+  ) {
+    //ellipse is the same svg command as arc, but with a radiusX and radiusY instead of just radius
+    if (startAngle === endAngle) {
+      return;
+    }
+
+    if (this.__currentElement.nodeName !== 'path') {
+      this.beginPath();
+    }
+
+    startAngle = startAngle % (2 * Math.PI);
+    endAngle = endAngle % (2 * Math.PI);
+    if (startAngle === endAngle) {
+      endAngle =
+        (endAngle + 2 * Math.PI - 0.001 * (counterClockwise ? -1 : 1)) %
+        (2 * Math.PI);
+    }
+    var endX =
+        x +
+        Math.cos(-rotation) * radiusX * Math.cos(endAngle) +
+        Math.sin(-rotation) * radiusY * Math.sin(endAngle),
+      endY =
+        y -
+        Math.sin(-rotation) * radiusX * Math.cos(endAngle) +
+        Math.cos(-rotation) * radiusY * Math.sin(endAngle),
+      startX =
+        x +
+        Math.cos(-rotation) * radiusX * Math.cos(startAngle) +
+        Math.sin(-rotation) * radiusY * Math.sin(startAngle),
+      startY =
+        y -
+        Math.sin(-rotation) * radiusX * Math.cos(startAngle) +
+        Math.cos(-rotation) * radiusY * Math.sin(startAngle),
+      sweepFlag = counterClockwise ? 0 : 1,
+      largeArcFlag = 0,
+      diff = endAngle - startAngle;
+
+    if (diff < 0) {
+      diff += 2 * Math.PI;
+    }
+
+    if (counterClockwise) {
+      largeArcFlag = diff > Math.PI ? 0 : 1;
+    } else {
+      largeArcFlag = diff > Math.PI ? 1 : 0;
+    }
+
+    this.lineTo(startX, startY);
+    this.__addPathCommand(
+      format(
+        'A {rx} {ry} {xAxisRotation} {largeArcFlag} {sweepFlag} {endX} {endY}',
+        {
+          rx: radiusX,
+          ry: radiusY,
+          xAxisRotation: rotation * (180 / Math.PI),
+          largeArcFlag: largeArcFlag,
+          sweepFlag: sweepFlag,
+          endX: endX,
+          endY: endY
+        }
+      )
+    );
+
+    this.__currentPosition = { x: endX, y: endY };
+  };
+
+  /**
+   * Not yet implemented
+   */
+  ctx.prototype.drawFocusRing = function() {};
+  ctx.prototype.createImageData = function() {};
+  ctx.prototype.getImageData = function() {};
+  ctx.prototype.putImageData = function() {};
+  ctx.prototype.globalCompositeOperation = function() {};
+  ctx.prototype.setTransform = function() {};
+
+  //add options for alternative namespace
+  if (typeof window === 'object') {
+    window.C2S = ctx;
+  }
+
+  // CommonJS/Browserify
+  if (typeof module === 'object' && typeof module.exports === 'object') {
+    module.exports = ctx;
+  }
+})();

+ 9 - 0
src/global.d.ts

@@ -1,3 +1,12 @@
+import { Meta2d } from "@meta2d/core";
+
+declare global {
+  var meta2d: Meta2d;
+  var C2S: any;
+  var folderJson: any;
+  var fileJson: any;
+}
+
 declare interface Window {
   company: string;
   accountHome: string;

+ 4 - 45
src/http.ts

@@ -7,56 +7,12 @@ import router from './router';
 axios.defaults.timeout = 60000;
 axios.defaults.withCredentials = false;
 
-const requestDebounceMap = new Map();
-const requestThrottleSet = new Set();
-
 // http request 拦截器
 axios.interceptors.request.use(
   (config: any) => {
-    config.baseURL = '/api';
     config.headers.Authorization =
       'Bearer ' + (localStorage.token || getCookie('token') || '');
 
-    if (config.params) {
-      // 防抖, 比如输入搜索
-      if (config.params.debounce > 0) {
-        const url = config.method + config.url;
-
-        const cache: any = requestDebounceMap.get(url);
-        if (cache) {
-          clearTimeout(cache.timer);
-          cache.reject();
-        }
-
-        delete config.params.debounce;
-        delete config.params.throttle;
-
-        return new Promise((resolve, reject) => {
-          const newCache: any = { reject };
-          newCache.timer = setTimeout(() => {
-            requestDebounceMap.delete(url);
-            resolve(config);
-          }, config.params.debounce);
-          requestDebounceMap.set(url, newCache);
-        });
-      }
-      // 节流,避免短时间重复请求,比如点击确定
-      else if (config.params.throttle > 0) {
-        const url = config.method + config.url;
-        // 已经存在,取消重复请求
-        if (requestThrottleSet.has(url)) {
-          return Promise.reject('Repeated request.');
-        }
-        requestThrottleSet.add(url);
-        setTimeout(() => {
-          requestThrottleSet.delete(url);
-        }, config.params.throttle);
-      }
-
-      delete config.params.debounce;
-      delete config.params.throttle;
-    }
-
     return config;
   },
   (err: any) => Promise.reject(err)
@@ -70,6 +26,9 @@ axios.interceptors.response.use(
       return;
     }
     if (response) {
+      if (response.status == undefined) {
+        return response;
+      }
       return response.data;
     }
     return;
@@ -79,7 +38,7 @@ axios.interceptors.response.use(
       return;
     }
     if (error && error.response) {
-      if (error.response.config.url === '/account/profile') {
+      if (error.response.config.url === '/api/account/profile') {
         return;
       }
 

+ 5 - 2
src/router/index.ts

@@ -1,6 +1,9 @@
-import { createRouter, createWebHistory } from 'vue-router';
+import { createRouter, createWebHistory } from "vue-router";
 
-const routes = [{ path: '/', component: () => import('@/views/Index.vue') }];
+const routes = [
+  { path: "/", component: () => import("@/views/Index.vue") },
+  { path: "/preview", component: () => import("@/views/Preview.vue") },
+];
 
 const router = createRouter({
   history: createWebHistory(import.meta.env.VITE_ROUTER_BASE),

+ 63 - 0
src/services/api.ts

@@ -0,0 +1,63 @@
+//所有的接口请求
+import axios from 'axios';
+export const cdn = '';
+// import.meta.env.VITE_ROUTER_BASE
+// ? ""
+// : "https://assets.le5lecdn.com";
+
+export const upCdn = import.meta.env.VITE_ROUTER_BASE
+  ? ''
+  : 'https://drive.le5lecdn.com';
+
+export async function delImage(image: string) {
+  if (image.startsWith(upCdn)) {
+    await axios.delete('/file' + image.replace(upCdn, ''));
+  } else {
+    await axios.delete(`${image}`);
+  }
+  return true;
+}
+
+export async function getFolders(query: any) {
+  const folder: any = await axios.post('/api/data/folders/get', {
+    query,
+  });
+  if (folder.error) {
+    return;
+  } else {
+    return folder;
+  }
+}
+
+export async function updateFolders(data: any) {
+  const folder: any = await axios.post('/api/data/folders/update', data);
+  if (folder.error) {
+    return;
+  } else {
+    return folder;
+  }
+}
+
+export async function addCollection(collection: string, data: any) {
+  return await axios.post(`/api/data/${collection}/add`, data);
+}
+
+export async function updateCollection(collection: string, data: any) {
+  return await axios.post(`/api/data/${collection}/update`, data);
+}
+
+export async function getLe5leV(id: string) {
+  return await axios.post('/api/data/le5leV/get', {
+    id,
+  });
+}
+
+export async function getComponents(id: string) {
+  return await axios.post(`/api/data/le5leV-components/get`, {
+    id,
+  });
+}
+
+export async function getComponentsList(data: any, config: any) {
+  return await axios.post('/api/data/le5leV-components/list', data, config);
+}

+ 438 - 0
src/services/common.ts

@@ -0,0 +1,438 @@
+import { reactive, ref } from 'vue';
+import router from '@/router/index';
+import { useUser } from '@/services/user';
+import { showNotification, Meta2dBackData, checkData } from '@/services/utils';
+import { MessagePlugin } from 'tdesign-vue-next';
+import localforage from 'localforage';
+import dayjs from 'dayjs';
+import { noLoginTip, localStorageName } from '@/services/utils';
+import { upload, dataURLtoBlob } from '@/services/file';
+import { delImage, addCollection, updateCollection } from '@/services/api';
+import { baseVer } from '@/services/upgrade';
+
+const dot = ref(false);
+
+export const useDot = () => {
+  const getDot = async () => {
+    return dot;
+  };
+  const setDot = async (value: boolean) => {
+    dot.value = value;
+    if (value) {
+      tree.patch = true;
+    }
+  };
+
+  return {
+    dot,
+    getDot,
+    setDot,
+  };
+};
+
+// import { noLoginTip } from "@/services/utils";
+
+const { user } = useUser();
+// const route = useRoute();
+// const router = useRouter();
+
+export enum SaveType {
+  Save,
+  SaveAs,
+}
+export const save = async (
+  type: SaveType = SaveType.Save,
+  component?: boolean,
+  notice?: boolean
+) => {
+  meta2d.stopAnimate();
+  const data: Meta2dBackData = meta2d.data();
+  if (!(user && user.id)) {
+    MessagePlugin.warning(noLoginTip);
+    localforage.setItem(localStorageName, JSON.stringify(data));
+    return;
+  }
+  checkData(data);
+  if (!data._id && router.currentRoute.value.query.id) {
+    data._id = router.currentRoute.value.query.id as string;
+  }
+
+  if (
+    (globalThis as any).beforeSaveMeta2d &&
+    !(await (globalThis as any).beforeSaveMeta2d(data))
+  ) {
+    return;
+  }
+  if (type === SaveType.SaveAs) {
+    //另存为去掉teams信息
+    delete data.teams;
+  }
+  //如果不是自己创建的团队图纸,就不去修改缩略图(没有权限去删除缩略图)
+  if (!(data.teams && data.owner?.id !== user.id)) {
+    let blob: Blob;
+    try {
+      blob = dataURLtoBlob(meta2d.toPng(10) + '');
+    } catch (e) {
+      MessagePlugin.error(
+        '无法下载,宽度不合法,画布可能没有画笔/画布大小超出浏览器最大限制'
+      );
+      return;
+    }
+    if (data._id && type === SaveType.Save) {
+      if (data.image && !(await delImage(data.image))) {
+        return;
+      }
+    }
+
+    const file = await upload(blob, true);
+    if (!file) {
+      return;
+    }
+
+    // 缩略图
+    data.image = file.url;
+    (meta2d.store.data as Meta2dBackData).image = data.image;
+  }
+
+  if (data.component || component) {
+    data.component = true;
+    // pens 存储原数据用于二次编辑 ; componentDatas 组合后的数据,用于复用
+    data.componentDatas = meta2d.toComponent(
+      undefined,
+      (meta2d.store.data as Meta2dBackData).showChild,
+      false //自定义组合节点生成默认锚点
+    );
+  } else {
+    data.component = false; // 必要值
+  }
+  let collection = data.component ? 'le5leV-components' : 'le5leV';
+  let ret: any;
+  if (!data.name) {
+    // 文件名称
+    data.name = `meta2d.${new Date().toLocaleString()}`;
+    (meta2d.store.data as Meta2dBackData).name = data.name;
+  }
+  !data.version && (data.version = baseVer);
+  if (!data.folder) {
+    data.folder = '大屏';
+  }
+  if (type === SaveType.SaveAs) {
+    // 另存为一定走 新增 ,由于后端 未控制 userId 等属性,清空一下
+    const delAttrs = [
+      'userId',
+      '_id',
+      'id',
+      'shared',
+      'star',
+      'view',
+      'username',
+      'editorName',
+      'editorId',
+      'createdAt',
+      'updatedAt',
+      'recommend',
+      'team',
+    ];
+    for (const k of delAttrs) {
+      delete (data as any)[k];
+    }
+    ret = await addCollection(collection, data);
+  } else {
+    if (data._id && data.teams && data.owner?.id !== user.id) {
+      // 团队图纸 不允许修改文件夹信息
+      delete data.folder;
+      ret = await updateCollection(collection, data);
+    } else if (data._id) {
+      ret = await updateCollection(collection, data);
+    } else {
+      ret = await addCollection(collection, data); // 新增
+    }
+  }
+
+  if (ret.error) {
+    return;
+  }
+
+  //  保存图纸之后的钩子函数
+  globalThis.afterSaveMeta2d && (await globalThis.afterSaveMeta2d(ret));
+  if (
+    !data._id ||
+    data.owner?.id !== user.id ||
+    router.currentRoute.value.query.version ||
+    type === SaveType.SaveAs // 另存为肯定走新增,也会产生新的 id
+  ) {
+    data._id = ret._id;
+    (meta2d.store.data as Meta2dBackData)._id = data._id;
+    router.replace({
+      path: '/',
+      query: {
+        id: data._id,
+        r: Date.now() + '',
+        component: data.component + '',
+      },
+    });
+  }
+
+  notice && MessagePlugin.success('保存成功!');
+  dot.value = false;
+  localforage.removeItem(localStorageName);
+  return true;
+};
+
+const pen = ref(false);
+export const drawPen = () => {
+  meta2d.inactive();
+  try {
+    if (!meta2d.canvas.drawingLineName) {
+      // 开了钢笔,需要关掉铅笔
+      meta2d.canvas.pencil && drawingPencil();
+      meta2d.drawLine(meta2d.store.options.drawingLineName);
+    } else {
+      meta2d.finishDrawLine();
+      meta2d.drawLine();
+    }
+    //钢笔
+    pen.value = !!meta2d.canvas.drawingLineName;
+  } catch (e: any) {
+    MessagePlugin.warning(e.message);
+  }
+};
+
+const pencil = ref(false);
+const drawingPencil = () => {
+  try {
+    if (!meta2d.canvas.pencil) {
+      // 开了铅笔需要关掉钢笔
+      meta2d.canvas.drawingLineName && drawPen();
+      meta2d.drawingPencil();
+    } else {
+      meta2d.stopPencil();
+    }
+    pencil.value = meta2d.canvas.pencil || false;
+  } catch (e: any) {
+    MessagePlugin.warning(e.message);
+  }
+};
+
+export const magnifier = ref(false);
+export const showMagnifier = () => {
+  if (!meta2d.canvas.magnifierCanvas.magnifier) {
+    meta2d.showMagnifier();
+  } else {
+    meta2d.hideMagnifier();
+  }
+  magnifier.value = meta2d.canvas.magnifierCanvas.magnifier;
+};
+
+export const map = ref(false);
+export const showMap = () => {
+  if (!meta2d.map?.isShow) {
+    meta2d.showMap();
+  } else {
+    meta2d.hideMap();
+  }
+  map.value = meta2d.map?.isShow;
+};
+
+export const title = '系统可能不会保存您所做的更改,是否继续?';
+export const unLogin = '未登录,系统可能不会保存您的文件,是否继续?';
+export const unsave = '当前文件未保存,是否继续?(开通vip可享受自动保存服务)';
+
+//未登录,当前文件可能不会保存
+//
+export const newFile = async () => {
+  autoSave();
+  setTimeout(() => {
+    newfile();
+  }, 1000);
+};
+
+export function autoSave() {
+  if (!dot.value) {
+    return;
+  }
+  const data: any = meta2d.data();
+  if (
+    user &&
+    user.id &&
+    user.isVip &&
+    data._id &&
+    !data.component &&
+    data.owner &&
+    data.owner.id === user.id
+  ) {
+    save(SaveType.Save);
+  } else {
+    data.updateAt = dayjs().format();
+    localforage.setItem(localStorageName, JSON.stringify(data));
+  }
+}
+
+export const notificFn = async (fn: Function, params: any) => {
+  if (!(user && user.id)) {
+    if (await showNotification(unLogin)) {
+      fn(params);
+    }
+  } else {
+    if (dot.value) {
+      if (user.isVip) {
+        if (await save(SaveType.Save)) {
+          fn(params);
+        }
+      } else {
+        if (await showNotification(unsave)) {
+          // newfile(false);
+          fn(params);
+        }
+      }
+    } else {
+      // newfile(false);
+      fn(params);
+    }
+  }
+};
+
+export const onScaleWindow = () => {
+  // meta2d.fitView();
+  meta2d.fitSizeView(true, 32);
+};
+
+export const onScaleView = () => {
+  meta2d.scale(1);
+  // meta2d.centerView();
+  const { x, y, origin, center } = meta2d.store.data;
+
+  meta2d.translate(-x - origin.x, -y - origin.y);
+  meta2d.translate(meta2d.store.options.x || 0, meta2d.store.options.y || 0);
+};
+
+export const newfile = async (noRouter: boolean = false) => {
+  meta2d.canvas.drawingLineName && drawPen();
+  meta2d.canvas.pencil && drawingPencil();
+  meta2d.canvas.magnifierCanvas.magnifier && showMagnifier();
+  meta2d.map?.isShow && showMap();
+  dot.value = false;
+  await localforage.removeItem(localStorageName);
+  // 打开文件操作不跳转
+  // const router = useRouter();
+  !noRouter &&
+    router.replace({
+      path: '/',
+      query: { r: Date.now() + '' },
+    });
+};
+
+const tree = reactive({
+  list: [],
+  patch: true,
+});
+
+export const getPenTree = () => {
+  if (tree.patch) {
+    tree.patch = false;
+    const list = [];
+    for (const item of meta2d.store.data.pens) {
+      if (item.parentId) {
+        continue;
+      }
+      const elem = calcElem(item);
+      elem && list.push(elem);
+    }
+    tree.list = list;
+  }
+
+  return tree.list;
+};
+
+export const getPenAnimations = (idOrTag?: string) => {
+  const animations = [];
+  let pens: any[] = meta2d.store.active || [];
+  if (idOrTag) {
+    pens = meta2d.find(idOrTag) || [];
+  }
+
+  for (const pen of pens) {
+    if (pen.animations) {
+      for (const a of pen.animations) {
+        animations.push(a.name);
+      }
+    }
+  }
+
+  return Array.from(new Set(animations));
+};
+
+const calcElem = (node: any) => {
+  if (!node) {
+    return;
+  }
+
+  const elem: any = {
+    label: (node as any).description || node.name,
+    value: node.id,
+    locked: node.locked,
+    visible: node.visible,
+  };
+
+  if (!node.children) {
+    return elem;
+  }
+
+  elem.children = [];
+  for (const id of node.children) {
+    const child = calcElem(meta2d.store.pens[id]);
+    child && elem.children.push(child);
+  }
+
+  return elem;
+};
+
+export const setChildrenVisible = (node: any, v: boolean) => {
+  const children = node.getChildren();
+  if (children && children.length > 0) {
+    for (const child of children) {
+      child.data.visible = v;
+      setChildrenVisible(child, v);
+    }
+  }
+};
+
+export const fonts = [
+  '新宋体',
+  '微软雅黑',
+  '黑体',
+  '楷体',
+  '-apple-system',
+  'BlinkMacSystemFont',
+  'PingFang SC',
+  'Hiragino Sans GB',
+  'Microsoft YaHei UI',
+  'Microsoft YaHei',
+  'fangsong',
+  'Source Han Sans CN',
+  'sans-serif',
+  'serif',
+  'Apple Color Emoji',
+  'Segoe UI Emoji',
+  'Segoe UI Symbol',
+];
+
+export const delAttrs = [
+  'userId',
+  'shared',
+  'team',
+  'owner',
+  'username',
+  'editor',
+  'editorId',
+  'editorName',
+  'createdAt',
+  'folder',
+  'image',
+  'id',
+  '_id',
+  'view',
+  'updatedAt',
+  'star',
+  'recommend',
+];

+ 29 - 0
src/services/debouce.ts

@@ -0,0 +1,29 @@
+const debounces = new WeakMap();
+const throttles = new WeakMap();
+
+export function debounce(fn: Function, delay: number) {
+  let cache: any = debounces.get(fn);
+  if (cache) {
+    clearTimeout(cache.timer);
+  } else {
+    cache = {};
+    debounces.set(fn, cache);
+  }
+  return new Promise((resolve, reject) => {
+    cache.timer = setTimeout(async () => {
+      resolve(await fn());
+      debounces.delete(fn);
+    }, delay);
+  });
+}
+
+export async function throttle(fn: Function, delay: number) {
+  const now = new Date().getTime();
+  const start: number = debounces.get(fn);
+  throttles.set(fn, now);
+  if (start && now - start < delay) {
+    return;
+  }
+
+  return await fn();
+}

+ 2807 - 0
src/services/defaults.ts

@@ -0,0 +1,2807 @@
+import { Pen, FormItem } from '@meta2d/core';
+import { ReplaceMode } from '@meta2d/chart-diagram';
+import { cdn } from './api';
+
+export const lineDashObj = [
+  undefined,
+  [5, 5],
+  [10, 10],
+  [10, 10, 2, 10],
+  [1, 16],
+];
+
+/**
+ * 默认动画
+ */
+export const normalAnimate: any = {
+  upDown: [
+    {
+      y: -10,
+      duration: 100,
+    },
+    { y: 0, duration: 100 },
+    { y: -10, duration: 200 },
+  ],
+  leftRight: [
+    {
+      x: -10,
+      duration: 100,
+    },
+    {
+      x: 10,
+      duration: 80,
+    },
+    {
+      x: -10,
+      duration: 50,
+    },
+    {
+      x: 10,
+      duration: 30,
+    },
+    {
+      x: 0,
+      duration: 300,
+    },
+  ],
+  heart: [
+    {
+      // 通过 scale 来替代原版心跳
+      scale: 1.1,
+      duration: 100,
+    },
+    {
+      scale: 1,
+      duration: 400,
+    },
+  ],
+  success: [{ background: '#389e0d22', color: '#237804', duration: 500 }],
+  warning: [
+    {
+      color: '#fa8c16',
+      lineDash: [10, 10],
+      duration: 300,
+    },
+    {
+      color: '#fa8c16',
+      lineDash: undefined,
+      duration: 500,
+    },
+    {
+      color: '#fa8c16',
+      lineDash: [10, 10],
+      duration: 300,
+    },
+  ],
+  error: [{ color: '#cf1322', background: '#cf132222', duration: 100 }],
+  show: [
+    {
+      color: '#fa541c',
+      rotate: -10,
+      duration: 100,
+    },
+    {
+      color: '#fa541c',
+      rotate: 10,
+      duration: 100,
+    },
+    {
+      color: '#fa541c',
+      rotate: 0,
+      duration: 100,
+    },
+  ],
+  rotate: [
+    {
+      rotate: 360,
+      duration: 1000,
+    },
+  ],
+};
+
+export const defaultFormat: Pen = {
+  borderRadius: 0,
+  rotate: 0,
+  paddingLeft: 0,
+  paddingRight: 0,
+  paddingTop: 0,
+  paddingBottom: 0,
+  progress: 0,
+  progressColor: '#fff0',
+  verticalProgress: false,
+  flipX: false,
+  flipY: false,
+  input: false,
+  lineDash: [],
+  lineCap: 'round',
+  lineJoin: 'round',
+  strokeType: 0,
+  lineGradientFromColor: undefined,
+  lineGradientToColor: undefined,
+  lineGradientAngle: 0,
+  // color: "#222",
+  hoverColor: undefined,
+  activeColor: undefined,
+  lineWidth: 0,
+  bkType: 0,
+  gradientFromColor: undefined,
+  gradientToColor: undefined,
+  gradientAngle: 0,
+  gradientRadius: 0,
+  hoverBackground: undefined,
+  activeBackground: undefined,
+  globalAlpha: 1,
+  anchorColor: undefined,
+  anchorRadius: 0,
+  shadowColor: undefined,
+  shadowBlur: 0,
+  shadowOffsetX: 0,
+  shadowOffsetY: 0,
+  textHasShadow: undefined,
+  fontFamily: undefined,
+  fontSize: 0,
+  textColor: undefined,
+  hoverTextColor: undefined,
+  activeTextColor: undefined,
+  textBackground: undefined,
+  fontStyle: undefined,
+  fontWeight: undefined,
+  textAlign: undefined,
+  textBaseline: undefined,
+  lineHeight: 0,
+  whiteSpace: undefined,
+  textWidth: 0,
+  textHeight: 0,
+  textLeft: 0,
+  textTop: 0,
+  ellipsis: false,
+  hiddenText: false,
+  keepDecimal: 0,
+  borderWidth: 0,
+  borderColor: undefined,
+  animateLineWidth: 0,
+  lineAnimateType: 0,
+  frames: [],
+  animateColor: undefined,
+  // animateType: undefined,
+  animateReverse: false,
+  background: '#fff0',
+  gradientColors: undefined,
+  lineGradientColors: undefined,
+  gradientSmooth: 0,
+};
+
+export interface FormItemType extends FormItem {
+  key: string; // 属性标识,绑定变量时使用
+  key2?: string; // 有些属性存在嵌套
+  name: string; // 标题
+  tips?: string; // 提示
+  placeholder?: string; // input placeholder
+  type:
+    | 'text' // string 类型输入框
+    | 'number'
+    | 'color'
+    | 'textarea'
+    | 'select'
+    | 'switch'
+    | 'code'
+    | 'image'
+    | 'icon'
+    | 'slider'
+    | 'autoComplete'
+    | 'label'; // 文字,不含输入框;
+  options?: {
+    // 选项
+    label: string; // 选项的标题,可以使用 html
+    value: any; // 选项的值
+    disabled?: boolean;
+  }[];
+  min?: number; // 最小值
+  max?: number; // 最大值
+  step?: number; // 步长
+  rows?: number; // textarea 所需要的行数
+  iconFamily?: string; // icon 类型节点需要
+  title?: string; // code 类型编辑器需要
+  language?: 'javascript' | 'json' | 'markdown'; // code 编辑器需要
+  readonly?: boolean; // 是否只读
+  mode?: 'multiple' | 'tags'; // select 选项
+  isNotString?: boolean; // monaco 需要 string 类型的 code ,不是 string 协助转换
+
+  multiple?: boolean; // 绑定多个属性
+  isTime?: boolean; // 是否是时序的, undefined 不会出现 历史趋势 的 checkbox
+  isYCategory?: boolean; // y 轴是否是分类,true y 轴分类,false x 轴分类,undefined 不会出现 y轴分类轴 的 checkbox
+  // 绑定单个属性是对象, 多个数组
+  // dataIds?: BindId | BindId[]; // 关联业务数据
+  precision?: number; //精度
+  gradient?: boolean; //是否为渐变色
+  hidden?: boolean; //是否隐藏
+}
+
+export const shapes = [
+  {
+    name: '基本形状',
+    show: true,
+    list: [
+      {
+        name: '正方形',
+        icon: 'l-rect',
+        id: 1,
+        data: {
+          width: 100,
+          height: 100,
+          name: 'square',
+        },
+      },
+      {
+        name: '矩形',
+        icon: 'l-rectangle',
+        id: 2,
+        data: {
+          width: 200,
+          height: 50,
+          borderRadius: 0.1,
+          name: 'rectangle',
+        },
+      },
+      {
+        name: '圆',
+        icon: 'l-circle',
+        id: 3,
+        data: {
+          width: 100,
+          height: 100,
+          name: 'circle',
+        },
+      },
+      {
+        name: '三角形',
+        icon: 'l-triangle',
+        id: 4,
+        data: {
+          width: 100,
+          height: 100,
+          name: 'triangle',
+        },
+      },
+      {
+        name: '菱形',
+        icon: 'l-diamond',
+        id: 5,
+        data: {
+          width: 100,
+          height: 100,
+          name: 'diamond',
+        },
+      },
+      {
+        name: '五边形',
+        icon: 'l-pentagon',
+        id: 6,
+        data: {
+          width: 100,
+          height: 100,
+          name: 'pentagon',
+        },
+      },
+      {
+        name: '六边形',
+        icon: 'l-hexagon',
+        id: 7,
+        data: {
+          width: 100,
+          height: 100,
+          name: 'hexagon',
+        },
+      },
+      {
+        name: '五角星',
+        icon: 'l-pentagram',
+        id: 8,
+        data: {
+          width: 100,
+          height: 100,
+          name: 'pentagram',
+        },
+      },
+      {
+        name: '左箭头',
+        icon: 'l-arrow-left',
+        id: 9,
+        data: {
+          width: 120,
+          height: 60,
+          name: 'leftArrow',
+        },
+      },
+      {
+        name: '右箭头',
+        icon: 'l-arrow-right',
+        id: 10,
+        data: {
+          width: 120,
+          height: 60,
+          name: 'rightArrow',
+        },
+      },
+      {
+        name: '双向箭头',
+        icon: 'l-twoway-arrow',
+        id: 11,
+        data: {
+          width: 150,
+          height: 60,
+          name: 'twowayArrow',
+        },
+      },
+      {
+        name: '云',
+        icon: 'l-cloud',
+        id: 13,
+        data: {
+          width: 100,
+          height: 100,
+          name: 'cloud',
+        },
+      },
+      {
+        name: '消息框',
+        icon: 'l-msg',
+        id: 14,
+        data: {
+          textTop: -0.1,
+          width: 100,
+          height: 100,
+          name: 'message',
+        },
+      },
+      {
+        name: '文件',
+        icon: 'l-file',
+        id: 15,
+        data: {
+          width: 80,
+          height: 100,
+          name: 'file',
+        },
+      },
+      {
+        name: '文字',
+        icon: 'l-text',
+        id: 16,
+        data: {
+          width: 160,
+          height: 30,
+          name: 'text',
+        },
+      },
+      {
+        name: '图片',
+        icon: 'l-image',
+        id: 17,
+        data: {
+          width: 100,
+          height: 100,
+          name: 'image',
+          image: cdn + '/img/logo.png',
+        },
+      },
+      {
+        name: '立方体',
+        icon: 'l-cube',
+        id: 18,
+        data: {
+          width: 60,
+          height: 100,
+          name: 'cube',
+          z: 0.25,
+          props: {
+            custom: [
+              {
+                key: 'z',
+                label: 'Z',
+                type: 'number',
+                min: 0,
+                placeholder: '<= 1 即宽度的比例',
+              },
+              {
+                key: 'backgroundFront',
+                label: '前背景色',
+                type: 'color',
+              },
+              {
+                key: 'backgroundUp',
+                label: '顶背景色',
+                type: 'color',
+              },
+              {
+                key: 'backgroundRight',
+                label: '右背景色',
+                type: 'color',
+              },
+            ],
+          },
+        },
+      },
+      {
+        name: '人',
+        icon: 'l-people',
+        id: 19,
+        data: {
+          width: 70,
+          height: 100,
+          name: 'people',
+        },
+      },
+      {
+        name: '视频',
+        icon: 'l-pc',
+        id: 20,
+        data: {
+          width: 200,
+          height: 200,
+          externElement: true,
+          name: 'video',
+          video:
+            'https://video.699pic.com/videos/17/69/11/a_aa3jeKZ0D63g1556176911_10s.mp4',
+        },
+      },
+      {
+        name: '网页',
+        icon: 'l-02',
+        id: 21,
+        data: {
+          name: 'iframe',
+          width: 100,
+          height: 100,
+          externElement: true,
+          iframe: 'http://le5le.com',
+        },
+      },
+    ],
+  },
+  {
+    name: '脑图',
+    show: true,
+    list: [
+      {
+        name: '主题',
+        icon: 'l-zhuti',
+        data: {
+          text: '主题',
+          width: 200,
+          height: 50,
+          name: 'mindNode',
+          borderRadius: 0.5,
+        },
+      },
+      {
+        name: '子主题',
+        icon: 'l-zizhuti',
+        data: {
+          text: '子主题',
+          width: 160,
+          height: 40,
+          name: 'mindLine',
+        },
+      },
+    ],
+  },
+  {
+    name: '流程图',
+    show: true,
+    list: [
+      {
+        name: '开始/结束',
+        icon: 'l-flow-start',
+        id: 21,
+        data: {
+          text: '开始/结束',
+          width: 120,
+          height: 40,
+          borderRadius: 0.5,
+          name: 'rectangle',
+        },
+      },
+      {
+        name: '流程',
+        icon: 'l-rectangle',
+        id: 22,
+        data: {
+          text: '流程',
+          width: 120,
+          height: 40,
+          name: 'rectangle',
+        },
+      },
+      {
+        name: '判定',
+        icon: 'l-diamond',
+        id: 23,
+        data: {
+          text: '判定',
+          width: 120,
+          height: 60,
+          name: 'diamond',
+        },
+      },
+      {
+        name: '数据',
+        icon: 'l-flow-data',
+        id: 24,
+        data: {
+          text: '数据',
+          width: 120,
+          height: 50,
+          name: 'flowData',
+          offsetX: 0.14,
+          form: [
+            {
+              key: 'offsetX',
+              name: '斜率',
+              type: 'number',
+              min: 0,
+              step: 0.1,
+              placeholder: '<= 1 即宽度的比例',
+            },
+          ] as FormItemType[],
+        },
+      },
+      {
+        name: '准备',
+        icon: 'l-flow-ready',
+        id: 25,
+        data: {
+          text: '准备',
+          width: 120,
+          height: 50,
+          name: 'hexagon',
+        },
+      },
+      {
+        name: '子流程',
+        icon: 'l-flow-subprocess',
+        id: 26,
+        data: {
+          text: '子流程',
+          width: 120,
+          height: 50,
+          name: 'flowSubprocess',
+        },
+      },
+      {
+        name: '数据库',
+        icon: 'l-db',
+        id: 27,
+        data: {
+          text: '数据库',
+          width: 80,
+          height: 120,
+          name: 'flowDb',
+        },
+      },
+      {
+        name: '文档',
+        icon: 'l-flow-document',
+        id: 28,
+        data: {
+          text: '文档',
+          width: 120,
+          height: 100,
+          name: 'flowDocument',
+        },
+      },
+      {
+        name: '内部存储',
+        icon: 'l-internal-storage',
+        id: 29,
+        data: {
+          text: '内部存储',
+          width: 120,
+          height: 80,
+          name: 'flowInternalStorage',
+        },
+      },
+      {
+        name: '外部存储',
+        icon: 'l-extern-storage',
+        id: 30,
+        data: {
+          text: '外部存储',
+          width: 120,
+          height: 80,
+          name: 'flowExternStorage',
+        },
+      },
+      {
+        name: '队列',
+        icon: 'l-flow-queue',
+        id: 31,
+        data: {
+          text: '队列',
+          width: 100,
+          height: 100,
+          name: 'flowQueue',
+        },
+      },
+      {
+        name: '手动输入',
+        icon: 'l-flow-manually',
+        id: 32,
+        data: {
+          text: '手动输入',
+          width: 120,
+          height: 80,
+          name: 'flowManually',
+        },
+      },
+      {
+        name: '展示',
+        icon: 'l-flow-display',
+        id: 33,
+        data: {
+          text: '展示',
+          width: 120,
+          height: 80,
+          name: 'flowDisplay',
+        },
+      },
+      {
+        name: '并行模式',
+        icon: 'l-flow-parallel',
+        id: 34,
+        data: {
+          text: '并行模式',
+          width: 120,
+          height: 50,
+          name: 'flowParallel',
+        },
+      },
+      {
+        name: '注释',
+        icon: 'l-flow-comment',
+        id: 35,
+        data: {
+          text: '注释',
+          width: 100,
+          height: 100,
+          name: 'flowComment',
+        },
+      },
+    ],
+  },
+  {
+    name: '活动图',
+    show: true,
+    list: [
+      {
+        name: '开始',
+        icon: 'l-inital',
+        id: 36,
+        data: {
+          text: '',
+          width: 30,
+          height: 30,
+          name: 'circle',
+          background: '#555',
+          lineWidth: 0,
+        },
+      },
+      {
+        name: '结束',
+        icon: 'l-final',
+        id: 37,
+        data: {
+          width: 30,
+          height: 30,
+          name: 'activityFinal',
+        },
+      },
+      {
+        name: '活动',
+        icon: 'l-action',
+        id: 38,
+        data: {
+          text: '活动',
+          width: 120,
+          height: 50,
+          borderRadius: 0.25,
+          name: 'rectangle',
+        },
+      },
+      {
+        name: '决策/合并',
+        icon: 'l-diamond',
+        id: 39,
+        data: {
+          text: '决策/合并',
+          width: 120,
+          height: 50,
+          name: 'diamond',
+        },
+      },
+      {
+        name: '垂直泳道',
+        icon: 'l-swimlane-v',
+        id: 40,
+        data: {
+          text: '垂直泳道',
+          width: 200,
+          height: 500,
+          name: 'swimlaneV',
+          textBaseline: 'top',
+          textTop: 20,
+          // textHeight: ,
+          lineTop: 0.08,
+        },
+      },
+      {
+        name: '水平泳道',
+        icon: 'l-swimlane-h',
+        id: 41,
+        data: {
+          text: '水平泳道',
+          width: 500,
+          height: 200,
+          name: 'swimlaneH',
+          textWidth: 0.01,
+          textLeft: 0.04,
+          textAlign: 'left',
+          lineLeft: 0.08,
+        },
+      },
+      {
+        name: '垂直分岔/汇合',
+        icon: 'l-fork-v',
+        id: 42,
+        data: {
+          text: '垂直分岔/汇合',
+          width: 10,
+          height: 150,
+          name: 'forkV',
+          fillStyle: '#555',
+          strokeStyle: 'transparent',
+        },
+      },
+      {
+        name: '水平分岔/汇合',
+        icon: 'l-fork',
+        id: 43,
+        data: {
+          text: '水平分岔/汇合',
+          width: 150,
+          height: 10,
+          name: 'forkH',
+          fillStyle: '#555',
+          strokeStyle: 'transparent',
+        },
+      },
+    ],
+  },
+  {
+    name: '时序图和类图',
+    show: true,
+    list: [
+      {
+        name: '生命线',
+        icon: 'l-lifeline',
+        id: 44,
+        data: {
+          text: '生命线',
+          width: 150,
+          height: 400,
+          textHeight: 50,
+          name: 'lifeline',
+        },
+      },
+      {
+        name: '激活',
+        icon: 'l-focus',
+        id: 45,
+        data: {
+          text: '激活',
+          width: 12,
+          height: 200,
+          name: 'sequenceFocus',
+        },
+      },
+      {
+        name: '简单类',
+        icon: 'l-simple-class',
+        id: 46,
+        data: {
+          text: 'Topolgoy',
+          width: 270,
+          height: 200,
+          textHeight: 200,
+          name: 'simpleClass',
+          textAlign: 'center',
+          textBaseline: 'top',
+          textTop: 10,
+          list: [
+            {
+              text: '- name: string\n+ setName(name: string): void',
+            },
+          ],
+        },
+      },
+      {
+        name: '类',
+        icon: 'l-class',
+        id: 47,
+        data: {
+          text: 'Topolgoy',
+          width: 270,
+          height: 200,
+          textHeight: 200,
+          name: 'interfaceClass',
+          textAlign: 'center',
+          textBaseline: 'top',
+          textTop: 10,
+          list: [
+            {
+              text: '- name: string',
+            },
+            {
+              text: '+ setName(name: string): void',
+            },
+          ],
+        },
+      },
+    ],
+  },
+
+  {
+    name: '故障树',
+    show: true,
+    list: [
+      {
+        name: '与门',
+        icon: 'l-ANDmen',
+        data: {
+          name: 'andGate',
+          width: 100,
+          height: 150,
+        },
+      },
+      {
+        name: '基本事件',
+        icon: 'l-jibenshijian',
+        data: {
+          name: 'basicEvent',
+          width: 100,
+          height: 150,
+        },
+      },
+      {
+        name: '未展开事件',
+        icon: 'l-weizhankaishijian',
+        data: {
+          name: 'unexpandedEvent',
+          width: 100,
+          height: 150,
+        },
+      },
+      {
+        name: '优先AND门',
+        icon: 'l-youxianANDmen',
+        data: {
+          name: 'priorityAndGate',
+          width: 100,
+          height: 150,
+        },
+      },
+      {
+        name: '禁止门',
+        icon: 'l-jinzhimen',
+        data: {
+          name: 'forbiddenGate',
+          width: 100,
+          height: 150,
+        },
+      },
+      {
+        name: '事件',
+        icon: 'l-shijian',
+        data: {
+          name: 'event',
+          width: 100,
+          height: 150,
+        },
+      },
+      {
+        name: '开关事件',
+        icon: 'l-kaiguanshijian',
+        data: {
+          name: 'switchEvent',
+          width: 100,
+          height: 150,
+        },
+      },
+      {
+        name: '条件事件',
+        icon: 'l-tiaojianshijian',
+        data: {
+          name: 'conditionalEvent',
+          width: 150,
+          height: 100,
+        },
+      },
+      {
+        name: '转移符号',
+        icon: 'l-zhuanyifuhao',
+        data: {
+          name: 'transferSymbol',
+          width: 100,
+          height: 150,
+        },
+      },
+      {
+        name: '或门',
+        icon: 'l-ORmen',
+        data: {
+          name: 'orGate',
+          width: 100,
+          height: 150,
+        },
+      },
+      {
+        name: '异或门',
+        icon: 'l-yihuomen',
+        data: {
+          name: 'xorGate',
+          width: 100,
+          height: 150,
+        },
+      },
+      {
+        name: '表决门',
+        icon: 'l-biaojuemen',
+        data: {
+          name: 'votingGate',
+          width: 100,
+          height: 150,
+        },
+      },
+    ],
+  },
+];
+
+export const charts = [
+  {
+    name: 'Echarts - 基础图表',
+    show: true,
+    list: [
+      {
+        name: '折线图',
+        icon: 'l-line-chart',
+        data: {
+          name: 'echarts',
+          width: 400,
+          height: 300,
+          externElement: true,
+          disableAnchor: true,
+          echarts: {
+            option: {
+              tooltip: {
+                trigger: 'axis',
+              },
+              grid: {
+                top: 10,
+                bottom: 20,
+                left: 40,
+                right: 5,
+              },
+              xAxis: {
+                type: 'category',
+                data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
+                axisLabel: {
+                  fontSize: 12,
+                  color: '#ffffff',
+                },
+              },
+              yAxis: {
+                type: 'value',
+                axisLabel: {
+                  fontSize: 12,
+                  color: '#ffffff',
+                },
+              },
+              series: [
+                {
+                  type: 'line',
+                  data: [820, 932, 901, 934, 1290, 1330, 1320],
+                },
+              ],
+            },
+            max: 100,
+          },
+          realTimes: [
+            {
+              key: 'echarts.option.series.0.data',
+              label: '数据',
+              type: 'object',
+            },
+            {
+              key: 'echarts.option',
+              label: 'echarts',
+              type: 'object',
+            },
+            {
+              key: 'echarts.max',
+              label: '最大数量',
+              type: 'number',
+            },
+          ],
+        },
+      },
+      {
+        name: '柱状图',
+        icon: 'l-bar-chart',
+        data: {
+          width: 300,
+          height: 200,
+          disableAnchor: true,
+          externElement: true,
+          name: 'echarts',
+          echarts: {
+            option: {
+              tooltip: {
+                trigger: 'axis',
+              },
+              grid: {
+                top: 10,
+                bottom: 20,
+                left: 40,
+                right: 5,
+              },
+              xAxis: {
+                type: 'category',
+                data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
+                axisTick: {
+                  alignWithLabel: true,
+                },
+                axisLabel: {
+                  fontSize: 12,
+                  color: '#ffffff',
+                },
+              },
+              yAxis: {
+                type: 'value',
+                axisLabel: {
+                  fontSize: 12,
+                  color: '#ffffff',
+                },
+              },
+              series: [
+                {
+                  name: '直接访问',
+                  type: 'bar',
+                  barWidth: '60%',
+                  label: {
+                    color: '#ffffff',
+                  },
+                  data: [10, 52, 200, 334, 390, 330, 220],
+                },
+              ],
+            },
+            max: 100,
+          },
+          realTimes: [
+            {
+              key: 'echarts.option.series.0.data',
+              label: '数据',
+              type: 'object',
+            },
+            {
+              key: 'echarts.option',
+              label: 'echarts',
+              type: 'object',
+            },
+            {
+              key: 'echarts.max',
+              label: '最大数量',
+              type: 'number',
+            },
+          ],
+        },
+      },
+      {
+        name: '饼图',
+        icon: 'l-pie-chart',
+        data: {
+          width: 300,
+          height: 200,
+          disableAnchor: true,
+          externElement: true,
+          name: 'echarts',
+          echarts: {
+            option: {
+              tooltip: {
+                trigger: 'item',
+              },
+              series: [
+                {
+                  type: 'pie',
+                  radius: ['50%', '70%'],
+                  label: {
+                    color: '#ffffff',
+                  },
+                  data: [
+                    { value: 335, name: '2D' },
+                    { value: 310, name: '3D' },
+                    { value: 234, name: '大屏' },
+                    { value: 135, name: '物联网平台' },
+                    { value: 1548, name: '图形库' },
+                  ],
+                },
+              ],
+            },
+            replaceMode: ReplaceMode.Replace,
+          },
+          realTimes: [
+            {
+              key: 'echarts.option.series.0.data',
+              label: '数据',
+              type: 'object',
+            },
+            {
+              key: 'echarts.option',
+              label: 'echarts',
+              type: 'object',
+            },
+          ],
+        },
+      },
+      {
+        name: '散点图',
+        icon: 'l-sandiantu',
+        data: {
+          width: 300,
+          height: 200,
+          disableAnchor: true,
+          externElement: true,
+          name: 'echarts',
+          echarts: {
+            option: {
+              grid: {
+                top: 10,
+                bottom: 20,
+                left: 40,
+                right: 10,
+              },
+              xAxis: {
+                axisLabel: {
+                  fontSize: 12,
+                  color: '#ffffff',
+                },
+              },
+              yAxis: {
+                axisLabel: {
+                  fontSize: 12,
+                  color: '#ffffff',
+                },
+              },
+              series: [
+                {
+                  symbolSize: 10,
+                  data: [
+                    [10.0, 8.04],
+                    [8.07, 6.95],
+                    [13.0, 7.58],
+                    [9.05, 8.81],
+                    [11.0, 8.33],
+                    [14.0, 7.66],
+                    [13.4, 6.81],
+                    [10.0, 6.33],
+                    [14.0, 8.96],
+                    [12.5, 6.82],
+                    [9.15, 7.2],
+                    [11.5, 7.2],
+                    [3.03, 4.23],
+                    [12.2, 7.83],
+                    [2.02, 4.47],
+                    [1.05, 3.33],
+                    [4.05, 4.96],
+                    [6.03, 7.24],
+                    [12.0, 6.26],
+                    [12.0, 8.84],
+                    [7.08, 5.82],
+                    [5.02, 5.68],
+                  ],
+                  type: 'scatter',
+                },
+              ],
+            },
+            replaceMode: ReplaceMode.Replace,
+          },
+          realTimes: [
+            {
+              key: 'echarts.option.series.0.data',
+              label: '数据',
+              type: 'object',
+            },
+            {
+              key: 'echarts.option',
+              label: 'echarts',
+              type: 'object',
+            },
+          ],
+        },
+      },
+      {
+        name: 'K线图',
+        icon: 'l-kxiantu',
+        data: {
+          width: 300,
+          height: 200,
+          disableAnchor: true,
+          externElement: true,
+          name: 'echarts',
+          echarts: {
+            option: {
+              grid: {
+                top: 20,
+                bottom: 30,
+                left: 40,
+                right: 10,
+              },
+              xAxis: {
+                axisLabel: {
+                  fontSize: 12,
+                  color: '#ffffff',
+                },
+                data: ['2017-10-24', '2017-10-25', '2017-10-26', '2017-10-27'],
+              },
+              yAxis: {
+                axisLabel: {
+                  fontSize: 12,
+                  color: '#ffffff',
+                },
+              },
+              series: [
+                {
+                  type: 'candlestick',
+                  data: [
+                    [20, 34, 10, 38],
+                    [40, 35, 30, 50],
+                    [31, 38, 33, 44],
+                    [38, 15, 5, 42],
+                  ],
+                },
+              ],
+            },
+            replaceMode: ReplaceMode.Replace,
+          },
+          realTimes: [
+            {
+              key: 'echarts.option.series.0.data',
+              label: '数据',
+              type: 'object',
+            },
+            {
+              key: 'echarts.option',
+              label: 'echarts',
+              type: 'object',
+            },
+          ],
+        },
+      },
+      {
+        name: '雷达图',
+        icon: 'l-leidatu',
+        data: {
+          width: 300,
+          height: 200,
+          disableAnchor: true,
+          externElement: true,
+          name: 'echarts',
+          echarts: {
+            option: {
+              grid: {
+                top: 20,
+                bottom: 30,
+                left: 40,
+                right: 10,
+              },
+              radar: {
+                // shape: 'circle',
+                indicator: [
+                  { name: 'Sales', max: 6500 },
+                  { name: 'Administration', max: 16000 },
+                  { name: 'Information Technology', max: 30000 },
+                  { name: 'Customer Support', max: 38000 },
+                  { name: 'Development', max: 52000 },
+                  { name: 'Marketing', max: 25000 },
+                ],
+              },
+              series: [
+                {
+                  name: 'Budget vs spending',
+                  type: 'radar',
+                  data: [
+                    {
+                      value: [4200, 3000, 20000, 35000, 50000, 18000],
+                      name: 'Allocated Budget',
+                    },
+                    {
+                      value: [5000, 14000, 28000, 26000, 42000, 21000],
+                      name: 'Actual Spending',
+                    },
+                  ],
+                },
+              ],
+            },
+            replaceMode: ReplaceMode.Replace,
+          },
+          realTimes: [
+            {
+              key: 'echarts.option.series.0.data',
+              label: '数据',
+              type: 'object',
+            },
+            {
+              key: 'echarts.option',
+              label: 'echarts',
+              type: 'object',
+            },
+          ],
+        },
+      },
+      {
+        name: '旭日图',
+        icon: 'l-xuritu',
+        data: {
+          width: 200,
+          height: 200,
+          disableAnchor: true,
+          externElement: true,
+          name: 'echarts',
+          echarts: {
+            option: {
+              series: [
+                {
+                  radius: ['15%', '80%'],
+                  type: 'sunburst',
+                  data: [
+                    {
+                      children: [
+                        {
+                          value: 5,
+                          children: [
+                            {
+                              value: 1,
+                              itemStyle: {
+                                color: '#F54F4A',
+                              },
+                            },
+                            {
+                              value: 2,
+                              children: [
+                                {
+                                  value: 1,
+                                  itemStyle: {
+                                    color: '#FF8C75',
+                                  },
+                                },
+                              ],
+                            },
+                            {
+                              children: [
+                                {
+                                  value: 1,
+                                },
+                              ],
+                            },
+                          ],
+                          itemStyle: {
+                            color: '#F54F4A',
+                          },
+                        },
+                        {
+                          value: 10,
+                          children: [
+                            {
+                              value: 6,
+                              children: [
+                                {
+                                  value: 1,
+                                  itemStyle: {
+                                    color: '#F54F4A',
+                                  },
+                                },
+                                {
+                                  value: 1,
+                                },
+                                {
+                                  value: 1,
+                                  itemStyle: {
+                                    color: '#FF8C75',
+                                  },
+                                },
+                                {
+                                  value: 1,
+                                },
+                              ],
+                              itemStyle: {
+                                color: '#FFB499',
+                              },
+                            },
+                            {
+                              value: 2,
+                              children: [
+                                {
+                                  value: 1,
+                                },
+                              ],
+                              itemStyle: {
+                                color: '#FFB499',
+                              },
+                            },
+                            {
+                              children: [
+                                {
+                                  value: 1,
+                                  itemStyle: {
+                                    color: '#FF8C75',
+                                  },
+                                },
+                              ],
+                            },
+                          ],
+                          itemStyle: {
+                            color: '#F54F4A',
+                          },
+                        },
+                      ],
+                      itemStyle: {
+                        color: '#F54F4A',
+                      },
+                    },
+                    {
+                      value: 9,
+                      children: [
+                        {
+                          value: 4,
+                          children: [
+                            {
+                              value: 2,
+                              itemStyle: {
+                                color: '#FF8C75',
+                              },
+                            },
+                            {
+                              children: [
+                                {
+                                  value: 1,
+                                  itemStyle: {
+                                    color: '#F54F4A',
+                                  },
+                                },
+                              ],
+                            },
+                          ],
+                          itemStyle: {
+                            color: '#F54F4A',
+                          },
+                        },
+                        {
+                          children: [
+                            {
+                              value: 3,
+                              children: [
+                                {
+                                  value: 1,
+                                },
+                                {
+                                  value: 1,
+                                  itemStyle: {
+                                    color: '#FF8C75',
+                                  },
+                                },
+                              ],
+                            },
+                          ],
+                          itemStyle: {
+                            color: '#FFB499',
+                          },
+                        },
+                      ],
+                      itemStyle: {
+                        color: '#FF8C75',
+                      },
+                    },
+                    {
+                      value: 7,
+                      children: [
+                        {
+                          children: [
+                            {
+                              value: 1,
+                              itemStyle: {
+                                color: '#FFB499',
+                              },
+                            },
+                            {
+                              value: 3,
+                              children: [
+                                {
+                                  value: 1,
+                                  itemStyle: {
+                                    color: '#FF8C75',
+                                  },
+                                },
+                                {
+                                  value: 1,
+                                },
+                              ],
+                              itemStyle: {
+                                color: '#FF8C75',
+                              },
+                            },
+                            {
+                              value: 2,
+                              children: [
+                                {
+                                  value: 1,
+                                },
+                                {
+                                  value: 1,
+                                  itemStyle: {
+                                    color: '#F54F4A',
+                                  },
+                                },
+                              ],
+                              itemStyle: {
+                                color: '#F54F4A',
+                              },
+                            },
+                          ],
+                          itemStyle: {
+                            color: '#FFB499',
+                          },
+                        },
+                      ],
+                      itemStyle: {
+                        color: '#F54F4A',
+                      },
+                    },
+                    {
+                      children: [
+                        {
+                          value: 6,
+                          children: [
+                            {
+                              value: 1,
+                              itemStyle: {
+                                color: '#FF8C75',
+                              },
+                            },
+                            {
+                              value: 2,
+                              children: [
+                                {
+                                  value: 2,
+                                  itemStyle: {
+                                    color: '#FF8C75',
+                                  },
+                                },
+                              ],
+                              itemStyle: {
+                                color: '#F54F4A',
+                              },
+                            },
+                            {
+                              value: 1,
+                              itemStyle: {
+                                color: '#FFB499',
+                              },
+                            },
+                          ],
+                          itemStyle: {
+                            color: '#FFB499',
+                          },
+                        },
+                        {
+                          value: 3,
+                          children: [
+                            {
+                              value: 1,
+                            },
+                            {
+                              children: [
+                                {
+                                  value: 1,
+                                  itemStyle: {
+                                    color: '#FF8C75',
+                                  },
+                                },
+                              ],
+                            },
+                            {
+                              value: 1,
+                            },
+                          ],
+                          itemStyle: {
+                            color: '#FFB499',
+                          },
+                        },
+                      ],
+                      itemStyle: {
+                        color: '#F54F4A',
+                      },
+                    },
+                  ],
+                  label: {
+                    rotate: 'radial',
+                    color: '#ffffff',
+                  },
+                },
+              ],
+            },
+            replaceMode: ReplaceMode.Replace,
+          },
+          realTimes: [
+            {
+              key: 'echarts.option.series.0.data',
+              label: '数据',
+              type: 'object',
+            },
+            {
+              key: 'echarts.option',
+              label: 'echarts',
+              type: 'object',
+            },
+          ],
+        },
+      },
+      {
+        name: '桑基图',
+        icon: 'l-sangshentu',
+        data: {
+          width: 300,
+          height: 200,
+          disableAnchor: true,
+          externElement: true,
+          name: 'echarts',
+          echarts: {
+            option: {
+              series: {
+                type: 'sankey',
+                layout: 'none',
+                emphasis: {
+                  focus: 'adjacency',
+                },
+                data: [
+                  {
+                    name: 'a',
+                  },
+                  {
+                    name: 'b',
+                  },
+                  {
+                    name: 'a1',
+                  },
+                  {
+                    name: 'a2',
+                  },
+                  {
+                    name: 'b1',
+                  },
+                  {
+                    name: 'c',
+                  },
+                ],
+                links: [
+                  {
+                    source: 'a',
+                    target: 'a1',
+                    value: 5,
+                  },
+                  {
+                    source: 'a',
+                    target: 'a2',
+                    value: 3,
+                  },
+                  {
+                    source: 'b',
+                    target: 'b1',
+                    value: 8,
+                  },
+                  {
+                    source: 'a',
+                    target: 'b1',
+                    value: 3,
+                  },
+                  {
+                    source: 'b1',
+                    target: 'a1',
+                    value: 1,
+                  },
+                  {
+                    source: 'b1',
+                    target: 'c',
+                    value: 2,
+                  },
+                ],
+                lineStyle: {
+                  color: 'source',
+                  curveness: 0.5,
+                },
+                label: {
+                  color: '#ffffff',
+                  fontSize: 10,
+                },
+              },
+            },
+            replaceMode: ReplaceMode.Replace,
+          },
+          realTimes: [
+            {
+              key: 'echarts.option.series.0.data',
+              label: '数据',
+              type: 'object',
+            },
+            {
+              key: 'echarts.option',
+              label: 'echarts',
+              type: 'object',
+            },
+          ],
+        },
+      },
+      {
+        name: '漏斗图',
+        icon: 'l-loudoutu',
+        data: {
+          width: 200,
+          height: 200,
+          disableAnchor: true,
+          externElement: true,
+          name: 'echarts',
+          echarts: {
+            option: {
+              tooltip: {
+                trigger: 'item',
+                formatter: '{a} <br/>{b} : {c}%',
+              },
+              series: [
+                {
+                  name: 'Expected',
+                  type: 'funnel',
+                  left: '2%',
+                  width: '80%',
+                  label: {
+                    color: '#ffffff',
+                    formatter: '{b}Expected',
+                  },
+                  labelLine: {
+                    show: false,
+                  },
+                  itemStyle: {
+                    opacity: 0.7,
+                  },
+                  emphasis: {
+                    label: {
+                      position: 'inside',
+                      formatter: '{b}Expected: {c}%',
+                    },
+                  },
+                  data: [
+                    { value: 60, name: 'Visit' },
+                    { value: 40, name: 'Inquiry' },
+                    { value: 20, name: 'Order' },
+                    { value: 80, name: 'Click' },
+                    { value: 100, name: 'Show' },
+                  ],
+                },
+                {
+                  name: 'Actual',
+                  type: 'funnel',
+                  left: '2%',
+                  width: '80%',
+                  maxSize: '80%',
+                  label: {
+                    position: 'inside',
+                    formatter: '{c}%',
+                    color: '#fff',
+                  },
+                  itemStyle: {
+                    opacity: 0.5,
+                    borderColor: '#fff',
+                    borderWidth: 2,
+                  },
+                  emphasis: {
+                    label: {
+                      position: 'inside',
+                      formatter: '{b}Actual: {c}%',
+                    },
+                  },
+                  data: [
+                    { value: 30, name: 'Visit' },
+                    { value: 10, name: 'Inquiry' },
+                    { value: 5, name: 'Order' },
+                    { value: 50, name: 'Click' },
+                    { value: 80, name: 'Show' },
+                  ],
+                  z: 100,
+                },
+              ],
+            },
+            replaceMode: ReplaceMode.Replace,
+          },
+          realTimes: [
+            {
+              key: 'echarts.option.series.0.data',
+              label: '数据',
+              type: 'object',
+            },
+            {
+              key: 'echarts.option',
+              label: 'echarts',
+              type: 'object',
+            },
+          ],
+        },
+      },
+      {
+        name: '仪表盘',
+        icon: 'l-dashboard-chart',
+        data: {
+          width: 200,
+          height: 200,
+          disableAnchor: true,
+          externElement: true,
+          name: 'echarts',
+          echarts: {
+            option: {
+              tooltip: {
+                formatter: '{a} <br/>{b} : {c}%',
+              },
+              series: [
+                {
+                  type: 'gauge',
+                  axisLine: {
+                    roundCap: true,
+                  },
+                  progress: {
+                    show: true,
+                    roundCap: true,
+                  },
+                  data: [{ value: 70 }],
+                },
+              ],
+            },
+            replaceMode: ReplaceMode.Replace,
+          },
+          realTimes: [
+            {
+              key: 'echarts.option.series.0.data',
+              label: '数据',
+              type: 'object',
+            },
+            {
+              key: 'echarts.option',
+              label: 'echarts',
+              type: 'object',
+            },
+          ],
+        },
+      },
+    ],
+  },
+  {
+    name: '乐吾乐Charts',
+    show: true,
+    list: [
+      {
+        name: '折线图',
+        icon: 'l-line-chart',
+        data: {
+          name: 'lineChart',
+          width: 400,
+          disableAnchor: true,
+          height: 200,
+          form: [
+            {
+              key: 'data',
+              name: 'data 数据',
+              type: 'code',
+              language: 'json',
+              isNotString: true,
+            },
+          ] as FormItemType[],
+          chartsColor: [
+            '#1890ff',
+            '#2FC25B',
+            '#FACC14',
+            '#c23531',
+            '#2f4554',
+            '#61a0a8',
+            '#d48265',
+          ],
+          xAxisData: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
+          smooth: true,
+          data: [
+            [1820, 1932, 1901, 1934, 1990, 1830, 1920],
+            [1710, 1932, 1901, 1834, 1700, 1830, 1720],
+          ],
+        },
+      },
+      {
+        name: '柱状图',
+        icon: 'l-bar-chart',
+        data: {
+          name: 'histogram',
+          x: 600,
+          y: 100,
+          width: 400,
+          height: 200,
+          disableAnchor: true,
+          form: [
+            {
+              key: 'data',
+              name: 'data 数据',
+              type: 'code',
+              language: 'json',
+              isNotString: true,
+            },
+          ] as FormItemType[],
+          chartsColor: [
+            '#1890ff',
+            '#2FC25B',
+            '#FACC14',
+            '#c23531',
+            '#2f4554',
+            '#61a0a8',
+            '#d48265',
+          ],
+          xAxisData: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
+          data: [
+            [120, 200, 150, 80, 70, 110, 130],
+            [140, 250, 150, 80, 60, 10, 30],
+            [40, 50, 180, 210, 60, 70, 30],
+          ],
+        },
+      },
+      {
+        name: '饼图',
+        icon: 'l-pie-chart',
+        data: {
+          name: 'pieChart',
+          x: 100,
+          y: 300,
+          width: 400,
+          height: 200,
+          disableAnchor: true,
+          form: [
+            {
+              key: 'data',
+              name: 'data 数据',
+              type: 'code',
+              language: 'json',
+              isNotString: true,
+            },
+          ] as FormItemType[],
+          chartsColor: [
+            '#1890ff',
+            '#36CBCB',
+            '#2FC25B',
+            '#FACC14',
+            '#F2637B',
+            '#fc8452',
+            '#9a60b4',
+            '#ea7ccc',
+          ],
+          data: [
+            [
+              { value: 1048, name: 'Search Engine' },
+              { value: 735, name: 'Direct' },
+              { value: 580, name: 'Email' },
+              { value: 484, name: 'Union Ads' },
+              { value: 300, name: 'Video Ads' },
+            ],
+            [
+              { value: 1548, name: 'Search' },
+              { value: 775, name: 'Direct' },
+              { value: 679, name: 'Market' },
+            ],
+          ],
+          chartsRadius: [
+            ['60%', '70%'],
+            ['0%', '50%'],
+          ],
+        },
+      },
+      {
+        name: '仪表盘',
+        icon: 'l-dashboard-chart',
+        data: {
+          name: 'gauge',
+          x: 600,
+          y: 300,
+          width: 400,
+          height: 400,
+          disableAnchor: true,
+          value: 90,
+          unit: 'm/s',
+          axisLine: [
+            [0.3, '#67e0e3'],
+            [0.7, '#37a2da'],
+            [1, '#fd666d'],
+          ],
+          animateCycle: 1,
+          keepAnimateState: 0,
+        },
+      },
+      {
+        name: '时钟',
+        icon: 'l-07',
+        data: {
+          name: 'gauge',
+          x: 600,
+          y: 300,
+          width: 400,
+          height: 400,
+          disableAnchor: true,
+          isClock: true,
+          startAngle: 90,
+          endAngle: -270,
+          min: 0,
+          max: 12,
+          splitNumber: 12,
+          background: '#3A3A3A',
+          color: '#C0911F',
+        },
+      },
+    ],
+  },
+];
+
+export const formComponents = [
+  {
+    name: '表单',
+    show: true,
+    list: [
+      {
+        name: '表格',
+        icon: 'l-biaoge',
+        data: {
+          name: 'table2',
+          width: 0,
+          height: 0,
+          form: [
+            {
+              key: 'data',
+              name: '表格数据',
+              type: 'code',
+              language: 'json',
+              isNotString: true,
+            },
+            {
+              key: 'styles',
+              name: '表格样式',
+              type: 'code',
+              language: 'json',
+              isNotString: true,
+            },
+          ] as FormItemType[],
+          disableAnchor: true,
+          colWidth: 150,
+          rowHeight: 40,
+          data: [
+            ['设备 ID', '设备名称', '数据协议', '状态', '操作'],
+            ['1', '200', 'MQTT', '正在运行', {}],
+            ['2', '湿度传感器', 'MQTT', '正在运行', {}],
+            ['3', '物联网设备', 'MQTT', '正在运行', {}],
+            ['4', '物联网设备/智能家居/智慧城市', 'MQTT', '正在运行', {}],
+          ],
+          styles: [
+            {
+              row: 1,
+              col: 1,
+              color: '#ff0000',
+              background: '#ffff00',
+              wheres: [
+                //触发条件 成立后才允许配置样式
+                {
+                  comparison: '<=',
+                  value: '123',
+                },
+              ],
+            },
+            {
+              row: 0,
+              height: 60,
+            },
+            {
+              col: 4,
+              width: 200, //为该列设置额外的节点
+              pens: [
+                {
+                  name: 'rectangle',
+                  width: 50,
+                  height: 20,
+                  text: '编辑',
+                  fontSize: 0.6,
+                  disableAnchor: true,
+                  activeBackground: '#40a9ff',
+                  activeColor: '#40a9ff',
+                  background: '#1890ff',
+                  color: '#1890ff',
+                  hoverBackground: '#40a9ff',
+                  hoverColor: '#40a9ff',
+                  textColor: '#ffffff',
+                  hoverTextColor: '#ffffff',
+                  activeTextColor: '#ffffff',
+                  events: [
+                    {
+                      action: 5,
+                      name: 'click',
+                      value: 'alert("点击了编辑")',
+                    },
+                  ],
+                },
+                {
+                  name: 'rectangle',
+                  width: 80,
+                  height: 20,
+                  text: '实时数据',
+                  fontSize: 0.6,
+                  disableAnchor: true,
+                  activeBackground: '#40a9ff',
+                  activeColor: '#40a9ff',
+                  background: '#1890ff',
+                  color: '#1890ff',
+                  hoverBackground: '#40a9ff',
+                  hoverColor: '#40a9ff',
+                  textColor: '#ffffff',
+                  hoverTextColor: '#ffffff',
+                  activeTextColor: '#ffffff',
+                  events: [
+                    {
+                      action: 5,
+                      name: 'click',
+                      value: 'alert("点击了实时数据")',
+                    },
+                  ],
+                },
+              ],
+            },
+          ],
+        },
+      },
+
+      {
+        name: '复选框',
+        icon: 'l-xuanzeqi',
+        data: {
+          name: 'checkbox',
+          width: 100,
+          height: 30,
+          fontSize: 16,
+          disableAnchor: true,
+          direction: 'vertical',
+          checked: true,
+          // isForbidden: true,
+          value: '选项一',
+          form: [
+            {
+              key: 'checked',
+              name: '选中',
+              type: 'switch',
+            },
+            {
+              key: 'isForbidden',
+              name: '是否禁用',
+              type: 'switch',
+            },
+            {
+              key: 'value',
+              name: '选项值',
+              type: 'text',
+            },
+          ] as FormItemType[],
+        },
+      },
+      {
+        name: '单选框',
+        icon: 'l-danxuankuang',
+        data: {
+          name: 'radio',
+          width: 150,
+          height: 30,
+          disableAnchor: true,
+          direction: 'horizontal', // 'vertical', //'horizontal',
+          form: [
+            {
+              key: 'options',
+              name: '选项',
+              type: 'code',
+              language: 'json',
+              isNotString: true,
+            },
+            {
+              key: 'direction',
+              name: '方向',
+              type: 'select',
+              options: [
+                {
+                  label: '水平',
+                  value: 'horizontal',
+                },
+                {
+                  label: '垂直',
+                  value: 'vertical',
+                },
+              ],
+            },
+            {
+              key: 'checked',
+              name: '选择项',
+              type: 'text',
+            },
+          ] as FormItemType[],
+          options: [
+            { text: '选项一', isForbidden: true },
+            { text: '选项二' },
+            { text: '选项三' },
+          ],
+          checked: '选项二',
+        },
+      },
+      {
+        name: '开关',
+        icon: 'l-kaiguan',
+        data: {
+          name: 'switch',
+          disableAnchor: true,
+          height: 30,
+          width: 60,
+          checked: true,
+          offColor: '#BFBFBF',
+          onColor: '#1890ff',
+          disableOffColor: '#E5E5E5',
+          disableOnColor: '#A3D3FF',
+          hoverBackground: '#40a9ff',
+          form: [
+            {
+              key: 'checked',
+              name: '开关状态',
+              type: 'switch',
+            },
+          ] as FormItemType[],
+        },
+      },
+      {
+        name: '进度条',
+        icon: 'l-jindutiao',
+        data: {
+          anchors: [],
+          disableAnchor: true,
+          name: 'slider',
+          x: 400,
+          y: 400,
+          width: 300,
+          height: 20,
+          value: 10,
+          textWidth: 50,
+          barHeight: 4, //修改无效
+          min: 0,
+          max: 100,
+          color: '#1890ff',
+          background: '#D4D6D9',
+          textColor: '#222222',
+          unit: '%',
+          form: [
+            //TODO 添加最大最小
+            {
+              key: 'value',
+              name: '当前进度值',
+              type: 'number',
+              min: 0,
+              max: 100,
+            },
+            {
+              key: 'unit',
+              name: '显示单位',
+              type: 'text',
+            },
+            {
+              key: 'textColor',
+              name: '文字颜色',
+              type: 'color',
+            },
+          ] as FormItemType[],
+        },
+      },
+      {
+        name: '按钮',
+        icon: 'l-anniu',
+        data: {
+          name: 'rectangle',
+          x: 300,
+          y: 200,
+          width: 80,
+          height: 30,
+          disableAnchor: true,
+          borderRadius: 2,
+          text: '按钮',
+          activeBackground: '#40a9ff',
+          activeColor: '#40a9ff',
+          background: '#1890ff',
+          color: '#1890ff',
+          hoverBackground: '#40a9ff',
+          hoverColor: '#40a9ff',
+          textColor: '#ffffff',
+          hoverTextColor: '#ffffff',
+          activeTextColor: '#ffffff',
+        },
+      },
+      {
+        name: '输入框',
+        icon: 'l-shurukuang',
+        data: {
+          x: 100,
+          y: 100,
+          height: 40,
+          width: 200,
+          disableAnchor: true,
+          name: 'rectangle',
+          borderRadius: 0.05,
+          input: true,
+          ellipsis: true,
+          text: '输入数据',
+          textAlign: 'left',
+          color: '#D9D9D9FF',
+          textColor: '#000000FF',
+          hoverTextColor: '#000000FF',
+          activeTextColor: '#000000FF',
+          textLeft: 10,
+        },
+      },
+      {
+        name: '选择器',
+        icon: 'l-xuanzeqi',
+        data: {
+          x: 100,
+          y: 100,
+          height: 40,
+          width: 200,
+          disableAnchor: true,
+          name: 'rectangle',
+          borderRadius: 0.05,
+          ellipsis: true,
+          text: '选项1',
+          textAlign: 'left',
+          input: true,
+          color: '#D9D9D9FF',
+          textColor: '#000000FF',
+          hoverTextColor: '#000000FF',
+          activeTextColor: '#000000FF',
+          textLeft: 10,
+          dropdownList: [
+            {
+              text: '选项1',
+            },
+            {
+              text: '选项2',
+            },
+            {
+              text: '选项3',
+            },
+          ],
+          form: [
+            {
+              key: 'text',
+              name: '选择项',
+              type: 'text',
+            },
+          ] as FormItemType[],
+        },
+      },
+    ],
+  },
+  {
+    name: '特殊图元',
+    show: true,
+    list: [
+      {
+        name: '海康威视摄像头',
+        icon: 'l-10',
+        data: {
+          name: 'hikVideo',
+          width: 200,
+          height: 200,
+          disableAnchor: true,
+          videoPrototocol: 1,
+          port: 80,
+          username: 'admin',
+          iWndowType: 1,
+          form: [
+            {
+              key: 'videoPrototocol',
+              name: '协议',
+              type: 'select',
+              options: [
+                {
+                  label: 'HTTP',
+                  value: 1,
+                },
+                {
+                  label: 'HTTPS',
+                  value: 2,
+                },
+              ],
+            },
+            {
+              key: 'videoIp',
+              name: 'IP地址',
+              type: 'text',
+            },
+            {
+              key: 'videoPort',
+              name: 'Port端口',
+              type: 'text',
+            },
+            {
+              key: 'username',
+              name: '用户名',
+              type: 'text',
+            },
+            {
+              key: 'password',
+              name: '密码',
+              type: 'text',
+            },
+            {
+              key: 'iWndowType',
+              name: '屏幕数',
+              type: 'number',
+            },
+          ] as FormItemType[],
+        },
+      },
+      {
+        name: '温度计',
+        icon: 'l-wenduji',
+        data: {
+          name: 'thermometer',
+          width: 100,
+          height: 300,
+          disableAnchor: true,
+          background: '#F40',
+          value: 10,
+          min: -20,
+          max: 20,
+          form: [
+            {
+              key: 'value',
+              name: '温度',
+              type: 'number',
+            },
+            {
+              key: 'min',
+              name: '最小值',
+              type: 'number',
+            },
+            {
+              key: 'max',
+              name: '最大值',
+              type: 'number',
+            },
+          ] as FormItemType[],
+        },
+      },
+      {
+        name: '温度计1',
+        icon: 'l-wenduji',
+        data: {
+          name: 'thermometer1',
+          width: 200,
+          height: 300,
+          disableAnchor: true,
+          backgroundColor: '#666',
+          value: 0,
+          min: -20,
+          max: 20,
+          markColor: '#fff',
+          barrelColor: '#624',
+          barrelShowColor: '#d26',
+          fontColor: '#fff',
+          sub: 5,
+          barrel: {
+            x: 0.3,
+            y: 0.2,
+            width: 0.2,
+            height: 7 / 9,
+          },
+          form: [
+            {
+              key: 'value',
+              name: '温度',
+              type: 'number',
+            },
+            {
+              key: 'min',
+              name: '最小值',
+              type: 'number',
+            },
+            {
+              key: 'max',
+              name: '最大值',
+              type: 'number',
+            },
+            {
+              key: 'backgroundColor',
+              name: '背景颜色',
+              type: 'color',
+            },
+            {
+              key: 'markColor',
+              name: '刻度颜色',
+              type: 'color',
+            },
+            {
+              key: 'barrelColor',
+              name: '温度柱体背景颜色',
+              type: 'color',
+            },
+            {
+              key: 'barrelShowColor',
+              name: '温度柱体高亮颜色',
+              type: 'color',
+            },
+            {
+              key: 'fontColor',
+              name: '字体颜色',
+              type: 'color',
+            },
+            {
+              key: 'sub',
+              name: '最小间隔',
+              type: 'number',
+            },
+          ] as FormItemType[],
+        },
+      },
+      {
+        name: '水位',
+        icon: 'l-db',
+        data: {
+          name: 'waterTank',
+          width: 100,
+          height: 100,
+          disableAnchor: true,
+          color: '#FFFFFF00',
+          progress: 0.5,
+          progressColor: '#00a9e6',
+          form: [
+            {
+              key: 'progress',
+              name: '进度',
+              type: 'number',
+              step: 0.1,
+              max: 1,
+              min: 0,
+            },
+            {
+              key: 'progressColor',
+              name: '进度颜色',
+              type: 'color',
+            },
+          ],
+        },
+      },
+      {
+        name: '水流检查器',
+        icon: 'l-shuiliujianceqi',
+        data: {
+          name: 'watermeter',
+          width: 100,
+          height: 100,
+          disableAnchor: true,
+          background: '#faad14',
+          value: 50,
+          max: 70,
+          min: 20,
+          maxBackground: '#f5222d',
+          minBackground: '#52c41a',
+          // maxText: 'max',
+          // minText: 'min',
+          scaleShow: false,
+          form: [
+            {
+              key: 'value',
+              name: '流量',
+              type: 'number',
+            },
+            {
+              key: 'max',
+              name: '最大值',
+              type: 'number',
+            },
+            {
+              key: 'min',
+              name: '最小值',
+              type: 'number',
+            },
+            {
+              key: 'maxBackground',
+              name: '最大值背景',
+              type: 'color',
+            },
+            {
+              key: 'minBackground',
+              name: '最小值背景',
+              type: 'color',
+            },
+            {
+              key: 'scaleShow',
+              name: '显示刻度',
+              type: 'switch',
+            },
+            {
+              key: 'maxText',
+              name: '最大值文字',
+              type: 'text',
+            },
+            {
+              key: 'minText',
+              name: '最小值文字',
+              type: 'text',
+            },
+          ] as FormItemType[],
+        },
+      },
+      {
+        name: '指示灯',
+        icon: 'l-tuxingzhishideng1',
+        data: {
+          name: 'indicatorLight',
+          width: 100,
+          height: 100,
+          displayStatus: 0,
+          background: '#5ac8f7',
+          color: '#5ac8f7',
+          hiddenText: true,
+          form: [
+            {
+              key: 'displayStatus',
+              name: '显示状态',
+              type: 'select',
+              options: [
+                {
+                  label: '默认',
+                  value: 0,
+                },
+                {
+                  label: '方形',
+                  value: 1,
+                },
+                {
+                  label: '圆形',
+                  value: 2,
+                },
+              ],
+            },
+          ] as FormItemType[],
+          frames: [
+            {
+              background: '#f53e6c',
+              color: '#f53e6c',
+              bkType: 0,
+              duration: 200,
+              strokeType: 0,
+              visible: true,
+            },
+            {
+              background: '#5ac8f7',
+              color: '#5ac8f7',
+              bkType: 0,
+              duration: 200,
+              strokeType: 0,
+              visible: true,
+            },
+          ],
+          animateType: 'custom',
+        },
+      },
+      {
+        name: '闸刀',
+        icon: 'l-zhadao',
+        data: {
+          name: 'knifeSwitch',
+          width: 100,
+          height: 100,
+          switch: false,
+          color: '#0AECA140',
+          background: '#748E9140',
+          form: [
+            {
+              key: 'switch',
+              name: '开关',
+              type: 'switch',
+            },
+          ] as FormItemType[],
+          events: [
+            {
+              action: 5,
+              name: 'click',
+              value: 'window.meta2d.setValue({id:pen.id,switch:!pen.switch});',
+            },
+          ],
+        },
+      },
+      {
+        name: '拨动开关',
+        icon: 'l-bodongkaiguan',
+        data: {
+          name: 'toggleSwitch',
+          width: 100,
+          height: 100,
+          switch: false,
+          color: '#0AECA140',
+          background: '#748E9140',
+          form: [
+            {
+              key: 'switch',
+              name: '开关',
+              type: 'switch',
+            },
+          ] as FormItemType[],
+          events: [
+            {
+              action: 5,
+              name: 'click',
+              value:
+                'window.meta2d.setValue({id:pen.id,switch:!pen.switch});window.meta2d.render()',
+            },
+          ],
+        },
+      },
+      {
+        name: '空气开关',
+        icon: 'l-bodongkaiguan',
+        data: {
+          name: 'airSwitch',
+          width: 180,
+          height: 240,
+          switch: true,
+          color: '#748E9173',
+          background: '#748E9140',
+
+          form: [
+            {
+              key: 'switch',
+              name: '开关',
+              type: 'switch',
+            },
+          ] as FormItemType[],
+          // events: [
+          //   {
+          //     action: 5,
+          //     name: 'click',
+          //     value:
+          //       'window.meta2d.setValue({id:pen.id,switch:!pen.switch});window.meta2d.render()',
+          //   },
+          // ],
+        },
+      },
+    ],
+  },
+];
+
+export const cases: any[] = [
+  { name: '智慧物联' },
+  { name: '电力能源' },
+  { name: '智慧水务' },
+  { name: '智慧工厂' },
+  { name: '智慧校园' },
+  { name: '智慧园区' },
+  { name: '智慧交通' },
+  { name: '智慧城市' },
+  { name: '智慧农业' },
+  { name: '电信机房' },
+  { name: '航天航空' },
+  { name: '智能家居' },
+];

+ 81 - 0
src/services/excel.ts

@@ -0,0 +1,81 @@
+import ExcelJS from 'exceljs';
+import { saveAs } from 'file-saver';
+import { MessagePlugin } from 'tdesign-vue-next';
+
+export async function importExcel(columns: any[]) {
+  return new Promise((resolve, reject) => {
+    const input = document.createElement('input');
+    input.type = 'file';
+    input.accept =
+      '.csv, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel,application/zip';
+    input.onchange = (event) => {
+      const elem: any = event.target;
+
+      if (elem.files && elem.files[0].name.indexOf('.xlsx') > 0) {
+        const workbook = new ExcelJS.Workbook();
+        const reader = new FileReader();
+        reader.readAsArrayBuffer(elem.files[0]);
+        reader.onload = async () => {
+          const buffer: any = reader.result;
+          await workbook.xlsx.load(buffer);
+          // 默认只解析第一个sheet
+          const worksheet = workbook.worksheets[0];
+          // 获取sheet1的所有rows
+          const rows = worksheet.getSheetValues();
+          if (rows.length === 0) {
+            MessagePlugin.warning('导入的excel文件不可为空!');
+            return;
+          }
+          let data: any = [];
+          let indexKeyMap: any = {};
+          worksheet.eachRow((row, rowNumber) => {
+            let _data: any = {};
+            row.eachCell((cell, colNumber) => {
+              if (rowNumber === 1) {
+                let _index = columns.findIndex(
+                  (item) => item.header === cell.value
+                );
+                if (_index !== -1) {
+                  indexKeyMap[colNumber] = columns[_index].key;
+                }
+              } else {
+                _data[indexKeyMap[colNumber]] = cell.value;
+              }
+            });
+            if (rowNumber !== 1) {
+              data.push(_data);
+            }
+          });
+          resolve(data);
+          MessagePlugin.success('导入成功!');
+        };
+      }
+    };
+    input.click();
+  });
+}
+
+export function saveAsExcel(
+  name: string,
+  columns: { header: string; key: string }[],
+  data: any[]
+) {
+  const fileName = `${name}.xlsx`;
+  const workbook = new ExcelJS.Workbook();
+  // workbook.creator = 'Me';
+  // workbook.lastModifiedBy = 'Her';
+  workbook.created = new Date();
+  // workbook.modified = new Date();
+  // workbook.lastPrinted = new Date();
+  const worksheet = workbook.addWorksheet(`${name}`);
+  worksheet.columns = columns;
+
+  worksheet.addRows(data);
+  workbook.xlsx.writeBuffer().then((data) => {
+    const blob = new Blob([data], {
+      type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=UTF-8',
+    });
+    saveAs(blob, fileName);
+    MessagePlugin.success('导出成功!');
+  });
+}

+ 55 - 5
src/services/file.ts

@@ -1,13 +1,19 @@
 import axios from 'axios';
 
-export async function upload(blob: Blob, shared = false, filename = '') {
+export async function upload(
+  blob: Blob,
+  shared = false,
+  filename = 'thumb.png',
+  directory = '/2D/缩略图'
+) {
   const form = new FormData();
-  form.append('directory', filename);
+  form.append('filename', filename);
+  form.append('directory', directory);
   form.append('public', shared + '');
   form.append('file', blob);
-  const ret: any = await axios.post('/api/image', form);
-  if (ret.error) {
-    return null;
+  const ret: any = await axios.post('/api/image/upload', form);
+  if (!ret) {
+    return;
   }
 
   return ret;
@@ -47,3 +53,47 @@ export function formatBytes(size: number) {
 
   return Math.round(n * 100) / 100 + ' ' + units[l];
 }
+
+/**
+ * 读取文件内容,返回字符串
+ * @param file 文件
+ */
+export async function readFile(file: Blob) {
+  return new Promise<string>((resolve, reject) => {
+    const reader = new FileReader();
+    reader.onload = () => {
+      resolve(reader.result as string);
+    };
+    reader.onerror = reject;
+    // readAsText 使 result 一定是字符串
+    reader.readAsText(file);
+  });
+}
+
+export function dataURLtoBlob(base64: string) {
+  let arr: any = base64.split(','),
+    mime = arr[0].match(/:(.*?);/)[1],
+    bstr = atob(arr[1]),
+    n = bstr.length,
+    u8arr = new Uint8Array(n);
+  while (n--) {
+    u8arr[n] = bstr.charCodeAt(n);
+  }
+  return new Blob([u8arr], { type: mime });
+}
+
+/**
+ * 图片转 Blob
+ * @param img 图片
+ */
+export function saveToBlob(img: HTMLImageElement): Blob {
+  const canvas: HTMLCanvasElement = document.createElement('canvas');
+  canvas.setAttribute('origin-clean', 'false');
+  canvas.width = img.width;
+  canvas.height = img.height;
+
+  const context: any = canvas.getContext('2d');
+  context.filter = window.getComputedStyle(img).filter;
+  context.drawImage(img, 0, 0, canvas.width, canvas.height);
+  return dataURLtoBlob(canvas.toDataURL());
+}

+ 48 - 0
src/services/icons.ts

@@ -0,0 +1,48 @@
+import axios from '@/http';
+import { parseSvg } from '@meta2d/svg';
+import { cdn } from './api';
+import { getFolders } from './png';
+
+const normalFolder = import.meta.env.VITE_BASEURL ? '/2d/svg/' : '/svg/';
+/**
+ * 请求 svg 的目录
+ * @returns
+ */
+export async function getIconFolders() {
+  return await getFolders(normalFolder);
+}
+/**
+ * 请求 svg 目录下的所有 svg
+ * @param name 目录名
+ * @returns
+ */
+export async function getIcons(name: string) {
+  const files = (await axios.get(normalFolder + name + '/')) as any[];
+  return await Promise.all(files.map((f) => svgToPens(f, name)));
+}
+
+export function filename(str: string) {
+  const i = str.lastIndexOf('.');
+  return str.substring(0, i);
+}
+
+async function svgToPens(f: any, diretoryName: string) {
+  const _name = filename(f.name);
+  const name = globalThis.fileJson
+    ? globalThis.fileJson[_name]
+      ? globalThis.fileJson[_name]
+      : _name
+    : _name;
+  const image = cdn + normalFolder + diretoryName + '/' + f.name;
+  const svgDom: string = await axios.get(image);
+  let _svgDom = svgDom.replace('stroke:#333;', 'stroke:#bdc7db;');
+  const data = parseSvg(_svgDom);
+  return {
+    name,
+    pinyin: globalThis.fileJson ? _name : null,
+    // image, // image 只作为缩略图
+    componentDatas: data,
+    svg: _svgDom,
+    component: true,
+  };
+}

+ 68 - 0
src/services/png.ts

@@ -0,0 +1,68 @@
+import axios from '@/http';
+import { cdn } from './api';
+
+const market = import.meta.env.VITE_BASEURL;
+
+const normalFolder = market ? '/2d/png/' : '/png/';
+/**
+ * 请求 png 的目录
+ * @returns
+ */
+export async function getPngFolders() {
+  return await getFolders(normalFolder);
+}
+
+/**
+ * 请求 png 目录下的所有 png
+ * @param name 目录名
+ * @returns
+ */
+export async function getPngs(name: string) {
+  const files = (await axios.get(normalFolder + name + '/')) as any[];
+  if (!files || !files.map) {
+    return [];
+  }
+  return files.map((f) => {
+    let fname = filename(f.name);
+    return {
+      name: globalThis.fileJson
+        ? globalThis.fileJson[fname]
+          ? globalThis.fileJson[fname]
+          : fname
+        : fname,
+      pinyin: globalThis.fileJson ? fname : null,
+      image: cdn + normalFolder + name + '/' + f.name,
+    };
+  });
+}
+
+export async function getFolders(folderName: string) {
+  const ret = (await axios.get(folderName)) as any[];
+
+  if (!ret || !ret.map) {
+    return [];
+  }
+  return await Promise.all(
+    ret.map(async (c: any) => {
+      const files = (await axios.get(folderName + c.name + '/')) as any[];
+      return {
+        name: globalThis.folderJson
+          ? globalThis.folderJson[c.name]
+            ? globalThis.folderJson[c.name]
+            : c.name
+          : c.name,
+        pinyin: globalThis.folderJson ? c.name : null,
+        show: true,
+        list: [],
+        count: files.length,
+        // 用于区别 png 与 svg 文件夹
+        svgFolder: folderName === normalFolder ? true : false,
+      };
+    })
+  );
+}
+
+export function filename(str: string) {
+  const i = str.lastIndexOf('.');
+  return str.substring(0, i);
+}

+ 9 - 18
src/services/register.ts

@@ -1,22 +1,15 @@
-import {
-  Meta2d,
-  register,
-  registerAnchors,
-  registerCanvasDraw,
-} from "@meta2d/core";
-import { flowPens, flowAnchors } from "@meta2d/flow-diagram";
+import { register, registerAnchors, registerCanvasDraw } from '@meta2d/core';
+import { flowPens, flowAnchors } from '@meta2d/flow-diagram';
 import {
   activityDiagram,
   activityDiagramByCtx,
-} from "@meta2d/activity-diagram";
-import { classPens } from "@meta2d/class-diagram";
-import { sequencePens, sequencePensbyCtx } from "@meta2d/sequence-diagram";
-import { register as registerEcharts } from "@meta2d/chart-diagram";
-import { registerHighcharts } from "@meta2d/chart-diagram";
-import { registerLightningChart } from "@meta2d/chart-diagram";
-import { formPens } from "@meta2d/form-diagram";
-import { chartsPens } from "@meta2d/le5le-charts";
-import { ftaPens, ftaPensbyCtx, ftaAnchors } from "@meta2d/fta-diagram";
+} from '@meta2d/activity-diagram';
+import { classPens } from '@meta2d/class-diagram';
+import { sequencePens, sequencePensbyCtx } from '@meta2d/sequence-diagram';
+import { register as registerEcharts } from '@meta2d/chart-diagram';
+import { formPens } from '@meta2d/form-diagram';
+import { chartsPens } from '@meta2d/le5le-charts';
+import { ftaPens, ftaPensbyCtx, ftaAnchors } from '@meta2d/fta-diagram';
 
 export function registerBasicDiagram() {
   register(flowPens());
@@ -27,8 +20,6 @@ export function registerBasicDiagram() {
   register(sequencePens());
   registerCanvasDraw(sequencePensbyCtx());
   registerEcharts();
-  registerHighcharts();
-  registerLightningChart();
   registerCanvasDraw(formPens());
   registerCanvasDraw(chartsPens());
   register(ftaPens());

+ 14 - 6
src/services/selections.ts

@@ -1,3 +1,4 @@
+import { Pen } from '@meta2d/core';
 import { reactive } from 'vue';
 
 export enum SelectionMode {
@@ -6,7 +7,11 @@ export enum SelectionMode {
   Pens,
 }
 
-const selections = reactive<any>({
+const selections = reactive<{
+  mode: SelectionMode;
+  pen?: Pen;
+  pens?: Pen[];
+}>({
   // 选中对象类型:0 - 画布;1 - 单个图元;2 - 多选
   mode: SelectionMode.File,
   pen: undefined,
@@ -14,18 +19,21 @@ const selections = reactive<any>({
 });
 
 export const useSelection = () => {
-  const select = (data: any) => {
-    if (!data) {
+  const select = (pens?: Pen[]) => {
+    if (!pens || !pens.length) {
       selections.mode = SelectionMode.File;
       selections.pen = undefined;
       selections.pens = undefined;
-    } else if (Array.isArray(data)) {
+      return;
+    }
+
+    if (pens.length > 1) {
       selections.mode = SelectionMode.Pens;
       selections.pen = undefined;
-      selections.pens = data;
+      selections.pens = pens;
     } else {
       selections.mode = SelectionMode.Pen;
-      selections.pen = data;
+      selections.pen = pens[0];
       selections.pens = undefined;
     }
   };

+ 53 - 0
src/services/theme.ts

@@ -0,0 +1,53 @@
+export const themes: any = {
+  dark: {
+    '--color': '#bdc7db',
+    '--color-title': '#e3e8f4',
+    '--color-desc': '#617b91',
+    '--color-gray': '#4f5b75',
+    '--color-background': '#1e2430',
+    '--color-background-active': '#161f2c',
+    '--color-background-hover': '#181f29',
+    '--color-background-input': '#303746',
+    '--color-background-popup': '#303746',
+    '--color-background-popup-hover': '#454f64',
+    '--color-border': '#000000',
+    '--color-border-input': '#535f79',
+    '--color-border-input-hover': '#454f64',
+    '--color-scrollbar': '#dad7d7',
+    '--color-scrollbar-hover': '#e5e5e5',
+    '--td-component-border': '#42516c',
+    '--color-background-editor': '#181b24',
+    '--color-border-editor': '#1e2430',
+    '--color-border-popup': 'transparent',
+    '--shadow-popup': 'none',
+  },
+  light: {
+    '--color': '#595959',
+    '--color-title': '#262626',
+    '--color-desc': '#8c8c8c',
+    '--color-gray': '#bfbfbf',
+    '--color-background': '#ffffff',
+    '--color-background-active': '#f7f8f9',
+    '--color-background-hover': '#181f29',
+    '--color-background-input': '#303746',
+    '--color-background-popup': '#ffffff',
+    '--color-background-popup-hover': '#454f64',
+    '--color-border': '#000000',
+    '--color-border-input': '#535f79',
+    '--color-border-input-hover': '#454f64',
+    '--color-scrollbar': '#dad7d7',
+    '--color-scrollbar-hover': '#e5e5e5',
+    '--td-component-border': '#42516c',
+    '--color-background-editor': '#181b24',
+    '--color-border-editor': '#1e2430',
+    '--color-border-popup': '#dddddd',
+    '--shadow-popup': '0 5px 8px 0px rgb(0 0 0 / 10%)',
+  },
+};
+
+export function switchTheme(themeName: string) {
+  let theme = themes[themeName];
+  for (let key in theme) {
+    document.documentElement.style.setProperty(key, theme[key]);
+  }
+}

+ 46 - 0
src/services/transfrom.ts

@@ -0,0 +1,46 @@
+import { lineDashObj, normalAnimate } from "./defaults";
+import { LineAnimateType } from "@meta2d/core";
+
+/*
+ * @Description:
+ * @Author: G
+ * @Date: 2021-10-18 17:17:37
+ * @LastEditTime: 2021-10-27 10:48:48
+ */
+export function transAnchorId(oldId: string | number) {
+  let id = Number(oldId);
+  if (id === 0) {
+    return 3;
+  }
+  return id - 1;
+}
+
+export function transLineDash(oldDash: number) {
+  return lineDashObj[oldDash];
+}
+
+// oldType: '', 'beads', 'dot', 'comet'
+export function transLineAnimateType(oldType: string) {
+  switch (oldType) {
+    case "beads":
+      return LineAnimateType.Beads;
+    case "dot":
+      return LineAnimateType.Dot;
+    case "comet": // TODO: 彗星在新版本中没有了,转换成默认动画
+      return LineAnimateType.Normal;
+    default:
+      return LineAnimateType.Normal;
+  }
+}
+
+export function transNodeAnimateFrame(node: any) {
+  return normalAnimate[node.animateType] || [];
+}
+
+export function transAutoPlay(el: any) {
+  if (el.hasOwnProperty("animatePlay")) {
+    return el.animatePlay;
+  } else {
+    return el.playLoop;
+  }
+}

+ 805 - 0
src/services/upgrade.ts

@@ -0,0 +1,805 @@
+import {
+  transAnchorId,
+  transLineAnimateType,
+  transLineDash,
+  transNodeAnimateFrame,
+  transAutoPlay,
+} from "./transfrom";
+import {
+  Meta2d,
+  Event,
+  EventAction,
+  calcRelativePoint,
+  isEqual,
+  Pen,
+  s8,
+  EventName,
+} from "@meta2d/core";
+import { isGif, Meta2dBackData } from "./utils";
+type Point = any;
+type Rect = any;
+
+export const baseVer = "1.0.0";
+declare const meta2d: Meta2d;
+
+enum LineSType {
+  Line = "line",
+  Polyline = "polyline",
+  Curve = "curve",
+  Mind = "mind",
+}
+/**
+ * 旧版本到新版本的格式转换函数
+ * 该方法存在定时器,随后执行的 meta2d.open 必须是同步代码
+ */
+export function upgrade(data: any, baseVer: string): Meta2dBackData {
+  const newData: Meta2dBackData = {
+    x: data.x ?? 0,
+    y: data.y ?? 0,
+    version: baseVer,
+    scale: data.scale,
+    name: data.name,
+    background: data.bkColor,
+    grid: data.grid,
+    gridColor: data.gridColor,
+    gridSize: data.gridSize,
+    locked: data.locked,
+    rule: data.rule,
+    ruleColor: data.ruleColor,
+    pens: convertPen(data.pens, data.scale),
+    folder: data.folder,
+    component: data.component,
+    class: data.class,
+    id: data.id,
+    websocket: data.websocket,
+    mqtt: data.mqttUrl,
+    mqttOptions: data.mqttOptions || {},
+    mqttTopics: data.mqttTopics,
+    shared: data.shared,
+    initJs: data.initJS,
+    origin: { x: 0, y: 0 },
+    center: { x: 0, y: 0 },
+    userId: data.userId,
+    owner: {
+      id: data.userId,
+    },
+    image: data.image,
+  };
+  return newData;
+}
+// pen格式转换
+export function convertPen(
+  pens: any[],
+  scale = 1,
+  parentEl?: any,
+  options: any = [],
+  level = 0
+) {
+  pens?.forEach((el, i) => {
+    const obj: Pen = {};
+    obj.id = el.id;
+    obj.tags = el.tags;
+    if (!el.type && el.name === "line") {
+      const { y, height } = el.rect;
+      el.rect.y = y + height / 2;
+      el.rect.height = 0;
+    }
+    if (el.type === 1 && !parentEl) {
+      // 元素为线,并且不属于组合图形
+      if (!setLineData(el, obj, scale)) {
+        // 本次数据丢弃,脏数据
+        return false;
+      }
+    } else {
+      if (!setNodeData(el, obj, parentEl, scale)) {
+        return false;
+      }
+      if (obj.name === "combine") {
+        convertPen(el.children, scale, el, options, level++);
+      }
+    }
+    obj.paddingLeft = el.paddingLeft;
+    obj.paddingRight = el.paddingRight;
+    obj.paddingTop = el.paddingTop;
+    obj.paddingBottom = el.paddingBottom;
+    obj.color = el.strokeStyle;
+    obj.lineWidth = el.lineWidth / scale;
+    obj.lineCap = el.lineCap;
+    obj.lineDash = transLineDash(el.dash);
+    obj.borderColor = el.borderColor;
+    obj.borderWidth = el.borderWidth / scale;
+    obj.rotate = el.rotate;
+    obj.visible = el.visible;
+    obj.title = el.title || el.markdown;
+    obj.background = el.fillStyle;
+    obj.lineHeight = el.lineHeight;
+
+    if (el.font) {
+      // 老版本存在 font ,把 font 抽出来
+      Object.assign(el, { ...el.font });
+    }
+
+    obj.fontSize = el.fontSize / scale;
+    obj.fontFamily = el.fontFamily;
+    obj.fontStyle = el.fontStyle;
+    obj.fontWeight = el.fontWeight;
+
+    obj.text = el.text;
+    obj.textColor = el.fontColor;
+    obj.textAlign = el.textAlign;
+    obj.textBaseline = el.textBaseline;
+    obj.textBackground = el.textBackground;
+    obj.whiteSpace = el.whiteSpace;
+
+    obj.animateSpan = el.animateSpan;
+    obj.animateColor = el.animateColor;
+    obj.animateCycle = el.animateCycle;
+    obj.animateReverse = el.animateReverse;
+    obj.nextAnimate = el.nextAnimate;
+    obj.lineAnimateType = transLineAnimateType(el.animateType);
+    obj.animateDotSize = el.animateDotSize;
+    obj.animateLineDash = el.animateLineDash;
+    (obj as any).animateType = el.animateType;
+    obj.frames = transNodeAnimateFrame(el);
+    obj.autoPlay = transAutoPlay(el);
+    obj.playLoop = el.playLoop;
+
+    obj.globalAlpha = el.globalAlpha;
+    obj.bkType = el.bkType;
+    obj.gradientFromColor = el.gradientFromColor;
+    obj.gradientToColor = el.gradientToColor;
+    obj.gradientAngle = el.gradientAngle;
+    obj.gradientRadius = el.gradientRadius;
+    obj.borderRadius = el.borderRadius;
+
+    obj.events = convertEvents(el.events || [], el.wheres || []);
+    options.push(obj);
+  });
+  return options;
+}
+
+function setNodeData(el: any, obj: any, parentEl?: any, scale = 1): boolean {
+  if (parentEl) {
+    obj.parentId = parentEl.id;
+    obj.locked = 2;
+  }
+  if (isLine(el.name) || isPen(el.name)) {
+    if (!setLineData(el, obj, scale)) {
+      return false;
+    }
+    obj.type = 0;
+    obj.close = el.closePath;
+  } else {
+    if (el.name === "combine") {
+      obj.name = "combine";
+      obj.children = el.children.map((child: any) => child.id);
+    } else {
+      obj.type = 0;
+      obj.name = el.name;
+      if (el.icon) {
+        obj.icon = el.icon;
+        obj.iconColor = el.iconColor;
+        obj.iconFamily = el.iconFamily;
+        obj.iconSize = el.iconSize / scale;
+      }
+      // 处理图片节点
+      if (el.image) {
+        obj.image = el.image;
+        obj.imageRatio = el.imageRatio;
+        obj.iconAlign = el.imageAlign;
+        obj.iconWidth = el.imageWidth / scale;
+        obj.iconHeight = el.imageHeight / scale;
+        isGif(el.image) && (obj.name = "gif");
+      }
+      //  处理echarts节点
+      if (el.name === "echarts") {
+        obj.echarts = el.data.echarts;
+      }
+      // 处理iframe节点
+      if (el.iframe) {
+        obj.name = "iframe";
+        obj.iframe = el.iframe;
+      }
+      // 处理视频节点
+      if (el.video) {
+        obj.name = "video";
+        obj.video = el.video;
+      }
+      // 处理音频节点
+      if (el.audio) {
+        obj.name = "video";
+        obj.audio = el.audio;
+      }
+    }
+  }
+  let rect = { ...el.rect };
+  if (parentEl) {
+    if (obj.name === "line") {
+      rect = calcRelativeRect(obj, parentEl.rect);
+    } else {
+      rect = calcRelativeRect(el.rect, parentEl.rect);
+    }
+  }
+
+  obj.x = rect.x;
+  obj.y = rect.y;
+  if (isEqual(rect.width, 1)) {
+    // 数据库中存在 1.0000000000000004 的值,认为是 1
+    obj.width = 1;
+  } else {
+    obj.width = rect.width;
+  }
+  obj.height = rect.height;
+  return true;
+}
+
+function setLineData(el: any, obj: any, scale = 1): boolean {
+  obj.name = "line";
+  obj.type = 1;
+  obj.fromArrow = el.fromArrow;
+  // TODO: 老版本转换新版本,箭头大小不设置,采用默认,有问题用户自行设置
+  // obj.fromArrowSize = el.fromArrowSize;
+  obj.toArrow = el.toArrow;
+  // obj.toArrowSize = el.toArrowSize;
+  let calcAnchors = [];
+  if (isPolyline(el.name)) {
+    calcAnchors = getPolylineCalcAnchors(el);
+  } else if (isCurveline(el.name)) {
+    calcAnchors = getCurvelineCalcAnchors(el);
+  } else if (isPen(el.name)) {
+    calcAnchors = getPenCalcAnchors(el);
+  }
+  if (!calcAnchors.length) {
+    // 长度为空,说明是脏数据,不转换
+    return false;
+  }
+  const rect = getLineRect(el, calcAnchors);
+  calcCenter(rect);
+  const anchors: Point = [];
+  calcAnchors.forEach((pt: Point) => {
+    anchors.push(calcRelativePoint(pt, rect));
+  });
+  obj.x = rect.x;
+  obj.y = rect.y;
+  obj.width = rect.width;
+  obj.height = rect.height;
+  obj.anchors = anchors;
+  if (el.textRect && anchors.length > 3) {
+    let textRatioX =
+      rect.width <= 1
+        ? 0
+        : ((el.fontSize / scale) * el.text.length) / rect.width;
+    let textRatioY =
+      rect.height <= 1
+        ? 0
+        : ((el.fontSize / scale) * el.lineHeight) / rect.height;
+    //老版本文字默认在最后两个控制点中心
+    let end = anchors[anchors.length - 2];
+    let start = anchors[anchors.length - 3];
+    obj.textLeft = (start.x + end.x - textRatioX) / 2;
+    if (obj.textLeft === 1) {
+      obj.textLeft = 0.99999;
+    }
+    obj.textTop = (start.y + end.y - textRatioY) / 2;
+    if (obj.textTop === 1) {
+      obj.textTop = 0.99999;
+    }
+    if (rect.width <= 1) {
+      obj.textLeft = ((-el.fontSize / scale / scale) * el.text.length) / 2;
+    }
+    if (rect.height <= 1) {
+      obj.textTop = ((-el.fontSize / scale / scale) * el.lineHeight) / 2;
+    }
+    obj.textWidth = rect.width;
+    el.textAlign = "left";
+    el.textBaseline = "top";
+  }
+  setControledLines(el, calcAnchors);
+  return true;
+}
+
+function getPolylineCalcAnchors(el: any) {
+  const { from, to } = getLineFromTo(el);
+  const calcAnchors = [
+    {
+      id: s8(),
+      connectTo: from.id,
+      penId: el.id,
+      x: from.x,
+      y: from.y,
+      anchorId: String(from.anchorIndex),
+    },
+    ...(el.controlPoints || []).map((p: Point) => ({
+      id: s8(),
+      connectTo: undefined,
+      penId: el.id,
+      x: p.x,
+      y: p.y,
+    })),
+    {
+      id: s8(),
+      connectTo: to.id,
+      penId: el.id,
+      x: to.x,
+      y: to.y,
+      anchorId: String(to.anchorIndex),
+    },
+  ];
+  return calcAnchors;
+}
+
+function getCurvelineCalcAnchors(el: any) {
+  const [nextPoint, prevPoint] = el.controlPoints;
+  const { from, to } = getLineFromTo(el);
+  const calcAnchors = [
+    {
+      id: s8(),
+      connectTo: from.id,
+      penId: el.id,
+      x: from.x,
+      y: from.y,
+      next: {
+        connectTo: from.id,
+        penId: el.id,
+        x: nextPoint.x,
+        y: nextPoint.y,
+      },
+      prev: {
+        connectTo: from.id,
+        penId: el.id,
+        x: 2 * from.x - nextPoint.x,
+        y: 2 * from.y - nextPoint.y,
+      },
+    },
+    {
+      id: s8(),
+      connectTo: to.id,
+      penId: el.id,
+      x: to.x,
+      y: to.y,
+      prev: {
+        connectTo: to.id,
+        penId: el.id,
+        x: prevPoint.x,
+        y: prevPoint.y,
+      },
+      next: {
+        connectTo: to.id,
+        penId: el.id,
+        x: 2 * to.x - prevPoint.x,
+        y: 2 * to.y - prevPoint.y,
+      },
+    },
+  ];
+  return calcAnchors;
+}
+
+function getPenCalcAnchors(el: any) {
+  if (el.children && el.children.length > 0) {
+    return el.children.reduce(
+      (arr: Point[], child: any, i: number, children: any) => {
+        if (i === 0) {
+          arr.push({
+            connectTo: undefined,
+            penId: el.id,
+            x: child.from.x,
+            y: child.from.y,
+          });
+        }
+        arr.push({
+          connectTo: undefined,
+          penId: el.id,
+          x: child.to.x,
+          y: child.to.y,
+        });
+        return arr;
+      },
+      []
+    );
+  }
+  if (el.points && el.points.length > 0) {
+    return el.points.reduce((arr: any, point: any, i: number, points: any) => {
+      let flag = false;
+      if (i > 0) {
+        const prev = points[i - 1];
+        if (point.x !== prev.x && point.y !== prev.y) {
+          flag = true;
+        }
+      } else {
+        flag = true;
+      }
+      if (flag) {
+        arr.push({
+          connectTo: undefined,
+          penId: el.id,
+          x: point.x,
+          y: point.y,
+        });
+      }
+      return arr;
+    }, []);
+  }
+  return [];
+}
+
+function getLineRect(pen: Pen, calcAnchors: any) {
+  getLineLength(pen, calcAnchors);
+  return getRectOfPoints(getLinePoints(pen, calcAnchors));
+}
+
+function getLineLength(pen: Pen, calcAnchors: any): number {
+  if (calcAnchors.length < 2) {
+    return 0;
+  }
+
+  let len = 0;
+  let from: Point;
+  calcAnchors.forEach((pt: Point) => {
+    if (from) {
+      from.lineLength = lineLen(from, from.next, pt.prev, pt);
+      len += from.lineLength;
+    }
+    from = pt;
+  });
+  if (pen.close) {
+    let to = calcAnchors[0];
+    from.lineLength = lineLen(from, from.next, to.prev, to);
+    len += from.lineLength;
+  }
+  pen.length = len;
+  return len;
+}
+
+function getLinePoints(pen: Pen, calcAnchors: any) {
+  const pts: Point[] = [];
+  let from: Point;
+  calcAnchors.forEach((pt: Point) => {
+    pts.push(pt);
+    from && pts.push(...getPoints(from, pt, pen));
+    from = pt;
+  });
+  if (pen.close && calcAnchors.length > 1) {
+    pts.push(...getPoints(from, calcAnchors[0], pen));
+  }
+  return pts;
+}
+
+function getRectOfPoints(points: Point[]): Rect {
+  let x = Infinity;
+  let y = Infinity;
+  let ex = -Infinity;
+  let ey = -Infinity;
+
+  points.forEach((item) => {
+    x = Math.min(x, item.x);
+    y = Math.min(y, item.y);
+    ex = Math.max(ex, item.x);
+    ey = Math.max(ey, item.y);
+  });
+  return { x, y, ex, ey, width: ex - x || 1, height: ey - y || 1 };
+}
+
+function getPoints(from: Point, to: Point, pen?: Pen) {
+  const pts: Point[] = [];
+  if (!to) {
+    return pts;
+  }
+
+  let step = 0.02;
+  if (from.lineLength) {
+    let r = 4;
+    if (pen && pen.lineWidth) {
+      r += pen.lineWidth / 2;
+    }
+    step = r / from.lineLength;
+  }
+  if (from.next) {
+    if (to.prev) {
+      for (let i = step; i < 1; i += step) {
+        pts.push(getBezierPoint(i, from, from.next, to.prev, to));
+      }
+    } else {
+      for (let i = step; i < 1; i += step) {
+        pts.push(getQuadraticPoint(i, from, from.next, to));
+      }
+    }
+  } else {
+    if (to.prev) {
+      for (let i = step; i < 1; i += step) {
+        pts.push(getQuadraticPoint(i, from, to.prev, to));
+      }
+    } else {
+      pts.push({ x: to.x, y: to.y });
+    }
+  }
+  if (pts.length > 1) {
+    from.curvePoints = pts;
+  }
+
+  return pts;
+}
+
+function getBezierPoint(
+  step: number,
+  from: Point,
+  cp1: Point,
+  cp2: Point,
+  to: Point
+) {
+  const { x: x1, y: y1 } = from;
+  const { x: x2, y: y2 } = to;
+  const { x: cx1, y: cy1 } = cp1;
+  const { x: cx2, y: cy2 } = cp2;
+
+  const pos = 1 - step;
+  const x =
+    x1 * pos * pos * pos +
+    3 * cx1 * step * pos * pos +
+    3 * cx2 * step * step * pos +
+    x2 * step * step * step;
+  const y =
+    y1 * pos * pos * pos +
+    3 * cy1 * step * pos * pos +
+    3 * cy2 * step * step * pos +
+    y2 * step * step * step;
+  return { x, y, step };
+}
+
+function getQuadraticPoint(step: number, from: Point, cp: Point, to: Point) {
+  const pos = 1 - step;
+  const x = pos * pos * from.x + 2 * pos * step * cp.x + step * step * to.x;
+  const y = pos * pos * from.y + 2 * pos * step * cp.y + step * step * to.y;
+  return { x, y, step };
+}
+
+function lineLen(from: Point, cp1?: Point, cp2?: Point, to?: Point): number {
+  if (!cp1 && !cp2) {
+    return (
+      Math.sqrt(
+        Math.pow(Math.abs(from.x - to.x), 2) +
+          Math.pow(Math.abs(from.y - to.y), 2)
+      ) || 0
+    );
+  }
+
+  const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
+  if (cp1 && cp2) {
+    path.setAttribute(
+      "d",
+      `M${from.x} ${from.y} C${cp1.x} ${cp1.y} ${cp2.x} ${cp2.y} ${to.x} ${to.y}`
+    );
+  } else if (cp1) {
+    path.setAttribute(
+      "d",
+      `M${from.x} ${from.y} Q${cp1.x} ${cp1.y} ${to.x} ${to.y}`
+    );
+  } else {
+    path.setAttribute(
+      "d",
+      `M${from.x} ${from.y} Q${cp2.x} ${cp2.y} ${to.x} ${to.y}`
+    );
+  }
+  return path.getTotalLength() || 0;
+}
+
+function calcRelativeRect(rect: Rect, worldRect: Rect) {
+  const relRect: Rect = {
+    x: (rect.x - worldRect.x) / worldRect.width || 0,
+    y: (rect.y - worldRect.y) / worldRect.height || 0,
+    width: rect.width / worldRect.width || 0,
+    height: rect.height / worldRect.height || 0,
+  };
+  relRect.ex = relRect.x + relRect.width;
+  relRect.ey = relRect.y + relRect.height;
+
+  return relRect;
+}
+
+function calcCenter(rect: Rect) {
+  if (!rect.center) {
+    rect.center = {} as Point;
+  }
+  rect.center.x = rect.x + rect.width / 2;
+  rect.center.y = rect.y + rect.height / 2;
+}
+
+// 锚点无需转换的 画笔类型
+const notTransformPens = ["triangle", "mindNode", "mindLine"];
+
+function setControledLines(el: any, calcAnchors: any) {
+  setTimeout(() => {
+    // 等节点被添加到数据中后,将节点与连线关联
+    const [fromNode] = meta2d.find((el.from && el.from.id) || "");
+    const [toNode] = meta2d.find((el.from && el.to.id) || "");
+    if (fromNode && fromNode.type === 0) {
+      if (!fromNode.connectedLines) {
+        fromNode.connectedLines = [];
+      }
+      fromNode.connectedLines.push({
+        anchor: `${
+          !notTransformPens.includes(fromNode.name || "")
+            ? transAnchorId(el.from.anchorIndex)
+            : el.from.anchorIndex
+        }`,
+        lineAnchor: calcAnchors[0].id,
+        lineId: el.id,
+      });
+    }
+    if (toNode && toNode.type === 0) {
+      if (!toNode.connectedLines) {
+        toNode.connectedLines = [];
+      }
+      toNode.connectedLines.push({
+        anchor: `${
+          !notTransformPens.includes(toNode.name || "")
+            ? transAnchorId(el.to.anchorIndex)
+            : el.to.anchorIndex
+        }`,
+        lineAnchor: calcAnchors[1].id,
+        lineId: el.id,
+      });
+    }
+  }, 0);
+}
+
+function isLine(name: string) {
+  return (
+    {
+      [LineSType.Curve]: true,
+      [LineSType.Mind]: true,
+      [LineSType.Line]: true,
+      [LineSType.Polyline]: true,
+    }[name] || false
+  );
+}
+
+function isPolyline(name: string) {
+  return (
+    {
+      [LineSType.Line]: true,
+      [LineSType.Polyline]: true,
+    }[name] || false
+  );
+}
+
+function isCurveline(name: string) {
+  return (
+    {
+      [LineSType.Curve]: true,
+      [LineSType.Mind]: true,
+    }[name] || false
+  );
+}
+
+function isPen(name: string) {
+  return name === "lines" || name === "graffiti";
+}
+
+// 版本比较函数
+export function compareVersion(version1: string, version2: string) {
+  const arr1 = version1.split(".");
+  const arr2 = version2.split(".");
+  const length1 = arr1.length;
+  const length2 = arr2.length;
+  const minlength = Math.min(length1, length2);
+  let i = 0;
+  for (i; i < minlength; i++) {
+    let a = parseInt(arr1[i]);
+    let b = parseInt(arr2[i]);
+    if (a > b) {
+      return 1;
+    } else if (a < b) {
+      return -1;
+    }
+  }
+  if (length1 > length2) {
+    for (let j = i; j < length1; j++) {
+      if (parseInt(arr1[j]) != 0) {
+        return 1;
+      }
+    }
+    return 0;
+  } else if (length1 < length2) {
+    for (let j = i; j < length2; j++) {
+      if (parseInt(arr2[j]) != 0) {
+        return -1;
+      }
+    }
+    return 0;
+  }
+  return 0;
+}
+
+function getLineFromTo(el: any) {
+  const rect = el.rect;
+  const from = el.from || {
+    x: rect.x,
+    y: rect.y + rect.height / 2,
+  };
+  const to = el.to || {
+    x: rect.x + rect.width,
+    y: rect.y + rect.height / 2,
+  };
+  return { from, to };
+}
+
+function convertEvents(events: any[], wheres: any[]): Event[] {
+  const newEvents = [];
+  const newEventType: EventName[] = [
+    "mousedown", // 老版本 click 对应新版本的 mousedown
+    "dblclick",
+    "valueUpdate",
+    "valueUpdate",
+    "enter",
+    "leave",
+    "click", // 老版本 mouseUp 对应新版本 click
+  ];
+  const newEventAction = [
+    EventAction.Link,
+    EventAction.StartAnimate,
+    EventAction.JS,
+    EventAction.GlobalFn,
+    undefined,
+    EventAction.PauseAnimate,
+    EventAction.StopAnimate,
+    EventAction.Emit,
+  ];
+
+  for (const event of events) {
+    const newEvent: any = {
+      name: newEventType[event.type],
+      action: newEventAction[event.action],
+      value: event.value,
+      params: event.params,
+      // TODO: 老版本 name 没有意义
+    };
+
+    newEvents.push(newEvent);
+  }
+
+  const whereActions: any = {
+    link: EventAction.Link,
+    StartAnimate: EventAction.StartAnimate,
+    PauseAnimate: EventAction.PauseAnimate,
+    StopAnimate: EventAction.StopAnimate,
+    Function: EventAction.JS,
+    GlobalFn: EventAction.GlobalFn,
+    Emit: EventAction.Emit,
+  };
+
+  for (const where of wheres) {
+    // 一个 where 可以有多个事件
+    for (const action of where.actions) {
+      const newEvent: any = {
+        name: "valueUpdate",
+        action: whereActions[action.do],
+        where: {
+          key: where.key,
+          comparison: where.comparison,
+          value: where.value,
+          fnJs: where.fn,
+        },
+      };
+      switch (newEvent.action) {
+        case EventAction.Link:
+          newEvent.value = action.url;
+          newEvent.params = action._blank;
+          break;
+        case EventAction.StartAnimate:
+        case EventAction.PauseAnimate:
+        case EventAction.StopAnimate:
+          newEvent.value = action.tag;
+          break;
+        case EventAction.JS:
+        case EventAction.GlobalFn:
+        case EventAction.Emit:
+          newEvent.value = action.fn;
+          newEvent.params = action.params;
+          break;
+      }
+
+      newEvents.push(newEvent);
+    }
+  }
+  return newEvents;
+}

+ 6 - 8
src/services/user.ts

@@ -23,7 +23,7 @@ export interface IUser {
   remember?: boolean;
   captcha?: string;
   vip?: string;
-  vipExpired?: boolean;
+  isVip?: boolean;
   roles?: string[];
   isOperation?: boolean;
   company?: any;
@@ -55,7 +55,7 @@ export const useUser = () => {
         params.token = '1';
       }
     }
-    const ret: IUser = await axios.get('/account/profile', { params });
+    const ret: IUser = await axios.get('/api/account/profile', { params });
     if (!ret) {
       return;
     }
@@ -65,7 +65,9 @@ export const useUser = () => {
   };
 
   const getMessage = async () => {
-    const ret: { unread: number } = await axios.post('/message/unread/count');
+    const ret: { unread: number } = await axios.post(
+      '/api/message/unread/count'
+    );
     ret && (message.unread = ret.unread);
   };
 
@@ -77,11 +79,7 @@ export const useUser = () => {
     if (data.vip) {
       const vip = new Date(data.vip);
       if (vip > new Date()) {
-        data.vipExpired = false;
-      } else if (vip > new Date('2023-01-17T08:00:00+08:00')) {
-        data.vipExpired = true;
-      } else {
-        data.vip = undefined;
+        data.isVip = true;
       }
       if (data.vip) {
         data.vip = dayjs(data.vip).format('YYYY-MM-DD HH:mm:ss');

+ 161 - 1
src/services/utils.ts

@@ -1,4 +1,10 @@
-import { FormItem, Pen, Meta2d, Meta2dData } from "@meta2d/core";
+import { Pen, Meta2dData } from '@meta2d/core';
+import { MessagePlugin, NotifyPlugin, Button } from 'tdesign-vue-next';
+import { h, ref } from 'vue';
+const market = import.meta.env.VITE_BASEURL;
+
+export const noLoginTip = '请先登录,否则无法保存!';
+export const localStorageName = 'le5leV';
 
 export interface Meta2dBackData extends Meta2dData {
   id?: string;
@@ -25,4 +31,158 @@ export interface Meta2dBackData extends Meta2dData {
   username?: string;
   editorId?: string;
   editorName?: string;
+  teams?: { id?: string; name?: string }[];
+  tags?: string[]; //标签数组
+}
+
+const notification = ref<any>(null);
+export function showNotification(title: string): Promise<boolean> {
+  return new Promise<boolean>((resolve) => {
+    const btnClick = () => {
+      NotifyPlugin.close(notification.value);
+      notification.value = null;
+      resolve(true);
+    };
+    if (!notification.value) {
+      notification.value = NotifyPlugin.info({
+        title: '提示',
+        content: title,
+        closeBtn: true,
+        onCloseBtnClick: () => {
+          //关闭按钮
+          notification.value = null;
+          resolve(false);
+        },
+        // duration: 1000000,
+        // @ts-ignore
+        footer: h(
+          Button,
+          {
+            theme: 'primary',
+            size: 'small',
+            style: {
+              'margin-top': '16px',
+              'margin-left': '256px',
+            },
+            onClick: btnClick,
+          },
+          '确定'
+        ),
+      });
+    }
+  });
+}
+
+export async function dealwithFormatbeforeOpen(data: Meta2dBackData) {
+  if (!data) {
+    return;
+  }
+  if ((!data.https || data.https?.length == 0) && data.http) {
+    data.https = [
+      {
+        http: data.http,
+        httpTimeInterval: data.httpTimeInterval,
+        httpHeaders: data.httpHeaders,
+      },
+    ];
+    delete data.http;
+    delete data.httpHeaders;
+    delete data.httpTimeInterval;
+  }
+  //新版渐进色
+  data.pens &&
+    data.pens.forEach((pen) => {
+      if (pen.lineGradientFromColor && pen.lineGradientToColor) {
+        pen.lineGradientColors = `linear-gradient(${
+          pen.lineGradientAngle ? Number(pen.lineGradientAngle) + 90 : 0
+        }deg,${pen.lineGradientFromColor} 0%,${pen.lineGradientToColor} 100%)`;
+      }
+      if (pen.gradientFromColor && pen.gradientToColor) {
+        pen.gradientColors = `linear-gradient(${
+          pen.gradientAngle ? Number(pen.gradientAngle) + 90 : 0
+        }deg,${pen.gradientFromColor} 0%,${pen.gradientToColor} 100%)`;
+      }
+    });
+}
+
+export function gotoAccount() {
+  MessagePlugin.info({
+    content: '请开通vip,即将跳转到开通页面...',
+    duration: 3,
+  });
+  setTimeout(() => {
+    if (market) {
+      window.open('/account?unVip=true');
+    } else {
+      let arr = location.host.split('.');
+      arr[0] = 'http://account';
+      let accountUrl = arr.join('.');
+      window.open(`${accountUrl}?unVip=true`);
+    }
+  }, 3000);
+}
+
+export function isGif(url: string): boolean {
+  return url.endsWith('.gif');
+}
+
+/**
+ * 正常的 assign 操作,是存在弊端的,
+ * 若源对象存在该属性,但目标对象不存在该属性(不存在并非 undefined),则会导致无法覆盖
+ * 该方法会把源对象的属性全部清空,然后再把目标对象的属性覆盖到源对象上
+ * source 可能是个监听的对象,所有最后一步再更改它的属性值
+ * @param source 原对象
+ * @param target 目标对象
+ */
+export function strictAssign(
+  source: Record<string, any>,
+  target: Record<string, any>
+) {
+  // source 的全部属性都是 undefined 的对象,而非没有这个属性
+  const undefinedSource: Record<string, any> = {};
+  Object.keys(source).forEach((key) => {
+    undefinedSource[key] = undefined;
+  });
+  Object.assign(undefinedSource, target);
+  Object.assign(source, undefinedSource);
+}
+
+export function checkData(data: Meta2dData) {
+  const pens: Pen[] = data.pens || [];
+  for (let i = 0; i < pens.length; i++) {
+    const pen: any = pens[i];
+    pen.events?.forEach((event: any) => {
+      delete event.setProps;
+    });
+
+    //处理画笔是脏数据的情况
+    if (
+      !(
+        pen.x > -Infinity &&
+        pen.x < Infinity &&
+        pen.y > -Infinity &&
+        pen.y < Infinity &&
+        pen.width > -Infinity &&
+        pen.width < Infinity &&
+        pen.height > -Infinity &&
+        pen.height < Infinity
+      )
+    ) {
+      pens.splice(i, 1);
+      --i;
+    } else if (
+      pen.x == null ||
+      pen.y == null ||
+      pen.width == null ||
+      pen.height == null
+    ) {
+      pens.splice(i, 1);
+      --i;
+    }
+  }
+
+  if (Array.isArray(data.mqttOptions)) {
+    // mqttOptions 是数组则认为是脏数据,删掉
+    data.mqttOptions = {};
+  }
 }

+ 292 - 5
src/styles/app.css

@@ -11,7 +11,7 @@ body {
   padding: 0;
   font-size: var(--font-size);
   font-family: var(--font-family);
-  overflow: overlay;
+  overflow: hidden;
   background: var(--color-background);
   color: var(--color);
 }
@@ -59,6 +59,10 @@ h5 {
   font-size: 16px;
 }
 
+.small {
+  font-size: 10px;
+}
+
 .bland {
   color: var(--color-bland);
 }
@@ -79,6 +83,10 @@ h5 {
   color: var(--color-gray);
 }
 
+.warning {
+  color: var(--color-warning);
+}
+
 .success {
   color: var(--color-success);
 }
@@ -92,6 +100,14 @@ h5 {
   cursor: pointer !important;
 }
 
+.hover-background:hover {
+  background-color: var(--color-background-popup-hover);
+}
+
+.ellipsis {
+  text-overflow: ellipsis;
+}
+
 a {
   color: var(--color-primary);
   cursor: pointer;
@@ -107,7 +123,7 @@ a.hover {
 }
 
 a.hover:hover {
-  color: var(--color-primary-hover);
+  color: var(--color-primary-hover) !important;
 }
 
 .button {
@@ -173,8 +189,31 @@ a.hover:hover {
   color: var(--color-bland-hover) !important;
 }
 
+.visible {
+  visibility: visible;
+}
+
+.hidden {
+  visibility: hidden;
+}
+
+.hover-icons {
+  .icon {
+    visibility: hidden;
+  }
+
+  &:hover {
+    .icon {
+      visibility: visible;
+    }
+  }
+}
+
 .flex {
   display: flex;
+  &.column {
+    flex-direction: column;
+  }
   &.middle {
     align-items: center;
   }
@@ -185,12 +224,32 @@ a.hover:hover {
   &.between {
     justify-content: space-between;
   }
+
+  .shrink-0 {
+    flex-shrink: 0;
+  }
 }
 
 .flex-grow {
   flex: 1;
 }
 
+.grid {
+  display: grid;
+}
+
+.left {
+  text-align: left;
+}
+
+.center {
+  text-align: center;
+}
+
+.right {
+  text-align: right;
+}
+
 .w-full {
   width: 100%;
 }
@@ -199,10 +258,38 @@ a.hover:hover {
   margin-left: 4px;
 }
 
+.mr-4 {
+  margin-right: 4px;
+}
+
+.mr-8 {
+  margin-right: 8px;
+}
+
 .ml-8 {
   margin-left: 8px;
 }
 
+.-ml-8 {
+  margin-left: -8px;
+}
+
+.mb-8 {
+  margin-bottom: 8px;
+}
+
+.ml-12 {
+  margin-left: 12px;
+}
+
+.ml-16 {
+  margin-left: 16px;
+}
+
+.mt-4 {
+  margin-top: 4px;
+}
+
 .mt-8 {
   margin-top: 8px;
 }
@@ -211,14 +298,214 @@ a.hover:hover {
   margin-top: 12px;
 }
 
+.mb-12 {
+  margin-bottom: 12px;
+}
+
+.mr-12 {
+  margin-right: 12px;
+}
+
 .mt-16 {
   margin-top: 16px;
 }
 
+.mr-16 {
+  margin-right: 16px;
+}
+
+.mb-16 {
+  margin-bottom: 16px;
+}
+
+.mt-20 {
+  margin-top: 20px;
+}
+
+.mt-24 {
+  margin-top: 24px;
+}
+
+.px-4 {
+  padding-left: 4px;
+  padding-right: 4px;
+}
+
+.py-4 {
+  padding-top: 4px;
+  padding-bottom: 4px;
+}
+
+.px-8 {
+  padding-left: 8px;
+  padding-right: 8px;
+}
+
+.py-8 {
+  padding-top: 8px;
+  padding-bottom: 8px;
+}
+
+.px-12 {
+  padding-left: 12px;
+  padding-right: 12px;
+}
+
+.py-12 {
+  padding-top: 12px;
+  padding-bottom: 12px;
+}
+
+.p-12 {
+  padding: 12px;
+}
+
+.pb-16 {
+  padding-bottom: 16px;
+}
+
+.px-16 {
+  padding-left: 16px;
+  padding-right: 16px;
+}
+
+.py-16 {
+  padding-top: 16px;
+  padding-bottom: 16px;
+}
+
+.px-16 {
+  padding-left: 16px;
+  padding-right: 16px;
+}
+
+.p-16 {
+  padding: 16px;
+}
+
+.border {
+  border: 1px solid var(--color-sub-border);
+}
+
+.transparent {
+  background-color: transparent;
+  background-image: linear-gradient(
+      45deg,
+      #c5c5c5 25%,
+      transparent 0,
+      transparent 75%,
+      #c5c5c5 0,
+      #c5c5c5
+    ),
+    linear-gradient(
+      45deg,
+      #c5c5c5 25%,
+      transparent 0,
+      transparent 75%,
+      #c5c5c5 0,
+      #c5c5c5
+    );
+  background-size: 6px 6px;
+  background-position: 0 0, 3px 3px;
+}
+
+.vip-label {
+  font-size: 10px;
+  background-color: #ff400030;
+  color: var(--color-bland);
+  padding: 0 6px;
+  border-radius: 2px;
+}
+
+.form-item {
+  display: flex;
+
+  & > label {
+    width: 76px;
+    font-size: 12px;
+    line-height: 30px;
+    flex-shrink: 0;
+    color: var(--color);
+  }
+
+  .t-input-number {
+    width: 100%;
+    line-height: 30px;
+    height: 30px;
+
+    .t-input__wrap {
+      height: 100%;
+
+      .t-input {
+        height: 100%;
+        background: none;
+        color: var(--color-title);
+        box-shadow: none;
+
+        /* &:hover,
+        &.t-is-focused {
+          border-color: var(--color-primary);
+        } */
+      }
+    }
+
+    .t-input-number__increase,
+    .t-input-number__decrease {
+      width: 20px;
+    }
+  }
+
+  .t-input,
+  .t-textarea textarea {
+    .t-input__inner {
+      text-overflow: unset;
+      &::placeholder {
+        color: var(--color-gray);
+      }
+    }
+
+    &.t-is-disabled {
+      border-color: var(--color-border-input) !important;
+
+      .t-input__inner {
+        color: var(--color-desc);
+      }
+    }
+  }
+
+  & > input {
+    background: none;
+    outline: none;
+    color: var(--color-desc);
+
+    &::placeholder {
+      color: var(--color-gray);
+    }
+  }
+
+  .t-icon {
+    font-size: 16px;
+    height: 30px;
+  }
+
+  .t-tag {
+    line-height: 24px;
+    .t-icon {
+      height: 23px;
+      margin-left: 4px;
+      margin-right: -4px;
+    }
+  }
+}
+
+*::placeholder {
+  color: var(--color-gray);
+}
+
 /*定义滚动条轨道 内阴影+圆角*/
 ::-webkit-scrollbar {
-  width: 5px;
-  height: 7px;
+  width: 3px;
+  height: 6px;
   background: transparent;
 }
 
@@ -228,7 +515,7 @@ a.hover:hover {
 
 /*滚动条里面小方块*/
 ::-webkit-scrollbar-thumb {
-  background-color: var(--color-scrollbar);
+  background-color: var(--color-scrollbar) !important;
   border-radius: 4px;
 }
 

+ 248 - 39
src/styles/props.css

@@ -10,30 +10,10 @@
   }
 
   .form-item {
-    display: flex;
-
-    label {
-      width: 80px;
-      font-size: 12px;
-      line-height: 30px;
-      flex-shrink: 0;
-      color: var(--color);
-    }
-
     .t-input-number {
-      width: 100px;
-      line-height: 30px;
-      height: 30px;
-
       .t-input__wrap {
-        height: 100%;
-
         .t-input {
-          height: 100%;
-          background: none;
           border-color: transparent;
-          color: var(--color-title);
-          padding-right: 20px;
 
           &:hover,
           &.t-is-focused {
@@ -41,37 +21,44 @@
           }
         }
       }
-
-      .t-input-number__increase,
-      .t-input-number__decrease {
-        width: 20px;
-      }
     }
 
-    .t-input {
+    .t-input,
+    .t-textarea textarea {
       border-color: transparent;
+
       &:hover,
       &.t-is-focused {
         border-color: var(--color-border-input);
       }
     }
 
-    .t-icon {
-      font-size: 16px;
-      height: 30px;
+    & > input {
+      border: none;
+    }
+  }
+
+  .t-dialog {
+    .form-item {
+      .t-input {
+        border-color: var(--color-border-input);
+        &:hover {
+          border-color: var(--color-primary);
+        }
+      }
     }
   }
 
   .t-collapse.t--border-less {
     .t-collapse-panel__header {
-      font-size: 14px;
-      font-weight: 400;
-      border-top: 1px solid var(--color-border-input);
+      font-size: 13px;
+      font-weight: 700;
+      border-top: 1px solid var(--td-border-level-1-color);
       color: var(--color-title);
     }
 
     .t-collapse-panel__wrapper .t-collapse-panel__content {
-      padding: 8px 16px 16px 16px;
+      padding: 0 16px 16px 16px;
     }
   }
 
@@ -84,12 +71,10 @@
     border-color: var(--color-desc);
 
     &::after {
-      width: 6px;
-      height: 6px;
-      left: 2px;
-      top: 2px;
-      margin: 0;
-      transform: none;
+      width: 12px;
+      height: 12px;
+      margin-top: -6px;
+      margin-left: -6px;
     }
   }
   .t-radio__label {
@@ -108,12 +93,236 @@
     }
   }
 
+  .t-table__th-cell-inner {
+    .t-is-indeterminate .t-checkbox__input {
+      &::after {
+        left: -1px !important;
+        top: 4.5px !important;
+      }
+    }
+  }
+
   .t-button {
     height: 24px;
     border-color: var(--color-desc);
+
     &:hover {
       border-color: var(--color-primary);
       color: var(--color-primary);
     }
+
+    &.t-button--variant-base {
+      border-color: var(--td-bg-color-component);
+      &:hover {
+        color: var(--color);
+        border-color: var(--color-border-input-hover);
+        background-color: var(--color-border-input-hover);
+      }
+
+      &.t-button--theme-primary {
+        border-color: var(--color-primary);
+        &:hover {
+          color: var(--color);
+          border-color: var(--color-primary-hover);
+          background-color: var(--color-primary-hover);
+        }
+      }
+    }
+
+    &.icon {
+      border-color: transparent;
+      padding: 0;
+      width: 24px;
+      &.active {
+        border-color: var(--color-border-input);
+        background-color: var(--color-border-input);
+      }
+
+      &:hover {
+        border-color: var(--color-primary);
+      }
+    }
+  }
+
+  .t-tree__empty {
+    color: var(--color-gray);
+    margin-left: 12px;
+  }
+
+  .t-tree__item {
+    cursor: pointer;
+
+    svg {
+      width: 16px;
+      color: var(--td-text-color-primary);
+      flex-shrink: 0;
+
+      &:hover {
+        color: var(--color-primary);
+      }
+    }
+
+    .gray svg {
+      color: var(--color-gray);
+    }
+
+    .t-tree__label {
+      .t-icon {
+        font-size: 14px;
+        margin-right: 4px;
+      }
+    }
+
+    &.t-is-active {
+      .t-tree__label,
+      svg,
+      .gray {
+        color: var(--color-primary);
+      }
+    }
+
+    .t-tree__operations {
+      padding: 0 8px;
+
+      .operations {
+        * {
+          display: none;
+        }
+
+        &.show {
+          * {
+            display: flex;
+          }
+        }
+      }
+    }
+
+    &:hover {
+      color: var(--color-primary);
+      .t-tree__label,
+      svg,
+      .gray {
+        color: var(--color-primary);
+      }
+
+      .t-tree__operations {
+        .operations * {
+          display: flex;
+        }
+      }
+    }
+
+    .t-tree__icon:not(:empty):hover {
+      background: none;
+    }
+  }
+
+  .t-tab-panel {
+    height: calc(100vh - 81px);
+    overflow-x: hidden;
+    overflow-y: auto;
+  }
+
+  .t-slider {
+    .t-slider__rail,
+    .t-slider__track {
+      height: 3px;
+    }
+
+    .t-slider__button {
+      width: 8px;
+      height: 8px;
+      border: none;
+    }
+
+    .t-slider__button--dragging {
+      box-shadow: none;
+    }
+  }
+
+  .t-switch {
+    background-color: var(--color-border-input);
+    height: 14px;
+    line-height: 1;
+
+    .t-switch__handle {
+      top: 2px;
+      &::before {
+        background-color: var(--color);
+      }
+    }
+
+    &.t-is-checked {
+      background-color: var(--color-primary);
+
+      .t-switch__handle {
+        top: 2px;
+        width: calc(var(--td-comp-size-xxxs) - 2 * 3px);
+        height: calc(var(--td-comp-size-xxxs) - 2 * 3px);
+
+        &::before {
+          background-color: #ffffff;
+        }
+      }
+    }
+  }
+
+  .t-upload__dragger {
+    width: 100%;
+    height: 100px;
+    padding: 0;
+
+    .t-upload__dragger-progress {
+      height: 100%;
+      align-items: center;
+      justify-content: center;
+    }
+
+    .t-upload__dragger-img-wrap {
+      width: auto;
+      height: 100%;
+    }
+
+    .t-upload__dragger-progress-info {
+      position: absolute;
+      background: #303746a0;
+      display: none;
+      max-width: 100%;
+      align-items: center;
+      justify-content: center;
+      top: 0;
+      left: 0;
+      bottom: 0;
+      right: 0;
+      margin: 0;
+    }
+
+    &:hover {
+      .t-upload__dragger-progress-info {
+        display: flex;
+      }
+    }
+  }
+
+  .t-color-picker__trigger {
+    &.small {
+      width: 90px;
+
+      .t-input {
+        width: 90px;
+        .t-input__inner {
+          text-overflow: ellipsis;
+        }
+      }
+    }
+
+    .t-input--auto-width {
+      max-width: 100%;
+
+      .t-input__input-pre {
+        max-width: 100%;
+        text-overflow: ellipsis;
+      }
+    }
   }
 }

+ 227 - 102
src/styles/tdesign.css

@@ -5,24 +5,34 @@
 
 .t-input {
   font-size: 13px;
+  background: none;
+  border: 1px solid var(--color-border-input);
+  height: 30px;
 
   &:hover,
   &.t-is-focused {
-    border-color: var(--color-border-input);
+    border-color: var(--color-primary);
   }
 
   &.t-is-error {
-    box-shadow: none;
-    border-color: var(--color-error);
+    box-shadow: none !important;
+    border-color: var(--color-error) !important;
   }
 
-  &.t-input--prefix > .t-input__prefix {
+  &.t-input--prefix > .t-input__prefix,
+  .t-input--suffix > .t-input__suffix {
     font-size: 12px;
     color: var(--color-desc);
   }
 
   .t-input__inner::-webkit-input-placeholder {
-    color: transparent;
+    /* color: transparent; */
+  }
+}
+
+.t-select {
+  .t-input.t-is-readonly {
+    background: none;
   }
 }
 
@@ -52,57 +62,11 @@
   }
 
   &.t-is-selected {
-    background-color: var(--color-background-popup-hover);
+    background: none;
     color: var(--color);
   }
 }
 
-.t-tree {
-  min-width: 100%;
-  width: max-content;
-
-  .t-tree__item {
-    line-height: 30px;
-    height: 30px;
-    padding-left: calc(var(--td-comp-margin-xxl) * (var(--level) + 0.4));
-
-    &:hover {
-      background-color: var(--color-background);
-    }
-
-    .t-tree__label {
-      margin-left: 0;
-    }
-
-    &.t-is-active {
-      background-color: var(--color-background);
-      .t-tree__label {
-        background: none;
-        color: var(--color-primary);
-      }
-    }
-  }
-
-  .t-tree__icon:not(:empty):hover {
-    i {
-      color: var(--color-primary-hover);
-    }
-
-    &::after {
-      background: none;
-    }
-  }
-}
-
-.t-tree__empty {
-  font-size: 12px;
-  margin-left: 16px;
-}
-
-.t-tree__item--open .t-icon {
-  color: var(--color);
-}
-
 .t-color-picker__format-mode-select .t-select,
 .t-color-picker__format-mode-select .t-input {
   background-color: var(--color-background-popup);
@@ -127,7 +91,7 @@
 }
 
 .t-dropdown {
-  border-color: transparent;
+  border-color: var(--color-border-popup);
 }
 
 .t-dropdown__menu-column {
@@ -135,7 +99,7 @@
 }
 
 .t-dropdown__menu {
-  padding: 10px 4px;
+  padding: 4px;
   background-color: var(--color-background-popup);
   border: none;
 
@@ -147,7 +111,7 @@
       display: block;
       padding: 0 16px;
       line-height: 32px;
-      border-radius: 4px;
+      border-radius: 2px;
 
       &:hover {
         background-color: var(--color-background-popup-hover);
@@ -167,12 +131,37 @@
           border-radius: 2px;
         }
       }
+
+      .t-input__wrap {
+        width: calc(100% + 32px);
+        margin-left: -16px;
+        .t-input {
+          background: none;
+          border-color: var(--color-border-input);
+          &:hover {
+            border-color: var(--color-primary);
+          }
+        }
+      }
+    }
+
+    &.t-dropdown__item--disabled {
+      .t-dropdown__item-text {
+        cursor: default;
+        background-color: var(--color-background-popup) !important;
+        color: var(--color-desc);
+      }
     }
   }
 
   .t-divider {
     margin: 4px 0;
     width: 100%;
+    border-top: 1px solid var(--color-border-input);
+  }
+
+  &::-webkit-scrollbar {
+    width: 6px !important;
   }
 }
 
@@ -180,12 +169,29 @@
   padding: 0 !important;
   margin-top: 0 !important;
   border-radius: var(--border-radius);
-  box-shadow: none;
+  box-shadow: var(--shadow-popup);
   background-color: var(--color-background-popup);
 
   .t-color-picker__format {
     align-items: center;
   }
+
+  ul {
+    padding: 16px 4px;
+    font-size: 12px;
+    li {
+      line-height: 30px;
+      list-style: none;
+      cursor: pointer;
+      &:hover {
+        color: var(--color-primary);
+      }
+    }
+  }
+
+  &::-webkit-scrollbar {
+    width: 6px !important;
+  }
 }
 
 .t-tooltip {
@@ -202,44 +208,15 @@
 .t-table {
   font-size: 13px;
 
-  th {
-    background: #f7f8fa;
-    color: #7f838c;
-    border-color: #edeef0;
-  }
-
-  td {
-    color: #474e59;
-    border-color: #edeef0;
-  }
+  .t-table__pagination {
+    padding: 16px 0;
 
-  &.border-none {
-    th {
-      background: none !important;
-      border: none;
-    }
-    td {
-      border: none;
-    }
-
-    .t-table__row--selected {
-      background-color: var(--color-primary-background);
-    }
-  }
-
-  &.hide-checkbox {
-    .t-table__cell-check {
-      * {
-        display: none;
-      }
+    svg {
+      color: var(--color);
     }
   }
 }
 
-.t-alert__description {
-  color: #002da0;
-}
-
 .t-button--variant-outline {
   background-color: transparent !important;
 }
@@ -278,9 +255,10 @@
   display: inline-block;
 }
 
-.t-tab-panel {
-  height: calc(100vh - 81px);
-  overflow-y: auto;
+.t-tabs__nav-container {
+  &.t-is-top::after {
+    background-color: var(--color-background-input);
+  }
 }
 
 .t-tabs__nav-item-wrapper > div {
@@ -323,31 +301,44 @@
   &.w-full {
     .t-input--auto-width {
       min-width: 100%;
+      max-width: 100%;
+      .t-input .t-input__inner {
+        text-overflow: ellipsis;
+        margin-right: 10px;
+      }
     }
   }
 
   &.simple {
-    width: 30px;
+    width: 16px;
+    height: 16px;
 
-    .t-input--auto-width {
-      min-width: 20px;
-      width: 20px;
+    * .t-input--auto-width {
+      min-width: 14px;
+      width: 14px;
     }
 
     .t-input {
-      height: 30px;
+      height: 16px;
       border: none;
       padding: 0;
       background: none;
 
       & > .t-input__prefix {
-        width: 20px;
-        height: 20px;
-        border: 1px solid var(--color-border-input);
-        border-radius: 2px;
+        margin: 0;
+        width: 14px;
+        height: 14px;
+        border: 1px solid var(--color-desc);
+        border-radius: 3px;
+
+        &:hover {
+          border-color: var(--color-primary);
+        }
 
         span {
-          height: 18px;
+          border: none;
+          height: 12.4px;
+          cursor: pointer;
         }
       }
 
@@ -358,6 +349,30 @@
   }
 }
 
+.t-radio-group {
+  .t-radio {
+    &:last-child {
+      margin-right: 0;
+    }
+  }
+  .t-radio__input {
+    background: none;
+  }
+
+  .t-radio-button {
+    border-color: var(--color-border-input);
+    &:last-child {
+      border-right-color: var(--color-border-input);
+    }
+
+    &.t-is-checked {
+      border-color: var(--color-border-input);
+      background-color: var(--color-border-input);
+      color: var(--td-text-color-primary) !important;
+    }
+  }
+}
+
 .rectangle.t-radio-group.t-radio-group--filled {
   background: none;
 
@@ -391,3 +406,113 @@
     }
   }
 }
+
+.t-message {
+  color: #000000e6;
+  background-color: #ffffff;
+  padding: 10px 16px;
+}
+
+.t-popup__arrow {
+  &::before {
+    background-color: var(--color-background-popup);
+  }
+}
+
+.t-popup[data-popper-placement^='top'] .t-popup__arrow::before {
+  top: -8px;
+  border-top-left-radius: 0;
+}
+
+.t-button--variant-base {
+  color: var(--color);
+
+  &.t-button--theme-primary {
+    color: #ffffff !important;
+  }
+}
+
+.t-color-picker__trigger--default__color {
+  background-color: transparent;
+  background-image: linear-gradient(
+      45deg,
+      #c5c5c5 25%,
+      transparent 0,
+      transparent 75%,
+      #c5c5c5 0,
+      #c5c5c5
+    ),
+    linear-gradient(
+      45deg,
+      #c5c5c5 25%,
+      transparent 0,
+      transparent 75%,
+      #c5c5c5 0,
+      #c5c5c5
+    );
+  background-size: 6px 6px;
+  background-position: 0 0, 3px 3px;
+}
+
+.t-divider--horizontal {
+  margin: 0;
+}
+
+.t-dialog {
+  padding: 16px 20px;
+  border-color: var(--color-dialog-border);
+
+  .t-dialog__header {
+    font-size: 14px;
+  }
+
+  .t-dialog__body {
+    overflow: initial;
+
+    & > .body {
+      width: calc(100% + 40px);
+      height: 380px;
+      margin-left: -20px;
+      padding: 0 20px;
+      overflow: auto;
+
+      &.t-tabs {
+        padding: 0;
+        & > div {
+          padding: 0 20px;
+        }
+
+        .t-tabs__content {
+          height: 340px;
+          overflow: auto;
+        }
+      }
+    }
+  }
+
+  .t-dialog__footer {
+    padding: 0;
+  }
+
+  .t-dialog__close {
+    margin-right: -6px;
+    &:hover {
+      background: none;
+      color: var(--color-primary);
+    }
+  }
+
+  .t-table__empty {
+    color: var(--color-gray);
+  }
+}
+
+.t-checkbox__label {
+  font-size: 12px;
+}
+
+.t-is-active {
+  .t-tree__label {
+    background: none;
+  }
+}

+ 23 - 6
src/styles/var.css

@@ -13,7 +13,7 @@
   --color-bland-hover: #fa541c;
 
   --color-primary: #4583ff;
-  --color-primary-hover: #1677ff;
+  --color-primary-hover: #0c56eb;
   --color-primary-disabled: #bae7ff;
   --color-success: #52c41a;
   --color-success-hover: #3fad09;
@@ -29,13 +29,14 @@
   --color-background-active: #161f2c;
   --color-background-hover: #181f29;
   --color-background-input: #303746;
-  --color-background-editor: #0f151f;
   --color-background-popup: #303746;
   --color-background-popup-hover: #454f64;
 
   --color-border: #000000;
-  --color-sub-border: #f7f7f7;
+  --color-sub-border: var(--color-background-input);
   --color-border-input: #535f79;
+  --color-border-input-hover: #454f64;
+  --color-border-popup: transparent;
 
   --color-scrollbar: #dad7d7;
   --color-scrollbar-hover: #e5e5e5;
@@ -45,11 +46,14 @@
   --shadow: 0px 2px 6px 0px rgba(0, 10, 38, 0.1);
   --shadow-hover: 0px 4px 8px 0px rgba(0, 10, 38, 0.3);
   --shadow-panel: 0px 2px 6px 0px rgb(0 10 38 / 4%);
+  --shadow-popup: none;
 
+  --td-bg-color-component-hover: var(--color-border-input-hover);
+  --td-bg-color-component-active: var(--color-background-input);
   --td-radius-medium: 4px;
-  --td-bg-color-container: #1e2430;
+  --td-bg-color-container: var(--color-background);
   --td-component-border: #42516c;
-  --td-bg-color-secondarycontainer: #42516c;
+  --td-bg-color-secondarycontainer: var(--td-component-border);
   --td-comp-size-xxl: 40px;
   --td-text-color-primary: var(--color);
   --td-text-color-secondary: var(--color-desc);
@@ -57,6 +61,19 @@
   --td-font-family: var(--font-family);
   --td-font-body-medium: var(--font-size) / var(--td-line-height-body-medium)
     var(--td-font-family);
-  --td-border-level-1-color: var(--color-background-popup-hover);
+  --td-border-level-1-color: var(--color-background-input);
   --td-text-color-placeholder: var(--color-desc);
+  --td-brand-color: var(--color-primary);
+  --td-bg-color-component: var(--color-border-input);
+  --td-brand-color-light: var(--color-border-input);
+  --td-mask-disabled: var(--color-background);
+  --td-text-color-disabled: var(--color-gray);
+  --td-bg-color-component-disabled: var(--color-border-input-hover);
+
+  --color-dialog-border: transparent;
+  --td-bg-color-specialcomponent: transparent;
+  --td-border-level-2-color: var(--color-border-input);
+
+  --color-background-editor: #181b24;
+  --color-border-editor: #1e2430;
 }

+ 2 - 9
src/views/Index.vue

@@ -5,7 +5,7 @@
     <div class="design-body">
       <Graphics />
       <View />
-      <div>
+      <div style="border-left: 1px solid var(--color-border); z-index: 7">
         <FileProps v-if="selections.mode === SelectionMode.File" />
         <PenProps v-else-if="selections.mode === SelectionMode.Pen" />
         <PensProps v-else />
@@ -15,9 +15,6 @@
 </template>
 
 <script lang="ts" setup>
-import { onBeforeMount, reactive } from 'vue';
-import axios from 'axios';
-
 import Header from './components/Header.vue';
 import Graphics from './components/Graphics.vue';
 import View from './components/View.vue';
@@ -28,10 +25,6 @@ import PensProps from './components/PensProps.vue';
 import { useSelection, SelectionMode } from '@/services/selections';
 
 const { selections } = useSelection();
-
-const data = reactive<any>({});
-
-onBeforeMount(async () => {});
 </script>
 
 <style lang="postcss" scoped>
@@ -40,7 +33,7 @@ onBeforeMount(async () => {});
 
   .design-body {
     display: grid;
-    grid-template-columns: 300px 1fr 300px;
+    grid-template-columns: 300px 1fr 301px;
     height: calc(100vh - 40px);
     border-top: 1px solid var(--color-border);
   }

+ 85 - 0
src/views/Preview.vue

@@ -0,0 +1,85 @@
+<template>
+  <div class="preview" :style="{ background: bgColor }">
+    <div class="meta2d-canvas" ref="meta2dDom"></div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, watch, onUnmounted } from 'vue';
+import localforage from 'localforage';
+import { localStorageName } from '@/services/utils';
+import { defaultFormat } from '@/services/defaults';
+import { useRouter, useRoute } from 'vue-router';
+import { Meta2d, Options, Pen } from '@meta2d/core';
+import { registerBasicDiagram } from '@/services/register';
+import { cdn, getLe5leV } from '@/services/api';
+const route = useRoute();
+
+const meta2dDom = ref('');
+
+const meta2dOptions: Options = {
+  cdn,
+  background: '#1e2430',
+  x: 10,
+  y: 10,
+  width: 1920,
+  height: 1080,
+  defaultFormat: { ...defaultFormat },
+};
+
+const bgColor = ref('#1e2430');
+
+onMounted(() => {
+  meta2d = new Meta2d(meta2dDom.value, meta2dOptions);
+  registerBasicDiagram();
+  open();
+  meta2d.on('opened', opened);
+});
+
+const watcher = watch(
+  () => route.query.id,
+  async () => {
+    open();
+  }
+);
+
+const open = async () => {
+  if (route.query.id) {
+    const ret: any = getLe5leV(route.query.id + '');
+    ret && meta2d.open(ret);
+  } else {
+    let data: any = await localforage.getItem(localStorageName);
+    if (data) {
+      data = JSON.parse(data);
+      data.locked = 1;
+      data.rule = false;
+      meta2d.open(data);
+      bgColor.value = data.background;
+    }
+  }
+};
+
+const opened = () => {
+  meta2d.fitSizeView(true, 10);
+};
+
+onUnmounted(() => {
+  watcher();
+  if (meta2d) {
+    meta2d.off('opened', opened);
+
+    meta2d.destroy();
+  }
+});
+</script>
+<style lang="postcss" scoped>
+.preview {
+  width: 100vw;
+  height: 100vh;
+  /* background-color: var(--color-background-editor); */
+  .meta2d-canvas {
+    width: 100%;
+    height: 100%;
+  }
+}
+</style>

+ 550 - 0
src/views/components/Actions.vue

@@ -0,0 +1,550 @@
+<template>
+  <div class="props">
+    <div v-for="(a, index) in data.actions" class="mb-12">
+      <div class="flex middle between">
+        <div class="flex middle">动作{{ index + 1 }}</div>
+        <t-icon
+          name="close"
+          class="hover"
+          @click="data.actions.splice(index, 1)"
+        />
+      </div>
+      <div class="py-4">
+        <div class="form-item mt-4">
+          <label>动作类型</label>
+          <t-select
+            v-model="a.action"
+            placeholder="请选择"
+            @change="onChangeAction(a)"
+          >
+            <t-option
+              v-for="option in actionOptions"
+              :key="option.value"
+              :value="option.value"
+              :label="option.label"
+            />
+          </t-select>
+        </div>
+        <template v-if="a.action == 0">
+          <div class="form-item mt-8">
+            <label>链接地址</label>
+            <t-input v-model="a.value" placeholder="URL" />
+          </div>
+          <div class="form-item mt-8">
+            <label>打开方式</label>
+            <t-radio-group v-model="a.params">
+              <t-radio value="_blank">新页面</t-radio>
+              <t-radio value="_self">当前页面</t-radio>
+            </t-radio-group>
+          </div>
+        </template>
+        <template v-else-if="a.action == 13">
+          <div class="form-item mt-8">
+            <label>视图</label>
+            <t-input v-model="a.value" placeholder="ID" />
+          </div>
+        </template>
+        <template v-else-if="a.action == 2 || a.action == 3 || a.action == 4">
+          <div class="form-item mt-8">
+            <label>对象类型</label>
+            <t-radio-group v-model="a.targetType" @change="a.value = ''">
+              <t-radio value="id">图元</t-radio>
+              <t-radio value="tag">组</t-radio>
+            </t-radio-group>
+          </div>
+          <div class="form-item mt-8">
+            <label>播放对象</label>
+            <t-tree-select
+              v-if="a.targetType === 'id'"
+              v-model="a.value"
+              :data="penTree"
+              filterable
+              placeholder="默认自己"
+              @change="getAnimations(a.value)"
+            />
+            <t-select
+              v-else
+              v-model="a.value"
+              :options="groups"
+              placeholder="组"
+              @change="getAnimations(a.value)"
+            />
+          </div>
+          <div class="form-item mt-8">
+            <label>动画名称</label>
+            <t-select-input
+              v-model:inputValue="a.params"
+              :value="a.params"
+              v-model:popupVisible="a.popupVisible"
+              allow-input
+              clearable
+              @clear="a.params = undefined"
+              @focus="a.popupVisible = true"
+              @blur="a.popupVisible = undefined"
+              placeholder="缺省第一个动画"
+            >
+              <template #panel>
+                <ul style="padding: 8px 12px">
+                  <li
+                    v-for="item in animations"
+                    :key="item"
+                    @click="a.params = item"
+                  >
+                    {{ item }}
+                  </li>
+                </ul>
+              </template>
+            </t-select-input>
+          </div>
+        </template>
+        <template v-else-if="a.action == 1">
+          <div class="form-item mt-8">
+            <label>对象类型</label>
+            <t-radio-group v-model="a.targetType" @change="a.params = ''">
+              <t-radio value="id">图元</t-radio>
+              <t-radio value="tag">组</t-radio>
+            </t-radio-group>
+          </div>
+          <div class="form-item mt-8">
+            <label>更改对象</label>
+            <t-tree-select
+              v-if="a.targetType === 'id'"
+              v-model="a.params"
+              :data="penTree"
+              filterable
+              placeholder="默认自己"
+              @change="getProps(a)"
+            />
+            <t-select
+              v-else
+              v-model="a.params"
+              :options="groups"
+              placeholder="组"
+            />
+          </div>
+          <div class="form-item mt-8">
+            <label>属性数据</label>
+            <div class="w-full">
+              <div class="prop-grid head">
+                <div>属性名</div>
+                <div>属性值</div>
+                <div class="right">
+                  <t-dropdown
+                    :min-column-width="160"
+                    @click="onAddValue(a, $event)"
+                  >
+                    <t-icon name="add-circle" class="hover" />
+
+                    <t-dropdown-menu>
+                      <t-dropdown-item
+                        key="custom"
+                        value="custom"
+                        disabled="true"
+                        divider="true"
+                        class="input"
+                      >
+                        <t-input
+                          v-model="a.input"
+                          placeholder="自定义"
+                          @enter="
+                            onAddValue(a, { key: a.input });
+                            a.input = '';
+                          "
+                        />
+                      </t-dropdown-item>
+                      <t-dropdown-item
+                        v-for="prop in a.props"
+                        :key="prop.value"
+                        :value="prop.value"
+                      >
+                        {{ prop.label }}
+                      </t-dropdown-item>
+                    </t-dropdown-menu>
+                  </t-dropdown>
+                </div>
+              </div>
+              <template
+                v-if="Object.keys(a.value).length"
+                class="center gray mt-8"
+              >
+                <div class="prop-grid mt-8" v-for="(value, key) in a.value">
+                  <div class="ml-8">
+                    <t-tooltip :content="key">
+                      {{ getPropDesc(a, key) }}
+                    </t-tooltip>
+                  </div>
+                  <div>
+                    <t-input v-model="a.value[key]" placeholder="值" />
+                  </div>
+                  <div class="right px-8" style="line-height: 20px">
+                    <t-icon
+                      name="delete"
+                      class="hover"
+                      @click="delete a.value[key]"
+                    />
+                  </div>
+                </div>
+              </template>
+              <div v-else class="center gray mt-8">暂无数据</div>
+            </div>
+          </div>
+        </template>
+        <template v-else-if="a.action == 14">
+          <div class="form-item mt-8">
+            <label>窗口标题</label>
+            <t-input v-model="a.value" placeholder="弹框标题" />
+          </div>
+          <div class="form-item mt-8">
+            <label>画面URL</label>
+            <t-input v-model="a.params" placeholder="窗口画面URL" />
+          </div>
+        </template>
+        <template v-else-if="a.action == 7">
+          <div class="form-item mt-8">
+            <label>消息名称</label>
+            <t-input v-model="a.value" placeholder="名称" />
+          </div>
+          <div class="form-item mt-8">
+            <label>消息参数</label>
+            <t-input v-model="a.params" placeholder="参数" />
+          </div>
+        </template>
+        <template v-else-if="a.action == 15">
+          <Network v-model="a.network" mode="1" />
+
+          <div class="form-item mt-8">
+            <label>数据对象</label>
+            <t-tree-select
+              v-model="a.params"
+              :data="penTree"
+              filterable
+              placeholder="默认自己"
+              @change="getProps(a)"
+            />
+          </div>
+          <div class="form-item mt-8">
+            <label>属性数据</label>
+            <div class="w-full">
+              <div class="prop-grid head">
+                <div>属性名</div>
+                <div>属性值</div>
+                <div class="right">
+                  <t-dropdown
+                    :min-column-width="160"
+                    @click="onAddValue(a, $event)"
+                  >
+                    <t-icon name="add-circle" class="hover" />
+
+                    <t-dropdown-menu>
+                      <t-dropdown-item
+                        key="custom"
+                        value="custom"
+                        disabled="true"
+                        divider="true"
+                        class="input"
+                      >
+                        <t-input
+                          v-model="a.input"
+                          placeholder="自定义"
+                          @enter="
+                            onAddValue(a, { key: a.input });
+                            a.input = '';
+                          "
+                        />
+                      </t-dropdown-item>
+                      <t-dropdown-item
+                        v-for="prop in a.props"
+                        :key="prop.value"
+                        :value="prop.value"
+                      >
+                        {{ prop.label }}
+                      </t-dropdown-item>
+                    </t-dropdown-menu>
+                  </t-dropdown>
+                </div>
+              </div>
+              <template
+                v-if="Object.keys(a.value).length"
+                class="center gray mt-8"
+              >
+                <div class="prop-grid mt-8" v-for="(value, key) in a.value">
+                  <div class="ml-8">
+                    <t-tooltip :content="key">
+                      {{ getPropDesc(a, key) }}
+                    </t-tooltip>
+                  </div>
+                  <div>
+                    <t-input v-model="a.value[key]" placeholder="值" />
+                  </div>
+                  <div class="right px-8" style="line-height: 20px">
+                    <t-icon
+                      name="delete"
+                      class="hover"
+                      @click="delete a.value[key]"
+                    />
+                  </div>
+                </div>
+              </template>
+              <div v-else class="center gray mt-8">暂无数据</div>
+            </div>
+          </div>
+        </template>
+        <template v-else-if="a.action == 8 || a.action == 9 || a.action == 10">
+          <div class="form-item mt-8">
+            <label>对象类型</label>
+            <t-radio-group v-model="a.targetType" @change="a.value = ''">
+              <t-radio value="id">图元</t-radio>
+              <t-radio value="tag">组</t-radio>
+            </t-radio-group>
+          </div>
+          <div class="form-item mt-8">
+            <label>播放对象</label>
+            <t-tree-select
+              v-if="a.targetType === 'id'"
+              v-model="a.value"
+              :data="penTree"
+              filterable
+              placeholder="默认自己"
+            />
+            <t-select
+              v-else
+              v-model="a.value"
+              :options="groups"
+              placeholder="组"
+            />
+          </div>
+        </template>
+        <template v-else-if="a.action == 16">
+          <div class="form-item mt-8">
+            <label>函数</label>
+            <div class="w-full">
+              <div>function javascriptFn(pen) {</div>
+              <CodeEditor
+                v-model="a.value"
+                class="mt-4"
+                style="height: 200px"
+              />
+              <div class="mt-4">}</div>
+            </div>
+          </div>
+        </template>
+      </div>
+    </div>
+    <div class="mt-8">
+      <a @click="data.actions.push({})"> + 添加动作 </a>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { onBeforeMount, ref } from 'vue';
+
+import CodeEditor from '@/views/components/common/CodeEditor.vue';
+import Network from './Network.vue';
+
+import { getPenAnimations, getPenTree } from '@/services/common';
+
+const { data } = defineProps<{
+  data: any;
+}>();
+
+const actionOptions = [
+  {
+    label: '打开链接',
+    value: 0,
+  },
+  {
+    label: '打开视图',
+    value: 13,
+  },
+  {
+    label: '播放动画',
+    value: 2,
+  },
+  {
+    label: '暂停动画',
+    value: 3,
+  },
+  {
+    label: '停止动画',
+    value: 4,
+  },
+  {
+    label: '更改属性',
+    value: 1,
+  },
+  {
+    label: '打开弹框',
+    value: 14,
+  },
+  {
+    label: '发送消息',
+    value: 7,
+  },
+  {
+    label: '发送数据',
+    value: 15,
+  },
+  {
+    label: '播放视频',
+    value: 8,
+  },
+  {
+    label: '暂停视频',
+    value: 9,
+  },
+  {
+    label: '停止视频',
+    value: 10,
+  },
+  {
+    label: '自定义函数',
+    value: 5,
+  },
+];
+
+const penTree: any = ref([]);
+const groups: any = ref([]);
+const animations: any = ref([]);
+
+onBeforeMount(() => {
+  if (!data.actions) {
+    data.actions = [];
+  }
+
+  penTree.value = getPenTree();
+  groups.value = [];
+  const d = meta2d.store.data as any;
+  if (d.groups) {
+    for (const item of d.groups) {
+      groups.value.push({ label: item, value: item });
+    }
+  }
+
+  animations.value = getPenAnimations();
+});
+
+const getAnimations = (idOrTag: string) => {
+  animations.value = getPenAnimations(idOrTag);
+};
+
+const onChangeAction = (action: any) => {
+  switch (action.action) {
+    case 0:
+      action.value = '';
+      action.params = '_blank';
+      break;
+    case 1:
+      action.params = '';
+      action.value = {};
+      action.targetType = 'id';
+      getProps(action);
+      break;
+    case 2:
+    case 3:
+    case 4:
+      action.value = '';
+      action.targetType = 'id';
+      break;
+    case 15:
+      action.network = { options: {} };
+      action.params = '';
+      action.value = {};
+      action.targetType = 'id';
+      getProps(action);
+      break;
+    default:
+      action.value = '';
+      action.params = '';
+      break;
+  }
+};
+
+const getProps = (c: any) => {
+  c.props = [
+    {
+      value: 'x',
+      label: 'X',
+    },
+    {
+      value: 'y',
+      label: 'Y',
+    },
+    {
+      value: 'width',
+      label: '宽',
+    },
+    {
+      value: 'height',
+      label: '高',
+    },
+    {
+      value: 'visible',
+      label: '显示',
+    },
+    {
+      value: 'text',
+      label: '文字',
+    },
+    {
+      value: 'progress',
+      label: '进度',
+    },
+    {
+      value: 'showChild',
+      label: '状态',
+    },
+    {
+      value: 'rotate',
+      label: '旋转',
+    },
+  ];
+
+  let target: any;
+  if (c.params) {
+    target = meta2d.findOne(c.params);
+  } else if (meta2d.store.active) {
+    target = meta2d.store.active[0];
+  }
+  if (target) {
+    for (const item of target.realTimes) {
+      const found = c.props.findIndex((elem: any) => elem.value === item.key);
+      if (found < 0) {
+        c.props.push({
+          value: item.key,
+          label: item.label,
+        });
+      }
+    }
+  }
+};
+
+const getPropDesc = (a: any, key: any) => {
+  const found = a.props.find((elem: any) => elem.value === key);
+  if (found) {
+    return found.label;
+  }
+
+  return key;
+};
+
+const onAddValue = (action: any, data: any) => {
+  if (!action.value[data.key]) {
+    action.value[data.key] = undefined;
+  }
+};
+</script>
+<style lang="postcss" scoped>
+.props {
+  .prop-grid {
+    display: grid;
+    grid-template-columns: 2fr 3fr 32px;
+    line-height: 30px;
+
+    &.head {
+      background: var(--color-background-input);
+      padding: 0 8px;
+    }
+  }
+}
+</style>

+ 594 - 0
src/views/components/AnimateFrames.vue

@@ -0,0 +1,594 @@
+<template>
+  <div class="animate-frames props">
+    <div class="head">
+      <label>{{ animate.name }} </label>
+      <t-icon
+        name="close"
+        class="hover"
+        style="font-size: 16px"
+        @click="close"
+      />
+    </div>
+    <div style="height: calc(100% - 42px); overflow: auto">
+      <template v-if="animate.frames.length">
+        <t-collapse
+          v-model="openedCollapses"
+          :borderless="true"
+          :expand-on-row-click="true"
+          @change="onChangeCollapse"
+        >
+          <t-collapse-panel v-for="(item, i) in animate.frames" :value="i">
+            <template #header>
+              <label style="font-weight: normal">{{ `帧${i + 1}` }}</label>
+            </template>
+            <template #headerRightContent>
+              <t-space size="small" @click.stop>
+                <t-tooltip content="在当前帧后面添加动画帧">
+                  <t-icon
+                    name="folder-add"
+                    class="hover ml-4"
+                    @click="addFrame(i)"
+                  />
+                </t-tooltip>
+                <t-tooltip content="添加属性">
+                  <t-dropdown
+                    :options="propOptions"
+                    @click="addProp(i, $event)"
+                    :minColumnWidth="150"
+                  >
+                    <t-icon name="file-add" class="hover ml-4" />
+                  </t-dropdown>
+                </t-tooltip>
+                <t-popconfirm
+                  content="确认删除该动画帧吗"
+                  placement="left"
+                  @confirm="animate.frames.splice(i, 1)"
+                >
+                  <t-icon name="delete" class="hover ml-4" />
+                </t-popconfirm>
+              </t-space>
+            </template>
+            <section>
+              <div class="form-item">
+                <label>时长</label>
+                <t-input-number
+                  v-model="item.duration"
+                  theme="normal"
+                  :min="1"
+                  placeholder="毫秒"
+                  suffix="ms"
+                />
+              </div>
+              <div class="form-item mt-8" v-for="prop in frameProps[i]">
+                <template v-if="propDescriptions[prop]">
+                  <label>
+                    {{ propDescriptions[prop].label }}
+                  </label>
+
+                  <div>
+                    <t-input-number
+                      class="w-full"
+                      v-if="propDescriptions[prop].type === 'number'"
+                      v-model="item[prop]"
+                      theme="normal"
+                      :placeholder="propDescriptions[prop].placeholder"
+                      :min="propDescriptions[prop].min"
+                      :max="propDescriptions[prop].max"
+                      :step="propDescriptions[prop].step"
+                    />
+                    <t-select
+                      class="w-full"
+                      v-else-if="propDescriptions[prop].type === 'select'"
+                      v-model="item[prop]"
+                      :placeholder="propDescriptions[prop].placeholder"
+                      :options="propDescriptions[prop].options"
+                    />
+                    <t-color-picker
+                      class="w-full"
+                      v-else-if="propDescriptions[prop].type === 'color'"
+                      v-model="item[prop]"
+                      :placeholder="propDescriptions[prop].placeholder"
+                      format="CSS"
+                      :enable-alpha="true"
+                      :color-modes="
+                        propDescriptions[prop].colorModes ||
+                        propDescriptions[prop].colorModesFn(
+                          item[propDescriptions[prop].colorModesBind]
+                        )
+                      "
+                      :show-primary-color-preview="false"
+                    />
+                    <t-switch
+                      v-else-if="propDescriptions[prop].type === 'bool'"
+                      v-model="item[prop]"
+                      class="ml-8 mt-8"
+                      size="small"
+                    />
+                    <t-select
+                      class="w-full"
+                      v-else-if="propDescriptions[prop].type === 'child'"
+                      v-model="item[prop]"
+                      :placeholder="propDescriptions[prop].placeholder"
+                    >
+                      <t-option
+                        v-for="(child, i) in selections.pen.children"
+                        :key="i"
+                        :value="i"
+                        :label="`状态${i + 1}`"
+                      />
+                    </t-select>
+                    <t-input
+                      class="w-full"
+                      v-else
+                      v-model="item[prop]"
+                      :placeholder="propDescriptions[prop].placeholder"
+                    />
+                  </div>
+                </template>
+                <template v-else>
+                  <label>
+                    {{ prop }}
+                  </label>
+                  <div>
+                    <t-input class="w-full" v-model="item[prop]" />
+                  </div>
+                </template>
+
+                <t-icon name="close" class="hover" />
+              </div>
+            </section>
+          </t-collapse-panel>
+        </t-collapse>
+        <t-divider />
+        <div class="mt-16 px-16">
+          <t-button class="w-full" @click="addFrame" style="height: 30px">
+            添加帧
+          </t-button>
+        </div>
+      </template>
+      <div class="flex column center blank" v-else>
+        <img src="/img/blank.png" />
+        <div class="gray center">还没有动画帧</div>
+        <div class="mt-8">
+          <t-button @click="addFrame" style="height: 30px">添加动画帧</t-button>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, reactive, ref } from 'vue';
+
+import { useSelection } from '@/services/selections';
+
+const { animate } = defineProps<{
+  animate: { name: string; frames: any[] };
+}>();
+
+const emit = defineEmits(['close']);
+
+const { selections }: { selections: any } = useSelection();
+
+const openedCollapses = ref([0]);
+
+const frameProps: any = ref([]);
+
+const propDescriptions: any = {
+  visible: {
+    label: '显示',
+    type: 'bool',
+  },
+  scale: {
+    label: '缩放',
+    type: 'number',
+    min: 0.01,
+    max: 100,
+    placeholder: '缩放比例',
+  },
+  rotate: {
+    label: '旋转',
+    type: 'number',
+    min: 0,
+    max: 360,
+    placeholder: '°',
+  },
+  x: {
+    label: 'X位移',
+    type: 'number',
+    placeholder: 'px',
+  },
+  y: {
+    label: 'Y位移',
+    type: 'number',
+    placeholder: 'px',
+  },
+  color: {
+    label: '前景颜色',
+    type: 'color',
+    colorModes: ['monochrome'],
+  },
+  bkType: {
+    label: '背景类型',
+    type: 'select',
+    options: [
+      {
+        label: '纯色',
+        value: 0,
+      },
+      {
+        label: '线性渐变',
+        value: 1,
+      },
+      {
+        label: '径向渐变',
+        value: 2,
+      },
+    ],
+  },
+  background: {
+    label: '背景颜色',
+    type: 'color',
+    colorModesFn: (t: number) => {
+      if (t > 0) {
+        return ['linear-gradient'];
+      } else {
+        return ['monochrome'];
+      }
+    },
+    colorModesBind: 'bkType',
+  },
+  text: {
+    label: '文字',
+  },
+  showChild: {
+    label: '状态',
+    type: 'child',
+  },
+  flipX: {
+    label: '水平翻转',
+    type: 'bool',
+  },
+  flipY: {
+    label: '垂直翻转',
+    type: 'bool',
+  },
+  progress: {
+    label: '进度',
+    type: 'number',
+    step: 0.1,
+    min: 0,
+    max: 1,
+    placeholder: '0 - 1之间',
+  },
+  progressColor: {
+    label: '进度颜色',
+    type: 'color',
+    colorModes: ['monochrome'],
+  },
+  verticalProgress: {
+    label: '垂直进度',
+    type: 'bool',
+  },
+  globalAlpha: {
+    label: '透明度',
+    type: 'number',
+    step: 0.1,
+    min: 0,
+    max: 1,
+    placeholder: '0 - 1之间',
+  },
+  dash: {
+    label: '线条样式',
+    type: 'select',
+    options: [
+      {
+        label: `—————————`,
+        value: 0,
+      },
+      {
+        label: `- - - - - - - - - - - - -`,
+        value: 1,
+      },
+      {
+        label: `—  —  —  —  —  — — `,
+        value: 2,
+      },
+      {
+        label: `— - — - — - — - —`,
+        value: 3,
+      },
+    ],
+  },
+  gradientRadius: {
+    label: '背景渐变半径',
+    type: 'number',
+    min: 0,
+    max: 1,
+    placeholder: '0 - 1之间',
+  },
+  lineWidth: {
+    label: '线条宽度',
+    type: 'number',
+    min: 1,
+    placeholder: 'px',
+  },
+  strokeType: {
+    label: '线条渐变',
+    type: 'bool',
+  },
+  lineGradientColors: {
+    label: '线条渐变颜色',
+    type: 'color',
+    colorModes: ['linear-gradient'],
+  },
+  fontFamily: {
+    label: '显示阴影',
+    type: 'select',
+    options: [
+      {
+        label: '新宋体',
+        value: '新宋体',
+      },
+      {
+        label: '微软雅黑',
+        value: '微软雅黑',
+      },
+      {
+        label: '黑体',
+        value: '黑体',
+      },
+      {
+        label: '楷体',
+        value: '楷体',
+      },
+      {
+        label: '-apple-system',
+        value: '-apple-system',
+      },
+      {
+        label: 'BlinkMacSystemFont',
+        value: 'BlinkMacSystemFont',
+      },
+      {
+        label: 'PingFang SC',
+        value: 'PingFang SC',
+      },
+      {
+        label: 'Hiragino Sans GB',
+        value: 'Hiragino Sans GB',
+      },
+      {
+        label: 'Microsoft YaHei UI',
+        value: 'Microsoft YaHei UI',
+      },
+      {
+        label: 'Microsoft YaHei',
+        value: 'Microsoft YaHei',
+      },
+      {
+        label: 'fangsong',
+        value: 'fangsong',
+      },
+      {
+        label: 'Source Han Sans CN',
+        value: 'Source Han Sans CN',
+      },
+      {
+        label: 'sans-serif',
+        value: 'sans-serif',
+      },
+      {
+        label: 'serif',
+        value: 'serif',
+      },
+      {
+        label: 'Apple Color Emoji',
+        value: 'Apple Color Emoji',
+      },
+      {
+        label: 'Segoe UI Emoji',
+        value: 'Segoe UI Emoji',
+      },
+      {
+        label: 'Segoe UI Symbol',
+        value: 'Segoe UI Symbol',
+      },
+    ],
+  },
+  fontSize: {
+    label: '字体大小',
+    type: 'number',
+    min: 1,
+    placeholder: 'px',
+  },
+  textColor: {
+    label: '文字颜色',
+    type: 'color',
+    colorModes: ['monochrome'],
+  },
+  textBackground: {
+    label: '文字背景',
+    type: 'color',
+    colorModes: ['monochrome'],
+  },
+  fontStyle: {
+    label: '文字倾斜',
+    type: 'select',
+    options: [
+      {
+        label: `正常`,
+        value: 'normal',
+      },
+      {
+        label: `倾斜`,
+        value: 'italic',
+      },
+    ],
+  },
+  fontWeight: {
+    label: '文字加粗',
+    type: 'select',
+    options: [
+      {
+        label: `正常`,
+        value: 'normal',
+      },
+      {
+        label: `加粗`,
+        value: 'bold',
+      },
+    ],
+  },
+  textHasShadow: {
+    label: '文字阴影',
+    type: 'bool',
+  },
+  shadow: {
+    label: '显示阴影',
+    type: 'bool',
+  },
+  shadowColor: {
+    label: '阴影颜色',
+    type: 'color',
+    colorModes: ['monochrome'],
+  },
+  shadowBlur: {
+    label: '阴影模糊半径',
+    type: 'number',
+    min: 0,
+    placeholder: '正数',
+  },
+  shadowOffsetX: {
+    label: '阴影X偏移',
+    type: 'number',
+    placeholder: 'px',
+  },
+  shadowOffsetY: {
+    label: '阴影Y偏移',
+    type: 'number',
+    placeholder: 'px',
+  },
+};
+
+const propOptions: any = reactive([]);
+
+onMounted(() => {
+  onChangeCollapse([0]);
+
+  Object.keys(propDescriptions).forEach((key) => {
+    propOptions.push({
+      value: key,
+      content: propDescriptions[key].label,
+    });
+  });
+});
+
+const addFrame = (i?: number) => {
+  if (i == undefined) {
+    i = animate.frames.length - 1;
+  }
+  animate.frames.splice(i, 0, {});
+
+  openedCollapses.value = [i + 1];
+  onChangeCollapse(openedCollapses.value);
+};
+
+const onChangeCollapse = (val: number[]) => {
+  if (!animate.frames.length) {
+    return;
+  }
+  frameProps.value = [];
+  for (let i = 0; i < animate.frames.length; i++) {
+    frameProps.value[i] = [];
+  }
+  for (const i of val) {
+    Object.keys(animate.frames[i]).forEach((key) => {
+      if (key !== 'duration') {
+        frameProps.value[i].push(key);
+      }
+    });
+  }
+};
+
+const addProp = (i: number, e: { value: string }) => {
+  const found = frameProps.value[i].findIndex((key: string) => key === e.value);
+  if (found < 0) {
+    frameProps.value[i].push(e.value);
+  }
+};
+
+const close = () => {
+  emit('close');
+};
+</script>
+<style lang="postcss" scoped>
+.animate-frames {
+  position: fixed;
+  top: 100px;
+  bottom: 12px;
+  right: 308px;
+  width: 300px;
+  border: 1px solid var(--color-border-input);
+  border-radius: 4px;
+  box-shadow: rgba(39, 54, 78, 0.08) 0px 2px 10px 0px,
+    rgba(39, 54, 78, 0.1) 4px 12px 40px 0px;
+
+  & > .head {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    background-color: var(--color-background-popup);
+    padding: 12px 16px;
+    font-size: 14px;
+    line-height: 1;
+    color: var(--color-title);
+  }
+
+  .blank {
+    height: 70%;
+    img {
+      padding: 16px;
+      opacity: 0.9;
+    }
+  }
+
+  .form-item {
+    position: relative;
+
+    & > div {
+      width: 190px;
+      flex-shrink: 0;
+    }
+
+    & > svg {
+      flex-shrink: 0;
+      visibility: hidden;
+    }
+
+    &:hover {
+      & > svg {
+        visibility: visible;
+      }
+    }
+  }
+
+  :deep(.t-collapse) {
+    .t-collapse-panel__header {
+      .t-input {
+        border-color: transparent;
+        &:hover {
+          border-color: var(--color-border-input);
+        }
+      }
+    }
+
+    .t-collapse-panel__icon:hover {
+      background: none;
+      svg {
+        color: var(--color-primary);
+      }
+    }
+  }
+}
+</style>

+ 182 - 0
src/views/components/ChargeCloudPublish.vue

@@ -0,0 +1,182 @@
+<template>
+  <div class="cloud-charge">
+    <div class="body">
+      <li
+        v-for="(item, index) in data.vips"
+        :key="index"
+        class="cloud-item"
+        :class="{ active: data.selected === index }"
+        @click="data.selected = index"
+      >
+        <h4 class="mt-16">{{ item.name }}</h4>
+        <div class="detail mt-8">快速发布,提升工作效率</div>
+        <div class="detail">减少运维工作,降低成本</div>
+        <div class="detail">专注业务,创新无负担</div>
+
+        <div class="price">
+          <span>¥</span>
+          {{ item.price }}
+        </div>
+        <div v-if="item.originalPrice !== item.price" class="original-price">
+          <span>原价:¥</span>
+          {{ item.originalPrice }}
+        </div>
+      </li>
+    </div>
+    <div class="btns">
+      <t-button @click="onSubmitOrder">提交订单</t-button>
+    </div>
+  </div>
+
+  <t-dialog
+    v-if="wechatPayDialog.show"
+    v-model:visible="wechatPayDialog.show"
+    class="pay-dialog"
+    header="乐吾乐收银台"
+    :close-on-overlay-click="false"
+    :top="95"
+    :width="700"
+    confirm-btn="支付完成"
+    :cancel-btn="null"
+    @close="getPayResult"
+  >
+    <WechatPay
+      :order="data.order"
+      :code-url="data.order.codeUrl"
+      @success="onSuccess"
+    />
+  </t-dialog>
+</template>
+
+<script lang="ts" setup>
+import { onBeforeMount, reactive } from 'vue';
+import axios from 'axios';
+import WechatPay from './WechatPay.vue';
+
+const { projectId } = defineProps<{
+  projectId: string;
+}>();
+
+const emit = defineEmits(['success']);
+
+const data = reactive<any>({
+  vips: [],
+  selected: 0,
+  order: { id: '', _id: '', codeUrl: '' },
+});
+
+const wechatPayDialog = reactive({
+  show: false,
+});
+
+onBeforeMount(async () => {
+  const ret: { list: any } = await axios.post('/api/goods/list', {
+    type: '云发布',
+  });
+  data.vips = ret.list;
+});
+
+const onSubmitOrder = async () => {
+  const result: any = await axios.post('/api/order/submit', {
+    goods: data.vips[data.selected],
+    paymentMethod: '微信',
+    data: { id: projectId },
+  });
+  if (result) {
+    data.order = result;
+    wechatPayDialog.show = true;
+  }
+};
+
+const onSuccess = (success: boolean) => {
+  emit('success', success);
+};
+
+const getPayResult = async () => {
+  const result: { state: number } = await axios.post('/order/pay/state', {
+    id: data.order.id || data.order._id,
+  });
+  if (result && result.state) {
+    return true;
+  }
+};
+</script>
+
+<style lang="postcss" scoped>
+.cloud-charge {
+  height: 300px;
+
+  display: flex;
+  flex-direction: column;
+
+  & > .body {
+    flex-grow: 1;
+    display: flex;
+    margin-left: -10px;
+    margin-right: -10px;
+
+    .cloud-item {
+      width: 200px;
+      border-radius: 6px;
+      box-shadow: 0px 1px 20px 0px rgba(0, 10, 38, 0.1);
+      margin: 0 10px;
+      text-align: center;
+      border: 1px solid var(--color-sub-border);
+
+      h1 {
+        font-weight: 700;
+        line-height: 40px;
+        border-radius: 6px 6px 0px 0px;
+        box-shadow: 0px 1px 20px 0px rgba(0, 10, 38, 0.1);
+        margin-bottom: 16px;
+      }
+
+      .detail {
+        font-size: 12px;
+        line-height: 30px;
+        display: flex;
+        justify-content: center;
+      }
+
+      .price {
+        margin-left: -10px;
+        margin-top: 20px;
+        color: var(--color-bland);
+        font-size: 24px;
+        text-align: center;
+
+        span {
+          font-size: 12px;
+          position: relative;
+          top: -4px;
+        }
+      }
+
+      .original-price {
+        font-size: 12px;
+        color: var(--color-desc);
+      }
+
+      &:hover {
+        border-color: var(--color-primary);
+        cursor: pointer;
+      }
+      &.active {
+        border-color: var(--color-primary);
+        cursor: pointer;
+
+        &.disable {
+          border-color: transparent;
+          cursor: default;
+        }
+      }
+    }
+  }
+
+  & > .btns {
+    flex-shrink: 0;
+    text-align: right;
+    margin-top: 20px;
+  }
+}
+</style>

+ 389 - 0
src/views/components/ContextMenu.vue

@@ -0,0 +1,389 @@
+<template>
+  <div class="l-context-menu t-dropdown__menu">
+    <template v-if="props.type === 'anchor'">
+      <t-dropdown-item>
+        <a @click="onAddAnchorHand">
+          <div class="flex">添加手柄 <span class="flex-grow"></span> H</div>
+        </a>
+      </t-dropdown-item>
+      <t-dropdown-item>
+        <a @click="onRemoveAnchorHand">
+          <div class="flex">删除手柄 <span class="flex-grow"></span> D</div>
+        </a>
+      </t-dropdown-item>
+      <t-dropdown-item>
+        <a @click="onToggleAnchorHand">
+          <div class="flex">切换手柄 <span class="flex-grow"></span> Shift</div>
+        </a>
+      </t-dropdown-item>
+    </template>
+    <template v-if="props.type === 'pen'">
+      <t-dropdown-item
+        :class="!choosePens() && !choosePen() ? 'item-diabled' : ''"
+      >
+        <a @click="layerMove('top')">
+          <div class="flex">置顶 <span class="flex-grow"></span></div>
+        </a>
+      </t-dropdown-item>
+      <t-dropdown-item
+        :class="!choosePens() && !choosePen() ? 'item-diabled' : ''"
+      >
+        <a @click="layerMove('bottom')">
+          <div class="flex">置底 <span class="flex-grow"></span></div>
+        </a>
+      </t-dropdown-item>
+      <t-dropdown-item
+        :class="!choosePens() && !choosePen() ? 'item-diabled' : ''"
+      >
+        <a @click="layerMove('up')">
+          <div class="flex">上一个图层 <span class="flex-grow"></span></div>
+        </a>
+      </t-dropdown-item>
+      <t-dropdown-item
+        :class="!choosePens() && !choosePen() ? 'item-diabled' : ''"
+      >
+        <a @click="layerMove('down')">
+          <div class="flex">下一个图层 <span class="flex-grow"></span></div>
+        </a>
+      </t-dropdown-item>
+      <t-divider />
+      <t-dropdown-item
+        :class="!choosePens() && !choosePen() ? 'item-diabled' : ''"
+      >
+        <a @click="layerMove('down')">
+          <div class="flex">下一个图层 <span class="flex-grow"></span></div>
+        </a>
+      </t-dropdown-item>
+      <template v-if="choosePens()">
+        <t-dropdown-item>
+          <a @click="combine()">
+            <div class="flex">组合 <span class="flex-grow"></span></div>
+          </a>
+        </t-dropdown-item>
+        <t-dropdown-item>
+          <a @click="combine(0)">
+            <div class="flex">组合为状态 <span class="flex-grow"></span></div>
+          </a>
+        </t-dropdown-item>
+      </template>
+      <t-dropdown-item v-if="hasChildren()">
+        <a @click="unCombine()">
+          <div class="flex">取消组合 <span class="flex-grow"></span></div>
+        </a>
+      </t-dropdown-item>
+      <t-dropdown-item
+        :class="!choosePens() && !choosePen() ? 'item-diabled' : ''"
+      >
+        <a @click="onLock">
+          <div class="flex">
+            {{ getLocked() ? '解锁' : '锁定' }} <span class="flex-grow"></span>
+          </div>
+        </a>
+      </t-dropdown-item>
+      <t-divider />
+      <t-dropdown-item
+        :class="!choosePens() && !choosePen() ? 'item-diabled' : ''"
+      >
+        <a @click="onDel">
+          <div class="flex">删除<span class="flex-grow"></span></div>
+        </a>
+      </t-dropdown-item>
+      <template v-if="isLine()">
+        <t-dropdown-item v-if="isLineType()">
+          <a @click="changeType(0)">
+            <div class="flex">变成节点<span class="flex-grow"></span></div>
+          </a>
+        </t-dropdown-item>
+        <t-dropdown-item v-else>
+          <a @click="changeType(1)">
+            <div class="flex">变成连线<span class="flex-grow"></span></div>
+          </a>
+        </t-dropdown-item>
+      </template>
+      <t-divider />
+      <t-dropdown-item :class="cantUndo() ? 'item-diabled' : ''">
+        <a @click="onUndo">
+          <div class="flex">撤销<span class="flex-grow"></span>Ctrl + Z</div>
+        </a>
+      </t-dropdown-item>
+      <t-dropdown-item :class="cantRedo() ? 'item-diabled' : ''">
+        <a @click="onRedo">
+          <div class="flex">恢复<span class="flex-grow"></span>Shift + Z</div>
+        </a>
+      </t-dropdown-item>
+      <t-divider />
+      <t-dropdown-item
+        :class="!choosePens() && !choosePen() ? 'item-diabled' : ''"
+      >
+        <a @click="onCut">
+          <div class="flex">剪切<span class="flex-grow"></span>Ctrl + X</div>
+        </a>
+      </t-dropdown-item>
+      <t-dropdown-item
+        :class="!choosePens() && !choosePen() ? 'item-diabled' : ''"
+      >
+        <a @click="onCopy">
+          <div class="flex">复制<span class="flex-grow"></span>Ctrl + C</div>
+        </a>
+      </t-dropdown-item>
+      <t-dropdown-item>
+        <a @click="onPaste">
+          <div class="flex">粘贴<span class="flex-grow"></span>Ctrl + V</div>
+        </a>
+      </t-dropdown-item>
+    </template>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue';
+import { LockState, Pen, PenType, Meta2d } from '@meta2d/core';
+
+const props = defineProps({
+  type: String,
+});
+const emit = defineEmits(['changeVisible']);
+function closeMenu() {
+  emit('changeVisible', false);
+}
+const onAddAnchorHand = () => {
+  meta2d.addAnchorHand();
+  closeMenu();
+};
+
+const onRemoveAnchorHand = () => {
+  meta2d.removeAnchorHand();
+  closeMenu();
+};
+
+const onToggleAnchorHand = () => {
+  meta2d.toggleAnchorHand();
+  closeMenu();
+};
+
+function choosePen(): boolean {
+  return meta2d?.store.active?.length === 1;
+}
+
+function choosePens(): boolean {
+  return meta2d?.store.active?.length > 1;
+}
+
+function layerMove(type: 'top' | 'bottom' | 'up' | 'down') {
+  const pens = meta2d.store.active;
+  if (hasImage()) {
+    if (type === 'top') {
+      meta2d.setValue({
+        id: pens[0].id,
+        isBottom: false,
+      });
+      meta2d.top(pens[0]);
+    } else if (type === 'bottom') {
+      meta2d.setValue({
+        id: pens[0].id,
+        isBottom: true,
+      });
+      meta2d.bottom(pens[0]);
+    } else if (type === 'up') {
+      if (pens[0].isBottom) {
+        meta2d.setValue({
+          id: pens[0].id,
+          isBottom: false,
+        });
+      } else {
+        meta2d.up(pens[0]);
+      }
+    } else if (type === 'down') {
+      if (!pens[0].isBottom) {
+        meta2d.setValue({
+          id: pens[0].id,
+          isBottom: true,
+        });
+      } else {
+        meta2d.down(pens[0]);
+      }
+    }
+  } else {
+    if (pens[0].name === 'gif') {
+      let zIndex = pens[0].calculative.zIndex;
+      if (type === 'top') {
+        zIndex == 999;
+      }
+      if (type === 'bottom') {
+        zIndex == -999;
+      }
+      if (type === 'up') {
+        zIndex++;
+      }
+      if (type === 'down') {
+        zIndex--;
+      }
+      meta2d.setValue({
+        id: pens[0].id,
+        zIndex,
+      });
+    } else {
+      if (Array.isArray(pens)) {
+        for (const pen of pens) {
+          meta2d[type](pen);
+        }
+      }
+    }
+    meta2d.render();
+  }
+  emit('changeVisible', false);
+}
+function hasChildren(): boolean {
+  return choosePen() && meta2d?.store.active[0]?.children?.length > 0;
+}
+
+function getLocked() {
+  return meta2d?.store.active?.some((pen: Pen) => pen.locked);
+}
+
+function combine(showChild?: number) {
+  meta2d.combine(meta2d.store.active, showChild);
+  closeMenu();
+}
+
+function unCombine() {
+  meta2d.uncombine();
+  closeMenu();
+}
+
+function onLock() {
+  const locked = !getLocked();
+  const pens = meta2d.store.active;
+  if (Array.isArray(pens)) {
+    for (const pen of pens) {
+      pen.locked = locked ? LockState.DisableMove : LockState.None;
+    }
+  }
+  meta2d.render();
+  closeMenu();
+}
+
+function onDel() {
+  meta2d.delete();
+  closeMenu();
+}
+
+function onUndo() {
+  meta2d.undo();
+  closeMenu();
+}
+
+function onRedo() {
+  meta2d.redo();
+  closeMenu();
+}
+
+function onCut() {
+  meta2d.cut();
+  closeMenu();
+}
+
+function onCopy() {
+  meta2d.copy();
+  closeMenu();
+}
+
+function onPaste() {
+  meta2d.paste();
+  closeMenu();
+}
+
+function cantUndo(): boolean {
+  return (
+    !!meta2d?.store.data.locked ||
+    meta2d?.store.histories.length == 0 ||
+    meta2d?.store.historyIndex == null ||
+    meta2d?.store.historyIndex < 0
+  );
+}
+
+function cantRedo(): boolean {
+  return (
+    !!meta2d?.store.data.locked ||
+    meta2d?.store.histories.length == 0 ||
+    meta2d?.store.historyIndex == null ||
+    meta2d?.store.historyIndex > meta2d?.store.histories.length - 2
+  );
+}
+
+function isLine(): boolean {
+  return choosePen() && meta2d?.store.active[0]?.name === 'line';
+}
+
+function isLineType(): boolean {
+  return meta2d?.store.active[0]?.type === PenType.Line;
+}
+
+function changeType(type: number) {
+  const id = meta2d.store.active[0].id;
+  meta2d.setValue(
+    {
+      id,
+      type,
+    },
+    {
+      history: true,
+    }
+  );
+  closeMenu();
+}
+
+function hasImage(): boolean {
+  const pen = meta2d.store.active[0];
+  return choosePen() && pen.image && pen.name !== 'gif';
+}
+</script>
+
+<style lang="postcss" scoped>
+.l-context-menu {
+  position: absolute;
+  display: flex;
+  flex-direction: column;
+  padding: 6px;
+  border-radius: 6px;
+  gap: 2px;
+  background-color: var(--color-background-popup);
+  z-index: 999;
+  .t-dropdown__item {
+    max-width: 240px !important;
+    /* padding: 0;
+    background-color: transparent; */
+
+    &:hover {
+      background-color: var(--color-background-popup-hover);
+    }
+
+    a {
+      width: 150px;
+      display: block;
+      text-decoration: none;
+      color: var(--color);
+      margin-left: 8px;
+
+      label {
+        font-size: 10px;
+        background-color: #ff400030;
+        color: var(--color-bland);
+        padding: 0 6px;
+        margin-left: 4px;
+        border-radius: 2px;
+      }
+    }
+  }
+
+  .item-diabled {
+    a {
+      cursor: not-allowed;
+      color: var(--td-text-color-disabled);
+    }
+    &:hover {
+      background-color: #0000;
+    }
+  }
+}
+</style>

+ 301 - 42
src/views/components/ElementTree.vue

@@ -1,5 +1,6 @@
 <template>
   <div class="elements">
+    <div class="title" style="margin: 8px 0 0 12px">图元结构</div>
     <t-tree
       class="flex-grow"
       ref="tree"
@@ -8,64 +9,298 @@
       activable
       :expand-parent="true"
       @active="onActive"
-      style="margin: 8px 0"
+      style="padding: 0 4px 8px 8px"
     >
-      <template #icon="{ node }">
-        <template v-if="!node.data.leaf">
-          <t-icon v-if="node.expanded" name="chevron-down" />
-          <t-icon v-else name="chevron-right" />
-        </template>
+      <template #label="{ node }: any">
+        <div class="flex middle" :class="{ gray: node.data.visible === false }">
+          <template v-if="node.getChildren()">
+            <t-icon v-if="node.expanded" name="folder-open" />
+            <t-icon v-else name="folder" />
+          </template>
+          <t-icon v-else name="control-platform" />
+
+          <t-input
+            v-if="node.data.edited"
+            v-model="node.data.label"
+            :autofocus="true"
+            @blur="onDescription(node)"
+            @enter="onDescription(node)"
+          />
+          <span v-else @dblclick="node.data.edited = true">
+            {{ node.label }}
+          </span>
+        </div>
       </template>
-      <template #label="{ node }">
-        <div class="flex middle">
-          {{ node.label }}
+      <template #operations="{ node }: any">
+        <div
+          class="flex middle operations"
+          :class="{
+            gray: node.data.visible === false,
+            show: node.data.visible === false || node.data.locked,
+          }"
+          style="width: 36px; height: 16px"
+        >
+          <t-tooltip
+            class="mr-4"
+            v-if="!node.data.locked"
+            content="可编辑"
+            placement="top"
+          >
+            <svg class="l-icon" aria-hidden="true" @click="lock(node, 1)">
+              <use xlink:href="#l-unlock"></use>
+            </svg>
+          </t-tooltip>
+          <t-tooltip
+            class="mr-4"
+            v-else-if="node.data.locked == 1"
+            content="禁止编辑"
+            placement="top"
+          >
+            <svg class="l-icon" aria-hidden="true" @click="lock(node, 2)">
+              <use xlink:href="#l-lock"></use>
+            </svg>
+          </t-tooltip>
+          <t-tooltip
+            class="mr-4"
+            v-else-if="node.data.locked == 2"
+            content="禁止编辑和移动"
+            placement="top"
+          >
+            <svg class="l-icon" aria-hidden="true" @click="lock(node, 10)">
+              <use xlink:href="#l-wufayidong"></use>
+            </svg>
+          </t-tooltip>
+          <t-tooltip
+            class="mr-4"
+            v-else-if="node.data.locked == 10"
+            content="禁止所有事件"
+            placement="top"
+          >
+            <svg class="l-icon" aria-hidden="true" @click="lock(node, 0)">
+              <use xlink:href="#l-jinyong"></use>
+            </svg>
+          </t-tooltip>
+
+          <t-icon
+            v-if="node.data.visible !== false"
+            name="browse"
+            @click="visible(node, false)"
+          />
+          <t-icon v-else name="browse-off" @click="visible(node, true)" />
         </div>
       </template>
     </t-tree>
-    <div class="groups">
-      <div class="flex middle between">
-        <div class="title" style="margin-bottom: 8px">图层分组</div>
-        <a> +新建分组</a>
+    <div class="groups-panel" style="padding: 8px 0">
+      <div class="flex middle between" style="padding: 0 12px">
+        <div class="title">分组</div>
+        <a @click="addGroup"> +新建分组</a>
+      </div>
+      <div class="groups">
+        <div
+          v-for="(item, i) in data.groups"
+          class="flex middle between hover"
+          :class="{ primary: i == data.activedGroup }"
+        >
+          <span
+            class="flex-grow"
+            v-if="i != data.editedGroup"
+            @click="activeGroup(i)"
+            @dblclick="
+              data.activedGroup = data.editedGroup = i;
+              data.group = item;
+            "
+          >
+            {{ item }}
+          </span>
+          <t-input
+            v-else
+            v-model="data.group"
+            :autofocus="true"
+            @blur="setGroup"
+            @enter="setGroup"
+          />
+          <t-popconfirm
+            content="确认删除该分组吗?"
+            @confirm="delGroup"
+            @cancel="data.deleteGroup = undefined"
+          >
+            <t-icon
+              name="delete"
+              class="ml-8"
+              :class="{ block: i == data.deleteGroup }"
+              @click="data.deleteGroup = i"
+            />
+          </t-popconfirm>
+        </div>
       </div>
     </div>
   </div>
 </template>
 
 <script lang="ts" setup>
-import { onBeforeMount, reactive, ref } from 'vue';
+import { onBeforeMount, onBeforeUnmount, reactive, ref } from 'vue';
+import { MessagePlugin } from 'tdesign-vue-next';
+
+import { LockState, Pen } from '@meta2d/core';
+import { getPenTree, setChildrenVisible } from '@/services/common';
 
 const tree = ref<any>(null);
 const data = reactive<any>({
-  tree: [
-    {
-      label: '图元1',
-      children: [
-        {
-          label: '图元1.1',
-        },
-        {
-          label: '图元1.2',
-        },
-      ],
-    },
-    {
-      label: '图元2',
-      children: [
-        {
-          label: '图元2.1',
-        },
-        {
-          label: '图元2.2',
-        },
-      ],
-    },
-  ],
+  tree: [],
   actived: [],
+  groups: [],
 });
 
-onBeforeMount(() => {});
+onBeforeMount(() => {
+  meta2d.on('opened', getTree);
+  meta2d.on('add', getTree);
+  meta2d.on('undo', getTree);
+  meta2d.on('redo', getTree);
+  meta2d.on('delete', getTree);
+  meta2d.on('combine', getTree);
+
+  getTree();
+
+  const d = meta2d.store.data as any;
+  if (!d.groups) {
+    d.groups = [];
+  }
+  data.groups = d.groups;
+});
+
+const getTree = () => {
+  data.tree = getPenTree();
+};
+
+const calcElem = (node: Pen) => {
+  if (!node) {
+    return;
+  }
+
+  const elem: any = {
+    label: (node as any).description || node.name,
+    value: node.id,
+    locked: node.locked,
+    visible: node.visible,
+  };
+
+  if (!node.children) {
+    return elem;
+  }
+
+  elem.children = [];
+  for (const id of node.children) {
+    const child = calcElem(meta2d.store.pens[id]);
+    child && elem.children.push(child);
+  }
+
+  return elem;
+};
+
+const onActive = (value: string[]) => {
+  if (!value.length) {
+    return;
+  }
+
+  const pens: Pen[] = [];
+  for (const item of meta2d.store.data.pens) {
+    for (const v of value) {
+      if (item.id === v) {
+        pens.push(item);
+      }
+    }
+  }
+  meta2d.active(pens, false);
+  meta2d.render();
+};
+
+const lock = (node: any, v: LockState) => {
+  node.data.locked = v;
+  meta2d.setValue({
+    id: node.value,
+    locked: v,
+  });
+};
+
+const visible = (node: any, v: boolean) => {
+  node.data.visible = v;
+  setChildrenVisible(node, v);
+  const pen = meta2d.findOne(node.value);
+  pen && meta2d.setVisible(pen, v);
+};
+
+const onDescription = (node: any) => {
+  node.data.edited = false;
+  node.setData({ label: node.data.label });
+  meta2d.setValue({
+    id: node.value,
+    description: node.data.label,
+  });
+};
+
+const addGroup = () => {
+  const i = data.groups.length + 1;
+  data.group = '组' + i;
+  data.groups.push(data.group);
+  data.activedGroup = data.editedGroup = i;
+};
+
+const activeGroup = (i: number) => {
+  data.activedGroup = i;
+  const group = data.groups[i];
+  const pens: Pen[] = [];
+  for (const item of meta2d.store.data.pens) {
+    if (item.tags?.includes(group)) {
+      pens.push(item);
+    }
+  }
+  meta2d.active(pens, false);
+  meta2d.render();
+};
+
+const setGroup = () => {
+  if (data.groups[data.editedGroup] === data.group) {
+    data.editedGroup = undefined;
+    return;
+  }
+
+  if (data.groups.includes(data.group)) {
+    MessagePlugin.error('已经存在相同分组!');
+    return;
+  }
+
+  for (const item of meta2d.store.data.pens) {
+    // @ts-ignore
+    if (item.group === data.groups[data.editedGroup]) {
+      // @ts-ignore
+      item.group === data.group;
+    }
+  }
 
-const onActive = (value: string[]) => {};
+  data.groups[data.editedGroup] = data.group;
+  data.editedGroup = undefined;
+};
+
+const delGroup = () => {
+  for (const item of meta2d.store.data.pens) {
+    // @ts-ignore
+    if (item.group === data.groups[data.deleteGroup]) {
+      // @ts-ignore
+      delete item.group;
+    }
+  }
+  data.groups.splice(data.deleteGroup, 1);
+  data.deleteGroup = undefined;
+};
+
+onBeforeUnmount(() => {
+  meta2d.off('opened', getTree);
+  meta2d.off('add', getTree);
+  meta2d.off('undo', getTree);
+  meta2d.off('redo', getTree);
+  meta2d.off('delete', getTree);
+  meta2d.off('combine', getTree);
+});
 </script>
 <style lang="postcss" scoped>
 .elements {
@@ -75,13 +310,37 @@ const onActive = (value: string[]) => {};
 
   & > * {
     overflow-y: auto;
+    width: 100%;
   }
 
-  .groups {
+  .groups-panel {
     flex-shrink: 0;
     height: 240px;
-    border-top: 1px solid var(--color-border);
+    border-top: 1px solid var(--color-border-input);
     padding: 8px 12px;
   }
+
+  .groups {
+    height: calc(100% - 24px);
+    overflow-y: auto;
+    padding: 0 12px 16px 12px;
+    & > div {
+      height: 30px;
+      line-height: 30px;
+
+      svg {
+        display: none;
+        &.block {
+          display: block;
+        }
+      }
+
+      &:hover {
+        svg {
+          display: block;
+        }
+      }
+    }
+  }
 }
 </style>

+ 129 - 62
src/views/components/FileProps.vue

@@ -2,24 +2,26 @@
   <div class="props">
     <t-tabs v-model="data.tab">
       <t-tab-panel :value="1" label="画布">
-        <t-space direction="vertical" class="panel">
+        <t-space direction="vertical" size="small" class="panel">
           <div class="form-item">
             <label>画布尺寸</label>
             <t-input-number
               label="W"
-              :value="meta2dInfo?.width"
+              :value="data.meta2dData.width"
               placeholder="宽"
-              theme="column"
+              theme="normal"
               min="1"
+              style="width: 80px"
               @change="changeValue($event, 'width')"
             />
             <t-input-number
               class="ml-8"
               label="H"
               placeholder="高"
-              theme="column"
+              theme="normal"
               min="1"
-              :value="meta2dInfo?.height"
+              :value="data.meta2dData.height"
+              style="width: 80px"
               @change="changeValue($event, 'height')"
             />
             <t-dropdown
@@ -54,7 +56,7 @@
               format="CSS"
               :color-modes="['monochrome']"
               :show-primary-color-preview="false"
-              :value="meta2dInfo?.background"
+              :value="data.meta2dData.background"
               @change="changeValue($event, 'background')"
             />
           </div>
@@ -62,7 +64,7 @@
             <label>背景图片</label>
             <t-upload
               class="ml-8"
-              v-model="bgfile"
+              v-model="data.background"
               action="/api/image/upload"
               theme="image"
               accept="image/*"
@@ -71,25 +73,35 @@
               :auto-upload="true"
               :upload-all-files-in-one-request="false"
               @success="fileSuccessed"
-              @remove = fileRemoved
+              @remove="fileRemoved"
             />
           </div>
         </t-space>
-        <t-space direction="vertical" class="mt-8">
+        <t-space direction="vertical" size="small" class="mt-8">
           <t-collapse
             :defaultValue="['1']"
             expandIconPlacement="right"
             :borderless="true"
           >
             <t-collapse-panel value="1" header="预览设置">
-              <t-space direction="vertical">
+              <t-space direction="vertical" size="small">
                 <div class="form-item">
                   <label>缩放方式</label>
                   <t-radio-group class="ml-8" default-value="1">
-                    <t-radio value="1" title="宽高中较小的铺满"
-                      >自动铺满</t-radio
+                    <t-radio
+                      value="1"
+                      title="宽高中较小的铺满"
+                      style="width: 200px"
                     >
-                    <t-radio value="2" title="高度等比例缩放">宽度铺满</t-radio>
+                      自动铺满
+                    </t-radio>
+                    <t-radio
+                      value="2"
+                      title="高度等比例缩放"
+                      style="width: 200px"
+                    >
+                      宽度铺满
+                    </t-radio>
                     <t-radio value="3" title="宽度等比例缩放">高度铺满</t-radio>
                   </t-radio-group>
                 </div>
@@ -101,38 +113,40 @@
                   <div class="form-item">
                     <label>水平对齐</label>
                     <t-radio-group class="ml-8" default-value="2">
-                      <t-radio value="1">左对齐</t-radio>
-                      <t-radio value="2">居中</t-radio>
-                      <t-radio value="3">右对齐</t-radio>
+                      <t-radio value="1" style="width: 200px">左对齐</t-radio>
+                      <t-radio value="2" style="width: 200px">居中</t-radio>
+                      <t-radio value="3" style="width: 200px">右对齐</t-radio>
                     </t-radio-group>
                   </div>
                   <div class="form-item">
                     <label>垂直对齐</label>
                     <t-radio-group class="ml-8" default-value="2">
-                      <t-radio value="1">顶部对齐</t-radio>
-                      <t-radio value="2">居中</t-radio>
-                      <t-radio value="3">底部对齐</t-radio>
+                      <t-radio value="1" style="width: 200px">顶部对齐</t-radio>
+                      <t-radio value="2" style="width: 200px">居中</t-radio>
+                      <t-radio value="3" style="width: 200px">底部对齐</t-radio>
                     </t-radio-group>
                   </div>
                 </template>
               </t-space>
             </t-collapse-panel>
             <t-collapse-panel value="2" header="进阶设置">
-              <t-space direction="vertical">
+              <t-space direction="vertical" size="small">
                 <div class="form-item">
                   <label>初始化动作</label>
                   <t-button
                     variant="outline"
-                    style="padding: 0 6px; margin: 2px 8px"
+                    style="padding: 0 4px; margin: 2px 8px"
+                    @click="showInitFnDialog"
                   >
                     <t-icon name="ellipsis" />
                   </t-button>
                 </div>
                 <div class="form-item">
-                  <label>数据格式转换</label>
+                  <label>数据监听</label>
                   <t-button
                     variant="outline"
-                    style="padding: 0 6px; margin: 2px 8px"
+                    style="padding: 0 4px; margin: 2px 8px"
+                    @click="showDataTransformation"
                   >
                     <t-icon name="ellipsis" />
                   </t-button>
@@ -146,36 +160,58 @@
         <ElementTree />
       </t-tab-panel>
     </t-tabs>
+    <t-dialog
+      v-if="initFnDialog.show"
+      :visible="true"
+      header="初始化动作"
+      @confirm="onOkInitFn"
+      @close="initFnDialog.show = false"
+      :width="700"
+    >
+      <CodeEditor v-model="initFnDialog.data" style="height: 300px" />
+    </t-dialog>
+
+    <t-dialog
+      v-if="dataTransformationDialog.show"
+      :visible="true"
+      header="数据监听"
+      @confirm="onOkDataTransformation"
+      @close="dataTransformationDialog.show = false"
+      :width="700"
+    >
+      <CodeEditor
+        v-model="dataTransformationDialog.data"
+        style="height: 300px"
+      />
+    </t-dialog>
   </div>
 </template>
 
 <script lang="ts" setup>
-import { onMounted, reactive, onUnmounted, ref } from "vue";
-import { getCookie } from "@/services/cookie";
-import ElementTree from "./ElementTree.vue";
-import { Meta2dData } from "@meta2d/core";
+import { onMounted, reactive, onUnmounted, ref } from 'vue';
+import { getCookie } from '@/services/cookie';
+import ElementTree from './ElementTree.vue';
+import CodeEditor from '@/views/components/common/CodeEditor.vue';
 
 const headers = {
-  Authorization: "Bearer " + (localStorage.token || getCookie("token") || ""),
+  Authorization: 'Bearer ' + (localStorage.token || getCookie('token') || ''),
 };
-const updataData = { directory: "/项目" };
+const updataData = { directory: '/项目' };
 
 const data = reactive<any>({
   tab: 1,
+  background: [],
+  meta2dData: {},
 });
 
-const bgfile = ref<any[]>([]);
-
-const meta2dInfo = ref<Meta2dData | any>();
-
 const screenList = reactive([
   {
-    name: "大屏",
+    name: '大屏',
     width: 1920,
     height: 1080,
   },
   {
-    name: "网页",
+    name: '网页',
     width: 1440,
     height: 1024,
   },
@@ -190,80 +226,111 @@ const screenList = reactive([
     height: 1194,
   },
   {
-    name: "平板Mini",
+    name: '平板Mini',
     width: 768,
     height: 1024,
   },
   {
-    name: "华为P8",
+    name: '华为P8',
     width: 360,
     height: 640,
   },
   {
-    name: "华为P40",
+    name: '华为P40',
     width: 395,
     height: 856,
   },
   {
-    name: "手机1",
+    name: '手机1',
     width: 430,
     height: 932,
   },
   {
-    name: "手机2",
+    name: '手机2',
     width: 375,
     height: 812,
   },
 ]);
 
+const initFnDialog = reactive<any>({
+  show: false,
+  data: '',
+});
+
+const dataTransformationDialog = reactive<any>({
+  show: false,
+  data: '',
+});
+
 const selectedSreen = (item: any) => {
-  (<any>globalThis).meta2d.store.data.width = item.width;
-  (<any>globalThis).meta2d.store.data.height = item.height;
-  (<any>globalThis).meta2d.store.patchFlagsBackground = true;
-  (<any>globalThis).meta2d.render();
+  meta2d.store.data.width = item.width;
+  meta2d.store.data.height = item.height;
+  meta2d.store.patchFlagsBackground = true;
+  meta2d.render();
   openData();
 };
 
 const fileSuccessed = async (content: any) => {
-  (<any>globalThis).meta2d.store.patchFlagsBackground = true;
-  (<any>globalThis).meta2d.setBackgroundImage(content.response.url);
-  (<any>globalThis).meta2d.store.patchFlagsBackground = true;
-  (<any>globalThis).meta2d.render();
+  meta2d.store.patchFlagsBackground = true;
+  meta2d.setBackgroundImage(content.response.url);
+  meta2d.store.patchFlagsBackground = true;
+  meta2d.render();
 };
 
 const fileRemoved = () => {
-  (<any>globalThis).meta2d.setBackgroundImage('');
-  (<any>globalThis).meta2d.store.patchFlagsBackground = true;
-  (<any>globalThis).meta2d.render();
-  bgfile.value = [];
-}
+  meta2d.setBackgroundImage('');
+  meta2d.store.patchFlagsBackground = true;
+  meta2d.render();
+  data.background = [];
+};
 
 const changeValue = (e: any, key: string) => {
-  (<any>globalThis).meta2d.store.data[key] = e;
-  (<any>globalThis).meta2d.render();
+  // @ts-ignore
+  meta2d.store.data[key] = e;
+  meta2d.render();
   openData();
 };
 
 onMounted(() => {
   openData();
-  (<any>globalThis).meta2d.on("opened", openData);
+  meta2d.on('opened', openData);
 });
 
 onUnmounted(() => {
-  (<any>globalThis).meta2d.off("opened", openData);
+  meta2d.off('opened', openData);
 });
 
 function openData() {
-  meta2dInfo.value = Object.assign({}, (<any>globalThis).meta2d.store.data);
-  if ((<any>globalThis).meta2d.store.data.bkImage) {
-    bgfile.value = [
+  data.meta2dData = Object.assign({}, meta2d.store.data);
+  if (meta2d.store.data.bkImage) {
+    data.background = [
       {
-        name: (<any>globalThis).meta2d.store.data.bkImage,
-        url: (<any>globalThis).meta2d.store.data.bkImage,
+        name: meta2d.store.data.bkImage,
+        url: meta2d.store.data.bkImage,
       },
     ];
   }
 }
+
+const showInitFnDialog = () => {
+  initFnDialog.data = meta2d.store.data.initJs;
+  initFnDialog.show = true;
+};
+
+const onOkInitFn = () => {
+  meta2d.store.data.initJs = initFnDialog.data;
+  initFnDialog.show = false;
+};
+
+const showDataTransformation = () => {
+  dataTransformationDialog.data = meta2d.store.data.socketCbJs;
+  dataTransformationDialog.show = true;
+};
+
+const onOkDataTransformation = () => {
+  meta2d.store.data.socketCbJs = dataTransformationDialog.data;
+  dataTransformationDialog.show = false;
+};
 </script>
 <style lang="postcss" scoped>
 .props {

+ 447 - 131
src/views/components/Graphics.vue

@@ -6,151 +6,331 @@
       </div>
       <t-input placeholder="搜索" />
     </div>
-    <div class="groups">
-      <div class="sub-groups">
+    <div class="groups-panel">
+      <div class="groups">
         <div
           v-for="group in groups"
-          :class="group.active ? 'active' : ''"
+          :class="group.name === activedGroup ? 'active' : ''"
           @click="groupChange(group.name)"
         >
           <t-icon :name="group.icon" />
           {{ group.name }}
         </div>
       </div>
-      <div class="list">
-        <div
-          class="show-item"
-          v-for="item in showList"
-          :draggable="true"
-          @dragstart="dragStart($event, item)"
-          @drag="drag($event, item)"
-          @dragend="dragEnd()"
-        >
-          <t-image
-            v-if="item.image"
-            :src="item.image"
-            fit="cover"
-            :style="{ width: '88px', height: '88px' }"
-          />
-          <i v-else class="t-icon" :class="item.icon"></i>
-          <p>{{ item.name }}</p>
-        </div>
+      <div class="list" :class="groupType ? 'two-list' : ''">
+        <t-collapse v-model:value="activedPanel" @change="onChangeGroupPanel">
+          <t-collapse-panel
+            :value="item.name"
+            :header="item.name"
+            v-for="item in subGroups"
+            :key="item.name"
+          >
+            <div v-if="item.loading">
+              <t-loading
+                text="加载中..."
+                size="small"
+                style="margin-left: 32px; margin-bottom: 4px"
+              />
+            </div>
+            <template v-else>
+              <div
+                class="graphic"
+                v-for="elem in item.list"
+                :draggable="!groupType || groupType >= 10"
+                @dragstart="dragStart($event, elem)"
+                @drag="drag($event, elem)"
+                @dragend="dragEnd()"
+                @click.stop="dragStart($event, elem)"
+                @dblclick.stop="open(elem)"
+              >
+                <t-image v-if="elem.image" :src="elem.image" />
+                <div class="svg-box" v-else-if="elem.svg" v-html="elem.svg" />
+                <svg v-else class="l-icon" aria-hidden="true">
+                  <use :xlink:href="'#' + elem.icon"></use>
+                </svg>
+                <p :title="elem.name">{{ elem.name }}</p>
+                <div class="price" v-if="elem.price > 0">
+                  ¥{{ elem.price }}
+                </div>
+              </div>
+              <div
+                v-if="!item.list || !item.list.length"
+                class="gray center"
+                style="white-space: nowrap; margin-left: 32px"
+              >
+                暂无数据,待更新
+              </div>
+            </template>
+          </t-collapse-panel>
+        </t-collapse>
       </div>
     </div>
   </div>
 </template>
 
 <script lang="ts" setup>
-import { onMounted, onUnmounted, reactive } from "vue";
+import { onMounted, onUnmounted, reactive, ref, watch } from 'vue';
+import { useRouter } from 'vue-router';
+import axios from 'axios';
+
+import { cases, shapes, charts, formComponents } from '@/services/defaults';
+import { getPngFolders, getPngs } from '@/services/png';
+import { getIconFolders, getIcons } from '@/services/icons';
+import { getComponents, getComponentsList, getLe5leV } from '@/services/api';
+import { convertPen } from '@/services/upgrade';
+import { deepClone } from '@meta2d/core';
+import { isGif } from '@/services/utils';
+import { autoSave, delAttrs } from '@/services/common';
+
+const router = useRouter();
+
+const activedGroup = ref('图形');
 
 const groups = reactive([
   {
-    icon: "desktop",
-    name: "场景",
-    key: "",
-    active: false,
+    icon: 'desktop',
+    name: '场景',
+    key: '',
   },
   {
-    icon: "root-list",
-    name: "模板",
-    key: "",
-    active: false,
+    icon: 'root-list',
+    name: '模板',
+    key: '',
   },
   {
-    icon: "chart",
-    name: "图表",
-    key: "",
-    active: false,
+    icon: 'chart',
+    name: '图表',
+    key: 'chart',
   },
   {
-    icon: "control-platform",
-    name: "控件",
-    key: "",
-    active: false,
+    icon: 'control-platform',
+    name: '控件',
+    key: '',
   },
   {
-    icon: "file-icon",
-    name: "图标",
-    key: "",
-    active: false,
+    icon: 'image',
+    name: '素材',
+    key: '',
   },
   {
-    icon: "chart-bubble",
-    name: "图形",
-    key: "",
-    active: true,
+    icon: 'file-icon',
+    name: '图标',
+    key: '',
   },
   {
-    icon: "app",
-    name: "我的",
-    key: "",
-    active: false,
+    icon: 'chart-bubble',
+    name: '图形',
+    key: 'shape',
+  },
+  {
+    icon: 'app',
+    name: '我的',
+    key: '',
   },
 ]);
+const subGroups = ref<any[]>([]);
+const groupType = ref(0);
+const activedPanel = ref([]);
+
+const templates = reactive<any>({});
+const materials = reactive([]);
+const icons = reactive([]);
 
-const groupChange = (name: string) => {
-  if (name === "图形") {
+const groupChange = async (name: string) => {
+  activedPanel.value = [];
+  activedGroup.value = name;
+  groupType.value = 0;
+  switch (name) {
+    case '场景':
+      groupType.value = 1;
+      subGroups.value = cases;
+      subGroups.value[0].loading = true;
+      if (!templates[name + cases[0].name]) {
+        templates[name + cases[0].name] = await getCaseProjects(
+          name,
+          cases[0].name
+        );
+      }
+      subGroups.value[0].list = templates[name + cases[0].name];
+      subGroups.value[0].loading = false;
+      break;
+    case '模板':
+      groupType.value = 2;
+      subGroups.value = cases;
+      subGroups.value[0].loading = true;
+      if (!templates[name + cases[0].name]) {
+        templates[name + cases[0].name] = await getCaseProjects(
+          name,
+          cases[0].name
+        );
+      }
+      subGroups.value[0].list = templates[name + cases[0].name];
+      subGroups.value[0].loading = false;
+      break;
+    case '图表':
+      subGroups.value = charts;
+      break;
+    case '控件':
+      subGroups.value = formComponents;
+      break;
+    case '素材':
+      if (!materials.length) {
+        materials.push(...(await getPngFolders()));
+      }
+      subGroups.value = materials;
+      break;
+    case '图标':
+      if (!icons.length) {
+        icons.push(...(await getIconFolders()));
+      }
+      subGroups.value = icons;
+      break;
+    case '图形':
+      subGroups.value = shapes;
+      break;
+    case '我的':
+      subGroups.value = await getPrivateCommponents();
+      groupType.value = 10;
+      break;
   }
+  activedPanel.value = [subGroups.value[0].name];
 };
 
-const showList = [
-  {
-    name: "square",
-    icon: "t-icon t-rect",
-    id: "",
-    data: {
-      text: "正方形",
-      width: 100,
-      height: 100,
-      name: "square",
-    },
-  },
-  {
-    name: "rectangle",
-    icon: "t-icon t-rectangle",
-    id: 2,
-    data: {
-      text: "圆角矩形",
-      width: 200,
-      height: 50,
-      borderRadius: 0.1,
-      name: "rectangle",
-    },
-  },
-  {
-    name: "circle",
-    icon: "t-icon t-circle",
-    image: "",
-    id: 3,
-    data: {
-      text: "圆",
-      width: 100,
-      height: 100,
-      name: "circle",
+const getCaseProjects = async (name: string, group: string) => {
+  const ret: any = await axios.post(
+    '/api/data/le5leV/list?current=1&pageSize=1000',
+    {
+      query: { tags: name, case: group },
+      shared: 'true',
+      projection: { _id: 1, name: 1, image: 1, price: 1 },
+    }
+  );
+
+  if (!ret) {
+    return [];
+  }
+  return ret.list;
+};
+
+const getPrivateCommponents = async () => {
+  const data = {
+    projection: {
+      image: 1,
+      _id: 1,
+      name: 1,
+      folder: 1,
+      component: 1,
     },
-  },
-  {
-    name: "triangle",
-    icon: "t-icon t-triangle",
-    id: 4,
-    data: {
-      text: "三角形",
-      width: 100,
-      height: 100,
-      name: "triangle",
+  };
+  const config = {
+    params: {
+      current: 1,
+      pageSize: 100,
     },
-  },
-];
+  };
+  const res: any = await getComponentsList(data, config);
+  const folderMap: any = {};
+  res.list?.map((item: any) => {
+    if (!folderMap[item.folder]) {
+      folderMap[item.folder] = [];
+    }
+
+    folderMap[item.folder].push(item);
+  });
+  let list = [];
+  for (let key in folderMap) {
+    list.push({
+      name: key === 'undefined' ? '未分类' : key,
+      show: true,
+      list: folderMap[key],
+    });
+  }
+
+  return list;
+};
 
-const dragStart = (event: DragEvent, item: any) => {
-  if (!item || !event.dataTransfer) {
+watch(
+  () => activedPanel.value,
+  async (newV: any[], oldV: any[]) => {
+    const newOpen: any = [];
+    for (let v of newV) {
+      !oldV.includes(v) && newOpen.push(v);
+    }
+    if (newOpen.length === 0) {
+      return;
+    }
+    if (activedGroup.value === '素材') {
+      const data: any = materials.find((item) => item.name === newOpen[0]);
+      if (!data.list || data.list.length === 0) {
+        data.list = await getPngs(
+          globalThis.folderJson ? data.pinyin : data.name
+        );
+        subGroups.value = deepClone(materials);
+      }
+    } else if (activedGroup.value === '图标') {
+      const data: any = icons.find((item) => item.name === newOpen[0]);
+      if (!data.list || data.list.length === 0) {
+        data.list = await getIcons(
+          globalThis.folderJson ? data.pinyin : data.name
+        );
+        subGroups.value = deepClone(icons);
+      }
+    }
+  }
+);
+
+const dragStart = async (event: DragEvent | MouseEvent, item: any) => {
+  if (
+    (groupType.value > 0 && groupType.value < 10) ||
+    !item ||
+    (event instanceof DragEvent && !event.dataTransfer)
+  ) {
     return;
   }
+  let data = null;
+  if (item._id && !item.componentDatas) {
+    let res: any = await getComponents(item._id);
+    item.component = true;
+    item.componentDatas = res.componentDatas;
+    item.componentData = res.componentData;
+  }
+  if (!item.data && !item.component && item.image) {
+    let target: any = event.target;
+    target.children[0] && (target = target.children[0].children[0]);
+    // firefox naturalWidth svg 图片 可能是 0
+    const normalWidth = target.naturalWidth || target.width;
+    const normalHeight = target.naturalHeight || target.height;
+    const [name, lockedOnCombine] = isGif(item.image)
+      ? ['gif', 0]
+      : ['image', undefined];
 
-  event.dataTransfer.setData(
-    "Meta2d",
-    JSON.stringify(item.componentData || item.data)
-  );
+    data = {
+      name,
+      width: 100,
+      height: 100 * (normalHeight / normalWidth),
+      image: item.image,
+      imageRatio: true,
+      lockedOnCombine,
+    };
+  } else if (item.component) {
+    if (item.componentData) {
+      const pens = convertPen([item.componentData]);
+      data = deepClone(pens);
+    } else if (item.componentDatas) {
+      data = deepClone(item.componentDatas);
+    }
+  } else {
+    data = item.componentDatas || item.data;
+  }
+  if (event instanceof DragEvent) {
+    meta2d.canvas.addCaches = [];
+    event.dataTransfer?.setData('Meta2d', JSON.stringify(data));
+  } else {
+    if (!Array.isArray(data)) {
+      data = deepClone([data]);
+    }
+    meta2d.canvas.addCaches = data;
+  }
   event.stopPropagation();
 };
 
@@ -166,36 +346,79 @@ const dragend = (event: any) => {
   event.target.style.opacity = 1;
 };
 
+const open = async (item: any) => {
+  autoSave();
+  router.push({
+    path: '/',
+    query: {
+      r: Date.now() + '',
+    },
+  });
+
+  const ret: any = await getLe5leV(item._id || item.id);
+  for (const k of delAttrs) {
+    delete (ret as any)[k];
+  }
+  meta2d.open(ret);
+};
+
+const onChangeGroupPanel = async (val: string[]) => {
+  if (groupType.value > 0 && groupType.value < 10 && val?.length) {
+    for (const name of val) {
+      if (
+        !templates[activedGroup.value + name] ||
+        !templates[activedGroup.value + name].length
+      ) {
+        for (const item of subGroups.value) {
+          if (item.name === name) {
+            item.loading = true;
+          }
+        }
+        templates[activedGroup.value + name] = await getCaseProjects(
+          activedGroup.value,
+          name
+        );
+        for (const item of subGroups.value) {
+          if (item.name === name) {
+            item.list = templates[activedGroup.value + name];
+            item.loading = false;
+          }
+        }
+      }
+    }
+  }
+};
+
 onMounted(() => {
-  document.removeEventListener("dragstart", dragstart);
-  document.removeEventListener("dragend", dragend);
+  groupChange('场景');
+  document.addEventListener('dragstart', dragstart, false);
+  document.addEventListener('dragend', dragend, false);
 });
 
 onUnmounted(() => {
-  document.addEventListener("dragstart", dragstart, false);
-  document.addEventListener("dragend", dragend, false);
+  document.removeEventListener('dragstart', dragstart);
+  document.removeEventListener('dragend', dragend);
 });
 </script>
 <style lang="postcss" scoped>
 .graphics {
   display: flex;
   flex-direction: column;
-  border-right: 1px solid var(--color-border);
 
   .input-search {
     flex-shrink: 0;
     height: 40px;
   }
 
-  .groups {
+  .groups-panel {
     display: grid;
     grid-template-columns: 50px 1fr;
-    border-top: 1px solid var(--color-border);
+    border-top: 1px solid var(--color-background-input);
     flex-grow: 1;
     overflow-y: auto;
     font-size: 12px;
 
-    .sub-groups {
+    .groups {
       & > div {
         display: flex;
         flex-direction: column;
@@ -206,7 +429,7 @@ onUnmounted(() => {
 
         .t-icon {
           font-size: 20px;
-          margin-bottom: 5px;
+          margin-bottom: 8px;
         }
 
         &:hover {
@@ -222,31 +445,124 @@ onUnmounted(() => {
     }
 
     .list {
+      overflow-y: auto;
+      max-height: calc(100vh - 100px);
       background-color: var(--color-background-active);
-      padding: 12px;
-      display: grid;
-      grid-template-columns: 112px 112px;
-      grid-template-rows: 142px;
+      padding-top: 8px;
+
+      * {
+        background-color: var(--color-background-active);
+      }
+
+      :deep(.t-collapse) {
+        border: none;
+      }
+      :deep(.t-collapse-panel__header) {
+        border: none;
+        font-size: 12px;
+        font-weight: 400;
+        padding: 8px 16px;
+        &:hover {
+          color: var(--color-primary);
+        }
+      }
+
+      :deep(.t-collapse-panel__body) {
+        border: none;
+      }
+      :deep(.t-collapse-panel__content) {
+        background-color: var(--color-background-active);
+        padding: 4px 4px 20px 4px;
+        display: grid;
+        grid-template-columns: 1fr 1fr 1fr;
+        grid-row-gap: 20px;
+      }
+      .graphic {
+        position: relative;
 
-      .show-item {
-        padding: 12px;
+        &:hover {
+          cursor: pointer;
+          color: var(--color-primary-hover);
+          svg {
+            color: var(--color-primary-hover);
+          }
+        }
         p {
-          margin-top: 10px;
-          height: 20px;
-          line-height: 20px;
           text-align: center;
-          font-size: 14px;
+          font-size: 12px;
+          height: 12px;
+          line-height: 1;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          display: -webkit-box;
+          -webkit-line-clamp: 1;
+          word-break: break-all;
+          -webkit-box-orient: vertical;
+        }
+        .t-image__wrapper {
+          height: 32px;
+          width: 32px;
+          background: #fff0;
+          margin-left: calc(50% - 16px);
+          margin-top: 10px;
+          margin-bottom: 10px;
+          :deep(.t-image) {
+            border-radius: 2px;
+          }
         }
 
-        i {
-          background-color: #fff;
-          border-radius: 4px;
-          height: 88px;
-          width: 88px;
+        svg {
+          color: var(--color);
+          height: 32px;
+          width: 100%;
+          margin: 4px 0px;
+        }
+
+        .svg-box {
+          height: 32px;
+          width: 32px;
+          margin-left: calc(50% - 16px);
+          margin-top: 10px;
+          margin-bottom: 10px;
+          &:deep(svg) {
+            height: 100%;
+            width: 100%;
+            .cls-1 {
+              stroke: var(--color) !important;
+            }
+          }
+        }
+
+        .price {
+          position: absolute;
+          top: 8px;
+          right: 8px;
+          display: inline-block;
+          z-index: 1;
+          border-radius: 2px;
+          background-color: #ff400060;
+          color: var(--color-bland);
+          font-size: 10px;
+          line-height: 1;
+          padding: 3px;
+        }
+      }
+    }
+
+    .two-list {
+      :deep(.t-collapse-panel__content) {
+        padding: 0 8px;
+        grid-template-columns: 116px 116px;
+      }
+      .graphic {
+        p {
+          margin-top: 10px;
+          margin-bottom: 12px;
         }
-        .t-image {
-          background-color: #fff;
-          border-radius: 4px;
+        .t-image__wrapper {
+          width: 88px;
+          height: 88px;
+          margin-left: 14px;
         }
       }
     }

+ 836 - 92
src/views/components/Header.vue

@@ -1,6 +1,6 @@
 <template>
   <div class="app-header">
-    <a class="logo" href="https://le5le.com" target="_blank">
+    <a class="logo" :href="assets.home" target="_blank">
       <img src="/favicon.ico" />
       <span>乐吾乐</span>
     </a>
@@ -9,30 +9,29 @@
       :maxHeight="560"
       :delay2="[10, 150]"
       overlayClassName="header-dropdown"
-      trigger="click"
     >
       <a> 文件 </a>
       <t-dropdown-menu>
-        <t-dropdown-item>
+        <t-dropdown-item @click="newFile">
           <a>新建文件</a>
         </t-dropdown-item>
-        <t-dropdown-item>
+        <t-dropdown-item @click="openFile">
           <a>打开文件</a>
         </t-dropdown-item>
-        <t-dropdown-item divider="true">
+        <t-dropdown-item divider="true" @click="loadFile">
           <a>导入文件</a>
         </t-dropdown-item>
         <t-dropdown-item>
-          <a>保存</a>
+          <a @click="save()">保存</a>
         </t-dropdown-item>
         <t-dropdown-item>
-          <a>另保存</a>
+          <a @click="save(SaveType.SaveAs)">另保存</a>
         </t-dropdown-item>
         <t-dropdown-item divider="true">
-          <a>下载JSON文件</a>
+          <a @click="downloadJson">下载JSON文件</a>
         </t-dropdown-item>
         <t-dropdown-item>
-          <a>
+          <a @click="downloadZip">
             <div class="flex">
               导出为ZIP文件 <span class="flex-grow"></span>
               <span><label>VIP</label></span>
@@ -40,7 +39,7 @@
           </a>
         </t-dropdown-item>
         <t-dropdown-item>
-          <a>
+          <a @click="downloadHtml">
             <div class="flex">
               导出为HTML <span class="flex-grow"></span>
               <span><label>VIP</label></span>
@@ -48,7 +47,7 @@
           </a>
         </t-dropdown-item>
         <t-dropdown-item>
-          <a>
+          <a @click="downloadVue3">
             <div class="flex">
               导出为Vue3组件 <span class="flex-grow"></span>
               <span><label>VIP</label></span>
@@ -56,7 +55,7 @@
           </a>
         </t-dropdown-item>
         <t-dropdown-item>
-          <a>
+          <a @click="downloadVue2">
             <div class="flex">
               导出为Vue2组件 <span class="flex-grow"></span>
               <span><label>VIP</label></span>
@@ -64,7 +63,7 @@
           </a>
         </t-dropdown-item>
         <t-dropdown-item divider="true">
-          <a>
+          <a @click="downloadReact">
             <div class="flex">
               导出为React组件 <span class="flex-grow"></span>
               <span><label>VIP</label></span>
@@ -72,10 +71,10 @@
           </a>
         </t-dropdown-item>
         <t-dropdown-item>
-          <a>下载为PNG</a>
+          <a @click="downloadPng">下载为PNG</a>
         </t-dropdown-item>
         <t-dropdown-item>
-          <a>下载为SVG</a>
+          <a @click="downloadSvg">下载为SVG</a>
         </t-dropdown-item>
       </t-dropdown-menu>
     </t-dropdown>
@@ -88,62 +87,50 @@
       <a> 编辑 </a>
       <t-dropdown-menu>
         <t-dropdown-item>
-          <a>
+          <a @click="onUndo">
             <div class="flex">
               撤销 <span class="flex-grow"></span> Ctrl + Z
             </div>
           </a>
         </t-dropdown-item>
         <t-dropdown-item divider="true">
-          <a>
+          <a @click="onRedo">
             <div class="flex">
               恢复 <span class="flex-grow"></span> Ctrl + Y
             </div>
           </a>
         </t-dropdown-item>
         <t-dropdown-item>
-          <a>
+          <a @click="onCut">
             <div class="flex">
               剪切 <span class="flex-grow"></span> Ctrl + X
             </div>
           </a>
         </t-dropdown-item>
         <t-dropdown-item>
-          <a>
+          <a @click="onCopy">
             <div class="flex">
               复制 <span class="flex-grow"></span> Ctrl + C
             </div>
           </a>
         </t-dropdown-item>
         <t-dropdown-item divider="true">
-          <a>
+          <a @click="onPaste">
             <div class="flex">
               粘贴 <span class="flex-grow"></span> Ctrl + V
             </div>
           </a>
         </t-dropdown-item>
         <t-dropdown-item>
-          <a>
+          <a @click="onAll">
             <div class="flex">
-              添加/删除锚点 <span class="flex-grow"></span> A
+              全选 <span class="flex-grow"></span> Ctrl + A
             </div>
           </a>
         </t-dropdown-item>
         <t-dropdown-item>
-          <a>
-            <div class="flex">添加手柄 <span class="flex-grow"></span> H</div>
-          </a>
-        </t-dropdown-item>
-        <t-dropdown-item>
-          <a>
-            <div class="flex">删除手柄 <span class="flex-grow"></span> D</div>
-          </a>
-        </t-dropdown-item>
-        <t-dropdown-item>
-          <a>
-            <div class="flex">
-              切换手柄 <span class="flex-grow"></span> Shift
-            </div>
+          <a @click="onDelete">
+            <div class="flex">删除 <span class="flex-grow"></span> DELETE</div>
           </a>
         </t-dropdown-item>
       </t-dropdown-menu>
@@ -157,42 +144,66 @@
       <a> 工具 </a>
       <t-dropdown-menu>
         <t-dropdown-item>
-          <a>窗口大小</a>
+          <a @click="onScaleWindow">窗口大小</a>
         </t-dropdown-item>
         <t-dropdown-item>
-          <a>放大</a>
+          <a @click="onScaleUp">放大</a>
         </t-dropdown-item>
         <t-dropdown-item>
-          <a>缩小</a>
+          <a @click="onScaleDown">缩小</a>
         </t-dropdown-item>
         <t-dropdown-item divider="true">
-          <a>100%视图</a>
+          <a @click="onScaleView">100%视图</a>
         </t-dropdown-item>
         <t-dropdown-item>
-          <a>鹰眼地图</a>
+          <a @click="showMap">
+            <div class="flex middle">
+              鹰眼地图 <span class="flex-grow"></span>
+              <t-icon v-show="map" name="check" />
+            </div>
+          </a>
         </t-dropdown-item>
         <t-dropdown-item divider="true">
-          <a>放大镜</a>
+          <a @click="showMagnifier">
+            <div class="flex middle">
+              放大镜 <span class="flex-grow"></span>
+              <t-icon v-show="magnifier" name="check" />
+            </div>
+          </a>
         </t-dropdown-item>
         <t-dropdown-item>
-          <a>
+          <a @click="onAutoAnchor">
             <div class="flex middle">
-              自动锚点 <span class="flex-grow"></span> <t-icon name="check" />
+              自动锚点 <span class="flex-grow"></span>
+              <t-icon v-show="autoAnchor" name="check" />
             </div>
           </a>
         </t-dropdown-item>
         <t-dropdown-item divider="true">
-          <a>
+          <a @click="onDisableAnchor">
             <div class="flex middle">
-              禁用锚点 <span class="flex-grow"></span> <t-icon name="check" />
+              显示锚点 <span class="flex-grow"></span>
+              <t-icon v-show="showAnchor" name="check" />
+            </div>
+          </a>
+        </t-dropdown-item>
+        <t-dropdown-item divider="true">
+          <a @click="onToggleAnchor">
+            <div
+              class="flex"
+              :style="{
+                color: showAnchor ? '' : '#4f5b75',
+              }"
+            >
+              添加/删除锚点 <span class="flex-grow"></span> A
             </div>
           </a>
         </t-dropdown-item>
         <t-dropdown-item>
-          <a>明亮主题</a>
+          <a @click="switchTheme('light')">明亮主题</a>
         </t-dropdown-item>
         <t-dropdown-item>
-          <a>暗黑主题</a>
+          <a @click="switchTheme('dark')">暗黑主题</a>
         </t-dropdown-item>
       </t-dropdown-menu>
     </t-dropdown>
@@ -204,27 +215,12 @@
     >
       <a> 帮助 </a>
       <t-dropdown-menu>
-        <t-dropdown-item>
-          <a>产品介绍</a>
-        </t-dropdown-item>
-        <t-dropdown-item>
-          <a>快速上手</a>
-        </t-dropdown-item>
-        <t-dropdown-item>
-          <a>使用手册</a>
-        </t-dropdown-item>
-        <t-dropdown-item divider="true">
-          <a>快捷键</a>
-        </t-dropdown-item>
-        <t-dropdown-item divider="true">
-          <a>企业服务与支持</a>
-        </t-dropdown-item>
-        <t-dropdown-item>
-          <a>关于我们</a>
+        <t-dropdown-item v-for="item in assets.helps" :divider="item.divider">
+          <a :href="item.url" target="_blank">{{ item.name }}</a>
         </t-dropdown-item>
       </t-dropdown-menu>
     </t-dropdown>
-    <input v-model="data.name" />
+    <input v-model="data.name" @input="onInputName" />
 
     <div style="width: 290px; flex-shrink: 0"></div>
     <t-dropdown
@@ -234,29 +230,36 @@
       :delay2="[10, 150]"
       overlayClassName="custom-dropdown header"
     >
-      <a style="margin-left: 32px; margin-right: 0">
+      <a style="margin-left: 32px; margin-right: 12px">
         <t-avatar
           size="small"
           :image="user.avatarUrl ? user.avatarUrl : baseUrl + 'img/avatar.png'"
         />
       </a>
       <t-dropdown-menu>
-        <t-dropdown-item>
-          <router-link to="/account/info">
+        <t-dropdown-item divider="true">
+          <a :href="assets.account">
             {{ user.username }}
-          </router-link>
+            <label class="ml-16 vip-label">VIP</label>
+          </a>
         </t-dropdown-item>
-        <t-dropdown-item>
-          <router-link to="/account/info">我的图纸</router-link>
+        <t-dropdown-item divider="true">
+          <a :href="`${assets.account}/v`" target="_blank"> 我的大屏 </a>
         </t-dropdown-item>
         <t-dropdown-item>
-          <router-link to="/account/teams">我的团队</router-link>
+          <a :href="`${assets.account}/account/teams`" target="_blank">
+            我的团队
+          </a>
         </t-dropdown-item>
         <t-dropdown-item>
-          <router-link to="/account/info">账号信息</router-link>
+          <a :href="`${assets.account}/account/info`" target="_blank">
+            账号信息
+          </a>
         </t-dropdown-item>
-        <t-dropdown-item :divider="true">
-          <router-link to="/account/security"> 安全设置 </router-link>
+        <t-dropdown-item divider="true">
+          <a :href="`${assets.account}/account/security`" target="_blank">
+            安全设置
+          </a>
         </t-dropdown-item>
         <t-dropdown-item>
           <a @click="signout">退出</a>
@@ -264,39 +267,780 @@
       </t-dropdown-menu>
     </t-dropdown>
     <div class="flex middle" v-else>
-      <a class="button primary solid" style="width: 80px" :href="login()">登录</a>
+      <a class="button primary solid" style="width: 80px" :href="login()">
+        登录
+      </a>
     </div>
   </div>
 </template>
 
 <script lang="ts" setup>
-import { reactive } from 'vue';
+import { reactive, ref, onBeforeMount, onUnmounted, nextTick } from 'vue';
+import { useRouter, useRoute } from 'vue-router';
 import { useUser } from '@/services/user';
+import { MessagePlugin } from 'tdesign-vue-next';
+import {
+  Meta2dBackData,
+  dealwithFormatbeforeOpen,
+  gotoAccount,
+  checkData,
+} from '@/services/utils';
+import { readFile } from '@/services/file';
+import { compareVersion, baseVer, upgrade } from '@/services/upgrade';
+import { parseSvg } from '@meta2d/svg';
+import { Pen, getGlobalColor, isShowChild } from '@meta2d/core';
+import { cdn, upCdn } from '@/services/api';
+import JSZip from 'jszip';
+import axios from 'axios';
+import { switchTheme } from '@/services/theme';
+import { noLoginTip } from '@/services/utils';
+import { useDot, autoSave, delAttrs } from '@/services/common';
+import {
+  save,
+  newFile,
+  SaveType,
+  onScaleView,
+  onScaleWindow,
+  showMagnifier,
+  showMap,
+  newfile,
+  drawPen,
+  map,
+  magnifier,
+} from '@/services/common';
 
-const market = import.meta.env.VITE_MARKET;
+const router = useRouter();
+const route = useRoute();
 
 const baseUrl = import.meta.env.BASE_URL || '/';
 
-const { user, message, getUser, getMessage, signout } = useUser();
+const assets = reactive({
+  home: 'https://le5le.com',
+  account: 'https://account.le5le.com',
+  helps: [
+    {
+      name: '产品介绍',
+      url: 'https://doc.le5le.com/document/118756411',
+    },
+    {
+      name: '快速上手',
+      url: 'https://doc.le5le.com/document/119363000',
+    },
+    {
+      name: '使用手册',
+      url: 'https://doc.le5le.com/document/118764244',
+    },
+    {
+      name: '快捷键',
+      url: 'https://doc.le5le.com/document/119620214',
+      divider: true,
+    },
+    {
+      name: '企业服务与支持',
+      url: 'https://doc.le5le.com/document/119296274',
+      divider: true,
+    },
+    {
+      name: '关于我们',
+      url: 'https://le5le.com/about.html',
+    },
+  ],
+});
 
+const { user, signout } = useUser();
+const { setDot } = useDot();
 const data = reactive({
   name: '空白文件',
 });
 
+onBeforeMount(async () => {
+  // 官网或安装包版本
+  if (
+    import.meta.env.VITE_TRIAL == undefined ||
+    import.meta.env.VITE_TRIAL == 1
+  ) {
+    return;
+  }
+
+  // 企业版
+  const ret = await axios.get('/api/assets');
+  if (ret) {
+    Object.assign(assets, ret);
+  }
+});
+
+const onInputName = () => {
+  (meta2d.store.data as Meta2dBackData).name = data.name;
+  setDot(true);
+};
+
+const initMeta2dName = () => {
+  data.name = (meta2d.store.data as Meta2dBackData).name || '';
+};
+
+nextTick(() => {
+  meta2d.on('opened', initMeta2dName);
+});
+
+onUnmounted(() => {
+  meta2d.off('opened', initMeta2dName);
+});
+
 function login() {
-  //TODO 临时地址
-  return `https://account.le5le.com/?cb=${encodeURIComponent(location.href)}`
-  // if (market) {
-  //       return `/account/login?cb=${encodeURIComponent(location.href)}`;
-  //     } else {
-  //       let arr = location.host.split('.');
-  //       arr[0] = 'http://account';
-  //       let accountUrl = arr.join('.');
-  //       return `${
-  //         (<any>globalThis).loginUrl ? (<any>globalThis).loginUrl : accountUrl
-  //       }?cb=${encodeURIComponent(location.href)}`;
-  //     }
+  return `${assets.account}?cb=${encodeURIComponent(location.href)}`;
+}
+
+function load(newT: boolean = false) {
+  const input = document.createElement('input');
+  input.type = 'file';
+  input.onchange = (event) => {
+    const elem = event.target as HTMLInputElement;
+    if (elem.files && elem.files[0]) {
+      newT && newfile(true);
+      // 路由跳转 可能在 openFile 后执行
+      if (elem.files[0].name.endsWith('.json')) {
+        openJson(elem.files[0]);
+      } else if (elem.files[0].name.endsWith('.svg')) {
+        MessagePlugin.info(
+          '可二次编辑但转换存在损失,若作为图片使用,请使用右侧属性面板的上传图片功能'
+        );
+        openSvg(elem.files[0]);
+      } else if (elem.files[0].name.endsWith('.zip')) {
+        openZip(elem.files[0]);
+      } else {
+        MessagePlugin.info('打开文件只支持 json,svg,zip 格式');
+      }
+    }
+  };
+  input.click();
+}
+
+const openJson = async (file: File) => {
+  const text = await readFile(file);
+  try {
+    let data: Meta2dBackData = JSON.parse(text);
+    if (!data.name) {
+      data.name = file.name.replace('.json', '');
+    }
+    if (!data.version || compareVersion(data.version, baseVer) === -1) {
+      // 如果版本号不存在或者版本号 version < 1.0.0
+      data = upgrade(data, baseVer);
+    }
+    dealwithFormatbeforeOpen(data);
+    for (const k of delAttrs) {
+      delete (data as any)[k];
+    }
+    meta2d.open(data);
+  } catch (e) {
+    console.log(e);
+  }
+};
+
+const openSvg = async (file: File) => {
+  const text = await readFile(file);
+  const pens: Pen[] = parseSvg(text);
+  meta2d.canvas.addCaches = pens;
+  MessagePlugin.info('svg转换成功,请点击画布决定放置位置');
+};
+
+const openZip = async (file: File) => {
+  if (!(user && user.id)) {
+    MessagePlugin.warning(noLoginTip);
+    return;
+  }
+
+  if (!user.isVip) {
+    gotoAccount();
+    return;
+  }
+
+  const { default: JSZip } = await import('jszip');
+  const zip = new JSZip();
+  await zip.loadAsync(file);
+
+  let dataStr = '';
+  for (const key in zip.files) {
+    if (zip.files[key].dir) {
+      continue;
+    }
+    if (key.endsWith('.json')) {
+      // 认为只有一个 json 文件
+      // dataStr = await zip.file(key).async('string');
+      break;
+    }
+  }
+
+  if (!dataStr) {
+    return false;
+  }
+
+  for (const key in zip.files) {
+    if (zip.files[key].dir) {
+      continue;
+    }
+    // let _png = key.indexOf('/png');
+    // let _img = key.indexOf('/img');
+    // let _image = key.indexOf('/image');
+    // let _file = key.indexOf('/file');
+    let _keyLower = key.toLowerCase();
+    // if (!key.endsWith('.json') && (_png !== -1 || _img !== -1 || _image !== -1 || _file !== -1)) {
+    if (
+      _keyLower.endsWith('.png') ||
+      _keyLower.endsWith('.svg') ||
+      _keyLower.endsWith('.gif') ||
+      _keyLower.endsWith('.jpg') ||
+      _keyLower.endsWith('.jpeg')
+    ) {
+      let filename = key.substr(key.lastIndexOf('/') + 1);
+      const extPos = filename.lastIndexOf('.');
+      let ext = '';
+      if (extPos > 0) {
+        ext = filename.substr(extPos);
+      }
+      filename = filename.substring(0, extPos > 8 ? 8 : extPos);
+
+      // 上传文件
+      const result: any = {};
+      //   await upload(
+      //   // await zip.file(key).async('blob'),
+      //   true,
+      //   filename + ext,
+      //   "/2D/未分类"
+      // );
+      let _key = key;
+      // if (_png) {
+      //   _key = key.substring(_png);
+      // } else if (_image) {
+      //   _key = key.substring(_png);
+      // } else if (_img) {
+      //   _key = key.substring(_img);
+      // } else if (_file) {
+      //   _key = key.substring(_file);
+      // }
+      if (result) {
+        if (dataStr.replaceAll) {
+          //'le5le.meta2d'
+          dataStr = dataStr.replaceAll(_key.slice(12), result.url);
+        } else {
+          while (dataStr.includes(_key)) {
+            dataStr = dataStr.replace(_key.slice(12), result.url);
+            // 正则 gm 在特殊情况下报错,例如如下场景
+            /**
+                 *    
+      const data = '{"image":"/image/materials/IoT-Chemical(化学)/Air stripper 2(汽提塔2).svg"}';
+      const key = '/image/materials/IoT-Chemical(化学)/Air stripper 2(汽提塔2).svg';
+      data.replace(key, '123');
+      data.replaceAll(key, '123')
+      data.replace(new RegExp(key, 'gm'), '123');
+      data.replace(new RegExp(key, 'g'), '123');
+                 */
+          }
+        }
+      }
+    }
+  }
+
+  try {
+    let data: Meta2dBackData = JSON.parse(dataStr);
+    if (data) {
+      if (!data.name) {
+        data.name = file.name.replace('.zip', '');
+      }
+      if (!data.version || compareVersion(data.version, baseVer) === -1) {
+        // 如果版本号不存在或者版本号 version < 1.0.0
+        data = upgrade(data, baseVer);
+      }
+      dealwithFormatbeforeOpen(data);
+      const delAttrs = [
+        'userId',
+        'shared',
+        'team',
+        'owner',
+        'username',
+        'editor',
+        'editorId',
+        'editorName',
+        'createdAt',
+        'folder',
+        'image',
+        'id',
+        '_id',
+        'view',
+        'updatedAt',
+        'star',
+        'recommend',
+      ];
+      for (const k of delAttrs) {
+        delete (data as any)[k];
+      }
+      meta2d.open(data);
+    }
+  } catch (e) {
+    return false;
+  }
+};
+
+async function loadFile(newT: boolean = false) {
+  autoSave();
+  setTimeout(() => {
+    load(newT);
+  }, 1000);
 }
+
+async function openFile() {
+  loadFile(true);
+}
+
+const downloadJson = () => {
+  const data: Meta2dBackData = meta2d.data();
+  if (data._id) delete data._id;
+  checkData(data);
+  import('file-saver').then(({ saveAs }) => {
+    saveAs(
+      new Blob(
+        [JSON.stringify(data).replaceAll(cdn, '').replaceAll(upCdn, '')],
+        {
+          type: 'text/plain;charset=utf-8',
+        }
+      ),
+      `${data.name || 'le5le.meta2d'}.json`
+    );
+  });
+};
+
+const downloadZip = async () => {
+  if (!(user && user.id)) {
+    MessagePlugin.warning(noLoginTip);
+    return;
+  }
+
+  if (!user.isVip) {
+    gotoAccount();
+    return;
+  }
+
+  MessagePlugin.info('正在下载打包中,可能需要几分钟,请耐心等待...');
+  const [{ default: JSZip }, { saveAs }] = await Promise.all([
+    import('jszip'),
+    import('file-saver'),
+  ]);
+
+  const zip: any = new JSZip();
+  const data: Meta2dBackData = meta2d.data();
+  let _fileName =
+    (data.name && data.name.replace(/\//g, '_').replace(/:/g, '_')) ||
+    'le5le.meta2d';
+  const _zip = zip.folder(`${_fileName}`);
+  if (data._id) delete data._id;
+  checkData(data);
+  _zip.file(
+    `${_fileName}.json`,
+    JSON.stringify(data).replaceAll(cdn, '').replaceAll(upCdn, '')
+  );
+  await zipImages(_zip, meta2d.store.data.pens);
+
+  const blob = await zip.generateAsync({ type: 'blob' });
+  saveAs(blob, `${_fileName}.zip`);
+};
+
+const downloadHtml = async () => {
+  if (!(user && user.id)) {
+    MessagePlugin.warning(noLoginTip);
+    return;
+  }
+
+  if (!user.isVip) {
+    gotoAccount();
+    return;
+  }
+
+  MessagePlugin.info('正在下载打包中,可能需要几分钟,请耐心等待...');
+
+  const data: Meta2dBackData = meta2d.data();
+  if (data._id) delete data._id;
+  checkData(data);
+  const [{ default: JSZip }, { saveAs }] = await Promise.all([
+    import('jszip'),
+    import('file-saver'),
+  ]);
+  const zip = new JSZip();
+  let _fileName =
+    (data.name && data.name.replace(/\//g, '_').replace(/:/g, '_')) ||
+    'le5le.meta2d';
+
+  //处理cdn图片地址
+  const _zip: any = zip.folder(`${_fileName}`);
+  _zip.file(
+    'data.json',
+    JSON.stringify(data).replaceAll(cdn, '').replaceAll(upCdn, '')
+  );
+  await Promise.all([zipImages(_zip, meta2d.store.data.pens), zipFiles(_zip)]);
+  const blob = await zip.generateAsync({ type: 'blob' });
+  saveAs(blob, `${_fileName}.zip`);
+};
+
+enum Frame {
+  vue2,
+  vue3,
+  react,
+}
+
+const downloadVue3 = async () => {
+  downloadAsFrame(Frame.vue3);
+};
+
+const downloadVue2 = async () => {
+  downloadAsFrame(Frame.vue2);
+};
+
+const downloadReact = async () => {
+  downloadAsFrame(Frame.react);
+};
+
+async function downloadAsFrame(type: Frame) {
+  if (!(user && user.id)) {
+    MessagePlugin.warning(noLoginTip);
+    return;
+  }
+
+  if (!user.isVip) {
+    gotoAccount();
+    return;
+  }
+
+  MessagePlugin.info('正在下载打包中,可能需要几分钟,请耐心等待...');
+
+  const data: Meta2dBackData = meta2d.data();
+  if (data._id) delete data._id;
+  checkData(data);
+  const [{ default: JSZip }, { saveAs }] = await Promise.all([
+    import('jszip'),
+    import('file-saver'),
+  ]);
+  const zip = new JSZip();
+  let _fileName =
+    (data.name && data.name.replace(/\//g, '_').replace(/:/g, '_')) ||
+    'le5le.meta2d';
+  const _zip: any = zip.folder(`${_fileName}`);
+  _zip.file(
+    'data.json',
+    JSON.stringify(data).replaceAll(cdn, '').replaceAll(upCdn, '')
+  );
+  await Promise.all([
+    zipImages(_zip, meta2d.store.data.pens),
+    type === Frame.vue3
+      ? zipVue3Files(_zip)
+      : type === Frame.vue2
+      ? zipVue2Files(_zip)
+      : zipReactFiles(_zip),
+  ]);
+  const blob = await zip.generateAsync({ type: 'blob' });
+  saveAs(blob, `${_fileName}.zip`);
+}
+
+async function zipVue3Files(zip: JSZip) {
+  const files = [
+    '/view/js/marked.min.js',
+    '/view/js/lcjs.iife.js',
+    '/view/vue3/Meta2d.vue',
+    '/view/index.html',
+    '/view/js/meta2d.js',
+    '/view/使用说明.md',
+  ] as const;
+  // 文件同时加载
+  await Promise.all(files.map((filePath) => zipFile(zip, filePath)));
+}
+
+async function zipVue2Files(zip: JSZip) {
+  const files = [
+    '/view/js/marked.min.js',
+    '/view/js/lcjs.iife.js',
+    '/view/vue2/Meta2d.vue',
+    '/view/index.html',
+    '/view/js/meta2d.js',
+    '/view/使用说明.md',
+  ] as const;
+  // 文件同时加载
+  await Promise.all(files.map((filePath) => zipFile(zip, filePath)));
+}
+
+async function zipReactFiles(zip: JSZip) {
+  const files = [
+    '/view/js/marked.min.js',
+    '/view/js/lcjs.iife.js',
+    '/view/react/Meta2d.jsx',
+    '/view/react/Meta2d.css',
+    '/view/index.html',
+    '/view/js/meta2d.js',
+    '/view/使用说明.md',
+  ] as const;
+  // 文件同时加载
+  await Promise.all(files.map((filePath) => zipFile(zip, filePath)));
+}
+
+async function zipFiles(zip: JSZip) {
+  const files = [
+    '/view/js/marked.min.js',
+    '/view/js/lcjs.iife.js',
+    '/view/js/index.js',
+    '/view/js/meta2d.js',
+    '/view/index.html',
+    '/view/index.css',
+    '/view/favicon.ico',
+    '/view/使用说明.pdf',
+  ] as const;
+  // 文件同时加载
+  await Promise.all(files.map((filePath) => zipFile(zip, filePath)));
+}
+
+async function zipFile(zip: JSZip, filePath: string) {
+  const res: Blob = await axios.get((cdn ? cdn + '/2d' : '') + filePath, {
+    responseType: 'blob',
+  });
+  zip.file(filePath.replace('/view', ''), res, { createFolders: true });
+}
+
+/**
+ * 图片放到 zip 里
+ * @param pens 可以是非具有 calculative 的 pen
+ */
+async function zipImages(zip: JSZip, pens: Pen[]) {
+  if (!pens) {
+    return;
+  }
+
+  // 不止 image 上有图片, strokeImage ,backgroundImage 也有图片
+  const imageKeys = [
+    {
+      string: 'image',
+    },
+    { string: 'strokeImage' },
+    { string: 'backgroundImage' },
+  ] as const;
+  const images: string[] = [];
+  for (const pen of pens) {
+    for (const i of imageKeys) {
+      const image = pen[i.string];
+      if (image) {
+        // HTMLImageElement 无法精确控制图片格式
+        if (
+          image.startsWith('/') ||
+          image.startsWith(cdn) ||
+          image.startsWith(upCdn)
+        ) {
+          // 只考虑相对路径下的 image ,绝对路径图片无需下载
+          if (!images.includes(image)) {
+            images.push(image);
+          }
+        }
+      }
+    }
+    // 无需递归遍历子节点,现在所有的节点都在外层
+  }
+  await Promise.all(images.map((image) => zipImage(zip, image)));
+}
+
+async function zipImage(zip: JSZip, image: string) {
+  const res: Blob = await axios.get(image, {
+    responseType: 'blob',
+    params: {
+      isZip: true,
+    },
+  });
+  zip.file(cdn ? image.replace(cdn, '').replace(upCdn, '') : image, res, {
+    createFolders: true,
+  });
+}
+
+const downloadImageTips =
+  '无法下载,宽度不合法,画布可能没有画笔/画布大小超出浏览器最大限制';
+
+const downloadPng = () => {
+  const name = (meta2d.store.data as Meta2dBackData).name;
+  try {
+    meta2d.downloadPng(name ? name + '.png' : undefined);
+  } catch (e) {
+    MessagePlugin.warning(downloadImageTips);
+  }
+};
+
+async function getIconDefs(url: string) {
+  let res: any = await axios.get(url);
+  let str = res.match(/@font-face([\s\S]*?)\}/)[1];
+  str = `@font-face ${str} }`;
+  return str;
+}
+
+const downloadSvg = async () => {
+  await import('@/assets/canvas2svg');
+  if (!C2S) {
+    MessagePlugin.error('请先加载乐吾乐官网下的canvas2svg.js');
+    return;
+  }
+
+  const rect: any = meta2d.getRect();
+  if (!isFinite(rect.width)) {
+    MessagePlugin.error(downloadImageTips);
+    return;
+  }
+  rect.x -= 10;
+  rect.y -= 10;
+  const ctx = new C2S(rect.width + 20, rect.height + 20);
+  ctx.textBaseline = 'middle';
+  ctx.strokeStyle = getGlobalColor(meta2d.store);
+  for (const pen of meta2d.store.data.pens) {
+    // 不使用 calculative.inView 的原因是,如果 pen 在 view 之外,那么它的 calculative.inView 为 false,但是它的绘制还是需要的
+    if (!isShowChild(pen, meta2d.store) || pen.visible == false) {
+      continue;
+    }
+    meta2d.renderPenRaw(ctx, pen, rect);
+  }
+
+  let mySerializedSVG = ctx.getSerializedSvg();
+  let icon_pens = meta2d.store.data.pens.filter(
+    (item) => item.iconFamily && item.icon
+  );
+  if (icon_pens && icon_pens.length > 0) {
+    let iconList = [
+      '/icon/国家电网/iconfont.css',
+      '/icon/电气工程/iconfont.css',
+      '/icon/通用图标/iconfont.css',
+    ];
+    let defsList: any = await Promise.all(
+      iconList.map((item) => getIconDefs(item))
+    );
+    mySerializedSVG = mySerializedSVG.replace(
+      '<defs/>',
+      `<defs>
+    <style type="text/css">
+${defsList.join('\n')}
+</style>
+{{bk}}
+  </defs>
+{{bkRect}}`
+    );
+  }
+  /*  mySerializedSVG = mySerializedSVG.replace(
+        '<defs/>',
+        `<defs>
+    <style type="text/css">
+  @font-face {
+    font-family: 'ticon';
+    src: url('icon/通用图标/iconfont.ttf') format('truetype');
+  }
+</style>
+{{bk}}
+  </defs>
+{{bkRect}}`
+      );
+*/
+  if (meta2d.store.data.background) {
+    mySerializedSVG = mySerializedSVG.replace('{{bk}}', '');
+    mySerializedSVG = mySerializedSVG.replace(
+      '{{bkRect}}',
+      `<rect x="0" y="0" width="100%" height="100%" fill="${meta2d.store.data.background}"></rect>`
+    );
+  } else {
+    mySerializedSVG = mySerializedSVG.replace('{{bk}}', '');
+    mySerializedSVG = mySerializedSVG.replace('{{bkRect}}', '');
+  }
+
+  mySerializedSVG = mySerializedSVG.replace(/--le5le--/g, '&#x');
+
+  const urlObject: any = (window as any).URL || window;
+  const export_blob = new Blob([mySerializedSVG]);
+  const url = urlObject.createObjectURL(export_blob);
+
+  const a = document.createElement('a');
+  a.setAttribute(
+    'download',
+    `${(meta2d.store.data as Meta2dBackData).name || 'le5le.meta2d'}.svg`
+  );
+  a.setAttribute('href', url);
+  const evt = document.createEvent('MouseEvents');
+  evt.initEvent('click', true, true);
+  a.dispatchEvent(evt);
+};
+
+const onUndo = () => {
+  meta2d.undo();
+};
+
+const onRedo = () => {
+  meta2d.redo();
+};
+
+const onCut = () => {
+  meta2d.cut();
+};
+
+const onCopy = () => {
+  meta2d.copy();
+};
+
+const onPaste = () => {
+  meta2d.paste();
+};
+
+const onAll = () => {
+  meta2d.activeAll();
+};
+
+const onDelete = () => {
+  meta2d.delete();
+};
+
+const onToggleAnchor = () => {
+  //取消连线状态
+  // meta2d.store.options.disableAnchor = false;
+  if (!meta2d.store.options.disableAnchor) {
+    meta2d.canvas.drawingLineName && drawPen();
+    meta2d.toggleAnchorMode();
+  }
+};
+
+const onAddAnchorHand = () => {
+  meta2d.addAnchorHand();
+};
+
+const onRemoveAnchorHand = () => {
+  meta2d.removeAnchorHand();
+};
+
+const onToggleAnchorHand = () => {
+  meta2d.toggleAnchorHand();
+};
+
+const onScaleUp = () => {
+  const _scale = meta2d.store.data.scale + 0.1;
+  meta2d.scale(_scale);
+};
+
+const onScaleDown = () => {
+  const _scale = meta2d.store.data.scale - 0.1;
+  meta2d.scale(_scale);
+};
+
+const autoAnchor = ref(true);
+const onAutoAnchor = () => {
+  meta2d.store.options.autoAnchor = !meta2d.store.options.autoAnchor;
+  autoAnchor.value = meta2d.store.options.autoAnchor;
+};
+
+const showAnchor = ref(false);
+const onDisableAnchor = () => {
+  meta2d.store.options.disableAnchor = !meta2d.store.options.disableAnchor;
+  changeDisableAnchor();
+};
+
+const changeDisableAnchor = () => {
+  const { disableAnchor, autoAnchor } = meta2d.store.options;
+  showAnchor.value = !disableAnchor || false;
+  if (disableAnchor && autoAnchor) {
+    // 禁用瞄点开了,需要关闭自动瞄点
+    onAutoAnchor();
+  }
+};
 </script>
 <style lang="postcss" scoped>
 .app-header {

+ 198 - 0
src/views/components/Network.vue

@@ -0,0 +1,198 @@
+<template>
+  <div class="props">
+    <div class="form-item mt-8">
+      <label>数据源名称</label>
+      <t-select-input
+        v-if="mode"
+        v-model:inputValue="modelValue.name"
+        :value="modelValue.name"
+        placeholder="数据源"
+        allow-input
+        clearable
+        v-model:popup-visible="popupVisible"
+        @focus="popupVisible = true"
+        @blur="popupVisible = undefined"
+        @input-change="onInput"
+      >
+        <template #panel>
+          <ul style="padding: 4px">
+            <li
+              class="hover-background"
+              style="line-height: 1.5; padding: 8px; border-radius: 2px"
+              v-for="item in networkList"
+              :key="item.url"
+              @click="() => onSelect(item)"
+            >
+              名称: {{ item.name }}
+              <div class="desc">地址: {{ item.url }}</div>
+            </li>
+            <li
+              v-if="!networkList.length"
+              style="line-height: 1.5; padding: 8px; border-radius: 2px"
+              :key="-1"
+            >
+              <div class="desc">暂无数据</div>
+            </li>
+          </ul>
+        </template>
+      </t-select-input>
+      <t-input v-else v-model="modelValue.name" />
+    </div>
+
+    <div class="form-item mt-8">
+      <label>网络类型</label>
+      <t-select
+        v-model="modelValue.type"
+        placeholder="MQTT"
+        @change="typeChange"
+      >
+        <t-option key="mqtt" value="mqtt" label="MQTT" />
+        <t-option key="websocket" value="websocket" label="Websocket" />
+        <t-option key="http" value="http" label="HTTP" />
+      </t-select>
+    </div>
+    <div class="form-item mt-8">
+      <label>URL地址</label>
+      <t-input v-model="modelValue.url" />
+    </div>
+    <template v-if="modelValue.type === 'websocket'">
+      <div class="form-item mt-8">
+        <label>Protocol</label>
+        <t-input v-model="modelValue.protocols" />
+      </div>
+    </template>
+    <template v-else-if="modelValue.type === 'http'">
+      <div class="form-item mt-8">
+        <label>请求方式</label>
+        <t-select v-model="modelValue.method" @change="httpMethodChange">
+          <t-option key="GET" value="GET" label="GET" />
+          <t-option key="POST" value="POST" label="POST" />
+        </t-select>
+      </div>
+      <div class="form-item mt-8">
+        <label>请求头</label>
+        <t-textarea
+          v-model="modelValue.httpHeaders"
+          :autosize="{ minRows: 3, maxRows: 5 }"
+          placeholder="请输入"
+        />
+      </div>
+      <div v-if="modelValue.method === 'POST'" class="form-item mt-8">
+        <label>请求体</label>
+        <t-textarea
+          v-model="modelValue.body"
+          :autosize="{ minRows: 3, maxRows: 5 }"
+          placeholder="请输入"
+        />
+      </div>
+    </template>
+    <template v-else>
+      <div class="form-item mt-8">
+        <label>Client Id</label>
+        <t-input v-model="modelValue.options.clientId" />
+      </div>
+      <div class="form-item mt-8">
+        <label>自动生成</label>
+        <t-switch
+          class="ml-8 mt-8"
+          v-model="modelValue.options.customClientId"
+          size="small"
+        />
+      </div>
+      <div class="form-item mt-8">
+        <label>用户名</label>
+        <t-input v-model="modelValue.options.username" />
+      </div>
+      <div class="form-item mt-8">
+        <label>密码</label>
+        <t-input v-model="modelValue.options.password" />
+      </div>
+      <div class="form-item mt-8">
+        <label>Topics</label>
+        <t-input v-model="modelValue.topics" />
+      </div>
+    </template>
+    <div class="form-item mt-8" v-if="mode">
+      <label> </label>
+      <div>
+        <t-button @click="onSave">保存到我的数据源</t-button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { onBeforeMount, ref } from 'vue';
+
+import { debounce } from '@/services/debouce';
+
+const { modelValue, mode } = defineProps<{
+  modelValue: any;
+  mode?: any;
+}>();
+
+const emit = defineEmits(['update:modelValue', 'change']);
+
+const popupVisible = ref<boolean>(false);
+const networkList = ref<any[]>([]);
+
+onBeforeMount(() => {});
+
+const typeChange = (t: string) => {
+  if (t === 'http') {
+    Object.assign(modelValue, {
+      http: '',
+      httpTimeInterval: 1000,
+      httpHeaders: '',
+      method: 'GET',
+      body: '',
+    });
+  } else if (t === 'websocket') {
+    modelValue.protocols = '';
+  } else {
+    Object.assign(modelValue, {
+      options: {
+        clientId: '',
+        username: '',
+        password: '',
+        customClientId: false,
+      },
+    });
+  }
+};
+
+const httpMethodChange = (method: string) => {
+  if (method === 'GET') {
+    modelValue.body = undefined;
+  }
+};
+
+const onSave = () => {
+  emit('update:modelValue', modelValue);
+  emit('change', modelValue);
+
+  // 保存到我的数据源
+  // todo
+};
+
+const onInput = (text: string) => {
+  debounce(getNetworks, 300);
+};
+
+// 请求我的数据源接口
+const getNetworks = async () => {
+  // const ret: any = await axios.get(`/api/xxx`);
+  // if (ret) {
+  //   dataDialog.networkList = ret.list
+  // }
+};
+
+const onSelect = (item: any) => {
+  Object.assign(modelValue, item);
+  popupVisible.value = false;
+};
+</script>
+<style lang="postcss" scoped>
+.props {
+}
+</style>

+ 432 - 0
src/views/components/PenAnimates.vue

@@ -0,0 +1,432 @@
+<template>
+  <div class="animations">
+    <template v-if="props.pen.animations.length">
+      <t-collapse
+        v-model="openedCollapses"
+        :borderless="true"
+        :expand-on-row-click="true"
+      >
+        <t-collapse-panel v-for="(item, i) in props.pen.animations" :value="i">
+          <template #header>
+            <div class="flex middle" @click.stop>
+              <t-input v-model="item.name" autoWidth class="mr-8" />
+              <t-icon
+                v-if="isPlaying === i"
+                name="stop-circle-1"
+                class="hover primary"
+                style="font-size: 16px"
+                @click="stop"
+              />
+              <t-icon
+                v-else
+                name="play-circle"
+                class="hover"
+                style="font-size: 16px"
+                @click="play(i)"
+              />
+            </div>
+          </template>
+          <template #headerRightContent>
+            <t-space size="small" @click.stop>
+              <t-icon
+                v-if="!props.pen.type && item.animate"
+                name="edit"
+                class="hover mr-4"
+                @click="animate = item"
+              />
+
+              <t-popconfirm
+                content="确认删除该动画吗"
+                placement="left"
+                @confirm="
+                  props.pen.animations.splice(i, 1);
+                  animate = undefined;
+                "
+              >
+                <t-icon name="delete" class="hover" />
+              </t-popconfirm>
+            </t-space>
+          </template>
+          <template v-if="props.pen.type">
+            <div class="form-item">
+              <label>动画类型</label>
+              <t-select v-model="item.lineAnimateType" placeholder="水流">
+                <t-option :key="0" :value="0" label="水流" />
+                <t-option :key="1" :value="1" label="水珠" />
+                <t-option :key="2" :value="2" label="圆点" />
+              </t-select>
+            </div>
+
+            <div class="form-item mt-8">
+              <label>运动速度</label>
+              <t-slider
+                class="ml-12"
+                v-model="item.animateSpan"
+                :show-tooltip="true"
+                :min="1"
+                :max="10"
+              />
+            </div>
+            <div class="form-item mt-8">
+              <label>动画颜色</label>
+              <t-color-picker
+                class="w-full"
+                format="CSS"
+                :enable-alpha="true"
+                :color-modes="['monochrome']"
+                :show-primary-color-preview="false"
+                :clearable="true"
+                v-model="item.animateColor"
+              />
+            </div>
+            <div class="form-item mt-8">
+              <label>轨迹宽度</label>
+              <t-input-number
+                v-model="item.animateLineWidth"
+                theme="column"
+                :min="1"
+                placeholder="默认"
+              />
+            </div>
+            <div class="form-item mt-8">
+              <label>反向流动</label>
+              <t-switch
+                class="ml-8 mt-8"
+                size="small"
+                v-model="item.animateReverse"
+              />
+            </div>
+            <div class="form-item mt-8">
+              <label>播放次数</label>
+              <t-input-number
+                v-model="item.animateCycle"
+                theme="column"
+                :min="1"
+                placeholder="无限"
+                title="缺省无限循环播放"
+              />
+            </div>
+            <div class="form-item mt-8">
+              <label>自动播放</label>
+              <t-switch
+                class="ml-8 mt-8"
+                size="small"
+                v-model="item.autoPlay"
+              />
+            </div>
+            <div
+              class="form-item mt-8"
+              title="当前动画结束后自动播放下一个对象的动画"
+            >
+              <label>下个动画</label>
+              <t-tree-select
+                v-model="item.nextAnimate"
+                :data="penTree"
+                filterable
+                placeholder="无"
+              />
+            </div>
+          </template>
+          <template v-else>
+            <div class="form-item">
+              <label>动画类型</label>
+              <t-select
+                v-model="item.animate"
+                clearable
+                placeholder="动画"
+                :options="animateList"
+                @change="changeAnimate(item)"
+              />
+            </div>
+            <div class="form-item mt-8">
+              <label>播放次数</label>
+              <t-input-number
+                v-model="item.animateCycle"
+                theme="column"
+                :min="1"
+                placeholder="无限"
+                title="缺省无限循环播放"
+              />
+            </div>
+            <div class="form-item mt-8">
+              <label>结束状态</label>
+              <t-select
+                v-model="item.keepanimationstate"
+                placeholder="初始状态"
+              >
+                <t-option :key="false" :value="false" label="初始状态" />
+                <t-option :key="true" :value="true" label="当前状态" />
+              </t-select>
+            </div>
+            <div class="form-item mt-8">
+              <label>线性播放</label>
+              <t-tooltip content="仅支持数字属性匀速线性播放" placement="top">
+                <t-select v-model="item.linear" placeholder="是">
+                  <t-option :key="true" :value="true" label="是" />
+                  <t-option :key="false" :value="false" label="否" />
+                </t-select>
+              </t-tooltip>
+            </div>
+            <div class="form-item mt-8">
+              <label>自动播放</label>
+              <t-switch
+                class="ml-8 mt-8"
+                size="small"
+                v-model="item.autoPlay"
+              />
+            </div>
+            <div
+              class="form-item mt-8"
+              title="当前动画结束后自动播放下一个对象的动画"
+            >
+              <label>下个动画</label>
+              <t-tree-select
+                v-model="item.nextAnimate"
+                :data="penTree"
+                filterable
+                placeholder="无"
+              />
+            </div>
+          </template>
+        </t-collapse-panel>
+      </t-collapse>
+      <t-divider />
+      <div class="p-16">
+        <t-button class="w-full" @click="addAnimate" style="height: 30px">
+          添加动画
+        </t-button>
+      </div>
+    </template>
+    <div class="flex column center blank" v-else>
+      <img src="/img/blank.png" />
+      <div class="gray center">还没有动画</div>
+      <div class="mt-8">
+        <t-button @click="addAnimate" style="height: 30px">添加动画</t-button>
+      </div>
+    </div>
+  </div>
+  <AnimateFrames
+    v-if="animate"
+    :animate="animate"
+    @close="animate = undefined"
+  />
+</template>
+
+<script lang="ts" setup>
+import { onBeforeMount, ref } from 'vue';
+
+import { getPenTree } from '@/services/common';
+import { deepClone } from '@meta2d/core';
+
+import AnimateFrames from './AnimateFrames.vue';
+
+const props = defineProps<{
+  pen: any;
+}>();
+
+const penTree: any = ref([]);
+
+const openedCollapses = ref([0]);
+
+const animate: any = ref(undefined);
+
+const animateList = [
+  {
+    label: '闪烁',
+    value: '闪烁',
+    data: [
+      {
+        visible: true,
+        duration: 100,
+      },
+      {
+        visible: false,
+        duration: 100,
+      },
+    ],
+  },
+  {
+    label: '缩放',
+    value: '缩放',
+    data: [
+      {
+        scale: 1.1,
+        duration: 100,
+      },
+      {
+        scale: 1,
+        duration: 400,
+      },
+    ],
+  },
+  {
+    label: '旋转',
+    value: '旋转',
+    data: [
+      {
+        rotate: 360,
+        duration: 1000,
+      },
+    ],
+  },
+  {
+    label: '上下跳动',
+    value: '上下跳动',
+    data: [
+      {
+        y: -10,
+        duration: 100,
+      },
+      { y: 0, duration: 100 },
+      { y: -10, duration: 200 },
+    ],
+  },
+  {
+    label: '左右跳动',
+    value: '左右跳动',
+    data: [
+      {
+        x: -10,
+        duration: 100,
+      },
+      {
+        x: 10,
+        duration: 80,
+      },
+      {
+        x: -10,
+        duration: 50,
+      },
+      {
+        x: 10,
+        duration: 30,
+      },
+      {
+        x: 0,
+        duration: 300,
+      },
+    ],
+  },
+  {
+    label: '颜色变化',
+    value: '颜色变化',
+    data: [
+      { color: '#4583ff', duration: 200 },
+      { color: '#ff4000', duration: 200 },
+    ],
+  },
+  {
+    label: '背景变化',
+    value: '背景变化',
+    data: [
+      { background: '#4583ff', duration: 200 },
+      { background: '#ff4000', duration: 200 },
+    ],
+  },
+  {
+    label: '文字变化',
+    value: '文字变化',
+    data: [
+      { text: '乐吾乐', duration: 200 },
+      { text: 'le5le', duration: 200 },
+    ],
+  },
+  {
+    label: '状态变化',
+    value: '状态变化',
+    data: [
+      { showChild: 0, duration: 200 },
+      { showChild: 1, duration: 200 },
+    ],
+  },
+  {
+    label: '翻转',
+    value: '翻转',
+    data: [
+      { flipX: true, flipY: true, duration: 200 },
+      { flipX: false, flipY: false, duration: 200 },
+    ],
+  },
+  {
+    label: '自定义',
+    value: 'custom',
+    data: [],
+  },
+];
+
+const isPlaying = ref(-1);
+
+onBeforeMount(() => {
+  if (!props.pen.animations) {
+    props.pen.animations = [];
+  }
+
+  const p = meta2d.findOne(props.pen.id);
+  if (p?.calculative?.start) {
+    // @ts-ignore
+    isPlaying.value = p?.currentAnimation;
+  }
+
+  penTree.value = getPenTree();
+});
+
+const addAnimate = () => {
+  openedCollapses.value.push(props.pen.animations.length);
+  props.pen.animations.push({
+    name: '动画' + (props.pen.animations.length + 1),
+  });
+};
+
+const changeAnimate = (item: any) => {
+  const animate: any = animateList.find((elem: any) => {
+    return elem.value === item.animate;
+  });
+
+  if (!animate) {
+    return;
+  }
+
+  item.frames = deepClone(animate.data);
+};
+
+const play = (i: number) => {
+  meta2d.startAnimate(props.pen.id, i);
+  isPlaying.value = i;
+};
+
+const stop = () => {
+  meta2d.stopAnimate(props.pen.id);
+  isPlaying.value = -1;
+};
+</script>
+<style lang="postcss" scoped>
+.animations {
+  height: 100%;
+
+  .blank {
+    height: 70%;
+    img {
+      padding: 16px;
+      opacity: 0.9;
+    }
+  }
+
+  :deep(.t-collapse) {
+    .t-collapse-panel__header {
+      .t-input {
+        border-color: transparent;
+        &:hover {
+          border-color: var(--color-border-input);
+        }
+      }
+    }
+
+    .t-collapse-panel__icon:hover {
+      background: none;
+      svg {
+        color: var(--color-primary);
+      }
+    }
+  }
+}
+</style>

+ 1006 - 0
src/views/components/PenDatas.vue

@@ -0,0 +1,1006 @@
+<template>
+  <div class="props">
+    <div
+      class="real-times"
+      v-if="props.pen.realTimes && props.pen.realTimes.length"
+    >
+      <div class="grid head">
+        <div class="title">数据名</div>
+        <div class="title">值</div>
+        <div class="title">触发器</div>
+        <div class="actions">
+          <t-icon name="more" />
+        </div>
+      </div>
+      <div class="grid" v-for="(item, i) in props.pen.realTimes">
+        <t-tooltip :content="item.key" placement="top">
+          <label class="label">{{ item.label }}</label>
+        </t-tooltip>
+        <div class="value">
+          <t-tooltip :content="getBindsDesc(item)" placement="top">
+            <t-icon
+              name="link"
+              class="hover"
+              :class="{ primary: item.binds?.length }"
+              @click="onBind(item)"
+            />
+          </t-tooltip>
+          <t-input
+            v-if="item.type === 'number'"
+            v-model="pen[item.key]"
+            placeholder="数字"
+            @change="changeValue(item.key)"
+          />
+          <t-switch
+            v-else-if="item.type === 'bool'"
+            v-model="pen[item.key]"
+            class="ml-8"
+            size="small"
+            @change="changeValue(item.key)"
+          />
+          <div
+            v-else-if="item.type === 'array' || item.type === 'object'"
+            class="gray ellipsis"
+            style="height: 30px"
+          >
+            {{ JSON.stringify(pen[item.key]) }}
+          </div>
+          <t-input
+            v-else
+            v-model="pen[item.key]"
+            placeholder="字符串"
+            @change="changeValue(item.key)"
+          />
+        </div>
+        <div>
+          <t-tooltip :content="item.triggers?.length || '触发器'">
+            <t-badge
+              :count="item.triggers?.length"
+              size="small"
+              dot
+              :offset="[0, 5]"
+            >
+              <t-icon
+                name="relativity"
+                class="hover"
+                @click="onTrigger(item)"
+              />
+            </t-badge>
+          </t-tooltip>
+        </div>
+        <div class="actions">
+          <t-dropdown
+            :options="moreOptions"
+            @click="onMenuMore($event, item, i)"
+            :minColumnWidth="80"
+          >
+            <t-icon name="more" class="more hover" />
+          </t-dropdown>
+        </div>
+      </div>
+      <div class="mt-8 pb-16">
+        <t-dropdown
+          :options="options"
+          @click="addRealTime"
+          :minColumnWidth="150"
+        >
+          <a class="ml-12"> <t-icon name="add-rectangle" /> 添加动态数据 </a>
+        </t-dropdown>
+      </div>
+    </div>
+    <div class="flex column center blank" v-else>
+      <img src="/img/blank.png" />
+      <div class="gray center">还没有动态数据</div>
+      <div class="mt-8">
+        <t-dropdown
+          :options="options"
+          @click="addRealTime"
+          :minColumnWidth="150"
+        >
+          <t-button style="height: 30px"> 添加动态数据 </t-button>
+        </t-dropdown>
+      </div>
+    </div>
+  </div>
+
+  <t-dialog
+    v-if="addDataDialog.show"
+    :visible="true"
+    class="data-dialog"
+    :header="addDataDialog.header"
+    @close="addDataDialog.show = false"
+    @confirm="onConfirmData"
+  >
+    <div class="form-item mt-16">
+      <label>数据名</label>
+      <t-input
+        v-model="addDataDialog.data.label"
+        placeholder="简短描述"
+        :disabled="!!addDataDialog.data.keywords"
+        @blur="onChangeLabel"
+      />
+    </div>
+    <div class="form-item mt-16">
+      <label>属性名</label>
+      <t-input
+        v-model="addDataDialog.data.key"
+        placeholder="关键字"
+        :disabled="!!addDataDialog.data.keywords"
+      />
+    </div>
+    <div class="form-item mt-16">
+      <label>类型</label>
+      <t-select
+        class="w-full"
+        :options="typeOptions"
+        v-model="addDataDialog.data.type"
+        placeholder="字符串"
+        :disabled="!!addDataDialog.data.keywords"
+        @change="addDataDialog.data.value = null"
+      />
+    </div>
+    <div class="form-item mt-16">
+      <label>值</label>
+      <div class="flex-grow" v-if="addDataDialog.data.type === 'number'">
+        <t-input
+          class="w-full"
+          v-model="addDataDialog.data.value"
+          placeholder="数字"
+        />
+        <div class="desc mt-8">
+          固定数字:直接输入数字。例如:5<br />
+          随机范围数字 :最小值-最大值。例如:0-1 或 0-100
+          <br />
+          随机指定数字 :数字1,数字2,数字3... 。 例如:1,5,10,20<br />
+        </div>
+      </div>
+      <div class="flex-grow" v-else-if="addDataDialog.data.type === 'bool'">
+        <t-select v-model="addDataDialog.data.value">
+          <t-option :key="true" :value="true" label="true"></t-option>
+          <t-option :key="false" :value="false" label="false"></t-option>
+          <t-option key="随机" label="随机"></t-option>
+        </t-select>
+        <div class="desc mt-8">
+          固定:指定true或false<br />
+          随机:随机生成一个布尔值<br />
+        </div>
+      </div>
+      <div
+        class="flex-grow"
+        v-else-if="
+          addDataDialog.data.type === 'array' ||
+          addDataDialog.data.type === 'object'
+        "
+      >
+        <CodeEditor v-model="addDataDialog.data.value" :json="true" />
+      </div>
+      <div class="flex-grow" v-else>
+        <t-input
+          class="w-full"
+          v-model="addDataDialog.data.value"
+          placeholder="字符串"
+        />
+        <div class="desc mt-8">
+          固定文字:直接输入。例如:大屏可视化<br />
+          随机文本:[文本长度]。例如:[8] 或 [16]<br />
+          随机指定文本:{文本1,文本2,文本3...} 。 例如:{大屏, 可视化}
+          <br />
+        </div>
+      </div>
+    </div>
+  </t-dialog>
+
+  <t-dialog
+    v-if="dataBindDialog.show"
+    :visible="true"
+    class="data-link-dialog"
+    header="动态数据绑定"
+    @close="
+      dataBindDialog.data.binds = dataBindDialog.bkBinds;
+      dataBindDialog.show = false;
+    "
+    @confirm="dataBindDialog.show = false"
+    :width="700"
+  >
+    <div class="form-item">
+      <label>当前绑定:</label>
+      <div class="label" v-if="dataBindDialog.data.binds?.length">
+        <t-tooltip
+          v-for="(tag, index) in dataBindDialog.data.binds"
+          :key="index"
+          :content="tag.id"
+        >
+          <t-tag class="mr-8 mb-8" closable @close="onRemoveBind(index)">
+            {{ tag.label }}
+          </t-tag>
+        </t-tooltip>
+      </div>
+      <div class="label gray" v-else>无</div>
+    </div>
+    <div class="form-item mt-8">
+      <t-input
+        placeholder="搜索"
+        v-model="dataBindDialog.input"
+        @change="onSearchDataset"
+        @enter="onSearchDataset"
+      >
+        <template #suffixIcon>
+          <t-icon name="search" class="hover" @click="onSearchDataset" />
+        </template>
+      </t-input>
+    </div>
+    <t-table
+      class="mt-12 data-list"
+      row-key="id"
+      :data="dataBindDialog.dataSet"
+      :columns="dataSetColumns"
+      size="small"
+      bordered
+      :loading="dataBindDialog.loading"
+      :pagination="query"
+      @page-change="onChangePagination"
+      :selected-row-keys="dataBindDialog.selectedIds"
+      @select-change="onSelectBindsChange"
+    >
+    </t-table>
+  </t-dialog>
+
+  <t-dialog
+    v-if="triggersDialog.show"
+    :visible="true"
+    class="data-events-dialog"
+    header="数据触发器"
+    @confirm="triggersDialog.show = false"
+    @close="triggersDialog.show = false"
+    :width="700"
+  >
+    <div class="body">
+      <t-collapse
+        v-model="triggersDialog.openedCollapses"
+        :borderless="true"
+        :expand-on-row-click="false"
+      >
+        <t-collapse-panel
+          v-for="(trigger, i) in triggersDialog.data.triggers"
+          :value="i"
+        >
+          <template #header>
+            <t-input v-model="trigger.name" class="mr-12" />
+          </template>
+          <template #headerRightContent>
+            <t-popconfirm
+              content="确认删除该触发器吗?"
+              @confirm="triggersDialog.data.triggers.splice(i, 1)"
+            >
+              <t-icon name="delete" class="hover" />
+            </t-popconfirm>
+          </template>
+          <section>
+            <div class="form-item banner">
+              <label>触发条件</label>
+              <div class="w-full flex middle between">
+                <div></div>
+                <t-radio-group v-model="trigger.conditionType">
+                  <t-radio value="and"> 满足全部条件 </t-radio>
+                  <t-radio value="or"> 满足任意条件 </t-radio>
+                </t-radio-group>
+              </div>
+            </div>
+            <div v-for="(c, index) in trigger.conditions" class="mb-12">
+              <div class="flex middle between head">
+                <div class="flex middle">
+                  <t-icon name="arrow-right" class="mr-4" />
+                  条件{{ index + 1 }}
+                </div>
+                <t-icon
+                  name="close"
+                  class="hover"
+                  @click="trigger.conditions.splice(index, 1)"
+                />
+              </div>
+              <div class="px-16 py-4">
+                <div class="form-item mt-4">
+                  <label>条件类型</label>
+                  <t-radio-group v-model="c.type">
+                    <t-radio value=""> 关系条件 </t-radio>
+                    <t-radio value="fn"> 高级条件 </t-radio>
+                  </t-radio-group>
+                </div>
+                <template v-if="!c.type">
+                  <div class="form-item mt-8">
+                    <label>比较条件</label>
+                    <div class="flex middle">
+                      <label class="shrink-0 mr-8">数据</label>
+                      <t-select
+                        v-model="c.operator"
+                        placeholder="关系运算"
+                        :options="operatorOptions"
+                        class="shrink-0 mr-8"
+                        style="width: 80px"
+                      />
+                      <t-select
+                        v-model="c.valueType"
+                        class="shrink-0 mr-8"
+                        style="width: 110px"
+                        placeholder="固定值"
+                      >
+                        <t-option key="" value="" label="固定值">
+                          固定值
+                        </t-option>
+                        <t-option key="prop" value="prop" label="对象属性值">
+                          对象属性值
+                        </t-option>
+                      </t-select>
+                      <template v-if="!c.valueType">
+                        <t-input
+                          v-model="c.value"
+                          class="shrink-0"
+                          style="width: 320px"
+                        />
+                      </template>
+                      <template v-else>
+                        <t-tree-select
+                          v-model="c.target"
+                          :data="penTree"
+                          filterable
+                          placeholder="对象"
+                          class="shrink-0 mr-8"
+                          style="width: 160px"
+                          @change="onChangeTriggerTarget(c)"
+                        />
+                        <t-select-input
+                          v-model:inputValue="c.value"
+                          :value="c.label"
+                          v-model:popupVisible="c.popupVisible"
+                          allow-input
+                          clearable
+                          @clear="c.label = undefined"
+                          @focus="c.popupVisible = true"
+                          @blur="c.popupVisible = undefined"
+                          @input-change="onInput(c)"
+                          class="shrink-0"
+                          style="width: 152px"
+                        >
+                          <template #panel>
+                            <ul style="padding: 8px 12px">
+                              <li
+                                v-for="item in c.targetProps"
+                                :key="item.value"
+                                @click="
+                                  c.value = item.value;
+                                  c.label = item.label;
+                                "
+                              >
+                                {{ item.label }}
+                              </li>
+                            </ul>
+                          </template>
+                        </t-select-input>
+                      </template>
+                    </div>
+                  </div>
+                </template>
+                <template v-else>
+                  <div>function condition(pen) {</div>
+                  <CodeEditor class="mt-4" v-model="c.fnJs" />
+                  <div class="mt-4">}</div>
+                </template>
+              </div>
+            </div>
+            <div class="mt-8">
+              <a @click="addTriggerCondition(trigger)"> + 添加条件 </a>
+            </div>
+
+            <div class="form-item banner mt-16">
+              <label>执行动作</label>
+            </div>
+            <Actions class="mt-8" :data="trigger" />
+          </section>
+        </t-collapse-panel>
+      </t-collapse>
+
+      <div class="mt-8">
+        <a @click="onAddTrigger"> + 添加触发器 </a>
+      </div>
+    </div>
+  </t-dialog>
+</template>
+
+<script lang="ts" setup>
+import {
+  getCurrentInstance,
+  onBeforeMount,
+  onUnmounted,
+  reactive,
+  ref,
+  toRaw,
+} from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+import { MessagePlugin } from 'tdesign-vue-next';
+import axios from 'axios';
+import { debounce } from '@/services/debouce';
+import { getPenTree } from '@/services/common';
+import { updatePen } from './pen';
+
+import CodeEditor from '@/views/components/common/CodeEditor.vue';
+import Actions from './Actions.vue';
+
+const route = useRoute();
+const router = useRouter();
+
+const {
+  proxy: { $forceUpdate },
+}: any = getCurrentInstance();
+
+const props = defineProps<{
+  pen: any;
+}>();
+
+const options = ref<any>([
+  {
+    value: '',
+    content: '自定义',
+    divider: true,
+  },
+  {
+    value: 'x',
+    content: 'X',
+    type: 'number',
+    keywords: true,
+  },
+  {
+    value: 'y',
+    content: 'Y',
+    type: 'number',
+    keywords: true,
+  },
+  {
+    value: 'width',
+    content: '宽',
+    type: 'number',
+    keywords: true,
+  },
+  {
+    value: 'height',
+    content: '高',
+    type: 'number',
+    keywords: true,
+  },
+  {
+    value: 'visible',
+    content: '显示',
+    type: 'bool',
+    keywords: true,
+  },
+  {
+    value: 'text',
+    content: '文字',
+    keywords: true,
+  },
+  {
+    value: 'progress',
+    content: '进度',
+    type: 'number',
+    keywords: true,
+  },
+  {
+    value: 'showChild',
+    content: '状态',
+    type: 'number',
+    keywords: true,
+  },
+  {
+    value: 'rotate',
+    content: '旋转',
+    type: 'number',
+    keywords: true,
+  },
+]);
+
+const moreOptions = ref<any>([
+  {
+    value: 'edit',
+    content: '编辑',
+  },
+  {
+    value: 'delete',
+    content: '移除',
+  },
+]);
+
+const typeOptions = [
+  {
+    label: '字符串',
+  },
+  {
+    label: '数字',
+    value: 'number',
+  },
+  {
+    label: '布尔',
+    value: 'bool',
+  },
+  {
+    label: '对象',
+    value: 'object',
+  },
+  {
+    label: '数组',
+    value: 'array',
+  },
+];
+
+const addDataDialog = reactive<any>({
+  show: false,
+  data: undefined,
+});
+
+const dataBindDialog = reactive<any>({
+  show: false,
+  data: undefined,
+});
+
+const dataSetColumns = [
+  {
+    colKey: 'row-select',
+    type: 'multiple',
+    width: 50,
+  },
+  {
+    colKey: 'id',
+    title: '编号',
+    width: 150,
+    ellipsis: { theme: 'light', trigger: 'context-menu' },
+  },
+  {
+    colKey: 'label',
+    title: '动态数据名称',
+    width: 220,
+    ellipsis: { theme: 'light', trigger: 'context-menu' },
+  },
+  {
+    colKey: 'case',
+    title: '场景',
+    ellipsis: { theme: 'light', trigger: 'context-menu' },
+  },
+];
+
+const operatorOptions = ref<any>([
+  { label: '=', value: '=' },
+  { label: '!=', value: '!=' },
+  { label: '>', value: '>' },
+  { label: '<', value: '<' },
+  { label: '>=', value: '>=' },
+  { label: '<=', value: '<=' },
+  { label: '包含', value: '[)' },
+  { label: '不包含', value: '![)' },
+]);
+
+const query = reactive<{
+  current: number;
+  pageSize: number;
+  total: number;
+  range: any[];
+}>({
+  current: 1,
+  pageSize: 10,
+  total: 0,
+  range: [],
+});
+
+const triggersDialog = reactive<any>({
+  show: false,
+  data: undefined,
+});
+
+const penTree: any = ref([]);
+
+let timer: any;
+
+onBeforeMount(() => {
+  // realTimesOptions - 扩展的动态数据下拉列表
+  if (props.pen.realTimesOptions) {
+    options.value[options.value.length - 1].divider = true;
+    options.value.push(...props.pen.realTimesOptions);
+  }
+
+  timer = setInterval($forceUpdate, 1000);
+});
+
+const addRealTime = (e: any) => {
+  if (e.keywords) {
+    if (!props.pen.realTimes) {
+      props.pen.realTimes = [];
+    }
+
+    props.pen.realTimes.push({
+      label: e.content,
+      key: e.value,
+      type: e.type,
+      keywords: e.keywords,
+    });
+    return;
+  }
+
+  addDataDialog.header = '添加动态数据';
+
+  addDataDialog.data = {
+    label: e.content,
+    key: e.value,
+    type: e.type,
+    keywords: e.keywords,
+  };
+  if (e.keywords) {
+    addDataDialog.data.label = '';
+  }
+  addDataDialog.show = true;
+};
+
+const onChangeLabel = () => {
+  if (!addDataDialog.data.key) {
+    addDataDialog.data.key = addDataDialog.data.label;
+  }
+};
+
+const onConfirmData = () => {
+  if (!props.pen.realTimes) {
+    props.pen.realTimes = [];
+  }
+  if (!addDataDialog.data.label || !addDataDialog.data.key) {
+    MessagePlugin.error('数据名或属性名不能为空!');
+    return;
+  }
+
+  if (addDataDialog.header === '添加动态数据') {
+    const found = props.pen.realTimes.findIndex((item: any) => {
+      return item.key === addDataDialog.data.key;
+    });
+    if (found > -1) {
+      MessagePlugin.error('已经存在相同属性数据!');
+      return;
+    }
+    props.pen.realTimes.push(addDataDialog.data);
+  }
+
+  addDataDialog.show = false;
+};
+
+const onMenuMore = (e: any, item: any, i: number) => {
+  switch (e.value) {
+    case 'edit':
+      addDataDialog.header = '编辑动态数据';
+      addDataDialog.data = item;
+      addDataDialog.show = true;
+      break;
+    case 'delete':
+      props.pen.realTimes.splice(i, 1);
+      break;
+    default:
+      break;
+  }
+};
+
+const onBind = (item: any) => {
+  if (!item.binds) {
+    item.binds = [];
+  }
+  dataBindDialog.data = item;
+  dataBindDialog.input = '';
+  dataBindDialog.selectedIds = [];
+  for (const i of item.binds) {
+    dataBindDialog.selectedIds.push(i.id);
+  }
+  dataBindDialog.bkBinds = [];
+  dataBindDialog.bkBinds.push(...item.binds);
+  dataBindDialog.show = true;
+
+  getDataset();
+};
+
+const onSearchDataset = () => {
+  debounce(getDataset, 300);
+};
+
+const getDataset = async () => {
+  // @ts-ignore
+  const data: Meta2dBackData = meta2d.data();
+
+  dataBindDialog.loading = true;
+
+  // 应该从data获取url或结果列表
+  const ret: any = await axios.get(
+    `/api/device/data/set?mock=1&q=${dataBindDialog.input}&current=${query.current}&pageSize=${query.pageSize}`
+  );
+
+  dataBindDialog.dataSet = ret.list;
+  query.total = ret.total;
+  dataBindDialog.loading = false;
+};
+
+const onChangePagination = (pageInfo: any) => {
+  router.push({
+    path: route.path,
+    query: { current: pageInfo.current, pageSize: pageInfo.pageSize },
+  });
+  query.current = pageInfo.current;
+  query.pageSize = pageInfo.pageSize;
+  getDataset();
+};
+
+const onSelectBindsChange = (value: string[], options: any) => {
+  dataBindDialog.selectedIds = value;
+
+  if (options.type === 'check') {
+    for (const item of options.selectedRowData) {
+      const found = dataBindDialog.data.binds.findIndex((elem: any) => {
+        return elem.id === item.id;
+      });
+      if (found < 0) {
+        dataBindDialog.data.binds.push(toRaw(item));
+      }
+    }
+  } else if (options.type === 'uncheck') {
+    if (options.currentRowKey === 'CHECK_ALL_BOX') {
+      for (const data of dataBindDialog.dataSet) {
+        const found = dataBindDialog.data.binds.findIndex((elem: any) => {
+          return elem.id === data.id;
+        });
+        if (found > -1) {
+          dataBindDialog.data.binds.splice(found, 1);
+        }
+      }
+    } else {
+      const found = dataBindDialog.data.binds.findIndex((elem: any) => {
+        return elem.id === options.currentRowKey;
+      });
+      if (found > -1) {
+        dataBindDialog.data.binds.splice(found, 1);
+      }
+    }
+  }
+};
+
+const onRemoveBind = (index: number) => {
+  dataBindDialog.data.binds.splice(index, 1);
+
+  dataBindDialog.selectedIds = [];
+  for (const i of dataBindDialog.data.binds) {
+    dataBindDialog.selectedIds.push(i.id);
+  }
+};
+
+const getBindsDesc = (item: any) => {
+  if (!item.binds || !item.binds.length) {
+    return '绑定动态数据';
+  }
+  let desc = '';
+  for (const i of item.binds) {
+    desc += i.label + ',';
+  }
+  if (desc && desc.length > 1) {
+    desc = desc.substring(0, desc.length - 1);
+  }
+  return desc;
+};
+
+const changeValue = (prop: string) => {
+  updatePen(props.pen, prop);
+};
+
+const onTrigger = (item: any) => {
+  if (!item.triggers) {
+    item.triggers = [];
+  }
+  triggersDialog.openedCollapses = [0];
+  triggersDialog.data = item;
+  triggersDialog.show = true;
+
+  penTree.value = getPenTree();
+};
+
+const onAddTrigger = () => {
+  const i = triggersDialog.data.triggers.length;
+  triggersDialog.data.triggers.push({
+    name: `触发器${i + 1}`,
+    conditionType: 'and',
+    conditions: [],
+    actions: [],
+  });
+
+  triggersDialog.openedCollapses.push(i);
+};
+
+const addTriggerCondition = (trigger: any) => {
+  trigger.conditions.push({
+    type: '',
+    operator: '=',
+    valueType: '',
+  });
+};
+
+const onChangeTriggerTarget = (c: any) => {
+  c.targetProps = [
+    {
+      value: 'x',
+      label: 'X',
+    },
+    {
+      value: 'y',
+      label: 'Y',
+    },
+    {
+      value: 'width',
+      label: '宽',
+    },
+    {
+      value: 'height',
+      label: '高',
+    },
+    {
+      value: 'visible',
+      label: '显示',
+    },
+    {
+      value: 'text',
+      label: '文字',
+    },
+    {
+      value: 'progress',
+      label: '进度',
+    },
+    {
+      value: 'showChild',
+      label: '状态',
+    },
+    {
+      value: 'rotate',
+      label: '旋转',
+    },
+  ];
+
+  const target: any = meta2d.findOne(c.target);
+  if (target) {
+    for (const item of target.realTimes) {
+      const found = c.targetProps.findIndex(
+        (elem: any) => elem.value === item.key
+      );
+      if (found < 0) {
+        c.targetProps.push({
+          value: item.key,
+          label: item.label,
+        });
+      }
+    }
+  }
+};
+
+const onInput = (item: any) => {
+  item.label = item.value;
+};
+
+onUnmounted(() => {
+  clearInterval(timer);
+});
+</script>
+<style lang="postcss" scoped>
+.props {
+  height: 100%;
+
+  .grid {
+    grid-template-columns: 60px 140px 54px 30px;
+    padding: 0 12px;
+
+    &.head {
+      background: var(--color-background-input);
+      line-height: 36px;
+      margin-bottom: 6px;
+
+      .title {
+        line-height: 36px;
+      }
+    }
+  }
+
+  .blank {
+    height: 70%;
+    img {
+      padding: 16px;
+      opacity: 0.9;
+    }
+  }
+
+  .label {
+    width: fit-content;
+    font-size: 10px;
+    line-height: 28px;
+    color: var(--color-desc);
+  }
+
+  .value {
+    padding-right: 8px;
+    display: flex;
+    align-items: center;
+
+    svg {
+      flex-shrink: 0;
+      margin-right: 4px;
+    }
+
+    & > div {
+      width: 110px;
+      &.t-switch {
+        width: fit-content;
+        margin-left: 4px;
+      }
+    }
+
+    :deep(.t-input) {
+      padding-left: 4px;
+      height: 26px;
+      border-color: transparent;
+      &:hover {
+        border-color: var(--color-primary);
+      }
+    }
+  }
+
+  .actions {
+    text-align: right;
+    padding-right: 2px;
+  }
+
+  .data-list {
+    height: 300px;
+    overflow: auto;
+  }
+}
+
+.body {
+  :deep(.t-collapse.t--border-less) {
+    .t-collapse-panel__header {
+      border-top: none;
+      border-bottom: 1px solid var(--td-border-level-1-color);
+      padding: 8px 0;
+
+      .t-input {
+        border: none;
+        padding-left: 0;
+        font-size: 14px;
+      }
+    }
+
+    .t-collapse-panel__content {
+      padding: 8px 0;
+    }
+  }
+
+  .title {
+    position: relative;
+    margin: 8px 0;
+
+    :deep(.t-input) {
+      border-color: var(--color-background-input);
+      border-radius: 0;
+      border-left: none;
+      border-top: none;
+      border-right: none;
+      padding-left: 0;
+      padding-bottom: 8px;
+      font-size: 14px;
+
+      &:hover {
+        border-color: var(--color-border-input);
+      }
+    }
+  }
+
+  .head {
+    margin-top: 10px;
+  }
+
+  .banner {
+    background-color: var(--color-background-input);
+    padding: 0 12px;
+  }
+}
+</style>

+ 159 - 0
src/views/components/PenEvents.vue

@@ -0,0 +1,159 @@
+<template>
+  <div class="props">
+    <div v-if="props.pen.events && props.pen.events.length">
+      <t-collapse
+        v-model="openedCollapses"
+        :borderless="true"
+        :expand-on-row-click="true"
+        expand-icon-placement="left"
+      >
+        <t-collapse-panel v-for="(item, i) in props.pen.events" :value="i">
+          <template #header>
+            <div @click.stop class="head">
+              <t-select v-model="item.name" :options="options" autoWidth />
+            </div>
+          </template>
+          <template #headerRightContent>
+            <t-space size="small" @click.stop>
+              <t-popconfirm
+                content="确认删除该交互事件吗"
+                placement="left"
+                @confirm="props.pen.events.splice(i, 1)"
+              >
+                <t-icon name="delete" class="hover" />
+              </t-popconfirm>
+            </t-space>
+          </template>
+
+          <Actions :data="props.pen.events[i]" />
+        </t-collapse-panel>
+      </t-collapse>
+      <t-divider />
+      <div class="p-16">
+        <t-dropdown
+          :options="options"
+          @click="addEvent"
+          :minColumnWidth="254"
+          :maxHeight="360"
+        >
+          <t-button class="w-full" style="height: 30px">
+            添加交互事件
+          </t-button>
+        </t-dropdown>
+      </div>
+    </div>
+    <div class="flex column center blank" v-else>
+      <img src="/img/blank.png" />
+      <div class="gray center">还没有交互事件</div>
+      <div class="mt-8">
+        <t-dropdown
+          :options="options"
+          @click="addEvent"
+          :minColumnWidth="150"
+          :maxHeight="360"
+        >
+          <t-button style="height: 30px"> 添加交互事件 </t-button>
+        </t-dropdown>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { onBeforeMount, onUnmounted, ref } from 'vue';
+
+import Actions from './Actions.vue';
+
+const props = defineProps<{
+  pen: any;
+}>();
+
+const options = ref<any>([
+  {
+    value: 'click',
+    content: '单击',
+  },
+  {
+    value: 'dblclick',
+    content: '双击',
+    divider: true,
+  },
+  {
+    value: 'enter',
+    content: '鼠标移入',
+  },
+  {
+    value: 'leave',
+    content: '鼠标移出',
+    divider: true,
+  },
+  {
+    value: 'active',
+    content: '获得焦点',
+  },
+  {
+    value: 'inactive',
+    content: '失去焦点',
+    divider: true,
+  },
+  {
+    value: 'mousedown',
+    content: '鼠标按下',
+  },
+  {
+    value: 'mouseup',
+    content: '鼠标抬起',
+    divider: true,
+  },
+  {
+    value: 'message',
+    content: '监听消息',
+  },
+]);
+
+const openedCollapses = ref([0]);
+
+onBeforeMount(() => {
+  if (!props.pen.events) {
+    props.pen.events = [];
+  }
+
+  for (const item of options.value) {
+    item.label = item.content;
+  }
+});
+
+const addEvent = (e: any) => {
+  props.pen.events.push({ name: e.value });
+};
+
+onUnmounted(() => {});
+</script>
+<style lang="postcss" scoped>
+.props {
+  height: 100%;
+
+  .blank {
+    height: 70%;
+    img {
+      padding: 16px;
+      opacity: 0.9;
+    }
+  }
+
+  .head {
+    :deep(.t-select) {
+      width: fit-content;
+
+      .t-input {
+        border-color: transparent;
+      }
+    }
+  }
+
+  .banner {
+    background-color: var(--color-background-input);
+    padding: 0 12px;
+  }
+}
+</style>

+ 1166 - 2
src/views/components/PenProps.vue

@@ -1,8 +1,1172 @@
 <template>
-  <div class="props"></div>
+  <div class="props">
+    <t-tabs v-model="data.tab">
+      <t-tab-panel :value="1" label="外观">
+        <t-space direction="vertical" class="py-16 w-full">
+          <div class="form-item px-12">
+            <label style="width: 50px">名称</label>
+            <t-input
+              class="w-full"
+              placeholder="名称"
+              v-model.number="data.pen.name"
+              @change="changeValue('name')"
+            />
+          </div>
+          <div class="form-item px-12" style="margin-top: -12px">
+            <label style="width: 50px">分组</label>
+            <t-select-input
+              v-model:inputValue="data.inputTag"
+              :value="data.pen.tags"
+              v-model:popupVisible="data.tagPopupVisible"
+              allow-input
+              placeholder="请输入或选择分组"
+              multiple
+              @tag-change="onChangeInputTag"
+              @focus="data.tagPopupVisible = true"
+              @blur="data.tagPopupVisible = false"
+              :tag-input-props="{ excessTagsDisplayType: 'scroll' }"
+            >
+              <template #panel>
+                <ul style="padding: 8px 12px">
+                  <li
+                    v-for="item in data.groups"
+                    :key="item"
+                    @click="onSelectTag(item)"
+                  >
+                    {{ item }}
+                  </li>
+                </ul>
+              </template>
+            </t-select-input>
+          </div>
+          <t-divider style="margin: -8px 0" />
+          <div class="form-item" style="margin-top: -12px">
+            <t-input
+              class="ml-4"
+              label="X"
+              placeholder="X"
+              v-model.number="data.rect.x"
+              style="width: 80px"
+              :format="decimalPlaces"
+              @change="changeValue('x')"
+            />
+            <t-icon name="link" class="hidden ml-4" />
+            <t-input
+              class="ml-4"
+              label="Y"
+              placeholder="Y"
+              v-model.number="data.rect.y"
+              style="width: 80px"
+              :format="decimalPlaces"
+              @change="changeValue('y')"
+            />
+            <t-input
+              class="ml-16"
+              v-model.number="data.pen.rotate"
+              placeholder="旋转"
+              style="width: 80px"
+              :format="decimalRound"
+              @change="changeValue('rotate')"
+            >
+              <template #prefix-icon>
+                <svg class="l-icon" aria-hidden="true">
+                  <use xlink:href="#l-rotate"></use>
+                </svg>
+              </template>
+            </t-input>
+          </div>
+          <div class="form-item hover-icons" style="margin-top: -12px">
+            <t-input
+              class="ml-4"
+              label="W"
+              v-model.number="data.rect.width"
+              placeholder="宽"
+              min="1"
+              style="width: 80px"
+              :format="decimalPlaces"
+              @change="changeValue('width')"
+            />
+            <t-tooltip v-if="data.pen.ratio" content="固定比例" placement="top">
+              <t-icon
+                name="link"
+                class="ml-4 hover"
+                @click="data.pen.ratio = !data.pen.ratio"
+              />
+            </t-tooltip>
+            <t-tooltip v-else content="不固定比例" placement="top">
+              <t-icon
+                name="link-unlink"
+                class="ml-4 hover icon"
+                @click="data.pen.ratio = !data.pen.ratio"
+              />
+            </t-tooltip>
+
+            <t-input
+              class="ml-4"
+              label="H"
+              placeholder="高"
+              v-model.number="data.rect.height"
+              min="1"
+              style="width: 80px"
+              :format="decimalPlaces"
+              @change="changeValue('height')"
+            />
+
+            <t-input
+              class="ml-16"
+              v-model.number="data.pen.borderRadius"
+              placeholder="圆角"
+              style="width: 80px"
+              @change="changeValue('borderRadius')"
+            >
+              <template #prefix-icon>
+                <svg class="l-icon" aria-hidden="true">
+                  <use xlink:href="#l-border-radius"></use>
+                </svg>
+              </template>
+            </t-input>
+          </div>
+
+          <t-divider style="margin: -8px 0" />
+          <div class="form-item px-16" style="margin-top: -12px">
+            <label>不透明度</label>
+            <t-slider
+              v-model="data.pen.globalAlpha"
+              :min="0"
+              :max="1"
+              :step="0.01"
+              @change="changeValue('globalAlpha')"
+            />
+            <span class="ml-16" style="width: 50px; line-height: 30px">
+              {{ data.pen.globalAlpha }}
+            </span>
+          </div>
+
+          <t-collapse
+            :defaultValue="['1', '2', '3', '4']"
+            expandIconPlacement="right"
+            :borderless="true"
+          >
+            <t-collapse-panel
+              v-if="data.pen.props.look !== false"
+              value="1"
+              header="外观"
+            >
+              <t-space direction="vertical" size="small" class="w-full">
+                <div class="form-item">
+                  <t-color-picker
+                    class="simple mt-8 mr-4"
+                    format="CSS"
+                    :enable-alpha="true"
+                    :color-modes="['monochrome']"
+                    :show-primary-color-preview="false"
+                    :clearable="true"
+                    v-model="data.pen.color"
+                    @change="changeValue('color')"
+                  />
+                  <label style="width: 64px">前景颜色</label>
+
+                  <t-color-picker
+                    class="simple mt-8 mr-4"
+                    format="CSS"
+                    :color-modes="['monochrome']"
+                    :show-primary-color-preview="false"
+                    v-model="data.pen.hoverColor"
+                    @change="changeValue('hoverColor')"
+                  />
+                  <label style="width: 64px">悬停颜色</label>
+
+                  <t-color-picker
+                    class="simple mt-8 mr-4"
+                    format="CSS"
+                    :color-modes="['monochrome']"
+                    :show-primary-color-preview="false"
+                    v-model="data.pen.activeColor"
+                    @change="changeValue('activeColor')"
+                  />
+                  <label style="width: 64px">选中颜色</label>
+                </div>
+
+                <div class="form-item">
+                  <label style="width: 32px">线条 </label>
+                  <t-select
+                    v-model="data.pen.dash"
+                    placeholder="线条样式"
+                    @change="changeValue('dash')"
+                    style="width: 80px"
+                  >
+                    <template #valueDisplay="{ value }">
+                      <svg
+                        xmlns="http://www.w3.org/2000/svg"
+                        version="1.1"
+                        style="width: 100%; height: 20px"
+                      >
+                        <g fill="none" stroke="var(--color)" stroke-width="1">
+                          <path v-if="value === 0" d="M0 9 l85 0" />
+                          <path
+                            v-else-if="value === 1"
+                            stroke-dasharray="5 5"
+                            d="M0 9 l85 0"
+                          />
+                          <path
+                            v-else-if="value === 2"
+                            stroke-dasharray="10 10"
+                            d="M0 9 l85 0"
+                          />
+                          <path
+                            v-else-if="value === 3"
+                            stroke-dasharray="10 10 2 10"
+                            d="M0 9 l85 0"
+                          />
+                        </g>
+                      </svg>
+                    </template>
+                    <t-option :key="0" :value="0">
+                      <svg
+                        xmlns="http://www.w3.org/2000/svg"
+                        version="1.1"
+                        style="width: 80px; height: 14px"
+                      >
+                        <g fill="none" stroke="var(--color)" stroke-width="1">
+                          <path d="M0 9 l85 0" />
+                        </g>
+                      </svg>
+                    </t-option>
+                    <t-option :key="1" :value="1">
+                      <svg
+                        xmlns="http://www.w3.org/2000/svg"
+                        version="1.1"
+                        style="width: 80px; height: 14px"
+                      >
+                        <g fill="none" stroke="var(--color)" stroke-width="1">
+                          <path stroke-dasharray="5 5" d="M0 9 l85 0" />
+                        </g>
+                      </svg>
+                    </t-option>
+                    <t-option :key="2" :value="2">
+                      <svg
+                        xmlns="http://www.w3.org/2000/svg"
+                        version="1.1"
+                        style="width: 80px; height: 14px"
+                      >
+                        <g fill="none" stroke="var(--color)" stroke-width="1">
+                          <path stroke-dasharray="10 10" d="M0 9 l85 0" />
+                        </g>
+                      </svg>
+                    </t-option>
+                    <t-option :key="3" :value="3">
+                      <svg
+                        xmlns="http://www.w3.org/2000/svg"
+                        version="1.1"
+                        style="width: 80px; height: 14px"
+                      >
+                        <g fill="none" stroke="var(--color)" stroke-width="1">
+                          <path stroke-dasharray="10 10 2 10" d="M0 9 l85 0" />
+                        </g>
+                      </svg>
+                    </t-option>
+                  </t-select>
+                  <t-input-number
+                    theme="normal"
+                    placeholder="线条宽度"
+                    v-model="data.pen.lineWidth"
+                    :min="1"
+                    :decimalPlaces="0"
+                    @change="changeValue('lineWidth')"
+                    class="ml-4"
+                    style="width: 40px"
+                  />
+                  <t-tooltip content="线条渐变" placement="top">
+                    <div class="flex middle ml-8">
+                      <t-checkbox
+                        v-model="data.pen.strokeType"
+                        @change="changeValue('strokeType')"
+                        style="width: 22px"
+                      />
+
+                      <t-color-picker
+                        v-if="data.pen.strokeType"
+                        class="simple mr-4"
+                        format="CSS"
+                        :color-modes="['linear-gradient']"
+                        :show-primary-color-preview="false"
+                        :clearable="true"
+                        :enableAlpha="true"
+                        v-model="data.pen.lineGradientColors"
+                        @change="changeValue('lineGradientColors')"
+                        placeholder="无"
+                      />
+                    </div>
+                  </t-tooltip>
+                </div>
+                <div class="flex" style="margin-left: 40px">
+                  <div class="flex column middle">
+                    <t-radio-group
+                      size="small"
+                      v-model="data.pen.lineCap"
+                      default-value="butt"
+                      @change="changeValue('lineCap')"
+                    >
+                      <t-radio-button value="butt">
+                        <t-tooltip content="默认" placement="top">
+                          <svg class="l-icon" aria-hidden="true">
+                            <use xlink:href="#l-duandian1"></use>
+                          </svg>
+                        </t-tooltip>
+                      </t-radio-button>
+                      <t-radio-button value="round">
+                        <t-tooltip content="圆形" placement="top">
+                          <svg class="l-icon" aria-hidden="true">
+                            <use xlink:href="#l-duandian2"></use>
+                          </svg>
+                        </t-tooltip>
+                      </t-radio-button>
+                      <t-radio-button value="square">
+                        <t-tooltip content="方形" placement="top">
+                          <svg class="l-icon" aria-hidden="true">
+                            <use xlink:href="#l-duandian3"></use>
+                          </svg>
+                        </t-tooltip>
+                      </t-radio-button>
+                    </t-radio-group>
+                    <div class="mt-4" style="font-size: 12px">末端样式</div>
+                  </div>
+                  <div class="flex column middle ml-16">
+                    <t-radio-group
+                      size="small"
+                      v-model="data.pen.lineJoin"
+                      default-value="miter"
+                      @change="changeValue('lineJoin')"
+                    >
+                      <t-radio-button value="miter">
+                        <t-tooltip content="默认" placement="top">
+                          <svg class="l-icon" aria-hidden="true">
+                            <use xlink:href="#l-jiedian1"></use>
+                          </svg>
+                        </t-tooltip>
+                      </t-radio-button>
+                      <t-radio-button value="round">
+                        <t-tooltip content="圆形" placement="top">
+                          <svg class="l-icon" aria-hidden="true">
+                            <use xlink:href="#l-jiedian2"></use>
+                          </svg>
+                        </t-tooltip>
+                      </t-radio-button>
+                      <t-radio-button value="bevel">
+                        <t-tooltip content="斜角" placement="top">
+                          <svg class="l-icon" aria-hidden="true">
+                            <use xlink:href="#l-jiedian3"></use>
+                          </svg>
+                        </t-tooltip>
+                      </t-radio-button>
+                    </t-radio-group>
+                    <div class="mt-4" style="font-size: 12px">连接样式</div>
+                  </div>
+                </div>
+                <div class="form-item">
+                  <label style="width: 32px">背景</label>
+                  <div class="ml-8">
+                    <t-radio-group
+                      size="small"
+                      v-model="data.pen.bkType"
+                      :default-value="0"
+                      @change="changeValue('bkType')"
+                    >
+                      <t-radio-button :value="0"> 纯色 </t-radio-button>
+                      <t-radio-button :value="1"> 线性渐变 </t-radio-button>
+                      <t-radio-button :value="2"> 径向渐变 </t-radio-button>
+                    </t-radio-group>
+                    <div v-if="data.pen.bkType === 0" class="mt-8 -ml-8">
+                      <t-color-picker
+                        class="w-full"
+                        format="CSS"
+                        :color-modes="['monochrome']"
+                        :show-primary-color-preview="false"
+                        v-model="data.pen.background"
+                        @change="changeValue('background')"
+                      />
+                    </div>
+                    <div
+                      v-else-if="data.pen.bkType === 1"
+                      class="mt-8 -ml-8"
+                      style="width: 200px"
+                    >
+                      <t-color-picker
+                        class="w-full"
+                        format="CSS"
+                        :color-modes="['linear-gradient']"
+                        :show-primary-color-preview="false"
+                        v-model="data.pen.gradientColors"
+                        @change="changeValue('gradientColors')"
+                      />
+                    </div>
+                    <div
+                      v-else-if="data.pen.bkType === 2"
+                      class="mt-8 flex middle"
+                    >
+                      <t-color-picker
+                        class="simple"
+                        format="CSS"
+                        :color-modes="['linear-gradient']"
+                        :show-primary-color-preview="false"
+                        v-model="data.pen.gradientColors"
+                        @change="changeValue('gradientColors')"
+                      />
+
+                      <t-input-number
+                        theme="column"
+                        placeholder="渐变半径"
+                        v-model="data.pen.gradientRadius"
+                        :min="0"
+                        :max="1"
+                        :step="0.1"
+                        @change="changeValue('gradientRadius')"
+                        class="ml-8"
+                        style="width: 100px"
+                      />
+                    </div>
+                  </div>
+                </div>
+                <div class="form-item">
+                  <label style="width: 32px">阴影 </label>
+                  <div class="flex middle ml-8">
+                    <t-checkbox
+                      v-model="data.pen.shadow"
+                      @change="changeValue('shadow')"
+                      style="width: 22px"
+                    />
+                    <t-color-picker
+                      v-if="data.pen.shadow"
+                      class="simple"
+                      format="CSS"
+                      :color-modes="['monochrome']"
+                      :show-primary-color-preview="false"
+                      v-model="data.pen.shadowColor"
+                      @change="changeValue('shadowColor')"
+                    />
+                  </div>
+                </div>
+                <div class="form-item" v-if="data.pen.shadow">
+                  <label style="width: 28px"></label>
+                  <div class="flex" style="margin-top: -8px">
+                    <t-input
+                      class="ml-4"
+                      label="X"
+                      placeholder="0"
+                      v-model.number="data.pen.shadowOffsetX"
+                      style="width: 60px"
+                      @change="changeValue('x')"
+                      title="X偏移"
+                    />
+                    <t-input
+                      class="ml-4"
+                      label="Y"
+                      placeholder="0"
+                      v-model.number="data.pen.shadowOffsetY"
+                      style="width: 60px"
+                      @change="changeValue('shadowOffsetY')"
+                      title="Y偏移"
+                    />
+                    <t-input
+                      class="ml-4"
+                      label="模糊"
+                      placeholder="0"
+                      v-model.number="data.pen.shadowBlur"
+                      style="width: 64px"
+                      @change="changeValue('shadowBlur')"
+                      title="模糊大小"
+                    />
+                  </div>
+                </div>
+              </t-space>
+            </t-collapse-panel>
+            <t-collapse-panel
+              v-if="data.pen.props.text"
+              value="2"
+              header="文字"
+            >
+              <t-space direction="vertical" size="small" class="w-full">
+                <div class="form-item">
+                  <div class="flex middle" style="margin-left: -10px">
+                    <t-select-input
+                      :value="data.pen.fontFamily"
+                      :popup-visible="data.fontFamilyPopupVisible"
+                      placeholder="字体名"
+                      allow-input
+                      style="width: 170px"
+                      @change="changeValue('fontFamily')"
+                      @enter="changeValue('fontFamily')"
+                      @blur="changeValue('fontFamily')"
+                      @popup-visible-change="onFontPopupVisible"
+                      :popup-props="{
+                        overlayInnerStyle: { width: 'auto' },
+                      }"
+                    >
+                      <template #panel>
+                        <ul style="padding: 12px">
+                          <li
+                            v-for="item in fonts"
+                            :key="item"
+                            @click="onFontFamily(item)"
+                          >
+                            {{ item }}
+                          </li>
+                        </ul>
+                      </template>
+                      <template #suffixIcon>
+                        <t-icon name="chevron-down" />
+                      </template>
+                    </t-select-input>
+
+                    <t-input
+                      class="ml-8"
+                      placeholder="字体大小"
+                      v-model.number="data.pen.fontSize"
+                      style="width: 80px"
+                      :format="decimalRound"
+                      @change="changeValue('fontSize')"
+                    />
+                  </div>
+                </div>
+                <div class="flex middle">
+                  <t-radio-group
+                    size="small"
+                    v-model="data.pen.textAlign"
+                    default-value="center"
+                    @change="changeValue('textAlign')"
+                  >
+                    <t-radio-button value="left">
+                      <t-tooltip content="居左" placement="top">
+                        <t-icon name="format-vertical-align-left" />
+                      </t-tooltip>
+                    </t-radio-button>
+                    <t-radio-button value="center">
+                      <t-tooltip content="居中" placement="top">
+                        <t-icon name="format-vertical-align-center" />
+                      </t-tooltip>
+                    </t-radio-button>
+                    <t-radio-button value="right">
+                      <t-tooltip content="居右" placement="top">
+                        <t-icon name="format-vertical-align-right" />
+                      </t-tooltip>
+                    </t-radio-button>
+                  </t-radio-group>
+                  <t-radio-group
+                    class="ml-8"
+                    size="small"
+                    v-model="data.pen.textBaseline"
+                    default-value="top"
+                    @change="changeValue('textBaseline')"
+                  >
+                    <t-radio-button value="top">
+                      <t-tooltip content="顶部对齐" placement="top">
+                        <t-icon name="format-horizontal-align-top" />
+                      </t-tooltip>
+                    </t-radio-button>
+                    <t-radio-button value="middle">
+                      <t-tooltip content="垂直居中" placement="middle">
+                        <t-icon name="format-horizontal-align-center" />
+                      </t-tooltip>
+                    </t-radio-button>
+                    <t-radio-button value="bottom">
+                      <t-tooltip content="底部对齐" placement="top">
+                        <t-icon name="format-horizontal-align-bottom" />
+                      </t-tooltip>
+                    </t-radio-button>
+                  </t-radio-group>
+
+                  <t-button
+                    :class="{ active: data.pen.fontWeight === 'bold' }"
+                    class="ml-8 icon"
+                    shape="rectangle"
+                    variant="text"
+                    @click="
+                      data.pen.fontWeight === 'bold'
+                        ? (data.pen.fontWeight = 'normal')
+                        : (data.pen.fontWeight = 'bold');
+                      changeValue('fontWeight');
+                    "
+                  >
+                    B
+                  </t-button>
+
+                  <t-button
+                    :class="{ active: data.pen.fontStyle === 'italic' }"
+                    class="ml-4 icon"
+                    shape="rectangle"
+                    variant="text"
+                    @click="
+                      data.pen.fontStyle === 'italic'
+                        ? (data.pen.fontStyle = 'normal')
+                        : (data.pen.fontStyle = 'italic');
+                      changeValue('fontStyle');
+                    "
+                    style="font-style: italic; font-family: serif"
+                    >I</t-button
+                  >
+                </div>
+                <div class="form-item">
+                  <t-color-picker
+                    class="simple mt-8 mr-4"
+                    format="CSS"
+                    :enable-alpha="true"
+                    :color-modes="['monochrome']"
+                    :show-primary-color-preview="false"
+                    :clearable="true"
+                    v-model="data.pen.color"
+                    @change="changeValue('color')"
+                  />
+                  <label style="width: 44px">前景</label>
+                  <t-color-picker
+                    class="simple mt-8 mr-4"
+                    format="CSS"
+                    :color-modes="['monochrome']"
+                    :show-primary-color-preview="false"
+                    v-model="data.pen.background"
+                    @change="changeValue('background')"
+                  />
+                  <label style="width: 44px">背景</label>
+
+                  <t-color-picker
+                    class="simple mt-8 mr-4"
+                    format="CSS"
+                    :color-modes="['monochrome']"
+                    :show-primary-color-preview="false"
+                    v-model="data.pen.hoverColor"
+                    @change="changeValue('hoverColor')"
+                  />
+                  <label style="width: 44px">悬停</label>
+
+                  <t-color-picker
+                    class="simple mt-8 mr-4"
+                    format="CSS"
+                    :color-modes="['monochrome']"
+                    :show-primary-color-preview="false"
+                    v-model="data.pen.activeColor"
+                    @change="changeValue('activeColor')"
+                  />
+                  <label style="width: 44px">选中</label>
+                </div>
+                <div class="form-item">
+                  <t-checkbox
+                    v-model="data.pen.whiteSpace"
+                    @change="changeValue('whiteSpace')"
+                    style="width: 64px"
+                  >
+                    换行
+                  </t-checkbox>
+                  <t-checkbox
+                    v-model="data.pen.ellipsis"
+                    @change="changeValue('ellipsis')"
+                    style="width: 68px"
+                  >
+                    省略号
+                  </t-checkbox>
+                  <t-tooltip content="行高">
+                    <t-input
+                      placeholder="行高"
+                      v-model.number="data.pen.lineHeight"
+                      style="width: 40px"
+                      @change="changeValue('lineHeight')"
+                    />
+                  </t-tooltip>
+                  <t-tooltip content="显示时保留小数位数">
+                    <t-input
+                      class="ml-4"
+                      placeholder="小数"
+                      v-model.number="data.pen.keepDecimal"
+                      style="width: 60px"
+                      @change="changeValue('keepDecimal')"
+                    />
+                  </t-tooltip>
+                </div>
+                <div class="form-item" style="margin-top: -4px">
+                  <t-tooltip content="水平偏移">
+                    <t-input
+                      placeholder="X"
+                      v-model.number="data.pen.textLeft"
+                      style="width: 60px; margin-left: -8px"
+                      @change="changeValue('textLeft')"
+                    />
+                  </t-tooltip>
+                  <t-tooltip content="垂直偏移">
+                    <t-input
+                      class="ml-4"
+                      placeholder="Y"
+                      v-model.number="data.pen.textTop"
+                      style="width: 60px"
+                      @change="changeValue('textTop')"
+                    />
+                  </t-tooltip>
+                  <t-tooltip content="宽">
+                    <t-input
+                      class="ml-4"
+                      placeholder="宽"
+                      v-model.number="data.pen.textWidth"
+                      style="width: 60px"
+                      @change="changeValue('textWidth')"
+                    />
+                  </t-tooltip>
+                  <t-tooltip content="高">
+                    <t-input
+                      class="ml-4"
+                      placeholder="高"
+                      v-model.number="data.pen.textHeight"
+                      style="width: 60px"
+                      @change="changeValue('textHeight')"
+                    />
+                  </t-tooltip>
+                </div>
+                <div class="flex middle">
+                  <t-checkbox
+                    v-model="data.pen.disableInput"
+                    @change="changeValue('disableInput')"
+                    style="width: 64px"
+                  >
+                    只读
+                  </t-checkbox>
+
+                  <t-checkbox
+                    v-model="data.pen.hiddenText"
+                    @change="changeValue('hiddenText')"
+                    style="width: 90px"
+                  >
+                    隐藏文字
+                  </t-checkbox>
+                </div>
+              </t-space>
+            </t-collapse-panel>
+            <t-collapse-panel
+              v-if="data.pen.props.image"
+              value="3"
+              header="图片"
+            >
+              <t-space direction="vertical" size="small" class="w-full">
+                <div>
+                  <t-upload
+                    ref="uploadRef"
+                    v-model="data.images"
+                    action="/api/image/upload"
+                    theme="image"
+                    accept="image/*"
+                    :headers="headers"
+                    :data="updataData"
+                    draggable
+                    @success="fileSuccessed"
+                    @remove="fileRemoved"
+                  >
+                    <template #fileListDisplay>
+                      <a class="mr-4" @click="upload"> 点击上传 </a>
+                      / 拖拽图片到此区域
+                    </template>
+                  </t-upload>
+                </div>
+                <div class="form-item hover-icons" style="margin-left: -12px">
+                  <t-input
+                    class="ml-4"
+                    label="W"
+                    v-model.number="data.pen.iconWidth"
+                    placeholder="自适应"
+                    min="1"
+                    style="width: 80px"
+                    :format="decimalPlaces"
+                    @change="changeValue('iconWidth')"
+                  />
+                  <t-tooltip
+                    v-if="data.pen.imageRatio"
+                    content="固定比例"
+                    placement="top"
+                  >
+                    <t-icon
+                      name="link"
+                      class="ml-4 hover"
+                      @click="data.pen.imageRatio = !data.pen.imageRatio"
+                    />
+                  </t-tooltip>
+                  <t-tooltip v-else content="不固定比例" placement="top">
+                    <t-icon
+                      name="link-unlink"
+                      class="ml-4 hover icon"
+                      @click="data.pen.imageRatio = !data.pen.imageRatio"
+                    />
+                  </t-tooltip>
+
+                  <t-input
+                    class="ml-4"
+                    label="H"
+                    placeholder="自适应"
+                    v-model.number="data.pen.iconHeight"
+                    min="1"
+                    style="width: 80px"
+                    :format="decimalPlaces"
+                    @change="changeValue('iconHeight')"
+                  />
+
+                  <t-checkbox
+                    class="ml-8"
+                    v-model="data.pen.isBottom"
+                    @change="changeValue('isBottom')"
+                  >
+                    置底
+                  </t-checkbox>
+                </div>
+                <div class="flex">
+                  <label style="width: 30px; color: var(--color)">Url:</label>
+                  {{ data.pen.image }}
+                </div>
+              </t-space>
+            </t-collapse-panel>
+            <t-collapse-panel
+              v-if="data.pen.props.custom"
+              value="4"
+              header="属性"
+            >
+              <t-space direction="vertical" size="small" class="w-full">
+                <div v-for="item in data.pen.props.custom" class="form-item">
+                  <label>{{ item.label }}</label>
+                  <t-checkbox
+                    class="ml-8"
+                    v-if="item.type === 'bool'"
+                    v-model="data.pen[item.key]"
+                    @change="changeValue(item.key)"
+                  />
+                  <t-input
+                    class="w-full"
+                    v-else-if="item.type === 'number'"
+                    v-model.number="data.pen[item.key]"
+                    @change="changeValue(item.key)"
+                    :placeholder="item.placeholder"
+                  />
+                  <t-color-picker
+                    class="w-full"
+                    v-else-if="item.type === 'color'"
+                    format="CSS"
+                    :color-modes="['monochrome']"
+                    :show-primary-color-preview="false"
+                    v-model="data.pen[item.key]"
+                    @change="changeValue(item.key)"
+                    :placeholder="item.placeholder"
+                  />
+                  <t-select
+                    class="w-full"
+                    v-else-if="item.type === 'select'"
+                    size="small"
+                    :options="item.options"
+                    v-model="data.pen[item.key]"
+                    @change="changeValue(item.key)"
+                    :placeholder="item.placeholder"
+                  />
+                  <t-input
+                    class="w-full"
+                    v-else
+                    v-model="data.pen[item.key]"
+                    @change="changeValue(item.key)"
+                    :placeholder="item.placeholder"
+                  />
+                </div>
+              </t-space>
+            </t-collapse-panel>
+          </t-collapse>
+
+          <t-divider style="margin: -8px 0" />
+          <div class="form-item px-16" style="margin-top: -12px">
+            <t-checkbox
+              v-model="data.pen.flipX"
+              @change="changeValue('flipX')"
+              style="width: 90px"
+            >
+              水平翻转
+            </t-checkbox>
+            <t-checkbox
+              v-model="data.pen.flipY"
+              @change="changeValue('flipY')"
+              style="width: 90px"
+            >
+              垂直翻转
+            </t-checkbox>
+
+            <label style="width: 50px">锚点半径</label>
+            <input
+              class="ml-4"
+              v-model.number="data.pen.anchorRadius"
+              style="width: 20px"
+              @change="changeValue('anchorRadius')"
+              placeholder="4"
+            />
+          </div>
+          <t-divider style="margin: -8px 0" />
+          <div class="form-item px-16" style="margin-top: -12px">
+            <t-checkbox
+              v-model="data.pen.disableRotate"
+              @change="changeValue('disableRotate')"
+              style="width: 90px"
+            >
+              禁止旋转
+            </t-checkbox>
+            <t-checkbox
+              v-model="data.pen.disableSize"
+              @change="changeValue('disableSize')"
+              style="width: 90px"
+            >
+              禁止缩放
+            </t-checkbox>
+            <t-checkbox
+              v-model="data.pen.disableAnchor"
+              @change="changeValue('disableAnchor')"
+              style="width: 90px"
+            >
+              禁用锚点
+            </t-checkbox>
+          </div>
+          <t-divider style="margin: -8px 0" />
+          <div class="form-item px-16" style="margin-top: -12px">
+            <label style="width: 60px">鼠标提示</label>
+            <t-button
+              shape="square"
+              variant="outline"
+              style="width: 24px"
+              @click="showTooltip"
+            >
+              <t-icon name="ellipsis" slot="icon"
+            /></t-button>
+          </div>
+
+          <t-dialog
+            v-if="tooltipDialog.show"
+            :visible="true"
+            header="鼠标提示"
+            @confirm="onOkTooltip"
+            @close="tooltipDialog.show = false"
+            :width="700"
+          >
+            <t-radio-group v-model="tooltipDialog.type">
+              <t-radio value="1">文字</t-radio>
+              <t-radio value="2">函数</t-radio>
+            </t-radio-group>
+
+            <div class="py-8">
+              <CodeEditor
+                v-show="tooltipDialog.type == 1"
+                v-model="tooltipDialog.title"
+                style="height: 300px"
+              />
+              <div v-show="tooltipDialog.type == 2">
+                <div>function tooltip(pen) {</div>
+                <CodeEditor
+                  v-model="tooltipDialog.titleFnJs"
+                  class="mt-4"
+                  style="height: 248px"
+                />
+                <div class="mt-4">}</div>
+              </div>
+            </div>
+            <div class="gray" style="font-size: 12px">支持Markdown格式</div>
+          </t-dialog>
+          <t-space />
+        </t-space>
+      </t-tab-panel>
+      <t-tab-panel :value="2" label="动画">
+        <PenAnimates :pen="data.pen" />
+      </t-tab-panel>
+      <t-tab-panel :value="3" label="数据">
+        <PenDatas :pen="data.pen" />
+      </t-tab-panel>
+      <t-tab-panel :value="4" label="交互">
+        <PenEvents :pen="data.pen" />
+      </t-tab-panel>
+    </t-tabs>
+  </div>
 </template>
 
-<script lang="ts" setup></script>
+<script lang="ts" setup>
+import { onBeforeMount, onUnmounted, reactive, ref, watch } from 'vue';
+
+import CodeEditor from '@/views/components/common/CodeEditor.vue';
+import PenAnimates from './PenAnimates.vue';
+import PenDatas from './PenDatas.vue';
+import PenEvents from './PenEvents.vue';
+
+import { getCookie } from '@/services/cookie';
+import { useSelection } from '@/services/selections';
+import { fonts } from '@/services/common';
+import { updatePen } from './pen';
+
+const headers = {
+  Authorization: 'Bearer ' + (localStorage.token || getCookie('token') || ''),
+};
+const updataData = { directory: '/项目' };
+
+const uploadRef = ref();
+
+const data = reactive<any>({
+  tab: 1,
+  pen: {},
+  rect: {},
+});
+
+const { selections } = useSelection();
+
+const tooltipDialog = reactive<any>({
+  show: false,
+});
+
+onBeforeMount(() => {
+  const d = meta2d.store.data as any;
+  if (!d.groups) {
+    d.groups = [];
+  }
+  data.groups = d.groups;
+  initPenData();
+
+  meta2d.on('translatePens', getRect);
+  meta2d.on('resizePens', getRect);
+  meta2d.on('rotatePens', getRect);
+});
+
+function initPenData() {
+  data.pen = selections.pen;
+  if (!data.pen.props) {
+    data.pen.props = {};
+  }
+  if (!data.pen.globalAlpha) {
+    data.pen.globalAlpha = 1;
+  }
+  if (!data.pen.dash) {
+    data.pen.dash = 0;
+  }
+  if (!data.pen.props.text) {
+    if (data.pen.text || data.pen.name === 'text') {
+      data.pen.props.text = true;
+    }
+  }
+  if (!data.pen.props.image) {
+    if (data.pen.image || data.pen.name === 'image') {
+      data.pen.props.image = true;
+    }
+  }
+  if (data.pen.image) {
+    data.images = [
+      {
+        url: data.pen.image,
+      },
+    ];
+  }
+  if (!data.pen.tags) {
+    data.pen.tags = [];
+  }
+  if (data.pen.bkType == undefined) {
+    data.pen.bkType = 0;
+  }
+  if (!data.pen.animations) {
+    data.pen.animations = [];
+  }
+  data.pen.shadow = !!data.pen.shadowColor;
+
+  getRect();
+}
+
+const watcher = watch(() => selections.pen.id, initPenData);
+
+const getRect = () => {
+  data.rect = meta2d.getPenRect(data.pen);
+};
+
+const decimalPlaces = (val: number) => {
+  if (!val) {
+    return 0;
+  }
+  return Math.round(+val * 100) / 100;
+};
+
+const decimalRound = (val: number) => {
+  return Math.round(+val || 0);
+};
+
+const changeValue = (prop: string) => {
+  updatePen(data.pen, prop);
+};
+
+const onFontPopupVisible = (val: boolean) => {
+  data.fontFamilyPopupVisible = val;
+};
+
+const onFontFamily = (fontFamily: string) => {
+  data.pen.fontFamily = fontFamily;
+  data.fontFamilyPopupVisible = false;
+  changeValue('fontFamily');
+};
+
+const fileSuccessed = async (content: any) => {
+  meta2d.store.patchFlagsBackground = true;
+  meta2d.setBackgroundImage(content.response.url);
+  meta2d.store.patchFlagsBackground = true;
+  meta2d.render();
+};
+
+const fileRemoved = () => {
+  meta2d.setBackgroundImage('');
+  meta2d.store.patchFlagsBackground = true;
+  meta2d.render();
+  data.background = [];
+};
+
+const upload = () => {
+  uploadRef.value.triggerUpload();
+};
+
+const onSelectTag = (tag: string) => {
+  data.tagPopupVisible = false;
+  if (data.pen.tags.includes(tag)) {
+    return;
+  }
+  data.pen.tags.push(tag);
+  changeValue('tags');
+};
+
+const onChangeInputTag = (currentTags: any, context: any) => {
+  const { trigger, index, item } = context;
+  if (['tag-remove', 'backspace'].includes(trigger)) {
+    data.pen.tags.splice(index, 1);
+  }
+  if (trigger === 'enter') {
+    onSelectTag(item);
+    const d = meta2d.store.data as any;
+    if (!d.groups.includes(item)) {
+      d.groups.push(item);
+      data.groups = d.groups;
+    }
+    data.inputTag = '';
+  }
+  data.tagPopupVisible = false;
+};
+
+const showTooltip = () => {
+  tooltipDialog.title = data.pen.title || '';
+  tooltipDialog.titleFnJs =
+    data.pen.titleFnJs || '// 例如:return `${pen.name}<br/>${pen.text}`;';
+  tooltipDialog.type = data.pen.titleFnJs ? '2' : '1';
+
+  tooltipDialog.show = true;
+};
+
+const onOkTooltip = () => {
+  if (tooltipDialog.type === '1') {
+    data.pen.title = tooltipDialog.title;
+    data.pen.titleFnJs = '';
+  } else {
+    data.pen.title = '';
+    data.pen.titleFnJs = tooltipDialog.titleFnJs;
+  }
+  tooltipDialog.show = false;
+};
+
+onUnmounted(() => {
+  watcher();
+  meta2d.off('translatePens', getRect);
+  meta2d.off('resizePens', getRect);
+  meta2d.off('rotatePens', getRect);
+});
+</script>
 <style lang="postcss" scoped>
 .props {
 }

+ 916 - 2
src/views/components/PensProps.vue

@@ -1,9 +1,923 @@
 <template>
-  <div class="props"></div>
+  <div class="props">
+    <t-tabs v-model="data.tab">
+      <t-tab-panel :value="1" label="外观">
+        <t-space direction="vertical" class="py-16 w-full">
+          <div class="px-16 flex between">
+            <label>选中了{{ selections.pens.length }}个图元</label>
+            <div class="icons">
+              <t-tooltip
+                class="mr-4"
+                v-if="!data.locked"
+                content="可编辑"
+                placement="top"
+              >
+                <svg class="l-icon" aria-hidden="true" @click="lock(1)">
+                  <use xlink:href="#l-unlock"></use>
+                </svg>
+              </t-tooltip>
+              <t-tooltip
+                class="mr-4"
+                v-else-if="data.locked == 1"
+                content="禁止编辑"
+                placement="top"
+              >
+                <svg class="l-icon" aria-hidden="true" @click="lock(2)">
+                  <use xlink:href="#l-lock"></use>
+                </svg>
+              </t-tooltip>
+              <t-tooltip
+                class="mr-4"
+                v-else-if="data.locked == 2"
+                content="禁止编辑和移动"
+                placement="top"
+              >
+                <svg class="l-icon" aria-hidden="true" @click="lock(10)">
+                  <use xlink:href="#l-wufayidong"></use>
+                </svg>
+              </t-tooltip>
+              <t-tooltip
+                class="mr-4"
+                v-else-if="data.locked == 10"
+                content="禁止所有事件"
+                placement="top"
+              >
+                <svg class="l-icon" aria-hidden="true" @click="lock(0)">
+                  <use xlink:href="#l-jinyong"></use>
+                </svg>
+              </t-tooltip>
+              <t-icon
+                v-if="data.visible !== false"
+                name="browse"
+                @click="visible(false)"
+                class="ml-8"
+              />
+              <t-icon
+                v-else
+                name="browse-off"
+                @click="visible(true)"
+                class="ml-8"
+              />
+            </div>
+          </div>
+          <t-collapse
+            :defaultValue="['1', '2', '3', '4']"
+            expandIconPlacement="right"
+            :borderless="true"
+          >
+            <t-collapse-panel value="1" header="对齐">
+              <t-space direction="vertical" size="small" class="w-full">
+                <div style="color: var(--color); margin-bottom: 2px">
+                  区域对齐
+                </div>
+                <div class="icons">
+                  <t-tooltip
+                    v-for="item in aligns"
+                    :content="item.label"
+                    placement="top"
+                  >
+                    <svg
+                      class="l-icon btn"
+                      aria-hidden="true"
+                      @click="align(item.value)"
+                    >
+                      <use :xlink:href="item.icon"></use>
+                    </svg>
+                  </t-tooltip>
+                </div>
+              </t-space>
+              <t-divider
+                style="margin: 16px -16px 12px -16px; width: calc(100% + 32px)"
+              />
+              <t-space direction="vertical" size="small" class="w-full">
+                <div style="color: var(--color); margin-bottom: 2px">
+                  以最后选中图元对齐
+                </div>
+                <div class="icons">
+                  <t-tooltip
+                    v-for="item in aligns2"
+                    :content="item.label"
+                    placement="top"
+                  >
+                    <svg
+                      class="l-icon btn"
+                      aria-hidden="true"
+                      @click="align2(item.value)"
+                    >
+                      <use :xlink:href="item.icon"></use>
+                    </svg>
+                  </t-tooltip>
+                </div>
+              </t-space>
+            </t-collapse-panel>
+            <t-collapse-panel value="2" header="外观">
+              <t-space direction="vertical" size="small" class="w-full">
+                <div class="form-item">
+                  <t-color-picker
+                    class="simple mt-8 mr-4"
+                    format="CSS"
+                    :enable-alpha="true"
+                    :color-modes="['monochrome']"
+                    :show-primary-color-preview="false"
+                    :clearable="true"
+                    v-model="data.color"
+                    @change="changeValue('color')"
+                  />
+                  <label style="width: 64px">前景颜色</label>
+
+                  <t-color-picker
+                    class="simple mt-8 mr-4"
+                    format="CSS"
+                    :color-modes="['monochrome']"
+                    :show-primary-color-preview="false"
+                    v-model="data.hoverColor"
+                    @change="changeValue('hoverColor')"
+                  />
+                  <label style="width: 64px">悬停颜色</label>
+
+                  <t-color-picker
+                    class="simple mt-8 mr-4"
+                    format="CSS"
+                    :color-modes="['monochrome']"
+                    :show-primary-color-preview="false"
+                    v-model="data.activeColor"
+                    @change="changeValue('activeColor')"
+                  />
+                  <label style="width: 64px">选中颜色</label>
+                </div>
+
+                <div class="form-item">
+                  <label style="width: 32px">线条 </label>
+                  <t-select
+                    v-model="data.dash"
+                    placeholder="线条样式"
+                    @change="changeValue('dash')"
+                    style="width: 80px"
+                  >
+                    <template #valueDisplay="{ value }">
+                      <svg
+                        xmlns="http://www.w3.org/2000/svg"
+                        version="1.1"
+                        style="width: 100%; height: 20px"
+                      >
+                        <g fill="none" stroke="var(--color)" stroke-width="1">
+                          <path v-if="value === 0" d="M0 9 l85 0" />
+                          <path
+                            v-else-if="value === 1"
+                            stroke-dasharray="5 5"
+                            d="M0 9 l85 0"
+                          />
+                          <path
+                            v-else-if="value === 2"
+                            stroke-dasharray="10 10"
+                            d="M0 9 l85 0"
+                          />
+                          <path
+                            v-else-if="value === 3"
+                            stroke-dasharray="10 10 2 10"
+                            d="M0 9 l85 0"
+                          />
+                        </g>
+                      </svg>
+                    </template>
+                    <t-option :key="0" :value="0">
+                      <svg
+                        xmlns="http://www.w3.org/2000/svg"
+                        version="1.1"
+                        style="width: 80px; height: 14px"
+                      >
+                        <g fill="none" stroke="var(--color)" stroke-width="1">
+                          <path d="M0 9 l85 0" />
+                        </g>
+                      </svg>
+                    </t-option>
+                    <t-option :key="1" :value="1">
+                      <svg
+                        xmlns="http://www.w3.org/2000/svg"
+                        version="1.1"
+                        style="width: 80px; height: 14px"
+                      >
+                        <g fill="none" stroke="var(--color)" stroke-width="1">
+                          <path stroke-dasharray="5 5" d="M0 9 l85 0" />
+                        </g>
+                      </svg>
+                    </t-option>
+                    <t-option :key="2" :value="2">
+                      <svg
+                        xmlns="http://www.w3.org/2000/svg"
+                        version="1.1"
+                        style="width: 80px; height: 14px"
+                      >
+                        <g fill="none" stroke="var(--color)" stroke-width="1">
+                          <path stroke-dasharray="10 10" d="M0 9 l85 0" />
+                        </g>
+                      </svg>
+                    </t-option>
+                    <t-option :key="3" :value="3">
+                      <svg
+                        xmlns="http://www.w3.org/2000/svg"
+                        version="1.1"
+                        style="width: 80px; height: 14px"
+                      >
+                        <g fill="none" stroke="var(--color)" stroke-width="1">
+                          <path stroke-dasharray="10 10 2 10" d="M0 9 l85 0" />
+                        </g>
+                      </svg>
+                    </t-option>
+                  </t-select>
+                  <t-input-number
+                    theme="normal"
+                    placeholder="线条宽度"
+                    v-model="data.lineWidth"
+                    :min="1"
+                    :decimalPlaces="0"
+                    @change="changeValue('lineWidth')"
+                    class="ml-4"
+                    style="width: 40px"
+                  />
+                  <t-tooltip content="线条渐变" placement="top">
+                    <div class="flex middle ml-8">
+                      <t-checkbox
+                        v-model="data.strokeType"
+                        @change="changeValue('strokeType')"
+                        style="width: 22px"
+                      />
+
+                      <t-color-picker
+                        v-if="data.strokeType"
+                        class="simple mr-4"
+                        format="CSS"
+                        :color-modes="['linear-gradient']"
+                        :show-primary-color-preview="false"
+                        :clearable="true"
+                        :enableAlpha="true"
+                        v-model="data.lineGradientColors"
+                        @change="changeValue('lineGradientColors')"
+                        placeholder="无"
+                      />
+                    </div>
+                  </t-tooltip>
+                </div>
+                <div class="flex" style="margin-left: 40px">
+                  <div class="flex column middle">
+                    <t-radio-group
+                      size="small"
+                      v-model="data.lineCap"
+                      default-value="butt"
+                      @change="changeValue('lineCap')"
+                    >
+                      <t-radio-button value="butt">
+                        <t-tooltip content="默认" placement="top">
+                          <svg class="l-icon" aria-hidden="true">
+                            <use xlink:href="#l-duandian1"></use>
+                          </svg>
+                        </t-tooltip>
+                      </t-radio-button>
+                      <t-radio-button value="round">
+                        <t-tooltip content="圆形" placement="top">
+                          <svg class="l-icon" aria-hidden="true">
+                            <use xlink:href="#l-duandian2"></use>
+                          </svg>
+                        </t-tooltip>
+                      </t-radio-button>
+                      <t-radio-button value="square">
+                        <t-tooltip content="方形" placement="top">
+                          <svg class="l-icon" aria-hidden="true">
+                            <use xlink:href="#l-duandian3"></use>
+                          </svg>
+                        </t-tooltip>
+                      </t-radio-button>
+                    </t-radio-group>
+                    <div class="mt-4" style="font-size: 12px">末端样式</div>
+                  </div>
+                  <div class="flex column middle ml-16">
+                    <t-radio-group
+                      size="small"
+                      v-model="data.lineJoin"
+                      default-value="miter"
+                      @change="changeValue('lineJoin')"
+                    >
+                      <t-radio-button value="miter">
+                        <t-tooltip content="默认" placement="top">
+                          <svg class="l-icon" aria-hidden="true">
+                            <use xlink:href="#l-jiedian1"></use>
+                          </svg>
+                        </t-tooltip>
+                      </t-radio-button>
+                      <t-radio-button value="round">
+                        <t-tooltip content="圆形" placement="top">
+                          <svg class="l-icon" aria-hidden="true">
+                            <use xlink:href="#l-jiedian2"></use>
+                          </svg>
+                        </t-tooltip>
+                      </t-radio-button>
+                      <t-radio-button value="bevel">
+                        <t-tooltip content="斜角" placement="top">
+                          <svg class="l-icon" aria-hidden="true">
+                            <use xlink:href="#l-jiedian3"></use>
+                          </svg>
+                        </t-tooltip>
+                      </t-radio-button>
+                    </t-radio-group>
+                    <div class="mt-4" style="font-size: 12px">连接样式</div>
+                  </div>
+                </div>
+                <div class="form-item">
+                  <label style="width: 32px">背景</label>
+                  <div class="ml-8">
+                    <t-radio-group
+                      size="small"
+                      v-model="data.bkType"
+                      :default-value="0"
+                      @change="changeValue('bkType')"
+                    >
+                      <t-radio-button :value="0"> 纯色 </t-radio-button>
+                      <t-radio-button :value="1"> 线性渐变 </t-radio-button>
+                      <t-radio-button :value="2"> 径向渐变 </t-radio-button>
+                    </t-radio-group>
+                    <div v-if="data.bkType === 0" class="mt-8 -ml-8">
+                      <t-color-picker
+                        class="w-full"
+                        format="CSS"
+                        :color-modes="['monochrome']"
+                        :show-primary-color-preview="false"
+                        v-model="data.background"
+                        @change="changeValue('background')"
+                      />
+                    </div>
+                    <div
+                      v-else-if="data.bkType === 1"
+                      class="mt-8 -ml-8"
+                      style="width: 200px"
+                    >
+                      <t-color-picker
+                        class="w-full"
+                        format="CSS"
+                        :color-modes="['linear-gradient']"
+                        :show-primary-color-preview="false"
+                        v-model="data.gradientColors"
+                        @change="changeValue('gradientColors')"
+                      />
+                    </div>
+                    <div v-else-if="data.bkType === 2" class="mt-8 flex middle">
+                      <t-color-picker
+                        class="simple"
+                        format="CSS"
+                        :color-modes="['linear-gradient']"
+                        :show-primary-color-preview="false"
+                        v-model="data.gradientColors"
+                        @change="changeValue('gradientColors')"
+                      />
+
+                      <t-input-number
+                        theme="column"
+                        placeholder="渐变半径"
+                        v-model="data.gradientRadius"
+                        :min="0"
+                        :max="1"
+                        :step="0.1"
+                        @change="changeValue('gradientRadius')"
+                        class="ml-8"
+                        style="width: 100px"
+                      />
+                    </div>
+                  </div>
+                </div>
+                <div class="form-item">
+                  <label style="width: 32px">阴影 </label>
+                  <div class="flex middle ml-8">
+                    <t-checkbox
+                      v-model="data.shadow"
+                      @change="changeValue('shadow')"
+                      style="width: 22px"
+                    />
+                    <t-color-picker
+                      v-if="data.shadow"
+                      class="simple"
+                      format="CSS"
+                      :color-modes="['monochrome']"
+                      :show-primary-color-preview="false"
+                      v-model="data.shadowColor"
+                      @change="changeValue('shadowColor')"
+                    />
+                  </div>
+                </div>
+                <div class="form-item" v-if="data.shadow">
+                  <label style="width: 28px"></label>
+                  <div class="flex" style="margin-top: -8px">
+                    <t-input
+                      class="ml-4"
+                      label="X"
+                      placeholder="0"
+                      v-model.number="data.shadowOffsetX"
+                      style="width: 60px"
+                      @change="changeValue('x')"
+                      title="X偏移"
+                    />
+                    <t-input
+                      class="ml-4"
+                      label="Y"
+                      placeholder="0"
+                      v-model.number="data.shadowOffsetY"
+                      style="width: 60px"
+                      @change="changeValue('shadowOffsetY')"
+                      title="Y偏移"
+                    />
+                    <t-input
+                      class="ml-4"
+                      label="模糊"
+                      placeholder="0"
+                      v-model.number="data.shadowBlur"
+                      style="width: 64px"
+                      @change="changeValue('shadowBlur')"
+                      title="模糊大小"
+                    />
+                  </div>
+                </div>
+              </t-space>
+            </t-collapse-panel>
+            <t-collapse-panel value="3" header="文字">
+              <t-space direction="vertical" size="small" class="w-full">
+                <div class="form-item">
+                  <div class="flex middle" style="margin-left: -10px">
+                    <t-select-input
+                      :value="data.fontFamily"
+                      :popup-visible="data.fontFamilyPopupVisible"
+                      placeholder="字体名"
+                      allow-input
+                      style="width: 170px"
+                      @change="changeValue('fontFamily')"
+                      @enter="changeValue('fontFamily')"
+                      @blur="changeValue('fontFamily')"
+                      @popup-visible-change="onFontPopupVisible"
+                      :popup-props="{
+                        overlayInnerStyle: { width: 'auto' },
+                      }"
+                    >
+                      <template #panel>
+                        <ul style="padding: 12px">
+                          <li
+                            v-for="item in fonts"
+                            :key="item"
+                            @click="onFontFamily(item)"
+                          >
+                            {{ item }}
+                          </li>
+                        </ul>
+                      </template>
+                      <template #suffixIcon>
+                        <t-icon name="chevron-down" />
+                      </template>
+                    </t-select-input>
+
+                    <t-input
+                      class="ml-8"
+                      placeholder="字体大小"
+                      v-model.number="data.fontSize"
+                      style="width: 80px"
+                      :format="decimalRound"
+                      @change="changeValue('fontSize')"
+                    />
+                  </div>
+                </div>
+                <div class="flex middle">
+                  <t-radio-group
+                    size="small"
+                    v-model="data.textAlign"
+                    default-value="center"
+                    @change="changeValue('textAlign')"
+                  >
+                    <t-radio-button value="left">
+                      <t-tooltip content="居左" placement="top">
+                        <t-icon name="format-vertical-align-left" />
+                      </t-tooltip>
+                    </t-radio-button>
+                    <t-radio-button value="center">
+                      <t-tooltip content="居中" placement="top">
+                        <t-icon name="format-vertical-align-center" />
+                      </t-tooltip>
+                    </t-radio-button>
+                    <t-radio-button value="right">
+                      <t-tooltip content="居右" placement="top">
+                        <t-icon name="format-vertical-align-right" />
+                      </t-tooltip>
+                    </t-radio-button>
+                  </t-radio-group>
+                  <t-radio-group
+                    class="ml-8"
+                    size="small"
+                    v-model="data.textBaseline"
+                    default-value="top"
+                    @change="changeValue('textBaseline')"
+                  >
+                    <t-radio-button value="top">
+                      <t-tooltip content="顶部对齐" placement="top">
+                        <t-icon name="format-horizontal-align-top" />
+                      </t-tooltip>
+                    </t-radio-button>
+                    <t-radio-button value="middle">
+                      <t-tooltip content="垂直居中" placement="middle">
+                        <t-icon name="format-horizontal-align-center" />
+                      </t-tooltip>
+                    </t-radio-button>
+                    <t-radio-button value="bottom">
+                      <t-tooltip content="底部对齐" placement="top">
+                        <t-icon name="format-horizontal-align-bottom" />
+                      </t-tooltip>
+                    </t-radio-button>
+                  </t-radio-group>
+
+                  <t-button
+                    :class="{ active: data.fontWeight === 'bold' }"
+                    class="ml-8 icon"
+                    shape="rectangle"
+                    variant="text"
+                    @click="
+                      data.fontWeight === 'bold'
+                        ? (data.fontWeight = 'normal')
+                        : (data.fontWeight = 'bold');
+                      changeValue('fontWeight');
+                    "
+                  >
+                    B
+                  </t-button>
+
+                  <t-button
+                    :class="{ active: data.fontStyle === 'italic' }"
+                    class="ml-4 icon"
+                    shape="rectangle"
+                    variant="text"
+                    @click="
+                      data.fontStyle === 'italic'
+                        ? (data.fontStyle = 'normal')
+                        : (data.fontStyle = 'italic');
+                      changeValue('fontStyle');
+                    "
+                    style="font-style: italic; font-family: serif"
+                    >I</t-button
+                  >
+                </div>
+                <div class="form-item">
+                  <t-color-picker
+                    class="simple mt-8 mr-4"
+                    format="CSS"
+                    :enable-alpha="true"
+                    :color-modes="['monochrome']"
+                    :show-primary-color-preview="false"
+                    :clearable="true"
+                    v-model="data.color"
+                    @change="changeValue('color')"
+                  />
+                  <label style="width: 44px">前景</label>
+                  <t-color-picker
+                    class="simple mt-8 mr-4"
+                    format="CSS"
+                    :color-modes="['monochrome']"
+                    :show-primary-color-preview="false"
+                    v-model="data.background"
+                    @change="changeValue('background')"
+                  />
+                  <label style="width: 44px">背景</label>
+
+                  <t-color-picker
+                    class="simple mt-8 mr-4"
+                    format="CSS"
+                    :color-modes="['monochrome']"
+                    :show-primary-color-preview="false"
+                    v-model="data.hoverColor"
+                    @change="changeValue('hoverColor')"
+                  />
+                  <label style="width: 44px">悬停</label>
+
+                  <t-color-picker
+                    class="simple mt-8 mr-4"
+                    format="CSS"
+                    :color-modes="['monochrome']"
+                    :show-primary-color-preview="false"
+                    v-model="data.activeColor"
+                    @change="changeValue('activeColor')"
+                  />
+                  <label style="width: 44px">选中</label>
+                </div>
+                <div class="form-item">
+                  <t-checkbox
+                    v-model="data.whiteSpace"
+                    @change="changeValue('whiteSpace')"
+                    style="width: 64px"
+                  >
+                    换行
+                  </t-checkbox>
+                  <t-checkbox
+                    v-model="data.ellipsis"
+                    @change="changeValue('ellipsis')"
+                    style="width: 68px"
+                  >
+                    省略号
+                  </t-checkbox>
+                  <t-tooltip content="行高">
+                    <t-input
+                      placeholder="行高"
+                      v-model.number="data.lineHeight"
+                      style="width: 40px"
+                      @change="changeValue('lineHeight')"
+                    />
+                  </t-tooltip>
+                  <t-tooltip content="显示时保留小数位数">
+                    <t-input
+                      class="ml-4"
+                      placeholder="小数"
+                      v-model.number="data.keepDecimal"
+                      style="width: 60px"
+                      @change="changeValue('keepDecimal')"
+                    />
+                  </t-tooltip>
+                </div>
+                <div class="form-item" style="margin-top: -4px">
+                  <t-tooltip content="水平偏移">
+                    <t-input
+                      placeholder="X"
+                      v-model.number="data.textLeft"
+                      style="width: 60px; margin-left: -8px"
+                      @change="changeValue('textLeft')"
+                    />
+                  </t-tooltip>
+                  <t-tooltip content="垂直偏移">
+                    <t-input
+                      class="ml-4"
+                      placeholder="Y"
+                      v-model.number="data.textTop"
+                      style="width: 60px"
+                      @change="changeValue('textTop')"
+                    />
+                  </t-tooltip>
+                  <t-tooltip content="宽">
+                    <t-input
+                      class="ml-4"
+                      placeholder="宽"
+                      v-model.number="data.textWidth"
+                      style="width: 60px"
+                      @change="changeValue('textWidth')"
+                    />
+                  </t-tooltip>
+                  <t-tooltip content="高">
+                    <t-input
+                      class="ml-4"
+                      placeholder="高"
+                      v-model.number="data.textHeight"
+                      style="width: 60px"
+                      @change="changeValue('textHeight')"
+                    />
+                  </t-tooltip>
+                </div>
+                <div class="flex middle">
+                  <t-checkbox
+                    v-model="data.disableInput"
+                    @change="changeValue('disableInput')"
+                    style="width: 64px"
+                  >
+                    只读
+                  </t-checkbox>
+
+                  <t-checkbox
+                    v-model="data.hiddenText"
+                    @change="changeValue('hiddenText')"
+                    style="width: 90px"
+                  >
+                    隐藏文字
+                  </t-checkbox>
+                </div>
+              </t-space>
+            </t-collapse-panel>
+          </t-collapse>
+        </t-space>
+        <t-divider style="margin-top: -8px" />
+        <div class="form-item p-16">
+          <t-checkbox
+            v-model="data.flipX"
+            @change="changeValue('flipX')"
+            style="width: 90px"
+          >
+            水平翻转
+          </t-checkbox>
+          <t-checkbox
+            v-model="data.flipY"
+            @change="changeValue('flipY')"
+            style="width: 90px"
+          >
+            垂直翻转
+          </t-checkbox>
+
+          <label style="width: 50px">锚点半径</label>
+          <input
+            class="ml-4"
+            v-model.number="data.anchorRadius"
+            style="width: 20px"
+            @change="changeValue('anchorRadius')"
+            placeholder="4"
+          />
+        </div>
+        <t-divider />
+        <div class="form-item p-16" style="margin-bottom: 20px">
+          <t-checkbox
+            v-model="data.disableRotate"
+            @change="changeValue('disableRotate')"
+            style="width: 90px"
+          >
+            禁止旋转
+          </t-checkbox>
+          <t-checkbox
+            v-model="data.disableSize"
+            @change="changeValue('disableSize')"
+            style="width: 90px"
+          >
+            禁止缩放
+          </t-checkbox>
+          <t-checkbox
+            v-model="data.disableAnchor"
+            @change="changeValue('disableAnchor')"
+            style="width: 90px"
+          >
+            禁用锚点
+          </t-checkbox>
+        </div>
+      </t-tab-panel>
+    </t-tabs>
+  </div>
 </template>
 
-<script lang="ts" setup></script>
+<script lang="ts" setup>
+import { onBeforeMount, onUnmounted, reactive, ref } from 'vue';
+
+import { LockState, Pen } from '@meta2d/core';
+
+import { updatePen } from './pen';
+
+import { useSelection } from '@/services/selections';
+import { fonts, setChildrenVisible } from '@/services/common';
+
+const { selections } = useSelection();
+
+const data = reactive<any>({
+  tab: 1,
+  locked: 0,
+  lineWidth: 1,
+});
+
+const aligns = [
+  {
+    value: 'left',
+    label: '左对齐',
+    icon: '#l-align-left',
+  },
+  {
+    value: 'right',
+    label: '水平居中对齐',
+    icon: '#l-align-center',
+  },
+  {
+    value: 'right',
+    label: '右对齐',
+    icon: '#l-align-right',
+  },
+  {
+    value: 'top',
+    label: '顶部对齐',
+    icon: '#l-align-top',
+  },
+  {
+    value: 'right',
+    label: '垂直居中对齐',
+    icon: '#l-align-middle',
+  },
+  {
+    value: 'bottom',
+    label: '底部对齐',
+    icon: '#l-align-bottom',
+  },
+  {
+    value: 'h-distribute',
+    label: '水平等距',
+    icon: '#l-horizontal-between',
+  },
+  {
+    value: 'v-distribute',
+    label: '垂直等距',
+    icon: '#l-vertical-between',
+  },
+];
+
+const aligns2 = [
+  {
+    value: 'left',
+    label: '左对齐',
+    icon: '#l-align-left',
+  },
+  {
+    value: 'right',
+    label: '水平居中对齐',
+    icon: '#l-align-center',
+  },
+  {
+    value: 'right',
+    label: '右对齐',
+    icon: '#l-align-right',
+  },
+  {
+    value: 'top',
+    label: '顶部对齐',
+    icon: '#l-align-top',
+  },
+  {
+    value: 'right',
+    label: '垂直居中对齐',
+    icon: '#l-align-middle',
+  },
+  {
+    value: 'bottom',
+    label: '底部对齐',
+    icon: '#l-align-bottom',
+  },
+  {
+    value: 'same-size',
+    label: '相同大小',
+    icon: '#l-same-size',
+  },
+];
+
+onBeforeMount(() => {});
+
+const lock = (v: LockState) => {
+  data.locked = v;
+  for (const item of selections.pens) {
+    meta2d.setValue({
+      id: item.id,
+      locked: v,
+    });
+  }
+};
+
+const visible = (v: boolean) => {
+  data.visible = v;
+  for (const item of selections.pens) {
+    meta2d.setVisible(item as any, v);
+  }
+};
+
+const align = (align: string) => {
+  if (align === 'h-distribute') {
+    meta2d.spaceBetween(meta2d.store.active);
+  } else if (align === 'v-distribute') {
+    meta2d.spaceBetweenColumn(meta2d.store.active);
+  } else {
+    meta2d.alignNodes(align, meta2d.store.active);
+  }
+};
+
+const align2 = (align: string) => {
+  if (align === 'same-size') {
+    meta2d.beSameByLast(meta2d.store.active);
+  } else {
+    meta2d.alignNodesByLast(align, meta2d.store.active);
+  }
+};
+
+const changeValue = (prop: string) => {
+  for (const item of selections.pens) {
+    data.id = item.id;
+    updatePen(data, prop, false);
+  }
+  meta2d.render();
+};
+
+const onFontFamily = (fontFamily: string) => {
+  data.fontFamily = fontFamily;
+  data.fontFamilyPopupVisible = false;
+  changeValue('fontFamily');
+};
+
+const onFontPopupVisible = (val: boolean) => {
+  data.fontFamilyPopupVisible = val;
+};
+
+const decimalRound = (val: number) => {
+  return Math.round(+val || 0);
+};
+</script>
 <style lang="postcss" scoped>
 .props {
+  .icons {
+    display: flex;
+
+    svg:hover {
+      cursor: pointer;
+      color: var(--color-primary);
+    }
+
+    .btn {
+      font-size: 16px;
+      margin-right: 16px;
+      color: var(--color);
+    }
+  }
 }
 </style>

文件差异内容过多而无法显示
+ 1277 - 34
src/views/components/View.vue


+ 104 - 0
src/views/components/WechatPay.vue

@@ -0,0 +1,104 @@
+<template>
+  <div class="wechat-pay">
+    <div class="order">
+      <div>
+        <div>订单编号:{{ props.order.id }}</div>
+        <div>订单类型:{{ props.order.goods.type }}</div>
+      </div>
+      <div class="flex items-center">
+        应付金额:
+        <span style="font-size: 20px; color: #f5222d">
+          ¥{{ props.order.amount }}</span
+        >
+      </div>
+    </div>
+    <div class="wepay">
+      <h5>微信支付</h5>
+      <div class="flex center middle">
+        <div class="mr-20">
+          <img class="wepay-qrcode" :src="payQRCode" />
+          <div class="wepay-text">
+            <div>请使用微信扫描</div>
+            <div>二维码完成支付</div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { onBeforeMount, onUnmounted, ref } from 'vue';
+import axios from 'axios';
+import QRCode from 'qrcode';
+
+const props = defineProps<{
+  order: any;
+  codeUrl: string;
+}>();
+
+const emit = defineEmits(['success']);
+
+const payType = ref('wechat');
+
+const payQRCode = ref('');
+
+let timer: any;
+
+onBeforeMount(async () => {
+  payQRCode.value = await QRCode.toDataURL(props.codeUrl);
+
+  timer = setInterval(async () => {
+    const success = await getPayResult();
+    if (success) {
+      clearInterval(timer);
+      emit('success', success);
+    }
+  }, 5000);
+});
+
+onUnmounted(() => {
+  clearInterval(timer);
+});
+
+const getPayResult = async () => {
+  const result: { state: number } = await axios.post('/api/order/pay/state', {
+    id: props.order.id || props.order._id,
+  });
+  if (result && result.state) {
+    return true;
+  }
+};
+</script>
+
+<style lang="postcss" scoped>
+.wechat-pay {
+  .order {
+    padding: 10px 0;
+    line-height: 30px;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+  }
+
+  .pay-type {
+    background-color: #f7f8fa;
+  }
+
+  .wepay {
+    color: var(--color-title);
+    margin-top: 16px;
+  }
+
+  .wepay-qrcode {
+    width: 150px;
+    margin-top: 24px;
+  }
+  .wepay-text {
+    padding: 10px 40px;
+    line-height: 20px;
+    color: #ffffff;
+    font-size: 13px;
+  }
+}
+</style>

+ 163 - 0
src/views/components/common/CodeEditor.vue

@@ -0,0 +1,163 @@
+<template>
+  <div ref="dom" class="code-editor"></div>
+</template>
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref, watch } from 'vue';
+
+//按需引入
+import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
+import 'monaco-editor/esm/vs/editor/browser/widget/codeEditorWidget.js';
+import 'monaco-editor/esm/vs/editor/contrib/bracketMatching/browser/bracketMatching.js';
+import 'monaco-editor/esm/vs/editor/contrib/caretOperations/browser/caretOperations.js';
+import 'monaco-editor/esm/vs/editor/contrib/caretOperations/browser/transpose.js';
+import 'monaco-editor/esm/vs/editor/contrib/clipboard/browser/clipboard.js';
+import 'monaco-editor/esm/vs/editor/contrib/codeAction/browser/codeActionContributions.js';
+import 'monaco-editor/esm/vs/editor/contrib/copyPaste/browser/copyPasteContribution.js';
+import 'monaco-editor/esm/vs/editor/contrib/comment/browser/comment.js';
+import 'monaco-editor/esm/vs/editor/contrib/contextmenu/browser/contextmenu.js';
+import 'monaco-editor/esm/vs/editor/contrib/cursorUndo/browser/cursorUndo.js';
+import 'monaco-editor/esm/vs/editor/contrib/find/browser/findController.js';
+import 'monaco-editor/esm/vs/editor/contrib/folding/browser/folding.js';
+import 'monaco-editor/esm/vs/editor/contrib/format/browser/formatActions.js';
+import 'monaco-editor/esm/vs/editor/contrib/documentSymbols/browser/documentSymbols.js';
+import 'monaco-editor/esm/vs/editor/contrib/inlineCompletions/browser/inlineCompletions.contribution.js';
+import 'monaco-editor/esm/vs/editor/contrib/hover/browser/hover.js';
+import 'monaco-editor/esm/vs/editor/contrib/indentation/browser/indentation.js';
+import 'monaco-editor/esm/vs/editor/contrib/inlayHints/browser/inlayHintsContribution.js';
+import 'monaco-editor/esm/vs/editor/contrib/inPlaceReplace/browser/inPlaceReplace.js';
+import 'monaco-editor/esm/vs/editor/contrib/lineSelection/browser/lineSelection.js';
+import 'monaco-editor/esm/vs/editor/contrib/linesOperations/browser/linesOperations.js';
+import 'monaco-editor/esm/vs/editor/contrib/linkedEditing/browser/linkedEditing.js';
+import 'monaco-editor/esm/vs/editor/contrib/links/browser/links.js';
+import 'monaco-editor/esm/vs/editor/contrib/longLinesHelper/browser/longLinesHelper.js';
+import 'monaco-editor/esm/vs/editor/contrib/multicursor/browser/multicursor.js';
+import 'monaco-editor/esm/vs/editor/contrib/parameterHints/browser/parameterHints.js';
+import 'monaco-editor/esm/vs/editor/contrib/rename/browser/rename.js';
+import 'monaco-editor/esm/vs/editor/contrib/semanticTokens/browser/documentSemanticTokens.js';
+import 'monaco-editor/esm/vs/editor/contrib/semanticTokens/browser/viewportSemanticTokens.js';
+import 'monaco-editor/esm/vs/editor/contrib/smartSelect/browser/smartSelect.js';
+import 'monaco-editor/esm/vs/editor/contrib/snippet/browser/snippetController2.js';
+import 'monaco-editor/esm/vs/editor/contrib/stickyScroll/browser/stickyScrollContribution.js';
+import 'monaco-editor/esm/vs/editor/contrib/suggest/browser/suggestController.js';
+import 'monaco-editor/esm/vs/editor/contrib/suggest/browser/suggestInlineCompletions.js';
+import 'monaco-editor/esm/vs/editor/contrib/tokenization/browser/tokenization.js';
+import 'monaco-editor/esm/vs/editor/contrib/toggleTabFocusMode/browser/toggleTabFocusMode.js';
+import 'monaco-editor/esm/vs/editor/contrib/unicodeHighlighter/browser/unicodeHighlighter.js';
+import 'monaco-editor/esm/vs/editor/contrib/unusualLineTerminators/browser/unusualLineTerminators.js';
+import 'monaco-editor/esm/vs/editor/contrib/wordHighlighter/browser/wordHighlighter.js';
+import 'monaco-editor/esm/vs/editor/contrib/wordOperations/browser/wordOperations.js';
+import 'monaco-editor/esm/vs/editor/contrib/wordPartOperations/browser/wordPartOperations.js';
+
+import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
+import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
+import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';
+
+import 'monaco-editor/esm/vs/language/json/monaco.contribution';
+import 'monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution';
+import 'monaco-editor/esm/vs/basic-languages/markdown/markdown.contribution';
+
+const dom = ref<any>();
+
+const { modelValue, json, language, options } = defineProps<{
+  modelValue: any;
+  json?: boolean;
+  language?: string;
+  options?: any;
+}>();
+
+const emit = defineEmits(['update:modelValue', 'change']);
+
+self.MonacoEnvironment = {
+  getWorker(_: any, label: string) {
+    if (label === 'json') {
+      return new jsonWorker();
+    }
+    if (label === 'typescript' || label === 'javascript') {
+      return new tsWorker();
+    }
+    return new editorWorker();
+  },
+};
+
+let editor: monaco.editor.IStandaloneCodeEditor;
+
+onMounted(() => {
+  let text = '';
+  if (json) {
+    if (modelValue) {
+      text = JSON.stringify(modelValue);
+    }
+  } else {
+    text = modelValue;
+  }
+  editor = monaco.editor.create(dom.value, {
+    value: text,
+    automaticLayout: true,
+    minimap: { enabled: false },
+    language: language || 'javascript',
+    theme: 'vs-dark',
+    ...options,
+  });
+  editor.onDidChangeModelContent(() => {
+    const currenValue = editor.getValue();
+    if (json) {
+      let obj: any;
+      try {
+        obj = JSON.parse(currenValue);
+      } catch {}
+      emit('update:modelValue', obj);
+      emit('change', obj);
+    } else {
+      emit('update:modelValue', currenValue);
+      emit('change', currenValue);
+    }
+  });
+});
+
+watch(
+  () => modelValue,
+  (newValue) => {
+    if (editor) {
+      const value = editor.getValue();
+      if (newValue !== value) {
+        editor.setValue(newValue);
+      }
+    }
+  }
+);
+
+watch(
+  () => options,
+  (newValue) => {
+    editor.updateOptions(newValue);
+  },
+  { deep: true }
+);
+
+watch(
+  () => language,
+  (newValue) => {
+    monaco.editor.setModelLanguage(editor.getModel()!, newValue);
+  }
+);
+
+onUnmounted(() => {
+  editor?.dispose();
+});
+</script>
+<style lang="postcss" scoped>
+.code-editor {
+  border: 1px solid var(--color-sub-border);
+  width: 100%;
+  min-height: 160px;
+
+  :deep(.monaco-editor) {
+    --vscode-editorGutter-background: var(--color-background-editor);
+    --vscode-editor-background: var(--color-background-editor);
+
+    .view-overlays .current-line {
+      border: 1px solid var(--color-border-editor);
+    }
+  }
+}
+</style>

+ 46 - 0
src/views/components/pen.ts

@@ -0,0 +1,46 @@
+export const updatePen = (pen: any, prop: string, render = true) => {
+  const v: any = { id: pen.id };
+  const rect: any = meta2d.getPenRect(pen);
+
+  v[prop] = pen[prop];
+  if (prop === 'x') {
+    v.x = rect.x;
+  } else if (prop === 'y') {
+    v.y = rect.y;
+  } else if (prop === 'width') {
+    v.height = (rect.width / pen.width) * pen.height;
+  } else if (prop === 'height') {
+    v.width = (rect.height / pen.height) * pen.width;
+  } else if (prop === 'shadow') {
+    if (v[prop]) {
+      !v.shadowOffsetX && (v.shadowOffsetX = 0);
+      !v.shadowOffsetY && (v.shadowOffsetY = 0);
+      !v.shadowBlur && (v.shadowBlur = 0);
+    } else {
+      v.shadowColor = '';
+    }
+  } else if (prop === 'lineGradientColors') {
+    //@ts-ignore
+    if (meta2d.store.active[0].name === 'line') {
+      //@ts-ignore
+      meta2d.store.active[0].calculative.gradientColorStop = null;
+    } else {
+      //@ts-ignore
+      meta2d.store.active[0].calculative.lineGradient = null;
+    }
+    //不同模式切换不同的系统配色
+  } else if (prop === 'titleFnJs') {
+    v.titleFn = null;
+  } else if (prop === 'dash') {
+    v.lineDash = lineDashObj[v[prop]];
+  }
+  meta2d.setValue(v, { render });
+};
+
+export const lineDashObj = [
+  undefined,
+  [5, 5],
+  [10, 10],
+  [10, 10, 2, 10],
+  [1, 16],
+];

+ 0 - 1
tsconfig.json

@@ -5,7 +5,6 @@
     "useDefineForClassFields": true,
     "module": "esnext",
     "moduleResolution": "node",
-    "strict": true,
     "jsx": "preserve",
     "sourceMap": true,
     "resolveJsonModule": true,

+ 68 - 11
vite.config.ts

@@ -1,25 +1,82 @@
-import { defineConfig } from "vite";
-import vue from "@vitejs/plugin-vue";
-import vueJsx from "@vitejs/plugin-vue-jsx";
-import * as path from "path";
+import { defineConfig, Plugin, ViteDevServer } from 'vite';
+import vue from '@vitejs/plugin-vue';
+import vueJsx from '@vitejs/plugin-vue-jsx';
+import * as path from 'path';
+import * as fs from 'fs';
+import formidable from 'formidable';
 
 // https://vitejs.dev/config/
 export default defineConfig({
-  plugins: [vue(), vueJsx()],
+  plugins: [vue(), vueJsx(), fileList()],
   resolve: {
     alias: {
-      "@": path.resolve(__dirname, "./src/"),
-      "@meta2d": path.resolve(__dirname, "../meta2d.js/packages"),
+      '@': path.resolve(__dirname, './src/'),
+      '@meta2d': path.resolve(__dirname, '../meta2d.js/packages'),
     },
   },
   build: {
-    outDir: "v",
+    outDir: 'v',
+    rollupOptions: {
+      output: {
+        manualChunks: {
+          monaco: [`monaco-editor`],
+        },
+      },
+    },
   },
   server: {
     proxy: {
-      "/image": "https://2d.le5le.com/",
-      "/file": "https://2d.le5le.com/",
-      "/api": "https://2d.le5le.com/",
+      '/image': 'https://2d.le5le.com/',
+      '/file': 'https://2d.le5le.com/',
+      '/api': 'https://2d.le5le.com/',
     },
   },
 });
+
+function fileList(): Plugin {
+  return {
+    name: 'vite-plugin-svg-png-files',
+    configureServer(server: ViteDevServer) {
+      server.middlewares.use((req, res, next) => {
+        const url = req.url as string;
+
+        if (
+          (url.startsWith('/svg/') || url.startsWith('/png/')) &&
+          url.endsWith('/')
+        ) {
+          const pwd = decodeURI(path.join(__dirname, 'public', url));
+          const files = fs.readdirSync(pwd, {
+            withFileTypes: true,
+          });
+
+          const list: {
+            name: string;
+            type?: string;
+          }[] = [];
+          for (const item of files) {
+            if (item.isDirectory()) {
+              list.push({ name: item.name, type: 'directory' });
+            } else {
+              list.push({ name: item.name });
+            }
+          }
+          res.end(JSON.stringify(list));
+        } else if (url === '/img' && req.method === 'POST') {
+          const form = formidable({
+            uploadDir: decodeURI(path.join(__dirname, 'public', '/img')),
+            keepExtensions: true,
+          });
+          form.parse(req, (err, fields, files) => {
+            if (!err) {
+              res.end(
+                JSON.stringify({ url: '/img/' + files.file.newFilename })
+              );
+            }
+          });
+        } else {
+          next();
+        }
+      });
+    },
+  };
+}

部分文件因为文件数量过多而无法显示