ソースを参照

feat: 迁移handsontable组件

qinlaiqiao 3 年 前
コミット
dfc141a5a3
49 ファイル変更3015 行追加5 行削除
  1. 2 0
      package.json
  2. 447 0
      src/components/handsontable/Handsontable.vue
  3. 58 0
      src/components/handsontable/composables/useClickPosition.ts
  4. 46 0
      src/components/handsontable/composables/useFixedLeftScroll.ts
  5. 62 0
      src/components/handsontable/composables/useGroupingHeaders.ts
  6. 57 0
      src/components/handsontable/composables/useHoverChangeRowBg.ts
  7. 49 0
      src/components/handsontable/composables/useInstance.ts
  8. 201 0
      src/components/handsontable/composables/useTableSettings.ts
  9. 27 0
      src/components/handsontable/composables/useVerticalScroll.ts
  10. 32 0
      src/components/handsontable/renderer/createRenderer.ts
  11. 43 0
      src/components/handsontable/renderer/index.ts
  12. 15 0
      src/components/handsontable/renderer/registerRenderers.ts
  13. 54 0
      src/components/handsontable/renderer/renderers/buttonRenderer.ts
  14. 18 0
      src/components/handsontable/renderer/renderers/checkBoxRender.ts
  15. 43 0
      src/components/handsontable/renderer/renderers/costKindRenderer.ts
  16. 47 0
      src/components/handsontable/renderer/renderers/costSwitcherRenderer.ts
  17. 135 0
      src/components/handsontable/renderer/renderers/deleteRenderer.ts
  18. 14 0
      src/components/handsontable/renderer/renderers/emptyRenderer.ts
  19. 20 0
      src/components/handsontable/renderer/renderers/feeRateRenderer.ts
  20. 24 0
      src/components/handsontable/renderer/renderers/gljTypeRenderer.ts
  21. 33 0
      src/components/handsontable/renderer/renderers/indentRender.ts
  22. 25 0
      src/components/handsontable/renderer/renderers/multipleLineRenderer.ts
  23. 38 0
      src/components/handsontable/renderer/renderers/popoverRenderer.ts
  24. 29 0
      src/components/handsontable/renderer/renderers/popoverSwitcherRenderer.ts
  25. 40 0
      src/components/handsontable/renderer/renderers/popoverValueRenderer.ts
  26. 70 0
      src/components/handsontable/renderer/renderers/recoverRenderer.ts
  27. 33 0
      src/components/handsontable/renderer/renderers/selectRender.ts
  28. 62 0
      src/components/handsontable/renderer/renderers/shareProjectRenderer.ts
  29. 29 0
      src/components/handsontable/renderer/renderers/simpleSelectRender.ts
  30. 180 0
      src/components/handsontable/renderer/renderers/switcherRenderer.ts
  31. 18 0
      src/components/handsontable/renderer/renderers/timestampRender.ts
  32. 4 2
      src/components/index.ts
  33. 221 0
      src/components/popover/Popover.vue
  34. 27 0
      src/components/popover/composables/popoverHelp.ts
  35. 18 0
      src/components/popover/composables/types.ts
  36. 42 0
      src/components/popover/composables/usePopover.ts
  37. 7 0
      src/constants/commonClassName.ts
  38. 37 0
      src/styles/_mixin.scss
  39. 25 0
      src/styles/_variables.scss
  40. 2 0
      src/styles/index.scss
  41. 100 0
      src/types/components.d.ts
  42. 0 0
      src/utils/common/.gitkeep
  43. 0 0
      src/utils/common/tools.ts
  44. 181 0
      src/utils/frontend/alert.ts
  45. 183 0
      src/utils/frontend/emitter.ts
  46. 167 0
      src/utils/frontend/http.ts
  47. 30 0
      src/utils/frontend/vueHelper.ts
  48. 19 2
      src/views/project/summary/components/cost-table/CostTable.vue
  49. 1 1
      src/views/project/summary/components/cost-table/style.scss

+ 2 - 0
package.json

@@ -29,10 +29,12 @@
     "vue-router": "^4.0.12"
   },
   "devDependencies": {
+    "@babel/polyfill": "^7.12.1",
     "@midwayjs/cli": "^1.2.71",
     "@midwayjs/cli-plugin-build": "^1.2.70",
     "@midwayjs/hooks-testing-library": "^2.2.2",
     "@midwayjs/vite-plugin-hooks": "^2.2.3",
+    "@sc/types": "^1.0.29",
     "@types/animejs": "^3.1.4",
     "@types/jest": "^26.0.23",
     "@types/koa-bodyparser": "^4.3.1",

+ 447 - 0
src/components/handsontable/Handsontable.vue

@@ -0,0 +1,447 @@
+<template>
+  <div
+    class="handsontable-container"
+    :style="borderStyle"
+    v-loading="loading"
+    element-loading-background="rgba(245, 245, 245, 0.5)"
+  >
+    <div class="table-wrapper" ref="handsontableRef"></div>
+  </div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, onBeforeUnmount, onMounted, ref, toRefs, watch } from 'vue';
+import Handsontable from '@sc/handsontable';
+import { TreeNode } from '@sc/tree';
+import debounce from 'lodash/debounce';
+import { off } from '@/utils/frontend/dom';
+import useClickPosition from '@/components/handsontable/composables/useClickPosition';
+import useInstance from './composables/useInstance';
+import useTableSettings, { getColumnsSettings, getTableData, overwriteReadOnly } from './composables/useTableSettings';
+import './renderer/registerRenderers';
+// import './cell-types/registerCellTypes';
+
+export default defineComponent({
+  name: 'Handsontable',
+  props: {
+    // 表格的数据
+    data: {
+      type: Array,
+      required: true,
+    },
+    // 表格的设置选项
+    settings: {
+      type: Object,
+      required: true,
+    },
+    // 是否是树结构,即每行数据是否是 `TreeNode`
+    tree: {
+      type: Boolean,
+      default: false,
+    },
+    // 边框, none 表示没有边框
+    border: {
+      type: String,
+      default: 'top,right,bottom,left',
+    },
+    // 整个表格是否只读
+    readOnly: {
+      type: Boolean,
+      default: false,
+    },
+    // 是否处于 loading 状态
+    loading: {
+      type: Boolean,
+      default: false,
+    },
+  },
+  setup(props) {
+    // 模板引用
+    const handsontableRef = ref<HTMLElement | null>(null);
+
+    const hotProps = props as Hot.IHandsontableProps;
+    const tableSettings = useTableSettings(hotProps);
+
+    let rawData = props.data as any;
+
+    // Handsontable 实例
+    let instance: Handsontable | null;
+
+    type RowHeaders = boolean | ((visualRow: number) => number);
+    const getRowHeader = (): RowHeaders => {
+      if (tableSettings.rowHeaders !== undefined) return tableSettings.rowHeaders as any;
+      return props.tree
+        ? (visualRow: number) => {
+          if (instance) {
+            const rowSourceData = instance.getSourceDataAtRow(instance.toPhysicalRow(visualRow)) as TreeNode;
+            return rowSourceData.getCtx().row() + 1;
+          }
+          return visualRow;
+        }
+        : true;
+    };
+    // 重新渲染,供外界调用
+    const Render = () => instance && instance.render();
+
+    const onResize = debounce(() => Render(), 100);
+
+    // 监听 mousedownPosition 事件的处理器
+    let clickPositionHandler: any;
+
+    onMounted(() => {
+      const wrapperDOM = handsontableRef.value as HTMLElement;
+      // handsontable 实例
+      instance = useInstance(wrapperDOM, tableSettings);
+
+      // 获取鼠标点击的区域相关代码
+      clickPositionHandler = useClickPosition(wrapperDOM, tableSettings);
+
+      // 设置行号
+      (() => {
+        const rowHeaders = getRowHeader();
+        instance.updateSettings({ rowHeaders }, false);
+      })();
+
+      // 改变窗口大小监听事件
+      window.addEventListener('resize', onResize);
+    });
+
+    const { readOnly, tree } = toRefs(props);
+
+    // 重新加载数据,供外界调用
+    const Load = (data?: any) => {
+      if (data) rawData = data;
+      instance && instance.loadData(getTableData(tree.value, rawData));
+    };
+
+    // 获取 instance,供外界调用
+    const GetInstance = () => instance;
+
+    // 更新 Settings,供外界调用(如增加或减少列)
+    const Update = (newTableSettings: Hot.ISettings) => {
+      const { columnsMeta } = newTableSettings;
+      const columnsData = getColumnsSettings(columnsMeta);
+      let newSettings;
+      if (columnsData) {
+        newSettings = { ...columnsData, ...newTableSettings };
+        delete newSettings.columnsMeta;
+      } else {
+        newSettings = newTableSettings;
+      }
+
+      overwriteReadOnly(newSettings, readOnly.value);
+
+      if (newSettings.rowHeaders === undefined)
+        // 设置行号
+        newSettings.rowHeaders = getRowHeader();
+      instance && instance.updateSettings(newSettings, false);
+    };
+
+    watch(readOnly, () => {
+      Update(props.settings);
+    });
+    watch(tree, () => {
+      const newSettings = props.settings;
+      const { columnsMeta } = newSettings;
+      columnsMeta &&
+        columnsMeta.forEach((columnMeta: any) => {
+          if (columnMeta.renderer === 'wc.switcherRenderer') {
+            delete columnMeta.renderer;
+          }
+        });
+
+      Update(newSettings);
+      Load();
+    });
+    // 组件 unmount 之前销毁 handsontable 实例
+    onBeforeUnmount(() => {
+      if (instance) {
+        window.removeEventListener('resize', onResize);
+
+        // 移除所有的监听器
+        // Handsontable.hooks.destroy && Handsontable.hooks.destroy(instance);
+        instance.destroy();
+        instance = null;
+      }
+      if (clickPositionHandler) off(document.body, 'mouseup', clickPositionHandler);
+    });
+
+    // 边框样式
+    const borderStyle = computed(() => {
+      if (props.border === 'none') {
+        return {
+          border: 'none',
+        };
+      }
+      const borders = props.border.split(',');
+      const style = {} as any;
+      borders.forEach(border => {
+        style[`border-${border}`] = '1px solid #d4d4d4';
+      });
+      return style;
+    });
+
+    return {
+      handsontableRef,
+      borderStyle,
+      Render,
+      Update,
+      Load,
+      GetInstance,
+    };
+  },
+});
+</script>
+
+<style lang="scss" scoped>
+.handsontable-container {
+  position: relative;
+  width: 100%;
+  height: 100%;
+  @include scrollbar-handsontable;
+}
+
+.table-wrapper {
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+
+  ::v-deep > .ht_master {
+  }
+
+  ::v-deep > .ht_clone_top_left_corner,
+  ::v-deep > .ht_clone_top {
+    .htCore {
+      .real-th {
+        position: relative;
+        overflow: visible;
+
+        &.ht__highlight {
+          .relative {
+            background-color: #dad8d6 !important;
+          }
+        }
+
+        .relative {
+          position: absolute;
+          bottom: 0;
+          left: 0;
+          padding: 0;
+          width: 100%;
+          background-color: $smoke;
+
+          &:hover {
+            background-color: #dfdfdf !important;
+          }
+        }
+      }
+    }
+  }
+
+  ::v-deep > .ht_clone_bottom {
+  }
+
+  ::v-deep > .ht_clone_left {
+    // overflow: visible !important;
+
+    .wtHolder {
+      width: 100% !important;
+    }
+
+    .htCore {
+      transition: box-shadow 0.3s ease;
+
+      &.shadow {
+        box-shadow: 4px 0 4px -3px rgba(0, 0, 0, 0.2);
+      }
+
+      tbody {
+        // 这段代码会导致小屏幕上缩放分辨率时,表格行错位的问题
+        /*th {
+          display: flex;
+          align-items: center;
+          justify-content: center;
+        }*/
+      }
+    }
+  }
+
+  ::v-deep > .ht_clone_top_left_corner {
+    .htCore {
+      thead {
+        tr {
+          th {
+            border-color: $gainsboro;
+          }
+        }
+      }
+    }
+  }
+
+  ::v-deep > .handsontableInputHolder {
+  }
+
+  ::v-deep > .ht_master,
+  ::v-deep > .ht_clone_top,
+  ::v-deep > .ht_clone_bottom,
+  ::v-deep > .ht_clone_left,
+  ::v-deep > .ht_clone_top_left_corner,
+  ::v-deep > .handsontableInputHolder {
+    .htCore {
+      td,
+      th {
+        // 当前选中行的样式
+        &.current-row {
+          background-color: $light-sky-blue !important;
+        }
+        &.show-highlight {
+          background-color: #c1e1fc !important;
+        }
+        // 当前选中单元格的样式
+        &.current {
+          &.highlight {
+            background-color: #c1e1fc !important;
+
+            .custom-button {
+              display: inline !important;
+              vertical-align: middle;
+              margin: -1px 0 0 2px;
+            }
+          }
+        }
+      }
+
+      th {
+        border-color: $gainsboro !important;
+        background-color: $smoke !important;
+        color: $black;
+        cursor: pointer;
+
+        &:hover {
+          background-color: #dfdfdf !important;
+        }
+
+        &.ht__highlight {
+          background-color: #dad8d6 !important;
+
+          .colHeader,
+          .rowHeader {
+            font-weight: bold;
+            color: #2577c2;
+          }
+        }
+      }
+
+      tbody {
+        tr {
+          &.hover {
+            td {
+              background-color: $ghost-grey;
+            }
+          }
+
+          td {
+            transition: background-color 0.15s;
+            border-color: $gainsboro;
+            color: $black;
+
+            &.bg-danger {
+              background-color: #fbe3e3;
+            }
+
+            &.font-danger {
+              color: $danger;
+            }
+
+            &.wcSingleLine {
+              white-space: nowrap;
+            }
+
+            &.htMiddle {
+              vertical-align: middle;
+            }
+          }
+        }
+      }
+    }
+
+    .htBorders {
+      .wtBorder.corner {
+        cursor: cell;
+      }
+    }
+  }
+
+  // 没有左边框
+  ::v-deep > .ht_clone_left {
+    .htCore {
+      th {
+        border-left: none;
+      }
+    }
+  }
+
+  ::v-deep > .ht_clone_top_left_corner {
+    .htCore {
+      thead {
+        th:first-child {
+          border-left: none;
+        }
+      }
+    }
+  }
+
+  // 没有上边框
+  ::v-deep > .ht_master,
+  ::v-deep > .ht_clone_top,
+  ::v-deep > .ht_clone_bottom,
+  ::v-deep > .ht_clone_left,
+  ::v-deep > .ht_clone_top_left_corner,
+  ::v-deep > .handsontableInputHolder {
+    .htCore {
+      thead {
+        tr {
+          th {
+            border-top: none !important;
+          }
+        }
+      }
+      /*tbody {
+        tr {
+          td {
+            border-left: none !important;
+          }
+        }
+      }*/
+    }
+  }
+
+  ::v-deep .red {
+    color: red !important;
+  }
+
+  ::v-deep .green {
+    color: green !important;
+  }
+  ::v-deep .stateCheckBox::after {
+    content: "";
+    display: block;
+    width: 7px;
+    height: 7px;
+    background-color: #007bff;
+    transform: translate(3px, 3px);
+  }
+
+  // 调整列大小的手柄
+  ::v-deep .manualColumnResizer {
+    cursor: ew-resize;
+    transform: translateX(3px);
+  }
+
+  // 调整行大小的手柄
+  ::v-deep .manualRowResizer {
+    cursor: ns-resize;
+    transform: translateY(3px);
+  }
+}
+</style>

