Forráskód Böngészése

feat: tab与菜单联动完成

xyh 2 éve
szülő
commit
0f22b0b7fb

+ 1 - 1
.eslintrc.js

@@ -26,7 +26,7 @@ module.exports = {
     extraFileExtensions: ['.vue'],
   },
   rules: {
-    'vue/v-on-event-hyphenation': [1, 'never'],
+    curly: [1, 'multi-or-nest', 'consistent'],
   },
   overrides: [
     {

+ 8 - 1
package.json

@@ -76,13 +76,18 @@
   },
   "dependencies": {
     "@icon-park/vue-next": "^1.4.2",
+    "@imengyu/vue3-context-menu": "^1.2.6",
     "@tanstack/vue-query": "^4.29.7",
     "@vee-validate/zod": "^4.9.3",
     "@vueuse/core": "^10.1.2",
+    "@vueuse/integrations": "^10.1.2",
     "axios": "^1.4.0",
     "dayjs": "^1.11.7",
+    "klona": "^2.0.6",
+    "lodash-es": "^4.17.21",
     "naive-ui": "^2.34.3",
     "pinia": "^2.0.36",
+    "sortablejs": "^1.15.0",
     "veboundary": "1.2.2",
     "vee-validate": "^4.9.3",
     "vue": "^3.3.2",
@@ -94,8 +99,10 @@
     "@commitlint/cli": "^17.6.3",
     "@commitlint/config-conventional": "^17.6.3",
     "@nabla/vite-plugin-eslint": "^1.5.0",
+    "@types/lodash-es": "^4.17.7",
     "@types/node": "20.1.4",
     "@types/rollup-plugin-visualizer": "^4.2.1",
+    "@types/sortablejs": "^1.15.1",
     "@typescript-eslint/eslint-plugin": "^5.59.5",
     "@typescript-eslint/parser": "^5.59.5",
     "@vitejs/plugin-vue": "^4.2.3",
@@ -109,7 +116,7 @@
     "editorconfig": "^1.0.2",
     "eslint": "^8.40.0",
     "eslint-config-prettier": "^8.8.0",
-    "eslint-config-proste": "^7.0.0",
+    "eslint-config-proste": "^7.1.0",
     "eslint-plugin-cypress": "^2.13.3",
     "eslint-plugin-import": "^2.27.5",
     "eslint-plugin-import-newlines": "^1.3.1",

+ 135 - 9
pnpm-lock.yaml

@@ -4,6 +4,9 @@ dependencies:
   '@icon-park/vue-next':
     specifier: ^1.4.2
     version: 1.4.2(vue@3.3.2)
+  '@imengyu/vue3-context-menu':
+    specifier: ^1.2.6
+    version: 1.2.6
   '@tanstack/vue-query':
     specifier: ^4.29.7
     version: 4.29.7(vue@3.3.2)
@@ -13,18 +16,30 @@ dependencies:
   '@vueuse/core':
     specifier: ^10.1.2
     version: 10.1.2(vue@3.3.2)
+  '@vueuse/integrations':
+    specifier: ^10.1.2
+    version: 10.1.2(axios@1.4.0)(sortablejs@1.15.0)(vue@3.3.2)
   axios:
     specifier: ^1.4.0
     version: 1.4.0
   dayjs:
     specifier: ^1.11.7
     version: 1.11.7
+  klona:
+    specifier: ^2.0.6
+    version: 2.0.6
+  lodash-es:
+    specifier: ^4.17.21
+    version: 4.17.21
   naive-ui:
     specifier: ^2.34.3
     version: 2.34.3(vue@3.3.2)
   pinia:
     specifier: ^2.0.36
     version: 2.0.36(typescript@5.0.4)(vue@3.3.2)
+  sortablejs:
+    specifier: ^1.15.0
+    version: 1.15.0
   veboundary:
     specifier: 1.2.2
     version: 1.2.2(vue@3.3.2)
@@ -54,12 +69,18 @@ devDependencies:
   '@nabla/vite-plugin-eslint':
     specifier: ^1.5.0
     version: 1.5.0(eslint@8.40.0)(vite@4.3.5)
+  '@types/lodash-es':
+    specifier: ^4.17.7
+    version: 4.17.7
   '@types/node':
     specifier: 20.1.4
     version: 20.1.4
   '@types/rollup-plugin-visualizer':
     specifier: ^4.2.1
     version: 4.2.1
+  '@types/sortablejs':
+    specifier: ^1.15.1
+    version: 1.15.1
   '@typescript-eslint/eslint-plugin':
     specifier: ^5.59.5
     version: 5.59.5(@typescript-eslint/parser@5.59.5)(eslint@8.40.0)(typescript@5.0.4)
@@ -100,8 +121,8 @@ devDependencies:
     specifier: ^8.8.0
     version: 8.8.0(eslint@8.40.0)
   eslint-config-proste:
-    specifier: ^7.0.0
-    version: 7.0.0(@typescript-eslint/eslint-plugin@5.59.5)(@typescript-eslint/parser@5.59.5)(eslint-plugin-import-newlines@1.3.1)(eslint-plugin-import@2.27.5)(eslint-plugin-vue@9.13.0)(eslint@8.40.0)
+    specifier: ^7.1.0
+    version: 7.1.0(@typescript-eslint/eslint-plugin@5.59.5)(@typescript-eslint/parser@5.59.5)(eslint-plugin-import-newlines@1.3.1)(eslint-plugin-import@2.27.5)(eslint-plugin-vue@9.13.0)(eslint@8.40.0)
   eslint-plugin-cypress:
     specifier: ^2.13.3
     version: 2.13.3(eslint@8.40.0)
@@ -1300,6 +1321,10 @@ packages:
       vue: 3.3.2
     dev: false
 
+  /@imengyu/vue3-context-menu@1.2.6:
+    resolution: {integrity: sha512-3JSwj9oQPiz6XDRoeDlFSrR7Yh4QGhf67aCEbRJR6Ld0GisHh9YRlsfKQSftPQdi+KNi3SOSZSXSH07z/RjTIg==}
+    dev: false
+
   /@intlify/core-base@9.2.2:
     resolution: {integrity: sha512-JjUpQtNfn+joMbrXvpR4hTF8iJQ2sEFzzK3KIESOx+f+uwIjgw20igOyaIdhfsVVBCds8ZM64MoeNSx+PHQMkA==}
     engines: {node: '>= 14'}
@@ -1509,11 +1534,9 @@ packages:
     resolution: {integrity: sha512-z0ptr6UI10VlU6l5MYhGwS4mC8DZyYer2mCoyysZtSF7p26zOX8UpbrV0YpNYLGS8K4PUFIyEr62IMFFjveSiQ==}
     dependencies:
       '@types/lodash': 4.14.194
-    dev: false
 
   /@types/lodash@4.14.194:
     resolution: {integrity: sha512-r22s9tAS7imvBt2lyHC9B8AGwWnXaYb1tY09oyLkXDs4vArpYJzw09nj8MLx5VfciBPGIb+ZwG0ssYnEPJxn/g==}
-    dev: false
 
   /@types/minimist@1.2.2:
     resolution: {integrity: sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==}
@@ -1550,6 +1573,10 @@ packages:
     resolution: {integrity: sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==}
     dev: true
 
+  /@types/sortablejs@1.15.1:
+    resolution: {integrity: sha512-g/JwBNToh6oCTAwNS8UGVmjO7NLDKsejVhvE4x1eWiPTC3uCuNsa/TD4ssvX3du+MLiM+SHPNDuijp8y76JzLQ==}
+    dev: true
+
   /@types/web-bluetooth@0.0.17:
     resolution: {integrity: sha512-4p9vcSmxAayx72yn70joFoL44c9MO/0+iVEBIQXe3v2h2SiAsEIo/G5v6ObFWvNKRFjbrVadNf9LqEEZeQPzdA==}
     dev: false
@@ -1795,7 +1822,7 @@ packages:
     dependencies:
       '@volar/language-core': 1.4.1
       '@volar/source-map': 1.4.1
-      '@vue/compiler-dom': 3.3.2
+      '@vue/compiler-dom': 3.3.4
       '@vue/compiler-sfc': 3.3.2
       '@vue/reactivity': 3.3.2
       '@vue/shared': 3.3.2
@@ -1843,12 +1870,28 @@ packages:
       estree-walker: 2.0.2
       source-map-js: 1.0.2
 
+  /@vue/compiler-core@3.3.4:
+    resolution: {integrity: sha512-cquyDNvZ6jTbf/+x+AgM2Arrp6G4Dzbb0R64jiG804HRMfRiFXWI6kqUVqZ6ZR0bQhIoQjB4+2bhNtVwndW15g==}
+    dependencies:
+      '@babel/parser': 7.21.8
+      '@vue/shared': 3.3.4
+      estree-walker: 2.0.2
+      source-map-js: 1.0.2
+    dev: true
+
   /@vue/compiler-dom@3.3.2:
     resolution: {integrity: sha512-6gS3auANuKXLw0XH6QxkWqyPYPunziS2xb6VRenM3JY7gVfZcJvkCBHkb5RuNY1FCbBO3lkIi0CdXUCW1c7SXw==}
     dependencies:
       '@vue/compiler-core': 3.3.2
       '@vue/shared': 3.3.2
 
+  /@vue/compiler-dom@3.3.4:
+    resolution: {integrity: sha512-wyM+OjOVpuUukIq6p5+nwHYtj9cFroz9cwkfmP9O1nzH68BenTTv0u7/ndggT8cIQlnBeOo6sUT/gvHcIkLA5w==}
+    dependencies:
+      '@vue/compiler-core': 3.3.4
+      '@vue/shared': 3.3.4
+    dev: true
+
   /@vue/compiler-sfc@3.3.2:
     resolution: {integrity: sha512-jG4jQy28H4BqzEKsQqqW65BZgmo3vzdLHTBjF+35RwtDdlFE+Fk1VWJYUnDMMqkFBo6Ye1ltSKVOMPgkzYj7SQ==}
     dependencies:
@@ -1869,6 +1912,14 @@ packages:
       '@vue/compiler-dom': 3.3.2
       '@vue/shared': 3.3.2
 
+  /@vue/compiler-ssr@3.3.4:
+    resolution: {integrity: sha512-m0v6oKpup2nMSehwA6Uuu+j+wEwcy7QmwMkVNVfrV9P2qE5KshC6RwOCq8fjGS/Eak/uNb8AaWekfiXxbBB6gQ==}
+    dependencies:
+      '@vue/compiler-dom': 3.3.4
+      '@vue/shared': 3.3.4
+    dev: true
+    optional: true
+
   /@vue/devtools-api@6.5.0:
     resolution: {integrity: sha512-o9KfBeaBmCKl10usN4crU53fYtC1r7jJwdGKjPT24t348rHxgfpZ0xL3Xm/gLUYnc0oTp8LAmrxOeLyu6tbk2Q==}
     dev: false
@@ -1909,9 +1960,24 @@ packages:
       '@vue/shared': 3.3.2
       vue: 3.3.2
 
+  /@vue/server-renderer@3.3.4(vue@3.3.2):
+    resolution: {integrity: sha512-Q6jDDzR23ViIb67v+vM1Dqntu+HUexQcsWKhhQa4ARVzxOY2HbC7QRW/ggkDBd5BU+uM1sV6XOAP0b216o34JQ==}
+    peerDependencies:
+      vue: 3.3.4
+    dependencies:
+      '@vue/compiler-ssr': 3.3.4
+      '@vue/shared': 3.3.4
+      vue: 3.3.2
+    dev: true
+    optional: true
+
   /@vue/shared@3.3.2:
     resolution: {integrity: sha512-0rFu3h8JbclbnvvKrs7Fe5FNGV9/5X2rPD7KmOzhLSUAiQH5//Hq437Gv0fR5Mev3u/nbtvmLl8XgwCU20/ZfQ==}
 
+  /@vue/shared@3.3.4:
+    resolution: {integrity: sha512-7OjdcV8vQ74eiz1TZLzZP4JwqM5fA94K6yntPS5Z25r9HDuGNzaGdgvwKYq6S+MxwF0TFRwe50fIR/MYnakdkQ==}
+    dev: true
+
   /@vue/test-utils@2.3.2(vue@3.3.2):
     resolution: {integrity: sha512-hJnVaYhbrIm0yBS0+e1Y0Sj85cMyAi+PAbK4JHqMRUZ6S622Goa+G7QzkRSyvCteG8wop7tipuEbHoZo26wsSA==}
     peerDependencies:
@@ -1920,8 +1986,8 @@ packages:
       js-beautify: 1.14.6
       vue: 3.3.2
     optionalDependencies:
-      '@vue/compiler-dom': 3.3.2
-      '@vue/server-renderer': 3.3.2(vue@3.3.2)
+      '@vue/compiler-dom': 3.3.4
+      '@vue/server-renderer': 3.3.4(vue@3.3.2)
     dev: true
 
   /@vueuse/core@10.1.2(vue@3.3.2):
@@ -1936,6 +2002,57 @@ packages:
       - vue
     dev: false
 
+  /@vueuse/integrations@10.1.2(axios@1.4.0)(sortablejs@1.15.0)(vue@3.3.2):
+    resolution: {integrity: sha512-wUpG3Wv6LiWerOwCzOAM0iGhNQ4vfFUTkhj/xQy7TLXduh2M3D8N08aS0KqlxsejY6R8NLxydDIM+68QfHZZ8Q==}
+    peerDependencies:
+      async-validator: '*'
+      axios: '*'
+      change-case: '*'
+      drauu: '*'
+      focus-trap: '*'
+      fuse.js: '*'
+      idb-keyval: '*'
+      jwt-decode: '*'
+      nprogress: '*'
+      qrcode: '*'
+      sortablejs: '*'
+      universal-cookie: '*'
+    peerDependenciesMeta:
+      async-validator:
+        optional: true
+      axios:
+        optional: true
+      change-case:
+        optional: true
+      drauu:
+        optional: true
+      focus-trap:
+        optional: true
+      fuse.js:
+        optional: true
+      idb-keyval:
+        optional: true
+      jwt-decode:
+        optional: true
+      nprogress:
+        optional: true
+      qrcode:
+        optional: true
+      sortablejs:
+        optional: true
+      universal-cookie:
+        optional: true
+    dependencies:
+      '@vueuse/core': 10.1.2(vue@3.3.2)
+      '@vueuse/shared': 10.1.2(vue@3.3.2)
+      axios: 1.4.0
+      sortablejs: 1.15.0
+      vue-demi: 0.14.1(vue@3.3.2)
+    transitivePeerDependencies:
+      - '@vue/composition-api'
+      - vue
+    dev: false
+
   /@vueuse/metadata@10.1.2:
     resolution: {integrity: sha512-3mc5BqN9aU2SqBeBuWE7ne4OtXHoHKggNgxZR2K+zIW4YLsy6xoZ4/9vErQs6tvoKDX6QAqm3lvsrv0mczAwIQ==}
     dev: false
@@ -3164,8 +3281,8 @@ packages:
       eslint: 8.40.0
     dev: true
 
-  /eslint-config-proste@7.0.0(@typescript-eslint/eslint-plugin@5.59.5)(@typescript-eslint/parser@5.59.5)(eslint-plugin-import-newlines@1.3.1)(eslint-plugin-import@2.27.5)(eslint-plugin-vue@9.13.0)(eslint@8.40.0):
-    resolution: {integrity: sha512-jBLmB3zyJTPthGHxrly1IwUKN93qTprpvGWn6OBSFvlpOa6DsXje8W2vN3uMzgErqrnsmY8XzhXbh0cTADi/ig==}
+  /eslint-config-proste@7.1.0(@typescript-eslint/eslint-plugin@5.59.5)(@typescript-eslint/parser@5.59.5)(eslint-plugin-import-newlines@1.3.1)(eslint-plugin-import@2.27.5)(eslint-plugin-vue@9.13.0)(eslint@8.40.0):
+    resolution: {integrity: sha512-i+w0HCOZxyN936cRHgEN7PV/218RP9YocJzMWTPizuU35jrvXemZ5yI7qSFZRGF1SGnzjObIW5u/ZV2YFxAFPA==}
     peerDependencies:
       '@typescript-eslint/eslint-plugin': '>=5.4.0'
       '@typescript-eslint/parser': '>=5.4.0'
@@ -4532,6 +4649,11 @@ packages:
     engines: {node: '>=0.10.0'}
     dev: true
 
+  /klona@2.0.6:
+    resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==}
+    engines: {node: '>= 8'}
+    dev: false
+
   /known-css-properties@0.27.0:
     resolution: {integrity: sha512-uMCj6+hZYDoffuvAJjFAPz56E9uoowFHmTkqRtRq5WyC5Q6Cu/fTZKNQpX/RbzChBYLLl3lo8CjFZBAZXq9qFg==}
     dev: true
@@ -6066,6 +6188,10 @@ packages:
       is-fullwidth-code-point: 4.0.0
     dev: true
 
+  /sortablejs@1.15.0:
+    resolution: {integrity: sha512-bv9qgVMjUMf89wAvM6AxVvS/4MX3sPeN0+agqShejLU5z5GX4C75ow1O2e5k4L6XItUyAK3gH6AxSbXrOM5e8w==}
+    dev: false
+
   /source-map-js@1.0.2:
     resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
     engines: {node: '>=0.10.0'}

+ 18 - 2
src/locales/home.ts

@@ -1,4 +1,20 @@
 export default {
-  zh: {menuBtn: {supplier: '供应商管理系统'}},
-  ko: {},
+  zh: {
+    menuBtn: {supplier: '供应商管理系统'},
+    contextMenu: {
+      close: '关闭当前标签',
+      closeRight: '关闭右侧标签',
+      closeOther: '关闭其他标签',
+      clear: '关闭所有标签',
+    },
+  },
+  ko: {
+    menuBtn: {supplier: '供应商管理系统'},
+    contextMenu: {
+      close: '关闭当前标签',
+      closeRight: '关闭右侧标签',
+      closeOther: '关闭其他标签',
+      clear: '关闭所有标签',
+    },
+  },
 };

+ 30 - 1
src/locales/register.ts

@@ -29,5 +29,34 @@ export default {
       name: '请填写法人姓名',
     },
   },
-  ko: {},
+  ko: {
+    tabs: ['① 提交基础信息', '② 申请成功'],
+    title: '填写企业信息,申请成为供应商!',
+    placeholder: {
+      enterprise: '填写企业名称',
+      phone: '填写手机联系方式',
+      email: '填写电子邮箱',
+      name: '填写法人姓名',
+    },
+    idcard: {title: '上传法人身份证'},
+    license: {title: '上传营业执照'},
+    maxSizeTip: '单张图片体积不超过20MB',
+    upload: '上传',
+    uploadTip: '拖拽图片到这里,或点击',
+    confirm: '下一步',
+    reset: '重置',
+    dropOverTip: '请松开鼠标',
+    successTitle: '恭喜,供应商注册申请成功',
+    successSubtitle: '审核结果将在5个工作日以内发送至您的邮箱,请您耐心等待~',
+    notifyButtonText: '通知企业审核',
+    errors: {
+      enterprise: '请填写企业名称',
+      phone: '请填写手机联系方式',
+      idcard: '请上传法人身份证',
+      licenseImage: '请上传营业执照',
+      email: '请填写电子邮箱',
+      emailType: '请填写正确的邮箱地址',
+      name: '请填写法人姓名',
+    },
+  },
 };

