浏览代码

feat: 项目流汇总subject tree & 集成上下文组件

qinlaiqiao 3 年之前
父节点
当前提交
22443e0544

+ 2 - 2
bootstrap.js

@@ -2,11 +2,11 @@ const { Framework } = require('@midwayjs/koa');
 const { Bootstrap } = require('@midwayjs/bootstrap');
 
 const web = new Framework().configure({
-  port: 7001,
+  port: 7002,
 });
 
 Bootstrap.load(web)
   .run()
   .then(() => {
-    console.log('Your application is running at http://localhost:7001');
+    console.log('Your application is running at http://localhost:7002');
   });

+ 215 - 10
package-lock.json

@@ -4,6 +4,30 @@
   "lockfileVersion": 1,
   "requires": true,
   "dependencies": {
+    "@ant-design/colors": {
+      "version": "5.1.1",
+      "resolved": "http://192.168.1.90:4873/@ant-design%2fcolors/-/colors-5.1.1.tgz",
+      "integrity": "sha1-gAshhrHifmZDLmfQPtlq8+IdiUA=",
+      "requires": {
+        "@ctrl/tinycolor": "^3.3.1"
+      }
+    },
+    "@ant-design/icons-svg": {
+      "version": "4.2.1",
+      "resolved": "http://192.168.1.90:4873/@ant-design%2ficons-svg/-/icons-svg-4.2.1.tgz",
+      "integrity": "sha1-hjDajrRHGkqr2u19H/apfcss8Fo="
+    },
+    "@ant-design/icons-vue": {
+      "version": "6.0.1",
+      "resolved": "http://192.168.1.90:4873/@ant-design%2ficons-vue/-/icons-vue-6.0.1.tgz",
+      "integrity": "sha1-nYBMPHTSz6+XyxjlgtO5QAk09f0=",
+      "requires": {
+        "@ant-design/colors": "^5.0.0",
+        "@ant-design/icons-svg": "^4.0.0",
+        "@types/lodash": "^4.14.165",
+        "lodash": "^4.17.15"
+      }
+    },
     "@babel/code-frame": {
       "version": "7.16.0",
       "resolved": "http://192.168.1.90:4873/@babel%2fcode-frame/-/code-frame-7.16.0.tgz",
@@ -406,7 +430,6 @@
       "version": "7.16.0",
       "resolved": "http://192.168.1.90:4873/@babel%2fruntime/-/runtime-7.16.0.tgz",
       "integrity": "sha1-4nuXfy4giLokdIv5m14d7OZOTws=",
-      "dev": true,
       "requires": {
         "regenerator-runtime": "^0.13.4"
       }
@@ -492,6 +515,11 @@
         "minimist": "^1.2.0"
       }
     },
+    "@ctrl/tinycolor": {
+      "version": "3.4.0",
+      "resolved": "http://192.168.1.90:4873/@ctrl%2ftinycolor/-/tinycolor-3.4.0.tgz",
+      "integrity": "sha1-w8WuVDyJfKqcKmhjC+01W+X5mQ8="
+    },
     "@dabh/diagnostics": {
       "version": "2.0.2",
       "resolved": "http://192.168.1.90:4873/@dabh%2fdiagnostics/-/diagnostics-2.0.2.tgz",
@@ -1642,6 +1670,40 @@
       "resolved": "http://192.168.1.90:4873/@popperjs%2fcore/-/core-2.10.2.tgz",
       "integrity": "sha1-B5jAM1Hw3qGlpMq93yalWny+5ZA="
     },
+    "@sc/handsontable": {
+      "version": "6.3.11",
+      "resolved": "http://192.168.1.90:4873/@sc%2fhandsontable/-/handsontable-6.3.11.tgz",
+      "integrity": "sha512-eih5AqPqRMtRLY0iByE/sfvpMb3GkCnm7hsZwRhzUxyUc7wYTlFV4jeKrA51KKjOJX3Om8ovaMMnBQowOG6dxw==",
+      "requires": {
+        "@sc/util": "^1.0.7",
+        "moment": "2.20.1",
+        "numbro": "^2.0.6",
+        "pikaday": "1.5.1"
+      },
+      "dependencies": {
+        "moment": {
+          "version": "2.20.1",
+          "resolved": "http://192.168.1.90:4873/moment/-/moment-2.20.1.tgz",
+          "integrity": "sha512-Yh9y73JRljxW5QxN08Fner68eFLxM5ynNOAw2LbIB1YAGeQzZT8QFSUvkAz609Zf+IHhhaUxqZK8dG3W/+HEvg=="
+        }
+      }
+    },
+    "@sc/tree": {
+      "version": "1.0.18",
+      "resolved": "http://192.168.1.90:4873/@sc%2ftree/-/tree-1.0.18.tgz",
+      "integrity": "sha512-u3A5U0Y3JBzt90JVJNZMFtxE2soyvmGUOAaGEzefWU1Gcg20QPU6RhL+OA+C4gi4Ydxm7ZWgsHthSLdBBjXEfw==",
+      "requires": {
+        "lodash": "^4.17.20"
+      }
+    },
+    "@sc/util": {
+      "version": "1.0.9",
+      "resolved": "http://192.168.1.90:4873/@sc%2futil/-/util-1.0.9.tgz",
+      "integrity": "sha512-OFOdA8KfQV/tN5yRXc9vPgwyPmi1a1MiyP37tBo4WrQH+tlFxLHen4v8iQT3lJ3nRCjbqswss/qRmto/9OclSQ==",
+      "requires": {
+        "lodash": "^4.17.21"
+      }
+    },
     "@sideway/address": {
       "version": "4.1.2",
       "resolved": "http://192.168.1.90:4873/@sideway%2faddress/-/address-4.1.2.tgz",
@@ -1660,6 +1722,22 @@
       "resolved": "http://192.168.1.90:4873/@sideway%2fpinpoint/-/pinpoint-2.0.0.tgz",
       "integrity": "sha1-z/j/rcNyrSn9P3gneusp5jLMcN8="
     },
+    "@simonwep/pickr": {
+      "version": "1.8.2",
+      "resolved": "http://192.168.1.90:4873/@simonwep%2fpickr/-/pickr-1.8.2.tgz",
+      "integrity": "sha1-ltyGZ1lA18rWPWnCIIPdHLuXl8s=",
+      "requires": {
+        "core-js": "^3.15.1",
+        "nanopop": "^2.1.0"
+      },
+      "dependencies": {
+        "core-js": {
+          "version": "3.19.1",
+          "resolved": "http://192.168.1.90:4873/core-js/-/core-js-3.19.1.tgz",
+          "integrity": "sha1-9vFzyuI+c6fYj6I7bp2jKSdsZkE="
+        }
+      }
+    },
     "@sinonjs/commons": {
       "version": "1.8.3",
       "resolved": "http://192.168.1.90:4873/@sinonjs%2fcommons/-/commons-1.8.3.tgz",
@@ -1965,8 +2043,7 @@
     "@types/lodash": {
       "version": "4.14.176",
       "resolved": "http://192.168.1.90:4873/@types%2flodash/-/lodash-4.14.176.tgz",
-      "integrity": "sha1-ZBFQ/BzaNvv6Mp3mA7uxddfuIMA=",
-      "dev": true
+      "integrity": "sha1-ZBFQ/BzaNvv6Mp3mA7uxddfuIMA="
     },
     "@types/mime": {
       "version": "1.3.2",
@@ -3202,6 +3279,36 @@
         }
       }
     },
