Ver Fonte

Merge branch 'main' of https://github.com/le5le-com/visualization-design

ananzhusen há 1 ano atrás
pai
commit
3cb2fd2e81

+ 2 - 2
package.json

@@ -3,7 +3,7 @@
   "private": true,
   "version": "0.0.1",
   "scripts": {
-    "start": "vite --open --port 7000",
+    "start": "vite --host --open --port 7000",
     "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/",
@@ -19,7 +19,7 @@
     "localforage": "^1.10.0",
     "monaco-editor": "^0.38.0",
     "qrcode": "^1.5.3",
-    "tdesign-vue-next": "^1.3.5",
+    "tdesign-vue-next": "^1.3.7",
     "vue": "^3.3.2",
     "vue-router": "^4.2.0"
   },

+ 4 - 4
pnpm-lock.yaml

@@ -33,8 +33,8 @@ dependencies:
     specifier: ^1.5.3
     version: 1.5.3
   tdesign-vue-next:
-    specifier: ^1.3.5
-    version: 1.3.5(vue@3.3.4)
+    specifier: ^1.3.7
+    version: 1.3.7(vue@3.3.4)
   vue:
     specifier: ^3.3.2
     version: 3.3.4
@@ -1947,8 +1947,8 @@ packages:
       vue: 3.3.4
     dev: false
 
-  /tdesign-vue-next@1.3.5(vue@3.3.4):
-    resolution: {integrity: sha512-kAhWq/Rzke9HmEpvAWTXiJ38cSPd+3wgyzjQ2CbWg27MqLZD3SE+ZZcWRBANrh6ilTV40ewUiUjgwGEwGYIxVA==}
+  /tdesign-vue-next@1.3.7(vue@3.3.4):
+    resolution: {integrity: sha512-AMo4TQmlfqDHq/pvvMvzthEG37yCZrbdm/2XJanYFs+2g1foO2a1NyKSFccm0SdslaWFJj3hQFYsBBr/IlMUUg==}
     peerDependencies:
       vue: '>=3.1.0'
     dependencies:

Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
public/material/草地.svg


+ 24 - 1
src/services/common.ts

@@ -114,7 +114,7 @@ export const save = async (
   }
   !data.version && (data.version = baseVer);
   if (!data.folder) {
-    data.folder = '大屏';
+    data.folder = '';
   }
   if (type === SaveType.SaveAs) {
     // 另存为一定走 新增 ,由于后端 未控制 userId 等属性,清空一下
@@ -436,3 +436,26 @@ export const delAttrs = [
   'star',
   'recommend',
 ];
+
+export const typeOptions = [
+  {
+    label: '字符串',
+    value: 'string',
+  },
+  {
+    label: '数字',
+    value: 'number',
+  },
+  {
+    label: '布尔',
+    value: 'bool',
+  },
+  {
+    label: '对象',
+    value: 'object',
+  },
+  {
+    label: '数组',
+    value: 'array',
+  },
+];

+ 0 - 48
src/services/icons.ts

@@ -1,48 +0,0 @@
-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,
-  };
-}

+ 56 - 40
src/services/png.ts

@@ -1,51 +1,26 @@
 import axios from '@/http';
+import { parseSvg } from '@meta2d/svg';
 import { cdn } from './api';
 
-const market = import.meta.env.VITE_BASEURL;
+const baseurl = 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 function filename(str: string) {
+  const i = str.lastIndexOf('.');
+  return str.substring(0, i);
 }
 
-export async function getFolders(folderName: string) {
-  const ret = (await axios.get(folderName)) as any[];
+export async function getFolders(name: string, svg?: boolean) {
+  const folder = baseurl + name + '/';
+  const ret = (await axios.get(folder)) 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[];
+      const files = (await axios.get(folder + c.name + '/')) as any[];
       return {
+        folder,
         name: globalThis.folderJson
           ? globalThis.folderJson[c.name]
             ? globalThis.folderJson[c.name]
@@ -55,14 +30,55 @@ export async function getFolders(folderName: string) {
         show: true,
         list: [],
         count: files.length,
-        // 用于区别 png 与 svg 文件夹
-        svgFolder: folderName === normalFolder ? true : false,
+        svg,
       };
     })
   );
 }
 
-export function filename(str: string) {
-  const i = str.lastIndexOf('.');
-  return str.substring(0, i);
+export async function getFiles(folder: string) {
+  if (folder[folder.length - 1] !== '/') {
+    folder += '/';
+  }
+  const files = (await axios.get(folder)) 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 + folder + f.name,
+    };
+  });
+}
+
+export async function getIcons(folder: string) {
+  const files = (await axios.get(folder)) as any[];
+  return await Promise.all(files.map((f) => svgToPens(f, folder)));
+}
+
+async function svgToPens(f: any, folder: string) {
+  const _name = filename(f.name);
+  const name = globalThis.fileJson
+    ? globalThis.fileJson[_name]
+      ? globalThis.fileJson[_name]
+      : _name
+    : _name;
+  const image = cdn + folder + 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,
+    componentDatas: data,
+    svg: _svgDom,
+    component: true,
+  };
 }

+ 74 - 1
src/styles/tdesign.css

@@ -420,7 +420,7 @@
 }
 
 .t-popup[data-popper-placement^='top'] .t-popup__arrow::before {
-  top: -8px;
+  top: -3px;
   border-top-left-radius: 0;
 }
 
@@ -464,6 +464,9 @@
 
   .t-dialog__header {
     font-size: 14px;
+    .t-icon {
+      width: 18px;
+    }
   }
 
   .t-dialog__body {
@@ -516,3 +519,73 @@
     background: none;
   }
 }
+
+.context-menu {
+  height: fit-content !important;
+  position: absolute;
+  border-radius: 4px;
+  background-color: var(--color-background-popup);
+  user-select: none;
+  z-index: 100;
+
+  .t-menu {
+    padding: 8px 4px;
+
+    .t-menu__item {
+      color: var(--color);
+      line-height: 32px;
+      padding: 0 16px;
+      margin: 0;
+
+      &:hover:not(.t-is-active):not(.t-is-opened):not(.t-is-disabled) {
+        background-color: var(--color-background-popup-hover);
+      }
+
+      &.t-is-disabled {
+        color: var(--color-gray);
+      }
+
+      .t-menu__content {
+        width: 100%;
+      }
+
+      & > div {
+        display: none;
+      }
+    }
+
+    .t-divider {
+      margin: 4px 0;
+      width: 100%;
+      border-top: 1px solid var(--color-border-input);
+    }
+  }
+}
+
+.t-menu__popup {
+  ul {
+    padding: 8px 4px;
+  }
+  .t-menu__item {
+    line-height: 32px;
+    height: 32px;
+    margin: 0;
+
+    & > div {
+      display: none;
+    }
+
+    .t-menu__content {
+      color: var(--color);
+      padding: 4px 0;
+    }
+
+    &:hover:not(.t-is-active):not(.t-is-opened):not(.t-is-disabled) {
+      background-color: var(--color-background-popup-hover);
+    }
+
+    &.t-is-disabled {
+      color: var(--color-gray);
+    }
+  }
+}