+ 58 - 0
src/components/handsontable/composables/useClickPosition.ts

@@ -0,0 +1,58 @@
+import { on, query } from '@/utils/frontend/dom';
+
+// 获取鼠标点击的区域相关代码
+export default function useClickPosition(wrapperDOM: HTMLElement, tableSettings: Hot.ISettings): any {
+  const { onClickPosition } = tableSettings;
+  if (typeof onClickPosition !== 'function') return undefined;
+  let whichKey: Hot.MouseKey;
+  let clickPosition: Hot.ClickPosition;
+
+  const $masterCore = query(wrapperDOM, '.ht_master.handsontable .htCore') as HTMLElement;
+  const $masterBorders = query(wrapperDOM, '.ht_master.handsontable .htBorders') as HTMLElement;
+  const $topCore = query(wrapperDOM, '.ht_clone_top.handsontable .htCore') as HTMLElement;
+  const $topBorders = query(wrapperDOM, '.ht_clone_top.handsontable .htBorders') as HTMLElement;
+  const $bottomCore = query(wrapperDOM, '.ht_clone_bottom.handsontable .htCore') as HTMLElement;
+  const $bottomBorders = query(wrapperDOM, '.ht_clone_bottom.handsontable .htBorders') as HTMLElement;
+  const $leftCore = query(wrapperDOM, '.ht_clone_left.handsontable .htCore') as HTMLElement;
+  const $leftBorders = query(wrapperDOM, '.ht_clone_left.handsontable .htBorders') as HTMLElement;
+  const $topLeftCore = query(wrapperDOM, '.ht_clone_top_left_corner.handsontable .htCore') as HTMLElement;
+  const $topLeftBorders = query(wrapperDOM, '.ht_clone_top_left_corner.handsontable .htBorders') as HTMLElement;
+
+  const containers = [
+    $masterCore,
+    $masterBorders,
+    $topCore,
+    $topBorders,
+    $bottomCore,
+    $bottomBorders,
+    $leftCore,
+    $leftBorders,
+    $topLeftCore,
+    $topLeftBorders,
+  ];
+  // 是否是内容区域
+  const isContent = (target: Node) => containers.some(container => container && container.contains(target));
+
+  const handleClick = (event: Event) => {
+    const e = event as MouseEvent;
+    switch (e.button) {
+      case 0:
+        whichKey = 'left';
+        break;
+      case 1:
+        whichKey = 'middle';
+        break;
+      case 2:
+      default:
+        whichKey = 'right';
+        break;
+    }
+    const target = event.target as Node;
+    if (wrapperDOM.contains(target)) clickPosition = isContent(target) ? 'content' : 'barren';
+    else clickPosition = 'outside';
+
+    onClickPosition(whichKey, clickPosition);
+  };
+  on(document.body, 'mouseup', handleClick);
+  return handleClick;
+}

+ 46 - 0
src/components/handsontable/composables/useFixedLeftScroll.ts

@@ -0,0 +1,46 @@
+import debounce from 'lodash/debounce';
+
+// 水平滚动添加阴影
+export default function useFixedLeftScroll(tableWrapper: HTMLElement): { registerFixedLeftScroll: () => void } {
+  // handsontable 主体部分元素
+  let $holder: HTMLElement | null;
+
+  // fixed Left 的核心部分元素
+  let $leftCore: HTMLElement | null;
+
+  // 滚动监听器防抖
+  const debouncedScroll = debounce(
+    () => {
+      if (!$holder || !$leftCore) return;
+      if ($holder.scrollLeft <= 0) {
+        $leftCore.classList.remove('shadow');
+      } else {
+        $leftCore.classList.add('shadow');
+      }
+    },
+    100,
+    { maxWait: 100 }
+  );
+
+  // 注册滚动监听器,用于添加fixed columns阴影
+  const registerFixedLeftScroll = () => {
+    // handsontable wrapper
+    const $wrapper = tableWrapper;
+    // handsontable 主体部分的内容的 holder
+    $holder = $wrapper.querySelector('.ht_master .wtHolder');
+    // fixed Left 部分的核心部分
+    $leftCore = $wrapper.querySelector('.ht_clone_left .htCore');
+    $holder && $holder.addEventListener('scroll', debouncedScroll);
+  };
+
+  // 注销滚动监听器
+  // const unregisterScroll = () => {
+  //   if ($holder) {
+  //     console.log('被注销');
+  //     $holder.removeEventListener('scroll', debouncedScroll);
+  //   }
+  // };
+  return {
+    registerFixedLeftScroll,
+  };
+}

+ 62 - 0
src/components/handsontable/composables/useGroupingHeaders.ts

@@ -0,0 +1,62 @@
+import { addClass, hasClass, query, queryAll } from '@/utils/frontend/dom';
+
+// 表头分组
+export default function useGroupingHeaders(tableWrapper: HTMLElement, cellGrid: number[][], fixedColumnsLeft = 0) {
+  const generateGroupHeaders = () => {
+    const heighten = ($th: Element, count: number) => {
+      addClass($th, 'real-th');
+      const $relative = query($th, '.relative') as HTMLElement;
+      $relative.style.height = `${count * 26 - 1}px`;
+      $relative.style.lineHeight = `${count * 26}px`;
+    };
+
+    const grouping = ($thead: HTMLElement, rowNums: number, colNums: number) => {
+      const $trList = Array.from(queryAll($thead, 'tr'));
+      if ($trList.length <= 1) return;
+      const $thList = [] as Element[][];
+      $trList.forEach($tr => {
+        const $rowThList = Array.from(queryAll($tr, 'th'));
+        // 去掉左上角的 th
+        $rowThList.shift();
+        $thList.push($rowThList);
+      });
+      // 二维数组,记录每个 th 是否已被它正下方的 th 覆盖,初始化为 false 代表没有被覆盖
+      const coveredGrid = new Array(rowNums).fill(null).map(() => new Array(colNums).fill(false));
+      for (let i = rowNums - 1; i >= 0; i--) {
+        for (let j = 0; j < colNums; j++) {
+          const $th = $thList[i][j];
+          if (!$th) return;
+          // coveredGrid[i][j] 为 true 说明这个单元格被它下面的单元格覆盖了,不用再考虑了
+          if (coveredGrid[i][j] || hasClass($th, 'hiddenHeader')) continue;
+          coveredGrid[i][j] = true;
+          // 将当前单元格上面的拥有相同 id 的单元格(这些单元格属于同一个合并单元格)标记为已覆盖:coveredGrid[k][j] = true
+          let k;
+          for (k = i - 1; k >= 0; k--) {
+            if (cellGrid[k][j] !== cellGrid[i][j]) break;
+            coveredGrid[k][j] = true;
+          }
+          // 从当前单元格开始往上数,有几个单元格属于同一个合并单元格
+          const count = i - k;
+          if (count > 1) {
+            heighten($th, count);
+          }
+        }
+      }
+    };
+    // handsontable clone top 部分
+    const $thead = query(tableWrapper, '.ht_clone_top .wtHolder .htCore thead') as HTMLElement;
+    grouping($thead, cellGrid.length, cellGrid[0].length);
+
+    // handsontable clone top left corner 部分
+    const $topLeftHead = query(tableWrapper, '.ht_clone_top_left_corner .wtHolder .htCore thead') as HTMLElement;
+    grouping($topLeftHead, cellGrid.length, fixedColumnsLeft);
+
+    // 设置左上角的单元格合并为一个单元格
+    const $topLeftLastTr = Array.from(queryAll($topLeftHead, 'tr')).pop() as HTMLElement;
+    const $topLeftLastTh = query($topLeftLastTr, 'th') as HTMLElement;
+    heighten($topLeftLastTh, cellGrid.length);
+  };
+  return {
+    generateGroupHeaders,
+  };
+}

+ 57 - 0
src/components/handsontable/composables/useHoverChangeRowBg.ts

@@ -0,0 +1,57 @@
+interface AdditionalInfoHTMLElement extends HTMLTableRowElement {
+  index: number;
+  correspondingList: HTMLCollectionOf<HTMLTableRowElement> | null;
+}
+
+export default function useHoverChangeRowBg(tableWrapper: HTMLElement) {
+  let $leftTrList: HTMLCollectionOf<HTMLTableRowElement> | null;
+  let $masterTrList: HTMLCollectionOf<HTMLTableRowElement> | null;
+
+  const mouseOverHandler = (e: Event) => {
+    const target = e.currentTarget as AdditionalInfoHTMLElement;
+    target.correspondingList &&
+      target.correspondingList[target.index] &&
+      target.correspondingList[target.index].classList.add('hover');
+    target && target.classList.add('hover');
+  };
+  const mouseOutHandler = (e: Event) => {
+    const target = e.currentTarget as AdditionalInfoHTMLElement;
+    target.correspondingList &&
+      target.correspondingList[target.index] &&
+      target.correspondingList[target.index].classList.remove('hover');
+    target && target.classList.remove('hover');
+  };
+
+  const registerHover = () => {
+    // handsontable wrapper
+    const $wrapper = tableWrapper;
+    const $leftTBody = $wrapper.querySelector('.ht_clone_left .htCore tbody');
+    $leftTrList = $leftTBody && $leftTBody.getElementsByTagName('tr');
+
+    const $masterTBody = $wrapper.querySelector('.ht_master .htCore tbody');
+    $masterTrList = $masterTBody && $masterTBody.getElementsByTagName('tr');
+
+    if ($masterTrList) {
+      for (let i = 0; i < $masterTrList.length; i++) {
+        const $tr = $masterTrList[i] as AdditionalInfoHTMLElement;
+        $tr.index = i;
+        $tr.correspondingList = $leftTrList;
+        $tr.onmouseover = mouseOverHandler;
+        $tr.onmouseout = mouseOutHandler;
+      }
+    }
+    if ($leftTrList) {
+      for (let i = 0; i < $leftTrList.length; i++) {
+        const $tr = $leftTrList[i] as AdditionalInfoHTMLElement;
+        $tr.index = i;
+        $tr.correspondingList = $masterTrList;
+        $tr.onmouseover = mouseOverHandler;
+        $tr.onmouseout = mouseOutHandler;
+      }
+    }
+  };
+
+  return {
+    registerHover,
+  };
+}

+ 49 - 0
src/components/handsontable/composables/useInstance.ts

@@ -0,0 +1,49 @@
+import Handsontable from '@sc/handsontable';
+// import useFixedLeftScroll from './useFixedLeftScroll';
+// import useHoverChangeRowBg from './useHoverChangeRowBg';
+import useVerticalScroll from './useVerticalScroll';
+import useGroupingHeaders from './useGroupingHeaders';
+
+export default function useInstance(tableWrapper: HTMLElement, settings: any) {
+  // 注册左侧水平固定滚动监听器
+  // const { registerFixedLeftScroll } = useFixedLeftScroll(tableWrapper);
+  // 注册 tr hover监听器(hover时改变行背景)
+  // const { registerHover } = useHoverChangeRowBg(tableWrapper);
+  // 注册垂直滚动监听器(用于大数据量快速滚动导致错位的问题:停止后,手动触发滚动1px)
+  const { registerVerticalScroll } = useVerticalScroll(tableWrapper);
+  if (settings.cellGrid) {
+    settings.viewportColumnRenderingOffset = Number.MAX_SAFE_INTEGER;
+  }
+
+  return new Handsontable(tableWrapper, {
+    rowHeaders: true,
+    manualColumnResize: true,
+    manualRowResize: true,
+    currentRowClassName: 'current-row',
+    // currentColClassName: 'current-col',
+    // 水平垂直居中,默认单行
+    className: 'htCenter htMiddle wcSingleLine',
+    renderAllRows: false,
+    autoColumnSize: false, // 提升性能
+    autoRowSize: false,
+
+    // 视口之外预渲染 1 行 此处设为 0 会导致树结构展开收起失效
+    viewportRowRenderingOffset: 30,
+    // 视口之外预渲染 1 列 此处设为 0 会导致树结构展开收起失效
+    viewportColumnRenderingOffset: 1,
+
+    selectionMode: 'range',
+    /*  rowHeights: 23, */
+    outsideClickDeselects: false, // 点击其它位置或表格,不清空表格的选中状态
+    afterRender() {
+      // if (settings.fixedColumnsLeft) registerFixedLeftScroll();
+      if (settings.cellGrid) {
+        const { generateGroupHeaders } = useGroupingHeaders(tableWrapper, settings.cellGrid, settings.fixedColumnsLeft);
+        generateGroupHeaders();
+      }
+      // registerHover();
+      registerVerticalScroll();
+    },
+    ...settings,
+  });
+}

+ 201 - 0
src/components/handsontable/composables/useTableSettings.ts