+    "ant-design-vue": {
+      "version": "2.2.8",
+      "resolved": "http://192.168.1.90:4873/ant-design-vue/-/ant-design-vue-2.2.8.tgz",
+      "integrity": "sha1-+ofPaELY7poNivOT/0CZ7MQHLys=",
+      "requires": {
+        "@ant-design/icons-vue": "^6.0.0",
+        "@babel/runtime": "^7.10.5",
+        "@simonwep/pickr": "~1.8.0",
+        "array-tree-filter": "^2.1.0",
+        "async-validator": "^3.3.0",
+        "dom-align": "^1.12.1",
+        "dom-scroll-into-view": "^2.0.0",
+        "lodash": "^4.17.21",
+        "lodash-es": "^4.17.15",
+        "moment": "^2.27.0",
+        "omit.js": "^2.0.0",
+        "resize-observer-polyfill": "^1.5.1",
+        "scroll-into-view-if-needed": "^2.2.25",
+        "shallow-equal": "^1.0.0",
+        "vue-types": "^3.0.0",
+        "warning": "^4.0.0"
+      },
+      "dependencies": {
+        "async-validator": {
+          "version": "3.5.2",
+          "resolved": "http://192.168.1.90:4873/async-validator/-/async-validator-3.5.2.tgz",
+          "integrity": "sha1-aOhmqWgk6LJpT/eoMcGiXETV5QA="
+        }
+      }
+    },
     "any-promise": {
       "version": "1.3.0",
       "resolved": "http://192.168.1.90:4873/any-promise/-/any-promise-1.3.0.tgz",
@@ -3294,6 +3401,11 @@
         "is-string": "^1.0.7"
       }
     },
+    "array-tree-filter": {
+      "version": "2.1.0",
+      "resolved": "http://192.168.1.90:4873/array-tree-filter/-/array-tree-filter-2.1.0.tgz",
+      "integrity": "sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw=="
+    },
     "array-union": {
       "version": "2.1.0",
       "resolved": "http://192.168.1.90:4873/array-union/-/array-union-2.1.0.tgz",
@@ -3685,6 +3797,11 @@
       "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==",
       "dev": true
     },
+    "bignumber.js": {
+      "version": "8.1.1",
+      "resolved": "http://192.168.1.90:4873/bignumber.js/-/bignumber.js-8.1.1.tgz",
+      "integrity": "sha1-Swcq5a6pwg9nMOTl1SnfEnHE2IU="
+    },
     "binary-extensions": {
       "version": "2.2.0",
       "resolved": "http://192.168.1.90:4873/binary-extensions/-/binary-extensions-2.2.0.tgz",
@@ -4399,6 +4516,11 @@
         "mime-db": ">= 1.43.0 < 2"
       }
     },
+    "compute-scroll-into-view": {
+      "version": "1.0.17",
+      "resolved": "http://192.168.1.90:4873/compute-scroll-into-view/-/compute-scroll-into-view-1.0.17.tgz",
+      "integrity": "sha1-aojxis2dQunPS6pr7H4FImB6t6s="
+    },
     "concat-map": {
       "version": "0.0.1",
       "resolved": "http://192.168.1.90:4873/concat-map/-/concat-map-0.0.1.tgz",
@@ -5117,6 +5239,16 @@
       "integrity": "sha512-Xu9mD0UjrJisTmv7lmVSDMagQcU9R5hwAbxsaAE/35XPnPLJobbuREfV/rraiSaEj/UOvgrzQs66zyTWTlyd+g==",
       "dev": true
     },
+    "dom-align": {
+      "version": "1.12.2",
+      "resolved": "http://192.168.1.90:4873/dom-align/-/dom-align-1.12.2.tgz",
+      "integrity": "sha1-D4Fk69DJwhsMeQMQSTzYVYkqzUs="
+    },
+    "dom-scroll-into-view": {
+      "version": "2.0.1",
+      "resolved": "http://192.168.1.90:4873/dom-scroll-into-view/-/dom-scroll-into-view-2.0.1.tgz",
+      "integrity": "sha512-bvVTQe1lfaUr1oFzZX80ce9KLDlZ3iU+XGNE/bz9HnGdklTieqsbmsLHe+rT2XWqopvL0PckkYqN7ksmm5pe3w=="
+    },
     "dom-serializer": {
       "version": "1.3.2",
       "resolved": "http://192.168.1.90:4873/dom-serializer/-/dom-serializer-1.3.2.tgz",
@@ -9268,8 +9400,7 @@
     "js-tokens": {
       "version": "4.0.0",
       "resolved": "http://192.168.1.90:4873/js-tokens/-/js-tokens-4.0.0.tgz",
-      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
-      "dev": true
+      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
     },
     "js-yaml": {
       "version": "4.1.0",
@@ -9744,6 +9875,11 @@
       "resolved": "http://192.168.1.90:4873/lodash/-/lodash-4.17.21.tgz",
       "integrity": "sha1-Z5WRxWTDv/quhFTPCz3zcMPWkRw="
     },
+    "lodash-es": {
+      "version": "4.17.21",
+      "resolved": "http://192.168.1.90:4873/lodash-es/-/lodash-es-4.17.21.tgz",
+      "integrity": "sha1-Q+YmxG5lkbd1C+srUBFzkMYJ4+4="
+    },
     "lodash._reinterpolate": {
       "version": "3.0.0",
       "resolved": "http://192.168.1.90:4873/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz",
@@ -9856,6 +9992,14 @@
         "triple-beam": "^1.3.0"
       }
     },
+    "loose-envify": {
+      "version": "1.4.0",
+      "resolved": "http://192.168.1.90:4873/loose-envify/-/loose-envify-1.4.0.tgz",
+      "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+      "requires": {
+        "js-tokens": "^3.0.0 || ^4.0.0"
+      }
+    },
     "lru-cache": {
       "version": "6.0.0",
       "resolved": "http://192.168.1.90:4873/lru-cache/-/lru-cache-6.0.0.tgz",
@@ -10136,9 +10280,9 @@
       }
     },
     "mitt": {
-      "version": "3.0.0",
-      "resolved": "http://192.168.1.90:4873/mitt/-/mitt-3.0.0.tgz",
-      "integrity": "sha1-ae+b1cgP9vV0c+jYkybQHEFL4L0="
+      "version": "2.1.0",
+      "resolved": "http://192.168.1.90:4873/mitt/-/mitt-2.1.0.tgz",
+      "integrity": "sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg=="
     },
     "mixin-deep": {
       "version": "1.3.2",
@@ -10292,6 +10436,11 @@
         }
       }
     },
+    "nanopop": {
+      "version": "2.1.0",
+      "resolved": "http://192.168.1.90:4873/nanopop/-/nanopop-2.1.0.tgz",
+      "integrity": "sha512-jGTwpFRexSH+fxappnGQtN9dspgE2ipa1aOjtR24igG0pv6JCxImIAmrLRHX+zUF5+1wtsFVbKyfP51kIGAVNw=="
+    },
     "natural-compare": {
       "version": "1.4.0",
       "resolved": "http://192.168.1.90:4873/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -10514,6 +10663,14 @@
         "path-key": "^2.0.0"
       }
     },
