liaojiaxing vor 1 Monat
Commit
f6250ebb97
47 geänderte Dateien mit 15512 neuen und 0 gelöschten Zeilen
  1. 24 0
      .gitignore
  2. 3 0
      .vscode/extensions.json
  3. 5 0
      README.md
  4. 10 0
      auto-imports.d.ts
  5. 35 0
      components.d.ts
  6. 13 0
      index.html
  7. 41 0
      package.json
  8. 7980 0
      pnpm-lock.yaml
  9. 1 0
      public/vite.svg
  10. 18 0
      src/App.vue
  11. 1 0
      src/assets/auto.svg
  12. 1 0
      src/assets/compare.svg
  13. 1 0
      src/assets/exchange.svg
  14. 1 0
      src/assets/history.svg
  15. 1 0
      src/assets/mindmap.svg
  16. 1 0
      src/assets/save.svg
  17. 1 0
      src/assets/setting.svg
  18. 1 0
      src/assets/vue.svg
  19. 112 0
      src/components/LuckySheet.vue
  20. 184 0
      src/components/Sheet.vue
  21. 157 0
      src/components/mindmap/Mindmap.vue
  22. 254 0
      src/components/mindmap/defaultTheme.ts
  23. 60 0
      src/components/plugins/controllers/custom-menu.controller.ts
  24. 115 0
      src/components/plugins/controllers/menu/export.menu.ts
  25. 99 0
      src/components/plugins/controllers/menu/import.menu.ts
  26. 61 0
      src/components/plugins/controllers/menu/save.menu.ts
  27. 71 0
      src/components/plugins/index.ts
  28. 10 0
      src/components/plugins/locale/en-US.ts
  29. 10 0
      src/components/plugins/locale/zh-CN.ts
  30. 15 0
      src/main.ts
  31. 20 0
      src/pages/common/config.ts
  32. 168 0
      src/pages/compare/index.vue
  33. 183 0
      src/pages/excel/ConfigDrawer.vue
  34. 276 0
      src/pages/excel/MindmapModal.vue
  35. 63 0
      src/pages/excel/index.vue
  36. 18 0
      src/pages/mindmap/index.vue
  37. 31 0
      src/router/index.ts
  38. 80 0
      src/store/editbom.ts
  39. 97 0
      src/utils/convert.ts
  40. 220 0
      src/utils/index.ts
  41. 9 0
      src/vite-env.d.ts
  42. 4949 0
      stats.html
  43. 23 0
      tsconfig.app.json
  44. 7 0
      tsconfig.json
  45. 25 0
      tsconfig.node.json
  46. 5 0
      uno.config.ts
  47. 52 0
      vite.config.ts

+ 24 - 0
.gitignore

@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?

+ 3 - 0
.vscode/extensions.json

@@ -0,0 +1,3 @@
+{
+  "recommendations": ["Vue.volar"]
+}

+ 5 - 0
README.md

@@ -0,0 +1,5 @@
+# Vue 3 + TypeScript + Vite
+
+This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
+
+Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

+ 10 - 0
auto-imports.d.ts

@@ -0,0 +1,10 @@
+/* eslint-disable */
+/* prettier-ignore */
+// @ts-nocheck
+// noinspection JSUnusedGlobalSymbols
+// Generated by unplugin-auto-import
+// biome-ignore lint: disable
+export {}
+declare global {
+
+}

+ 35 - 0
components.d.ts

@@ -0,0 +1,35 @@
+/* eslint-disable */
+// @ts-nocheck
+// Generated by unplugin-vue-components
+// Read more: https://github.com/vuejs/core/pull/3399
+// biome-ignore lint: disable
+export {}
+
+/* prettier-ignore */
+declare module 'vue' {
+  export interface GlobalComponents {
+    ElButton: typeof import('element-plus/es')['ElButton']
+    ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
+    ElCol: typeof import('element-plus/es')['ElCol']
+    ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
+    ElDrawer: typeof import('element-plus/es')['ElDrawer']
+    ElForm: typeof import('element-plus/es')['ElForm']
+    ElFormItem: typeof import('element-plus/es')['ElFormItem']
+    ElInput: typeof import('element-plus/es')['ElInput']
+    ElOption: typeof import('element-plus/es')['ElOption']
+    ElRow: typeof import('element-plus/es')['ElRow']
+    ElSelect: typeof import('element-plus/es')['ElSelect']
+    ElTable: typeof import('element-plus/es')['ElTable']
+    ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
+    ElTag: typeof import('element-plus/es')['ElTag']
+    ElTooltip: typeof import('element-plus/es')['ElTooltip']
+    LuckySheet: typeof import('./src/components/LuckySheet.vue')['default']
+    Mindmap: typeof import('./src/components/mindmap/Mindmap.vue')['default']
+    RouterLink: typeof import('vue-router')['RouterLink']
+    RouterView: typeof import('vue-router')['RouterView']
+    Sheet: typeof import('./src/components/Sheet.vue')['default']
+  }
+  export interface GlobalDirectives {
+    vLoading: typeof import('element-plus/es')['ElLoadingDirective']
+  }
+}

+ 13 - 0
index.html

@@ -0,0 +1,13 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>延锋BOM管理系统</title>
+  </head>
+  <body>
+    <div id="root"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

+ 41 - 0
package.json

@@ -0,0 +1,41 @@
+{
+  "name": "yanfeng-bommgr-frontend",
+  "private": true,
+  "version": "0.0.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vue-tsc -b && vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "@element-plus/icons-vue": "^2.3.1",
+    "@univerjs/icons": "^0.4.6",
+    "@univerjs/preset-sheets-core": "^0.9.2",
+    "@univerjs/presets": "^0.9.2",
+    "@vitejs/plugin-vue-jsx": "^5.0.1",
+    "@zwight/luckyexcel": "^1.1.6",
+    "element-plus": "^2.10.4",
+    "file-saver": "^2.0.5",
+    "less": "^4.4.0",
+    "less-loader": "^12.3.0",
+    "lodash-es": "^4.17.21",
+    "normalize.css": "^8.0.1",
+    "pinia": "^3.0.3",
+    "simple-mind-map": "0.14.0-fix.1",
+    "vue": "^3.5.17",
+    "vue-router": "^4.5.1"
+  },
+  "devDependencies": {
+    "@types/lodash-es": "^4.17.12",
+    "@vitejs/plugin-vue": "^6.0.0",
+    "@vue/tsconfig": "^0.7.0",
+    "rollup-plugin-visualizer": "^6.0.3",
+    "typescript": "~5.8.3",
+    "unocss": "^66.3.3",
+    "unplugin-auto-import": "^19.3.0",
+    "unplugin-vue-components": "^28.8.0",
+    "vite": "^7.0.4",
+    "vue-tsc": "^2.2.12"
+  }
+}

Datei-Diff unterdrückt, da er zu groß ist
+ 7980 - 0
pnpm-lock.yaml


Datei-Diff unterdrückt, da er zu groß ist
+ 1 - 0
public/vite.svg


+ 18 - 0
src/App.vue

@@ -0,0 +1,18 @@
+<script setup lang="ts">
+import zhCn from 'element-plus/es/locale/lang/zh-cn'
+</script>
+
+<template>
+  <div class="app-wrapper">
+    <el-config-provider :locale="zhCn">
+      <router-view/>
+    </el-config-provider>
+  </div>
+</template>
+
+<style scoped>
+.app-wrapper {
+  width: 100vw;
+  height: 100vh;
+}
+</style>

Datei-Diff unterdrückt, da er zu groß ist
+ 1 - 0
src/assets/auto.svg


Datei-Diff unterdrückt, da er zu groß ist
+ 1 - 0
src/assets/compare.svg


Datei-Diff unterdrückt, da er zu groß ist
+ 1 - 0
src/assets/exchange.svg


Datei-Diff unterdrückt, da er zu groß ist
+ 1 - 0
src/assets/history.svg


Datei-Diff unterdrückt, da er zu groß ist
+ 1 - 0
src/assets/mindmap.svg


Datei-Diff unterdrückt, da er zu groß ist
+ 1 - 0
src/assets/save.svg


Datei-Diff unterdrückt, da er zu groß ist
+ 1 - 0
src/assets/setting.svg


+ 1 - 0
src/assets/vue.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

+ 112 - 0
src/components/LuckySheet.vue

@@ -0,0 +1,112 @@
+<template>
+  <div class="luckysheet-container" :id="containerId"></div>
+  <div v-show="isMaskShow" id="tip">loading...</div>
+</template>
+
+<script setup lang="ts">
+import {
+  ref,
+  onMounted,
+  defineProps,
+  watch,
+  defineExpose,
+  onBeforeUnmount,
+} from "vue";
+import LuckyExcel from "luckyexcel";
+import type { UploadRawFile } from "element-plus";
+
+const isMaskShow = ref(false);
+const containerId = "luckysheet_" + Date.now() + "";
+const luckyInstance = ref<any>(null);
+
+const props = defineProps<{
+  file?: File | UploadRawFile;
+}>();
+
+const loadFile = (file: File | UploadRawFile) => {
+  console.log("loadFile", props.file);
+  LuckyExcel.transformExcelToLucky(
+    file,
+    function (exportJson: any, luckysheetfile: any) {
+      if (exportJson.sheets == null || exportJson.sheets.length == 0) {
+        alert(
+          "Failed to read the content of the excel file, currently does not support xls files!"
+        );
+        return;
+      }
+      console.log(
+        "exportJson",
+        exportJson,
+        luckysheetfile,
+        luckyInstance.value
+      );
+
+      window.luckysheet?.destroy();
+
+      setTimeout(() => {
+        window.luckysheet.create({
+          container: containerId, //luckysheet is the container id
+          showinfobar: false,
+          data: exportJson.sheets,
+          title: exportJson.info.name,
+          userInfo: exportJson.info.name.creator,
+          lang: "zh",
+        });
+      }, 200);
+    }
+  );
+};
+
+watch(
+  () => props.file,
+  (file) => {
+    if (file) {
+      isMaskShow.value = true;
+      loadFile(file);
+      isMaskShow.value = false;
+    }
+  }
+);
+
+defineExpose({
+  loadFile,
+});
+
+onBeforeUnmount(() => {
+  luckyInstance.value?.destroy();
+});
+
+// !!! create luckysheet after mounted
+onMounted(() => {
+  luckyInstance.value = window.luckysheet.create({
+    container: containerId,
+    lang: "zh",
+    showinfobar: false,
+  });
+});
+</script>
+
+<style scoped>
+.luckysheet-container {
+  margin: 0px;
+  padding: 0px;
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+}
+
+#tip {
+  position: absolute;
+  z-index: 1000000;
+  left: 0px;
+  top: 0px;
+  bottom: 0px;
+  right: 0px;
+  background: rgba(255, 255, 255, 0.8);
+  text-align: center;
+  font-size: 40px;
+  align-items: center;
+  justify-content: center;
+  display: flex;
+}
+</style>