@@ -0,0 +1,201 @@
+import { TreeNode } from '@sc/tree';
+import cloneDeep from 'lodash/cloneDeep';
+
+type GroupHeaderCell = Hot.IGroupHeaderCell | string;
+
+interface IColumnsData {
+  colHeaders: string[];
+  colWidths: number[];
+  columns: any[];
+}
+
+export function getColumnsSettings(columnsMeta: Hot.IColumnMeta[] | undefined): IColumnsData | null {
+  if (!columnsMeta) return null;
+  // 列的元数据
+  const colHeaders: string[] = [];
+  const colWidths: number[] = [];
+  const columns: any[] = [];
+
+  cloneDeep(columnsMeta).forEach(meta => {
+    colHeaders.push(meta.title);
+    colWidths.push(meta.width);
+    delete (meta as any).title;
+    delete (meta as any).width;
+    columns.push(meta);
+  });
+
+  return {
+    colHeaders,
+    colWidths,
+    columns,
+  };
+}
+
+// 生成 nestedHeaders 配置项
+export function generateNestedHeadersData(groupingHeaders: GroupHeaderCell[][]) {
+  // 行的个数
+  const rowNums = groupingHeaders.length;
+  // 列的个数
+  let colNums = 0;
+  groupingHeaders[0].forEach(cell => {
+    if (typeof cell === 'string') {
+      colNums += 1;
+    } else {
+      colNums += cell.colspan || 1;
+    }
+  });
+
+  // 二维数组,记录每个 th 对应的 “合并后的单元格” 的 id,初始化为 0
+  const cellGrid = new Array(rowNums).fill(null).map(() => new Array(colNums).fill(0));
+
+  // 合并后的单元格的 id,从左到右,从上到下,依次递增
+  let cellID = 0;
+
+  // 合并后的单元格的 id 具体的信息映射:当前的合并单元格的 label、起始的 th 的坐标(x、y)、rowspan、colspan
+  const cellMap: Common.IStringIndex = {};
+
+  /**
+   * 下面的循环是为了填充 cellGrid 和 cellMap
+   */
+  // 遍历 groupingHeaders 选项中的每一行
+  groupingHeaders.forEach((rowData, row) => {
+    // 遍历当前行中的每一列,每次都从 col = 0 开始
+    let col = 0;
+    rowData.forEach(cellData => {
+      let cell = cellData as Hot.IGroupHeaderCell;
+      if (typeof cellData === 'string') cell = { label: cellData };
+      const { label, rowspan = 1, colspan = 1 } = cell;
+
+      // 当前的 th 已经属于某个合并后的单元格了
+      if (cellGrid[row][col]) {
+        // 在当前行中,从 col 列开始往后找,直至找到一个没有被合并单元格占据的 th 为止
+        for (let j = col; j < colNums; j++) {
+          if (!cellGrid[row][j]) {
+            col = j;
+            break;
+          }
+        }
+      }
+      cellID += 1;
+      cellMap[cellID] = { label, x: row, y: col, rowspan, colspan };
+
+      // 按照 rowspan 和 colspan,将当前的 th 附近的其他 th 纳入合并单元格中
+      for (let i = row; i < row + rowspan; i++) cellGrid[i][col] = cellID;
+      for (let j = col; j < col + colspan; j++) cellGrid[row][j] = cellID;
+      col += colspan;
+    });
+  });
+
+  // nestedHeaders 配置项
+  const nestedHeaders: any[] = new Array(rowNums).fill(null).map(() => []);
+
+  for (let i = 0; i < rowNums; i++) {
+    for (let j = 0; j < colNums; ) {
+      const { label, colspan } = cellMap[cellGrid[i][j]];
+      if (colspan === 1) {
+        nestedHeaders[i].push(label);
+      } else {
+        nestedHeaders[i].push({ label, colspan });
+      }
+      j += colspan;
+    }
+  }
+  return {
+    nestedHeaders,
+    cellGrid,
+  };
+}
+
+export function getTableData(tree: boolean, data: any[]): any[] {
+  // handsontable 真正渲染的数据(如,props.tree为true时,只渲染 visible 为 true 的行)
+  let tableData = [];
+  // 判断是不是树
+  if (tree) {
+    // 针对树结构做的特殊处理(隐藏某些行)
+    let i = 0;
+    let rowData;
+    let ctx;
+    while (i < data.length) {
+      rowData = data[i] as TreeNode;
+      ctx = rowData.getCtx();
+
+      tableData.push(rowData);
+      // 展开
+      if (ctx.expanded) {
+        i += 1;
+      }
+      // 收缩
+      else {
+        // 后代节点
+        i += ctx.posterity().length + 1;
+      }
+    }
+  } else {
+    tableData = data;
+  }
+  return tableData;
+}
+
+// props 的 readOnly 为 true 时优先级最高
+export function overwriteReadOnly(tableSettings: Hot.ISettings, globalReadOnly: boolean) {
+  const { cells, cell, columns } = tableSettings;
+  if (cells && typeof cells === 'function') {
+    tableSettings.cells = (row?: number, col?: number, prop?: any) => {
+      const cellProperties = cells(row, col, prop);
+      if (globalReadOnly) {
+        cellProperties.readOnly = true;
+      }
+      return cellProperties;
+    };
+  }
+
+  cell &&
+    cell.forEach((item: { readOnly: boolean; }) => {
+      if (globalReadOnly) {
+        item.readOnly = true;
+      }
+    });
+
+  if (columns && typeof columns !== 'function') {
+    columns.forEach((column: { readOnly: boolean; }) => {
+      if (globalReadOnly) {
+        column.readOnly = true;
+      }
+    });
+  }
+}
+
+export default function useTableSettings(props: Readonly<Hot.IHandsontableProps>): Hot.ISettings {
+  const { data, tree, readOnly } = props;
+  const { columnsMeta, groupingHeaders } = props.settings;
+  // 列的元数据
+  const columnsData = getColumnsSettings(columnsMeta);
+
+  let nestedHeadersData;
+  // 表头分组
+  if (groupingHeaders) {
+    nestedHeadersData = generateNestedHeadersData(groupingHeaders);
+  }
+
+  // handsontable 真正渲染的数据(如,props.tree为true时,只渲染 visible 为 true 的行)
+  const tableData = getTableData(tree, data);
+
+  if (tree) {
+    // 树结构不支持 manualRowMove
+    props.settings.manualRowMove = false;
+  }
+
+  const tableSettings = {
+    ...props.settings,
+    ...columnsData,
+    ...nestedHeadersData,
+    data: tableData,
+    // readOnly,
+  };
+  delete tableSettings.columnsMeta;
+  delete tableSettings.groupingHeaders;
+
+  overwriteReadOnly(tableSettings, readOnly);
+
+  return tableSettings;
+}

+ 27 - 0
src/components/handsontable/composables/useVerticalScroll.ts

@@ -0,0 +1,27 @@
+import debounce from 'lodash/debounce';
+
+export default function useVerticalScroll(tableWrapper: HTMLElement) {
+  // handsontable 主体部分元素
+  let $holder: HTMLElement | null;
+
+  // 滚动监听器防抖
+  const debouncedScroll = debounce(() => {
+    if ($holder) {
+      if ($holder.scrollTop - 1 >= 0) {
+        $holder.scrollTop -= 1;
+      }
+
+      $holder.removeEventListener('scroll', debouncedScroll);
+    }
+  }, 100);
+
+  const registerVerticalScroll = () => {
+    // handsontable 主体部分的内容的 holder
+    $holder = tableWrapper.querySelector('.ht_master .wtHolder');
+    $holder && $holder.addEventListener('scroll', debouncedScroll);
+  };
+
+  return {
+    registerVerticalScroll,
+  };
+}

+ 32 - 0
src/components/handsontable/renderer/createRenderer.ts

@@ -0,0 +1,32 @@
+import Handsontable, { GridSettings } from '@sc/handsontable';
+import { addClass, append, empty, style } from '@/utils/frontend/dom';
+
+export default function createRenderer(renderer: Hot.IRenderer) {
+  // eslint-disable-next-line func-names
+  return function (
+    instance: Handsontable,
+    TD: HTMLElement,
+    row: number,
+    col: number,
+    prop: string | number,
+    value: any,
+    cellProperties: GridSettings
+  ): HTMLElement {
+    empty(TD);
+    style(TD, { padding: '0' });
+
+    const container = renderer(instance, row, col, prop, value, cellProperties);
+
+    // 设置自定义className
+    const { className } = cellProperties;
+    const classList = [];
+    if (typeof className === 'string') {
+      classList.push(...className.split(' '));
+    } else if (className instanceof Array) {
+      classList.push(...className);
+    }
+    addClass(TD, ...classList);
+    append(TD, container);
+    return container;
+  };
+}

+ 43 - 0
src/components/handsontable/renderer/index.ts

@@ -0,0 +1,43 @@
+import switcherRenderer from './renderers/switcherRenderer';
+import recoverRenderer from './renderers/recoverRenderer';
+import { deleteRenderer, deleteRendererPriceFile, deleteRendererRateFile } from './renderers/deleteRenderer';
+// import questionRender from './renderers/questionRender';
+// import lockUnitPriceRender from './renderers/stateCheckBoxRender';
+import timestampRender from './renderers/timestampRender';
+// import switcherQuestionRenderer from './renderers/switcherQuestionRenderer';
+import costKindRenderer from './renderers/costKindRenderer';
+import gljTypeRenderer from './renderers/gljTypeRenderer';
+import shareProjectRenderer from './renderers/shareProjectRenderer';
+// import fromUserRenderer from './renderers/fromUserRenderer';
+// import toUsersRenderer from './renderers/toUsersRenderer';
+import indentRender from './renderers/indentRender';
+import multipleLineRenderer from './renderers/multipleLineRenderer';
+
+const rendererRegisterList: Hot.RendererItem[] = [
+  // renderer 函数
+  ['wc.switcherRenderer', switcherRenderer as Hot.IRenderer],
+  ['wc.recoverRenderer', recoverRenderer],
+  ['wc.deleteRenderer', deleteRenderer],
+  ['wc.deleteRenderer.priceFile', deleteRendererPriceFile],
+  ['wc.deleteRenderer.rateFile', deleteRendererRateFile],
+  // ['wc.questionRenderer', questionRender],
+  ['wc.timestampRender', timestampRender],
+  // 分享工程
+  ['wc.shareProjectRenderer', shareProjectRenderer],
+  // 来自
+  // ['wc.fromUser', fromUserRenderer],
+  // 指定用户
+  // ['wc.toUsers', toUsersRenderer],
+  // 工料机类型render
+  ['wc.gljTypeRenderer', gljTypeRenderer],
+  // 用于第一列可点开下拉且可以点击详情的情况
+  // ['wc.switcherQuestionRenderer', switcherQuestionRenderer],
+  // 造价书节点类型
+  ['wc.costKindRenderer', costKindRenderer],
+  // 用于需要缩进的情况
+  ['wc.indentRender', indentRender],
+  // 多行数据单行显示的情况
+  ['wc.multipleLineRenderer', multipleLineRenderer],
+];
+
+export default rendererRegisterList;

+ 15 - 0
src/components/handsontable/renderer/registerRenderers.ts

@@ -0,0 +1,15 @@
+import Handsontable from '@sc/handsontable';
+import createRenderer from '@/components/handsontable/renderer/createRenderer';
+import registerRendererList from './index';
+import costSwitcherRenderer from './renderers/costSwitcherRenderer';
+
+const { renderers } = Handsontable;
+registerRendererList.map(item => {
+  const rendererName = item[0];
+  const renderer = item[1];
+  renderers.registerRenderer(rendererName, createRenderer(renderer));
+  return item;
+});
+
+// 原生的注册方法
+renderers.registerRenderer('wc.cost.switcherRenderer', costSwitcherRenderer);

+ 54 - 0
src/components/handsontable/renderer/renderers/buttonRenderer.ts

@@ -0,0 +1,54 @@
+import { on, parse, query, style } from '@/utils/frontend/dom';
+
+type Callback = (...args: any) => void;
+type GetValue = (...args: any) => any;
+export default function getButtonRender(callback: Callback, getValue?: GetValue) {
+  const buttonRender = (
+    instance: Handsontable,
+    td: HTMLElement,
+    row: number,
+    col: number,
+    prop: string | number,
+    value: any,
+    cellProperties: Hot.IColumnMeta
+  ): void => {
+    const data: any = instance.getSourceDataAtRow(row);
+    const className = (cellProperties.className as string) || 'htRight htMiddle wcSingleLine';
+    value = getValue ? getValue(data) : value;
+    if (cellProperties.readOnly && cellProperties.showButton !== true) {
+      td.innerHTML = `${value || ''}`;
+    } else {
+      td.innerHTML = '';
+
+      const div = parse(`<div class="${className}">${value || ''}</div>`) as HTMLElement;
+      const button = parse(
+        `<button class="custom-button el-button el-button--primary is-circle "> <i class="iconfont dsk-more" style="font-size:14px"></i></button>`
+      ) as HTMLElement;
+
+      let transX = 3;
+      const transY = value ? -3 : -12;
+      if (cellProperties.className && typeof cellProperties.className === 'string') {
+        if (cellProperties.className.indexOf('htLeft') !== -1) transX = instance.getColWidth(col) - 5; // 默认右对齐,左对齐时要调整偏移量
+        if (cellProperties.className.indexOf('htCenter') !== -1) transX = instance.getColWidth(col) / 2 - 3; // 居中对齐,偏移一半
+      }
+      style(button, { transform: `translate(${transX}px,${transY}px`, position: 'absolute' });
+
+      td.appendChild(button);
+      td.appendChild(div);
+
+      on(button, 'click', () => callback(data, instance));
+
+      // 双击进入编辑状态时,hot用一个输入框覆盖在TD上面,这时候button还是显示状态,要手动移除(不能用display node, button本身是隐藏的,通过 &.current选中状态时显示的)
+      if (!cellProperties.readOnly) on(div, 'dblclick', () => td.removeChild(button)); // 对于只读的单元格,双击不移除按钮
+
+      // 如果被 上一个 'dblclick' 事件进入编辑状态执行了移除按钮操作,在单元格内容不改变的情况下退出编辑状态,这时候单元格不会重新render,所以要把被移除的button添加回来
+      on(div, 'click', () => {
+        const tem = query(td, 'custom-button');
+        if (!tem) td.insertBefore(button, div);
+      });
+    }
+
+    td.className = className;
+  };
+  return buttonRender;
+}