+    "numbro": {
+      "version": "2.3.5",
+      "resolved": "http://192.168.1.90:4873/numbro/-/numbro-2.3.5.tgz",
+      "integrity": "sha1-q0kb1e5q83Mca9sRIxrRnS7k/HI=",
+      "requires": {
+        "bignumber.js": "^8.1.1"
+      }
+    },
     "nwsapi": {
       "version": "2.2.0",
       "resolved": "http://192.168.1.90:4873/nwsapi/-/nwsapi-2.2.0.tgz",
@@ -10630,6 +10787,11 @@
         "es-abstract": "^1.19.1"
       }
     },
+    "omit.js": {
+      "version": "2.0.2",
+      "resolved": "http://192.168.1.90:4873/omit.js/-/omit.js-2.0.2.tgz",
+      "integrity": "sha512-hJmu9D+bNB40YpL9jYebQl4lsTW6yEHRTroJzNLqQJYHm7c+NQnJGfZmIWh8S3q3KoaxV1aLhV6B3+0N0/kyJg=="
+    },
     "on-finished": {
       "version": "2.3.0",
       "resolved": "http://192.168.1.90:4873/on-finished/-/on-finished-2.3.0.tgz",
@@ -11065,6 +11227,14 @@
       "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==",
       "dev": true
     },
+    "pikaday": {
+      "version": "1.5.1",
+      "resolved": "http://192.168.1.90:4873/pikaday/-/pikaday-1.5.1.tgz",
+      "integrity": "sha1-CkhUm8GhTqHQjEQHTXYbwvK/z9M=",
+      "requires": {
+        "moment": "2.x"
+      }
+    },
     "pinkie": {
       "version": "2.0.4",
       "resolved": "http://192.168.1.90:4873/pinkie/-/pinkie-2.0.4.tgz",
@@ -11837,8 +12007,7 @@
     "regenerator-runtime": {
       "version": "0.13.9",
       "resolved": "http://192.168.1.90:4873/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz",
-      "integrity": "sha1-iSV0Kpj/2QgUmI11Zq0wyjsmO1I=",
-      "dev": true
+      "integrity": "sha1-iSV0Kpj/2QgUmI11Zq0wyjsmO1I="
     },
     "regex-not": {
       "version": "1.0.2",
@@ -12351,6 +12520,14 @@
         "ajv-keywords": "^3.1.0"
       }
     },
+    "scroll-into-view-if-needed": {
+      "version": "2.2.28",
+      "resolved": "http://192.168.1.90:4873/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.28.tgz",
+      "integrity": "sha1-WhWy9YpSZCyIyOylhGROAXA9ZFo=",
+      "requires": {
+        "compute-scroll-into-view": "^1.0.17"
+      }
+    },
     "semver": {
       "version": "5.7.1",
       "resolved": "http://192.168.1.90:4873/semver/-/semver-5.7.1.tgz",
@@ -12404,6 +12581,11 @@
         "safe-buffer": "^5.0.1"
       }
     },
+    "shallow-equal": {
+      "version": "1.2.1",
+      "resolved": "http://192.168.1.90:4873/shallow-equal/-/shallow-equal-1.2.1.tgz",
+      "integrity": "sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA=="
+    },
     "shebang-command": {
       "version": "1.2.0",
       "resolved": "http://192.168.1.90:4873/shebang-command/-/shebang-command-1.2.0.tgz",
@@ -14504,6 +14686,21 @@
         "vscode-vue-languageservice": "^0.27.0"
       }
     },
+    "vue-types": {
+      "version": "3.0.2",
+      "resolved": "http://192.168.1.90:4873/vue-types/-/vue-types-3.0.2.tgz",
+      "integrity": "sha1-7BbgXUEsA4Ji/B76TOuWR+f7YB0=",
+      "requires": {
+        "is-plain-object": "3.0.1"
+      },
+      "dependencies": {
+        "is-plain-object": {
+          "version": "3.0.1",
+          "resolved": "http://192.168.1.90:4873/is-plain-object/-/is-plain-object-3.0.1.tgz",
+          "integrity": "sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g=="
+        }
+      }
+    },
     "w3c-hr-time": {
       "version": "1.0.2",
       "resolved": "http://192.168.1.90:4873/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",
@@ -14531,6 +14728,14 @@
         "makeerror": "1.0.12"
       }
     },