+ 6 - 1
src/main.ts

@@ -2,6 +2,9 @@ import '@styles/global.css';
 import '@styles/variable.css';
 import '@styles/naiveui.css';
 import '@icon-park/vue-next/styles/index.css';
+import '@imengyu/vue3-context-menu/lib/vue3-context-menu.css';
+import '@styles/contextMenu.css';
+
 import {createApp} from 'vue';
 import App from './App.vue';
 import {router} from '@routes';
@@ -9,6 +12,7 @@ import {VueQueryPlugin, QueryClient} from '@tanstack/vue-query';
 import {createPinia} from 'pinia';
 import i18n from '@locales';
 import {HomeTwo} from '@icon-park/vue-next';
+import ContextMenu from '@imengyu/vue3-context-menu';
 
 const client = new QueryClient({
   defaultOptions: {
@@ -25,7 +29,8 @@ app
   .use(router)
   .use(i18n)
   .use(createPinia())
-  .use(VueQueryPlugin, {queryClient: client});
+  .use(VueQueryPlugin, {queryClient: client})
+  .use(ContextMenu);
 
 // #region 注册菜单使用的icon
 app.component('HomeMenuIcon', HomeTwo);

+ 1 - 0
src/pages/home/index.vue

@@ -10,6 +10,7 @@ import TabList from './tab/index.vue';
 defineOptions({
   name: 'Home',
 });
+
 </script>
 
 <template>

+ 0 - 14
src/pages/home/menu/Icon.vue

@@ -1,14 +0,0 @@
-<script setup lang='ts'>
-import {NIcon} from 'naive-ui';
-
-defineOptions({name: 'MenuIcon'});
-
-const props = defineProps<{icon: string}>();
-</script>
-
-<template>
-  <NIcon>
-    <component :is="props.icon" theme="filled" />
-  </NIcon>
-</template>
-

+ 0 - 20
src/pages/home/menu/hooks.ts

@@ -1,20 +0,0 @@
-import {type MenuOption} from 'naive-ui';
-import {h} from 'vue';
-import MenuIcon from './Icon.vue';
-
-function renderIcon(icon: string) {
-  return () => h(MenuIcon, {icon});
-}
-
-export const menuOptions: MenuOption[] = [
-  {label: '首页', key: '-1', icon: renderIcon('HomeMenuIcon')},
-  {
-    label: '准入审核',
-    key: '2',
-    icon: renderIcon('HomeMenuIcon'),
-    children: [
-      {label: '审核列表', key: '2-1'},
-      {label: '准入审批', key: '2-2'},
-    ],
-  },
-];

+ 86 - 0
src/pages/home/menu/hooks.tsx

@@ -0,0 +1,86 @@
+import {type MenuOption, NIcon} from 'naive-ui';
+import {defineComponent, h, reactive, resolveComponent, watch} from 'vue';
+import {HomeTwo} from '@icon-park/vue-next';
+import {useTabStore, storeToRefs} from '@stores';
+import {find} from 'lodash-es';
+
+const MenuIcon = defineComponent({
+  props: {
+    icon: {type: String, required: true},
+  },
+  setup(props) {
+    const IconComponent = resolveComponent(props.icon) as typeof HomeTwo;
+
+    return function() {
+      return <NIcon>
+        <IconComponent theme="filled" />
+      </NIcon>;
+    };
+  },
+});
+
+function renderIcon(icon: string) {
+  return () => h(MenuIcon, {icon});
+}
+
+export const menuOptions: MenuOption[] = [
+  {label: '首页', key: '-1', icon: renderIcon('HomeMenuIcon'), pid: '0'},
+  {
+    label: '准入审核',
+    key: '1',
+    icon: renderIcon('HomeMenuIcon'),
+    pid: '0',
+    children: [
+      {label: '审核列表', key: '2', pid: '1'},
+      {label: '准入审批', key: '3', pid: '1'},
+    ],
+  },
+  {
+    label: '基础资料',
+    key: '4',
+    icon: renderIcon('HomeMenuIcon'),
+    children: [
+      {label: '物料信息', key: '5', pid: '4'},
+      {label: 'BOM管理', key: '6', pid: '4'},
+    ],
+  },
+];
+
+export function useMenu() {
+  const tabStore = useTabStore();
+  const {activeKey, tabList} = storeToRefs(tabStore);
+  const expandKeys = reactive<string[]>([]);
+
+  function onExpandedUpdate(keys: string[] | string) {
+    expandKeys.length = 0;
+    expandKeys.push(...keys);
+  }
+
+  function onUpdate(
+    key: string,
+    item: MenuOption,
+  ) {
+    const {label, pid} = item as (MenuOption & {pid: string, label: string});
+
+    tabStore.dispatch({
+      type: 'ADD',
+      payload: {label: label as string, key, url: '/', pid},
+    });
+  }
+
+  // activeKey变化后更新菜单的展开菜单的值
+  watch(activeKey, function(value) {
+    if (expandKeys[0] === value) return;
+
+    const pid = find(
+      tabList.value,
+      val => val.key === value,
+    )?.pid;
+
+    if (!pid) return;
+
+    onExpandedUpdate(pid);
+  });
+
+  return [{activeKey, expandKeys}, {onUpdate, onExpandedUpdate}] as const;
+}

+ 8 - 1
src/pages/home/menu/index.vue

@@ -1,13 +1,15 @@
 <script setup lang='ts'>
 import {NLayoutSider, NMenu, NScrollbar} from 'naive-ui';
 import {useToggle} from '@vueuse/core';
-import {menuOptions} from './hooks';
+import {menuOptions, useMenu} from './hooks';
 import {MenuUnfoldOne} from '@icon-park/vue-next';
 
 defineOptions({name: 'HomeMenu'});
 
 const [collapsed, setCollapsed] = useToggle();
 
+const [{activeKey, expandKeys}, {onUpdate, onExpandedUpdate}] = useMenu();
+
 </script>
 
 <template>
@@ -27,12 +29,17 @@ const [collapsed, setCollapsed] = useToggle();
     </div>
     <NScrollbar class="menu">
       <NMenu
+        accordion
+        :value="activeKey"
+        :expandedKeys="expandKeys"
         :collapsedWidth="76"
         :collapsed="collapsed"
         :options="menuOptions"
         :iconSize="20"
         :collapsedIconSize="20"
         :indent="28"
+        @update:value="onUpdate"
+        @update:expandedKeys="onExpandedUpdate"
       />
     </NScrollbar>
   </NLayoutSider>

+ 69 - 0
src/pages/home/tab/hooks.ts

@@ -0,0 +1,69 @@
+import {storeToRefs, useTabStore} from '@stores';
+import {useSortable} from '@vueuse/integrations/useSortable';
+import {ref} from 'vue';
+import {type Options} from 'sortablejs';
+import ContextMenu from '@imengyu/vue3-context-menu';
+import {useI18n} from 'vue-i18n';
+
+export function useTabSrotable() {
+  const state = useTabStore();
+  const {tabList} = storeToRefs(state);
+
+  const tabRef = ref<HTMLUListElement>();
+
+  useSortable(
+    tabRef,
+    tabList,
+    {animation: 150} as Options,
+  );
+
+  return {tabRef};
+}
+
+export function useContextMenu() {
+  const tabStore = useTabStore();
+
+  function remove(key: string) {
+    tabStore.dispatch({type: 'REMOVE', payload: key});
+  }
+
+  const {t} = useI18n();
+
+  function onContextMenu(e: MouseEvent) {
+    e.preventDefault();
+    const target = e.target as HTMLLIElement;
+    const key = target.getAttribute('data-key');
+    if (!key || key === '-1') return;
+
+    ContextMenu.showContextMenu({
+      x: e.x,
+      y: e.y,
+      items: [
+        {
+          label: t('home.contextMenu.close'),
+          onClick: remove.bind(null, key),
+        },
+        {
+          label: t('home.contextMenu.closeRight'),
+          onClick() {
+            tabStore.dispatch({type: 'REMOVE_RIGHT', payload: key});
+          },
+        },
+        {
+          label: t('home.contextMenu.closeOther'),
+          onClick() {
+            tabStore.dispatch({type: 'REMOVE_OTHER', payload: key});
+          },
+        },
+        {
+          label: t('home.contextMenu.clear'),
+          onClick() {
+            tabStore.dispatch({type: 'CLEAR'});
+          },
+        },
+      ],
+    });
+  }
+
+  return {onContextMenu, remove};
+}

+ 12 - 6
src/pages/home/tab/index.css

@@ -1,16 +1,22 @@
+.tab-list-wrapper {
+  --tab-height: 42px;
+
+  height: var(--tab-height);
+}
+
 .tab-list {
-  display: flex;
-  width: 100%;
-  padding-bottom: 2px;
+  height: var(--tab-height);
+  white-space: nowrap;
   background-color: var(--layout-background-color);
-  border-bottom: 1px solid var(--border-color);
+  border-bottom: 1px solid #eee;
 }
 
 .tab-item {
-  display: flex;
+  display: inline-flex;
   flex-shrink: 0;
   align-items: center;
-  padding: 10px 16px;
+  height: var(--tab-height);
+  padding: 0 16px;
   font-size: 14px;
   cursor: pointer;
 

+ 32 - 11
src/pages/home/tab/index.vue

@@ -1,21 +1,42 @@
 <script setup lang='ts'>
 import {CloseSmall} from '@icon-park/vue-next';
+import {storeToRefs, useTabStore} from '@stores';
+import {NScrollbar} from 'naive-ui';
+import {useContextMenu, useTabSrotable} from './hooks';
 
 defineOptions({name: 'HomeTabList'});
+
+const tabStore = useTabStore();
+const {tabList, activeKey} = storeToRefs(tabStore);
+
+const {tabRef} = useTabSrotable();
+const {onContextMenu} = useContextMenu();
 </script>
 
 <template>
-  <ul class="tab-list">
-    <li class="tab-item">首页</li>
-    <li :class="['tab-item', 'tab-item-active']">
-      首页
-      <CloseSmall
-        theme="outline"
-        size="14"
-        class="close"
-      />
-    </li>
-  </ul>
+  <div class="tab-list-wrapper">
+    <NScrollbar xScrollable>
+      <ul class="tab-list" ref="tabRef">
+        <li
+          v-for="data in tabList"
+          :key="data.key"
+          :class="['tab-item', {'tab-item-active': data.key === activeKey}]"
+          @click="tabStore.setActiveKey(data.key)"
+          :data-key="data.key"
+          @contextmenu=" onContextMenu"
+        >
+          {{data.label}}
+          <CloseSmall
+            v-if="data.key !== '-1'"
+            theme="outline"
+            size="14"
+            class="close"
+            :fill="data.key === activeKey ? 'var(--primary-color)' : '#ccc'"
+          />
+        </li>
+      </ul>
+    </NScrollbar>
+  </div>
 </template>
 
 <style scoped>

+ 4 - 0
src/pages/home/user/index.css

@@ -9,8 +9,12 @@
 }
 
 .name {
+  max-width: calc(100% - 40px - 26px - 16px);
   margin-right: 10px;
   margin-left: 16px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
 
 .dropdown-icon {

+ 123 - 4
src/stores/tab.ts

@@ -1,4 +1,5 @@
 import {defineStore} from 'pinia';
+import {klona} from 'klona/json';
 
 type State = {
   activeKey: string;
@@ -6,24 +7,98 @@ type State = {
     key: string;
     url: string;
     label: string;
+    pid: string;
   }[];
 };
 
+type DispatchOptions =
+  | {type: 'ADD'; payload: State['tabList'][0]}
+  | {type: 'REMOVE'; payload: string}
+  | {type: 'REMOVE_OTHER'; payload: string}
+  | {type: 'REMOVE_RIGHT'; payload: string}
+  | {type: 'CLEAR'}
+  | {type: 'SORT'; payload: State['tabList']};
+
 type Action = {
   setActiveKey: (key: string) => void;
   clear: () => void;
+  dispatch: (value: DispatchOptions) => void;
+};
+
+const defaultTab: State['tabList'][0] = {
+  key: '-1',
+  url: '/main',
+  label: '首页',
+  pid: '0',
 };
 
+function reducer(
+  state: State['tabList'],
+  action: DispatchOptions,
+): State['tabList'] {
+  const {type} = action;
+
+  switch (type) {
+    case 'ADD': {
+      const {payload} = action;
+
+      const exist = state.find(val => val.key === payload.key);
+      if (exist) {
+        return state;
+      }
+
+      return [...state, payload];
+    }
+    case 'REMOVE': {
+      const {payload} = action;
+      const idx = state.findIndex(val => val.key === payload);
+      if (idx < 0) {
+        return state;
+      }
+
+      const nextState = [...state];
+      nextState.splice(idx, 1);
+      return nextState;
+    }
+    case 'REMOVE_OTHER': {
+      const {payload} = action;
+      const idx = state.findIndex(val => val.key === payload);
+      if (idx < 0) {
+        return state;
+      }
+
+      return [{...defaultTab}, {...state[idx]}];
+    }
+    case 'REMOVE_RIGHT': {
+      const {payload} = action;
+      const idx = state.findIndex(val => val.key === payload);
+      if (idx < 0) {
+        return state;
+      }
+
+      const {length} = state;
+      const nextState = [...state];
+      nextState.splice(idx + 1, length - idx - 1);
+
+      return nextState;
+    }
+    case 'CLEAR': {
+      return [{...defaultTab}];
+    }
+    case 'SORT':
+      return action.payload;
+    default:
+      return state;
+  }
+}
+
 export const useTabStore = defineStore<string, State, any, Action>(
   'tab',
   {
     state() {
       return {
         activeKey: '-1',
-        tabList: [
-          {key: '-1', label: '首页', url: '/'},
-          {key: '1', label: '审核列表', url: '/shenhe'},
-        ],
+        tabList: [{...defaultTab}],
       };
     },
     actions: {
@@ -34,6 +109,50 @@ export const useTabStore = defineStore<string, State, any, Action>(
       setActiveKey(key) {
         this.activeKey = key;
       },
+      dispatch(value) {
+        let nextActiveKey = this.activeKey;
+        const prev = {
+          activeKey: this.activeKey,
+          tabList: [...this.tabList],
+        };
+        const tabList = klona(this.tabList);
+        const result = reducer(tabList, value);
+
+        switch (value.type) {
+          case 'ADD': // 新增后key改为最后一个
+            nextActiveKey = value.payload.key;
+            break;
+          case 'REMOVE': {
+            // 如果关闭的是当前active 修改为active的前一个
+            const idx = tabList.findIndex(
+              val => val.key === value.payload,
+            );
+
+            prev.activeKey === prev.tabList[idx].key
+              && (nextActiveKey = prev.tabList[idx - 1].key);
+
+            break;
+          }
+          case 'REMOVE_OTHER': // 修改为关闭的那个
+            nextActiveKey = value.payload;
+            break;
+          case 'REMOVE_RIGHT': {
+            // 判断是否关闭了active 如果关闭了则显示触发的那个
+            result.findIndex(val => val.key === prev.activeKey) < 0
+              && (nextActiveKey = value.payload);
+            break;
+          }
+          case 'CLEAR': // 显示首页
+            nextActiveKey = '-1';
+            break;
+          case 'SORT':
+            // 不需要操作
+            break;
+        }
+
+        this.activeKey = nextActiveKey;
+        this.tabList = result;
+      },
     },
   },
 );

+ 17 - 0
src/styles/contextMenu.css

@@ -0,0 +1,17 @@
+.mx-icon-placeholder {
+  display: none !important;
+}
+
+.mx-context-menu {
+  width: 220px;
+}
+
+.mx-context-menu-item {
+  --mx-menu-hover-backgroud: var(--primary-color);
+  --mx-menu-hover-text: white;
+
+  padding: 6px;
+  margin: 0 6px;
+  cursor: pointer;
+  border-radius: 4px;
+}

+ 2 - 0
vite.config.ts

@@ -7,6 +7,7 @@ import postcssNest from 'postcss-nesting';
 import postcssPresetEnv from 'postcss-preset-env';
 import eslint from '@nabla/vite-plugin-eslint';
 import browserslistToEsbuild from 'browserslist-to-esbuild';
+import VueJsx from '@vitejs/plugin-vue-jsx';
 
 export default defineConfig({
   define: {
@@ -39,6 +40,7 @@ export default defineConfig({
       filename: './visualizer/index.html',
     }) as unknown as PluginOption,
     eslint(),
+    VueJsx(),
   ],
   test: {
     include: ['src/**/*.{test, spec}.{js,jsx,ts,tsx}'],