+ 1 - 1
src/views/Index.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="page-app">
+  <div class="page-app" @contextmenu.prevent>
     <Header />
 
     <div class="design-body">

+ 131 - 352
src/views/components/ContextMenu.vue

@@ -1,389 +1,168 @@
 <template>
-  <div class="l-context-menu t-dropdown__menu">
+  <t-menu class="context-menu" @change="onMenu">
     <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>
+      <t-menu-item value="addAnchorHand">
+        <div class="flex">添加手柄 <span class="flex-grow"></span> H</div>
+      </t-menu-item>
+      <t-menu-item value="removeAnchorHand">
+        <div class="flex">删除手柄 <span class="flex-grow"></span> D</div>
+      </t-menu-item>
+      <t-menu-item value="toggleAnchorHand">
+        <div class="flex">切换手柄 <span class="flex-grow"></span> Shift</div>
+      </t-menu-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-menu-item :disabled="!selections.mode" value="top"> 置顶 </t-menu-item>
+      <t-menu-item :disabled="!selections.mode" value="bottom">
+        置底
+      </t-menu-item>
+      <t-menu-item :disabled="!selections.mode" value="up">
+        上一个图层
+      </t-menu-item>
+      <t-menu-item :disabled="!selections.mode" value="down">
+        下一个图层
+      </t-menu-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 v-if="selections.mode === SelectionMode.Pens">
+        <t-menu-item value="group"> 组合 </t-menu-item>
+        <t-menu-item value="states"> 组合为状态 </t-menu-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-menu-item v-if="hasChildren" value="unGroup"> 取消组合 </t-menu-item>
+      <t-menu-item :disabled="!selections.mode" value="lock">
+        {{ locked ? '解锁' : '锁定' }}
+      </t-menu-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>
+      <t-menu-item :disabled="!selections.mode" value="del"> 删除 </t-menu-item>
+      <template v-if="isNameLine">
+        <t-menu-item value="changeType">
+          {{ isLine ? '变成节点' : '变成连线' }}
+        </t-menu-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-menu-item :disabled="cantUndo" value="undo">
+        <div class="flex">撤销<span class="flex-grow"></span>Ctrl + Z</div>
+      </t-menu-item>
+      <t-menu-item :disabled="cantRedo" value="redo">
+        <div class="flex">恢复<span class="flex-grow"></span>Shift + Z</div>
+      </t-menu-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>
+      <t-menu-item :disabled="!selections.mode" value="cut">
+        <div class="flex">剪切<span class="flex-grow"></span>Ctrl + X</div>
+      </t-menu-item>
+      <t-menu-item :disabled="!selections.mode" value="copy">
+        <div class="flex">复制<span class="flex-grow"></span>Ctrl + C</div>
+      </t-menu-item>
+      <t-menu-item value="paste">
+        <div class="flex">粘贴<span class="flex-grow"></span>Ctrl + V</div>
+      </t-menu-item>
     </template>
-  </div>
+  </t-menu>
 </template>
 
 <script setup lang="ts">
-import { ref } from 'vue';
-import { LockState, Pen, PenType, Meta2d } from '@meta2d/core';
+import { onMounted, ref } from 'vue';
+import { LockState, Pen, PenType } from '@meta2d/core';
+import { useSelection, SelectionMode } from '@/services/selections';
 
 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();
-};
+const { selections } = useSelection();
 
-function choosePen(): boolean {
-  return meta2d?.store.active?.length === 1;
-}
+const hasChildren = ref(false);
+const locked = ref(false);
+const isNameLine = ref(false);
+const isLine = ref(false);
+const cantUndo = ref(false);
+const cantRedo = ref(false);
 
-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();
+onMounted(() => {
+  if (selections.mode === SelectionMode.Pen) {
+    hasChildren.value = meta2d?.store.active[0]?.children?.length > 0;
+    isNameLine.value = meta2d?.store.active[0]?.name === 'line';
+    isLine.value = meta2d?.store.active[0]?.type === PenType.Line;
   }
-  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;
-    }
+  if (selections.mode) {
+    locked.value = meta2d?.store.active?.some((pen: Pen) => pen.locked);
   }
-  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 (
+  cantUndo.value =
     !!meta2d?.store.data.locked ||
     meta2d?.store.histories.length == 0 ||
     meta2d?.store.historyIndex == null ||
-    meta2d?.store.historyIndex < 0
-  );
-}
+    meta2d?.store.historyIndex < 0;
 
-function cantRedo(): boolean {
-  return (
+  cantRedo.value =
     !!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;
+    meta2d?.store.historyIndex > meta2d?.store.histories.length - 2;
+});
 
-      label {
-        font-size: 10px;
-        background-color: #ff400030;
-        color: var(--color-bland);
-        padding: 0 6px;
-        margin-left: 4px;
-        border-radius: 2px;
+const onMenu = (val: string) => {
+  switch (val) {
+    case 'addAnchorHand':
+      meta2d.addAnchorHand();
+      break;
+    case 'removeAnchorHand':
+      meta2d.removeAnchorHand();
+      break;
+    case 'toggleAnchorHand':
+      meta2d.toggleAnchorHand();
+      break;
+    case 'top':
+    case 'bottom':
+    case 'up':
+    case 'down':
+    case 'undo':
+    case 'redo':
+    case 'cut':
+    case 'copy':
+    case 'paste':
+      (meta2d as any)[val]();
+      break;
+    case 'group':
+      meta2d.combine(meta2d.store.active);
+      break;
+    case 'states':
+      meta2d.combine(meta2d.store.active, 0);
+      break;
+    case 'unGroup':
+      meta2d.uncombine();
+      break;
+    case 'lock':
+      {
+        const pens = meta2d.store.active;
+        if (Array.isArray(pens)) {
+          for (const pen of pens) {
+            pen.locked = locked.value ? LockState.DisableMove : LockState.None;
+          }
+        }
+        meta2d.render();
       }
-    }
+      break;
+    case 'del':
+      meta2d.delete();
+      break;
+    case 'changeType':
+      meta2d.setValue(
+        {
+          id: meta2d.store.active[0].id,
+          type: isLine ? 0 : 1,
+        },
+        {
+          history: true,
+        }
+      );
+      break;
   }
 
-  .item-diabled {
-    a {
-      cursor: not-allowed;
-      color: var(--td-text-color-disabled);
-    }
-    &:hover {
-      background-color: #0000;
-    }
-  }
-}
-</style>
+  emit('changeVisible', false);
+};
+</script>
+
+<style lang="postcss" scoped></style>