+    "warning": {
+      "version": "4.0.3",
+      "resolved": "http://192.168.1.90:4873/warning/-/warning-4.0.3.tgz",
+      "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
+      "requires": {
+        "loose-envify": "^1.0.0"
+      }
+    },
     "watchpack": {
       "version": "1.7.5",
       "resolved": "http://192.168.1.90:4873/watchpack/-/watchpack-1.7.5.tgz",

+ 5 - 1
package.json

@@ -14,13 +14,17 @@
   "dependencies": {
     "@midwayjs/hooks": "^2.2.2",
     "@midwayjs/koa": "^2.11.0",
+    "@sc/handsontable": "^6.3.11",
+    "@sc/tree": "^1.0.18",
+    "@sc/util": "^1.0.9",
     "animate.css": "^4.1.1",
     "animejs": "^3.2.1",
+    "ant-design-vue": "^2.2.8",
     "echarts": "^5.2.2",
     "element-plus": "^1.1.0-beta.24",
     "koa-bodyparser": "^4.3.0",
     "lodash": "^4.17.21",
-    "mitt": "^3.0.0",
+    "mitt": "^2.1.0",
     "vue": "^3.2.16",
     "vue-router": "^4.0.12"
   },

+ 226 - 0
src/components/context-menu/ContextMenu.vue

@@ -0,0 +1,226 @@
+<template>
+  <div class="context-menu" @click="visible = false">
+    <a-dropdown
+        :trigger="['contextmenu']"
+        :overlayClassName="`context-menu-overlay ${randomClass}`"
+        v-model:visible="visible"
+        ref="dropdownRef"
+    >
+      <div class="trigger-area" ref="triggerAreaRef">
+        <slot></slot>
+      </div>
+      <template #overlay v-if="!disabled">
+        <a-menu>
+          <context-sub-menu @hide="visible = false" :menu-groups="menuGroups"/>
+        </a-menu>
+      </template>
+    </a-dropdown>
+  </div>
+</template>
+
+<script lang="ts">
+import {defineComponent, ref, PropType, onMounted, watchEffect} from 'vue';
+import {Dropdown, Menu} from 'ant-design-vue';
+import {isFunction} from 'lodash';
+import {on, query} from 'utils/frontend/dom';
+import ContextSubMenu from './ContextSubMenu.vue';
+
+/*
+对于每个菜单项的 text,我们将其拆解成一个个的 widget
+如:菜单项1-1{%input[type=text][value=张三]%}行
+*/
+interface IWidget {
+  // 普通文本或者输入框
+  component: 'text' | 'input';
+  // 文本值或者输入框(文本输入框或者数字输入框)的值
+  value?: string | number;
+  // 输入框的类型
+  type?: 'text' | 'number';
+  // 数字输入框最小值
+  min?: number;
+  // 数字输入框最大值
+  max?: number;
+  // 数字输入框步数
+  step?: number;
+  // 精度
+  precision?: number;
+  // 百分比
+  percent?: boolean;
+}
+
+interface IMenuItem extends Comp.ContextMenu.IMenuItem {
+  content: IWidget[];
+  submenu?: IMenuItem[][];
+}
+
+// 菜单项的每一项
+export type MenuItem = IMenuItem;
+// 菜单项的每一个 group
+export type MenuGroup = IMenuItem[];
+
+export default defineComponent({
+  name: 'ContextMenu',
+  components: {ContextSubMenu, ADropdown: Dropdown, AMenu: Menu},
+  props: {
+    // 菜单分组列表
+    itemGroups: {
+      type: Array as PropType<MenuGroup[]>,
+      required: true,
+    },
+    // 是否禁用上下文菜单(不显示)
+    disabled: {
+      type: Boolean,
+      default: false,
+    },
+    // 全局只读(会显示右键菜单,但是全部子项为 disabled)
+    readOnly: {
+      type: Boolean,
+      default: false,
+    },
+    // 触发区域是否根据子元素的大小而定
+    autoSize: {
+      type: Boolean,
+      default: false,
+    },
+  },
+  setup(props) {
+    // 用户传过来的 text 中可能含有包括 input 输入框的格式符,通过此函数可以将它们拆解出来,变成一个个的 widget
+    const getWidgets = (text: string) => {
+      const inputReg = /({%input.*?%})/;
+      const splits: string[] = text.split(inputReg);
+      const widgets: IWidget[] = [];
+      splits.forEach(item => {
+        if (inputReg.test(item)) {
+          // 匹配 input 模式字符串
+          const attrs = item.match(
+              /(\[type=(text|number)\])|(\[value=.*?\])|(\[min=.*?\])|(\[max=.*?\])|(\[step=.*?\])|(\[percent=.*?\])|(\[precision=.*?\])/g
+          );
+          const metaArr: Common.IStringIndex[] = [];
+          if (attrs) {
+            attrs.forEach(attr => {
+              const [key, val] = attr.replace('[', '').replace(']', '').split('=');
+              metaArr.push({
+                [key]: val,
+              });
+            });
+          }
+          let widget: IWidget = {component: 'input'};
+          metaArr.forEach((meta: any) => {
+            widget = Object.assign(widget, {...meta});
+          });
+          if (widget.type === 'number') {
+            widget.min = widget.min ? parseInt(`${widget.min}`, 10) : 1;
+            widget.max = widget.max ? parseInt(`${widget.max}`, 10) : 100;
+            widget.step = widget.step ? widget.step : 1;
+            widget.precision = widget.precision ? parseInt(`${widget.precision}`, 10) : 0;
+            widget.percent = `${widget.percent}` === 'true';
+            widget.value = widget.value ? parseFloat(`${widget.value}`) : widget.min;
+          } else if (widget.type === 'text') {
+            widget.value = widget.value ? widget.value : '';
+          }
+          widgets.push(widget);
+        } else if (item.trim() !== '') {
+          // 普通文本字符串
+          widgets.push({
+            component: 'text',
+            value: item,
+          });
+        }
+      });
+      return widgets;
+    };
+
+    // 递归求解父组件传过来的菜单分组数据
+    const reshapeGroups = async (groups: MenuGroup[]) => {
+      const menuGroups: MenuGroup[] = [];
+      for (const group of groups) {
+        const menuGroup: MenuGroup = [];
+        for (const item of group) {
+          const menuItem: MenuItem = {
+            text: isFunction(item.text) ? item.text() : item.text,
+            content: getWidgets(isFunction(item.text) ? item.text() : item.text),
+            icon: item.icon,
+            hidden: isFunction(item.hidden) ? item.hidden() : item.hidden,
+            // 全局只读优先级最高
+            // eslint-disable-next-line no-nested-ternary
+            disable: props.readOnly === true ? true : isFunction(item.disable) ? await item.disable() : item.disable,
+            callback: item.callback,
+            submenu: item.submenu ? await reshapeGroups(item.submenu) : undefined,
+          };
+          menuGroup.push(menuItem);
+        }
+        menuGroups.push(menuGroup);
+      }
+
+      return menuGroups;
+    };
+
+    // 菜单项数组
+    const menuGroups: any = ref([]);
+
+    // 菜单是否可见
+    const visible = ref(false);
+
+    // 每次右键的时候,都重新计算一下
+    const triggerAreaRef = ref<HTMLElement | null>(null);
+    onMounted(() => {
+      const triggerArea = triggerAreaRef.value;
+      if (triggerArea) {
+        on(triggerArea, 'contextmenu', async () => {
+          menuGroups.value = await reshapeGroups(props.itemGroups);
+        });
+
+        if (props.autoSize) {
+          triggerArea.style.height = 'auto';
+        }
+      }
+    });
+
+    const randomClass = `context-menu-overlay-${Math.random().toString().slice(2)}`;
+
+    // 禁用当前 dropdown menu 自身的系统右键上下文菜单
+    watchEffect(() => {
+      if (!visible.value) {
+        return;
+      }
+      setTimeout(() => {
+        const contextMenuOverlay = query(document.body, `.${randomClass}`) as HTMLElement;
+        if (contextMenuOverlay) {
+          contextMenuOverlay.oncontextmenu = (e: MouseEvent) => e.preventDefault();
+        }
+      }, 200);
+    });
+
+    // 手动隐藏,供外界调用
+    const Hide = () => {
+      visible.value = false;
+    };
+
+    return {
+      menuGroups,
+      visible,
+      randomClass,
+      triggerAreaRef,
+      Hide,
+    };
+  },
+});
+</script>
+
+<style lang="scss" scoped>
+.trigger-area {
+  position: relative;
+  width: 100%;
+  height: 100%;
+}
+
+.context-menu {
+  width: 100%;
+  height: 100%;
+}
+</style>
+<style lang="scss">
+.context-menu-overlay {
+  @include dropdown-menu(220px, auto, 190px);
+}
+</style>

+ 117 - 0
src/components/context-menu/ContextSubMenu.vue

@@ -0,0 +1,117 @@
+<template>
+  <template v-for="(group, groupIndex) in menuGroups" :key="groupIndex">
+    <template v-for="(item, itemIndex) in group" :key="itemIndex">
+      <!-- 有子菜单 -->
+      <template v-if="item.submenu">
+        <a-sub-menu :disabled="item.disable" v-if="!item.hidden" :key="item.text">
+          <template #title>
+            <span><iconfont :class="item.icon" />{{ item.text }}</span>
+          </template>
+          <context-sub-menu @hide="$emit('hide')" :menu-groups="item.submenu" />
+        </a-sub-menu>
+      </template>
+      <!-- 没有子菜单 -->
+      <template v-else>
+        <a-menu-item :disabled="item.disable" v-if="!item.hidden">
+          <div class="menu-item-wrap" @click="handleMenuItemClick(item)">
+            <iconfont :class="item.icon" />
+            <span v-for="(widget, index) in item.content" :key="index">
+              <!-- 普通文本 -->
+              <template v-if="widget.component === 'text'">
+                {{ widget.value }}
+              </template>
+              <!-- 文本输入框 -->
+              <template v-else-if="widget.component === 'input' && widget.type === 'text'">
+                <span @click.stop>
+                  <a-input v-model:value="widget.value" size="small" @pressEnter="handleMenuItemClick(item)" />
+                </span>
+              </template>
+              <!-- 数字输入框 -->
+              <template v-else-if="widget.component === 'input' && widget.type === 'number'">
+                <span @click.stop>
+                  <a-input-number
+                    :class="{ 'is-percent': widget.percent }"
+                    v-model:value="widget.value"
+                    :min="widget.min"
+                    :max="widget.max"
+                    :step="widget.step"
+                    :precision="widget.precision"
+                    :formatter="widget.percent ? value => `${value}%` : undefined"
+                    :parser="widget.percent ? value => value.replace('%', '') : undefined"
+                    @pressEnter="handleMenuItemClick(item)"
+                  />
+                </span>
+              </template>
+            </span>
+          </div>
+        </a-menu-item>
+      </template>
+    </template>
+    <a-menu-divider v-if="showDivider(groupIndex, menuGroups)" />
+  </template>
+</template>
+
+<script lang="ts">
+import { defineComponent, PropType } from 'vue';
+import { Menu, Input, InputNumber } from 'ant-design-vue';
+import { MenuItem, MenuGroup } from './ContextMenu.vue';
+
+export default defineComponent({
+  name: 'ContextSubMenu',
+  components: {
+    ASubMenu: Menu.SubMenu,
+    AMenuItem: Menu.Item,
+    AMenuDivider: Menu.Divider,
+    AInput: Input,
+    AInputNumber: InputNumber,
+  },
+  props: {
+    menuGroups: {
+      type: Array as PropType<MenuGroup[]>,
+      required: true,
+    },
+  },
+  emits: {
+    hide: null,
+  },
+  setup(props, context) {
+    // 点击某一个菜单项时调用其回调函数
+    const handleMenuItemClick = (item: MenuItem) => {
+      if (item.disable) return;
+
+      // 隐藏下拉菜单
+      context.emit('hide');
+      if (item.callback) {
+        const { content, callback } = item;
+        let inputVal: string | number | undefined = '';
+        if (content) {
+          content.forEach(widget => {
+            if (widget.component === 'input') {
+              inputVal = widget.value;
+            }
+          });
+        }
+        callback && callback(inputVal);
+      }
+    };
+    // 是否显示分割线
+    const showDivider = (groupIndex: number, menuGroups: MenuGroup[]) => {
+      if (groupIndex === menuGroups.length - 1) return false;
+      const hasVisibleItems = menuGroups[groupIndex].some(item => !item.hidden);
+      let notLastVisibleGroup = false;
+      for (let i = groupIndex + 1; i < menuGroups.length; i++) {
+        const visibleItems = menuGroups[i].some(item => !item.hidden);
+        if (visibleItems) {
+          notLastVisibleGroup = true;
+          break;
+        }
+      }
+      return hasVisibleItems && notLastVisibleGroup;
+    };
+    return {
+      handleMenuItemClick,
+      showDivider,
+    };
+  },
+});
+</script>

+ 7 - 0
src/components/index.ts

@@ -5,9 +5,16 @@ import type { App } from 'vue';
 import Iconfont from './iconfont/Iconfont.vue';
 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';
 
 export default function (app: App<Element>) {
     app.component(Iconfont.name, Iconfont);
     app.component(ResizableLayout.name, ResizableLayout);
     app.component(ResizableLayoutItem.name, ResizableLayoutItem);
+    app.component(ResizableLayoutItem.name, ResizableLayoutItem);
+    app.component(ContextMenu.name, ContextMenu);
+    app.component(ContextSubMenu.name, ContextSubMenu);
+    // app.component(Handsontable.name, Handsontable);
 }

+ 0 - 1
src/constants/emitter.ts

@@ -1,6 +1,5 @@
 /* mitt事件相关 */
 import mitt, { Emitter, Handler } from 'mitt';
-import tabStore from '@/store/modules/tabs';
 
 /**
  * mitt事件枚举,分发和监听的事件类型必须在此定义

+ 0 - 18
src/styles/_element_plus.scss

@@ -3,24 +3,6 @@
   $colors: (
     'white': #ffffff,
     'black': #000000,
-    //"primary": (
-    //  "base": $primary,
-    //),
-    //'success': (
-    //  'base': $success,
-    //),
-    //'warning': (
-    //  'base': $warning,
-    //),
-    //'danger': (
-    //  'base': $danger,
-    //),
-    //'error': (
-    //  'base': $danger,
-    //),
-    //'info': (
-    //  'base': $info,
-    //),
   ),
   $font-path: 'element-plus/theme-chalk/fonts',
   $sm: 1140px,

+ 241 - 6
src/styles/_mixin.scss

@@ -4,10 +4,245 @@
   @return ($px/$rem) + rem;
 }
 
-// mixin 示例
-@mixin center {
-  position: absolute;
-  left: 50%;
-  top: 50%;
-  transform: translate(-50%, -50%);
+@mixin dropdown-menu($width:auto, $min-width:auto, $submenu-min-width:auto) {
+  z-index: 2050; /* 没有这行则 抽屉无法显示右键菜单 */
+  width: $width;
+  min-width: $min-width !important;
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.12), 0 0 6px rgba(0, 0, 0, 0.04), 0 2px 8px rgba(0, 0, 0, 0.15);
+  background: #fff;
+  border-radius: 4px;
+
+  .ant-dropdown-menu {
+    padding: 8px 0;
+    border-radius: 4px;
+
+    .ant-dropdown-menu-item {
+      line-height: 25px;
+      margin: 0;
+      padding: 0 16px;
+      font-size: 13px;
+      outline: 0;
+      display: flex;
+      align-items: center;
+      transition: 0.2s;
+      border-bottom: 1px solid transparent;
+      color: #606266 !important;
+      background: #fff;
+
+      .ant-dropdown-menu-title-content {
+        width: 100%;
+      }
+
+      .menu-item-wrap {
+        display: flex;
+        flex-wrap: wrap;
+        align-items: center;
+        width: 100%;
+        padding: 0 !important;
+
+        .ant-input {
+          width: 40px;
+          height: 24px;
+          margin: 0 5px;
+          line-height: 1.4;
+          padding: 1px 3px !important;
+          font-size: 12px;
+          text-align: center;
+        }
+
+        .ant-input-number {
+          width: 40px;
+          margin: 0 5px;
+          font-size: 12px;
+          vertical-align: middle;
+
+          &.is-percent {
+            width: 60px;
+          }
+
+          .ant-input-number-input-wrap {
+            height: 24px;
+
+            .ant-input-number-input {
+              height: 100%;
+              font-size: 12px;
+              vertical-align: top;
+              padding: 0 16px 0 0;
+              text-align: center;
+            }
+          }
+
+          .ant-input-number-handler-wrap {
+            width: 16px;
+            opacity: 1;
+
+            &:hover {
+              .ant-input-number-handler {
+                height: 50%;
+              }
+            }
+
+            .ant-input-number-handler-down {
+              display: flex;
+              align-items: center;
+              justify-content: center;
+
+              &:hover {
+                height: 50% !important;
+              }
+
+              .ant-input-number-handler-down-inner {
+                position: static;
+                margin-top: 0;
+              }
+            }
+
+            .ant-input-number-handler-up {
+              display: flex;
+              align-items: center;
+              justify-content: center;
+
+              &:hover {
+                height: 50% !important;
+              }
+
+              .ant-input-number-handler-up-inner {
+                position: static;
+                margin-top: 0;
+              }
+            }
+          }
+        }
+      }
+
+      &:hover {
+        background: #ecf5ff;
+        color: #409eff !important;
+
+        .iconfont {
+          color: #409eff;
+        }
+      }
+
+      &-disabled {
+        color: #c0c4cc !important;
+
+        &:hover {
+          background: #fff;
+          color: #c0c4cc !important;
+
+          .iconfont {
+            color: #c0c4cc !important;
+          }
+        }
+      }
+
+      .iconfont {
+        display: inline-block;
+        width: 14px;
+        font-size: 13px;
+        margin-right: 8px;
+      }
+    }
+  }
+
+  .ant-dropdown-menu-submenu {
+
+    &-disabled {
+      .ant-dropdown-menu-submenu-title {
+        color: #c0c4cc !important;
+
+        &:hover {
+          background: #fff;
+          color: #c0c4cc !important;
+
+          .iconfont {
+            color: #c0c4cc !important;
+          }
+
+          .ant-dropdown-menu-submenu-arrow-icon {
+            color: #c0c4cc !important;
+          }
+        }
+
+        .ant-dropdown-menu-submenu-arrow-icon {
+          color: #c0c4cc !important;
+        }
+      }
+    }
+
+    &-title {
+      line-height: 32px;
+      margin: 0;
+      outline: 0;
+      padding: 0 16px !important;
+      display: flex;
+      align-items: center;
+      font-size: 13px;
+      color: #606266 !important;
+      border-bottom: 1px solid transparent;
+      transition: 0.2s;
+      background: #fff;
+
+      &:hover {
+        background: #ecf5ff;
+        color: #409eff !important;
+
+        .iconfont {
+          color: #409eff;
+        }
+
+        .ant-dropdown-menu-submenu-arrow-icon {
+          color: #409eff !important;
+        }
+      }
+
+      .iconfont {
+        display: inline-block;
+        width: 14px;
+        font-size: 13px;
+        margin-right: 8px;
+      }
+
+      .ant-dropdown-menu-submenu-arrow {
+        &-icon {
+          display: flex;
+          align-items: center;
+          margin-top: 1px;
+          width: 14px;
+          height: 14px;
+
+          > svg {
+            display: none;
+          }
+
+          &::after {
+            font-family: 'iconfont';
+            content: '\e605';
+            font-size: 18px;
+          }
+
+          font-size: 14px;
+          font-weight: 800;
+          color: #606266 !important;
+        }
+      }
+    }
+
+    > .ant-dropdown-menu-submenu-content {
+      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.12), 0 0 6px rgba(0, 0, 0, 0.04), 0 2px 8px rgba(0, 0, 0, 0.15);
+    }
+  }
+
+  .ant-dropdown-menu-submenu-popup {
+    min-width: $submenu-min-width;
+
+    .ant-dropdown-menu-sub {
+      margin: 0;
+    }
+  }
+
+  .ant-dropdown-menu-item-divider {
+    margin: 0;
+  }
 }