+ 18 - 0
src/components/handsontable/renderer/renderers/checkBoxRender.ts

@@ -0,0 +1,18 @@
+import { cellTypes, GridSettings } from '@sc/handsontable';
+
+// 自定义checkbox,默认的checkBox value为undefined时灰显
+export default function checkBoxRender(
+  instance: Handsontable,
+  TD: HTMLElement,
+  row: number,
+  col: number,
+  prop: string | number,
+  value: any,
+  cellProperties: GridSettings
+): HTMLElement {
+  const container = cellTypes.checkbox.renderer(instance, TD, row, col, prop, value, cellProperties);
+  const element = TD.children[0] as any;
+  if (!value) element.className = 'htCheckboxRendererInput';
+
+  return container;
+}

+ 43 - 0
src/components/handsontable/renderer/renderers/costKindRenderer.ts

@@ -0,0 +1,43 @@
+import { parse } from '@/utils/frontend/dom';
+import { GridSettings } from '@sc/handsontable';
+import { BRType, GljTypeShortName, IBill } from '@sc/types';
+
+const TextMap: any = {
+  1: '大项',
+  2: '分部',
+  3: '分项',
+  4: '清单',
+  5: '补项',
+  6: '分类',
+  7: '费用',
+  20: '定',
+  21: '安',
+  22: '量',
+  24: '超',
+  25: '子目',
+  50: '主',
+  51: '设',
+};
+
+export default function (
+  instance: Handsontable,
+  row: number,
+  col: number,
+  prop: string | number,
+  value: any,
+  cellProperties: GridSettings
+): HTMLElement {
+  // 当前行的源数据
+  const bill = instance.getSourceDataAtRow(row) as IBill;
+  let text = '';
+  if (bill.kind === BRType.GLJ) {
+    text = GljTypeShortName[bill.type];
+  } else if (bill.kind === BRType.VP) {
+    text = TextMap[bill.kind] + GljTypeShortName[bill.type];
+  } else {
+    text = TextMap[bill.kind];
+  }
+  const container = parse(`<div>${text}</div>`) as HTMLElement;
+  cellProperties.readOnly = true;
+  return container;
+}

+ 47 - 0
src/components/handsontable/renderer/renderers/costSwitcherRenderer.ts

@@ -0,0 +1,47 @@
+import { bindPopover } from '@/components/popover/composables/usePopover';
+import { GridSettings } from '@sc/handsontable';
+import { BRType, IRation, RationPrefix } from '@sc/types';
+import createRenderer from '../createRenderer';
+import switcherRenderer from './switcherRenderer';
+
+const costSwitchRender = (
+  instance: Handsontable,
+  row: number,
+  col: number,
+  prop: string | number,
+  value: any,
+  cellProperties: GridSettings
+) => {
+  let code = value || '';
+  // 当前行的源数据
+  const rowSourceData = instance.getSourceDataAtRow(row) as IRation;
+  const { adjustState } = rowSourceData;
+  // 定额显示借 换 补 等
+  if (rowSourceData.kind === BRType.RATION) {
+    const prefix = rowSourceData.prefix || '';
+    const tailStr = adjustState ? RationPrefix.REP : '';
+    code = prefix + code + tailStr;
+  }
+  const container = switcherRenderer(instance, row, col, prop, code, cellProperties);
+  return container;
+};
+
+export default function (
+  instance: Handsontable,
+  TD: HTMLElement,
+  row: number,
+  col: number,
+  prop: string | number,
+  value: any,
+  cellProperties: GridSettings
+) {
+  const rowSourceData = instance.getSourceDataAtRow(row) as IRation;
+  const { adjustState } = rowSourceData;
+  const nativeRender = createRenderer(costSwitchRender);
+  const container = nativeRender(instance, TD, row, col, prop, value, cellProperties);
+  // 有定额调整状态的要悬浮提示
+  if (adjustState) {
+    bindPopover(TD, container, adjustState);
+  }
+  return TD;
+}

+ 135 - 0
src/components/handsontable/renderer/renderers/deleteRenderer.ts

@@ -0,0 +1,135 @@
+import { DeleteEnum, IProject } from '@sc/types';
+import Handsontable, { GridSettings } from '@sc/handsontable';
+import { parse, style, on, hover } from '@/utils/frontend/dom';
+import { TreeNode } from '@sc/tree';
+import { EmitterType, getEmitter } from '@/utils/frontend/emitter';
+
+// 回收站彻底删除renderer
+const containerStyle = {
+  cursor: 'pointer',
+  padding: '3px',
+  width: '21px',
+  height: '27px',
+  marginLeft: '1px',
+  color: '#eb1e1e',
+  transition: 'color 0.2s',
+  'font-size': '1rem',
+};
+const template = `
+    <span class="delete-renderer iconfont dsk-close"></>
+`;
+// 当前行的上下文对象
+const getCtx = (instance: Handsontable, row: number) => {
+  // 当前行的源数据
+  const rowSourceData = instance.getSourceDataAtRow(row) as TreeNode;
+  // 当前行的上下文对象
+  return rowSourceData.getCtx();
+};
+
+const hoverEffect = (container: HTMLElement) => {
+  // 鼠标 hover 变色
+  hover(
+    container,
+    () => {
+      style(container, { color: '#d51d1d', fontSize: '16px' });
+    },
+    () => {
+      style(container, { color: '#eb1e1e', fontSize: '1rem' });
+    }
+  );
+};
+
+const emitter = getEmitter();
+
+// 删除
+export function deleteRenderer(
+  instance: Handsontable,
+  row: number,
+  col: number,
+  prop: string | number,
+  value: DeleteEnum,
+  cellProperties: GridSettings
+): HTMLElement {
+  if (!value || value === DeleteEnum.NORMAL) {
+    return parse('<span></span>') as HTMLElement;
+  }
+
+  const container = parse(template) as HTMLElement;
+
+  // 绑定点击事件
+  on(container, 'click', () => {
+    const curProject = instance.getSourceDataAtRow(row) as TreeNode<IProject>;
+    emitter.emit(EmitterType.COMPLETELY_REMOVE, curProject);
+  });
+
+  // 鼠标 hover 变色
+  hoverEffect(container);
+
+  /* == 设置样式 == */
+  style(container, containerStyle);
+  return container;
+}
+
+// 删除单价文件
+export function deleteRendererPriceFile(
+  instance: Handsontable,
+  row: number,
+  col: number,
+  prop: string | number,
+  value: any,
+  cellProperties: GridSettings
+): HTMLElement {
+  // 当前行的上下文对象
+  const ctx = getCtx(instance, row);
+
+  const container = parse(template) as HTMLElement;
+
+  // 绑定点击事件
+  on(container, 'click', () => {
+    console.log('删除单价文件');
+    ctx.expanded = !ctx.expanded;
+
+    const currentSourceData = instance.getSourceData() as Array<TreeNode>;
+
+    instance.loadData(currentSourceData);
+  });
+
+  // 鼠标 hover 变色
+  hoverEffect(container);
+
+  /* == 设置样式 == */
+  style(container, containerStyle);
+  return container;
+}
+
+// 删除费率文件
+export function deleteRendererRateFile(
+  instance: Handsontable,
+  row: number,
+  col: number,
+  prop: string | number,
+  value: any,
+  cellProperties: GridSettings
+): HTMLElement {
+  // 当前行的上下文对象
+  const ctx = getCtx(instance, row);
+
+  const container = parse(template) as HTMLElement;
+
+  // 绑定点击事件
+  on(container, 'click', () => {
+    console.log('删除费率文件');
+    ctx.expanded = !ctx.expanded;
+
+    const currentSourceData = instance.getSourceData() as Array<TreeNode>;
+
+    instance.loadData(currentSourceData);
+  });
+
+  // 鼠标 hover 变色
+  hoverEffect(container);
+
+  /* == 设置样式 == */
+  style(container, containerStyle);
+  return container;
+}

+ 14 - 0
src/components/handsontable/renderer/renderers/emptyRenderer.ts

@@ -0,0 +1,14 @@
+import { GridSettings } from '@sc/handsontable';
+// 对于有些单元格,如checkbox, 它的值为false但是,不符合条件的不想显示出来,可以用这个renderer
+export default function emptyRender(
+  instance: Handsontable,
+  TD: HTMLElement,
+  row: number,
+  col: number,
+  prop: string | number,
+  value: any,
+  cellProperties: GridSettings
+) {
+  TD.innerHTML = '';
+  return TD;
+}

+ 20 - 0
src/components/handsontable/renderer/renderers/feeRateRenderer.ts

@@ -0,0 +1,20 @@
+import { isDef } from '@/utils/common/tools';
+import { GridSettings } from '@sc/handsontable';
+
+// 费率显示转换 (100需要显示为空)
+export default function (
+  instance: Handsontable,
+  TD: HTMLElement,
+  row: number,
+  col: number,
+  prop: string | number,
+  value: any,
+  cellProperties: GridSettings
+): HTMLElement {
+  if (+value === 100 || !isDef(value)) {
+    value = '';
+  }
+  TD.innerHTML = `${value}`;
+  TD.className = (cellProperties.className as string) || '';
+  return TD;
+}

+ 24 - 0
src/components/handsontable/renderer/renderers/gljTypeRenderer.ts

@@ -0,0 +1,24 @@
+import { isDef } from '@/utils/common/tools';
+import { parse } from '@/utils/frontend/dom';
+import { GridSettings } from '@sc/handsontable';
+import { GljTypeShortName, GljTypeFullName, IBaseGlj } from '@sc/types';
+
+// 工料机类型至显示文字转换
+export default function (
+  instance: Handsontable,
+  row: number,
+  col: number,
+  prop: string | number,
+  value: any,
+  cellProperties: GridSettings
+): HTMLElement {
+  // 当前行的源数据
+  const glj = instance.getSourceDataAtRow(row) as IBaseGlj;
+  let text = '';
+  if (isDef(glj.type)) {
+    text = prop === 'fullTypeName' ? GljTypeFullName[glj.type] : GljTypeShortName[glj.type];
+  }
+  const container = parse(`<div>${text}</div>`) as HTMLElement;
+  cellProperties.readOnly = true;
+  return container;
+}

+ 33 - 0
src/components/handsontable/renderer/renderers/indentRender.ts

@@ -0,0 +1,33 @@
+import Handsontable from '@sc/handsontable';
+import { parse, style } from '@/utils/frontend/dom';
+import { TreeNode } from '@sc/tree';
+
+export default function (
+  instance: Handsontable,
+  row: number,
+  col: number,
+  prop: string | number,
+  value: any
+): HTMLElement {
+  // 当前行的源数据
+  const rowSourceData = instance.getSourceDataAtRow(row) as TreeNode;
+  // 当前行的上下文对象
+  const ctx = rowSourceData.getCtx();
+
+  const template = `
+    <div class="ht-indent-renderer">
+      <span>${value || ''}</span>
+    </div>
+  `;
+
+  // indent-renderer
+  const container = parse(template) as HTMLElement;
+
+  // 层级缩进
+  const paddingLeft = `${6 + ctx.depth() * 20}px`;
+  style(container, {
+    paddingLeft,
+  });
+
+  return container;
+}

+ 25 - 0
src/components/handsontable/renderer/renderers/multipleLineRenderer.ts

@@ -0,0 +1,25 @@
+import Handsontable from '@sc/handsontable';
+import { parse, style } from '@/utils/frontend/dom';
+import { TreeNode } from '@sc/tree';
+
+export default function (
+  instance: Handsontable,
+  row: number,
+  col: number,
+  prop: string | number,
+  value: any
+): HTMLElement {
+  let newValue = '';
+  if (value) newValue = value.replaceAll('\n', '');
+
+  const template = `
+    <div class="ht-indent-renderer">
+      <span>${newValue}</span>
+    </div>
+  `;
+
+  // indent-renderer
+  const container = parse(template) as HTMLElement;
+
+  return container;
+}

+ 38 - 0
src/components/handsontable/renderer/renderers/popoverRenderer.ts

@@ -0,0 +1,38 @@
+import Handsontable, { GridSettings, cellTypes } from '@sc/handsontable';
+
+import { hover, parse, empty } from '@/utils/frontend/dom';
+import { TreeNode } from '@sc/tree';
+import { bindPopover } from '@/components/popover/composables/usePopover';
+
+type callback = (...args: any) => string;
+
+export default function getPopoverRenderer(callback: callback) {
+  const popoverRender = (
+    instance: Handsontable,
+    td: HTMLElement,
+    row: number,
+    col: number,
+    prop: string | number,
+    value: any,
+    cellProperties: GridSettings
+  ): HTMLElement => {
+    cellTypes.text.renderer(instance, td, row, col, prop, value, cellProperties);
+    // 当前行的源数据
+    empty(td); // 先清空数据,多次刷新时会出现两行的问题
+    const rowSourceData = instance.getSourceDataAtRow(row) as TreeNode;
+    const template = `<div>${value}</div>`;
+    const container = parse(template) as HTMLElement;
+    if (td.childNodes[0]) {
+      td.removeChild(td.childNodes[0]);
+    }
+    if (value) {
+      td.appendChild(container);
+    }
+    const content = callback(rowSourceData, td);
+
+    if (content && value) bindPopover(td, container, content);
+
+    return td;
+  };
+  return popoverRender;
+}

+ 29 - 0
src/components/handsontable/renderer/renderers/popoverSwitcherRenderer.ts