+ 184 - 0
src/components/Sheet.vue

@@ -0,0 +1,184 @@
+<template>
+  <div class="univer-wrapper">
+    <slot></slot>
+    <div ref="container" v-loading="loading" style="width: 100%; height: 100%; flex: 1"></div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import {
+  onMounted,
+  ref,
+  onBeforeUnmount,
+  defineExpose,
+  defineProps,
+  watch,
+} from "vue";
+import { UniverSheetsCorePreset } from "@univerjs/preset-sheets-core";
+import type { Univer, FUniver, Workbook } from "@univerjs/presets";
+import UniverPresetSheetsCoreZhCN from "@univerjs/preset-sheets-core/locales/zh-CN";
+import { createUniver, LocaleType, merge } from "@univerjs/presets";
+import "@univerjs/preset-sheets-core/lib/index.css";
+import { ElMessage } from "element-plus";
+
+import { UniverSheetsCustomMenuPlugin } from "./plugins";
+import CustomImportMenu from "./plugins/controllers/menu/import.menu";
+import CustomExportMenu from "./plugins/controllers/menu/export.menu";
+// import CustomSaveMenu from "./plugins/controllers/menu/save.menu";
+
+const container = ref<HTMLDivElement | null>(null);
+const loading = ref(false);
+
+let univerInstance: Univer | null = null;
+let univerAPIInstance: FUniver | null = null;
+
+// 暴露方法
+export type UniverExpose = {
+  getUniverInstance: () => Univer | null;
+  getUniverAPIInstance: () => FUniver | null;
+  getUniverSnapshot: (workbook: Workbook) => void;
+};
+
+const getUniverSnapshot = () => {
+  const activeWorkbook = univerAPIInstance?.getActiveWorkbook();
+  console.log(activeWorkbook);
+  if (!activeWorkbook) {
+    throw new Error("Workbook is not initialized");
+  }
+  return activeWorkbook.save();
+};
+
+export type SheetProps = {
+  /**
+   * 是否显示导入菜单
+   */
+  showImportMenu?: boolean;
+  /**
+   * 是否显示导出菜单
+   */
+  showExportMenu?: boolean;
+  /**
+   * 表格实例创建完成
+   */
+  created?: (univer: Univer, univerApi: FUniver) => void;
+  /**
+   * 工作簿数据
+   */
+  workbook?: Workbook;
+};
+
+const props = defineProps<SheetProps>();
+
+// 暴露给父组件
+const expose = {
+  getUniverInstance: () => univerInstance,
+  getUniverAPIInstance: () => univerAPIInstance,
+  getUniverSnapshot,
+};
+defineExpose(expose);
+
+onMounted(() => {
+  const { univer, univerAPI } = createUniver({
+    locale: LocaleType.ZH_CN,
+    locales: {
+      [LocaleType.ZH_CN]: merge({}, UniverPresetSheetsCoreZhCN),
+    },
+    presets: [
+      UniverSheetsCorePreset({
+        container: container.value!,
+        menu: {
+          // 隐藏保护菜单
+          "sheet.command.add-range-protection-from-toolbar": {
+            hidden: true,
+          },
+        },
+      }),
+    ],
+  });
+
+  const menu: any[] = [
+    // 保存按钮
+    // CustomSaveMenu({
+    //   after: () => {
+    //     const saveData = getUniverSnapshot();
+    //     console.log(saveData);
+    //   },
+    // })
+  ];
+
+  if (props?.showImportMenu) {
+    menu.push(
+      CustomImportMenu({
+        before: () => {
+          loading.value = true;
+        },
+        after: ({ error }) => {
+          loading.value = false;
+          if (error) {
+            ElMessage.error(error.message || "导入失败");
+            return;
+          }
+          ElMessage.success("导入成功");
+        },
+      })
+    );
+  }
+
+  if (props?.showExportMenu) {
+    menu.push(
+      CustomExportMenu({
+        snapshot: getUniverSnapshot,
+        after: (res) => {
+          loading.value = false;
+          if (res) {
+            ElMessage.error(res.message || "导出失败");
+          } else {
+            ElMessage.success("导出成功");
+          }
+        },
+      })
+    );
+  }
+
+  univer.registerPlugin(UniverSheetsCustomMenuPlugin, {
+    instance: univer,
+    menu,
+  });
+
+  univerAPI.createWorkbook({});
+
+  univerInstance = univer;
+  univerAPIInstance = univerAPI;
+  props?.created?.(univer, univerAPI);
+});
+
+watch(
+  () => props.workbook,
+  (workbook) => {
+    if (workbook && univerAPIInstance) {
+      univerAPIInstance?.createWorkbook(workbook);
+    }
+  },
+  {
+    immediate: true,
+    deep: true,
+  }
+)
+
+onBeforeUnmount(() => {
+  univerInstance?.dispose();
+  univerAPIInstance?.dispose();
+  univerInstance = null;
+  univerAPIInstance = null;
+});
+</script>
+
+<style scoped>
+.univer-wrapper {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+
+}
+</style>

+ 157 - 0
src/components/mindmap/Mindmap.vue

@@ -0,0 +1,157 @@
+<template>
+  <div class="mindmap-container" ref="mindmapRef"></div>
+</template>
+
+<script setup lang="tsx">
+import {
+  ref,
+  onMounted,
+  onBeforeUnmount,
+  defineProps,
+  watch,
+  defineExpose,
+  withDefaults,
+  defineEmits,
+} from "vue";
+import MindMap from "simple-mind-map";
+import defaultTheme from "./defaultTheme";
+// mindmap plugins
+import Drag from "simple-mind-map/src/plugins/Drag";
+import SearchPlugin from "simple-mind-map/src/plugins/Search";
+
+MindMap.usePlugin(Drag);
+MindMap.usePlugin(SearchPlugin);
+
+export type MindMapInstance = {
+  getInstance: () => MindMap;
+  getActiveNodeList: () => any[];
+};
+
+const props = withDefaults(
+  defineProps<{
+    data?: any;
+    readonly?: boolean;
+  }>(),
+  {
+    readonly: false,
+  }
+);
+
+const emit = defineEmits(["update:data"]);
+
+const mindmapRef = ref<HTMLDivElement | null>(null);
+const mindmap = ref<MindMap | null>(null);
+const activeNodeList = ref<any[]>([]);
+let loaded = false;
+
+onBeforeUnmount(() => {
+  mindmap.value?.destroy();
+});
+
+watch(
+  () => props.data,
+  (data) => {
+    console.log("data", data);
+    data && mindmap.value?.updateData(data);
+  },
+  {
+    deep: true,
+    immediate: true,
+  }
+);
+
+watch(
+  () => props.readonly,
+  (val) => {
+    mindmap.value?.setMode(val ? "readonly" : "edit");
+  }
+);
+
+onMounted(() => {
+  const instance = new MindMap({
+    el: mindmapRef.value!,
+    data: props.data || {
+      data: {
+        text: "主物料",
+      },
+      children: [],
+    },
+    themeConfig: {
+      ...defaultTheme,
+      backgroundColor: "#eee",
+    },
+    // 只读
+    readonly: !!props.readonly,
+    isUseCustomNodeContent: true,
+    customCreateNodeContent: (node: any) => {
+      const { sourceData = {} } = node.getData();
+      console.log("node", node, sourceData);
+      // return你的自定义DOM节点
+      let div = document.createElement("div");
+      div.className = "mx-12px my-8px";
+      div.style = "user-select: none;";
+      div.innerHTML = `
+        <div class="w-200px text-#666">
+          ${
+            // <div class="h-60px w-80px overflow-hidden m-auto mb-8px">
+            //   <img src="https://picsum.photos/80/60" class="m-auto"/>
+            // </div>
+            ""
+            }
+          <div class="border-b border-b-solid flex items-center justify-between pb-4px">
+            <span class="text-sm font-light">零件号</span>
+            <span class="text-sm font-medium">123445</span>
+          </div>
+          <div class="border-b border-b-solid flex items-center justify-between pb-4px">
+            <span class="text-sm font-light">描述</span>
+            <span class="text-sm font-medium">xxxx零件</span>
+          </div>
+          <div class="border-b flex items-center justify-between pb-4px">
+            <span class="text-sm font-light">数量</span>
+            <span class="text-sm font-medium">10</span>
+          </div>
+        </div>`;
+      return div;
+    },
+  } as any);
+
+  // 事件监听
+  // 自适应
+  instance.on("node_tree_render_end", () => {
+    if (loaded) return;
+    // @ts-ignore
+    instance?.view?.fit();
+    loaded = true;
+  });
+  // 激活节点
+  instance.on("node_active", (_node: any, list: any[]) => {
+    activeNodeList.value = list;
+  });
+  // 数据改变
+  instance.on("data_change", (data: any) => {
+    // console.log("data_change", data);
+    emit("update:data", data);
+  });
+  // 视图数据改变
+  // instance.on("view_data_change", (data: any) => {
+  //   console.log("view_data_change", data);
+  // });
+
+  mindmap.value = instance;
+});
+
+// 定义暴露给父组件的方法
+defineExpose({
+  getInstance: () => mindmap.value,
+  getActiveNodeList: () => activeNodeList.value,
+});
+</script>
+
+<style scoped>
+.mindmap-container {
+  width: 100%;
+  height: 100%;
+  padding: 0;
+  margin: 0;
+}
+</style>

+ 254 - 0
src/components/mindmap/defaultTheme.ts