+ 73 - 0
src/utils/common/common.ts

@@ -0,0 +1,73 @@
+/* eslint-disable import/prefer-default-export */
+export const replaceAll = (FindText: RegExp | string, RepText: string, str: string): string => {
+  const regExp = new RegExp(FindText, 'g');
+  return str.replace(regExp, RepText);
+};
+
+// 全局替换 字符串(处理字符串内有需要转义的字符 如:'()')
+export const replaceStrAll = (replaceThis: string, withThis: string, inThis: string): string => {
+  withThis = withThis.replace(/\$/g, '$$$$');
+  return inThis.replace(
+    // eslint-disable-next-line no-useless-escape
+    new RegExp(replaceThis.replace(/([\/\,\!\\\^\$\{\}\[\]\(\)\.\*\+\?\|<>\-\&])/g, '\\$&'), 'g'),
+    withThis
+  );
+};
+
+// 是否未定义
+export const isDef = (value: any): boolean => {
+  return value !== undefined && value !== null;
+};
+
+// 是否为空值
+export const isEmptyVal = (val: any): boolean => {
+  return val === null || val === undefined || val === '';
+};
+
+export const getPatten = (decimal: number) => {
+  const header = '0,0.';
+  return header + '00000000'.substr(0, decimal);
+};
+
+// 判断是否数字
+export const isNumeric = (n: any) => {
+  /* eslint-disable */
+  var t = typeof n;
+
+  return t == 'number'
+    ? !isNaN(n) && isFinite(n)
+    : t == 'string'
+    ? !n.length
+      ? false
+      : n.length == 1
+      ? /\d/.test(n)
+      : /^\s*[+-]?\s*(?:(?:\d+(?:\.\d+)?(?:e[+-]?\d+)?)|(?:0x[a-f\d]+))\s*$/i.test(n)
+    : t == 'object'
+    ? !!n && typeof n.valueOf() == 'number' && !(n instanceof Date)
+    : false;
+};
+
+// 判断数据库中的某个字段是否为空
+export const isEmptyForDB = (obj: any) => {
+  return obj == null || obj === -1 || obj === '';
+};
+
+// 判断是否过期
+export const isExpired = (deadline:number)=> {
+  // deadline 为 0 表示无限制
+  if (deadline !== 0) {
+    return Date.now() >= deadline + 24 * 60 * 60 * 1000;
+  }
+  return false;
+}
+
+const canvas = document.createElement("canvas");
+
+export const getTextWidth = (text:string, font :string)=> {
+  const context = canvas.getContext("2d") as CanvasRenderingContext2D;
+  context.font = font;
+  const metrics = context.measureText(text);
+  return metrics.width;
+}
+
+