@@ -0,0 +1,29 @@
+import { bindPopover } from '@/components/popover/composables/usePopover';
+import { GridSettings } from '@sc/handsontable';
+import { BRType, IRation, RationPrefix } from '@sc/types';
+import createRenderer from '../createRenderer';
+import switcherRenderer from './switcherRenderer';
+
+type callback = (...args: any) => Promise<string>;
+
+export default function getPopoverSwitcherRender(callback: callback) {
+  return async (
+    instance: Handsontable,
+    TD: HTMLElement,
+    row: number,
+    col: number,
+    prop: string | number,
+    value: any,
+    cellProperties: GridSettings
+  ) => {
+    const rowSourceData = instance.getSourceDataAtRow(row);
+    const nativeRender = createRenderer(switcherRenderer);
+    const container = nativeRender(instance, TD, row, col, prop, value, cellProperties);
+    const content = await callback(rowSourceData);
+    // 有定额调整状态的要悬浮提示
+    if (content) {
+      bindPopover(TD, container, content);
+    }
+    return TD;
+  };
+}

+ 40 - 0
src/components/handsontable/renderer/renderers/popoverValueRenderer.ts

@@ -0,0 +1,40 @@
+import { bindPopover } from '@/components/popover/composables/usePopover';
+import { classLeft } from '@/constants/commonClassName';
+import { getTextWidth } from '@/utils/common/tools';
+import { parse } from '@/utils/frontend/dom';
+import { GridSettings } from '@sc/handsontable';
+
+let font: string | undefined;
+
+// 首次获取一次就行,提高性能,所有表格字体应是统一的
+const getFont = (container: HTMLElement) => {
+  if (font) return font;
+  font = getComputedStyle(container).font;
+  return font;
+};
+
+// 如果字符宽度超过列宽,悬浮提示
+export default function popoverValueRenderer(
+  instance: Handsontable,
+  td: HTMLElement,
+  row: number,
+  col: number,
+  prop: string | number,
+  value: any,
+  cellProperties: GridSettings
+): HTMLElement {
+  // 当前行的源数据
+  td.innerHTML = ''; // 先清空数据,多次刷新时会出现两行的问题
+  td.className = (cellProperties.className as string) || classLeft;
+  if (value) {
+    const container = parse(`<div>${value}</div>`) as HTMLElement;
+    td.appendChild(container);
+    setTimeout(() => {
+      const colWidth = instance.getColWidth(col) - 4; // 减去padding
+      const textWidth = getTextWidth(value, getFont(container));
+      if (textWidth > colWidth) bindPopover(td, container, value);
+    });
+  }
+
+  return td;
+}

+ 70 - 0
src/components/handsontable/renderer/renderers/recoverRenderer.ts

@@ -0,0 +1,70 @@
+import { hover, on, parse, style } from '@/utils/frontend/dom';
+import { TreeNode } from '@sc/tree';
+import { DeleteEnum, IProject } from '@sc/types';
+import { EmitterType, getEmitter } from '@/utils/frontend/emitter';
+import { GridSettings } from '@sc/handsontable';
+
+const containerStyle = {
+  cursor: 'pointer',
+  padding: '3px',
+  width: '21px',
+  height: '27px',
+  marginLeft: '1px',
+  color: '#409eff',
+  transition: 'color 0.2s',
+  'font-size': '1rem',
+};
+const template = `
+    <span class="recover-renderer iconfont dsk-undo"></>
+`;
+
+// 当前行的上下文对象
+const getCtx = (instance: Handsontable, row: number) => {
+  // 当前行的源数据
+  const rowSourceData = instance.getSourceDataAtRow(row) as TreeNode;
+  // 当前行的上下文对象
+  return rowSourceData.getCtx();
+};
+
+const hoverEffect = (container: HTMLElement) => {
+  // 鼠标 hover 变色
+  hover(
+    container,
+    () => {
+      style(container, { color: '#007bff', fontSize: '16px' });
+    },
+    () => {
+      style(container, { color: '#409eff', fontSize: '1rem' });
+    }
+  );
+};
+
+const emitter = getEmitter();
+
+// 恢复按钮渲染器
+export default function recoverRenderer(
+  instance: Handsontable,
+  row: number,
+  col: number,
+  prop: string | number,
+  value: DeleteEnum,
+  cellProperties: GridSettings
+): HTMLElement {
+  if (!value || value === DeleteEnum.NORMAL) {
+    return parse('<span></span>') as HTMLElement;
+  }
+  const container = parse(template) as HTMLElement;
+
+  // 绑定点击事件
+  on(container, 'click', () => {
+    const curProject = instance.getSourceDataAtRow(row) as TreeNode<IProject>;
+    emitter.emit(EmitterType.RECYCLE, curProject);
+  });
+
+  // 鼠标 hover 变色
+  hoverEffect(container);
+
+  /* == 设置样式 == */
+  style(container, containerStyle);
+  return container;
+}

+ 33 - 0
src/components/handsontable/renderer/renderers/selectRender.ts

@@ -0,0 +1,33 @@
+import { empty } from '@/utils/frontend/dom';
+import { cellTypes, GridSettings } from '@sc/handsontable';
+import { find } from 'lodash';
+
+export default function selectRender(
+  instance: Handsontable,
+  TD: HTMLElement,
+  row: number,
+  col: number,
+  prop: string | number,
+  value: any,
+  cellProperties: GridSettings
+) {
+  // 调用dropdown绑定相关事件
+  cellTypes.dropdown.renderer(instance, TD, row, col, prop, '', cellProperties);
+  const { source } = cellProperties;
+  const option = find(source, { value });
+  if (option) value = option.key;
+
+  const container = document.createElement('div');
+  container.className = 'wcDropdown';
+  const textDiv = document.createElement('div');
+  textDiv.className = 'wcDropdownText';
+  textDiv.appendChild(document.createTextNode(value || ''));
+  const ARROW = TD.firstChild;
+  if (cellProperties.readOnly !== true) container.appendChild(ARROW as HTMLElement);
+
+  container.appendChild(textDiv);
+  empty(TD);
+  TD.appendChild(container);
+
+  return container;
+}

+ 62 - 0
src/components/handsontable/renderer/renderers/shareProjectRenderer.ts

@@ -0,0 +1,62 @@
+import Handsontable, { GridSettings } from '@sc/handsontable';
+import { parse } from '@/utils/frontend/dom';
+import { ProjectType, IReceivedShareProject } from '@sc/types';
+import { bindPopover } from '@/components/popover/composables/usePopover';
+
+const getIconName = (type: ProjectType) => {
+  let iconName = '';
+  switch (type) {
+    case ProjectType.FOLDER:
+      iconName = 'dsk-folder-open';
+      break;
+    case ProjectType.CONSTRUCTION:
+      iconName = 'dsk-city-one';
+      break;
+    case ProjectType.SINGLE:
+      iconName = 'dsk-home-two';
+      break;
+    default:
+      break;
+  }
+  return iconName;
+};
+
+export default function (
+  instance: Handsontable,
+  row: number,
+  col: number,
+  prop: string | number,
+  value: any,
+  cellProperties: GridSettings
+): HTMLElement {
+  // 当前行的源数据
+  const rowSourceData = instance.getSourceDataAtRow(row) as IReceivedShareProject;
+  const children = rowSourceData.children;
+  if (!children.length) {
+    return parse('<div></div>') as HTMLElement;
+  }
+
+  const items = children.map(
+    item =>
+      `<div style="display: inline-flex; align-items: center; background: #EBEEF5; margin-left: 5px; padding: 0px 5px; height: calc(100% - 2px);">
+        <i class="iconfont ${getIconName(item.type)}"></i>
+        <span>${item.name}</span>
+      </div>`
+  );
+  const template = `<div style="display: flex; align-items: center; height: 100%;">${items.join('')}</div>`;
+
+  const td = parse('<div></div>') as HTMLElement;
+  const container = parse(template) as HTMLElement;
+  td.appendChild(container);
+  const content = children
+    .map(
+      item => `<div> <i class="iconfont ${getIconName(item.type)}"></i>
+  <span>${item.name}</span></div>`
+    )
+    .join('');
+  if (content) {
+    bindPopover(td, container, content);
+  }
+
+  return td;
+}

+ 29 - 0
src/components/handsontable/renderer/renderers/simpleSelectRender.ts

@@ -0,0 +1,29 @@
+import { empty } from '@/utils/frontend/dom';
+import { cellTypes, GridSettings } from '@sc/handsontable';
+
+// 普通的不支持key Value 的下拉框render
+export default function simpleSelectRender(
+  instance: Handsontable,
+  TD: HTMLElement,
+  row: number,
+  col: number,
+  prop: string | number,
+  value: any,
+  cellProperties: GridSettings
+) {
+  // 调用dropdown绑定相关事件
+  cellTypes.dropdown.renderer(instance, TD, row, col, prop, '', cellProperties);
+  const container = document.createElement('div');
+  container.className = 'wcDropdown';
+  const textDiv = document.createElement('div');
+  textDiv.className = 'wcDropdownText';
+  textDiv.appendChild(document.createTextNode(value || ''));
+  const ARROW = TD.firstChild;
+  if (cellProperties.readOnly !== true) container.appendChild(ARROW as HTMLElement);
+
+  container.appendChild(textDiv);
+  empty(TD);
+  TD.appendChild(container);
+
+  return container;
+}

+ 180 - 0
src/components/handsontable/renderer/renderers/switcherRenderer.ts

@@ -0,0 +1,180 @@
+import Handsontable, { GridSettings } from '@sc/handsontable';
+import { parse, query, on, queryAll } from '@/utils/frontend/dom';
+import { TreeNode } from '@sc/tree';
+
+interface IIconItem {
+  icon: string;
+  color?: string;
+  callback?: () => void;
+}
+
+interface ICellProperties extends GridSettings {
+  // 最前面的 icons
+  headIcons?: IIconItem[];
+  // 文本前面的 icons
+  textIcons?: IIconItem[];
+  // 直接跟在文本后面的 icons
+  textBehindIcons?: IIconItem[];
+  // 在单元格最后面的 icons
+  tailIcons?: IIconItem[];
+  // 最前面的偏置距离,默认是 5
+  offsetLeft?: number;
+}
+
+/* 此 renderer 仅适用于树结构的表格 */
+export default function (
+  instance: Handsontable,
+  row: number,
+  col: number,
+  prop: string | number,
+  value: any,
+  cellProperties: ICellProperties
+): HTMLElement {
+  const { headIcons = [], textIcons = [], textBehindIcons = [], tailIcons = [], offsetLeft = 5 } = cellProperties;
+
+  // 当前行的源数据
+  const rowSourceData = instance.getSourceDataAtRow(row) as TreeNode;
+  // 当前行的上下文对象
+  const ctx = rowSourceData.getCtx();
+  const level = ctx.depth();
+  const isLastChild = !ctx.next();
+  const isFirstAncestor = !ctx.parent() && !ctx.prev();
+  const isSingleNode = isLastChild && isFirstAncestor && !ctx.firstChild();
+
+  const toggleIconCls = (() => {
+    if (ctx.children().length) return ctx.expanded ? 'ht-toggle-icon-shrink' : 'ht-toggle-icon-expand';
+    return '';
+  })();
+
+  let headIconHtml = '';
+  headIcons.forEach(
+    item => (headIconHtml += `<i class="iconfont ${item.icon}" style="color: ${item.color || '#007bff'}"></i>`)
+  );
+
+  let textIconHtml = '';
+  textIcons.forEach(
+    item => (textIconHtml += `<i class="iconfont ${item.icon}" style="color: ${item.color || '#007bff'}"></i>`)
+  );
+
+  let textBehindIconHtml = '';
+  textBehindIcons.forEach(
+    item =>
+      (textBehindIconHtml += `<i class="iconfont ${item.icon}" style="color: ${item.color || '#007bff'}"></i>`)
+  );
+
+  let tailIconHtml = '';
+  tailIcons.forEach(
+    item => (tailIconHtml += `<i class="iconfont ${item.icon}" style="color: ${item.color || '#007bff'}"></i>`)
+  );
+
+  // html 模板
+  const template = `
+    <div class="ht-switcher-container">
+        <div class="ht-switcher-wrap">
+          ${headIcons.length > 0 ? `<div class="head-icon-wrap">${headIconHtml}</div>` : ''}
+          <div class="tree-line-wrap">
+            ${'<i class="tree-line"></i>'.repeat(level)}
+          </div>
+          <div class="toggle-icon-wrap">
+            ${isFirstAncestor ? '' : '<i class="toggle-icon-top-line"></i>'}
+            ${isSingleNode ? '' : '<i class="toggle-icon-right-line"></i>'}
+            ${isLastChild ? '' : '<i class="toggle-icon-bottom-line"></i>'}
+            <!-- 展开 / 收起的 icon -->
+            <i class="toggle-icon ${toggleIconCls}"></i>
+          </div>
+          <!-- 文本 -->
+          <div class="text">
+            ${textIcons.length > 0 ? `<span class="text-icon-wrap">${textIconHtml}</span>` : ''}
+            <span class="content">${value || ''}</span>
+            ${textBehindIcons.length > 0 ? `<span class="text-behind-icon-wrap">${textBehindIconHtml}</span>` : ''}
+          </div>
+          ${tailIcons.length > 0 ? `<div class="tail-icon-wrap">${tailIconHtml}</div>` : ''}
+        </div>
+    </div>
+  `;
+
+  // 将字符串模板解析成 dom,  .switcher-container
+  const container = parse(template) as HTMLElement;
+  // 内部 wrap, .switcher-wrap
+  const wrapper = query(container, '.ht-switcher-wrap') as HTMLElement;
+  // 文本 dom
+  const textContent = query(wrapper, '.text .content') as HTMLElement;
+  wrapper.style.minWidth = `${
+    offsetLeft +
+    46 +
+    17 * level +
+    textIcons.length * 16 +
+    textContent.innerText.length * 15 +
+    textBehindIcons.length * 16
+  }px`;
+  // 偏置
+  wrapper.style.marginLeft = `${offsetLeft}px`;
+
+  // 展开 / 收起的 icon
+  const toggleIcon = query(wrapper, '.toggle-icon') as HTMLElement;
+  const tailWrap = query(wrapper, '.tail-icon-wrap') as HTMLElement;
+  if (tailWrap) {
+    const icons = queryAll(tailWrap, '.iconfont');
+    for (let i = 0; i < icons.length; i++) {
+      const icon = icons[i] as HTMLElement;
+      const cb = tailIcons[i].callback;
+      if (cb) {
+        icon.onclick = cb;
+      }
+    }
+  }
+
+  const treeLines = queryAll(wrapper, '.tree-line');
+  let lastIndex = 1;
+  let parent = ctx.parent();
+  while (parent) {
+    // 父节点是否是其同级节点中的最后的子节点
+    const parentIsLastChild = !parent.getCtx().next();
+    if (parentIsLastChild) {
+      const lastIndexTreeLine = treeLines[level - lastIndex] as HTMLElement;
+      lastIndexTreeLine.style.opacity = '0';
+    }
+    lastIndex += 1;
+    parent = parent.getCtx().parent();
+  }
+
+  // 绑定点击事件
+  on(toggleIcon, 'click', () => {
+    ctx.expanded = !ctx.expanded;
+    const descendants = ctx.posterity();
+
+    // 当前表格展示的源数据
+    const currentSourceData = instance.getSourceData() as TreeNode[];
+    // 当前行的索引
+    const currentRowIndex = currentSourceData.findIndex(item => ctx.ref.ID === item.ID);
+
+    // 展开
+    if (ctx.expanded) {
+      const tobeAddData = [] as TreeNode[];
+      descendants.forEach(item => {
+        if (item.getCtx().visible()) {
+          tobeAddData.push(item);
+        }
+      });
+
+      currentSourceData.splice(currentRowIndex + 1, 0, ...tobeAddData);
+    }
+    // 收起
+    else {
+      // 先将expanded设为true是为了方便获取后代节点的可见性
+      ctx.expanded = true;
+      let deleteCount = 0;
+      descendants.forEach(item => {
+        if (item.getCtx().visible()) {
+          deleteCount += 1;
+        }
+      });
+      currentSourceData.splice(currentRowIndex + 1, deleteCount);
+      // 最后将expanded恢复为false
+      ctx.expanded = false;
+    }
+    instance.loadData(currentSourceData);
+  });
+
+  return container;
+}

