ananzhusen 1 mesiac pred
rodič
commit
8dec22ac58

+ 414 - 0
src/services/handle3d.ts

@@ -0,0 +1,414 @@
+/** 3D数据加密解密 */
+import CryptoJS from "crypto-js";
+
+/** 部署时获取资源路径以及数据 */
+export const getResource = (data: any, path: string, type: 'deploy' | 'toVue2' | 'toVue3' | 'toReact'|'deployAsZip') => {
+  const _data = JSON.parse(
+    JSON.stringify(parseData(data)).replace(
+      /\?versionId=([^\"]+)/g,
+      ""
+    )
+  );
+  let resultData: any[] = [];
+  if (type === "deploy") {
+    const result = deploy(_data, "/view");
+    resultData = [
+      ...result.resources,
+      { path: `/view/projects/${path}`, data: result.data },
+    ];
+  } else if (
+    type === "toVue3" ||
+    type === "toVue2" ||
+    type === "toReact"
+  ) {
+    const storePath = {
+      toVue3: "/vue3/public/view/",
+      toVue2: "/vue2/public/view/",
+      toReact: "/react/public/view/",
+    }[type];
+    const result = deploy(_data, storePath);
+    resultData = [
+      ...result.resources,
+      {
+        path: joinUrl(`${storePath}projects/${path}`),
+        data: result.data,
+      },
+    ];
+  } else if (type === 'deployAsZip') {
+    const result = deployAsZip(_data);
+    resultData = [
+      ...result.resources,
+      { path: `${path}`, data: result.data },
+    ];
+  }
+  return resultData;
+};
+
+const parseData = (data: any) => {
+  if (typeof data === "string") {
+    data = data.startsWith("{") ? data : decrypto(data);
+    return JSON.parse(data);
+  }
+  return data;
+};
+
+/** 加密,data为未加密的json数据 */
+const crypto = (data: string) => {
+  return c(data);
+};
+
+/** 解密,data为加密后的json数据 */
+const decrypto = (data: string) => {
+  return d(data);
+};
+
+function s16() {
+  let chars = [
+    "0",
+    "1",
+    "2",
+    "3",
+    "4",
+    "5",
+    "6",
+    "7",
+    "8",
+    "9",
+    "A",
+    "B",
+    "C",
+    "D",
+    "E",
+    "F",
+    "G",
+    "H",
+    "I",
+    "J",
+    "K",
+    "L",
+    "M",
+    "N",
+    "O",
+    "P",
+    "Q",
+    "R",
+    "S",
+    "T",
+    "U",
+    "V",
+    "W",
+    "X",
+    "Y",
+    "Z",
+    "a",
+    "b",
+    "c",
+    "d",
+    "e",
+    "f",
+    "g",
+    "h",
+    "i",
+    "j",
+    "k",
+    "l",
+    "m",
+    "n",
+    "o",
+    "p",
+    "q",
+    "r",
+    "s",
+    "t",
+    "u",
+    "v",
+    "w",
+    "x",
+    "y",
+    "z",
+  ];
+  let strs = "";
+  for (let i = 0; i < 16; i++) {
+    let id = Math.ceil(Math.random() * 61);
+    strs += chars[id];
+  }
+  return strs;
+}
+
+const a = (word: string, k: string, i: string) => {
+  const srcs = CryptoJS.enc.Utf8.parse(word);
+  const encrypted = CryptoJS.AES.encrypt(srcs, CryptoJS.enc.Utf8.parse(k), {
+    iv: CryptoJS.enc.Utf8.parse(i),
+    mode: CryptoJS.mode.CBC,
+    padding: CryptoJS.pad.Pkcs7,
+  });
+  return encrypted.ciphertext.toString().toUpperCase();
+};
+
+const b = (word: string, k: string, i: string) => {
+  const encryptedHexStr = CryptoJS.enc.Hex.parse(word);
+  const srcs = CryptoJS.enc.Base64.stringify(encryptedHexStr);
+  const decrypt = CryptoJS.AES.decrypt(srcs, CryptoJS.enc.Utf8.parse(k), {
+    iv: CryptoJS.enc.Utf8.parse(i),
+    mode: CryptoJS.mode.CBC,
+    padding: CryptoJS.pad.Pkcs7,
+  });
+  const decryptedStr = decrypt.toString(CryptoJS.enc.Utf8);
+  return decryptedStr.toString();
+};
+
+const c = (word: string): string => {
+  const k = s16().toUpperCase();
+  const i = s16().toUpperCase();
+  const d = a(word, k, i);
+  return k + d + i;
+};
+
+const d = (word: string): string => {
+  const k = word.substring(0, 16);
+  const i = word.substring(word.length - 16);
+  return b(word.substring(16, word.length - 16), k, i);
+};
+
+const deploy = (data: any, storePath = "") => {
+  const { scenes, resourceUrls } = data;
+  Object.keys(resourceUrls).forEach((key) => {
+    const url = resourceUrls[key];
+    if (
+      typeof url === "string" &&
+      ["http:", "https:", "/"].some((host) => url.startsWith(host))
+    ) {
+      const index = url.lastIndexOf("/assets/");
+      if (index !== -1) {
+        // 从/assets开始截取
+        resourceUrls[key] = url.substring(index + 1);
+      }
+    }
+  });
+  // 加载场景用到的所有资源
+  const urlCacheMap = new Map<string, string>();
+  // 使用相对路径
+  const filePath = "projects/assets/files/";
+  const transUrl = (url: string) => {
+    if (urlCacheMap.has(url)) {
+      return urlCacheMap.get(url)!;
+    }
+    if (
+      typeof url === "string" &&
+      ["http:", "https:", "/"].some((host) => url.startsWith(host))
+    ) {
+      const suffix = filesuffix(url, true);
+      const fullpath = joinUrl(filePath, createId() + suffix);
+      urlCacheMap.set(url, fullpath);
+      return fullpath;
+    }
+    return url;
+  };
+
+  for (const sceneData of scenes) {
+    if (!sceneData) {
+      continue;
+    }
+    const {
+      nodes = [],
+      scene = {},
+      glbMap = {},
+      textures = {},
+      materials = {},
+      DOMDatas = [],
+    } = sceneData;
+    Object.keys(glbMap).forEach((glbId) => {
+      const { url, name } = glbMap[glbId];
+      const newUrl = transUrl(url + name);
+      glbMap[glbId].url = filepath(newUrl);
+      glbMap[glbId].name = filename(newUrl, true);
+    });
+
+    for (const node of [
+      scene,
+      ...nodes,
+      ...DOMDatas,
+      ...Object.keys(materials).map((id) => materials[id]),
+      ...Object.keys(textures).map((id) => textures[id]),
+    ]) {
+      convertResourceAddress(node, transUrl);
+    }
+  }
+  const _data = JSON.stringify(data);
+  return {
+    resources: [...urlCacheMap].map(([url, path]) => ({
+      url,
+      path: joinUrl(storePath, path),
+    })),
+    data: crypto(_data),
+  };
+};
+
+const deployAsZip = (
+  data: any,
+  rootPath: string = ""
+) => {
+  delete data.resourceUrls;
+  const filePath = joinUrl(rootPath, "/files/");
+  const fileUrlMap: { [url: string]: string } = {};
+  // 加载场景用到的所有资源
+  const transUrl = (url: string) => {
+    if (!url || fileUrlMap[url]) {
+      return;
+    }
+    if (
+      typeof url === "string" &&
+      ["http:", "https:", "/", "projects/assets/"].some((host) =>
+        url.startsWith(host)
+      )
+    ) {
+      fileUrlMap[url] = createId();
+    }
+  };
+  const { scenes = [] } = data;
+  for (const sceneData of scenes) {
+    if (!sceneData) {
+      continue;
+    }
+    const {
+      nodes = [],
+      scene = {},
+      glbMap = {},
+      textures = {},
+      materials = {},
+      DOMDatas = [],
+    } = sceneData;
+    Object.keys(glbMap).forEach((glbId) => {
+      const { url, name } = glbMap[glbId];
+      transUrl(url + name);
+    });
+    for (const node of [
+      scene,
+      ...nodes,
+      ...DOMDatas,
+      ...Object.keys(materials).map((id) => materials[id]),
+      ...Object.keys(textures).map((id) => textures[id]),
+    ]) {
+      convertResourceAddress(node, transUrl, false);
+    }
+  }
+  return {
+    resources: Object.keys(fileUrlMap).map((url) => ({
+      url,
+      path: joinUrl(filePath, fileUrlMap[url]),
+    })),
+    data: JSON.stringify({
+      data: crypto(JSON.stringify(data)),
+      map: fileUrlMap,
+    }),
+  };
+};
+
+const filesuffix = (str: string, withDecimalPoint = false) => {
+  const paths = getUrlNoParam(str).split("/");
+  const name = paths[paths.length - 1];
+  const i = name.lastIndexOf(".");
+  if (i === -1) {
+    return "";
+  }
+  return name.substring(withDecimalPoint ? i : i + 1).toLocaleLowerCase();
+};
+
+const getUrlNoParam = (url: string) => {
+  return url.split("?")[0];
+};
+
+const joinUrl = (...urls: string[]) => {
+  if (urls.length < 2) {
+    return urls[0] || "";
+  }
+  return urls
+    .map((url, index) => {
+      if (index === 0) {
+        return url.replace(/\/$/, "");
+      }
+      if (index === urls.length - 1) {
+        return url.replace(/^\//, "");
+      }
+      return url.replace(/^\//, "").replace(/\/$/, "");
+    })
+    .join("/");
+};
+
+const createId = (prefix?: string) => {
+  return `${prefix || ""}${RandomGUID()}`;
+};
+
+const RandomGUID = () => {
+  return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
+    const r = (Math.random() * 16) | 0,
+      v = c === "x" ? r : (r & 0x3) | 0x8;
+    return v.toString(16);
+  });
+};
+
+const filepath = (str: string, trimDelimiter = false) => {
+  const paths = str.split("/");
+  paths[paths.length - 1] = "";
+  if (trimDelimiter) {
+    if (paths[0] === "") {
+      paths.shift();
+    }
+    if (paths[paths.length - 1] === "") {
+      paths.pop();
+    }
+  }
+  return paths.join("/");
+};
+
+const filename = (str: string, withSuffix = false) => {
+  const paths = getUrlNoParam(str).split("/");
+  const name = paths[paths.length - 1];
+  if (withSuffix) {
+    return name;
+  }
+  const i = name.lastIndexOf(".");
+  if (i === -1) {
+    return name;
+  }
+  return name.substring(0, i);
+};
+
+const urlProps = [
+  "url",
+  "imageSource",
+  "skyboxUrl",
+  "hdrUrl",
+  "colorGradingTexture",
+  "source",
+  "backgroundImage",
+];
+const convertResourceAddress = (
+  data: any,
+  transFn: (url: string) => any,
+  reset = true
+) => {
+  for (const urlProp of urlProps) {
+    if (urlProp in data === false) {
+      continue;
+    }
+    const url = data[urlProp] || "";
+    const newUrl = transFn(url);
+    if (reset) {
+      data[urlProp] = newUrl;
+    }
+  }
+  if (data.contents) {
+    data.contents.forEach((content: any) =>
+      convertResourceAddress(content, transFn, reset)
+    );
+  }
+  if (data.__initOption) {
+    convertResourceAddress(data.__initOption, transFn, reset);
+  }
+  if (data.children) {
+    data.children.forEach((child: any) =>
+      convertResourceAddress(child, transFn, reset)
+    );
+  }
+};

+ 1674 - 0
src/services/project.ts

@@ -0,0 +1,1674 @@
+import { reactive, ref } from 'vue';
+import { addCollection, updateCollection, getCollection, cdn } from './api';
+import {
+  getPayList,
+  getDownloadList,
+  Frame,
+  get2dComponentJs,
+  getTemPngs,
+  getDeployPngs,
+  img_cdn,
+  img_upCdn,
+  getFrameDownloadList,
+  getComponentPurchased,
+  getGoods,
+  getDeployAccess,
+} from './download';
+import { isDownload, rootDomain } from './defaults';
+import { newFile, queryURLParams } from './common';
+import axios from 'axios';
+import { MessagePlugin } from 'tdesign-vue-next';
+import { useUser } from '@/services/user';
+import { getResource } from './handle3d';
+import { s8 } from './random';
+import { load3d } from './load3d';
+import router from '@/router';
+import { noLoginTip } from './utils';
+import localforage from 'localforage';
+
+export const activedGroup = ref('');
+export const activeAssets = ref('system');
+
+const { user } = useUser();
+
+const isVip = () => {
+  if (!(user && user.id)) {
+    MessagePlugin.warning(noLoginTip);
+    return;
+  }
+
+  if (!user.vip) {
+    MessagePlugin.info('需要开通会员~');
+    return;
+  }
+  return true;
+};
+
+const taskDialog = reactive({
+  visible: false,
+  tasks: [],
+  cancel: false,
+  reload: false,
+  downloadList: [],
+});
+
+export const useTask = () => {
+  const setTask = (key, value) => {
+    taskDialog[key] = value;
+  };
+
+  return {
+    taskDialog,
+    setTask,
+  };
+};
+
+export const reDownload = async () => {
+  taskDialog.tasks[1].status = 'process';
+  taskDialog.reload = false;
+  let result = await saveZip(taskDialog.downloadList);
+  if (result === 'error') {
+    taskDialog.tasks[1].status = 'error';
+    taskDialog.reload = true;
+    return;
+  }
+  taskDialog.tasks[1].status = 'success';
+
+  MessagePlugin.closeAll();
+  taskDialog.tasks[2].status = 'success';
+  MessagePlugin.success('下载成功,请在浏览器下载列表中查看');
+  taskDialog.visible = false;
+};
+
+const payDialog = reactive({
+  visible: false,
+  data: {
+    price: 0,
+    list: [],
+    hasJs: false,
+    checked: true,
+  },
+  token: false,
+});
+
+export const usePay = () => {
+  const setPay = (key, value) => {
+    payDialog[key] = value;
+  };
+
+  return {
+    payDialog,
+    setPay,
+  };
+};
+
+const prePay = async () => {
+  if (user.vipData?.deployment) {
+    //可视化会员
+    if ((user.vipData.deployment & 128) === 128) {
+      //直接下载
+      payDialog.token = true;
+      doDownloadProjectSource();
+      return;
+    }
+  }
+  //TODO 付费
+  let purchased = await getComponentPurchased({
+    pngs: [...prePayList.pngs],
+    jsPens: [],
+    iotPens: [],
+    svgPens: [],
+  });
+  if (
+    [...prePayList.jsPens].length ||
+    [...prePayList.iotPens].length ||
+    [...prePayList.svgPens].length
+  ) {
+    payDialog.data.hasJs = true;
+  } else {
+    payDialog.data.hasJs = false;
+  }
+  let names = purchased.map((item) => item.name);
+  let goods = await getGoods();
+  let list = [...prePayList.pngs].filter((item) => !names.includes(item));
+  if (!list.length) {
+    //直接下载
+    doDownloadProjectSource();
+  } else {
+    let unitPrice = goods.find((item) => item.type === '图片图元').unitPrice;
+    payDialog.data.price = unitPrice * list.length;
+    payDialog.data.checked = true;
+    payDialog.visible = true;
+    payDialog.data.list = list;
+  }
+};
+
+export const payProjectDialog = reactive({
+  visible: false,
+  data: {
+    price: 0,
+    payAll: false,
+    list: [],
+    title: '',
+    checked: true,
+    payList: [],
+  },
+  token: '',
+});
+
+const prePayProject = async () => {
+  if (user.vipData?.deployment) {
+    //可视化会员
+    if ((user.vipData.deployment & 128) === 128) {
+      //直接下载
+      payDialog.token = true;
+      doDownloadProject();
+      return;
+    }
+  }
+
+  const result: {
+    token?: string;
+    vip?: boolean;
+    error?: string;
+    deployment?: any;
+  } = await getDeployAccess(
+    Object.keys(page3dDatas).length ? '3d,v' : 'v',
+    prePayList
+  );
+  if (result.vip) {
+    if (result.token) {
+      payProjectDialog.token = result.token;
+      doDownloadProject();
+      return;
+    }
+  }
+
+  //图形库
+  let _goods = await getGoods();
+  let purchasedList = await getComponentPurchased(prePayList);
+  payProjectDialog.data.list = [];
+  // return;
+  payProjectDialog.data.payAll = false;
+  // _goods.forEach((goods)=>{
+
+  for (let i = 0; i < _goods.length; i++) {
+    let goods = _goods[i];
+    let purchased = purchasedList?.filter((_item) => _item.type === goods.type);
+    let names = purchased.map((item) => item.name);
+    let list = [];
+    if (goods.type === '图片图元') {
+      list = [...prePayList.pngs];
+    } else if (goods.type === 'JS线性图元') {
+      list = [...prePayList.jsPens];
+    } else if (goods.type === 'SVG线性图元') {
+      list = [...prePayList.svgPens];
+    } else if (goods.type === '控件') {
+      list = [...prePayList.iotPens];
+    }
+    let num = 0;
+    if (goods.type === 'SVG线性图元' && [...prePayList.svgPens].includes('*')) {
+      //需要购买全部
+      num = goods.count - names.length;
+      payProjectDialog.data.payAll = true;
+    } else {
+      // list.forEach((item)=>{
+      for (let j = 0; j < list.length; j++) {
+        let item = list[j];
+        if (!names.includes(item)) {
+          if (goods.type === '控件') {
+            payProjectDialog.data.list.push({
+              name: item,
+            });
+          } else if (goods.type === 'JS线性图元') {
+            payProjectDialog.data.list.push({
+              svg: globalThis.jsPensMap ? globalThis.jsPensMap[item] : item,
+            });
+          } else {
+            payProjectDialog.data.list.push({
+              img: item,
+            });
+          }
+          num += 1;
+        }
+      }
+    }
+    goods.num = num;
+  }
+  let price = 0;
+  for (let i = 0; i < _goods.length; i++) {
+    price += _goods[i].num * _goods[i].unitPrice;
+  }
+  payProjectDialog.data.price = price;
+
+  let payList = [];
+  let names = purchasedList.map((item) => item.name);
+  prePayList.pngs.forEach((item) => {
+    if (!names.includes(item)) {
+      payList.push({
+        type: '图片图元',
+        name: item,
+      });
+    }
+  });
+  prePayList.jsPens.forEach((item) => {
+    if (!names.includes(item)) {
+      payList.push({
+        type: 'JS线性图元',
+        name: item,
+      });
+    }
+  });
+
+  prePayList.iotPens.forEach((item) => {
+    if (!names.includes(item)) {
+      payList.push({
+        type: '控件',
+        name: item,
+      });
+    }
+  });
+  if ([...prePayList.svgPens].includes('*')) {
+    payList.push({
+      type: 'SVG线性图元',
+    });
+  } else {
+    prePayList.svgPens.forEach((item) => {
+      if (!names.includes(item)) {
+        payList.push({
+          type: 'SVG线性图元',
+          name: item,
+        });
+      }
+    });
+  }
+  payProjectDialog.data.checked = true;
+  payProjectDialog.data.payList = payList;
+
+  switch (downloadType) {
+    case Frame.html:
+      payProjectDialog.data.title = '下载离线部署包';
+      break;
+    case Frame.vue2:
+      payProjectDialog.data.title = '下载vue2组件包';
+      break;
+    case Frame.vue3:
+      payProjectDialog.data.title = '下载vue3组件包';
+      break;
+    case Frame.react:
+      payProjectDialog.data.title = '下载react组件包';
+      break;
+  }
+  if (payProjectDialog.data.price === 0) {
+    doDownloadProject();
+  } else {
+    payProjectDialog.visible = true;
+  }
+};
+
+const data = reactive({
+  id: undefined,
+  tree: [
+    // {
+    //   value: '1',
+    //   label: '文件夹1',
+    //   children: [
+    //     {
+    //       value: '11',
+    //       label: '页面1',
+    //       type: 'page',
+    //       pageId: '1',
+    //       pageType: '', //v/2d/3d
+    //       tag: '首页',
+    //     },
+    //   ],
+    // },
+    // {
+    //   value: '2',
+    //   label: '文件夹2',
+    //   children: [
+    //     {
+    //       value: '21',
+    //       label: '页面1',
+    //       type: 'page',
+    //     },
+    //     {
+    //       value: '22',
+    //       label: '页面1',
+    //       type: 'page',
+    //     },
+    //   ],
+    // },
+  ],
+  actived: [],
+});
+
+export const graphicsRef = ref(null);
+const tree = ref();
+const activeNode = ref(null);
+let activePage = null;
+
+//新建工程
+export const newProject = () => {
+  //新建页面
+  newFile();
+  //新建工程
+  data.tree = [
+    {
+      value: s8(),
+      label: '文件夹',
+      children: [
+        {
+          value: s8(),
+          label: '页面',
+          type: 'page',
+          pageType: '', //v/2d/3d
+        },
+      ],
+    },
+  ];
+  data.id = undefined;
+
+  //展示工程
+  activeAssets.value = 'structure';
+  graphicsRef.value.assetsChange(activeAssets.value);
+  activedGroup.value = '工程';
+};
+
+const getHomePage = (treeData) => {
+  for (let i = 0; i < treeData.length; i++) {
+    if (treeData[i].type === 'page' && treeData[i].tag === '首页') {
+      return treeData[i];
+    }
+    if (treeData[i].children) {
+      const page = getHomePage(treeData[i].children);
+      if (page) {
+        return page;
+      }
+    }
+  }
+};
+
+export const loadProject = () => {
+  if (!isVip()) {
+    return;
+  }
+  const input = document.createElement('input');
+  input.type = 'file';
+  input.onchange = async (event) => {
+    const elem = event.target as HTMLInputElement;
+    if (elem.files && elem.files[0]) {
+      // 路由跳转 可能在 openFile 后执行
+      if (elem.files[0].name.endsWith('.zip')) {
+        let projectData = await uploadProjectSource(elem.files[0]);
+        if (projectData) {
+          let home = getHomePage(projectData.data);
+          if (!home) {
+            home = projectData.data[0].children?.length
+              ? projectData.data[0].children[0]
+              : projectData.data[0];
+          }
+          router.push({
+            path: '/',
+            query: {
+              r: Date.now() + '',
+              id: home.pageId,
+            },
+          });
+        }
+
+        //获取首页
+        if (activedGroup.value !== '工程') {
+          activeAssets.value = 'structure';
+          graphicsRef.value.assetsChange(activeAssets.value);
+          activedGroup.value = '工程';
+        }
+      } else {
+        MessagePlugin.info('打开工程文件只支持 zip 格式');
+      }
+    }
+  };
+  input.click();
+};
+
+export const useProject = () => {
+  const getProject = async (id: string) => {
+    if (id === data.id) {
+      setActive(meta2d.store.data.id);
+      return;
+    }
+    const ret: any = await getCollection('web', id);
+    data.tree = ret.data;
+    data.id = ret.id;
+    setActive(meta2d.store.data.id);
+  };
+
+  const setActive = (pageId) => {
+    const find = (treeData) => {
+      for (let i = 0; i < treeData.length; i++) {
+        if (treeData[i].pageId === pageId) {
+          data.actived = [treeData[i].value];
+          return;
+        }
+        if (treeData[i].children) {
+          find(treeData[i].children);
+        }
+      }
+    };
+    find(data.tree);
+  };
+
+  const setProject = async (data) => {};
+
+  const saveProject = async (id?: string, over?: boolean) => {
+    if (!data.tree.length) {
+      //无数据
+      return;
+    }
+    if (!activeNode.value && !over) {
+      return;
+    }
+    if (id) {
+      if (!tree.value) {
+        // let activePage = getLeaf(activeNode.value.data.value,data.tree);
+        setData(data.tree, activeNode.value.data.value, 'pageId', id);
+        // activePage.pageId = id;
+      } else {
+        await activeNode.value.setData({ pageId: id });
+      }
+      setData(data.tree, activeNode.value.data.value, 'pageId', id);
+    }
+    // return
+
+    const treeData = (await tree?.value?.getTreeData?.()) || data.tree;
+    if (!(treeData && treeData.length > 0)) {
+      return;
+    }
+
+    const _data = {
+      id: data.id,
+      data: treeData,
+      name: 'test',
+      image: '',
+    };
+    // if(data.id&&!id){
+    //   // 已经有工程 没有 不需要更新数据
+    //   return data.id;
+    // }
+    if (data.id) {
+      await updateCollection('web', _data);
+      return data.id;
+    } else {
+      const ret: any = await addCollection('web', _data);
+      data.id = ret.id;
+      return ret.id;
+    }
+  };
+
+  const clearProject = () => {
+    data.id = undefined;
+    data.tree = [];
+  };
+
+  const setActivedNode = (node) => {
+    activeNode.value = node;
+    // if(!data.tree){
+
+    // }
+    // activePage = getLeaf(node.data.value,data.tree);
+  };
+  return {
+    data,
+    tree,
+    activePage,
+    activeNode,
+    saveProject,
+    getProject,
+    setProject,
+    clearProject,
+    setActivedNode,
+  };
+};
+
+const getLeaf = (id, arr: any) => {
+  arr.forEach((item) => {
+    if (item.children) {
+      getLeaf(id, item.children);
+    } else {
+      if (item.value === id) {
+        return item;
+      }
+    }
+  });
+};
+
+const setData = (arr: any, id, key, value) => {
+  arr.forEach((item) => {
+    if (item.children) {
+      setData(item.children, id, key, value);
+    } else {
+      if (item.value === id) {
+        item[key] = value;
+      }
+    }
+  });
+};
+
+let downloadType: Frame = Frame.html;
+let pageDatas = {};
+let page3dDatas = {};
+let page2dDatas = {};
+
+const setDownloadType = (type: Frame) => {
+  downloadType = type;
+};
+
+const prePayList = reactive({
+  pngs: new Set<string>(),
+  jsPens: new Set<string>(),
+  iotPens: new Set<string>(),
+  svgPens: new Set<string>(),
+});
+
+let downloadList = [];
+
+const isV = (url) => {
+  return (
+    url.indexOf(`v${rootDomain}`) !== -1 ||
+    url.indexOf(`view${rootDomain}/v`) !== -1 ||
+    url.indexOf(`/view/v`) !== -1 ||
+    url.indexOf(`/preview`) !== -1
+  );
+};
+
+const is3D = (url) => {
+  return (
+    url.indexOf(`3d${rootDomain}`) !== -1 ||
+    url.indexOf(`view${rootDomain}/3d`) !== -1 ||
+    url.indexOf(`/view/3d`) !== -1
+  );
+};
+
+const is2D = (url) => {
+  return (
+    url.indexOf(`2d${rootDomain}`) !== -1 ||
+    url.indexOf(`view${rootDomain}/2d`) !== -1 ||
+    url.indexOf(`/view/2d`) !== -1
+  );
+};
+
+const getPageDataByUrl = async (url) => {
+  let id = queryURLParams(url.split('?')[1])?.id;
+  if (isV(url)) {
+    if (!pageDatas[id]) {
+      let data = await getCollection('v', id);
+      pageDatas[id] = data.data;
+    }
+    //TODO 更改图纸中的iframe地址
+    if (downloadType === Frame.html) {
+      return `/view?data=${id}`; //`/view/v?data=${id}`
+    } else {
+      return `/2d?id=${id}`;
+    }
+  } else if (is3D(url)) {
+    if (!page3dDatas[id]) {
+      let data = await getCollection('3d', id);
+      page3dDatas[id] = data.data;
+    }
+    if (downloadType === Frame.html) {
+      return `/view?data=${id}`; //`/view/v?data=${id}`
+    } else {
+      return `/view/index.html?data=${id}`;
+    }
+  } else if (is2D(url)) {
+    if (!page2dDatas[id]) {
+      let data = await getCollection('2d', id);
+      page2dDatas[id] = data.data;
+    }
+    if (downloadType === Frame.html) {
+      return `/view?data=${id}`; //`/view/v?data=${id}`
+    } else {
+      return `/2d?id=${id}`;
+    }
+  }
+};
+
+//获取工程包含的所有数据内容
+/**
+ *
+ * @param replace 是否替换地址
+ */
+const getDatas = async (replace = true) => {
+  const list = treeToList(data.tree).filter((item) => item.type && item.pageId);
+  pageDatas = {};
+  page3dDatas = {};
+  page2dDatas = {};
+
+  await Promise.all(
+    list.map(async (item: any) => {
+      let v = await getCollection('v', item.pageId);
+      pageDatas[item.pageId] = v.data;
+      if (v.data?.pens?.length) {
+        // 内嵌iframe网页
+        const pens = v.data.pens.filter(
+          (pen) =>
+            pen.name === 'iframe' &&
+            (isV(pen.iframe) || is3D(pen.iframe) || is2D(pen.iframe))
+        );
+        for (let i = 0; i < pens.length; i++) {
+          let pen = pens[i];
+          let iframe = await getPageDataByUrl(pen.iframe);
+          if (replace) {
+            pen.iframe = iframe;
+          }
+        }
+
+        for (let i = 0; i < v.data.pens.length; i++) {
+          let pen = v.data.pens[i];
+          if (pen.events?.length) {
+            for (let j = 0; j < pen.events.length; j++) {
+              //打开弹框
+              const actions = pen.events[j].actions; //.filter((action)=>action.action === 14);
+              for (let k = 0; k < actions.length; k++) {
+                let action = actions[k];
+                if (action.action === 14) {
+                  //打开弹框
+                  let iframe = await getPageDataByUrl(action.params);
+                  if (replace) {
+                    action.params = iframe;
+                  }
+                } else if (action.action === 1) {
+                  //更改iframe属性
+                  if (action.value?.iframe) {
+                    let iframe = await getPageDataByUrl(action.value.iframe);
+                    if (replace) {
+                      action.value.iframe = iframe;
+                    }
+                  }
+                }
+              }
+            }
+          }
+          if (pen.triggers?.length) {
+            for (let j = 0; j < pen.triggers.length; j++) {
+              const trigger = pen.triggers[j];
+              for (let k = 0; k < trigger.status.length; k++) {
+                let status = trigger.status[k];
+                for (let l = 0; l < status.actions.length; l++) {
+                  let action = status.actions[l];
+                  if (action.action === 14) {
+                    //打开弹框
+                    // action.params = await getPageDataByUrl(action.params);
+                    let iframe = await getPageDataByUrl(action.params);
+                    if (replace) {
+                      action.params = iframe;
+                    }
+                  } else if (action.action === 1) {
+                    //更改iframe属性
+                    if (action.value?.iframe) {
+                      // action.value.iframe = await getPageDataByUrl(action.value.iframe);
+                      let iframe = await getPageDataByUrl(action.value.iframe);
+                      if (replace) {
+                        action.value.iframe = iframe;
+                      }
+                    }
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    })
+  );
+};
+
+const getEnterprisePens = async () => {
+  let pngs = [],
+    jsPens = [],
+    iotPens = [],
+    svgPens = [];
+  for (let key in pageDatas) {
+    const pageData = pageDatas[key];
+    const payList = getPayList(pageData);
+    pngs.push(...payList.pngs);
+    jsPens.push(...payList.jsPens);
+    iotPens.push(...payList.iotPens);
+    svgPens.push(...payList.svgPens);
+  }
+
+  for (let key in page2dDatas) {
+    const pageData = page2dDatas[key];
+    const payList = getPayList(pageData);
+    pngs.push(...payList.pngs);
+    jsPens.push(...payList.jsPens);
+    iotPens.push(...payList.iotPens);
+    svgPens.push(...payList.svgPens);
+  }
+
+  prePayList.pngs = new Set(pngs);
+  prePayList.jsPens = new Set(jsPens);
+  prePayList.iotPens = new Set(iotPens);
+  prePayList.svgPens = new Set(svgPens);
+};
+
+// 下载工程
+export const downloadProject = async (type: Frame) => {
+  if (!isVip()) {
+    return;
+  }
+  downloadType = type;
+  //通过工程获取所有的页面数据 ,包括2d 3d v
+  await getDatas();
+  if (isDownload) {
+    //安装包
+    doDownloadProject();
+    return;
+  }
+  //获取 2d 大屏企业图形库
+  await getEnterprisePens();
+  await prePayProject();
+};
+
+export const doDownloadProject = async () => {
+  //TODO 验证付费图形库
+  taskDialog.visible = true;
+  taskDialog.tasks = [
+    {
+      status: 'prepare',
+      title: '获取工程数据资源',
+    },
+    {
+      status: 'prepare',
+      title: '下载工程资源',
+    },
+    {
+      status: 'prepare',
+      title: '完成',
+    },
+  ];
+  taskDialog.tasks[0].status = 'process';
+
+  let dList = [];
+  let flag_3d = false;
+  if (downloadType === Frame.html) {
+    for (let key in pageDatas) {
+      dList.push(...(await getDownloadList(pageDatas[key], key, false)));
+    }
+    for (let key in page2dDatas) {
+      dList.push(...(await getDownloadList(page2dDatas[key], key, false)));
+    }
+
+    for (let key in page3dDatas) {
+      flag_3d = true;
+      let source3dList = await getResource(
+        page3dDatas[key].data,
+        key,
+        'deploy'
+      );
+      dList.push(...source3dList);
+    }
+
+    //下载运行环境文件
+    dList.push(...getDownloadList(undefined, 'v', flag_3d));
+  } else {
+    for (let key in pageDatas) {
+      pageDatas[key].userId = user.id;
+      dList.push(
+        ...(await getFrameDownloadList(
+          pageDatas[key],
+          key,
+          downloadType,
+          false
+        ))
+      );
+    }
+    for (let key in page2dDatas) {
+      dList.push(
+        ...(await getFrameDownloadList(
+          page2dDatas[key],
+          key,
+          downloadType,
+          false
+        ))
+      );
+    }
+    for (let key in page3dDatas) {
+      flag_3d = true;
+      let source3dList = await getResource(
+        page3dDatas[key].data,
+        key,
+        'deploy'
+      );
+      dList.push(...source3dList);
+    }
+
+    //下载运行环境文件
+    dList.push(...getFrameDownloadList(undefined, 'v', downloadType, flag_3d));
+  }
+  taskDialog.tasks[0].status = 'success';
+  taskDialog.tasks[1].status = 'process';
+  downloadList = uniqueObjArrayFast(dList, 'path');
+
+  //下载列表
+  let result = await saveDownload(downloadList);
+  if (!result) {
+    return;
+  }
+  taskDialog.tasks[1].status = 'success';
+  taskDialog.tasks[2].status = 'process';
+  taskDialog.visible = false;
+};
+
+const saveDownload = async (downloadList) => {
+  const list = [...downloadList];
+  //控件
+  let jsPath = '';
+  let jsPensPath = '';
+  switch (downloadType) {
+    case Frame.html:
+      jsPath = '/view/js/2d-components.js';
+      jsPensPath = `/view/js/1.js`;
+      break;
+    case Frame.vue2:
+      jsPath = '/meta2d-vue2/public/js/2d-components.js';
+      jsPensPath = `/meta2d-vue2/public/js/1.js`;
+      break;
+    case Frame.vue3:
+      jsPath = '/meta2d-vue3/public/js/2d-components.js';
+      jsPensPath = `/meta2d-vue3/public/js/1.js`;
+      break;
+    case Frame.react:
+      jsPath = '/meta2d-react/public/js/2d-components.js';
+      jsPensPath = `/meta2d-react/public/js/1.js`;
+      break;
+  }
+  if (isDownload) {
+    // 安装包
+    list.push({
+      url: '/view/js/2d-components.js', //需要购买
+      path: jsPath,
+    });
+  } else {
+    const js = await get2dComponentJs([...prePayList.iotPens]);
+    list.push({
+      data: js,
+      path: jsPath,
+    });
+
+    ///png图形库
+    let pngs: any = {};
+    //TODO token
+    let token = '';
+    if (token) {
+      pngs = await getDeployPngs([...prePayList.pngs], token);
+    } else {
+      getTemPngs;
+      pngs = await getTemPngs([...prePayList.pngs]);
+    }
+    list.forEach((item) => {
+      if (item.url) {
+        let url = item.url.replace(img_cdn, '').replace(img_upCdn, '');
+        if (pngs[url]) {
+          item.url = pngs[url];
+        }
+      }
+    });
+    //js线性图元
+    let arr = [];
+    if (token) {
+      arr = [...prePayList.jsPens];
+    } else {
+      const res: any = await axios.post(
+        '/api/paid/2d/component?pageSize=1000',
+        {
+          type: 'JS线性图元',
+          collection: 'v',
+          id: meta2d.store.data.id,
+        }
+      );
+      let purchased = [];
+      if (res?.list && res.list.length) {
+        purchased = res.list.map((item) => item.name);
+      }
+      [...prePayList.jsPens].forEach((item) => {
+        if (purchased.includes(item)) {
+          arr.push(item);
+        }
+      });
+    }
+    const res_list: any = await axios.post('/api/2d/tools', {
+      list: arr.map((item) => {
+        return {
+          type: 'JS线性图元',
+          name: item,
+        };
+      }),
+    });
+    const json =
+      `!window.meta2dToolId&&(window.meta2dToolId="${res_list?.id}");var tmpTools=` +
+      JSON.stringify(res_list?.list) +
+      `;!window.meta2dTools&&(window.meta2dTools=[]);window.meta2dTools.push.apply(window.meta2dTools,tmpTools);`;
+
+    list.push({
+      data: json,
+      path: jsPensPath,
+    });
+
+    //SVG线性图元
+    if ([...prePayList.svgPens].length) {
+      // let purchased = data.purchasedList?.filter(
+      //   (_item) => _item.type === 'SVG线性图元'
+      // );
+      let purchased = [];
+      if (!token) {
+        const res: any = await axios.post(
+          '/api/paid/2d/component?pageSize=1000',
+          {
+            type: 'SVG线性图元',
+            collection: 'v',
+            id: meta2d.store.data.id,
+          }
+        );
+        purchased = res?.list || [];
+      }
+      //TODO
+      let count = 0; // data.goods.find((item) => item.type === 'SVG线性图元')?.count;
+      if (purchased.length === count || token) {
+        // if (purchased.length === 1 && !purchased[0].name) {
+        //已经购买全部
+        list.forEach((item) => {
+          if (
+            item.data &&
+            (item.path.indexOf('/projects/2d') !== -1 ||
+              item.path.indexOf('/projects/v') !== -1 ||
+              item.path.indexOf('/public/json') !== -1) &&
+            item.path.indexOf('/projects/v/png/') === -1 &&
+            item.path.indexOf('/projects/2d/png/') === -1
+          ) {
+            //清空所有svgpath
+            let meta2dData = JSON.parse(item.data);
+            for (let key of Object.keys(meta2dData.paths)) {
+              let path = meta2dData.paths[key];
+              if (
+                path.indexOf('-1.18Zm4-1') !== -1 ||
+                path.indexOf('-1.19Zm4-1') !== -1 ||
+                path.indexOf('2.85ZM') !== -1 ||
+                path.indexOf('-1-2.39.3') !== -1
+              ) {
+                meta2dData.paths[key] = '';
+              }
+            }
+            item.data = JSON.stringify(meta2dData);
+          }
+        });
+      } else {
+        let svgnames = purchased.map((i) => i.name);
+        list.forEach((item) => {
+          if (
+            item.data &&
+            (item.path.indexOf('/projects/2d') !== -1 ||
+              item.path.indexOf('/projects/v') !== -1 ||
+              item.path.indexOf('/public/json') !== -1) &&
+            item.path.indexOf('/projects/v/png/') === -1 &&
+            item.path.indexOf('/projects/2d/png/') === -1
+          ) {
+            //2d 图纸数据
+            let meta2dData = JSON.parse(item.data);
+            meta2dData.pens.forEach((pen) => {
+              if (pen.name === 'svgPath' && pen.svgUrl) {
+                if (svgnames.includes(pen.svgUrl.replace(img_cdn, ''))) {
+                  pen.pathId = null;
+                }
+              }
+            });
+            item.data = JSON.stringify(meta2dData);
+          }
+        });
+      }
+    }
+  }
+  //开始下载list
+  let result = await saveZip(list);
+  if (result === 'error') {
+    taskDialog.tasks[1].status = 'error';
+    taskDialog.reload = true;
+    taskDialog.downloadList = list;
+    return false;
+  }
+  MessagePlugin.closeAll();
+  MessagePlugin.success('下载成功,请在浏览器下载列表中查看');
+  return true;
+};
+
+const saveZip = async (list: any[], fileName: string = '工程下载') => {
+  //开始下载list
+  const [{ default: JSZip }, { saveAs }] = await Promise.all([
+    import('jszip'),
+    import('file-saver'),
+  ]);
+  const zip = new JSZip();
+  const _zip = zip.folder(`${fileName}`);
+
+  const results = await Promise.all(
+    list.map(async (item: any) => {
+      if (item.url) {
+        //接口请求
+        try {
+          let url = item.url.startsWith('/') ? cdn + item.url : item.url;
+          if (url.indexOf('/view/index.html') !== -1) {
+            url = url.replace('/view/index.html', '/v/view/index.html'); //线上不包含cdn的inde.html
+          }
+          if (url.includes('?')) {
+            url = url + `&r=${Date.now()}`;
+          } else {
+            url = url + `?r=${Date.now()}`;
+          }
+          let res: Blob = null;
+          let localBlob: any = await localforage.getItem(item.path);
+          if (localBlob) {
+            res = localBlob;
+          } else {
+            res = await axios.get(url, {
+              responseType: 'blob',
+            });
+          }
+          if (!res) {
+            throw new Error('请求失败');
+          }
+          localforage.setItem(item.path, res); //缓存
+          let path = item.path.split('?')[0];
+          if (path.startsWith('/')) {
+            path = path.slice(1);
+          }
+          _zip.file(path, res, { createFolders: true });
+        } catch (error) {
+          return { error: error.message }; // 返回错误信息
+        }
+      } else if (item.data) {
+        //直接写数据
+        let path = item.path;
+        if (path.startsWith('/')) {
+          path = path.slice(1);
+        }
+        _zip.file(path, item.data, { createFolders: true });
+      }
+    })
+  );
+  let errorLen = results.map((item) => item && item.error);
+  if (errorLen.length > 5) {
+    MessagePlugin.error('下载失败,请确保网络畅通');
+    return 'error';
+  }
+  //清除本地存储
+  list.forEach((item) => {
+    localforage.removeItem(item.path);
+  });
+  const blob = await zip.generateAsync({ type: 'blob' });
+  saveAs(blob, `${fileName}.zip`);
+};
+
+//遍历树
+const treeToList = (tree) => {
+  const list = [];
+  for (let i = 0; i < tree.length; i++) {
+    const item = tree[i];
+    list.push(item);
+    if (item.children) {
+      list.push(...treeToList(item.children));
+    }
+  }
+  return list;
+};
+
+//下载zip资源
+export const downloadProjectSource = async () => {
+  if (!isVip()) {
+    return; //非vip用户不能下载
+  }
+  await getDatas(false);
+  if (isDownload) {
+    //安装包
+    doDownloadProjectSource();
+    return;
+  }
+  await getEnterprisePens(); //获取企业图库
+  await prePay();
+};
+
+export const doDownloadProjectSource = async () => {
+  // prePayList.pngs.forEach
+  taskDialog.reload = false;
+  taskDialog.visible = true;
+  taskDialog.tasks = [
+    {
+      status: 'prepare',
+      title: '获取工程数据资源',
+    },
+    {
+      status: 'prepare',
+      title: '下载资源',
+    },
+    {
+      status: 'prepare',
+      title: '完成',
+    },
+  ];
+
+  taskDialog.tasks[0].status = 'process';
+  let dList = [];
+  let imgList = [];
+  for (let key in pageDatas) {
+    imgList.push(...(await getImgList(pageDatas[key])));
+  }
+  for (let key in page2dDatas) {
+    imgList.push(...(await getImgList(page2dDatas[key])));
+  }
+  imgList = uniqueObjArrayFast(imgList, 'path');
+
+  //获取去水印企业图库
+  if (imgList.length && !isDownload) {
+    let pngs: any = {};
+    if (payDialog.token) {
+      pngs = await getDeployPngs(
+        imgList.map((item) => item.url),
+        undefined
+      );
+    } else {
+      pngs = await getTemPngs(imgList.map((item) => item.url));
+    }
+    if (!pngs) {
+      pngs = {};
+    }
+    imgList.forEach((item) => {
+      if (item.url) {
+        if (pngs[item.url]) {
+          item.url = pngs[item.url];
+        }
+      }
+    });
+  }
+
+  dList.push(...imgList);
+  for (let key in pageDatas) {
+    dList.push({
+      data: JSON.stringify(pageDatas[key]),
+      path: `${key}.json`,
+    });
+  }
+  for (let key in page2dDatas) {
+    dList.push({
+      data: JSON.stringify(page2dDatas[key]),
+      path: `${key}.json`,
+    });
+  }
+  //TODO 获取zip包的内容
+  for (let key in page3dDatas) {
+    let source3dList = await getResource(
+      page3dDatas[key].data,
+      '3d-' + key,
+      'deployAsZip'
+    );
+    dList.push(...source3dList);
+  }
+  dList.push({
+    data: JSON.stringify(data.tree),
+    path: 'project.json',
+  });
+  dList = uniqueObjArrayFast(dList, 'path'); //去重
+  taskDialog.tasks[0].status = 'success';
+  taskDialog.tasks[1].status = 'process';
+
+  //下载
+  //开始下载list
+  let result = await saveZip(dList);
+  if (result === 'error') {
+    taskDialog.tasks[1].status = 'error';
+    taskDialog.reload = true;
+    taskDialog.downloadList = dList;
+    return;
+  }
+  taskDialog.tasks[1].status = 'success';
+
+  MessagePlugin.closeAll();
+  taskDialog.tasks[2].status = 'success';
+  MessagePlugin.success('下载成功,请在浏览器下载列表中查看');
+  taskDialog.visible = false;
+};
+
+//TODO 任务取消 是否需要删除已经上传的内容
+export const uploadProjectSource = async (file) => {
+  taskDialog.visible = true;
+  taskDialog.tasks = [
+    {
+      status: 'prepare',
+      title: '解析ZIP文件',
+    },
+    {
+      status: 'prepare',
+      title: '上传资源文件',
+    },
+    {
+      status: 'prepare',
+      title: '生成项目',
+    },
+    {
+      status: 'prepare',
+      title: '完成',
+    },
+  ];
+  // return;
+  //TODO 上传zip包
+  const { default: JSZip } = await import('jszip');
+  const zip = new JSZip();
+  await zip.loadAsync(file, { base64: true });
+
+  taskDialog.tasks[0].status = 'success';
+  taskDialog.tasks[1].status = 'process';
+
+  let fileName = file.name.slice(0, -4);
+  let treeData = '';
+  for (const key in zip.files) {
+    if (zip.files[key].dir) {
+      fileName = key.split('/')[0]; // 取第一个文件夹名称
+      continue;
+    }
+    if (key.endsWith('project.json')) {
+      // 工程json 文件
+      treeData = await zip.file(key).async('string');
+      break;
+    }
+  }
+
+  if (!treeData) {
+    MessagePlugin.error('这不是一个工程包,请重新选择');
+    return false;
+  }
+
+  const imgMap = {}; //新老图纸映射
+  const _2dDataMap = {}; //2d数据映射
+  const _3dIDMap = {}; //3d id映射
+  const _2dIDMap = {}; //2d id映射
+  //上传所有图片资源
+  for (const key in zip.files) {
+    if (taskDialog.cancel) {
+      return;
+    }
+    if (zip.files[key].dir) {
+      continue;
+    }
+    let _keyLower = key.toLowerCase();
+    if (
+      _keyLower.endsWith('.png') ||
+      _keyLower.endsWith('.svg') ||
+      _keyLower.endsWith('.gif') ||
+      _keyLower.endsWith('.jpg') ||
+      _keyLower.endsWith('.jpeg')
+    ) {
+      let _filename = key.substr(key.lastIndexOf('/') + 1);
+      const form = new FormData();
+      form.append('name', _filename);
+      form.append('directory', '/大屏/图片/默认');
+      form.append('shared', true + '');
+      form.append('file', await zip.file(key).async('blob'));
+      form.append('conflict', 'new');
+      const result: any = await axios.post('/api/image/upload', form);
+      let arr = key.split('/');
+      // arr.shift();
+
+      if (arr[0] === fileName) {
+        arr.shift();
+      }
+      if (arr[0] === fileName) {
+        arr.shift();
+      }
+      let _key = '/' + arr.join('/');
+
+      if (result) {
+        imgMap[_key] = result.url;
+      }
+    } else if (_keyLower.endsWith('.json')) {
+      let arr = key.split('/');
+      if (arr[0] === fileName) {
+        arr.shift();
+      }
+      if (arr[0] === fileName) {
+        arr.shift();
+      }
+      let data = await zip.file(key).async('string');
+      let id = arr[0].split('.')[0];
+      // if(!arr[0].endsWith('.json')){
+      if (!key.endsWith('project.json')) {
+        _2dDataMap[id] = data;
+      }
+      // }
+    } else {
+      //TODO 多个3d场景问题
+      if (key.indexOf('3d') !== -1 && key.indexOf('files/') === -1) {
+        let id_3d = await load3d(zip, key);
+        if (id_3d) {
+          let originId = key.split('3d-')[1];
+          _3dIDMap[originId] = id_3d;
+        }
+      }
+    }
+  }
+
+  taskDialog.tasks[1].status = 'success';
+  taskDialog.tasks[2].status = 'process';
+  if (taskDialog.cancel) {
+    return;
+  }
+  //将 图纸里面的地址替换成最新的地址
+  for (let old in imgMap) {
+    let newImg = imgMap[old];
+    for (let key in _2dDataMap) {
+      _2dDataMap[key] = _2dDataMap[key].replaceAll(old, newImg);
+    }
+  }
+  //上传所有图纸
+  //生成 老图纸和新图纸对应的map
+  let arr = Object.keys(_2dDataMap);
+  // for(let key in _2dDataMap){
+  for (let i = 0; i < arr.length; i++) {
+    if (taskDialog.cancel) {
+      return;
+    }
+    let idata = JSON.parse(_2dDataMap[arr[i]]);
+    const ret: any = await addCollection('v', {
+      data: idata,
+      image: idata.image || 'xxx',
+      name: idata.name || '新建项目',
+      folder: idata.folder,
+      system: false,
+      case: idata.case,
+    });
+    _2dIDMap[arr[i]] = ret.id;
+  }
+  treeData = JSON.parse(treeData);
+  arr = treeToList(treeData)
+    .filter((item) => item.pageId)
+    .map((item) => item.pageId);
+  deepUpdateID(treeData, _2dIDMap);
+  //上传 生成的新的工程树
+  const projectData = {
+    id: data.id,
+    data: treeData,
+    name: 'test',
+    image: '',
+  };
+  const ret: any = await addCollection('web', projectData);
+  const projectId = ret.id;
+
+  //更新图纸里面工程树id内容
+  // for(let key in _2dDataMap){
+  for (let i = 0; i < arr.length; i++) {
+    if (taskDialog.cancel) {
+      return;
+    }
+    let idata = await update2dData(_2dDataMap[arr[i]], _2dIDMap, _3dIDMap);
+    const ret: any = await updateCollection('v', {
+      data: idata,
+      id: _2dIDMap[arr[i]],
+      otherData: { projectId },
+    });
+    _2dIDMap[arr[i]] = ret.id;
+  }
+  taskDialog.tasks[2].status = 'success';
+  taskDialog.tasks[3].status = 'process';
+
+  taskDialog.tasks[3].status = 'success';
+  taskDialog.visible = false;
+  return ret;
+};
+
+//将工程树里面的图纸id替换成新的id
+const deepUpdateID = (treeData, _2dIDMap) => {
+  treeData.forEach((item) => {
+    if (item.children) {
+      deepUpdateID(item.children, _2dIDMap);
+    } else {
+      if (item.type === 'page') {
+        item.pageId = _2dIDMap[item.pageId];
+      }
+    }
+  });
+};
+
+const update2dData = async (_data, _2dIDMap, _3dIDMap) => {
+  let domain = 'https://view.le5le.com';
+  if (isDownload || location.origin.indexOf('le5le.com') === -1) {
+    domain = location.origin + '/view';
+  }
+  let data = JSON.parse(_data);
+  if (data.pens?.length) {
+    // 内嵌iframe网页
+    const pens = data.pens.filter(
+      (pen) =>
+        pen.name === 'iframe' &&
+        (isV(pen.iframe) || is3D(pen.iframe) || is2D(pen.iframe))
+    );
+    for (let i = 0; i < pens.length; i++) {
+      let pen = pens[i];
+      let id = queryURLParams(pen.iframe.split('?')[1])?.id;
+      if (is3D(pen.iframe)) {
+        pen.iframe = `${domain}/3d/?id=${_3dIDMap[id]}`;
+      } else {
+        pen.iframe = `${domain}/v/?id=${_2dIDMap[id]}`;
+      }
+    }
+
+    for (let i = 0; i < data.pens.length; i++) {
+      let pen = data.pens[i];
+      if (pen.events?.length) {
+        for (let j = 0; j < pen.events.length; j++) {
+          //打开弹框
+          const actions = pen.events[j].actions;
+          for (let k = 0; k < actions.length; k++) {
+            let action = actions[k];
+            if (action.action === 14) {
+              //打开弹框
+              if (
+                is2D(action.params) ||
+                is3D(action.params) ||
+                isV(action.params)
+              ) {
+                let id = queryURLParams(action.params.split('?')[1])?.id;
+                action.params = `${domain}/v/?id=${_2dIDMap[id]}`;
+              }
+            } else if (action.action === 1) {
+              //更改iframe属性
+              if (action.value?.iframe) {
+                if (is2D(action.value.iframe) || isV(action.value.iframe)) {
+                  let id = queryURLParams(
+                    action.value.iframe.split('?')[1]
+                  )?.id;
+                  action.value.iframe = `${domain}/v/?id=${_2dIDMap[id]}`;
+                } else if (is3D(action.value.iframe)) {
+                  let id = queryURLParams(
+                    action.value.iframe.split('?')[1]
+                  )?.id;
+                  action.value.iframe = `${domain}/3d/?id=${_3dIDMap[id]}`;
+                }
+              }
+            } else if (action.action === 13) {
+              //打开视图
+              action.value = _2dIDMap[action.value];
+            }
+          }
+        }
+      }
+      if (pen.triggers?.length) {
+        for (let j = 0; j < pen.triggers.length; j++) {
+          const trigger = pen.triggers[j];
+          for (let k = 0; k < trigger.status.length; k++) {
+            let status = trigger.status[k];
+            for (let l = 0; l < status.actions.length; l++) {
+              let action = status.actions[l];
+              if (action.action === 14) {
+                //打开弹框
+                if (
+                  is2D(action.params) ||
+                  is3D(action.params) ||
+                  isV(action.params)
+                ) {
+                  let id = queryURLParams(action.params.split('?')[1])?.id;
+                  action.params = `${domain}/v/?id=${_2dIDMap[id]}`;
+                }
+              } else if (action.action === 1) {
+                //更改iframe属性
+                if (action.value?.iframe) {
+                  if (is2D(action.value.iframe) || isV(action.value.iframe)) {
+                    let id = queryURLParams(
+                      action.value.iframe.split('?')[1]
+                    )?.id;
+                    action.value.iframe = `${domain}/v/?id=${_2dIDMap[id]}`;
+                  } else if (is3D(action.value.iframe)) {
+                    let id = queryURLParams(
+                      action.value.iframe.split('?')[1]
+                    )?.id;
+                    action.value.iframe = `${domain}/3d/?id=${_3dIDMap[id]}`;
+                  }
+                }
+              } else if (action.action === 13) {
+                //打开视图
+                action.value = _2dIDMap[action.value];
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+  return data;
+};
+
+//去重
+function uniqueObjArrayFast(arr, key) {
+  const seen = {};
+  return arr.filter((item) => {
+    const val = item[key];
+    return seen.hasOwnProperty(val) ? false : (seen[val] = true);
+  });
+}
+
+const uploadProject = async (file) => {};
+
+const getImgList = async (meta2dData) => {
+  let lists = [];
+  let img = meta2dData.bkImage;
+  if (img) {
+    if (
+      img.startsWith('/') ||
+      img.startsWith(img_cdn) ||
+      img.startsWith(img_upCdn)
+    ) {
+      let _img = img.replace(img_cdn, '').replace(img_upCdn, '');
+      if (_img.startsWith('/v/')) {
+        _img = _img.slice(2);
+      }
+      _img = decodeURIComponent(_img);
+      lists.push({
+        url: img,
+        path: _img,
+      });
+      meta2dData.bkImage = _img;
+    }
+  }
+  //图片图元(image strokeImage backgroundImage)
+  const imageKeys = ['image', 'strokeImage', 'backgroundImage'];
+  const images: string[] = [];
+  for (const pen of meta2dData.pens) {
+    for (const i of imageKeys) {
+      const image = pen[i];
+      if (image) {
+        if (
+          image.startsWith('/') ||
+          image.startsWith(img_cdn) ||
+          image.startsWith(img_upCdn)
+        ) {
+          // 只考虑相对路径下的 image ,绝对路径图片无需下载
+          let _img = image.replace(img_cdn, '').replace(img_upCdn, '');
+          if (_img.startsWith('/v/')) {
+            _img = _img.slice(2);
+          }
+          _img = decodeURIComponent(_img);
+          if (!images.includes(image)) {
+            // let _img = image.replace(cdn, '').replace(upCdn, '');
+            lists.push({
+              url: image,
+              path: _img,
+            });
+          }
+          pen[i] = _img;
+        }
+      }
+    }
+    pen.events?.forEach((event) => {
+      if (event.actions?.length) {
+        event.actions.forEach((action) => {
+          if (action.action === 1) {
+            //更改属性
+            if (action.value?.image) {
+              let image = action.value.image;
+              if (
+                image.startsWith('/') ||
+                image.startsWith(img_cdn) ||
+                image.startsWith(img_upCdn)
+              ) {
+                // 只考虑相对路径下的 image ,绝对路径图片无需下载
+                let _img = image.replace(img_cdn, '').replace(img_upCdn, '');
+                if (_img.startsWith('/v/')) {
+                  _img = _img.slice(2);
+                }
+                _img = decodeURIComponent(_img);
+                if (!images.includes(image)) {
+                  lists.push({
+                    url: image,
+                    path: _img,
+                  });
+                }
+                action.value.image = _img;
+              }
+            }
+          }
+        });
+      }
+    });
+    pen.triggers?.forEach((trigger) => {
+      trigger?.status?.forEach((state) => {
+        if (state.actions?.length) {
+          state.actions.forEach((action) => {
+            if (action.action === 1) {
+              //更改属性
+              if (action.value?.image) {
+                let image = action.value.image;
+                if (
+                  image.startsWith('/') ||
+                  image.startsWith(img_cdn) ||
+                  image.startsWith(img_upCdn)
+                ) {
+                  // 只考虑相对路径下的 image ,绝对路径图片无需下载
+                  let _img = image.replace(img_cdn, '').replace(img_upCdn, '');
+                  if (_img.startsWith('/v/')) {
+                    _img = _img.slice(2);
+                  }
+                  _img = decodeURIComponent(_img);
+                  if (!images.includes(image)) {
+                    lists.push({
+                      url: image,
+                      path: _img,
+                    });
+                  }
+                  action.value.image = _img;
+                }
+              }
+            }
+          });
+        }
+      });
+    });
+  }
+
+  return lists;
+};

+ 580 - 0
src/views/components/Project.vue

@@ -0,0 +1,580 @@
+<template>
+  <div class="content" @click="closeMenu">
+    <div class="flex between">
+      <div></div>
+      <div>
+        <t-dropdown :minColumnWidth="200" :hide-after-item-click="false">
+          <t-tooltip content="导入属性列表" placement="top">
+            <div class="icon-box">
+              <AddIcon />
+            </div>
+          </t-tooltip>
+          <t-dropdown-menu>
+            <t-dropdown-item @click="append(undefined, true)">
+              <a>新建文件夹</a>
+            </t-dropdown-item>
+            <t-dropdown-item @click="append(undefined)" divider="true">
+              <a>新建页面</a>
+            </t-dropdown-item>
+            <t-dropdown-item @click="downloadProjectSource">
+              <a>
+                <div class="flex">
+                  导出工程 <span class="flex-grow"></span>
+                <span v-if="!isDownload"><label>VIP</label></span>
+                </div>
+              </a>
+            </t-dropdown-item>
+            <t-dropdown-item @click="downloadProject(Frame.html)">
+              <a>
+                <div class="flex">
+                  导出工程离线部署包 <span class="flex-grow"></span>
+                <span v-if="!isDownload"><label>VIP</label></span>
+                </div>
+              </a>
+            </t-dropdown-item>
+            <t-dropdown-item @click="downloadProject(Frame.vue3)">
+              <a>
+                <div class="flex">
+                  导出工程Vue3组件包 <span class="flex-grow"></span>
+                <span v-if="!isDownload"><label>VIP</label></span>
+                </div>
+              </a>
+            </t-dropdown-item>
+            <t-dropdown-item @click="downloadProject(Frame.vue2)">
+              <a>
+                <div class="flex">
+                  导出工程Vue2组件包 <span class="flex-grow"></span>
+                <span v-if="!isDownload"><label>VIP</label></span>
+                </div>
+              </a>
+            </t-dropdown-item>
+            <t-dropdown-item @click="downloadProject(Frame.react)">
+              <a>
+                <div class="flex">
+                  导出工程react组件包 <span class="flex-grow"></span>
+                <span v-if="!isDownload"><label>VIP</label></span>
+                </div>
+              </a>
+            </t-dropdown-item>
+          </t-dropdown-menu>
+        </t-dropdown>
+      </div>
+    </div>
+    <t-tree
+      class="flex-grow"
+      ref="tree"
+      :data="data.tree"
+      expandAll
+      :actived="data.actived"
+      activable
+      style="padding: 0 4px 8px 8px"
+    >
+      <template #label="{ node }: any">
+        <div class="flex middle" :class="{ gray: node.data.visible === false }">
+          <FileIcon v-if="node.data.type === 'page'" />
+          <template v-else>
+            <folder-open-icon class="mr-8" v-if="node.expanded" />
+            <folder-icon class="mr-8" v-else />
+          </template>
+          <t-input
+            v-if="node.data.edited"
+            v-model="node.data.label"
+            :autofocus="true"
+            style="width: 100px"
+            @blur="onDescription(node)"
+            @enter="onDescription(node)"
+          />
+          <span
+            v-else
+            style="width: 100px"
+            @dblclick="ondblclick(node)"
+            @contextmenu="oncontextmenu($event, node)"
+            @click="onClick(node)"
+          >
+            {{ node.label }}
+          </span>
+        </div>
+      </template>
+      <!-- <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: 46px; height: 16px"
+        ></div>
+      </template> -->
+    </t-tree>
+    <t-menu
+      class="context-menu"
+      v-if="contextmenu.visible"
+      :style="contextmenu.style"
+      @change="onMenu"
+      expandType="popup"
+    >
+      <t-menu-item v-for="item in operations" :value="item.key">
+        <div class="flex">{{ item.label }}</div>
+      </t-menu-item>
+      <t-menu-item
+        v-if="current.data.type === 'page'"
+        value="createFromTemplate"
+      >
+        <div class="flex">从模版创建</div>
+      </t-menu-item>
+      <t-menu-item v-if="current.data.type === 'page'" value="quotePage">
+        <div class="flex">引用页面</div>
+      </t-menu-item>
+      <t-menu-item
+        v-if="current.data.type === 'page' && current.data.pageId"
+        value="copyPage"
+      >
+        <div class="flex">复制页面</div>
+      </t-menu-item>
+      <t-menu-item :disabled="!copyData" value="pastePage">
+        <div class="flex">粘贴页面</div>
+      </t-menu-item>
+      <t-menu-item v-if="current.data.type === 'page'" value="delete">
+        <t-tooltip
+          content="仅删除与工程的关联关系,不删除图纸"
+          placement="right"
+        >
+          <div class="flex">移除页面</div>
+        </t-tooltip>
+      </t-menu-item>
+      <t-menu-item v-else value="delete">
+        <div class="flex">删除文件夹</div>
+      </t-menu-item>
+    </t-menu>
+  </div>
+  <ProjectModal
+    v-model:visible="projectDialog.visible"
+    v-model:type="projectDialog.type"
+    @change="getScene"
+  />
+  <!-- <StepModal
+    v-model:visible="taskDialog.visible"
+    :tasks="taskDialog.tasks"
+    :reload="taskDialog.reload"
+    @cancel="setTask('cancel', true)"
+    @reDownload="reDownload"
+  /> -->
+  <ProjectPayModal
+    v-model:visible="payDialog.visible"
+    v-model:data="payDialog.data"
+    @change="doDownload"
+  />
+  <ProjectCPayModal
+    v-model:visible="payProjectDialog.visible"
+    v-model:data="payProjectDialog.data"
+    @change="doCDownload"
+  />
+</template>
+
+<script lang="ts" setup>
+import { reactive, ref, onMounted, onUnmounted, onBeforeUnmount } from 'vue';
+import {
+  FolderOpenIcon,
+  FolderIcon,
+  AddIcon,
+  FileIcon,
+} from 'tdesign-icons-vue-next';
+import { s8 } from '@/services/random';
+import { blank, useDot } from '@/services/common';
+import { MessagePlugin } from 'tdesign-vue-next';
+import { useRouter } from 'vue-router';
+import {
+  useProject,
+  useTask,
+  loadProject,
+  downloadProject,
+  downloadProjectSource,
+  doDownloadProjectSource,
+  usePay,
+  payProjectDialog,
+  doDownloadProject,
+  reDownload,
+} from '@/services/project';
+import ProjectModal from './common/ProjectModal.vue';
+import { addCollection, updateCollection, getCollection } from '@/services/api';
+import Data from './Data.vue';
+import { deepClone } from '@meta2d/core';
+import { DialogPlugin } from 'tdesign-vue-next';
+// import StepModal from './common/StepModal.vue';
+import { Frame } from '@/services/download';
+import ProjectPayModal from './common/ProjectPayModal.vue';
+import ProjectCPayModal from './common/ProjectCPayModal.vue';
+import { isDownload } from '@/services/defaults';
+
+const { taskDialog, setTask } = useTask();
+
+const { payDialog } = usePay();
+
+onBeforeUnmount(() => {
+  data.tree = tree.value.getTreeData();
+});
+
+const { data, tree, activeNode, activePage, setActivedNode, saveProject } =
+  useProject();
+const { dot } = useDot();
+const router = useRouter();
+
+const operations: any[] = [
+  {
+    label: '新建文件夹',
+    key: 'newFolder',
+  },
+  {
+    label: '新建页面',
+    key: 'newFile',
+  },
+];
+
+const onDescription = (node: any) => {
+  node.data.edited = false;
+  node.setData({ label: node.data.label });
+  dot.value = true;
+};
+
+const ondblclick = (node: any) => {
+  if (node.data.value !== data.actived[0]) {
+    return;
+  }
+  node.data.edited = true;
+};
+
+const append = async (node, folder?: boolean) => {
+  const item: any = {
+    value: s8(),
+    type: 'page',
+    label: '页面',
+  };
+  if (folder) {
+    item.label = '文件夹';
+    item.type = 'folder';
+    item.children = [];
+  }
+  if (node && node.data.type === 'page') {
+    insertAfter(node, folder);
+    return;
+  }
+  if (!node) {
+    tree.value.appendTo('', item);
+  } else {
+    tree.value.appendTo(node.value, item);
+  }
+};
+
+const pastePage = async (node) => {
+  let copyPageId = copyData.value.pageId;
+  const item = copyData.value;
+  item.value = s8();
+  item.pageId = '';
+  if (node) {
+    if (node.data.type === 'page') {
+      tree.value.insertAfter(node.value, item);
+    } else {
+      tree.value.appendTo(node.value, item);
+    }
+  }
+  data.actived = [item.value];
+  let acNode = tree.value.getItem(item.value);
+
+  setActivedNode(acNode);
+  // saveProject(undefined);
+  // return;
+  if (copyPageId) {
+    const ret: any = await getCollection('v', copyPageId);
+    const body: any = {
+      data: ret.data,
+      image: '',
+      name: ret.name,
+      folder: ret.folder,
+      template: ret.template,
+      case: ret.case,
+      otherData: {
+        projectId: data.id,
+      },
+    };
+    const ret1: any = await addCollection('v', body); // 新增
+    if (ret1.id) {
+      saveProject(ret1.id);
+      router.push({
+        path: '/',
+        query: {
+          r: Date.now() + '',
+          id: ret1.id,
+        },
+      });
+      copyData.value = null;
+    }
+  }
+};
+
+const deletePage = async (node) => {
+  if (node.data.type !== 'page') {
+    if (node.data.children?.length) {
+      MessagePlugin.info('请先删除文件夹下的页面');
+      return;
+    }
+  }
+  //仅删除图纸与工程的关联关系,不会删除图纸
+  await tree.value.remove(node.value);
+  if (node.data.type === 'page') {
+    // setActivedNode(current);
+    await saveProject(undefined, true);
+    if (node.data.pageId) {
+      await updateCollection('v', {
+        id: node.data.pageId,
+        otherData: { projectId: '' },
+      });
+    }
+  }
+};
+
+const insertAfter = async (node, folder?: boolean) => {
+  const item: any = {
+    value: s8(),
+    type: 'page',
+    label: '页面',
+  };
+  if (folder) {
+    item.label = '文件夹';
+    item.type = 'folder';
+    item.children = [];
+  }
+  if (item) {
+    await tree.value.insertAfter(node.value, item);
+    // setLabel(item.value);
+  }
+};
+
+const contextmenu = reactive<any>({
+  visible: false,
+  type: '',
+  style: {},
+});
+
+let current = null;
+
+const oncontextmenu = (e, node: any) => {
+  if (node.data.type === 'page' && node.data.value !== data.actived[0]) {
+    return;
+  }
+  contextmenu.style = {
+    left: e.clientX + 'px',
+    top: e.clientY + 'px',
+  };
+  contextmenu.visible = true;
+  current = node;
+};
+
+let copyData = ref(null);
+
+const onMenu = (val: string) => {
+  switch (val) {
+    case 'newFolder':
+      append(current, true);
+      break;
+    case 'newFile':
+      append(current);
+      break;
+    case 'delete':
+      deletePage(current);
+      break;
+    case 'createFromTemplate':
+      projectDialog.value.visible = true;
+      projectDialog.value.type = 'template';
+      break;
+    case 'copyPage':
+      copyData.value = deepClone(current.data);
+      break;
+    case 'pastePage':
+      if (copyData.value) {
+        pastePage(current);
+        // append(current);
+        // current.children[current.children.length - 1].data = copyData.value;
+      }
+      break;
+    case 'quotePage':
+      projectDialog.value.visible = true;
+      projectDialog.value.type = 'v';
+      break;
+  }
+  contextmenu.visible = false;
+};
+
+const closeMenu = () => {
+  contextmenu.visible = false;
+};
+
+let clickTimer = null;
+
+const onClick = (node) => {
+  if (clickTimer) {
+    clearTimeout(clickTimer); // 如果在双击时间内,再次点击则清除定时器
+    clickTimer = null;
+  } else {
+    clickTimer = setTimeout(() => {
+      click(node);
+      clickTimer = null;
+    }, 300);
+  }
+};
+
+const click = (node) => {
+  if (node.data.type !== 'page') {
+    // data.actived = [];
+    return;
+  }
+  if (meta2d.store.data.pens.length && dot.value) {
+    MessagePlugin.info('请先保存当前画布');
+    return;
+  }
+  data.actived = [node.value];
+  setActivedNode(node);
+  if (node.data.pageId) {
+    // 是否需要自动保存
+    open(node.data.pageId);
+  } else {
+    blank();
+    router.push({
+      path: '/',
+      query: {
+        r: Date.now() + '',
+      },
+    });
+  }
+};
+
+const open = async (id: string) => {
+  if (meta2d.store.data.pens.length && dot.value) {
+    MessagePlugin.info('请先保存当前画布');
+    return;
+  }
+  router.push({
+    path: '/',
+    query: {
+      r: Date.now() + '',
+      id,
+    },
+  });
+};
+
+const getData = () => {
+  const data = tree.value.getTreeData();
+};
+
+const projectDialog = ref({
+  visible: false,
+  type: 'template',
+  data: {},
+});
+
+const getScene = async (e) => {
+  if (projectDialog.value.type === 'template') {
+    if (meta2d.store.data.id) {
+      //先删除引用关系
+      await updateCollection('v', {
+        id: meta2d.store.data.id,
+        otherData: { projectId: '' },
+      });
+    }
+
+    const ret: any = await getCollection('v', e.id);
+    ret.data.id = undefined; //meta2d.store.data.id;
+    meta2d.open(ret.data);
+    history.replaceState({}, document.title, window.location.pathname);
+  } else if (projectDialog.value.type === 'v') {
+    //TODO 一个页面不能被多个项目引用。
+    const ret: any = await getCollection('v', e.id, 'id,other_data');
+    if (ret.otherData?.projectId) {
+      const confirmDia = DialogPlugin({
+        header: '提示',
+        body: '该页面已被引用,是否拷贝一份?',
+        confirmBtn: '是',
+        cancelBtn: '否',
+        onConfirm: async () => {
+          //如果当前已经存在id,则删除关系
+          if (meta2d.store.data.id) {
+            await updateCollection('v', {
+              id: meta2d.store.data.id,
+              otherData: { projectId: '' },
+            });
+          }
+          const ret: any = await getCollection('v', e.id);
+          ret.data.id = undefined;
+          // ret.data.id = meta2d.store.data.id;
+          meta2d.open(ret.data);
+          history.replaceState({}, document.title, window.location.pathname);
+          dot.value = true;
+          confirmDia.hide();
+          projectDialog.value.visible = false;
+        },
+        onClose: ({ e, trigger }) => {
+          confirmDia.hide();
+          projectDialog.value.visible = false;
+        },
+      });
+      return;
+    }
+    // 引用页面
+    if (meta2d.store.data.id) {
+      await updateCollection('v', {
+        id: meta2d.store.data.id,
+        otherData: { projectId: '' },
+      });
+    }
+
+    setActivedNode(current);
+    await saveProject(e.id);
+    let _data = {
+      id: e.id,
+      otherData: { projectId: data.id },
+    };
+    await updateCollection('v', _data);
+    router.push({
+      path: '/',
+      query: {
+        r: Date.now() + '',
+        id: e.id,
+      },
+    });
+  }
+  projectDialog.value.visible = false;
+};
+
+const doDownload = async () => {
+  await doDownloadProjectSource();
+};
+
+const doCDownload = async () => {
+  await doDownloadProject();
+};
+</script>
+<style lang="postcss" scoped>
+.content {
+  height: 100%;
+  .icon-box {
+    width: 24px;
+    height: 24px;
+    margin: 4px;
+    text-align: center;
+    line-height: 24px;
+    border-radius: 4px;
+
+    &:hover {
+      background: var(--td-brand-color-light);
+    }
+
+    .t-icon {
+      width: 14px;
+      height: 14px;
+    }
+  }
+  .context-menu {
+    width: 120px !important;
+    box-shadow: var(--shadow-popup);
+  }
+}
+</style>

+ 316 - 0
src/views/components/common/ProjectCPayModal.vue

@@ -0,0 +1,316 @@
+<template>
+  <t-dialog
+    v-if="props.visible"
+    v-model:visible="props.visible"
+    class="deploy-dialog"
+    :close-on-overlay-click="false"
+    :top="95"
+    :width="700"
+    :cancel-btn="null"
+    :confirm-btn="{
+      content: !props.data.checked ? '直接下载' : '去支付',
+      style: {
+        width: '110px',
+        fontWeight: 400,
+      },
+    }"
+    @cancel="close"
+    @close="close"
+    @confirm="prePay"
+  >
+    <template #header>
+      <div>
+        <span style="vertical-align: middle; font-size: 16px">{{
+          props.data.title
+        }}</span>
+        <!-- <a
+            :href="'#'"
+            target="_blank"
+            class="hover"
+          >
+            <span class="deploy-tip">如何部署集成?></span>
+          </a> -->
+      </div>
+    </template>
+    <!-- <div v-if="!data.publish&&!data.vip" class="pay-box flex">
+     <div class="pay-title">{{ payListDialog.title }}</div>
+     <div class="pay-title pay-tip" v-html="payListDialog.tip"></div>
+     <div class="pay-price" :style="{
+        color:  data.deploy.price <=0?'#6e7b9199':''
+     }">
+       <p>¥</p>
+       <p>{{ data.deploy.price }}</p>
+     </div>
+   </div> -->
+    <div class="pay-box pay-diagram">
+      <div class="pay-up flex">
+        <div class="pay-title">
+          <t-checkbox v-model="props.data.checked"> 企业图形库 </t-checkbox>
+          <ChevronDownIcon
+            @click="props.data.expend = false"
+            v-if="props.data.expend"
+          />
+          <ChevronRightIcon @click="props.data.expend = true" v-else />
+        </div>
+        <div class="pay-title pay-tip">
+          {{
+            props.data.checked
+              ? '图库去水印,一次购买,永久授权'
+              : '图库含水印,或显示为空,无商业授权'
+          }}
+        </div>
+        <div
+          class="pay-price"
+          :style="{
+            color: props.data.checked ? '' : '#6e7b9199',
+          }"
+        >
+          <p>¥</p>
+          <p>{{ props.data.checked ? props.data.price : 0 }}</p>
+        </div>
+      </div>
+      <div v-if="props.data.expend" class="pay-down">
+        <div v-for="item in props.data.list">
+          <img v-if="item.img" :src="item.img" />
+          <div v-else-if="item.svg" v-html="item.svg"></div>
+          <svg v-else class="l-icon" aria-hidden="true">
+            <use :xlink:href="'#' + iotPensMap[item.name]?.icon"></use>
+          </svg>
+        </div>
+        <p v-if="props.data.payAll">
+          【说明】检查到当前项目为老版本格式,需要购买整套SVG线性图元。
+        </p>
+      </div>
+    </div>
+    <div class="pay-footer">
+      合计
+      <div
+        :style="{
+          color: !props.data.checked ? '#6e7b9199' : '',
+        }"
+      >
+        <p>¥</p>
+        <p>{{ props.data.checked ? props.data.price : 0 }}</p>
+      </div>
+    </div>
+  </t-dialog>
+  <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="wechatPayDialog.show = false"
+    :footer="false"
+  >
+    <Pay
+      :order="wechatPayDialog.order"
+      :alipay-url="wechatPayDialog.order.alipayUrl"
+      :code-url="wechatPayDialog.order.codeUrl"
+      @success="onSuccess"
+    />
+  </t-dialog>
+</template>
+
+<script lang="ts" setup>
+import { formComponents } from '@/services/defaults';
+import { reactive, watch } from 'vue';
+import Pay from '../Pay.vue';
+import axios from 'axios';
+import { ChevronRightIcon, ChevronDownIcon } from 'tdesign-icons-vue-next';
+import { MessagePlugin } from 'tdesign-vue-next';
+
+const emit = defineEmits(['update:visible', 'change', 'success']);
+
+function close() {
+  emit('update:visible', false);
+}
+const props = defineProps<{
+  visible: boolean;
+  data: {
+    price: number;
+    payAll: boolean;
+    list: any[];
+    title: string;
+    checked: boolean;
+    expend?: boolean;
+    // purchasedList: any[];
+    payList?: any[];
+  };
+  token?: '';
+}>();
+
+watch(
+  () => props.visible,
+  (val) => {
+    if (val) {
+      getIotPensMap();
+    }
+  }
+);
+
+const wechatPayDialog = reactive({
+  show: false,
+  order: null,
+});
+
+const iotPensMap = {};
+
+const getIotPensMap = () => {
+  formComponents.forEach((item) => {
+    item.list.forEach((_item) => {
+      iotPensMap[_item.data.name] = { name: _item.name, icon: _item.icon };
+    });
+  });
+};
+
+const prePay = async () => {
+  if (props.data.checked) {
+    const res: any = await axios.post('/api/order/resource/submit', {
+      goods: {},
+      '2ds': props.data.payList,
+      collection: 'v',
+      // id:meta2d.store.data.id
+    });
+    wechatPayDialog.show = true;
+    wechatPayDialog.order = res;
+  } else {
+    emit('update:visible', false);
+    emit('change', true);
+  }
+};
+
+const onSuccess = (success: boolean) => {
+  finishPay();
+  emit('success', success);
+};
+
+const finishPay = async () => {
+  MessagePlugin.success('支付成功');
+  wechatPayDialog.show = false;
+  emit('update:visible', false);
+  emit('change', true);
+};
+</script>
+<style lang="postcss" scoped>
+.pay-box {
+  background: rgba(175, 202, 255, 0.04);
+  border-radius: 4px;
+  margin-bottom: 24px;
+  padding: 20px 24px;
+  position: relative;
+  display: flex;
+  .pay-up {
+    display: flex;
+  }
+  :deep(.t-checkbox__label) {
+    font-size: 16px;
+    color: #6e7b91;
+    margin-left: 16px;
+  }
+
+  .pay-title {
+    font-size: 16px;
+    color: #6e7b91;
+    .t-icon {
+      margin-top: -5px;
+      margin-left: 12px;
+    }
+    :deep(.t-checkbox__input) {
+      background: #fff0;
+    }
+    :deep(.t-is-checked) {
+      .t-checkbox__input {
+        background: var(--color-primary);
+      }
+    }
+  }
+  .pay-tip {
+    flex: 1;
+    text-align: right;
+    margin-right: 100px;
+    color: #fa541c;
+  }
+  .pay-price {
+    position: absolute;
+    right: 24px;
+    color: #4480f9;
+    display: flex;
+    p:nth-child(2) {
+      font-size: 20px;
+    }
+  }
+
+  .pay-down {
+    margin-top: 16px;
+    display: flex;
+    flex-wrap: wrap;
+    /* justify-content:space-between; */
+    /* padding-top:24px; */
+    max-height: 200px;
+    /* overflow-y: scroll; */
+    & > div {
+      width: 48px;
+      height: 48px;
+      background: #1c283b;
+      border-radius: 4px;
+      margin-right: 2.5px;
+      margin-bottom: 2.5px;
+      &:nth-child(12n) {
+        margin-right: 0px;
+      }
+      img {
+        margin: 4px;
+        width: 40px;
+        height: 40px;
+      }
+      .l-icon {
+        /* width: 80px; */
+        /* height: 80px; */
+        /* margin:8px; */
+        font-size: 24px;
+        margin: 12px;
+      }
+      & > div {
+        margin: 8px 12px;
+      }
+
+      :deep(svg) {
+        path {
+          stroke: var(--color) !important;
+        }
+      }
+    }
+    & > p {
+      display: block;
+      margin-top: 8px;
+      width: 100%;
+    }
+  }
+}
+.pay-footer {
+  display: flex;
+  margin-bottom: -48px;
+  margin-left: 24px;
+  font-size: 14px;
+  & > div {
+    color: #4480f9;
+    display: flex;
+    p:nth-child(2) {
+      font-size: 24px;
+      font-weight: 800;
+      margin-top: -3px;
+    }
+    p:nth-child(1) {
+      margin: 0px 4px;
+    }
+  }
+}
+.pay-diagram {
+  flex-direction: column;
+}
+</style>

+ 153 - 0
src/views/components/common/ProjectModal.vue

@@ -0,0 +1,153 @@
+<template>
+   <t-dialog
+      v-model:visible="props.visible"
+      :header="title[props.type]"
+      class="project-dialog"
+      :width="700"
+      @close="close"
+      @confirm="confirm"
+    >
+      <div class="flex box-list"  style="flex-wrap:wrap;justify-content: flex-start;">
+          <template v-if="replaceDialog.list?.length">
+            <div class="box" :class="{active:replaceDialog.select?.id===_item.id}" @click="selectScene(_item)" v-for="_item in replaceDialog.list">
+              <div class="box-img">
+                <img :src="_item.image"></img>
+              </div>
+              <div class="item-title" :title="_item.name">{{_item.name}}</div>
+            </div>
+          </template>
+          <template v-else >
+             <div class="item-title" style="height:50px;">
+               暂无数据
+             </div>
+          </template>
+        </div>
+      <t-pagination :pageSizeOptions="[15,30,60]" :total="replaceDialog.total"  v-model="replaceDialog.current" :pageSize="replaceDialog.pageSize" @change="pageChange" />
+    </t-dialog>
+</template>
+
+<script lang='ts' setup>
+import { reactive,defineComponent,ref, onMounted, watch } from 'vue';
+import { getCollectionList } from '@/services/api';
+
+const props = defineProps<{
+  visible: boolean;
+  type: string;
+}>();
+const emit = defineEmits(['update:visible', 'change']);
+
+function close() {
+  emit('update:visible', false);
+}
+
+const replaceDialog = reactive<any>({
+  list:[],
+  current:1,
+  pageSize:15,
+  total:0,
+  collection:'v',
+  select:{
+
+  }
+});
+
+const title = {
+  template:'模版列表',
+  c:'选择组件'
+}
+
+watch(()=>props.visible,(val)=>{
+  if(val){
+    getList(props.type);
+  }
+})
+
+const getList = async(e?:string) => {
+   let collection = e;
+   if(!collection){
+     collection = replaceDialog.collection;
+   }else{
+    replaceDialog.collection = e;
+    replaceDialog.current = 1;
+   }
+   const data = {
+      template:false,
+      projection: 'name,image,id',
+   };
+
+   if(collection === 'template'){
+     collection = 'v';
+     data.template = true;
+   }
+  
+  const config = {
+    params: {
+      current: replaceDialog.current,
+      pageSize: replaceDialog.pageSize,
+    },
+  };
+  //2.请求所有图纸/组件数据
+  const res: any = await getCollectionList(collection, data, config);
+  replaceDialog.list = res.list;
+  replaceDialog.total = res.total;
+}
+
+const pageChange = (e) => {
+  replaceDialog.current = e.current;
+  replaceDialog.pageSize = e.pageSize;
+  getList();
+}
+
+const confirm = () => {
+  emit('change',replaceDialog.select);
+}
+
+const selectScene = (_item) => {
+  replaceDialog.select = _item;
+}
+</script>
+<style lang='postcss' scoped>
+  .box-list{
+       flex-wrap: wrap;
+       padding:8px;
+       margin-bottom:12px;
+       background-color: var(--color-background-active);
+       overflow:scroll;
+       max-height:375px;
+       min-height: 350px;
+      .box{
+        width:125px;
+        height:110px;
+        padding:8px;
+        .box-img{
+          width:100%;
+          height:70px;
+        }
+        /* .title{
+          text-align:center; 
+           white-space: nowrap;
+           overflow: hidden;
+           text-overflow:ellipsis;
+           font-size:12px;
+           color: var(--td-text-color-secondary);
+        } */
+        &:hover{
+          border-radius: 4px;
+          border:1px solid var(--color-primary);
+        }
+      }
+
+      .item-title{
+        text-align:center; 
+        white-space: nowrap;
+        overflow: hidden;
+        text-overflow:ellipsis;
+        font-size:12px;
+        color: var(--td-text-color-secondary);
+      }
+      .active{
+        border-radius: 4px;
+        border:1px solid var(--color-primary);
+      }
+    }
+</style>

+ 282 - 0
src/views/components/common/ProjectPayModal.vue

@@ -0,0 +1,282 @@
+<template>
+  <t-dialog
+    v-if="props.visible"
+    v-model:visible="props.visible"
+    class="zip-dialog"
+    :close-on-overlay-click="false"
+    :top="95"
+    :width="700"
+    :cancel-btn="null"
+    :confirm-btn="{
+          content: props.data.checked?'去支付':'直接下载',
+          style: {
+            width: '110px',
+          } }"
+    @close="close"
+    @confirm="prePay"
+  >
+  <template #header>
+        <div>
+          <span style="vertical-align: middle">导出工程Zip包</span>
+            <a
+            :href="``"
+            target="_blank"
+            class="hover"
+            >
+              <span class="deploy-tip"> 说明?></span>
+            </a>
+        </div>
+   </template>
+   <div class="pay-box pay-diagram" style="font-size: 14px;
+    line-height: 30px;
+    color: #6E7B91;">
+    Zip包仅包含数据文件和图片文件,不包含js等依赖库。
+    <div >- 注意</div>Zip包用于平台(已包含js等依赖库)的快捷导入导出工程,无法作为独立部署包使用。
+    <p v-if="props.data.hasJs">
+      <p>- 当前图纸包含js企业图形依赖库。</p>
+      项目中打开如果没有js企业图形依赖库,对应的图元将无法显示。
+      推荐下载为离线部署包(包含js企业图形依赖库)。
+  </p>
+   </div>
+   <div class="pay-box pay-diagram">
+    <div class="pay-up">
+     <div class="pay-title">
+      <t-checkbox v-model="props.data.checked"> 企业图形库  
+      
+    </t-checkbox>
+    <ChevronDownIcon @click="data.expend=false" v-if="data.expend" />
+      <ChevronRightIcon @click="data.expend=true" v-else />
+     </div>
+     <div class="pay-title pay-tip" >
+      {{ props.data.checked?'图库去水印,一次购买,永久授权':'图库含水印,或显示为空,无商业授权' }}
+     </div>
+     <div class="pay-price" :style="{
+       color:props.data.checked?'':'#6e7b9199'
+     }">
+       <p>¥</p>
+       <p>{{ props.data.checked?props.data.price:0 }}</p>
+     </div>
+    </div>
+    <div v-if="data.expend" class="pay-down">
+        <div v-for="item in props.data.list">
+          <img :src="item" />
+        </div>
+    </div>
+   </div>
+   <div class="pay-footer">
+    合计
+    <div  :style="{
+       color:props.data.checked?'':'#6e7b9199'
+     }">
+       <p>¥</p>
+       <p>{{props.data.checked? props.data.price:0 }}</p>
+     </div>
+   </div>
+</t-dialog>
+<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="wechatPayDialog.show = false"
+    :footer="false"
+  >
+    <Pay
+      :order="wechatPayDialog.order"
+      :alipay-url="wechatPayDialog.order.alipayUrl"
+      :code-url="wechatPayDialog.order.codeUrl"
+      @success="onSuccess"
+    />
+  </t-dialog>
+</template>
+
+<script lang='ts' setup>
+import { reactive,defineComponent,ref } from 'vue';
+import { ChevronRightIcon, ChevronDownIcon} from 'tdesign-icons-vue-next';
+import axios from 'axios';
+import Pay from '../Pay.vue';
+import { MessagePlugin } from 'tdesign-vue-next';
+
+const data = reactive({
+  // checked:true,
+  // price:0,
+  expend:false,
+  // hasJs:false
+});
+
+const props = defineProps<{
+  visible: boolean;
+  data:{
+    hasJs:boolean;
+    price:number;
+    list:string[];
+    checked:boolean;
+  }
+}>();
+
+const wechatPayDialog = reactive({
+  show: false,
+  order:null
+});
+
+const emit = defineEmits(['update:visible', 'change','success']);
+
+function close() {
+  emit('update:visible', false);
+}
+
+
+const prePay = async ()=>{
+  if(props.data.checked){
+    const res: any = await axios.post('/api/order/resource/submit', {
+     goods:{},
+    '2ds':props.data.list.map((item)=>{return { type: '图片图元', name: item}}),
+     collection:'v',
+     id:meta2d.store.data.id
+    });
+    wechatPayDialog.show = true;
+    wechatPayDialog.order = res;
+  }else {
+    //直接下载
+    emit('update:visible', false);
+    emit('change',true);
+  }
+}
+
+const onSuccess = (success: boolean) => {
+  finishPay();
+  emit('success', success);
+};
+
+const finishPay = async () => {
+    MessagePlugin.success('支付成功');
+    wechatPayDialog.show = false;
+    emit('update:visible', false);
+    emit('change',true);
+}
+</script>
+<style lang='postcss' scoped>
+.pay-box{
+  background: rgba(175,202,255,0.04);
+  border-radius: 4px;
+  margin-bottom:24px;
+  padding:20px 24px;
+  position:relative;
+  display:flex;
+  .pay-up{
+    display:flex;
+  }
+ :deep(.t-checkbox__label){
+  font-size: 16px;
+    color: #6e7b91;
+    margin-left:16px;
+ }
+
+  .pay-title{
+    font-size: 16px;
+    color: #6e7b91;
+    .t-icon{
+      margin-top:-5px;
+      margin-left:12px;
+    }
+    :deep(.t-checkbox__input){
+      background:#fff0;
+    }
+    :deep(.t-is-checked){
+      .t-checkbox__input{
+        background:var(--color-primary);
+      }
+    }
+  }
+  .pay-tip{
+    flex:1;
+    text-align:right;
+    margin-right: 100px;
+    color: #fa541c;
+  }
+  .pay-price{
+    position:absolute;
+    right:24px;
+    color:#4480F9;
+    display:flex;
+    p:nth-child(2){
+      font-size: 20px;
+    }
+  }
+
+  .pay-down{
+    margin-top:16px;
+    display:flex;
+    flex-wrap:wrap;
+    /* justify-content:space-between; */
+    /* padding-top:24px; */
+    max-height: 200px;
+    /* overflow-y: scroll; */
+    &>div{
+      width: 48px;
+      height: 48px;
+      background: #1c283b;
+      border-radius: 4px;
+      margin-right:2.5px;
+      margin-bottom:2.5px;
+      &:nth-child(12n){
+        margin-right: 0px;
+      }
+      img{
+        margin:4px;
+        width:40px;
+        height:40px;
+      }
+      .l-icon{
+        /* width: 80px; */
+        /* height: 80px; */
+        /* margin:8px; */
+        font-size:24px;
+        margin:12px;
+      }
+      &>div{
+        margin: 8px 12px;
+      }
+
+      :deep(svg){
+        path{
+          stroke: var(--color) !important;
+        }
+      }
+    }
+    &>p{
+      display: block;
+      margin-top:8px;
+      width:100%;
+    }
+  }
+
+}
+.pay-footer{
+  display:flex;
+  margin-bottom: -48px;
+  margin-left:24px;
+  font-size: 14px;
+  &>div{
+    color:#4480F9;
+    display:flex;
+    p:nth-child(2){
+      font-size: 24px;
+      font-weight: 800;
+      margin-top:-3px;
+    }
+    p:nth-child(1){
+      margin:0px 4px;
+    }
+  }
+}
+.pay-diagram{
+    flex-direction:column;
+}
+
+</style>

+ 94 - 0
src/views/components/common/StepModal.vue

@@ -0,0 +1,94 @@
+<template>
+  <t-dialog
+    v-model:visible="props.visible"
+    header="下载进度"
+    class="project-dialog"
+    :width="700"
+    :closeBtn="false"
+  >
+    <div class="task-steps-container">
+      <t-steps class="task-steps" :current="3">
+        <t-step-item
+          v-for="step in props.tasks"
+          :title="step.title"
+          :status="step.status"
+        >
+          <template #icon>
+            <StopIcon v-if="step.status === 'prepare'" />
+            <LoadingIcon
+              v-else-if="step.status === 'process'"
+              class="text-color-primary"
+            />
+            <CheckCircleIcon
+              v-else-if="step.status === 'success'"
+              class="text-color-success"
+            />
+            <ErrorCircleIcon
+              v-else-if="step.status === 'warn'"
+              class="text-color-warning"
+            />
+            <CloseCircleIcon
+              v-else-if="step.status === 'error'"
+              class="text-color-error"
+            />
+          </template>
+        </t-step-item>
+      </t-steps>
+    </div>
+    <template #footer>
+      <t-button v-if="props.reload" theme="primary" @click="reDownload"> 重新下载 </t-button>
+      <t-popconfirm theme="default" content="确认取消吗?" @confirm="close">
+        <t-button theme="default"> 取消任务 </t-button>
+      </t-popconfirm>
+    </template>
+  </t-dialog>
+</template>
+
+<script lang="ts" setup>
+import {
+  CheckCircleIcon,
+  CloseCircleIcon,
+  ErrorCircleIcon,
+  LoadingIcon,
+  StopIcon,
+} from 'tdesign-icons-vue-next';
+
+const emit = defineEmits(['update:visible', 'cancel','reDownload']);
+
+function close() {
+  emit('update:visible', false);
+  emit('cancel');
+}
+
+const props = defineProps<{
+  visible: boolean;
+  tasks: any[];
+  reload:boolean;
+}>();
+
+function reDownload() {
+  emit('reDownload',true); 
+}
+
+</script>
+<style lang="postcss" scoped>
+.task-steps-container {
+  padding-top: 30px;
+  height: 100px;
+}
+.text-color-success {
+  color: var(--color-success);
+}
+.task-steps {
+  :deep(.t-steps-item--success) {
+    .t-steps-item__title {
+      color: var(--color-success);
+    }
+  }
+  :deep(.t-steps-item) {
+    .t-steps-item__title {
+      font-size: 14px;
+    }
+  }
+}
+</style>