+ 199 - 0
src/utils/frontend/dom.ts

@@ -0,0 +1,199 @@
+/* == class 相关 == */
+const any = (obj: any) => obj;
+
+// 添加 class
+export const addClass = (element: Element, ...className: string[]): void => {
+  element.classList.add(...className);
+};
+
+// 移除 class
+export const removeClass = (element: Element, ...className: string[]): void => {
+  element.classList.remove(...className);
+};
+
+// 是否包含指定 class
+export const hasClass = (element: Element, className: string): boolean => {
+  return element.classList.contains(className);
+};
+
+// 切换指定 class
+// 若为移除,则返回 false;若为添加,则返回 true
+export const toggleClass = (element: Element, className: string): boolean => {
+  return element.classList.toggle(className);
+};
+
+/* == DOM 相关 == */
+
+// 查找第一个指定的css选择器
+export const query = (startNode: Element, cssSelector: string): Element | null => {
+  return startNode.querySelector(cssSelector);
+};
+
+// 查找所有指定的css选择器
+export const queryAll = (startNode: Element, cssSelector: string): NodeListOf<Element> => {
+  return (startNode || document).querySelectorAll(cssSelector);
+};
+
+// 返回或者设置 innerHTML
+// eslint-disable-next-line consistent-return
+export const html = (element: Element, innerHTML?: string): string | void => {
+  if (typeof innerHTML === 'undefined') {
+    return element.innerHTML;
+  }
+  element.innerHTML = innerHTML;
+};
+
+// 返回或者设置 innerText
+// eslint-disable-next-line consistent-return
+export const text = (element: HTMLElement, innerText?: string): string | void => {
+  if (typeof innerText === 'undefined') {
+    return element.innerText;
+  }
+  element.innerText = innerText;
+};
+
+// 创建元素
+export const createElement = (tagName: string): HTMLElement => {
+  return document.createElement(tagName);
+};
+
+// 在container内部的后面挂载元素
+export const append = (container: Element, ...element: Element[]): void => {
+  container.append(...element);
+};
+
+// 在container内部的前面挂载元素
+export const prepend = (container: Element, ...element: Element[]): void => {
+  container.prepend(...element);
+};
+
+// 移除元素
+export const remove = (element: Element | null): void => {
+  element && element.remove();
+};
+
+// 绑定事件
+export const on = (element: Element, eventName: string, callback: (event: Event) => void, useCapture = false): void => {
+  element.addEventListener(eventName, callback, useCapture);
+};
+
+// 解绑事件
+export const off = (element: Element, eventName: string, callback: (event: Event) => void): void => {
+  element.removeEventListener(eventName, callback);
+};
+
+// hover
+export const hover = (element: Element, handlerIn: (e: Event) => void, handlerOut: (e: Event) => void): void => {
+  on(element, 'mouseenter', handlerIn);
+  on(element, 'mouseleave', handlerOut);
+};
+
+// wheel滚轮事件
+export const wheel = (element: Element, handlerWheel: (e: Event) => void): void => {
+  on(element, 'mousewheel', handlerWheel);
+};
+
+// 解析字符串生成DOM元素
+export const parse = (innerHTML: string): Element => {
+  const container = createElement('div');
+  html(container, innerHTML.trim());
+  return container.firstChild as Element;
+};
+
+// 设置元素样式
+export const style = (element: HTMLElement, styleObj: { [key in keyof CSSStyleDeclaration]?: string }): void => {
+  const keys = Object.keys(styleObj);
+  keys.forEach(key => {
+    any(element.style)[key] = any(styleObj)[key];
+  });
+};
+
+// 清空dom元素
+export const empty = (element: Element): void => {
+  let child;
+  // eslint-disable-next-line no-cond-assign
+  while ((child = element.lastChild)) {
+    element.removeChild(child);
+  }
+};
+
+// 获取元素的位置
+export const getElementOffset = (element: HTMLElement) => {
+  const offset = { left: 0, top: 0 };
+  let current: HTMLElement = element.offsetParent as any;
+
+  offset.left += element.offsetLeft;
+  offset.top += element.offsetTop;
+
+  while (current !== null) {
+    offset.left += current.offsetLeft;
+    offset.top += current.offsetTop;
+    current = current.offsetParent as any;
+  }
+  return offset;
+};
+
+/**
+ * Returns caret position in text input
+ *
+ * @author http://stackoverflow.com/questions/263743/how-to-get-caret-position-in-textarea
+ * @return {Number}
+ */
+export const getCaretPosition = (el: HTMLInputElement) => {
+  if (el.selectionStart) {
+    return el.selectionStart;
+  }
+  if ((document as any).selection) {
+    // IE8
+    el.focus();
+
+    const r = (document as any).selection.createRange();
+
+    if (r == null) {
+      return 0;
+    }
+    const re = (el as any).createTextRange();
+    const rc = re.duplicate();
+
+    re.moveToBookmark(r.getBookmark());
+    rc.setEndPoint('EndToStart', re);
+
+    return rc.text.length;
+  }
+
+  return 0;
+};
+
+/**
+ * Sets caret position in text input.
+ *
+ * @author http://blog.vishalon.net/index.php/javascript-getting-and-setting-caret-position-in-textarea/
+ * @param {Element} element
+ * @param {Number} pos
+ * @param {Number} endPos
+ */
+export const setCaretPosition = (element: HTMLInputElement, pos: number, endPos: number) => {
+  if (endPos === 0) {
+    endPos = pos;
+  }
+  if (element.setSelectionRange) {
+    element.focus();
+
+    try {
+      element.setSelectionRange(pos, endPos);
+    } catch (err) {
+      const elementParent = element.parentNode as HTMLElement;
+      const parentDisplayValue = elementParent.style.display;
+      elementParent.style.display = 'block';
+      element.setSelectionRange(pos, endPos);
+      elementParent.style.display = parentDisplayValue;
+    }
+  } else if ((element as any).createTextRange) {
+    // IE8
+    const range = (element as any).createTextRange();
+    range.collapse(true);
+    range.moveEnd('character', endPos);
+    range.moveStart('character', pos);
+    range.select();
+  }
+};