@@ -0,0 +1,254 @@
+//  默认主题
+export default {
+  // 节点内边距
+  paddingX: 15,
+  paddingY: 5,
+  // 图片显示的最大宽度
+  imgMaxWidth: 200,
+  // 图片显示的最大高度
+  imgMaxHeight: 100,
+  // icon的大小
+  iconSize: 20,
+  // 连线的粗细
+  lineWidth: 1,
+  // 连线的颜色
+  lineColor: '#424242',
+  // 连线样式
+  lineDasharray: 'none',
+  // 连线是否开启流动效果,仅在虚线时有效(需要注册LineFlow插件)
+  lineFlow: false,
+  // 流动效果一个周期的时间,单位:s
+  lineFlowDuration: 1,
+  // 流动方向是否是从父节点到子节点
+  lineFlowForward: true,
+  // 连线风格
+  lineStyle: 'straight', // 曲线(curve)【仅支持logicalStructure、mindMap、verticalTimeline三种结构】、直线(straight)、直连(direct)【仅支持logicalStructure、mindMap、organizationStructure、verticalTimeline四种结构】
+  // 曲线连接时,根节点和其他节点的连接线样式保持统一,默认根节点为 ( 型,其他节点为 { 型,设为true后,都为 { 型。仅支持logicalStructure、mindMap两种结构
+  rootLineKeepSameInCurve: true,
+  // 曲线连接时,根节点和其他节点的连线起始位置保持统一,默认根节点的连线起始位置在节点中心,其他节点在节点右侧(或左侧),如果该配置设为true,那么根节点的连线起始位置也会在节点右侧(或左侧)
+  rootLineStartPositionKeepSameInCurve: false,
+  // 直线连接(straight)时,连线的圆角大小,设置为0代表没有圆角,仅支持logicalStructure、mindMap、verticalTimeline三种结构
+  lineRadius: 5,
+  // 连线是否显示标记,目前只支持箭头
+  showLineMarker: false,
+  // 概要连线的粗细
+  generalizationLineWidth: 1,
+  // 概要连线的颜色
+  generalizationLineColor: '#999',
+  // 概要曲线距节点的距离
+  generalizationLineMargin: 0,
+  // 概要节点距节点的距离
+  generalizationNodeMargin: 20,
+  // 关联线默认状态的粗细
+  associativeLineWidth: 2,
+  // 关联线默认状态的颜色
+  associativeLineColor: 'rgb(51, 51, 51)',
+  // 关联线激活状态的粗细
+  associativeLineActiveWidth: 8,
+  // 关联线激活状态的颜色
+  associativeLineActiveColor: 'rgba(2, 167, 240, 1)',
+  // 关联线样式
+  associativeLineDasharray: '6,4',
+  // 关联线文字颜色
+  associativeLineTextColor: 'rgb(51, 51, 51)',
+  // 关联线文字大小
+  associativeLineTextFontSize: 14,
+  // 关联线文字行高
+  associativeLineTextLineHeight: 1.2,
+  // 关联线文字字体
+  associativeLineTextFontFamily: '微软雅黑, Microsoft YaHei',
+  // 背景颜色
+  backgroundColor: '#fafafa',
+  // 背景图片
+  backgroundImage: 'none',
+  // 背景重复
+  backgroundRepeat: 'no-repeat',
+  // 设置背景图像的起始位置
+  backgroundPosition: 'center center',
+  // 设置背景图片大小
+  backgroundSize: 'cover',
+  // 节点使用只有底边横线的样式,仅支持logicalStructure、mindMap、catalogOrganization、organizationStructure四种结构
+  nodeUseLineStyle: false,
+  // 根节点样式
+  root: {
+    shape: 'rectangle',
+    fillColor: '#ffffffff',
+    fontFamily: '微软雅黑, Microsoft YaHei',
+    color: '#fff',
+    fontSize: 16,
+    fontWeight: 'bold',
+    fontStyle: 'normal',
+    borderColor: '#808080',
+    borderWidth: 1,
+    borderDasharray: 'none',
+    borderRadius: 5,
+    textDecoration: 'none',
+    gradientStyle: false,
+    startColor: '#999',
+    endColor: '#fff',
+    startDir: [0, 0],
+    endDir: [1, 0],
+    // 连线标记的位置,start(头部)、end(尾部),该配置在showLineMarker配置为true时生效
+    lineMarkerDir: 'end',
+    // 节点鼠标hover和激活时显示的矩形边框的颜色,主题里不设置,默认会取hoverRectColor实例化选项的值
+    hoverRectColor: '',
+    // 点鼠标hover和激活时显示的矩形边框的圆角大小
+    hoverRectRadius: 5,
+    // 文本对齐
+    textAlign: 'left',// right、center、justify、left
+    // 图片放置位置,相对于整个文本内容
+    imgPlacement: 'top', // left、right、bottom、top
+    // 标签放置位置
+    tagPlacement: 'right' // right(文字右侧)、bottom(文本内容下方)
+    // 下列样式也支持给节点设置,用于覆盖最外层的设置
+    // paddingX,
+    // paddingY,
+    // lineWidth,
+    // lineColor,
+    // lineDasharray,
+    // lineFlow,
+    // lineFlowDuration,
+    // lineFlowForward
+    // 关联线的所有样式
+  },
+  // 二级节点样式
+  second: {
+    shape: 'rectangle',
+    marginX: 30,
+    marginY: 30,
+    fillColor: '#fafafa',
+    fontFamily: '微软雅黑, Microsoft YaHei',
+    color: '#fff',
+    fontSize: 16,
+    fontWeight: 'normal',
+    fontStyle: 'normal',
+    borderColor: '#808080',
+    borderWidth: 1,
+    borderDasharray: 'none',
+    borderRadius: 5,
+    textDecoration: 'none',
+    gradientStyle: false,
+    startColor: '#999',
+    endColor: '#fff',
+    startDir: [0, 0],
+    endDir: [1, 0],
+    lineMarkerDir: 'end',
+    hoverRectColor: '',
+    hoverRectRadius: 5,
+    textAlign: 'left',
+    imgPlacement: 'top',
+    tagPlacement: 'right'
+  },
+  // 三级及以下节点样式
+  node: {
+    shape: 'rectangle',
+    marginX: 30,
+    marginY: 20,
+    fillColor: '#fafafa',
+    fontFamily: '微软雅黑, Microsoft YaHei',
+    color: '#fff',
+    fontSize: 14,
+    fontWeight: 'normal',
+    fontStyle: 'normal',
+    borderColor: '#808080',
+    borderWidth: 1,
+    borderRadius: 5,
+    borderDasharray: 'none',
+    textDecoration: 'none',
+    gradientStyle: false,
+    startColor: '#999',
+    endColor: '#fff',
+    startDir: [0, 0],
+    endDir: [1, 0],
+    lineMarkerDir: 'end',
+    hoverRectColor: '',
+    hoverRectRadius: 5,
+    textAlign: 'left',
+    imgPlacement: 'top',
+    tagPlacement: 'right'
+  },
+  // 概要节点样式
+  generalization: {
+    shape: 'rectangle',
+    marginX: 100,
+    marginY: 40,
+    fillColor: '#fff',
+    fontFamily: '微软雅黑, Microsoft YaHei',
+    color: '#565656',
+    fontSize: 16,
+    fontWeight: 'normal',
+    fontStyle: 'normal',
+    borderColor: '#999',
+    borderWidth: 1,
+    borderDasharray: 'none',
+    borderRadius: 5,
+    textDecoration: 'none',
+    gradientStyle: false,
+    startColor: '#999',
+    endColor: '#fff',
+    startDir: [0, 0],
+    endDir: [1, 0],
+    hoverRectColor: '',
+    hoverRectRadius: 5,
+    textAlign: 'left',
+    imgPlacement: 'top',
+    tagPlacement: 'right'
+  }
+}
+
+// 检测主题配置是否是节点大小无关的
+const nodeSizeIndependenceList = [
+  'lineWidth',
+  'lineColor',
+  'lineDasharray',
+  'lineStyle',
+  'generalizationLineWidth',
+  'generalizationLineColor',
+  'associativeLineWidth',
+  'associativeLineColor',
+  'associativeLineActiveWidth',
+  'associativeLineActiveColor',
+  'associativeLineTextColor',
+  'associativeLineTextFontSize',
+  'associativeLineTextLineHeight',
+  'associativeLineTextFontFamily',
+  'backgroundColor',
+  'backgroundImage',
+  'backgroundRepeat',
+  'backgroundPosition',
+  'backgroundSize',
+  'rootLineKeepSameInCurve',
+  'rootLineStartPositionKeepSameInCurve',
+  'showLineMarker',
+  'lineRadius',
+  'hoverRectColor',
+  'hoverRectRadius',
+  'lineFlow',
+  'lineFlowDuration',
+  'lineFlowForward',
+  'textAlign'
+]
+export const checkIsNodeSizeIndependenceConfig = (config: any) => {
+  let keys = Object.keys(config)
+  for (let i = 0; i < keys.length; i++) {
+    if (
+      !nodeSizeIndependenceList.find(item => {
+        return item === keys[i]
+      })
+    ) {
+      return false
+    }
+  }
+  return true
+}
+
+// 连线的样式
+export const lineStyleProps = [
+  'lineColor',
+  'lineDasharray',
+  'lineWidth',
+  'lineMarkerDir',
+  'lineFlow',
+  'lineFlowDuration',
+  'lineFlowForward'
+]

+ 60 - 0
src/components/plugins/controllers/custom-menu.controller.ts

@@ -0,0 +1,60 @@
+import {
+  Disposable,
+  ICommandService,
+  Inject,
+  IConfigService,
+} from '@univerjs/presets';
+import {
+  ComponentManager,
+  IShortcutService,
+  RibbonStartGroup,
+  IMenuManagerService,
+} from '@univerjs/preset-sheets-core';
+import { CUSTOM_PLUGIN_CONFIG } from '..';
+import type { ICustomMenuPluginConfig } from "..";
+export class CustomMenuController extends Disposable {
+  constructor(
+    @ICommandService private readonly _commandService: ICommandService,
+    @IMenuManagerService
+    private readonly _menuMangerService: IMenuManagerService,
+    @Inject(ComponentManager)
+    private readonly _componentManager: ComponentManager,
+    @IShortcutService private readonly _shortcutService: IShortcutService,
+    @IConfigService private readonly _configService: IConfigService
+  ) {
+    super();
+
+    this._init();
+  }
+
+  private _init(): void {
+    const config: ICustomMenuPluginConfig =
+      this._configService.getConfig(CUSTOM_PLUGIN_CONFIG)!;
+    const { menu } = config || {};
+    menu.forEach((item, index) => {
+      if (item.operation)
+        this.disposeWithMe(
+          this._commandService.registerCommand(item.operation)
+        ); // register command
+      if (item.shortcut)
+        this.disposeWithMe(
+          this._shortcutService.registerShortcut(item.shortcut)
+        ); // register shortcut
+      if (item.icon)
+        this.disposeWithMe(
+          this._componentManager.register(item.icon.name, item.icon.component)
+        ); // register icon component
+
+      if (item.menu) {
+        this._menuMangerService.mergeMenu({
+          [RibbonStartGroup.HISTORY]: {
+            [item.menu().id]: {
+              order: -((menu.length || 0) - index) - 2,
+              menuItemFactory: item.menu,
+            },
+          },
+        });
+      }
+    });
+  }
+}