+ 18 - 0
src/components/handsontable/renderer/renderers/timestampRender.ts

@@ -0,0 +1,18 @@
+// 时间戳render为日期
+import { GridSettings } from '@sc/handsontable';
+import dayjs from 'dayjs';
+import { text } from '@/utils/frontend/dom';
+
+export default function timestampRender(
+  instance: Handsontable,
+  row: number,
+  col: number,
+  prop: string | number,
+  value: number,
+  cellProperties: GridSettings
+): HTMLElement {
+  const span = document.createElement('span');
+  const dateStr = value ? dayjs(value).format('YYYY-MM-DD') : '';
+  text(span, dateStr);
+  return span;
+}

+ 4 - 2
src/components/index.ts

@@ -7,7 +7,8 @@ import ResizableLayout from './resizable-layout/ResizableLayout.vue';
 import ResizableLayoutItem from './resizable-layout/ResizableLayoutItem.vue';
 import ContextMenu from './context-menu/ContextMenu.vue';
 import ContextSubMenu from './context-menu/ContextSubMenu.vue';
-// import Handsontable from './handsontable/Handsontable.vue';
+import Handsontable from './handsontable/Handsontable.vue';
+import Popover from './popover/Popover.vue';
 
 export default function (app: App<Element>) {
     app.component(Iconfont.name, Iconfont);
@@ -15,5 +16,6 @@ export default function (app: App<Element>) {
     app.component(ResizableLayoutItem.name, ResizableLayoutItem);
     app.component(ContextMenu.name, ContextMenu);
     app.component(ContextSubMenu.name, ContextSubMenu);
-    // app.component(Handsontable.name, Handsontable);
+    app.component(Handsontable.name, Handsontable);
+    app.component(Popover.name, Popover);
 }

+ 221 - 0
src/components/popover/Popover.vue

@@ -0,0 +1,221 @@
+<template>
+  <div @mouseleave="toclosePopover">
+    <div :class="tipClassName" :style="popoverStyle">
+      <slot></slot>
+      <div v-html="content"></div>
+    </div>
+  </div>
+</template>
+<script lang="ts">
+import { defineComponent, ref } from 'vue';
+import { hidePopover } from './composables/popoverHelp';
+import { Position } from './composables/types';
+
+export default defineComponent({
+  name: 'Popover',
+  props: {
+    top: { type: Number, default: 0 }, // 定位坐标top
+    left: { type: Number, default: 0 }, // 定位坐标left
+    content: {}, // 内容
+    tranHeight: { type: Number, default: 0 }, // 向下偏移量,一般在handsontable中才需要
+    tranWidth: { type: Number, default: 0 }, // 向左偏移量,一般在handsontable中才需要,
+    style: {},
+    // popover方向 ,horizontal为水平方向(左右显示),vertical为垂直方向(上下显示)
+    direction: { type: String, default: 'horizontal' },
+  },
+  emits: ['destroy'],
+  setup(props) {
+    const { scrollWidth, scrollHeight } = document.body;
+    let tipClassName = 'tipPosition tip';
+    const tipPosition: Position = {};
+    // 鼠标离开组件时,销毁组件
+    const toclosePopover = () => {
+      hidePopover();
+    };
+    // 水平显示(只在左或者右显示)
+    if (props.direction === 'horizontal') {
+      tipClassName += 'H';
+      if (props.top > scrollHeight / 2) {
+        tipClassName += 'Bottom';
+        tipPosition.bottom = `${scrollHeight - props.top - props.tranHeight - 8}px`;
+      } else {
+        tipClassName += 'Top';
+        tipPosition.top = `${props.top - 10}px`;
+      }
+      if (props.left > scrollWidth / 2) {
+        tipClassName += 'Right';
+        tipPosition.right = `${scrollWidth - props.left + 8}px`;
+      } else {
+        tipClassName += 'Left';
+        tipPosition.left = `${props.left + props.tranWidth + 8}px`;
+      }
+    } else {
+      tipClassName += 'V';
+      if (props.top > scrollHeight / 2) {
+        tipClassName += 'Top';
+        tipPosition.bottom = `${scrollHeight - props.top + 8}px`;
+      } else {
+        tipClassName += 'Bottom';
+        tipPosition.top = `${props.top + props.tranHeight + 8}px`;
+      }
+      if (props.left > scrollWidth / 2) {
+        tipClassName += 'Right';
+        tipPosition.right = `${scrollWidth - props.left - props.tranWidth / 2}px`;
+      } else {
+        tipClassName += 'Left';
+        tipPosition.left = `${props.left + props.tranWidth / 2}px`;
+      }
+    }
+
+    const objStyle = {};
+    Object.assign(objStyle, props.style);
+    const popoverStyle = ref({
+      ...objStyle,
+      ...tipPosition,
+      display: 'block',
+    });
+
+    return { popoverStyle, tipClassName, toclosePopover, tipPosition };
+  },
+});
+</script>
+
+<style lang="scss" scoped>
+.tipPosition {
+  background: $light-black;
+  color: $white;
+  position: absolute;
+  padding: 16px;
+  border-radius: 13px;
+  word-break: break-all;
+  z-index: 200;
+  // display: none;
+}
+
+.tipHBottomRight:after {
+  content: '';
+  width: 0;
+  height: 0;
+  border-style: solid;
+  border-width: 8px 0 8px 8px;
+  border-color: transparent transparent transparent $light-black;
+  position: absolute;
+  bottom: 12px;
+  right: -6px;
+}
+
+.tipHTopRight:after {
+  content: '';
+  width: 0;
+  height: 0;
+  border-style: solid;
+  border-width: 8px 0 8px 8px;
+  border-color: transparent transparent transparent $light-black;
+  position: absolute;
+  top: 12px;
+  right: -6px;
+}
+
+.tipHTopLeft:after {
+  content: '';
+  width: 0;
+  height: 0;
+  border-style: solid;
+  border-width: 8px 8px 8px 0;
+  border-color: transparent $light-black transparent transparent;
+  position: absolute;
+  top: 12px;
+  left: -6px;
+}
+
+.tipHBottomLeft:after {
+  content: '';
+  width: 0;
+  height: 0;
+  border-style: solid;
+  border-width: 8px 8px 8px 0;
+  border-color: transparent $light-black transparent transparent;
+  position: absolute;
+  bottom: 12px;
+  left: -6px;
+}
+
+.tipVBottomRight {
+  transform: translateX(50%);
+
+  &:after {
+    content: '';
+    width: 0;
+    height: 0;
+    border-style: solid;
+    border-width: 0px 8px 8px 8px;
+    border-color: transparent transparent $light-black transparent;
+    position: absolute;
+    top: -6px;
+    right: 50%;
+    transform: translateX(50%);
+  }
+}
+
+.tipVTopRight {
+  transform: translateX(50%);
+
+  &:after {
+    content: '';
+    width: 0;
+    height: 0;
+    border-style: solid;
+    border-width: 8px 8px 0 8px;
+    border-color: $light-black transparent transparent transparent;
+    position: absolute;
+    bottom: -6px;
+    right: 50%;
+    transform: translateX(50%);
+  }
+}
+
+.tipVTopLeft {
+  transform: translateX(-50%);
+
+  &:after {
+    content: '';
+    width: 0;
+    height: 0;
+    border-style: solid;
+    border-width: 8px 8px 0px 8px;
+    border-color: $light-black transparent transparent transparent;
+    position: absolute;
+    bottom: -6px;
+    left: 30px;
+    transform: translateX(-50%);
+  }
+}
+
+.tipVBottomLeft {
+  transform: translateX(-50%);
+
+  &:after {
+    content: '';
+    width: 0;
+    height: 0;
+    border-style: solid;
+    border-width: 0px 8px 8px 8px;
+    border-color: transparent transparent $light-black transparent;
+    position: absolute;
+    top: -6px;
+    left: 30px;
+    transform: translateX(50%);
+  }
+}
+::v-deep .jobContent {
+  margin-bottom: 20px;
+  color: $blue;
+  max-width: 320px;
+}
+::v-deep .characterText {
+  color: #fff;
+}
+::v-deep .unNormal {
+  color: $blue;
+}
+</style>

+ 27 - 0
src/components/popover/composables/popoverHelp.ts

@@ -0,0 +1,27 @@
+import { createPopover } from '@/utils/frontend/vueHelper';
+import { Component } from 'vue';
+import { PopoverSetting } from './types';
+
+let isShow = false;
+// 节流变量
+const mask: any = {};
+// 延时id数列
+const list: any = [];
+export const showPopover = (settings: PopoverSetting, component?: Component) => {
+  isShow = true;
+  mask.tID = setTimeout(() => {
+    if (isShow === true) {
+      createPopover(component, settings);
+    }
+  }, 500);
+  list.push(mask.tID);
+};
+
+export const hidePopover = () => {
+  isShow = false;
+  // 每次取消操作的时候就去掉第一个队员
+  clearTimeout(list[0]);
+  list.splice(0, 1);
+  if (document.body.getElementsByClassName('popover-content')[0])
+    document.body.getElementsByClassName('popover-content')[0].remove();
+};

+ 18 - 0
src/components/popover/composables/types.ts

@@ -0,0 +1,18 @@
+export interface Position {
+  top?: string;
+  bottom?: string;
+  left?: string;
+  right?: string;
+  height?: string;
+  width?: string;
+  [key: string]: any;
+}
+export interface PopoverSetting {
+  top?: number | string;
+  bottom?: number | string;
+  left?: number | string;
+  right?: number | string;
+  height?: number | string;
+  width?: number | string;
+  [key: string]: any;
+}

+ 42 - 0
src/components/popover/composables/usePopover.ts

@@ -0,0 +1,42 @@
+import { getElementOffset, hover } from '@/utils/frontend/dom';
+import Popover from '@/components/popover/Popover.vue';
+import { hidePopover, showPopover } from './popoverHelp';
+
+// 悬浮提示
+export const showTdHitTips = (td: HTMLElement, content = '', direction = 'horizontal') => {
+  const offset = getElementOffset(td);
+  let scrollDom: HTMLElement = td.parentElement as any;
+  for (let i = 0; i < 5; i++) {
+    scrollDom = scrollDom.parentElement as any;
+  }
+  const scrollTop = scrollDom.scrollTop || 0;
+  const scrollLeft = scrollDom.scrollLeft || 0;
+  // 内容为空字符串是不显示气泡
+  if (content) {
+    showPopover(
+      {
+        top: offset.top - scrollTop,
+        left: offset.left - scrollLeft,
+        tranHeight: td.clientHeight,
+        tranWidth: td.clientWidth,
+        content,
+        direction,
+      },
+      Popover
+    );
+  }
+};
+
+export const bindPopover = (TD: HTMLElement, container: HTMLElement, content = '', direction = 'horizontal') => {
+  if (content) {
+    const mouseenterEvent = () => {
+      showTdHitTips(TD, content, direction);
+    };
+
+    const mouseleaveEvent = () => {
+      hidePopover();
+    };
+    // 鼠标hover时
+    hover(container, mouseenterEvent, mouseleaveEvent);
+  }
+};

+ 7 - 0
src/constants/commonClassName.ts

@@ -0,0 +1,7 @@
+export const classRight = 'htRight htMiddle wcSingleLine';
+export const classLeft = 'htLeft htMiddle wcSingleLine';
+export const classCenter = 'htCenter htMiddle wcSingleLine';
+
+export const autoClassRight = 'htRight htMiddle';
+export const autoClassLeft = 'htLeft htMiddle';
+export const autoClassCenter = 'htCenter htMiddle';

+ 37 - 0
src/styles/_mixin.scss

@@ -245,3 +245,40 @@
     margin: 0;
   }
 }
+
+// handsontable 滚动条
+@mixin scrollbar-handsontable {
+  ::-webkit-scrollbar {
+    width: 8px;
+    height: 8px;
+  }
+
+  ::-webkit-scrollbar-track {
+    background-color: $backdrop;
+    padding: 1px;
+  }
+
+  ::-webkit-scrollbar-track:horizontal {
+    border-top: 1px solid #e6e6e6;
+  }
+
+  ::-webkit-scrollbar-track:vertical {
+    border-left: 1px solid #e6e6e6;
+  }
+
+  ::-webkit-scrollbar-corner {
+    display: block;
+    background-color: $backdrop;
+  }
+
+  ::-webkit-scrollbar-thumb {
+    min-width: 50px;
+    min-height: 50px;
+    background-color: $light-grey;
+    border-radius: 1px;
+  }
+
+  ::-webkit-scrollbar-thumb:hover {
+    background-color: rgba(0, 0, 0, 0.4);
+  }
+}