+ 614 - 165
src/views/components/Graphics.vue

@@ -17,14 +17,53 @@
           {{ group.name }}
         </div>
       </div>
-      <div class="list" :class="groupType ? 'two-list' : ''">
-        <t-collapse v-model:value="activedPanel" @change="onChangeGroupPanel">
+      <div class="list" :class="groupType ? 'two-columns' : ''">
+        <div v-if="activedGroup === '我的'" class="px-16 mt-12 mb-8 ml-4">
+          <a @click="onCreateFolder">+ 新建文件夹</a>
+        </div>
+        <t-collapse
+          v-if="groupType < 2"
+          v-model:value="activedPanel"
+          @change="onChangeGroupPanel"
+        >
           <t-collapse-panel
             :value="item.name"
-            :header="item.name"
             v-for="item in subGroups"
             :key="item.name"
           >
+            <template #header>
+              <div class="flex middle mr-8">
+                <div v-if="item.edited" @click.stop>
+                  <t-input
+                    v-model="item.label"
+                    style="width: 140px"
+                    @blur="createFoder"
+                    @enter="createFoder"
+                    @keyup="onKeyHeader"
+                  />
+                </div>
+                <div v-else class="ellipsis" style="width: 140px">
+                  {{ item.name }}
+                </div>
+              </div>
+            </template>
+            <template #headerRightContent v-if="item.canEdited">
+              <t-space size="small" @click.stop>
+                <t-icon
+                  name="edit"
+                  class="hover mr-4"
+                  @click="onEditHeader(item)"
+                />
+
+                <t-popconfirm
+                  content="确认删除该文件夹吗"
+                  placement="left"
+                  @confirm=""
+                >
+                  <t-icon name="delete" class="hover" />
+                </t-popconfirm>
+              </t-space>
+            </template>
             <div v-if="item.loading">
               <t-loading
                 text="加载中..."
@@ -36,14 +75,21 @@
               <div
                 class="graphic"
                 v-for="elem in item.list"
-                :draggable="!groupType || groupType >= 10"
+                :draggable="elem.draggable !== false"
                 @dragstart="dragStart($event, elem)"
                 @drag="drag($event, elem)"
                 @dragend="dragEnd()"
                 @click.stop="dragStart($event, elem)"
                 @dblclick.stop="open(elem)"
+                @contextmenu="onContextMenu($event, item, elem)"
+                :title="elem.draggable === false ? '双击打开' : '拖拽到画布'"
               >
-                <t-image v-if="elem.image" :src="elem.image" />
+                <t-image
+                  v-if="elem.image"
+                  :src="elem.image"
+                  :lazy="true"
+                  fit="contain"
+                />
                 <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>
@@ -63,34 +109,110 @@
             </template>
           </t-collapse-panel>
         </t-collapse>
+        <div v-else class="t-collapse-panel__content" style="padding: 8px">
+          <div
+            class="graphic"
+            v-for="elem in subGroups"
+            :draggable="elem.draggable !== false"
+            @dragstart="dragStart($event, elem)"
+            @drag="drag($event, elem)"
+            @dragend="dragEnd()"
+            @click.stop="dragStart($event, elem)"
+            @dblclick.stop="open(elem)"
+            :title="elem.draggable === false ? '双击打开' : '拖拽到画布'"
+          >
+            <t-image
+              v-if="elem.image"
+              :src="elem.image"
+              :lazy="true"
+              fit="contain"
+            />
+            <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="!subGroups.length"
+            class="gray center"
+            style="white-space: nowrap; margin-left: 32px"
+          >
+            暂无数据,待更新
+          </div>
+        </div>
       </div>
     </div>
+
+    <div
+      class="context-menu-box"
+      ref="contextmenuDom"
+      v-if="contextmenu.visible"
+      tabindex="0"
+      :style="contextmenu.style"
+      @blu1r="contextmenu.visible = false"
+    >
+      <t-menu class="context-menu" @change="onMenu" expandType="popup">
+        <t-submenu
+          value="move"
+          title="移动到"
+          v-if="contextmenu.subMenus.length"
+        >
+          <t-menu-item
+            v-for="subMenu in contextmenu.subMenus"
+            :value="'move:' + subMenu.name"
+          >
+            {{ subMenu.name }}
+          </t-menu-item>
+        </t-submenu>
+        <t-menu-item value="edit"> 编辑 </t-menu-item>
+        <t-menu-item value="del"> 删除 </t-menu-item>
+      </t-menu>
+    </div>
+
+    <t-dialog
+      v-if="delDialog.show"
+      theme="danger"
+      header="删除"
+      :visible="true"
+      @close="delDialog.show = false"
+      @confirm="delComponet"
+    >
+      确定删除该数据吗?删除后不可恢复!
+    </t-dialog>
   </div>
 </template>
 
 <script lang="ts" setup>
-import { onMounted, onUnmounted, reactive, ref, watch } from 'vue';
+import { onMounted, onUnmounted, reactive, ref } 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 { getFolders, getFiles, getIcons } from '@/services/png';
+import {
+  getComponents,
+  getComponentsList,
+  getLe5leV,
+  updateCollection,
+} from '@/services/api';
 import { convertPen } from '@/services/upgrade';
 import { deepClone } from '@meta2d/core';
 import { isGif } from '@/services/utils';
 import { autoSave, delAttrs } from '@/services/common';
+import { MessagePlugin } from 'tdesign-vue-next';
 
 const router = useRouter();
 