+ 115 - 0
src/components/plugins/controllers/menu/export.menu.ts

@@ -0,0 +1,115 @@
+import {
+  type ICommand,
+  type IAccessor,
+  CommandType,
+  IUniverInstanceService,
+  UniverInstanceType,
+} from '@univerjs/presets';
+import {
+  MenuItemType,
+  KeyCode,
+  MetaKeys,
+} from '@univerjs/preset-sheets-core';
+import type {
+  IShortcutItem,
+  IMenuButtonItem,
+} from '@univerjs/preset-sheets-core';
+import { ExportSingle } from '@univerjs/icons';
+import type { ICustomMenuPulginParams } from '../..';
+import LuckyExcel from '@zwight/luckyexcel';
+
+const OperationId = 'custom-menu.operation.export';
+const ExportButtonOperation: (config?: ICustomMenuPulginParams) => ICommand = (
+  config
+) => ({
+  id: OperationId,
+  type: CommandType.OPERATION,
+  handler: async (_accessor: IAccessor) => {
+    console.log('Export button operation', config);
+    const before = await config?.before?.();
+
+    if (config?.before && !before) return false;
+    const univer = _accessor.get(IUniverInstanceService);
+    const snapshot = univer
+      .getCurrentUnitForType(UniverInstanceType.UNIVER_SHEET)
+      ?.getSnapshot();
+
+    const postParams = {
+      snapshot: config?.snapshot?.() || snapshot,
+      fileName: config?.fileName,
+      ...(before || {}),
+    };
+
+    LuckyExcel.transformUniverToExcel({
+      ...postParams,
+      getBuffer: false,
+      success: (buffer: Buffer) => {
+        console.log('success', buffer);
+        config?.after?.();
+      },
+      error: (error: Error) => {
+        console.log('error', error);
+        // config?.after?.(error);
+        self.postMessage({ error });
+        config?.after?.({ error });
+      },
+    });
+    return true;
+  },
+});
+
+const ExportShortcutItem: IShortcutItem = {
+  id: OperationId,
+  description: 'shortcut.export',
+  group: '1_common-edit',
+  binding: MetaKeys.CTRL_COMMAND | MetaKeys.SHIFT | KeyCode.E,
+};
+
+function CustomMenuItemExportButtonFactory(): IMenuButtonItem<string> {
+  return {
+    // Bind the command id, clicking the button will trigger this command
+    id: OperationId,
+    // The type of the menu item, in this case, it is a button
+    type: MenuItemType.BUTTON,
+    // The icon of the button, which needs to be registered in ComponentManager
+    icon: 'ExportSingle',
+    // The tooltip of the button. Prioritize matching internationalization. If no match is found, the original string will be displayed
+    tooltip: 'customMenu.export',
+    // The title of the button. Prioritize matching internationalization. If no match is found, the original string will be displayed
+    title: 'customMenu.export',
+  };
+}
+
+const CustomExportMenu = (config?: ICustomMenuPulginParams) => ({
+  operation: ExportButtonOperation(config),
+  shortcut: ExportShortcutItem,
+  menu: CustomMenuItemExportButtonFactory,
+  icon: { name: 'ExportSingle', component: ExportSingle },
+});
+// const downloadFile = (fileName: string, buffer: Buffer) => {
+//   const link = document.createElement('a');
+
+//   let blob: Blob;
+//   if (typeof buffer === 'string') {
+//     blob = new Blob([buffer], { type: 'text/csv;charset=utf-8;' });
+//   } else {
+//     blob = new Blob([buffer], {
+//       type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=UTF-8',
+//     });
+//   }
+
+//   const url = URL.createObjectURL(blob);
+//   link.href = url;
+//   link.download = fileName;
+//   document.body.appendChild(link);
+//   link.click();
+
+//   link.addEventListener('click', () => {
+//     link.remove();
+//     setTimeout(() => {
+//       URL.revokeObjectURL(url);
+//     }, 200);
+//   });
+// };
+
+export default CustomExportMenu;

+ 99 - 0
src/components/plugins/controllers/menu/import.menu.ts

@@ -0,0 +1,99 @@
+import { MenuItemType, KeyCode, MetaKeys } from "@univerjs/preset-sheets-core";
+import type {
+  IShortcutItem,
+  IMenuButtonItem,
+} from "@univerjs/preset-sheets-core";
+import type { IAccessor, ICommand, IWorkbookData } from "@univerjs/presets";
+import {
+  CommandType,
+  IUniverInstanceService,
+  UniverInstanceType,
+  Workbook,
+} from "@univerjs/presets";
+import { DownloadSingle } from "@univerjs/icons";
+
+import type { ICustomMenuPulginParams } from "../..";
+import { waitUserSelectExcelFile } from "@/utils";
+import LuckyExcel from "@zwight/luckyexcel";
+
+const OperationId = "custom-menu.operation.import";
+const ImportButtonOperation: (config?: ICustomMenuPulginParams) => ICommand = (
+  config
+) => ({
+  id: OperationId,
+  type: CommandType.OPERATION,
+  handler: async (_accessor: IAccessor) => {
+    config?.before?.();
+    console.log('import button operation')
+    const univer = _accessor.get(IUniverInstanceService);
+    const unitId = univer
+      .getCurrentUnitForType(UniverInstanceType.UNIVER_SHEET)
+      ?.getUnitId();
+    try {
+      waitUserSelectExcelFile({
+        accept: ".xlsx",
+        onSelect: (file: File) => {
+          LuckyExcel.transformExcelToUniver(
+            file,
+            async (exportJson: any) => {
+              if (unitId) {
+                univer.disposeUnit(unitId);
+              }
+              console.log(exportJson);
+              setTimeout(() => {
+                const workbook = univer.createUnit<IWorkbookData, Workbook>(
+                  UniverInstanceType.UNIVER_SHEET,
+                  exportJson || {}
+                );
+                config?.after?.({ workbook });
+              }, 200);
+            },
+            (error: any) => {
+              console.log(error);
+            }
+          );
+        },
+        onCancel: () => config?.after?.({ error: { message: "取消导入" } }),
+        onError: (error: any) => config?.after?.({ error }),
+      });
+    } catch (error) {
+      config?.after?.({ error });
+    }
+
+    return true;
+  },
+});
+
+const ImportShortcutItem: IShortcutItem = {
+  id: OperationId,
+  description: "shortcut.import",
+  group: "1_common-edit",
+  binding: MetaKeys.CTRL_COMMAND | MetaKeys.SHIFT | KeyCode.I,
+};
+
+function CustomMenuItemImportButtonFactory(): IMenuButtonItem<string> {
+  return {
+    // Bind the command id, clicking the button will trigger this command
+    id: OperationId,
+    // The type of the menu item, in this case, it is a button
+    type: MenuItemType.BUTTON,
+    // The icon of the button, which needs to be registered in ComponentManager
+    icon: "DownloadSingle",
+    // The tooltip of the button. Prioritize matching internationalization. If no match is found, the original string will be displayed
+    tooltip: "customMenu.import",
+    // The title of the button. Prioritize matching internationalization. If no match is found, the original string will be displayed
+    title: "customMenu.import",
+    // The button position can be configured in the toolbar or context menu using MenuPosition. If it is a sheet, you can also use SheetMenuPosition to configure the row header, column header, or sheet bar context menu
+    // positions: [MenuPosition.TOOLBAR_START],
+    // group: MenuGroup.TOOLBAR_HISTORY,
+  };
+}
+
+const CustomImportMenu = (config?: ICustomMenuPulginParams) => ({
+  operation: ImportButtonOperation(config),
+  shortcut: ImportShortcutItem,
+  menu: CustomMenuItemImportButtonFactory,
+  icon: { name: "DownloadSingle", component: DownloadSingle },
+});
+
+export default CustomImportMenu;

+ 61 - 0
src/components/plugins/controllers/menu/save.menu.ts

@@ -0,0 +1,61 @@
+import {
+  MenuItemType,
+  KeyCode,
+  MetaKeys,
+} from "@univerjs/preset-sheets-core";
+import type {
+  IShortcutItem,
+  IMenuButtonItem,
+} from "@univerjs/preset-sheets-core";
+import type { ICommand } from "@univerjs/presets";
+import { CommandType } from "@univerjs/presets";
+import { SaveSingle } from "@univerjs/icons";
+import type { ICustomMenuPulginParams, UniverMenuConfig } from "../..";
+
+const OperationId = "custom-menu.operation.save";
+const SaveButtonOperation: (config?: ICustomMenuPulginParams) => ICommand = (
+  config
+) => ({
+  id: OperationId,
+  type: CommandType.OPERATION,
+  handler: async () => {
+    config?.after?.();
+    return true;
+  },
+});
+
+const SaveShortcutItem: IShortcutItem = {
+  id: OperationId,
+  description: "shortcut.save",
+  group: "1_common-edit",
+  binding: MetaKeys.CTRL_COMMAND | KeyCode.S,
+};
+
+function CustomMenuItemSaveButtonFactory(
+  // accessor: IAccessor
+): IMenuButtonItem<string> {
+  return {
+    // Bind the command id, clicking the button will trigger this command
+    id: OperationId,
+    // The type of the menu item, in this case, it is a button
+    type: MenuItemType.BUTTON,
+    // The icon of the button, which needs to be registered in ComponentManager
+    icon: "SaveSingle",
+    // The tooltip of the button. Prioritize matching internationalization. If no match is found, the original string will be displayed
+    tooltip: "customMenu.save",
+    // The title of the button. Prioritize matching internationalization. If no match is found, the original string will be displayed
+    title: "customMenu.save",
+  };
+}
+
+const CustomSaveMenu: (config?: ICustomMenuPulginParams) => UniverMenuConfig = (
+  config?: ICustomMenuPulginParams
+) => ({
+  id: OperationId,
+  operation: SaveButtonOperation(config),
+  shortcut: SaveShortcutItem,
+  menu: CustomMenuItemSaveButtonFactory,
+  icon: { name: "SaveSingle", component: SaveSingle },
+});
+
+export default CustomSaveMenu;

+ 71 - 0
src/components/plugins/index.ts

@@ -0,0 +1,71 @@
+import {
+  LocaleService,
+  Plugin,
+  Inject,
+  Injector,
+  UniverInstanceType,
+  IConfigService,
+  Univer,
+} from '@univerjs/presets';
+import type { ICommand, Dependency, IAccessor } from '@univerjs/presets';
+import type { IMenuButtonItem, IShortcutItem } from '@univerjs/preset-sheets-core';
+
+import zhCN from './locale/zh-CN';
+import enUS from './locale/en-US';
+import { CustomMenuController } from './controllers/custom-menu.controller';
+
+const SHEET_CUSTOM_MENU_PLUGIN = 'SHEET_CUSTOM_MENU_PLUGIN';
+
+export interface ICustomMenuPluginConfig {
+  instance: Univer;
+  menu: Array<{
+    operation: ICommand<object, boolean>;
+    shortcut?: IShortcutItem<object>;
+    menu: () => IMenuButtonItem<string>;
+    icon?: { name: string; component: any };
+  }>;
+}
+export interface UniverMenuConfig {
+  id: string;
+  operation: ICommand<object, boolean>;
+  shortcut?: IShortcutItem<object>;
+  menu?: (accessor: IAccessor) => IMenuButtonItem<string>;
+  icon?: { name: string; component: any };
+  onlyOperation?: boolean;
+}
+export interface ICustomMenuPulginParams {
+  [key: string]: any;
+  before?: () => void | Promise<any>;
+  after?: (param?: any) => void;
+}
+export const CUSTOM_PLUGIN_CONFIG = 'CUSTOM_PLUGIN_CONFIG';
+const AllCustomController = [CustomMenuController];
+
+export class UniverSheetsCustomMenuPlugin extends Plugin {
+  static override type = UniverInstanceType.UNIVER_SHEET;
+  static override pluginName = SHEET_CUSTOM_MENU_PLUGIN;
+
+  constructor(
+    config: ICustomMenuPluginConfig,
+    @Inject(Injector) protected readonly _injector: Injector,
+    @Inject(LocaleService) private readonly _localeService: LocaleService,
+    @IConfigService private readonly _configService: IConfigService
+  ) {
+    super();
+    this._localeService.load({
+      zhCN,
+      enUS,
+    });
+    this._configService.setConfig(CUSTOM_PLUGIN_CONFIG, config);
+  }
+
+  onReady(): void {
+    AllCustomController.forEach((d) => this._injector.get(d));
+  }
+
+  override onStarting(): void {
+    ([AllCustomController] as Dependency[]).forEach((d) =>
+      this._injector.add(d)
+    );
+  }
+}

+ 10 - 0
src/components/plugins/locale/en-US.ts

@@ -0,0 +1,10 @@
+export default {
+  customMenu: {
+    export: 'Export',
+    import: 'Import',
+  },
+  shortcut: {
+    export: 'Export',
+    import: 'Import',
+  },
+};

+ 10 - 0
src/components/plugins/locale/zh-CN.ts

@@ -0,0 +1,10 @@
+export default {
+  customMenu: {
+    export: '导出',
+    import: '导入',
+  },
+  shortcut: {
+    export: '导出',
+    import: '导入',
+  },
+};

+ 15 - 0
src/main.ts

@@ -0,0 +1,15 @@
+import 'normalize.css';
+import { createApp } from 'vue'
+import App from './App.vue'
+import router from './router';
+import 'virtual:uno.css'
+import 'element-plus/theme-chalk/el-message.css'
+import { createPinia} from 'pinia'
+
+const app = createApp(App)
+const pinia = createPinia()
+
+app.use(router)
+app.use(pinia)
+
+app.mount('#root')

+ 20 - 0
src/pages/common/config.ts

@@ -0,0 +1,20 @@
+export const columnConfig = [
+  {
+    title: '序号',
+    dataIndex: 'index',
+    width: 80,
+    align: 'center',
+    fixed: 'left',
+  },
+  {
+    title: '名称',
+    dataIndex: 'name',
+    width: 200,
+    align: 'left',
+    fixed: 'left',
+  },
+  {
+    title: '数量',
+    dataIndex: 'num',
+  }
+]

+ 168 - 0
src/pages/compare/index.vue

@@ -0,0 +1,168 @@
+<template>
+  <div class="w-full h-full flex flex-col">
+    <div class="h-50px px-12px flex items-center flex-shrink-0">
+      <div class="text-18px font-bold">项目对比</div>
+      <div class="ml-20px text-18px font-bold text-#82bb5d flex items-center">
+        <span>左边物料名称 [版本号]</span>
+        <el-button class="mx-12px" link
+          ><img :src="exchangeImg" class="w-22px"
+        /></el-button>
+        <span>右边物料名称 [版本号]</span>
+      </div>
+    </div>
+
+    <div class="h-46px bg-#f2f3f6 flex px-20px flex-shrink-0">
+      <div class="flex-1 flex gap-12px items-center">
+        <el-select
+          v-model="compareData.left.material"
+          placeholder="请选择"
+          clearable
+        ></el-select>
+        <el-select
+          v-model="compareData.left.version"
+          placeholder="版本"
+          class="w-200px"
+        ></el-select>
+      </div>
+      <div class="w-1px h-full bg-#ddd mx-20px"></div>
+      <div class="flex-1 flex gap-12px items-center">
+        <el-select
+          v-model="compareData.right.material"
+          placeholder="请选择"
+          clearable
+        ></el-select>
+        <el-select
+          v-model="compareData.right.version"
+          placeholder="版本"
+          class="w-200px"
+        ></el-select>
+      </div>
+    </div>
+
+    <div
+      class="h-50px px-20px flex items-center justify-between border-b border-b-solid border-#999 flex-shrink-0"
+    >
+      <div class="flex gap-12px items-center">
+        <el-tag>内部零件号</el-tag>
+        <el-select placeholder="选择属性" class="w-220px"></el-select>
+      </div>
+      <div class="flex gap-12px items-center">
+        <!-- <el-checkbox>拉平对比</el-checkbox> -->
+        <el-checkbox>只展示不同</el-checkbox>
+        <el-button>重置</el-button>
+        <el-button type="primary">对比</el-button>
+      </div>
+    </div>
+
+    <div class="flex-1">
+      <div class="w-full h-full flex flex-col px-20px pt-12px box-border">
+        <div class="flex-1 flex gap-12px">
+          <el-table
+            ref="tableRef1"
+            class="border border-solid border-gray-300"
+            :data="new Array(20).fill(1)"
+            :max-height="maxHeight"
+            @scroll="handleScroolTop"
+            highlight-current-row
+          >
+            <el-table-column label="名称"></el-table-column>
+            <el-table-column label="内部零件号"></el-table-column>
+            <el-table-column label="值"></el-table-column>
+          </el-table>
+          <el-table
+            ref="tableRef2"
+            class="border border-solid border-gray-300"
+            :data="new Array(20).fill(1)"
+            :max-height="maxHeight"
+             @scroll="handleScroolTop"
+             highlight-current-row
+          >
+            <el-table-column label="名称"></el-table-column>
+            <el-table-column label="内部零件号"></el-table-column>
+            <el-table-column label="值"></el-table-column>
+          </el-table>
+        </div>
+        <div class="h-40px flex items-center justify-between px-20px">
+          <div class="text-base text-gray-500">属性</div>
+          <el-checkbox>只展示不同</el-checkbox>
+        </div>
+        <div class="flex-1 flex gap-12px">
+          <el-table
+            ref="tableRef3"
+            class="border border-solid border-gray-300"
+            :data="new Array(20).fill(1)"
+            :max-height="maxHeight"
+             @scroll="handleScroolBottom"
+          >
+            <el-table-column label="属性名称"></el-table-column>
+            <el-table-column label="属性值"></el-table-column>
+          </el-table>
+          <el-table
+            ref="tableRef4"
+            class="border border-solid border-gray-300"
+            :data="new Array(20).fill(1)"
+            :max-height="maxHeight"
+             @scroll="handleScroolBottom"
+          >
+            <el-table-column label="属性名称"></el-table-column>
+            <el-table-column label="属性值"></el-table-column>
+          </el-table>
+        </div>
+      </div>
+      <div v-if="false" class="w-full h-full flex items-center justify-center">
+        <span class="text-#3eb5f1 text-28px">请先选择物料后开始对比!</span>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { onMounted, onUnmounted, ref } from "vue";
+import exchangeImg from "@/assets/exchange.svg";
+import type { TableInstance } from "element-plus";
+
+const maxHeight = ref(0);
+const tableRef1 = ref<TableInstance>();
+const tableRef2 = ref<TableInstance>();
+const tableRef3 = ref<TableInstance>();
+const tableRef4 = ref<TableInstance>();
+
+const compareData = ref({
+  left: {
+    material: undefined,
+    version: undefined,
+  },
+  right: {
+    material: undefined,
+    version: undefined,
+  },
+});
+
+const handleScroolTop = ({scrollTop}: {scrollTop: number}) => {
+  tableRef1.value?.setScrollTop(scrollTop);
+  tableRef2.value?.setScrollTop(scrollTop);
+}
+
+const handleScroolBottom = ({scrollTop}: {scrollTop: number}) => {
+  tableRef3.value?.setScrollTop(scrollTop);
+  tableRef4.value?.setScrollTop(scrollTop);
+}
+// 计算表格最大高度
+const getMaxHeight = () => {
+  const height = document.body.clientHeight - 147;
+
+  maxHeight.value = height ? (height - 52) / 2 - 4 : 0;
+};
+
+onMounted(() => {
+  getMaxHeight();
+  // 监听容器变化
+  window.addEventListener("resize", getMaxHeight);
+});
+
+onUnmounted(() => {
+  window.removeEventListener("resize", getMaxHeight);
+});
+</script>
+
+<style scoped></style>

+ 183 - 0
src/pages/excel/ConfigDrawer.vue

@@ -0,0 +1,183 @@
+<template>
+  <el-drawer v-model="visible" title="物料配置" size="600">
+    <el-form ref="form" label-position="top">
+      <el-row :gutter="12">
+        <el-col :span="12">
+          <el-form-item label="SN">
+            <el-input v-model="formData.sn" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="级号">
+            <el-select disabled placeholder="级号">
+              <el-option
+                v-for="item in 10"
+                :key="item"
+                :label="item"
+                :value="item"
+              />
+            </el-select>
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-row :gutter="12">
+        <el-col :span="12">
+          <el-form-item label="自制/外购/标准件/领用件/DB/CCC/NA">
+            <el-input v-model="formData.name" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="ERP号">
+            <el-input v-model="formData.desc" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-row :gutter="12">
+        <el-col :span="12">
+          <el-form-item label="客户零件号">
+            <el-input v-model="formData.desc" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="客户版本号">
+            <el-input v-model="formData.desc" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-row :gutter="12">
+        <el-col :span="12">
+          <el-form-item label="内部零件号">
+            <el-input v-model="formData.desc" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="零件名/描述">
+            <el-input v-model="formData.desc" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-row :gutter="12">
+        <el-col :span="12">
+          <el-form-item label="材料名称">
+            <el-input v-model="formData.desc" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="材料牌号">
+            <el-input v-model="formData.desc" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-row :gutter="12">
+        <el-col :span="12">
+          <el-form-item label="规格及标准">
+            <el-input v-model="formData.desc" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="生产工艺">
+            <el-input v-model="formData.desc" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-row :gutter="12">
+        <el-col :span="12">
+          <el-form-item label="表面处理">
+            <el-input v-model="formData.desc" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="颜色">
+            <el-input v-model="formData.desc" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-row :gutter="12">
+        <el-col :span="8"
+          ><el-form-item label="长">
+            <el-input v-model="formData.desc" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="8">
+          <el-form-item label="宽">
+            <el-input v-model="formData.desc" /> </el-form-item
+        ></el-col>
+        <el-col :span="8">
+          <el-form-item label="高">
+            <el-input v-model="formData.desc" /> </el-form-item
+        ></el-col>
+      </el-row>
+      <el-row :gutter="12">
+        <el-col :span="12">
+          <el-form-item label="数量">
+            <el-input v-model="formData.desc" /> </el-form-item
+        ></el-col>
+        <el-col :span="12"
+          ><el-form-item label="单位">
+            <el-input v-model="formData.desc" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-row :gutter="12">
+        <el-col :span="12"
+          ><el-form-item label="每件净重">
+            <el-input v-model="formData.desc" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12"
+          ><el-form-item label="每件毛重">
+            <el-input v-model="formData.desc" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-row :gutter="12">
+        <el-col :span="12"
+          ><el-form-item label="工艺消耗定额">
+            <el-input v-model="formData.desc" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="利用率%">
+            <el-input v-model="formData.desc" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-form-item label="供应商">
+        <el-input v-model="formData.desc" />
+      </el-form-item>
+      <el-form-item label="备注">
+        <el-input type="textarea" :rows="4" v-model="formData.desc" />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="visible = false">取消</el-button>
+      <el-button type="primary" @click="visible = false">确定</el-button>
+    </template>
+  </el-drawer>
+</template>
+
+<script setup lang="ts">
+import { ref, defineExpose, reactive } from "vue";
+
+const visible = ref(false);
+const formData = reactive({
+  sn: "",
+  name: "",
+  desc: "",
+});
+
+const open = () => {
+  visible.value = true;
+};
+
+const close = () => {
+  visible.value = false;
+};
+
+defineExpose({
+  open,
+  close,
+});
+</script>
+
+<style scoped></style>

+ 276 - 0
src/pages/excel/MindmapModal.vue

@@ -0,0 +1,276 @@
+<template>
+  <div v-if="visible" class="mindmap-modal">
+    <div class="mindmap-modal_header">
+      <div class="text-xl font-semibold">查看“BOM物料名称”</div>
+      <el-button
+        v-if="!hideClose"
+        link
+        @click="close"
+        :icon="Close"
+        style="font-size: 1.2em"
+      ></el-button>
+    </div>
+    <div class="mindmap-modal_tool">
+      <el-form ref="formRef" :model="formData" class="flex items-center" inline>
+        <el-form-item label="收缩至" prop="level" class="mr-12px">
+          <el-select
+            v-model="formData.level"
+            placeholder="请选择"
+            style="width: 120px"
+            @change="changeLevel"
+          >
+            <el-option v-for="item in 10" :key="item" :label="`${item}级`" :value="item" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="布局" prop="layout" class="mr-12px">
+          <el-select
+            v-model="formData.layout"
+            placeholder="请选择"
+            style="width: 120px"
+            @change="changeLayout"
+          >
+            <el-option label="水平" value="logicalStructure" />
+            <el-option label="垂直" value="organizationStructure" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="搜索" props="search">
+          <div class="flex">
+            <el-input v-model="formData.search" style="width: 120px" />
+            <el-button
+              class="ml-12px"
+              :icon="Search"
+              @click="handleSearch"
+            ></el-button>
+          </div>
+        </el-form-item>
+      </el-form>
+      <div class="flex gap-4px">
+        <el-tooltip content="添加">
+          <el-button
+            type="default"
+            circle
+            :icon="CirclePlusFilled"
+            @click="addNode"
+          />
+        </el-tooltip>
+        <el-tooltip content="详情">
+          <el-button
+            type="default"
+            circle
+            :icon="InfoFilled"
+            @click="openNodeDetail"
+          />
+        </el-tooltip>
+        <el-tooltip content="自适应">
+          <el-button type="default" circle @click="autoFit">
+            <img :src="AutoIcon" style="width: 1em" />
+          </el-button>
+        </el-tooltip>
+        <el-tooltip content="定位到根节点">
+          <el-button type="default" circle :icon="Aim" @click="zoomToRoot" />
+        </el-tooltip>
+        <el-tooltip content="放大">
+          <el-button
+            type="default"
+            circle
+            :icon="ZoomIn"
+            @click="handleZoom(1)"
+          />
+        </el-tooltip>
+        <el-tooltip content="缩小">
+          <el-button
+            type="default"
+            circle
+            :icon="ZoomOut"
+            @click="handleZoom(0)"
+          />
+        </el-tooltip>
+      </div>
+    </div>
+
+    <div class="mindmap-modal_body">
+      <Mindmap v-model:data="editBomStore.mindmapData" ref="mindmapRef" />
+    </div>
+  </div>
+
+  <ConfigDrawer ref="configDrawerRef" />
+</template>
+
+<script setup lang="ts">
+import { ref, defineExpose, reactive, defineProps } from "vue";
+import {
+  Aim,
+  CirclePlusFilled,
+  Close,
+  InfoFilled,
+  Search,
+  ZoomIn,
+  ZoomOut,
+} from "@element-plus/icons-vue";
+import type { MindMapInstance } from "@/components/mindmap/Mindmap.vue";
+import type { FormInstance } from "element-plus";
+import { ElMessage } from "element-plus";
+import { bfsWalk } from "simple-mind-map/src/utils";
+import { useEditBomStore } from "@/store/editbom";
+
+import Mindmap from "@/components/mindmap/Mindmap.vue";
+import ConfigDrawer from "./ConfigDrawer.vue";
+import AutoIcon from "@/assets/auto.svg";
+
+const props = defineProps<{
+  defaultOpen?: boolean;
+  hideClose?: boolean;
+}>();
+
+const visible = ref(!!props.defaultOpen);
+const mindmapRef = ref<MindMapInstance>();
+const configDrawerRef = ref();
+const formRef = ref<FormInstance>();
+const editBomStore = useEditBomStore();
+const formData = reactive({
+  level: undefined,
+  layout: "logicalStructure",
+  search: "",
+});
+
+// 定位到根节点
+const zoomToRoot = () => {
+  const mindmap = mindmapRef.value?.getInstance();
+  mindmap?.renderer?.setRootNodeCenter();
+};
+
+// 切换布局
+const changeLayout = (value: string) => {
+  const mindmap = mindmapRef.value?.getInstance();
+  mindmap?.setLayout(value);
+  let fited = false;
+  mindmap?.on("node_tree_render_end", () => {
+    // @ts-ignore
+    !fited && mindmap?.view?.fit();
+    fited = true;
+  });
+};
+
+// 切换层级
+const changeLevel = (level: number) => {
+  const mindmap = mindmapRef.value?.getInstance();
+  mindmap?.execCommand('UNEXPAND_TO_LEVEL', level);
+}
+
+const autoFit = () => {
+  const mindmap = mindmapRef.value?.getInstance();
+  // @ts-ignore
+  mindmap?.view?.fit();
+};
+
+// 节点详情
+const openNodeDetail = () => {
+  const activeList = mindmapRef.value?.getActiveNodeList();
+  if (!activeList?.length) {
+    ElMessage.warning("请选择节点");
+    return;
+  }
+  configDrawerRef.value.open();
+};
+
+// 缩放
+const handleZoom = (num: number) => {
+  const mindmap = mindmapRef.value?.getInstance();
+  if (num) {
+    // @ts-ignore
+    mindmap?.view?.enlarge();
+  } else {
+    // @ts-ignore
+    mindmap?.view?.narrow();
+  }
+};
+
+// 添加节点
+const addNode = () => {
+  const mindmap = mindmapRef.value?.getInstance();
+  const activeList = mindmapRef.value?.getActiveNodeList();
+  if (!activeList?.length) {
+    ElMessage.warning("请选择父节点");
+    return;
+  }
+
+  mindmap?.execCommand("INSERT_CHILD_NODE");
+};
+
+// 搜索
+const handleSearch = () => {
+  const result = [];
+  const mindmap = mindmapRef.value?.getInstance();
+  const data = mindmap?.getData(false);
+  console.log(data);
+  // TODO: 处理搜索结果
+  bfsWalk(data, (node: any) => {
+    console.log('遍历节点:', node);
+    if(node.data?.a?.includes(formData.search)) {
+      result.push(node);
+    }
+  });
+};
+
+// 打开弹窗
+const open = () => {
+  visible.value = true;
+};
+
+// 关闭弹窗
+const close = () => {
+  formRef.value?.resetFields();
+  visible.value = false;
+};
+
+defineExpose({
+  open,
+  close,
+});
+</script>
+
+<style lang="less" scoped>
+.mindmap-modal {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  z-index: 99;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  &_header {
+    height: 40px;
+    width: 100%;
+    box-sizing: border-box;
+    background: #fff;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: 0 20px;
+    line-height: 40px;
+  }
+  &_tool {
+    width: 100%;
+    box-sizing: border-box;
+    background: #fff;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: 0 20px;
+  }
+  &_body {
+    flex: 1;
+    width: 100%;
+    background: #fafafa;
+  }
+}
+
+:deep(*) {
+  .el-form-item {
+    margin-top: 12px;
+    margin-bottom: 12px;
+  }
+}
+</style>

+ 63 - 0
src/pages/excel/index.vue

@@ -0,0 +1,63 @@
+<template>
+  <div class="page-container">
+    <div class="header-bar">
+      <div>
+        <el-button link @click="openMindmap" class="mr-8px"><img class="w-1em mr-4px" :src="saveImg"/>保存</el-button>
+        <!-- <el-button link @click="openMindmap"><img class="w-1em mr-4px" :src="mindmapImg"/>思维导图模式</el-button> -->
+      </div>
+      <div>
+        <!-- <el-button link @click="openMindmap" class="mr-8px"><img class="w-1em mr-4px" :src="saveImg"/>项目比对</el-button> -->
+        <el-select placeholder="历史版本" class="w-120px" size="small"> 
+          <el-option label="2025.7.16 12:12" value="1"></el-option>
+        </el-select>
+      </div>
+    </div>
+    <Sheet ref="sheetRef" :created="onSheetCreated" show-export-menu show-import-menu />
+  </div>
+  <MindmapModal ref="mindmapModalRef"/>
+</template>
+
+<script setup lang="ts">
+import { ref } from "vue";
+import type { UniverExpose } from "@/components/Sheet.vue";
+import Sheet from "@/components/Sheet.vue";
+import MindmapModal from "./MindmapModal.vue";
+// import mindmapImg from "@/assets/mindmap.svg";
+import saveImg from "@/assets/save.svg";
+import { useEditBomStore } from "@/store/editbom";
+import type { FUniver, Univer } from "@univerjs/presets";
+
+const sheetRef = ref<UniverExpose | null>(null);
+const mindmapModalRef = ref<any>(null);
+const editBomStore = useEditBomStore();
+
+const onSheetCreated = (univer: Univer, univerApi: FUniver) => {
+  editBomStore.univer = univer;
+  editBomStore.univerApi = univerApi;
+}
+
+const openMindmap = () => {
+  mindmapModalRef.value.open();
+};
+</script>
+
+<style lang="less" scoped>
+.page-container {
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  /* align-items: center; */
+  justify-content: center;
+  height: 100%;
+}
+
+.header-bar {
+  height: 40px;
+  background: #f5f5f5;
+  padding: 0 12px;
+  line-height: 40px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: space-between;
+}
+</style>

+ 18 - 0
src/pages/mindmap/index.vue

@@ -0,0 +1,18 @@
+<template>
+  <MindmapModal ref="mindmapModalRef" :hide-close="true" />
+</template>
+
+<script setup lang="ts">
+import { onMounted, ref } from "vue"
+import MindmapModal from "../excel/MindmapModal.vue"
+
+const mindmapModalRef = ref<InstanceType<typeof MindmapModal>>()
+
+onMounted(() => {
+  mindmapModalRef.value?.open()
+})
+</script>
+
+<style scoped>
+
+</style>

+ 31 - 0
src/router/index.ts

@@ -0,0 +1,31 @@
+import { createRouter, createWebHashHistory } from 'vue-router';
+import type { RouteRecordRaw } from 'vue-router';
+
+const routes: Array<RouteRecordRaw> = [
+  {
+    path: '/',
+    redirect: '/excel'
+  },
+  {
+    path: '/excel',
+    name: 'OnlineExcel',
+    component: () => import('../pages/excel/index.vue'),
+  },
+  {
+    path: '/mindmap',
+    name: 'Mindmap',
+    component: () => import('../pages/mindmap/index.vue'),
+  },
+  {
+    path: '/compare',
+    name: 'Compare',
+    component: () => import('../pages/compare/index.vue'),
+  },
+];
+
+const router = createRouter({
+  history: createWebHashHistory(),
+  routes,
+});
+
+export default router; 

+ 80 - 0
src/store/editbom.ts

@@ -0,0 +1,80 @@
+import { defineStore } from "pinia";
+import { computed, ref, watch } from "vue";
+import {
+  convertUniverData2ObjectList,
+  array2mindmapData,
+} from "@/utils/convert";
+
+import type { Workbook, FUniver, Univer } from "@univerjs/presets";
+import { ElMessage } from "element-plus";
+
+export const useEditBomStore = defineStore("editBom", () => {
+  const workbook = ref<Workbook | null>(null);
+  const univerApi = ref<FUniver | null>(null);
+  const univer = ref<Univer | null>(null);
+
+  /**
+   * 思维导图数据与excel数据转换
+   * 拦截思维导图数据 转换成excel数据
+   */
+  const mindmapData = computed({
+    get() {
+      const fWorkbook = univerApi.value?.getActiveWorkbook();
+      const fWorksheet = fWorkbook?.getActiveSheet();
+
+      const rows = fWorksheet?.getLastRow() || 0;
+      const columns = fWorksheet?.getLastColumn() || 0;
+      const range = fWorksheet?.getRange(0, 0, rows + 1, columns + 1);
+      // 获取表格数据
+      const rowData = range?.getValues();
+
+      if (!rowData) {
+        ElMessage.error("数据异常");
+        return [];
+      }
+      // 表格数据转换成对象列表
+      const json = convertUniverData2ObjectList(rowData);
+      // 转换成mindmap数据
+      const list = array2mindmapData(json);
+      return {
+        id: "root",
+        data: {
+          type: "root",
+        },
+        children: list,
+      };
+    },
+    set(value: any) {
+      console.log("修改mindmap:", value);
+      // 1、移动行数据 sheet.moveRows(要移动的行(范围), 目标位置)
+      // 2、新增行数据 sheet.insertRowAfter(目标行)
+      // 3、修改行数据 
+      // 3.1 获取范围
+      // 3.2 设置范围值
+      if (!workbook.value) return;
+    },
+  });
+
+  watch(
+    () => univerApi.value,
+    (api) => {
+      if(api) {
+        // 开始编辑事件 params.cancel = true 阻止编辑
+        api.addEvent(api.Event.BeforeSheetEditStart, params => {
+          console.log('BeforeSheetEditStart', params)
+          // params.cancel = true
+        });
+        api.addEvent(api.Event.SheetEditChanging, params => {
+          console.log('SheetEditChanging', params)
+        });
+      }
+    }
+  );
+
+  return {
+    workbook,
+    mindmapData,
+    univer,
+    univerApi,
+  };
+});

+ 97 - 0
src/utils/convert.ts

@@ -0,0 +1,97 @@
+/**
+ * 转换univer数据为对象列表
+ * @param data univer数据
+ * @returns 对象列表
+ *
+ */
+export function convertUniverData2ObjectList(
+  data: any[][]
+): Record<string, any>[] {
+  const result: any[] = [];
+  const getKey = (index: number) => {
+    let key = `key_${index}`;
+    for (let i = 4; i >= 0; i--) {
+      if (data[i][index] !== null) {
+        key = data[i][index];
+        break;
+      }
+    }
+    return key;
+  };
+
+  // 遍历工作表 前5行默认为表头 根据表头下表获取key值
+  for (let i = 5; i < data.length; i++) {
+    const obj: Record<string, any> = {};
+    data[i].forEach((cell, j) => {
+      const key = getKey(j);
+      obj[key] = {
+        index: j,
+        value: cell,
+      };
+    });
+    result.push(obj);
+  }
+
+  return result;
+}
+
+interface TreeNode {
+  id: string;
+  children?: TreeNode[];
+  parentId?: string;
+  level: number;
+  [key: string]: any;
+}
+
+export const array2mindmapData = (data: any[]): TreeNode[] => {
+  const nodes: TreeNode[] = [];
+  
+  // 第一步:转换所有节点
+  data.forEach(item => {
+    const sn = item['SN'].value;
+    const level = sn.split('.').length - 1;
+    
+    const node: TreeNode = {
+      id: sn,
+      level,
+      data: {
+        sourceData: item,
+      },
+      children: []
+    };
+    
+    // 计算父节点ID
+    if (sn.includes('.')) {
+      const parentId = sn.substring(0, sn.lastIndexOf('.'));
+      node.parentId = parentId;
+    }
+    
+    nodes.push(node);
+  });
+
+  // 第二步:构建树
+  const tree: TreeNode[] = [];
+  const map = new Map<string, TreeNode>();
+  
+  // 创建查找表
+  nodes.forEach(node => {
+    map.set(node.id, node);
+  });
+  
+  // 构建树形结构
+  nodes.forEach(node => {
+    if (node.parentId) {
+      const parent = map.get(node.parentId);
+      if (parent) {
+        if (!parent.children) {
+          parent.children = [];
+        }
+        parent.children.push(node);
+      }
+    } else {
+      tree.push(node);
+    }
+  });
+  
+  return tree;
+};

+ 220 - 0
src/utils/index.ts