+ 25 - 0
src/styles/_variables.scss

@@ -24,6 +24,31 @@ $backdrop: #fbfaf9;
 $text-primary: #535a5d;
 $border-grey: #ddd;
 
+$black: #000;
+$light-black: #303133;
+$white: #fff;
+$orange: #ff6501;
+$blue: #409eff; /* #1890ff  #409eff */
+$light-blue: #66b1ff;
+$sky-blue: #bae7ff;
+$light-sky-blue: #e6f7ff;
+$dark-blue: #007bff;
+$grey: #909399;
+$light-grey: #ccc;
+$dark-grey: #666;
+$snow-grey: #f7f7f9;
+$gainsboro: #d4d4d4;
+$ghost-grey: #fafafa;
+$smoke: #f5f5f5;
+$red: #f56c6c;
+$light-red: #fef0f0;
+$indian-red: #ff4d4f;
+$firebrick: #dc3545;
+$green: #67c23a;
+$forest: #28a745;
+$yellow: #e6a23c;
+$cyan: #17a2ba;
+
 /* 导出变量(css in js) 变量名为cssVars */
 :export {
   smallScreen: $small-screen;

+ 2 - 0
src/styles/index.scss

@@ -4,5 +4,7 @@
 @import "element_plus";
 @import "animate.css";
 
+@import "@sc/handsontable/dist/handsontable.full.min.css";
+
 // iconfont,新增图标需要更新此url
 @import url("//at.alicdn.com/t/font_2835708_qzi4i5ccjr.css");

+ 100 - 0
src/types/components.d.ts

@@ -34,4 +34,104 @@ declare namespace ResizableLayout {
     interface IResizableLayoutComponent {
         Refresh: () => void;
     }
+}
+
+
+
+type Renderer = (
+    instance: Handsontable,
+    TD: HTMLElement,
+    row: number,
+    col: number,
+    prop: string | number,
+    value: any,
+    cellProperties: GridSettings
+) => HTMLElement;
+
+declare namespace Hot {
+    interface ICoords {
+        startRow: number;
+        startCol: number;
+        endRow: number;
+        endCol: number;
+    }
+
+    interface IColumnMeta {
+        title: string;
+        data: string;
+        renderer?: | string | Renderer | ((
+            instance: Handsontable,
+            td: HTMLElement,
+            row: number,
+            col: number,
+            prop: string | number,
+            value: any,
+            cellProperties: IColumnMeta
+        ) => void);
+        editor?: string;
+        readOnly?: boolean;
+        showButton?: boolean; // 默认readOnly为true时,右上角的按钮也不显示,对于readOnly为true,又要显示按钮的情况,这里也要设置为true
+        width: number;
+        // 小数位数,如果存在此项配置,搭配'wc.numeric' cellType后,会将数值string转换为number,并进行四舍五入
+        decimal?: number | (() => number);
+        // 允许输入空值,转换成null
+        numberAllowEmpty?: boolean;
+        type?: string;
+        checkedTemplate?: any;
+        uncheckedTemplate?: any;
+        numericFormat?: { pattern?: string; zeroFormat?: string };
+        source?: string[] | any[];
+        className?: string;
+        required?: boolean;
+        isTemporary?: boolean;
+    }
+
+    type MouseKey = 'left' | 'middle' | 'right';
+    type ClickPosition = 'outside' | 'barren' | 'content';
+
+    interface IGroupHeaderCell {
+        label: string;
+        colspan?: number;
+        rowspan?: number;
+    }
+
+    type GroupHeaderCell = IGroupHeaderCell | string;
+
+    interface ISettings extends GridSettings {
+        columnsMeta?: IColumnMeta[];
+        /**
+         * 获取鼠标点击的区域
+         * @param isOutside 是否在表格外面
+         * @param isBarren 是否在表格内部的空白区域
+         * @param isContent 是否在表格内容的内同区域
+         */
+        onClickPosition?: (whichKey: MouseKey, clickPosition: ClickPosition) => void;
+        // 表头分组
+        groupingHeaders?: GroupHeaderCell[][];
+        // contextMenu?: { callback: (...args: any) => void; items: any };
+    }
+
+    interface IHandsontableProps {
+        data: any[];
+        settings: ISettings;
+        tree: boolean;
+        readOnly: boolean;
+        border: string;
+        loading: boolean;
+    }
+
+    interface IRenderer {
+        (
+            instance: Handsontable,
+            row: number,
+            col: number,
+            prop: string | number,
+            value: any,
+            cellProperties: GridSettings
+        ): HTMLElement;
+    }
+
+    type CellChangeParam = [/* row */ number, /* prop */ string, /* oldValue */ any, /* newValue */ any];
+
+    type RendererItem = [string, ComponentObjectPropsOptions | IRenderer];
 }

+ 0 - 0
src/utils/common/.gitkeep


src/utils/common/common.ts → src/utils/common/tools.ts


+ 181 - 0
src/utils/frontend/alert.ts

@@ -0,0 +1,181 @@
+/**
+ 使用方法:Alert('title', 'content') 或者 Alert('content') 或者 Alert.info('title', 'content') 或者 Alert.info('content')
+ Alert.success, Alert.error, Alert.warning 使用方法同理
+
+ 注意,Confirm({...}) 或者 Confirm.info({...}) 方法参数与上面的不同,由于confirm有回调函数,故传参为
+ Confirm.info({
+      title?: string; // 标题,可选
+      content: string; // 内容,必选
+      okText?: string; // 可选,默认为 '确认'
+      cancelText?: string; // 可选,默认为 '取消'
+      onOk?: () => void; // 确认按钮回调
+      onCancel?: () => void; // 取消按钮回调
+    })
+ Confirm.success, Confirm.error, Confirm.warning 使用方法同理
+ */
+import { VNode } from 'vue';
+import { ElMessageBox, ElMessage } from 'element-plus';
+
+type MessageBoxType = 'info' | 'success' | 'error' | 'warning' | undefined;
+
+function alert(type: MessageBoxType, content: string, callback?: (action: string) => void) {
+  ElMessageBox.alert(content, {
+    type,
+    showClose: false,
+    callback,
+  });
+}
+
+const Alert = (content: string, callback?: (action: string) => void): void => {
+  alert(undefined, content, callback);
+};
+Alert.info = (content: string, callback?: (action: string) => void): void => {
+  alert('info', content, callback);
+};
+Alert.success = (content: string, callback?: (action: string) => void): void => {
+  alert('success', content, callback);
+};
+Alert.error = (content: string, callback?: (action: string) => void): void => {
+  alert('error', content, callback);
+};
+Alert.warning = (content: string, callback?: (action: string) => void): void => {
+  alert('warning', content, callback);
+};
+
+interface ConfirmParam {
+  title?: string;
+  content: string;
+  okText?: string;
+  cancelText?: string;
+  onOk?: () => void;
+  onCancel?: () => void;
+}
+
+// options的属性可为element-plus notification-box options的任意一项:https://element-plus.gitee.io/#/zh-CN/component/message-box
+function confirm(type: MessageBoxType, param: ConfirmParam, options?: Record<string, any>) {
+  const { title, content, onOk, onCancel } = param;
+  let { okText, cancelText } = param;
+  if (!okText) {
+    okText = '确认';
+  }
+
+  if (!cancelText) {
+    cancelText = '取消';
+  }
+  const message = content;
+  const head = title;
+
+  const props = {
+    confirmButtonText: okText,
+    cancelButtonText: cancelText,
+    type,
+    showClose: false,
+  };
+
+  if (options) {
+    Object.assign(props, options);
+  }
+
+  let promise;
+  if (message && head) {
+    promise = ElMessageBox.confirm(message, head, props);
+  } else if (message) {
+    promise = ElMessageBox.confirm(message, props);
+  }
+  promise &&
+    promise
+      .then(() => {
+        onOk && onOk();
+      })
+      .catch(() => {
+        onCancel && onCancel();
+      });
+}
+
+const Confirm = (param: ConfirmParam, options?: Record<string, any>): void => {
+  confirm(undefined, param, options);
+};
+Confirm.info = (param: ConfirmParam, options?: Record<string, any>): void => {
+  confirm('info', param, options);
+};
+Confirm.success = (param: ConfirmParam, options?: Record<string, any>): void => {
+  confirm('success', param, options);
+};
+Confirm.error = (param: ConfirmParam, options?: Record<string, any>): void => {
+  confirm('error', param, options);
+};
+Confirm.warning = (param: ConfirmParam, options?: Record<string, any>): void => {
+  confirm('warning', param, options);
+};
+
+enum MessageType {
+  SUCCESS = 'success',
+  WARNING = 'warning',
+  INFO = 'info',
+  ERROR = 'error',
+}
+interface IMessageConfig {
+  message: string | VNode;
+  type: MessageType;
+  iconClass: string;
+  dangerouslyUseHTMLString: boolean;
+  customClass: string;
+  duration: number;
+  showClose: boolean;
+  center: boolean;
+  onClose: () => void;
+  offset: number;
+}
+
+interface IMessageHandle {
+  close: () => void;
+}
+
+const baseMessageConfig: Partial<IMessageConfig> = {
+  duration: 3000,
+  // offset: 30,
+};
+
+/* 提示 */
+// 可统一共用的提示文本
+export enum MessageContent {
+  PERMISSION = '对不起,您没有权限进行该操作',
+  CELL_VALIDATE = '输入的数据类型不对,请重新输入',
+}
+const Message = (message: string, options: Partial<IMessageConfig> = {}): IMessageHandle => {
+  return ElMessage({ message, ...baseMessageConfig, ...options });
+};
+
+Message.success = (message: string, options: Partial<IMessageConfig> = {}): IMessageHandle => {
+  return ElMessage({ message, ...baseMessageConfig, ...options, type: MessageType.SUCCESS });
+};
+
+Message.warning = (message: string, options: Partial<IMessageConfig> = {}): IMessageHandle => {
+  return ElMessage({ message, ...baseMessageConfig, ...options, type: MessageType.WARNING });
+};
+
+Message.info = (message: string, options: Partial<IMessageConfig> = {}): IMessageHandle => {
+  return ElMessage({ message, ...baseMessageConfig, ...options, type: MessageType.INFO });
+};
+
+Message.error = (message: string, options: Partial<IMessageConfig> = {}): IMessageHandle => {
+  return ElMessage({ message, ...baseMessageConfig, ...options, type: MessageType.ERROR });
+};
+
+export const asyncConfirm = (content: string, title?: string) => {
+  return new Promise<boolean>((resolve, reject) => {
+    Confirm.warning({
+      content,
+      title,
+      onOk: () => {
+        resolve(true);
+      },
+      onCancel: () => {
+        console.log('cancel');
+        resolve(false);
+      },
+    });
+  });
+};
+
+export { Alert, Confirm, Message };

+ 183 - 0
src/utils/frontend/emitter.ts

@@ -0,0 +1,183 @@
+/* mitt事件相关 */
+import mitt, { Emitter, Handler } from 'mitt';
+// import tabStore from '@/store/modules/tabs';
+
+/**
+ * mitt事件枚举,分发和监听的事件类型必须在此定义
+ */
+export enum EmitterType {
+  // 拖动拖拽布局
+  DRAG_BAR_MOVE = 'dragBarMove',
+  // sidebar侧边栏宽度变化
+  SIDEBAR_WIDTH_CHANGE = 'sidebarWidthChange',
+  // 点击了新建项目按钮
+  CLICK_CREATE_PROJECT = 'clickCreateProject',
+  // 重新加载建设项目
+  RELOAD_CONSTRUCTION_PROJECTS = 'reload:construction',
+  // 刷新项目管理-全部 表格
+  REFRESH_ALL_TABLE = 'refresh:allTable',
+  // 更改了项目管理-全部树
+  UPDATE_ALL_TREE = 'update:allTree',
+  // 更改了项目导航树
+  UPDATE_SUBJECT_TREE = 'update:subjectTree',
+  // 更改(恢复)回收站树
+  UPDATE_RECYCLE_TREE = 'update:recycleTree',
+  // 点击了回收站的恢复按钮
+  RECYCLE = 'clickRecycle',
+  // 点击了彻底删除
+  COMPLETELY_REMOVE = 'clickCompletelyRemove',
+  // 更改了造价书节点
+  UPDATE_COST_NODE = 'update:costNode',
+  // 更新了特征及内容文本
+  UPDATE_CONTENT_CHARACTER = 'update:contentCharacter',
+  // 更新造价书列设置
+  UPDATE_COST_SETTING = 'update:costSetting',
+  // 刷新造价书
+  REFRESH_COST = 'refresh:cost',
+  // 刷新造价书按钮有效性
+  REFRESH_COST_BUTTON = 'refresh:costButton',
+  // 选中造价书节点
+  SELECT_COST = 'select:cost',
+  // 设置造价书Loading
+  SET_COST_LOADING = 'set:costLoading',
+  // 刷新项目工料机表格
+  REFRESH_PROJECT_GLJ = 'refresh:projectGlj',
+  // 刷新关联材料表格
+  REFRESH_RELATED_MATERIAL = 'refresh:relatedMaterial',
+  // 刷新建设项目级工料机汇总
+  REFRESH_SUMMARY_GLJ = 'refresh:summaryGlj',
+  // 切换建设项目工料机汇总表格的loading状态
+  TRIGGER_SUMMARY_LOADING = 'trigger:summaryLoading',
+  // 刷新量价表格
+  REFRESH_VOLUME_PRICE = 'refresh:volumePrice',
+  // 刷新定额工料机表格
+  REFRESH_RATION_GLJ = 'refresh:rationGlj',
+  // 刷新定额安装增加费表格
+  REFRESH_RATION_INSTALL = 'refresh:rationInstall',
+  // 刷新费用修改表格
+  REFRESH_EDIT_RULE = 'refresh:editRule',
+  // 刷新计算程序表格
+  REFRESH_CALC_PROGRAM = 'refresh:calcProgram',
+  // 刷新定额子目换算表格
+  REFRESH_RATION_CALC = 'refresh:rationCalc',
+  // 刷新定额库定额列表
+  REFRESH_LIB_RATION = 'refresh:libRation',
+  // 刷新定额库定额列表列设置
+  UPDATE_LIB_RATION_SETTING = 'refresh:libRationSetting',
+  // 刷新定额模板子目
+  RELOAD_RATION_TEMPLATE = 'refresh:rationTemplate',
+  // 刷新信息价表格
+  REFRESH_INFO_PRICE = 'refresh:infoPrice',
+  // 刷新费率页面表格
+  REFRESH_FEE_RATE = 'refresh:feeRate',
+  // 刷新补充定额章节树按钮区有效性
+  REFRESH_RATION_TREE_TOOLS = 'refresh:rationTreeTools',
+  // 刷新补充定额表格
+  REFRESH_CPT_RATION = 'refresh:cptRation',
+  // 刷新安装增加费用项表格
+  REFRESH_INSTALL_FEE_ITEM = 'refresh:installFeeItem',
+  // 项目分享历史变更
+  PROJECT_SHARE_HISTORY_CHANGED = 'projectShareHistoryChanged',
+  // 定额库分享历史变更
+  RATION_LIB_SHARE_HISTORY_CHANGED = 'rationLibShareHistoryChanged',
+  // 人材机库分享历史变更
+  GLJ_LIB_SHARE_HISTORY_CHANGED = 'gljLibShareHistoryChanged',
+  // 更新了子目换算总表数据
+  UPDATE_CPT_COE = 'update:cptCoe',
+  // 关闭计算基数弹窗
+  CLOSE_FORMULA_POPUP = 'close:formulaPopup',
+  // 打开库标签
+  OPEN_LIB_TAB = 'open:libTab',
+  // 添加新成员
+  ADD_NEW_MEMBER = 'addNewMember',
+  // 打开消息中心
+  OPEN_NOTIFICATION = 'openNotification',
+  // 未读消息数量变化
+  UNREAD_COUNT_CHANGE = 'unreadCountChange',
+  // 新消息
+  NEW_MESSAGE = 'newMessage',
+  // 离开 socket 房间
+  LEAVE_ROOM = 'leaveRoom',
+  // 刷新工料机库显示(按选中类别)
+  REFRESH_CPT_GLJ_LIB = 'refresh:cptGljLib',
+  // 变更补充定额库
+  CHANGE_CPT_RATION_LIB = 'change:cptRationLib',
+  // 变更补充人材机库
+  CHANGE_CPT_GLJ_LIB = 'change:cptGljLib',
+  // 刷新书签批注
+  REFRESH_BOOKMARK = 'refresh:bookmark',
+  // 更新显示/隐藏特征按钮
+  UPDATE_SHOW_FEATURE = 'update:isShowFeature',
+  // 造价单书签批注用,hover事件计数器
+  CLEAR_CELL_HOVER_LIST = 'clear:cellHoverList',
+  // 清空人材机组成物
+  CLEAR_GLJ_COMPONENT = 'clear:gljComponent',
+  // 刷新建设项目分享标记
+  REFRESH_CONSTRUCTION_SHARE_MARK = 'refresh:constructionShareMark',
+  // 单位工程信息指标表格刷新
+  REFRESH_TARGET_GRID = 'refresh:targetGrid',
+  // 刷新建设项目汇总
+  REFRESH_CONSTRUCTION_SUMMARY = 'refresh:constructionSummary',
+  // 刷新分享项目数据
+  REFRESH_SHARE_PROJECT = 'refresh:shareProject',
+  // 刷新分享定额库数据
+  REFRESH_SHARE_RATION_LIB = 'refresh:shareRationLib',
+  // 刷新分享人材机库数据
+  REFRESH_SHARE_GLJ_LIB = 'refresh:shareGljLib',
+  // 追加定额库选项
+  APPEND_RATION_LIB_OPTION = 'append:rationLibOption',
+  // 追加人材机库选项
+  APPEND_GLJ_LIB_OPTION = 'append:gljLibOption',
+  // 移除定额库选项
+  REMOVE_RATION_LIB_OPTION = 'remove:rationLibOption',
+  // 移除人材机库选项
+  REMOVE_GLJ_LIB_OPTION = 'remove:gljLibOption',
+  // 刷新项目权限
+  REFRESH_PROJECT_PERMISSION = 'refresh:projectPermission',
+}
+
+// 单例的emitter(有些事件不能用单例,可能会有多建设项目页面)
+const singletonEmitter = mitt();
+
+const emitterMap = new Map<string, Emitter>();
+
+// 已出现的监听枚举
+const listenedMap: Record<string, Handler> = {};
+
+// 获取mitt事件处理方法,如果不传入key,则处理事件的对象为singletonEmitter。即不需要区分emitter的时候,不传入key
+export const getEmitter = (key?: string) => {
+  let emitter: Emitter;
+  if (key) {
+    if (!emitterMap.get(key)) {
+      emitterMap.set(key, mitt());
+    }
+    emitter = emitterMap.get(key) as Emitter;
+  } else {
+    emitter = singletonEmitter;
+  }
+  return {
+    /**
+     * !!!注意!!!:emit监听时,按需增加监听事件名称,同一类型下,不可有重复监听事件名称
+     * 由于组件可能多次渲染(多次进入setup方法),为了避免重复监听事件。
+     */
+    on<T = any>(emitterType: EmitterType, handler: Handler<T>, listenerName?: string) {
+      const listenerKey = `${emitterType}-${listenerName}`;
+      if (listenedMap[listenerKey]) {
+        this.off(emitterType, listenedMap[listenerKey]);
+      }
+      listenedMap[listenerKey] = handler;
+      emitter.on<T>(emitterType, handler);
+    },
+    off<T = any>(emitterType: EmitterType, handler: Handler<T>) {
+      emitter.off<T>(emitterType, handler);
+    },
+    emit<T = any>(emitterType: EmitterType, payload?: T) {
+      emitter.emit<T>(emitterType, payload);
+    },
+  };
+};
+
+// 获取当前建设项目的emitter
+// export const getActiveEmitter = () => {
+//   return getEmitter(tabStore.activeKey || '');
+// };

+ 167 - 0
src/utils/frontend/http.ts

@@ -0,0 +1,167 @@
+// eslint-disable-next-line import/no-cycle
+import compilationStore from '@/store/modules/compilation';
+import userStore from '@/store/modules/user';
+import { clearTab } from '@/store/clear';
+/**
+ * axios封装
+ * 请求拦截、响应拦截、错误统一处理
+ */
+import { IResult, LoginType } from '@sc/types';
+import axios from 'axios';
+import { get as getCookie } from 'js-cookie';
+import router from '@/router';
+import { getShareLinkFromHref, isShareHref } from './share';
+// eslint-disable-next-line import/no-cycle
+import enterpriseStore from '@/store/modules/enterprise';
+
+/**
+ * 请求失败后的错误统一处理
+ * @param {Number} errno 请求失败的错误码
+ * @param message 错误信息
+ */
+const handleError = async (errno: number, message: string) => {
+  // 状态码判断
+  switch (errno) {
+    // 401: 未登录状态,跳转登录页
+    case 1009:
+      await router.replace('/login');
+      break;
+    // 没有费用定额权限
+    case 3005:
+      if (userStore.loginType === LoginType.ENTERPRISE) {
+        clearTab();
+        await router.replace('/workbench');
+      }
+      break;
+    // 分享链接失效
+    case 4005:
+      if (isShareHref()) window.location.reload();
+      break;
+    default:
+      console.log(message);
+  }
+};
+
+// 创建axios实例
+const instance = axios.create({ timeout: 1000 * 12, baseURL: process.env.VUE_APP_BASE_URL });
+// 设置post请求头
+// 暂时可不用
+// instance.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded';
+/**
+ * 请求拦截器
+ * 每次请求前,如果存在token则在请求头中携带token
+ */
+instance.interceptors.request.use(
+  config => {
+    // 登录流程控制中,根据本地是否存在token判断用户的登录情况
+    // 但是即使token存在,也有可能token是过期的,所以在每次的请求头中携带token
+    // 后台根据携带的token判断用户的登录情况,并返回给我们对应的状态码
+    // 而后我们可以在响应拦截器中,根据状态码进行一些统一的操作。
+
+    // 分享链接,需要设置请求头,后端才可根据请求头进行校验等
+    const shareLink = getShareLinkFromHref();
+    if (shareLink) {
+      config.headers['Share-Link'] = shareLink;
+      // 分享链接的情况,请求头需要携带费用定额ID,因为现在后端大多数请求、获取数据库表依赖费用定额ID
+      // 注意:后续可考虑将后端所有方法,不依赖session里的compilationID、userID,全通过token的方式,这样更好维护,不过代码改动量比较大
+      if (compilationStore.compilationInfo.ID) {
+        config.headers.Compilation = compilationStore.compilationInfo.ID;
+      }
+    }
+
+    const csrfToken = getCookie('csrfToken');
+    // Cookies.get('csrfToken');
+    if (csrfToken) config.headers.post['x-csrf-token'] = csrfToken;
+
+    // 退出登录的时候将本地清空
+    if (config.url?.startsWith('/user/logout')) {
+      userStore.commitReset();
+      enterpriseStore.commitReset();
+    }
+    return config;
+  },
+  error => Promise.reject(error)
+);
+
+// 响应拦截器
+instance.interceptors.response.use(
+  // 请求成功
+  res => (res.status === 200 ? Promise.resolve(res) : Promise.reject(res)),
+  // 请求失败
+  async error => {
+    if (error.message && error.message.includes('timeout')) {
+      return Promise.reject(new Error('请求超时,请稍后再试'));
+    }
+    const { response } = error;
+    const {
+      data: { errno, message },
+    } = response;
+    if (response) {
+      await handleError(errno, message);
+      return Promise.reject(response);
+    }
+    return Promise.reject(error);
+  }
+);
+
+/**
+ * get方法,对应get请求
+ * @param {String} url [请求的url地址]
+ * @param {Object} params [请求时携带的参数]
+ * @param isPlain
+ */
+export function get<T = any>(url: string, params?: any, isPlain = false): Promise<T> {
+  return new Promise((resolve, reject) => {
+    instance
+      .get(url, {
+        params,
+      })
+      .then(res => {
+        const result: IResult<T> = res.data;
+        if (result.errno > 0) {
+          reject(result);
+        } else {
+          // 有些外部的请求返回的数据接口跟项目中的接口格式是不一样的,这个时候设置isPlain为true,直接返回响应数据
+          resolve(isPlain ? (result as unknown as T) : result.data);
+        }
+      })
+      .catch(err => {
+        if (err instanceof Error) {
+          reject(err);
+        } else {
+          reject(err.data);
+        }
+      });
+  });
+}
+
+/**
+ * post方法,对应post请求
+ * @param {String} url [请求的url地址]
+ * @param {Object} params [请求时携带的参数]
+ * @param isPlain
+ */
+export function post<T = any>(url: string, params?: any, isPlain = false): Promise<T> {
+  return new Promise((resolve, reject) => {
+    instance
+      .post(url, params)
+      .then(res => {
+        const result: IResult<T> = res.data;
+        if (result.errno > 0) {
+          reject(result);
+        } else {
+          // 有些外部的请求返回的数据接口跟项目中的接口格式是不一样的,这个时候设置isPlain为true,直接返回响应数据
+          resolve(isPlain ? (result as any as T) : result.data);
+        }
+      })
+      .catch(err => {
+        if (err instanceof Error) {
+          reject(err);
+        } else {
+          reject(err.data);
+        }
+      });
+  });
+}
+
+// export default instance;

+ 30 - 0
src/utils/frontend/vueHelper.ts

@@ -0,0 +1,30 @@
+import { App, Component } from 'vue';
+import { IGlobalPopup } from '@/components/popup/types';
+
+type GlobalPopupMethod = (component?: Component, options?: any) => IGlobalPopup;
+type GlobalPopoverMethod = (component?: Component, options?: any) => void;
+export interface IGlobalProperties {
+  $popup: GlobalPopupMethod;
+  $popover: GlobalPopoverMethod;
+  [func: string]: any;
+}
+
+let curApp: App;
+
+export function setGlobalApp(app: App): void {
+  curApp = app;
+}
+
+export function getGlobal(): IGlobalProperties {
+  if (!curApp) {
+    throw new Error('没有正确注册全局app');
+  }
+  return curApp.config.globalProperties as IGlobalProperties;
+}
+
+export function createPopup<T = any>(component?: Component, options?: T): IGlobalPopup {
+  return getGlobal().$popup(component, options);
+}
+export function createPopover<T = any>(component?: Component, options?: T): void {
+  return getGlobal().$popover(component, options);
+}

+ 19 - 2
src/views/project/summary/components/cost-table/CostTable.vue

@@ -1,10 +1,27 @@
 <script setup lang="ts">
-import {onMounted, reactive, ref} from "vue";
+import { onMounted, reactive, ref } from "vue";
+const tableData = [
+  ['', 'Tesla', 'Nissan', 'Toyota', 'Honda', 'Mazda', 'Ford'],
+  ['2017', 10, 11, 12, 13, 15, 16],
+  ['2018', 10, 11, 12, 13, 15, 16],
+  ['2019', 10, 11, 12, 13, 15, 16],
+  ['2020', 10, 11, 12, 13, 15, 16],
+  ['2021', 10, 11, 12, 13, 15, 16]
+]
+
+const tableSettings = {
+  colHeaders: true,
+}
 </script>
 
 <template>
   <section class="cost-table">
-    造价书表
+    <handsontable
+      :data="tableData"
+      :settings="tableSettings"
+      class="cost-table"
+      ref="costHotRef"
+    />
   </section>
 </template>
 

+ 1 - 1
src/views/project/summary/components/cost-table/style.scss

@@ -1,5 +1,5 @@
 .cost-table {
   @apply h-full;
   background: #fff;
-  border: 1px solid #e8eaec;
+  // border: 1px solid #e8eaec;
 }