+ 0 - 1
src/views/project/summary/Summary.vue

@@ -20,7 +20,6 @@ const handleProjectExplorerSize = (size: number) => {
 const handleMainContentSize = (size: number) => {
   mainContentSize.value = size
 }
-
 const handleMainLeftSize = (size: number) => {
   mainLeftSize.value = size
 }

+ 130 - 8
src/views/project/summary/components/project-explorer/ProjectExplorer.vue

@@ -1,16 +1,138 @@
 <script setup lang="ts">
-import {onMounted, reactive, ref} from "vue";
+import {onMounted, reactive, ref, PropType} from "vue";
+
+const props = defineProps({
+  data: {
+    type: Array as PropType<SubjectTreeNode[]>,
+    required: true,
+  },
+})
+// 上下文菜单设置
+const menuGroup: Comp.ContextMenu.MenuGroup[] = [
+  [
+    {
+      text: '新建单项工程',
+      icon: 'dsk-home-two-add',
+      disable() {
+        return false;
+      },
+      callback() {
+        console.log('新建单项工程')
+      },
+    },
+    {
+      text: '新建单位工程',
+      icon: 'dsk-file-addition-one',
+      disable() {
+        return false;
+      },
+      callback() {
+        console.log('新建单位工程')
+      },
+    },
+    {
+      text: '重命名',
+      icon: 'dsk-edit',
+      disable() {
+        return false;
+      },
+      callback() {
+        console.log('重命名')
+      },
+    },
+    {
+      text: '删除',
+      icon: 'dsk-delete-one',
+      disable() {
+        return false;
+      },
+      callback() {
+        console.log('删除')
+      },
+    },
+    {
+      text: '分享协作',
+      icon: 'dsk-peoples',
+      disable() {
+        return false;
+      },
+      callback() {
+        console.log('分享协作')
+      },
+    },
+  ],
+];
+
+const readOnly = ref(false)
+const loading = ref(false)
+
+const data = [
+  {
+    label: '市妇幼保健院新址配套市政道路',
+    icon: 'dsk-city-one',
+    children: [
+      {
+        label: '单项工程1',
+        icon: 'dsk-home-two',
+        children: [
+          {
+            label: '单位工程1.1',
+          },
+          {
+            label: '单位工程1.2',
+          },
+        ],
+      },
+      {
+        label: '单项工程2',
+        icon: 'dsk-home-two',
+        children: [
+          {
+            label: '单位工程2.1',
+          },
+          {
+            label: '单位工程2.2',
+          },
+        ],
+      },
+    ],
+  },
+]
 </script>
 
 <template>
   <section class="project-explorer">
-      <ul class="tabs">
-        <li class="tab active">原始结构</li>
-        <li class="tab">整理结构</li>
-      </ul>
-      <div class="tree-wrap">
-          树结构
-      </div>
+    <ul class="tabs">
+      <li class="tab active">原始结构</li>
+      <li class="tab">整理结构</li>
+    </ul>
+    <div class="tree-wrap">
+      <context-menu :item-groups="menuGroup" ref="contextMenuRef" :readOnly="readOnly" auto-size>
+        <el-tree
+            class="subject-tree"
+            v-loading="loading"
+            icon-class="el-icon-arrow-right"
+            :data="data"
+            default-expand-all
+            :expand-on-click-node="false"
+            node-key="id"
+            highlight-current
+            :current-node-key="currentNodeKey"
+            @node-click="handleNodeClick"
+            @node-contextmenu="handleNodeContextmenu"
+            ref="treeRef"
+        >
+          <template #default="{ data }">
+            <span class="custom-tree-node">
+              <iconfont class="subject-icon" :class="data.icon"/>
+              <span class="text-wrap">
+                <i class="text" :title="data.label">{{ data.label }}</i>
+              </span>
+            </span>
+          </template>
+        </el-tree>
+      </context-menu>
+    </div>
   </section>
 </template>
 

+ 76 - 0
src/views/project/summary/components/project-explorer/style.scss

@@ -43,5 +43,81 @@
     padding: 8px;
     height: calc(100% - 36px);
     overflow: hidden;
+
+    .subject-tree {
+      ::v-deep .el-tree-node {
+        width: 100%;
+
+        &.is-current {
+          > .el-tree-node__content {
+            > .custom-tree-node {
+              color: $text-primary;
+
+              > .subject-icon {
+                color: $text-primary;
+              }
+
+              > .share-icon {
+                color: $text-primary;
+              }
+            }
+          }
+        }
+
+        > .el-tree-node__content {
+          width: 100%;
+          height: 28px;
+
+          > .el-tree-node__expand-icon {
+            display: inline-block;
+            width: 26px;
+            height: 26px;
+            font-size: 14px;
+            padding: 6px;
+            margin: 0;
+
+            &.is-leaf {
+              color: transparent;
+            }
+          }
+
+          > .custom-tree-node {
+            display: flex;
+            align-items: center;
+            width: calc(100% - 30px);
+            height: 32px;
+            line-height: 32px;
+            border-radius: 0;
+            user-select: none;
+            padding-right: 7px;
+            margin-left: 4px;
+            overflow: hidden;
+
+            > .subject-icon {
+              flex: 0 0 auto;
+              margin-right: 5px;
+            }
+
+            > .text-wrap {
+              flex: 1 1 auto;
+              overflow: hidden;
+              text-overflow: ellipsis;
+              white-space: nowrap;
+
+              .text {
+                height: 13px;
+                line-height: 13px;
+              }
+            }
+
+            > .share-icon {
+              flex: 0 0 16px;
+              line-height: 16px;
+              color: $primary;
+            }
+          }
+        }
+      }
+    }
   }
 }

+ 47 - 1
src/views/project/summary/components/tool-bar/ToolBar.vue

@@ -4,7 +4,53 @@ import {onMounted, reactive, ref} from "vue";
 
 <template>
   <section class="tool-bar">
-    工具栏
+    <div class="tools">
+      <el-dropdown trigger="click">
+        <el-tooltip content="导入" placement="top" :offset="10">
+          <el-button size="small" class="submenu">
+            <iconfont class="dsk-upload"/>
+          </el-button>
+        </el-tooltip>
+        <template #dropdown>
+          <el-dropdown-menu>
+            <el-dropdown-item command="item1">这是菜单1</el-dropdown-item>
+            <el-dropdown-item command="item2">这是菜单2</el-dropdown-item>
+          </el-dropdown-menu>
+        </template>
+      </el-dropdown>
+      <el-tooltip content="删除" placement="bottom">
+        <el-button size="small">
+          <iconfont class="dsk-close"/>
+        </el-button>
+      </el-tooltip>
+      <el-tooltip content="升级" placement="bottom">
+        <el-button size="small">
+          <iconfont class="dsk-arrow-left"/>
+        </el-button>
+      </el-tooltip>
+      <el-tooltip content="降级" placement="bottom">
+        <el-button size="small">
+          <iconfont class="dsk-arrow-right"/>
+        </el-button>
+      </el-tooltip>
+      <el-tooltip content="上移" placement="bottom">
+        <el-button size="small">
+          <iconfont class="dsk-arrow-up"/>
+        </el-button>
+      </el-tooltip>
+      <el-tooltip content="下移" placement="bottom">
+        <el-button size="small">
+          <iconfont class="dsk-arrow-down"/>
+        </el-button>
+      </el-tooltip>
+    </div>
+    <div class="menus">
+      <span class="menu-item">标准清单</span>
+      <span class="menu-item">定额库</span>
+      <span class="menu-item toggle-icon">
+        <iconfont class="icon dsk-menu-unfold"/>
+      </span>
+    </div>
   </section>
 </template>
 

+ 44 - 2
src/views/project/summary/components/tool-bar/style.scss

@@ -1,8 +1,50 @@
 .tool-bar {
-  @apply flex items-center;
+  @apply flex items-center justify-between;
   background: #fff;
   height: 36px;
-  padding-left: 20px;
+  padding: 0 8px;
   margin-bottom: 5px;
   border: 1px solid #e8eaec;
+
+  .tools {
+    .el-button {
+      height: 34px;
+      line-height: 16px;
+      min-height: auto;
+      border: none;
+      padding: 4px 10px;
+      border-radius: 0;
+      margin-right: 6px;
+
+      &.is-disabled {
+        color: #8a9194;
+      }
+
+      & + .el-button {
+        margin-left: 0;
+      }
+
+      &:hover {
+        background-color: #edeff5;
+      }
+
+      .iconfont {
+        vertical-align: bottom;
+      }
+    }
+  }
+
+  .menus {
+    height: 34px;
+    line-height: 34px;
+
+    .menu-item {
+      @apply inline-block align-top cursor-pointer;
+      margin-left: 10px;
+      padding: 0 8px;
+      .icon {
+        font-size: 18px;
+      }
+    }
+  }
 }

+ 7 - 1
tsconfig.json

@@ -20,10 +20,16 @@
     "sourceMap": true,
     "experimentalDecorators": true,
     "emitDecoratorMetadata": true,
+    "baseUrl": ".",
     "types": [
       "vite/client",
       "jest"
-    ]
+    ],
+    "paths": {
+      "@/*": [
+        "src/*"
+      ]
+    }
   },
   "include": [
     "src/**/*.ts",