@@ -0,0 +1,220 @@
+// import * as XLSX from "xlsx";
+// import type { WorkBook, WorkSheet } from "xlsx";
+// import type {
+//   IWorkbookData,
+//   IWorksheetData,
+//   ICellData,
+//   IObjectMatrixPrimitiveType,
+//   IStyleData,
+// } from "@univerjs/presets";
+// import { LocaleType } from "@univerjs/presets";
+// import { uniqueId } from "lodash-es";
+
+// // 获取单元格数据
+// const getCellData = (worksheet: WorkSheet) => {
+//   const cellData: IObjectMatrixPrimitiveType<ICellData> = {};
+//   for (const cellKey in worksheet) {
+//     if (cellKey[0] === "!") continue;
+
+//     const match = cellKey.match(/([A-Z]+)(\d+)/);
+//     const columnName = match?.[1] || "A";
+//     const rowIndex = parseInt(match?.[2] || "1", 10) - 1;
+//     const colIndex = XLSX.utils.decode_col(columnName);
+
+//     const cell = worksheet[cellKey];
+//     const data = {
+//       v: cell.v, // 单元格原始值
+//       s: "", // 单元格样式id或者样式对象
+//       t: cell.t, // 单元格类型
+//       p: cell.h, // 富文本
+//       f: cell.f, // 单元格公式
+//       si: "", // 公式id
+//     };
+
+//     if (cellData[rowIndex]) {
+//       cellData[rowIndex][colIndex] = data;
+//     } else {
+//       cellData[rowIndex] = {};
+//       cellData[rowIndex][colIndex] = data;
+//     }
+//   }
+
+//   return cellData;
+// };
+
+// /** 
+//  * xlsx的workbook对象转换成IWorkbookData结构
+//  * @param workbook xlsx的workbook对象
+//  */
+// export const convertXLSXWorkbookToUniver = (
+//   workbook: WorkBook
+// ): IWorkbookData => {
+//   const sheets: Record<string, IWorksheetData> = {};
+//   const sheetOrder: string[] = [];
+//   console.log(workbook);
+
+//   const styles: Record<string, IStyleData> = {};
+
+//   // @ts-ignore 遍历所有单元格样式
+//   workbook?.Styles?.CellXf?.forEach((xf, index) => {
+//     // @ts-ignore
+//     styles[index] = getStyleData(xf, workbook.Styles);
+//   });
+
+//   // 遍历工作表,对每个工作表进行处理
+//   workbook.SheetNames.forEach((sheetName, sheetIndex) => {
+//     const worksheet = workbook.Sheets[sheetName];
+//     // 获取总的行数列数
+//     const jsonSheet = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
+//     const rowCount = jsonSheet.length;
+//     const columnCount = Math.max(...jsonSheet.map((row: any) => row.length));
+//     // 获取单元格数据
+//     const cellData = getCellData(worksheet);
+//     // 获取合并数据
+//     const mergeData = (worksheet["!merges"] || []).map(item => ({ startRow: item.s.r, startColumn: item.s.c, endRow: item.e.r, endColumn: item.e.c }));
+
+//     // 构造IWorksheetData对象
+//     const sheetId = `sheet_${sheetIndex}`;
+//     sheetOrder.push(sheetId);
+//     // 构造单元格数据结构
+//     sheets[sheetId] = {
+//       id: sheetId,
+//       name: sheetName,
+//       hidden: workbook.Workbook?.Sheets?.[sheetIndex]?.Hidden ? 1 : 0,
+//       rowCount,
+//       columnCount,
+//       mergeData,
+//       cellData,
+//       tabColor: "",
+//       zoomRatio: 1,
+//       freeze: {
+//         startRow: -1,
+//         startColumn: -1,
+//         ySplit: 0,
+//         xSplit: 0,
+//       },
+//       scrollTop: 0,
+//       scrollLeft: 0,
+//       defaultColumnWidth: 73,
+//       defaultRowHeight: 23,
+//       rowData: {},
+//       columnData: {},
+//       showGridlines: 1,
+//       rowHeader: {
+//         width: 46,
+//         hidden: 0,
+//       },
+//       columnHeader: {
+//         height: 20,
+//         hidden: 0,
+//       },
+//       rightToLeft: 0,
+//     };
+//   });
+
+//   // 返回IWorkbookData结构
+//   return {
+//     id: uniqueId(),
+//     name: "myWorkbook",
+//     appVersion: "1.0.0",
+//     locale: LocaleType.ZH_CN,
+//     styles,
+//     sheetOrder,
+//     sheets,
+//   };
+// };
+
+// const hAlignMap = {
+//   'left': 1,
+//   'center': 2,
+//   'right': 3,
+// }
+
+// const vAlignMap = {
+//   'top': 1,
+//   'center': 2,
+//   'bottom': 3,
+// }
+
+// const getStyleData = (xf: any, styles: any): IStyleData => {
+//   const { Borders = [], Fills = [], Fonts = [], NuberFmt = [] } = styles || {};
+//   const font = Fonts[xf?.fontId];
+//   const alignment = xf?.alignment;
+//   const border = Borders[xf?.borderId];
+//   const fill = Fills[xf?.fillId];
+//   const numberFormat = NuberFmt[xf?.numFmtId];
+
+//   return {
+//     // 字体
+//     ff: font?.name,
+//     // 字体大小
+//     fs: font?.sz,
+//     // 倾斜
+//     it: font?.italic,
+//     // 加粗
+//     bl: font?.bold,
+//     // 下划线
+//     ul: font?.underline,
+//     // 删除线
+//     st: font?.strike,
+//     // 上划线
+//     ol: font?.outline,
+//     // 背景颜色
+//     bg: {
+//       rgb: fill?.fgColor?.rgb,
+//     },
+//     // 边框
+//     bd: {
+//       t: border?.top?.style,
+//       b: border?.bottom?.style,
+//       l: border?.left?.style,
+//       r: border?.right?.style,
+//       tl_br: border?.diagonalUp ? { cl: { th: 0 }, s: 0} : undefined,
+//       bl_tr: border?.diagonalDown ? { cl: { th: 0 }, s: 0} : undefined,
+//     },
+//     // 字体颜色
+//     cl: font?.color?.rgb, // TODO 根据theme或者index获取
+//     // 上标下标
+//     va: 1,
+//     // 文字旋转
+//     tr: {
+//       a: 0, // 文字旋转角度
+//       v: 0, // 是否垂直。1 表示垂直,0 表示水平。默认值为 0。当 v 为 1 时,a 无效
+//     },
+//     // 水平对齐
+//     ht: hAlignMap[alignment?.vertical as '' || 'center'],
+//     // 垂直对齐
+//     vt: vAlignMap[alignment?.horizontal as '' || 'center'],
+//     // 截断溢出
+//     tb: alignment?.wrapText ? 3 : undefined,
+//     // 内边距
+//     // pd: '',
+//     // 数字格式
+//     n: {
+//       pattern: numberFormat
+//     },
+//   }
+// }
+
+
+// 选择文件
+export const waitUserSelectExcelFile = (params: {
+  onSelect?: (result: File) => void;
+  onCancel?: () => void;
+  onError?: (error: any) => void;
+  accept?: string;
+}) => {
+  const { onSelect, onCancel, accept = '.csv' } = params;
+  const input = document.createElement('input');
+  input.type = 'file';
+  input.accept = accept;
+  input.click();
+  input.oncancel = () => {
+    onCancel?.();
+  };
+  input.onchange = () => {
+    const file = input.files?.[0];
+    if (!file) return;
+    onSelect?.(file);
+  };
+};

+ 9 - 0
src/vite-env.d.ts

@@ -0,0 +1,9 @@
+/// <reference types="vite/client" />
+
+interface Window {
+  luckysheet: any;
+}
+declare module 'luckyexcel';
+declare module 'simple-mind-map/src/plugins/Drag';
+declare module 'simple-mind-map/src/plugins/Search';
+declare module 'simple-mind-map/src/utils';

Datei-Diff unterdrückt, da er zu groß ist
+ 4949 - 0
stats.html


+ 23 - 0
tsconfig.app.json

@@ -0,0 +1,23 @@
+{
+  "extends": "@vue/tsconfig/tsconfig.dom.json",
+  "compilerOptions": {
+    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+
+    /* Linting */
+    "strict": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "erasableSyntaxOnly": false,
+    "noFallthroughCasesInSwitch": true,
+    "noUncheckedSideEffectImports": true,
+    "jsxImportSource": "vue",
+    "jsx": "preserve",
+
+    "experimentalDecorators": true,
+    
+    "baseUrl": ".",
+    "paths": {
+      "@/*": ["src/*"]
+    }
+  }
+}

+ 7 - 0
tsconfig.json

@@ -0,0 +1,7 @@
+{
+  "files": [],
+  "references": [
+    { "path": "./tsconfig.app.json" },
+    { "path": "./tsconfig.node.json" }
+  ]
+}

+ 25 - 0
tsconfig.node.json

@@ -0,0 +1,25 @@
+{
+  "compilerOptions": {
+    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+    "target": "ES2023",
+    "lib": ["ES2023"],
+    "module": "ESNext",
+    "skipLibCheck": true,
+
+    /* Bundler mode */
+    "moduleResolution": "bundler",
+    "allowImportingTsExtensions": true,
+    "verbatimModuleSyntax": true,
+    "moduleDetection": "force",
+    "noEmit": true,
+
+    /* Linting */
+    "strict": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "erasableSyntaxOnly": true,
+    "noFallthroughCasesInSwitch": true,
+    "noUncheckedSideEffectImports": true
+  },
+  "include": ["vite.config.ts"]
+}

+ 5 - 0
uno.config.ts

@@ -0,0 +1,5 @@
+import { defineConfig } from 'unocss'
+
+export default defineConfig({
+  // ...UnoCSS options
+})

+ 52 - 0
vite.config.ts

@@ -0,0 +1,52 @@
+import { defineConfig } from "vite";
+import vue from "@vitejs/plugin-vue";
+import { resolve } from "path";
+import AutoImport from "unplugin-auto-import/vite";
+import Components from "unplugin-vue-components/vite";
+import { ElementPlusResolver } from "unplugin-vue-components/resolvers";
+import UnoCSS from "unocss/vite";
+import vueJsx from "@vitejs/plugin-vue-jsx";
+import { visualizer } from "rollup-plugin-visualizer";
+
+// https://vite.dev/config/
+export default defineConfig({
+  base: "./",
+  plugins: [
+    vue(),
+    AutoImport({
+      resolvers: [ElementPlusResolver()],
+    }),
+    Components({
+      resolvers: [ElementPlusResolver()],
+    }),
+    UnoCSS(),
+    vueJsx(),
+    visualizer({
+      gzipSize: true,
+      brotliSize: true,
+      emitFile: false,
+      filename: "stats.html", //分析图生成的文件名
+      open: true, //如果存在本地服务端口,将在打包后自动展示
+    }),
+  ],
+  resolve: {
+    alias: {
+      "@": resolve(__dirname, "src"),
+    },
+  },
+  build: {
+    // cssCodeSplit: false,
+    sourcemap: false,
+    minify: "esbuild",
+    rollupOptions: {
+      output: {
+        chunkFileNames: 'static/js/[name]-[hash].js',
+        entryFileNames: 'static/js/[name]-[hash].js',
+        assetFileNames: 'static/[ext]/[name]-[hash].[ext]'
+      }
+    }
+  },
+  esbuild: {
+    // drop: ["console"],
+  },
+});