-const activedGroup = ref('图形');
+const activedGroup = ref('');
 
 const groups = reactive([
   {
     icon: 'desktop',
     name: '场景',
     key: '',
+    class: 'tow',
   },
   {
     icon: 'root-list',
@@ -103,18 +225,18 @@ const groups = reactive([
     key: 'chart',
   },
   {
-    icon: 'control-platform',
-    name: '控件',
+    icon: 'image',
+    name: '素材',
     key: '',
   },
   {
-    icon: 'image',
-    name: '素材',
+    icon: 'control-platform',
+    name: '图元',
     key: '',
   },
   {
-    icon: 'file-icon',
-    name: '图标',
+    icon: 'relativity',
+    name: '控件',
     key: '',
   },
   {
@@ -132,9 +254,11 @@ const subGroups = ref<any[]>([]);
 const groupType = ref(0);
 const activedPanel = ref([]);
 
-const templates = reactive<any>({});
-const materials = reactive([]);
-const icons = reactive([]);
+const caseCaches = reactive<any>({});
+const templates = ref([]);
+const materials = ref([]);
+const pngs = ref([]);
+const icons = ref([]);
 
 const groupChange = async (name: string) => {
   activedPanel.value = [];
@@ -145,27 +269,21 @@ const groupChange = async (name: string) => {
       groupType.value = 1;
       subGroups.value = cases;
       subGroups.value[0].loading = true;
-      if (!templates[name + cases[0].name]) {
-        templates[name + cases[0].name] = await getCaseProjects(
+      if (!caseCaches[name + cases[0].name]) {
+        caseCaches[name + cases[0].name] = await getCaseProjects(
           name,
           cases[0].name
         );
       }
-      subGroups.value[0].list = templates[name + cases[0].name];
+      subGroups.value[0].list = caseCaches[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
-        );
+      if (!templates.value.length) {
+        templates.value = await getCaseProjects(name);
       }
-      subGroups.value[0].list = templates[name + cases[0].name];
-      subGroups.value[0].loading = false;
+      subGroups.value = templates.value;
       break;
     case '图表':
       subGroups.value = charts;
@@ -174,33 +292,45 @@ const groupChange = async (name: string) => {
       subGroups.value = formComponents;
       break;
     case '素材':
-      if (!materials.length) {
-        materials.push(...(await getPngFolders()));
+      groupType.value = 2;
+      if (!materials.value.length) {
+        materials.value = await getFiles('material/');
       }
-      subGroups.value = materials;
+      subGroups.value = materials.value;
       break;
-    case '图标':
-      if (!icons.length) {
-        icons.push(...(await getIconFolders()));
+    case '图元':
+      subGroups.value = [];
+      if (!pngs.value.length) {
+        pngs.value = await getFolders('png');
       }
-      subGroups.value = icons;
+      subGroups.value.push(...pngs.value);
+      if (!icons.value.length) {
+        icons.value = await getFolders('svg');
+      }
+      subGroups.value.push(...icons.value);
+      onChangeGroupPanel([subGroups.value[0].name]);
       break;
     case '图形':
       subGroups.value = shapes;
       break;
     case '我的':
-      subGroups.value = await getPrivateCommponents();
-      groupType.value = 10;
+      subGroups.value = await getPrivateGroups();
+      groupType.value = 1;
+      onChangeGroupPanel([subGroups.value[0].name]);
       break;
   }
   activedPanel.value = [subGroups.value[0].name];
 };
 
-const getCaseProjects = async (name: string, group: string) => {
+const getCaseProjects = async (name: string, group?: string) => {
+  const query: any = { tags: name };
+  if (group) {
+    query.case = group;
+  }
   const ret: any = await axios.post(
-    '/api/data/le5leV/list?current=1&pageSize=1000',
+    '/api/data/le5leV/list?current=1&pageSize=100',
     {
-      query: { tags: name, case: group },
+      query,
       shared: 'true',
       projection: { _id: 1, name: 1, image: 1, price: 1 },
     }
@@ -209,119 +339,112 @@ const getCaseProjects = async (name: string, group: string) => {
   if (!ret) {
     return [];
   }
+  for (const item of ret.list) {
+    item.draggable = false;
+  }
   return ret.list;
 };
 
-const getPrivateCommponents = async () => {
-  const data = {
-    projection: {
-      image: 1,
-      _id: 1,
-      name: 1,
-      folder: 1,
-      component: 1,
+const getPrivateGroups = async () => {
+  const list = [
+    {
+      name: '我的组件',
+      list: [],
     },
-  };
+  ];
   const config = {
     params: {
       current: 1,
-      pageSize: 100,
+      pageSize: 1000,
     },
   };
-  const res: any = await getComponentsList(data, config);
-  const folderMap: any = {};
-  res.list?.map((item: any) => {
-    if (!folderMap[item.folder]) {
-      folderMap[item.folder] = [];
-    }
+  let ret: any = await axios.post(
+    '/api/data/folders/list',
+    {
+      projection: {
+        image: 1,
+        _id: 1,
+        name: 1,
+        list: 1,
+      },
+      query: {
+        type: `le5leV-components`,
+      },
+      sort: { createdAt: 1 },
+    },
+    config
+  );
+  if (!ret) {
+    ret = { list: [] };
+  }
 
-    folderMap[item.folder].push(item);
-  });
-  let list = [];
-  for (let key in folderMap) {
-    list.push({
-      name: key === 'undefined' ? '未分类' : key,
-      show: true,
-      list: folderMap[key],
-    });
+  for (const item of ret.list) {
+    item.canEdited = true;
   }
 
+  list.push(...ret.list);
+  list.push({
+    name: '3D',
+    list: [],
+  });
+
   return list;
 };
 
-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 ||
+    item.draggable === false ||
     (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];
-
+  if (item['3d']) {
     data = {
-      name,
-      width: 100,
-      height: 100 * (normalHeight / normalWidth),
-      image: item.image,
-      imageRatio: true,
-      lockedOnCombine,
+      name: 'iframe',
+      width: 400,
+      height: 300,
+      externElement: true,
+      iframe: 'https://view3d.le5le.com/?id=' + (item._id || item.id),
     };
-  } 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 (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];
+
+      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));
@@ -347,6 +470,9 @@ const dragend = (event: any) => {
 };
 
 const open = async (item: any) => {
+  if (item.draggable !== false) {
+    return;
+  }
   autoSave();
   router.push({
     path: '/',
@@ -363,30 +489,312 @@ const open = async (item: any) => {
 };
 
 const onChangeGroupPanel = async (val: string[]) => {
-  if (groupType.value > 0 && groupType.value < 10 && val?.length) {
+  if (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;
+      switch (activedGroup.value) {
+        case '场景':
+          if (
+            !caseCaches[activedGroup.value + name] ||
+            !caseCaches[activedGroup.value + name].length
+          ) {
+            for (const item of subGroups.value) {
+              if (item.name === name) {
+                item.loading = true;
+              }
+            }
+            caseCaches[activedGroup.value + name] = await getCaseProjects(
+              activedGroup.value,
+              name
+            );
+            for (const item of subGroups.value) {
+              if (item.name === name) {
+                item.list = caseCaches[activedGroup.value + name];
+                item.loading = false;
+              }
+            }
           }
-        }
-        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;
+          break;
+        case '图元':
+          for (const item of subGroups.value) {
+            if (item.name === name && !item.list.length) {
+              item.loading = true;
+              if (item.svg) {
+                item.list = await getIcons(item.folder);
+              } else {
+                item.list = await getFiles(item.folder + item.name);
+              }
+              item.loading = false;
+            }
           }
+          break;
+
+        case '我的':
+          for (const item of subGroups.value) {
+            if (!item.list.length) {
+              item.loading = true;
+              if (item.name === '我的组件') {
+                const data = {
+                  query: { folder: '' },
+                  projection: {
+                    image: 1,
+                    _id: 1,
+                    name: 1,
+                    component: 1,
+                  },
+                };
+                const config = {
+                  params: {
+                    current: 1,
+                    pageSize: 1000,
+                  },
+                };
+                const res: any = await getComponentsList(data, config);
+                if (res?.list) {
+                  item.list = res.list;
+                }
+              } else if (item.name === '3D') {
+                const data = {
+                  projection: {
+                    image: 1,
+                    _id: 1,
+                    name: 1,
+                  },
+                };
+                const config = {
+                  params: {
+                    current: 1,
+                    pageSize: 1000,
+                  },
+                };
+                const res: any = await axios.post(
+                  '/api/data/le5le3d/list',
+                  data,
+                  config
+                );
+                if (res?.list) {
+                  for (const item of res.list) {
+                    item['3d'] = true;
+                  }
+                  item.list = res.list;
+                }
+              }
+              item.loading = false;
+            }
+          }
+          break;
+      }
+    }
+  }
+};
+
+const editedFolder = ref<any>(undefined);
+const onCreateFolder = () => {
+  editedFolder.value = {
+    _id: '',
+    name: '',
+    label: '新建文件夹',
+    list: [],
+    edited: true,
+    canEdited: true,
+  };
+  subGroups.value.splice(subGroups.value.length - 1, 0, editedFolder.value);
+};
+
+const createFoder = async () => {
+  if (!editedFolder.value.label) {
+    return;
+  }
+  if (editedFolder.value.label === editedFolder.value.name) {
+    editedFolder.value.edited = false;
+    return;
+  }
+  const found = subGroups.value.findIndex(
+    (group: any) => group.name === editedFolder.value.label
+  );
+  if (found >= 0) {
+    MessagePlugin.error('已经存在相同名称文件夹');
+    return;
+  }
+
+  if (editedFolder.value._id) {
+    const ret: any = await axios.post('/api/data/folders/update', {
+      _id: editedFolder.value._id,
+      name: editedFolder.value.label,
+    });
+    if (ret) {
+      editedFolder.value.name = editedFolder.value.label;
+      editedFolder.value.edited = false;
+    }
+  } else {
+    const ret: any = await axios.post('/api/data/folders/add', {
+      name: editedFolder.value.label,
+      type: 'le5leV-components',
+      list: [],
+    });
+    if (ret) {
+      editedFolder.value.name = editedFolder.value.label;
+      editedFolder.value._id = ret._id;
+      editedFolder.value.edited = false;
+    }
+  }
+};
+
+const onEditHeader = (item: any) => {
+  item.label = item.name;
+  item.edited = true;
+  editedFolder.value = item;
+};
+
+const onKeyHeader = (text: string, event: any) => {
+  if (event.e.key === 'Escape') {
+    editedFolder.value.edited = false;
+  }
+};
+
+// 我的组件右键菜单
+const contextmenu = reactive<any>({
+  visible: false,
+  style: {},
+  // 子分类
+  group: undefined,
+  // 组件图纸
+  component: undefined,
+  // 右键二级子菜单
+  subMenus: [],
+});
+const contextmenuDom = ref<any>(null);
+const onContextMenu = async (e: MouseEvent, group: string, item: any) => {
+  e.preventDefault();
+  e.stopPropagation();
+
+  if (activedGroup.value !== '我的' || !item.component) {
+    return;
+  }
+
+  contextmenu.group = group;
+  contextmenu.component = item;
+
+  if (document.body.clientHeight - e.clientY < 128) {
+    contextmenu.style = {
+      left: e.clientX + 'px',
+      bottom: '4px',
+    };
+  } else {
+    contextmenu.style = {
+      left: e.clientX + 'px',
+      top: e.clientY + 'px',
+    };
+  }
+
+  contextmenu.subMenus = [];
+  for (const elem of subGroups.value) {
+    if (elem === group || elem.name === '3D') {
+      continue;
+    }
+    contextmenu.subMenus.push(elem);
+  }
+  contextmenu.visible = true;
+  setTimeout(() => {
+    if (contextmenuDom.value) {
+      contextmenuDom.value.focus();
+    }
+  }, 500);
+};
+
+const delDialog = reactive<any>({});
+
+const onMenu = async (val: string) => {
+  const id = contextmenu.component._id || contextmenu.component.id;
+
+  switch (val) {
+    case 'edit':
+      autoSave();
+      router.push({
+        path: '/',
+        query: {
+          id,
+          c: 1,
+          r: Date.now() + '',
+        },
+      });
+
+      break;
+    case 'del':
+      delDialog.show = true;
+      break;
+    default:
+      if (val.indexOf('move:')) {
+        return;
+      }
+
+      val = val.replace('move:', '');
+      const group = contextmenu.subMenus.find(
+        (element: any) => element.name === val
+      );
+      if (!group) {
+        return;
+      }
+      // 前端: 添加组件到目标文件夹
+      group.list.push(contextmenu.component);
+      // 前端:从源文件夹移出组件
+      contextmenu.group.list.forEach((item: any, index: number, arr: any[]) => {
+        if (id === item._id || id === item.id) {
+          arr.splice(index, 1);
         }
+      });
+
+      // 更新后端组件信息
+      let ret = await updateCollection('le5leV-components', {
+        _id: id,
+        folder: val === '我的组件' ? '' : val,
+      });
+      if (!ret) {
+        return;
+      }
+
+      // 更新后端源文件夹列表
+      if (contextmenu.group.name !== '我的组件') {
+        await axios.post('/api/data/folders/update', {
+          _id: contextmenu.group._id || contextmenu.group.id,
+          list: contextmenu.group.list,
+        });
       }
+
+      // 更新后端目标文件夹列表
+      if (group.name !== '我的组件') {
+        await axios.post('/api/data/folders/update', {
+          _id: group._id || group.id,
+          list: group.list,
+        });
+      }
+      break;
+  }
+
+  contextmenu.visible = false;
+};
+
+const delComponet = async () => {
+  const id = contextmenu.component._id || contextmenu.component.id;
+  await axios.post(`/api/data/le5leV-components/delete`, {
+    id,
+  });
+
+  // 前端:从源文件夹移出组件
+  contextmenu.group.list.forEach((item: any, index: number, arr: any[]) => {
+    if (id === item._id || id === item.id) {
+      arr.splice(index, 1);
     }
+  });
+
+  // 更新后端源文件夹列表
+  if (contextmenu.group.name !== '我的组件') {
+    await axios.post('/api/data/folders/update', {
+      _id: contextmenu.group._id || contextmenu.group.id,
+      list: contextmenu.group.list,
+    });
   }
+
+  delDialog.show = false;
 };
 
 onMounted(() => {
@@ -417,6 +825,7 @@ onUnmounted(() => {
     flex-grow: 1;
     overflow-y: auto;
     font-size: 12px;
+    z-index: 100;
 
     .groups {
       & > div {
@@ -455,6 +864,7 @@ onUnmounted(() => {
       }
 
       :deep(.t-collapse) {
+        min-height: 100vh;
         border: none;
       }
       :deep(.t-collapse-panel__header) {
@@ -475,19 +885,42 @@ onUnmounted(() => {
         padding: 4px 4px 20px 4px;
         display: grid;
         grid-template-columns: 1fr 1fr 1fr;
-        grid-row-gap: 20px;
+        grid-row-gap: 12px;
+      }
+
+      :deep(.t-loading--center) {
+        width: 100px;
+        .t-loading__text {
+          margin-left: 8px;
+          height: 24px;
+        }
       }
+
+      :deep(.t-image__error) {
+        .t-space-item:last-child {
+          display: none;
+        }
+      }
+
+      :deep(.t-image__loading) {
+        .t-space-item:last-child {
+          display: none;
+        }
+      }
+
       .graphic {
         position: relative;
+        padding: 10px 0;
+        border-radius: 2px;
+        border: 1px solid transparent;
 
         &:hover {
           cursor: pointer;
-          color: var(--color-primary-hover);
-          svg {
-            color: var(--color-primary-hover);
-          }
+          border-color: var(--color-primary);
         }
         p {
+          margin-top: 10px;
+          padding: 0 10px;
           text-align: center;
           font-size: 12px;
           height: 12px;
@@ -502,10 +935,7 @@ onUnmounted(() => {
         .t-image__wrapper {
           height: 32px;
           width: 32px;
-          background: #fff0;
-          margin-left: calc(50% - 16px);
-          margin-top: 10px;
-          margin-bottom: 10px;
+          margin: auto;
           :deep(.t-image) {
             border-radius: 2px;
           }
@@ -515,7 +945,7 @@ onUnmounted(() => {
           color: var(--color);
           height: 32px;
           width: 100%;
-          margin: 4px 0px;
+          margin: auto;
         }
 
         .svg-box {
@@ -549,23 +979,42 @@ onUnmounted(() => {
       }
     }
 
-    .two-list {
+    .two-columns {
       :deep(.t-collapse-panel__content) {
-        padding: 0 8px;
-        grid-template-columns: 116px 116px;
+        grid-template-columns: 1fr 1fr;
       }
       .graphic {
-        p {
-          margin-top: 10px;
-          margin-bottom: 12px;
-        }
         .t-image__wrapper {
-          width: 88px;
+          width: 100px;
           height: 88px;
-          margin-left: 14px;
+          background-color: var(--color-background);
         }
       }
     }
   }
+
+  .context-menu-box {
+    position: fixed;
+    z-index: 200;
+    & > div {
+      width: 140px !important;
+    }
+
+    :deep(.t-menu) {
+      .t-menu__item {
+        &.t-is-opened {
+          background-color: var(--color-background-popup-hover);
+          transition: none !important;
+        }
+      }
+      .t-fake-arrow {
+        transform: rotate(-90deg) !important;
+      }
+
+      .t-fake-arrow--active {
+        transform: rotate(90deg) !important;
+      }
+    }
+  }
 }
 </style>

+ 1 - 0
src/views/components/Header.vue

@@ -9,6 +9,7 @@
       :maxHeight="560"
       :delay2="[10, 150]"
       overlayClassName="header-dropdown"
+      trigger1="click"
     >
       <a> 文件 </a>
       <t-dropdown-menu>

+ 3 - 23
src/views/components/PenDatas.vue

@@ -419,7 +419,7 @@ 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 { getPenTree, typeOptions } from '@/services/common';
 import { updatePen } from './pen';
 
 import CodeEditor from '@/views/components/common/CodeEditor.vue';
@@ -508,28 +508,6 @@ const moreOptions = ref<any>([
   },
 ]);
 
-const typeOptions = [
-  {
-    label: '字符串',
-  },
-  {
-    label: '数字',
-    value: 'number',
-  },
-  {
-    label: '布尔',
-    value: 'bool',
-  },
-  {
-    label: '对象',
-    value: 'object',
-  },
-  {
-    label: '数组',
-    value: 'array',
-  },
-];
-
 const addDataDialog = reactive<any>({
   show: false,
   data: undefined,
@@ -662,6 +640,8 @@ const onConfirmData = () => {
     props.pen.realTimes.push(addDataDialog.data);
   }
 
+  meta2d.penMock(props.pen);
+
   addDataDialog.show = false;
 };
 

+ 224 - 88
src/views/components/View.vue

@@ -7,29 +7,28 @@
       <t-tooltip content="保存为大屏" placement="bottom">
         <a>
           <t-badge
-            :style="{
-              color: canSave ? '' : '#4f5b75',
-              cursor: canSave ? '' : 'not-allowed',
-            }"
+            :class="{ gray: route.query.c }"
             dot
             :showZero="false"
             :count="dot ? 1 : 0"
           >
             <t-icon
               name="save"
-              @click="canSave && save(SaveType.Save, false, true)" /></t-badge
-        ></a>
+              @click="!route.query.c && save(SaveType.Save, false, true)"
+            />
+          </t-badge>
+        </a>
       </t-tooltip>
       <t-tooltip content="保存为我的组件" placement="bottom">
-        <a
-          :style="{
-            color: canSaveComponent ? '' : '#4f5b75',
-            cursor: canSaveComponent ? '' : 'not-allowed',
-          }"
-          ><t-icon
+        <a :class="{ gray: route.query.id && !route.query.c }">
+          <t-icon
             name="layers"
-            @click="canSaveComponent && save(SaveType.Save, true, true)"
-        /></a>
+            @click="
+              (!route.query.id || route.query.c) &&
+                save(SaveType.Save, true, true)
+            "
+          />
+        </a>
       </t-tooltip>
       <t-tooltip content="格式化(双击可连续使用)" placement="bottom">
         <a
@@ -248,10 +247,7 @@
         <a><t-icon name="qrcode" /></a>
         <template #content>
           <div style="padding: 12px 12px 6px 12px">
-            <img
-              v-if="route.query.id && route.query.component !== 'true'"
-              :src="qrcode.url"
-            />
+            <img v-if="route.query.id && !route.query.c" :src="qrcode.url" />
             <div
               v-else-if="!route.query.id"
               class="gray center mb-4"
@@ -277,12 +273,9 @@
     </div>
     <div id="meta2d"></div>
     <ContextMenu
-      :style="{
-        left: contextMenuValue.left,
-        top: contextMenuValue.top,
-      }"
-      v-show="contextMenuVisible"
-      :type="contextMenuType"
+      v-if="contextmenu.visible"
+      :type="contextmenu.type"
+      :style="contextmenu.style"
       @changeVisible="changeContextMenuVisible"
     />
     <t-dialog
@@ -393,23 +386,41 @@
             <span><label class="vip-label ml-4">VIP</label></span>
           </template>
           <template #panel>
-            <div class="form-item mt-12">
-              <label>网络地址</label>
+            <div class="form-item mt-20">
+              <label style="width: 100px">
+                网络地址
+                <t-tooltip
+                  content="使用网络数据代替自定义数据。高优先级,生产环境使用"
+                >
+                  <t-icon
+                    name="help-circle"
+                    class="ml-4 hover"
+                    style="margin-top: -2px"
+                  />
+                </t-tooltip>
+              </label>
               <div class="w-full">
                 <t-input v-model="dataDialog.datasetUrl" />
-                <div class="desc mt-4">
-                  高优先级。存在网络地址时,用网络数据代替自定义数据
-                </div>
               </div>
             </div>
-            <div class="form-item mt-16">
-              <label>自定义</label>
-              <div class="w-full">
+            <div class="form-item" style="margin-top: 28px">
+              <label style="width: 100px">
+                自定义
+                <t-tooltip content="初始静态或模拟数据,开发设计阶段使用">
+                  <t-icon
+                    name="help-circle"
+                    class="ml-4 hover"
+                    style="margin-top: -2px"
+                  />
+                </t-tooltip>
+              </label>
+              <div class="w-full flex">
                 <t-button @click="importDataset">从Excel导入</t-button>
-                <a href="/data.xlsx" download class="ml-16">下载Excel示例</a>
-                <span class="desc ml-16">
-                  “自定义”数据集 - 方便构建项目时了解数据结构
-                </span>
+                <a href="/data.xlsx" download class="ml-16 mt-4">
+                  下载Excel示例
+                </a>
+                <div class="flex-grow"></div>
+                <a class="mt-4" @click="showAddData()"> + 添加数据 </a>
               </div>
             </div>
 
@@ -424,13 +435,20 @@
               <template #label="{ row }">
                 {{ `${row.label}(${row.key})` }}
               </template>
-              <template #actions="{ row }"> </template>
+              <template #type="{ row }">
+                {{ row.type || 'string' }}
+              </template>
+              <template #actions="{ row, rowIndex }">
+                <t-icon name="edit" class="hover" @click="showAddData(row)" />
+                <t-icon
+                  name="delete"
+                  class="ml-12 hover"
+                  @click="dataDialog.dataset.splice(rowIndex, 1)"
+                />
+              </template>
               <template #empty>
                 <div class="center">
-                  <div>暂无数据</div>
-                  <div class="mt-8">
-                    <a @click="importDataset"> 从Excel导入</a>
-                  </div>
+                  暂无数据, <a class="mt-4" @click="showAddData()"> + 添加 </a>
                 </div>
               </template>
             </t-table>
@@ -455,6 +473,83 @@
       </template>
     </t-dialog>
 
+    <t-dialog
+      v-if="addDataDialog.show"
+      :visible="true"
+      class="data-dialog"
+      :header="addDataDialog.header"
+      @close="addDataDialog.show = false"
+      @confirm="onOkAddData"
+    >
+      <div class="form-item mt-16">
+        <label>名称</label>
+        <t-input v-model="addDataDialog.data.label" placeholder="简短描述" />
+      </div>
+      <div class="form-item mt-16">
+        <label>数据ID</label>
+        <t-input v-model="addDataDialog.data.key" placeholder="数据ID" />
+      </div>
+      <div class="form-item mt-16">
+        <label>类型</label>
+        <t-select
+          class="w-full"
+          :options="typeOptions"
+          v-model="addDataDialog.data.type"
+          placeholder="字符串"
+          @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="publishDialog.show"
       width="700px"
@@ -624,7 +719,12 @@ import { MessagePlugin } from 'tdesign-vue-next';
 
 import { registerBasicDiagram } from '@/services/register';
 import { useUser } from '@/services/user';
-import { cdn, getLe5leV, updateCollection } from '@/services/api';
+import {
+  cdn,
+  getComponents,
+  getLe5leV,
+  updateCollection,
+} from '@/services/api';
 import {
   save,
   autoSave,
@@ -633,6 +733,7 @@ import {
   onScaleView,
   onScaleWindow,
   useDot,
+  typeOptions,
 } from '@/services/common';
 import { useSelection } from '@/services/selections';
 import { defaultFormat } from '@/services/defaults';
@@ -641,6 +742,7 @@ import { debounce } from '@/services/debouce';
 import { importExcel } from '@/services/excel';
 import { s8 } from '@/services/random';
 
+import CodeEditor from './common/CodeEditor.vue';
 import ContextMenu from './ContextMenu.vue';
 import Network from './Network.vue';
 import ChargeCloudPublish from './ChargeCloudPublish.vue';
@@ -676,11 +778,12 @@ const publishDialog = reactive<any>({});
 
 const publishChargeDialog = reactive<any>({});
 
+const addDataDialog = reactive<any>({});
+
 onMounted(() => {
   meta2d = new Meta2d('meta2d', meta2dOptions);
   registerBasicDiagram();
   open(true);
-  saveStatusChange();
   // @ts-ignore
   meta2d.on('active', active);
   // @ts-ignore
@@ -701,7 +804,7 @@ onMounted(() => {
   // 所有编辑栏所做修改
   meta2d.on('components-update-value', patchFlag);
   // @ts-ignore
-  meta2d.on('contextmenu', contextmenu);
+  meta2d.on('contextmenu', onContextmenu);
   meta2d.on('click', canvasClick);
 
   timer = setInterval(autoSave, 60000);
@@ -715,39 +818,27 @@ const watcher = watch(
   () => route.query,
   async () => {
     open();
-    saveStatusChange();
   }
 );
 
-const canSave = ref(true);
-const canSaveComponent = ref(true);
-
-const saveStatusChange = () => {
+const open = async (flag: boolean = false) => {
   if (route.query.id) {
-    if (route.query.component === 'true') {
-      canSave.value = false;
-    } else if (route.query.component === 'false') {
-      canSaveComponent.value = false;
+    let ret: any;
+    if (route.query.c) {
+      ret = await getComponents(route.query.id + '');
     } else {
-      canSaveComponent.value = false;
+      ret = await getLe5leV(route.query.id + '');
     }
-  } else {
-    canSave.value = true;
-    canSaveComponent.value = true;
-  }
-};
-
-const open = async (flag: boolean = false) => {
-  if (route.query.id) {
-    const ret: any = await getLe5leV(route.query.id + '');
     if (ret) {
       meta2d.open(ret);
-      shared.value = ret.shared;
+      if (!route.query.c) {
+        shared.value = ret.shared;
 
-      const qr: any = await QRCode.toDataURL(
-        `https://view2d.le5le.com/?id=${route.query.id + ''}`
-      );
-      qrcode.url = qr;
+        const qr: any = await QRCode.toDataURL(
+          `https://view2d.le5le.com/?id=${route.query.id + ''}`
+        );
+        qrcode.url = qr;
+      }
     }
   } else {
     if (flag) {
@@ -802,7 +893,7 @@ onUnmounted(() => {
     meta2d.off('translatePens', patchFlag);
     meta2d.off('components-update-value', patchFlag);
     // @ts-ignore
-    meta2d.off('contextmenu', contextmenu);
+    meta2d.off('contextmenu', onContextmenu);
     meta2d.off('click', canvasClick);
 
     meta2d.destroy();
@@ -1053,41 +1144,53 @@ const preview = async () => {
   });
 };
 
-const contextMenuVisible = ref(false);
-const contextMenuValue = ref({
-  left: '0px',
-  top: '0px',
+const contextmenu = reactive<any>({
+  visible: false,
+  type: '',
+  style: {},
 });
-const contextMenuType = ref('');
 
-const contextmenu = ({ e, rect }: { e: any; rect: any }) => {
-  contextMenuType.value = '';
-  contextMenuValue.value.left = e.clientX + 'px';
-  contextMenuValue.value.top = e.clientY + 'px';
+const onContextmenu = ({ e, rect }: { e: any; rect: any }) => {
+  contextmenu.type = '';
+  contextmenu.style = {
+    left: e.clientX + 'px',
+    top: e.clientY + 'px',
+  };
+
   if (
     meta2d.store.hoverAnchor &&
     meta2d.canvas.hoverType === HoverType.LineAnchor
   ) {
-    contextMenuType.value = 'anchor';
+    contextmenu.type = 'anchor';
+    if (document.body.clientHeight - e.clientY < 128) {
+      contextmenu.style = {
+        left: e.clientX + 'px',
+        bottom: '4px',
+      };
+    }
   } else {
-    //其他右键菜单
-    contextMenuType.value = 'pen';
+    contextmenu.type = 'pen';
+    if (document.body.clientHeight - e.clientY < 450) {
+      contextmenu.style = {
+        left: e.clientX + 'px',
+        bottom: '4px',
+      };
+    }
   }
 
-  if (!contextMenuVisible.value && contextMenuType.value) {
-    contextMenuVisible.value = true;
-  }
-  if (!contextMenuType.value) {
-    contextMenuVisible.value = false;
+  if (contextmenu.type) {
+    contextmenu.visible = true;
+  } else {
+    contextmenu.visible = false;
   }
 };
 
 const canvasClick = () => {
-  contextMenuVisible.value = false;
+  contextmenu.visible = false;
 };
 
 const changeContextMenuVisible = (e: boolean) => {
-  contextMenuVisible.value = e;
+  contextmenu.visible = e;
 };
 
 const networkColumns = ref([
@@ -1192,6 +1295,11 @@ const datasetColumns = ref([
     title: '值',
     ellipsis: true,
   },
+  {
+    colKey: 'actions',
+    title: '操作',
+    width: 80,
+  },
 ]);
 
 const importDataset = async () => {
@@ -1221,6 +1329,34 @@ const importDataset = async () => {
   dataDialog.dataset = data;
 };
 
+const showAddData = (row?: any) => {
+  if (row) {
+    addDataDialog.header = '编辑数据';
+    addDataDialog.data = row;
+  } else {
+    addDataDialog.header = '添加数据';
+    addDataDialog.data = { type: 'string' };
+  }
+
+  addDataDialog.show = true;
+};
+
+const onOkAddData = () => {
+  if (!addDataDialog.data.label) {
+    MessagePlugin.error('请填写名称');
+    return;
+  }
+  if (!addDataDialog.data.key) {
+    MessagePlugin.error('请填写数据ID');
+    return;
+  }
+  if (!dataDialog.dataset) {
+    dataDialog.dataset = [];
+  }
+  dataDialog.dataset.push(addDataDialog.data);
+  addDataDialog.show = false;
+};
+
 const share = async () => {
   if (!route.query.id) {
     MessagePlugin.error('请先保存!');

+ 3 - 1
vite.config.ts

@@ -41,7 +41,9 @@ function fileList(): Plugin {
         const url = req.url as string;
 
         if (
-          (url.startsWith('/svg/') || url.startsWith('/png/')) &&
+          (url.startsWith('/svg/') ||
+            url.startsWith('/png/') ||
+            url.startsWith('/material/')) &&
           url.endsWith('/')
         ) {
           const pwd = decodeURI(path.join(__dirname, 'public', url));

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff