朱永杰 1 год назад
Сommit
7cba3e7050
100 измененных файлов с 8677 добавлено и 0 удалено
  1. 5 0
      .idea/.gitignore
  2. 6 0
      .idea/inspectionProfiles/Project_Default.xml
  3. 8 0
      .idea/modules.xml
  4. 12 0
      .idea/v3-admin-vite-main.iml
  5. 6 0
      .idea/vcs.xml
  6. 25 0
      v3-admin-vite-main/.editorconfig
  7. 4 0
      v3-admin-vite-main/.env
  8. 10 0
      v3-admin-vite-main/.env.development
  9. 10 0
      v3-admin-vite-main/.env.production
  10. 10 0
      v3-admin-vite-main/.env.staging
  11. 8 0
      v3-admin-vite-main/.eslintignore
  12. 74 0
      v3-admin-vite-main/.eslintrc.cjs
  13. 1 0
      v3-admin-vite-main/.github/FUNDING.yml
  14. 35 0
      v3-admin-vite-main/.github/workflows/deploy.yml
  15. 35 0
      v3-admin-vite-main/.gitignore
  16. 4 0
      v3-admin-vite-main/.husky/pre-commit
  17. 2 0
      v3-admin-vite-main/.npmrc
  18. 8 0
      v3-admin-vite-main/.prettierignore
  19. 10 0
      v3-admin-vite-main/.vscode/extensions.json
  20. 16 0
      v3-admin-vite-main/.vscode/hook.code-snippets
  21. 29 0
      v3-admin-vite-main/.vscode/settings.json
  22. 14 0
      v3-admin-vite-main/.vscode/vue.code-snippets
  23. 21 0
      v3-admin-vite-main/LICENSE
  24. 10 0
      v3-admin-vite-main/README.md
  25. 89 0
      v3-admin-vite-main/README.zh-CN.md
  26. 16 0
      v3-admin-vite-main/index.html
  27. 103 0
      v3-admin-vite-main/package.json
  28. 5500 0
      v3-admin-vite-main/pnpm-lock.yaml
  29. 22 0
      v3-admin-vite-main/prettier.config.js
  30. 45 0
      v3-admin-vite-main/public/app-loading.css
  31. BIN
      v3-admin-vite-main/public/favicon.ico
  32. 17 0
      v3-admin-vite-main/src/App.vue
  33. 0 0
      v3-admin-vite-main/src/api/cow/index.ts
  34. 36 0
      v3-admin-vite-main/src/api/hook-demo/use-fetch-select.ts
  35. 26 0
      v3-admin-vite-main/src/api/hook-demo/use-fullscreen-loading.ts
  36. 23 0
      v3-admin-vite-main/src/api/login/index.ts
  37. 11 0
      v3-admin-vite-main/src/api/login/types/login.ts
  38. 37 0
      v3-admin-vite-main/src/api/table/index.ts
  39. 29 0
      v3-admin-vite-main/src/api/table/types/table.ts
  40. 1 0
      v3-admin-vite-main/src/assets/error-page/403.svg
  41. 1 0
      v3-admin-vite-main/src/assets/error-page/404.svg
  42. BIN
      v3-admin-vite-main/src/assets/layouts/logo-text-1.png
  43. BIN
      v3-admin-vite-main/src/assets/layouts/logo-text-2.png
  44. BIN
      v3-admin-vite-main/src/assets/layouts/logo.png
  45. 58 0
      v3-admin-vite-main/src/components/Notify/NotifyList.vue
  46. 66 0
      v3-admin-vite-main/src/components/Notify/data.ts
  47. 95 0
      v3-admin-vite-main/src/components/Notify/index.vue
  48. 92 0
      v3-admin-vite-main/src/components/Screenfull/index.vue
  49. 54 0
      v3-admin-vite-main/src/components/SearchMenu/SearchFooter.vue
  50. 202 0
      v3-admin-vite-main/src/components/SearchMenu/SearchModal.vue
  51. 110 0
      v3-admin-vite-main/src/components/SearchMenu/SearchResult.vue
  52. 29 0
      v3-admin-vite-main/src/components/SearchMenu/index.vue
  53. 29 0
      v3-admin-vite-main/src/components/SvgIcon/index.vue
  54. 30 0
      v3-admin-vite-main/src/components/ThemeSwitch/index.vue
  55. 49 0
      v3-admin-vite-main/src/config/layouts.ts
  56. 28 0
      v3-admin-vite-main/src/config/route.ts
  57. 15 0
      v3-admin-vite-main/src/config/white-list.ts
  58. 20 0
      v3-admin-vite-main/src/constants/app-key.ts
  59. 13 0
      v3-admin-vite-main/src/constants/cache-key.ts
  60. 7 0
      v3-admin-vite-main/src/directives/index.ts
  61. 17 0
      v3-admin-vite-main/src/directives/permission/index.ts
  62. 11 0
      v3-admin-vite-main/src/hooks/useDevice.ts
  63. 49 0
      v3-admin-vite-main/src/hooks/useFetchSelect.ts
  64. 35 0
      v3-admin-vite-main/src/hooks/useFullscreenLoading.ts
  65. 16 0
      v3-admin-vite-main/src/hooks/useLayoutMode.ts
  66. 42 0
      v3-admin-vite-main/src/hooks/usePagination.ts
  67. 48 0
      v3-admin-vite-main/src/hooks/useRouteListener.ts
  68. 57 0
      v3-admin-vite-main/src/hooks/useTheme.ts
  69. 23 0
      v3-admin-vite-main/src/hooks/useTitle.ts
  70. 7 0
      v3-admin-vite-main/src/icons/index.ts
  71. 1 0
      v3-admin-vite-main/src/icons/svg/404.svg
  72. 1 0
      v3-admin-vite-main/src/icons/svg/bug.svg
  73. 1 0
      v3-admin-vite-main/src/icons/svg/component.svg
  74. 1 0
      v3-admin-vite-main/src/icons/svg/dashboard.svg
  75. 1 0
      v3-admin-vite-main/src/icons/svg/fullscreen-exit.svg
  76. 1 0
      v3-admin-vite-main/src/icons/svg/fullscreen.svg
  77. 1 0
      v3-admin-vite-main/src/icons/svg/keyboard-down.svg
  78. 1 0
      v3-admin-vite-main/src/icons/svg/keyboard-enter.svg
  79. 1 0
      v3-admin-vite-main/src/icons/svg/keyboard-esc.svg
  80. 1 0
      v3-admin-vite-main/src/icons/svg/keyboard-up.svg
  81. 1 0
      v3-admin-vite-main/src/icons/svg/link.svg
  82. 1 0
      v3-admin-vite-main/src/icons/svg/lock.svg
  83. 1 0
      v3-admin-vite-main/src/icons/svg/menu.svg
  84. 1 0
      v3-admin-vite-main/src/icons/svg/search.svg
  85. 5 0
      v3-admin-vite-main/src/icons/svg/unocss.svg
  86. 168 0
      v3-admin-vite-main/src/layouts/LeftMode.vue
  87. 109 0
      v3-admin-vite-main/src/layouts/LeftTopMode.vue
  88. 75 0
      v3-admin-vite-main/src/layouts/TopMode.vue
  89. 50 0
      v3-admin-vite-main/src/layouts/components/AppMain.vue
  90. 71 0
      v3-admin-vite-main/src/layouts/components/Breadcrumb/index.vue
  91. 67 0
      v3-admin-vite-main/src/layouts/components/CompConsumer/index.ts
  92. 18 0
      v3-admin-vite-main/src/layouts/components/Footer/index.vue
  93. 35 0
      v3-admin-vite-main/src/layouts/components/Hamburger/index.vue
  94. 66 0
      v3-admin-vite-main/src/layouts/components/Logo/index.vue
  95. 128 0
      v3-admin-vite-main/src/layouts/components/NavigationBar/index.vue
  96. 45 0
      v3-admin-vite-main/src/layouts/components/RightPanel/index.vue
  97. 103 0
      v3-admin-vite-main/src/layouts/components/Settings/SelectLayoutMode.vue
  98. 82 0
      v3-admin-vite-main/src/layouts/components/Settings/index.vue
  99. 120 0
      v3-admin-vite-main/src/layouts/components/Sidebar/SidebarItem.vue
  100. 0 0
      v3-admin-vite-main/src/layouts/components/Sidebar/SidebarItemLink.vue

+ 5 - 0
.idea/.gitignore

@@ -0,0 +1,5 @@
+# 默认忽略的文件
+/shelf/
+/workspace.xml
+# 基于编辑器的 HTTP 客户端请求
+/httpRequests/

+ 6 - 0
.idea/inspectionProfiles/Project_Default.xml

@@ -0,0 +1,6 @@
+<component name="InspectionProjectProfileManager">
+  <profile version="1.0">
+    <option name="myName" value="Project Default" />
+    <inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
+  </profile>
+</component>

+ 8 - 0
.idea/modules.xml

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="ProjectModuleManager">
+    <modules>
+      <module fileurl="file://$PROJECT_DIR$/.idea/v3-admin-vite-main.iml" filepath="$PROJECT_DIR$/.idea/v3-admin-vite-main.iml" />
+    </modules>
+  </component>
+</project>

+ 12 - 0
.idea/v3-admin-vite-main.iml

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<module type="WEB_MODULE" version="4">
+  <component name="NewModuleRootManager">
+    <content url="file://$MODULE_DIR$">
+      <excludeFolder url="file://$MODULE_DIR$/.tmp" />
+      <excludeFolder url="file://$MODULE_DIR$/temp" />
+      <excludeFolder url="file://$MODULE_DIR$/tmp" />
+    </content>
+    <orderEntry type="inheritedJdk" />
+    <orderEntry type="sourceFolder" forTests="false" />
+  </component>
+</module>

+ 6 - 0
.idea/vcs.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="VcsDirectoryMappings">
+    <mapping directory="$PROJECT_DIR$" vcs="Git" />
+  </component>
+</project>

+ 25 - 0
v3-admin-vite-main/.editorconfig

@@ -0,0 +1,25 @@
+# 修改配置后重启编辑器
+# 配置项文档:https://editorconfig.org/
+
+# 告知 EditorConfig 插件,当前即是根文件
+root = true
+
+# 适用全部文件
+[*]
+## 设置字符集
+charset = utf-8
+## 缩进风格 space | tab,建议 space(会自动继承给 Prettier)
+indent_style = space
+## 缩进的空格数(会自动继承给 Prettier)
+indent_size = 2
+## 换行符类型 lf | cr | crlf,一般都是设置为 lf
+end_of_line = lf
+## 是否在文件末尾插入空白行
+insert_final_newline = true
+## 是否删除一行中的前后空格
+trim_trailing_whitespace = true
+
+# 适用 .md 文件
+[*.md]
+insert_final_newline = false
+trim_trailing_whitespace = false

+ 4 - 0
v3-admin-vite-main/.env

@@ -0,0 +1,4 @@
+# 所有环境自定义的环境变量(命名必须以 VITE_ 开头)
+
+## 项目标题
+VITE_APP_TITLE = V3 Admin Vite

+ 10 - 0
v3-admin-vite-main/.env.development

@@ -0,0 +1,10 @@
+# 开发环境自定义的环境变量(命名必须以 VITE_ 开头)
+
+## 后端接口公共路径(如果解决跨域问题采用反向代理就只需写公共路径)
+VITE_BASE_API = '/api/v1'
+
+## 路由模式 hash 或 html5
+VITE_ROUTER_HISTORY = 'hash'
+
+## 开发环境地址前缀(一般 '/','./' 都可以)
+VITE_PUBLIC_PATH = '/'

+ 10 - 0
v3-admin-vite-main/.env.production

@@ -0,0 +1,10 @@
+# 生产环境自定义的环境变量(命名必须以 VITE_ 开头)
+
+## 后端接口公共路径(如果解决跨域问题采用 CORS 就需要写全路径)
+VITE_BASE_API = 'https://mock.mengxuegu.com/mock/63218b5fb4c53348ed2bc212/api/v1'
+
+## 路由模式 hash 或 html5
+VITE_ROUTER_HISTORY = 'hash'
+
+## 打包路径(就是网站前缀,例如部署到 https://un-pany.github.io/v3-admin-vite/ 域名下,就需要填写 /v3-admin-vite/)
+VITE_PUBLIC_PATH = '/v3-admin-vite/'

+ 10 - 0
v3-admin-vite-main/.env.staging

@@ -0,0 +1,10 @@
+# 预发布环境自定义的环境变量(命名必须以 VITE_ 开头)
+
+## 后端接口公共路径(如果解决跨域问题采用 CORS 就需要写全路径)
+VITE_BASE_API = 'https://mock.mengxuegu.com/mock/63218b5fb4c53348ed2bc212/api/v1'
+
+## 路由模式 hash 或 html5
+VITE_ROUTER_HISTORY = 'hash'
+
+## 打包路径(就是网站前缀,例如部署到 https://un-pany.github.io/v3-admin-vite/ 域名下,就需要填写 /v3-admin-vite/)
+VITE_PUBLIC_PATH = '/v3-admin-vite/'

+ 8 - 0
v3-admin-vite-main/.eslintignore

@@ -0,0 +1,8 @@
+# Eslint 会忽略的文件
+
+.DS_Store
+node_modules
+dist
+dist-ssr
+*.local
+.npmrc

+ 74 - 0
v3-admin-vite-main/.eslintrc.cjs

@@ -0,0 +1,74 @@
+module.exports = {
+  root: true,
+  env: {
+    browser: true,
+    node: true,
+    es6: true
+  },
+  extends: [
+    "plugin:vue/vue3-essential",
+    "eslint:recommended",
+    "@vue/typescript/recommended",
+    "@vue/prettier",
+    "@vue/eslint-config-typescript"
+  ],
+  parser: "vue-eslint-parser",
+  parserOptions: {
+    parser: "@typescript-eslint/parser",
+    ecmaVersion: 2020,
+    sourceType: "module",
+    jsxPragma: "React",
+    ecmaFeatures: {
+      jsx: true,
+      tsx: true
+    }
+  },
+  rules: {
+    // TS
+    "@typescript-eslint/no-explicit-any": "off",
+    "no-debugger": "off",
+    "@typescript-eslint/explicit-module-boundary-types": "off",
+    "@typescript-eslint/ban-types": "off",
+    "@typescript-eslint/ban-ts-comment": "off",
+    "@typescript-eslint/no-empty-function": "off",
+    "@typescript-eslint/no-non-null-assertion": "off",
+    "@typescript-eslint/no-unused-vars": [
+      "error",
+      {
+        argsIgnorePattern: "^_",
+        varsIgnorePattern: "^_"
+      }
+    ],
+    "no-unused-vars": [
+      "error",
+      {
+        argsIgnorePattern: "^_",
+        varsIgnorePattern: "^_"
+      }
+    ],
+    // Vue
+    "vue/no-v-html": "off",
+    "vue/require-default-prop": "off",
+    "vue/require-explicit-emits": "off",
+    "vue/multi-word-component-names": "off",
+    "vue/html-self-closing": [
+      "error",
+      {
+        html: {
+          void: "always",
+          normal: "always",
+          component: "always"
+        },
+        svg: "always",
+        math: "always"
+      }
+    ],
+    // Prettier
+    "prettier/prettier": [
+      "error",
+      {
+        endOfLine: "auto"
+      }
+    ]
+  }
+}

+ 1 - 0
v3-admin-vite-main/.github/FUNDING.yml

@@ -0,0 +1 @@
+custom: https://github.com/un-pany/v3-admin-vite/issues/69

+ 35 - 0
v3-admin-vite-main/.github/workflows/deploy.yml

@@ -0,0 +1,35 @@
+name: Build And Deploy v3-admin-vite
+
+on:
+  push:
+    branches:
+      - main
+
+jobs:
+  build-and-deploy:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v2
+        with:
+          persist-credentials: false
+
+      - name: Setup Node.js 20.10.0
+        uses: actions/setup-node@master
+        with:
+          node-version: 20.10.0
+
+      - name: Setup pnpm
+        uses: pnpm/action-setup@v2
+        with:
+          version: 8.11.0
+
+      - name: Build
+        run: pnpm install && pnpm build:prod
+
+      - name: Deploy
+        uses: JamesIves/github-pages-deploy-action@releases/v3
+        with:
+          ACCESS_TOKEN: ${{ secrets.V3_ADMIN_VITE }}
+          BRANCH: gh-pages
+          FOLDER: dist

+ 35 - 0
v3-admin-vite-main/.gitignore

@@ -0,0 +1,35 @@
+# Git 会忽略的文件
+
+.DS_Store
+node_modules
+dist
+dist-ssr
+.eslintcache
+
+# Local env files
+*.local
+
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+!.vscode/settings.json
+!.vscode/*.code-snippets
+.idea
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+# Use the PNPM
+package-lock.json
+yarn.lock

+ 4 - 0
v3-admin-vite-main/.husky/pre-commit

@@ -0,0 +1,4 @@
+#!/bin/sh
+. "$(dirname "$0")/_/husky.sh"
+
+npx lint-staged

+ 2 - 0
v3-admin-vite-main/.npmrc

@@ -0,0 +1,2 @@
+# 通过该配置兜底解决组件没有类型提示的问题
+shamefully-hoist = true

+ 8 - 0
v3-admin-vite-main/.prettierignore

@@ -0,0 +1,8 @@
+# Prettier 会忽略的文件
+
+.DS_Store
+node_modules
+dist
+dist-ssr
+*.local
+.npmrc

+ 10 - 0
v3-admin-vite-main/.vscode/extensions.json

@@ -0,0 +1,10 @@
+{
+  "recommendations": [
+    "editorconfig.editorconfig",
+    "dbaeumer.vscode-eslint",
+    "esbenp.prettier-vscode",
+    "vue.vscode-typescript-vue-plugin",
+    "vue.volar",
+    "antfu.unocss",
+  ]
+}

+ 16 - 0
v3-admin-vite-main/.vscode/hook.code-snippets

@@ -0,0 +1,16 @@
+{
+  "Vue3 Hook 代码结构一键生成": {
+    "prefix": "Vue3 Hook",
+    "body": [
+      "import { ref } from \"vue\"\n",
+      "const refName1 = ref<string>(\"这是一个响应式变量\")\n",
+      "export function useHookName() {",
+      "\tconst refName2 = ref<string>(\"这是一个响应式变量\")\n",
+      "\tconst fnName = () => {}\n",
+      "\treturn { refName1, refName2, fnName }",
+      "}",
+      "$1"
+    ],
+    "description": "Vue3 Hook"
+  }
+}

+ 29 - 0
v3-admin-vite-main/.vscode/settings.json

@@ -0,0 +1,29 @@
+{
+  "editor.codeActionsOnSave": {
+    "source.fixAll.eslint": "explicit"
+  },
+  "[vue]": {
+    "editor.defaultFormatter": "esbenp.prettier-vscode"
+  },
+  "[javascript]": {
+    "editor.defaultFormatter": "esbenp.prettier-vscode"
+  },
+  "[typescript]": {
+    "editor.defaultFormatter": "esbenp.prettier-vscode"
+  },
+  "[json]": {
+    "editor.defaultFormatter": "esbenp.prettier-vscode"
+  },
+  "[jsonc]": {
+    "editor.defaultFormatter": "esbenp.prettier-vscode"
+  },
+  "[html]": {
+    "editor.defaultFormatter": "esbenp.prettier-vscode"
+  },
+  "[css]": {
+    "editor.defaultFormatter": "esbenp.prettier-vscode"
+  },
+  "[scss]": {
+    "editor.defaultFormatter": "esbenp.prettier-vscode"
+  }
+}

+ 14 - 0
v3-admin-vite-main/.vscode/vue.code-snippets

@@ -0,0 +1,14 @@
+{
+  "Vue3 SFC 代码结构一键生成": {
+    "prefix": "Vue3 SFC",
+    "body": [
+      "<script lang=\"ts\" setup></script>\n",
+      "<template>",
+      "\t<div class=\"app-container\">...</div>",
+      "</template>\n",
+      "<style scoped></style>",
+      "$1"
+    ],
+    "description": "Vue3 SFC"
+  }
+}

+ 21 - 0
v3-admin-vite-main/LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2022-present pany <https://github.com/pany-ang>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 10 - 0
v3-admin-vite-main/README.md

@@ -0,0 +1,10 @@
+# configure
+1. installation of the recommended plugins in the .vscode directory
+2. node version 18.x or 20+
+3. pnpm version 8.x or latest
+
+# install dependencies
+pnpm i
+
+# start the service
+pnpm dev

+ 89 - 0
v3-admin-vite-main/README.zh-CN.md

@@ -0,0 +1,89 @@
+
+## 特性
+
+- **Vue3**:采用 Vue3 + script setup 最新的 Vue3 组合式 API
+- **Element Plus**:Element UI 的 Vue3 版本
+- **Pinia**: 传说中的 Vuex5
+- **Vite**:真的很快
+- **Vue Router**:路由路由
+- **TypeScript**:JavaScript 语言的超集
+- **PNPM**:更快速的,节省磁盘空间的包管理工具
+- **Scss**:和 Element Plus 保持一致
+- **CSS 变量**:主要控制项目的布局和颜色
+- **ESlint**:代码校验
+- **Prettier**:代码格式化
+- **Axios**:发送网络请求(已封装好)
+- **UnoCSS**:具有高性能且极具灵活性的即时原子化 CSS 引擎
+- **兼容移动端**: 布局兼容移动端页面分辨率
+
+## 功能
+
+- **用户管理**:登录、登出演示
+- **权限管理**:内置页面权限(动态路由)、指令权限、权限函数、路由守卫
+- **多环境**:开发环境(development)、预发布环境(staging)、正式环境(production)
+- **多主题**:内置普通、黑暗、深蓝三种主题模式
+- **多布局**:内置左侧、顶部、混合三种布局模式
+- **错误页面**: 403、404
+- **Dashboard**:根据不同用户显示不同的 Dashboard 页面
+- **其他内置功能**:SVG、动态侧边栏、动态面包屑、标签页快捷导航、Screenfull 全屏、自适应收缩侧边栏、Hook(Composables)
+
+## 🚀 开发
+
+```bash
+# 配置
+1. 一键安装 .vscode 目录中推荐的插件
+2. node 版本 18.x 或 20+
+3. pnpm 版本 8.x 或最新版
+
+# 安装依赖
+pnpm i
+
+# 启动服务
+pnpm dev
+```
+
+## ✔️ 预览
+
+```bash
+# 预览预发布环境
+pnpm preview:stage
+
+# 预览正式环境
+pnpm preview:prod
+```
+
+## 📦️ 多环境打包
+
+```bash
+# 构建预发布环境
+pnpm build:stage
+
+# 构建正式环境
+pnpm build:prod
+```
+
+## 🔧 代码检查
+
+```bash
+# 代码格式化
+pnpm lint
+
+# 单元测试
+pnpm test
+```
+
+## Git 提交规范参考
+
+- `feat` 增加新的业务功能
+- `fix` 修复业务问题/BUG
+- `perf` 优化性能
+- `style` 更改代码风格, 不影响运行结果
+- `refactor` 重构代码
+- `revert` 撤销更改
+- `test` 测试相关, 不涉及业务代码的更改
+- `docs` 文档和注释相关
+- `chore` 更新依赖/修改脚手架配置等琐事
+- `workflow` 工作流改进
+- `ci` 持续集成相关
+- `types` 类型定义文件更改
+- `wip` 开发中

+ 16 - 0
v3-admin-vite-main/index.html

@@ -0,0 +1,16 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <link rel="icon" href="/favicon.ico" />
+    <link rel="stylesheet" href="/app-loading.css" />
+    <title>%VITE_APP_TITLE%</title>
+  </head>
+  <body>
+    <div id="app">
+      <div id="app-loading"></div>
+    </div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

+ 103 - 0
v3-admin-vite-main/package.json

@@ -0,0 +1,103 @@
+{
+  "name": "v3-admin-vite",
+  "version": "4.4.0",
+  "description": "一个免费开源的中后台管理系统基础解决方案,基于 Vue3、TypeScript、Element Plus、Pinia 和 Vite 等主流技术",
+  "author": {
+    "name": "pany",
+    "email": "939630029@qq.com",
+    "url": "https://github.com/pany-ang"
+  },
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/un-pany/v3-admin-vite.git"
+  },
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build:stage": "vue-tsc --noEmit && vite build --mode staging",
+    "build:prod": "vue-tsc --noEmit && vite build",
+    "preview:stage": "pnpm build:stage && vite preview",
+    "preview:prod": "pnpm build:prod && vite preview",
+    "lint:eslint": "eslint --cache --max-warnings 0 \"{src,tests,types}/**/*.{vue,js,jsx,ts,tsx}\" --fix",
+    "lint:prettier": "prettier --write \"{src,tests,types}/**/*.{vue,js,jsx,ts,tsx,json,css,less,scss,html,md}\"",
+    "lint": "pnpm lint:eslint && pnpm lint:prettier",
+    "prepare": "husky install",
+    "test": "vitest"
+  },
+  "dependencies": {
+    "@element-plus/icons-vue": "2.3.1",
+    "axios": "1.6.7",
+    "dayjs": "1.11.10",
+    "element-plus": "2.5.6",
+    "js-cookie": "3.0.5",
+    "lodash-es": "4.17.21",
+    "mitt": "3.0.1",
+    "normalize.css": "8.0.1",
+    "nprogress": "0.2.0",
+    "path-browserify": "1.0.1",
+    "path-to-regexp": "6.2.1",
+    "pinia": "2.1.7",
+    "screenfull": "6.0.2",
+    "vue": "3.4.19",
+    "vue-router": "4.2.5",
+    "vxe-table": "4.4.1",
+    "vxe-table-plugin-element": "3.0.7",
+    "xe-utils": "3.5.11"
+  },
+  "devDependencies": {
+    "@types/js-cookie": "3.0.6",
+    "@types/lodash-es": "4.17.12",
+    "@types/node": "20.11.19",
+    "@types/nprogress": "0.2.3",
+    "@types/path-browserify": "1.0.2",
+    "@typescript-eslint/eslint-plugin": "7.0.2",
+    "@typescript-eslint/parser": "7.0.2",
+    "@vitejs/plugin-vue": "5.0.4",
+    "@vitejs/plugin-vue-jsx": "3.1.0",
+    "@vue/eslint-config-prettier": "9.0.0",
+    "@vue/eslint-config-typescript": "12.0.0",
+    "@vue/test-utils": "2.4.4",
+    "eslint": "8.56.0",
+    "eslint-plugin-prettier": "5.1.3",
+    "eslint-plugin-vue": "9.21.1",
+    "husky": "9.0.11",
+    "jsdom": "24.0.0",
+    "lint-staged": "15.2.2",
+    "prettier": "3.2.5",
+    "sass": "1.71.0",
+    "typescript": "5.3.3",
+    "unocss": "0.58.5",
+    "vite": "5.1.3",
+    "vite-plugin-svg-icons": "2.0.1",
+    "vite-svg-loader": "5.1.0",
+    "vitest": "1.3.0",
+    "vue-eslint-parser": "9.4.2",
+    "vue-tsc": "1.8.27"
+  },
+  "lint-staged": {
+    "*.{vue,js,jsx,ts,tsx}": [
+      "eslint --fix",
+      "prettier --write"
+    ],
+    "*.{css,less,scss,html,md}": [
+      "prettier --write"
+    ],
+    "package.json": [
+      "prettier --write"
+    ]
+  },
+  "keywords": [
+    "vue",
+    "vue3",
+    "admin",
+    "vue-admin",
+    "vue3-admin",
+    "vite",
+    "vite-admin",
+    "element-plus",
+    "element-plus-admin",
+    "ts",
+    "typescript"
+  ],
+  "license": "MIT"
+}

Разница между файлами не показана из-за своего большого размера
+ 5500 - 0
v3-admin-vite-main/pnpm-lock.yaml


+ 22 - 0
v3-admin-vite-main/prettier.config.js

@@ -0,0 +1,22 @@
+/**
+ * 修改配置后重启编辑器
+ * 配置项文档:https://prettier.io/docs/en/configuration.html
+ * @type {import("prettier").Config}
+ */
+
+export default {
+  /** 每一行的宽度 */
+  printWidth: 120,
+  /** 在对象中的括号之间是否用空格来间隔 */
+  bracketSpacing: true,
+  /** 箭头函数的参数无论有几个,都要括号包裹 */
+  arrowParens: "always",
+  /** 换行符的使用 */
+  endOfLine: "auto",
+  /** 是否采用单引号 */
+  singleQuote: false,
+  /** 对象或者数组的最后一个元素后面不要加逗号 */
+  trailingComma: "none",
+  /** 是否加分号 */
+  semi: false
+}

+ 45 - 0
v3-admin-vite-main/public/app-loading.css

@@ -0,0 +1,45 @@
+/** 白屏阶段会执行的 CSS 加载动画 */
+
+#app-loading {
+  position: relative;
+  top: 45vh;
+  margin: 0 auto;
+  color: #409eff;
+  font-size: 12px;
+}
+
+#app-loading,
+#app-loading::before,
+#app-loading::after {
+  width: 2em;
+  height: 2em;
+  border-radius: 50%;
+  animation: 2s ease-in-out infinite app-loading-animation;
+}
+
+#app-loading::before,
+#app-loading::after {
+  content: "";
+  position: absolute;
+}
+
+#app-loading::before {
+  left: -4em;
+  animation-delay: -0.2s;
+}
+
+#app-loading::after {
+  left: 4em;
+  animation-delay: 0.2s;
+}
+
+@keyframes app-loading-animation {
+  0%,
+  80%,
+  100% {
+    box-shadow: 0 2em 0 -2em;
+  }
+  40% {
+    box-shadow: 0 2em 0 0;
+  }
+}

BIN
v3-admin-vite-main/public/favicon.ico


+ 17 - 0
v3-admin-vite-main/src/App.vue

@@ -0,0 +1,17 @@
+<script lang="ts" setup>
+import { useTheme } from "@/hooks/useTheme"
+import { ElNotification } from "element-plus"
+// 将 Element Plus 的语言设置为中文
+import zhCn from "element-plus/es/locale/lang/zh-cn"
+
+const { initTheme } = useTheme()
+
+/** 初始化主题 */
+initTheme()
+</script>
+
+<template>
+  <el-config-provider :locale="zhCn">
+    <router-view />
+  </el-config-provider>
+</template>

+ 0 - 0
v3-admin-vite-main/src/api/cow/index.ts


+ 36 - 0
v3-admin-vite-main/src/api/hook-demo/use-fetch-select.ts

@@ -0,0 +1,36 @@
+/** 模拟接口响应数据 */
+const SELECT_RESPONSE_DATA = {
+  code: 0,
+  data: [
+    {
+      label: "苹果",
+      value: 1
+    },
+    {
+      label: "香蕉",
+      value: 2
+    },
+    {
+      label: "橘子",
+      value: 3,
+      disabled: true
+    }
+  ],
+  message: "获取 Select 数据成功"
+}
+
+/** 模拟接口 */
+export function getSelectDataApi() {
+  return new Promise<typeof SELECT_RESPONSE_DATA>((resolve, reject) => {
+    // 模拟接口响应时间 2s
+    setTimeout(() => {
+      // 模拟接口调用成功
+      if (Math.random() < 0.8) {
+        resolve(SELECT_RESPONSE_DATA)
+      } else {
+        // 模拟接口调用出错
+        reject(new Error("接口发生错误"))
+      }
+    }, 2000)
+  })
+}

+ 26 - 0
v3-admin-vite-main/src/api/hook-demo/use-fullscreen-loading.ts

@@ -0,0 +1,26 @@
+/** 模拟接口响应数据 */
+const SUCCESS_RESPONSE_DATA = {
+  code: 0,
+  data: {
+    list: [] as number[]
+  },
+  message: "获取成功"
+}
+
+/** 模拟请求接口成功 */
+export function getSuccessApi(list: number[]) {
+  return new Promise<typeof SUCCESS_RESPONSE_DATA>((resolve) => {
+    setTimeout(() => {
+      resolve({ ...SUCCESS_RESPONSE_DATA, data: { list } })
+    }, 1000)
+  })
+}
+
+/** 模拟请求接口失败 */
+export function getErrorApi() {
+  return new Promise((_resolve, reject) => {
+    setTimeout(() => {
+      reject(new Error("发生错误"))
+    }, 1000)
+  })
+}

+ 23 - 0
v3-admin-vite-main/src/api/login/index.ts

@@ -0,0 +1,23 @@
+import { request } from "@/utils/service"
+import type * as Login from "./types/login"
+
+/** 登录并返回 Token */
+export function loginApi(data: Login.LoginRequestData) {
+  return request<Login.LoginResponseData>(
+    "apiUser",
+    {
+      url: "users/login",
+      method: "post",
+      data
+    },
+    true
+  )
+}
+
+/** 获取用户详情 */
+export function getUserInfoApi() {
+  return request<Login.UserInfoResponseData>("apiUser", {
+    url: "users/user",
+    method: "get"
+  })
+}

+ 11 - 0
v3-admin-vite-main/src/api/login/types/login.ts

@@ -0,0 +1,11 @@
+export interface LoginRequestData {
+  username: string
+  /** 密码 */
+  password: string
+}
+
+export type LoginCodeResponseData = ApiResponseData<string>
+
+export type LoginResponseData = ApiResponseData<string>
+
+export type UserInfoResponseData = ApiResponseData<{ username: string; roles: string[] }>

+ 37 - 0
v3-admin-vite-main/src/api/table/index.ts

@@ -0,0 +1,37 @@
+import { request } from "@/utils/service"
+import type * as Table from "./types/table"
+
+/** 增 */
+export function createTableDataApi(data: Table.CreateOrUpdateTableRequestData) {
+  return request("apiCow",{
+    url: "table",
+    method: "post",
+    data
+  })
+}
+
+/** 删 */
+export function deleteTableDataApi(id: string) {
+  return request("apiCow",{
+    url: `table/${id}`,
+    method: "delete"
+  })
+}
+
+/** 改 */
+export function updateTableDataApi(data: Table.CreateOrUpdateTableRequestData) {
+  return request("apiCow",{
+    url: "table",
+    method: "put",
+    data
+  })
+}
+
+/** 查 */
+export function getTableDataApi(params: Table.GetTableRequestData) {
+  return request<Table.GetTableResponseData>("apiOrder",{
+    url: "/orders/user/list",
+    method: "get",
+    params
+  })
+}

+ 29 - 0
v3-admin-vite-main/src/api/table/types/table.ts

@@ -0,0 +1,29 @@
+export interface CreateOrUpdateTableRequestData {
+  id?: string
+  username: string
+  password?: string
+}
+
+export interface GetTableRequestData {
+  /** 当前页码 */
+  currentPage: number
+  /** 查询条数 */
+  limit: number
+  /** 查询参数:用户名 */
+  username?: string
+  /** 查询参数:手机号 */
+  phone?: string
+  offset?: number
+}
+
+export interface GetTableData {
+  createTime: string
+  email: string
+  id: string
+  phone: string
+  roles: string
+  status: boolean
+  username: string
+}
+
+export type GetTableResponseData = ApiResponseData<[]>

Разница между файлами не показана из-за своего большого размера
+ 1 - 0
v3-admin-vite-main/src/assets/error-page/403.svg


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
v3-admin-vite-main/src/assets/error-page/404.svg


BIN
v3-admin-vite-main/src/assets/layouts/logo-text-1.png


BIN
v3-admin-vite-main/src/assets/layouts/logo-text-2.png


BIN
v3-admin-vite-main/src/assets/layouts/logo.png


+ 58 - 0
v3-admin-vite-main/src/components/Notify/NotifyList.vue

@@ -0,0 +1,58 @@
+<script lang="ts" setup>
+import { type ListItem } from "./data"
+
+interface Props {
+  list: ListItem[]
+}
+
+const props = defineProps<Props>()
+</script>
+
+<template>
+  <el-empty v-if="props.list.length === 0" />
+  <el-card v-else v-for="(item, index) in props.list" :key="index" shadow="never" class="card-container">
+    <template #header>
+      <div class="card-header">
+        <div>
+          <span>
+            <span class="card-title">{{ item.title }}</span>
+            <el-tag v-if="item.extra" :type="item.status" effect="plain" size="small">{{ item.extra }}</el-tag>
+          </span>
+          <div class="card-time">{{ item.datetime }}</div>
+        </div>
+        <div v-if="item.avatar" class="card-avatar">
+          <img :src="item.avatar" width="34" />
+        </div>
+      </div>
+    </template>
+    <div class="card-body">
+      {{ item.description ?? "No Data" }}
+    </div>
+  </el-card>
+</template>
+
+<style lang="scss" scoped>
+.card-container {
+  margin-bottom: 10px;
+  .card-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    .card-title {
+      font-weight: bold;
+      margin-right: 10px;
+    }
+    .card-time {
+      font-size: 12px;
+      color: grey;
+    }
+    .card-avatar {
+      display: flex;
+      align-items: center;
+    }
+  }
+  .card-body {
+    font-size: 12px;
+  }
+}
+</style>

+ 66 - 0
v3-admin-vite-main/src/components/Notify/data.ts

@@ -0,0 +1,66 @@
+export interface ListItem {
+  avatar?: string
+  title: string
+  datetime?: string
+  description?: string
+  status?: "primary" | "success" | "info" | "warning" | "danger"
+  extra?: string
+}
+
+export const notifyData: ListItem[] = [
+  {
+    avatar: "https://gw.alipayobjects.com/zos/rmsportal/OKJXDXrmkNshAMvwtvhu.png",
+    title: "V3 Admin Vite 上线啦",
+    datetime: "一年前",
+    description:
+      "一个免费开源的中后台管理系统基础解决方案,基于 Vue3、TypeScript、Element Plus、Pinia 和 Vite 等主流技术"
+  },
+  {
+    avatar: "https://gw.alipayobjects.com/zos/rmsportal/OKJXDXrmkNshAMvwtvhu.png",
+    title: "V3 Admin 上线啦",
+    datetime: "两年前",
+    description: "一个中后台管理系统基础解决方案,基于 Vue3、TypeScript、Element Plus 和 Pinia"
+  }
+]
+
+export const messageData: ListItem[] = [
+  {
+    avatar: "https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png",
+    title: "来自楚门的世界",
+    description: "如果再也不能见到你,祝你早安、午安和晚安",
+    datetime: "1998-06-05"
+  },
+  {
+    avatar: "https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png",
+    title: "来自大话西游",
+    description: "如果非要在这份爱上加上一个期限,我希望是一万年",
+    datetime: "1995-02-04"
+  },
+  {
+    avatar: "https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png",
+    title: "来自龙猫",
+    description: "心存善意,定能途遇天使",
+    datetime: "1988-04-16"
+  }
+]
+
+export const todoData: ListItem[] = [
+  {
+    title: "任务名称",
+    description: "这家伙很懒,什么都没留下",
+    extra: "未开始",
+    status: "info"
+  },
+  {
+    title: "任务名称",
+    description: "这家伙很懒,什么都没留下",
+    extra: "进行中",
+    status: "primary"
+  },
+  {
+    title: "任务名称",
+    description: "这家伙很懒,什么都没留下",
+    extra: "已超时",
+    status: "danger"
+  }
+]

+ 95 - 0
v3-admin-vite-main/src/components/Notify/index.vue

@@ -0,0 +1,95 @@
+<script lang="ts" setup>
+import { ref, computed } from "vue"
+import { ElMessage } from "element-plus"
+import { Bell } from "@element-plus/icons-vue"
+import NotifyList from "./NotifyList.vue"
+import { type ListItem, notifyData, messageData, todoData } from "./data"
+
+type TabName = "通知" | "消息" | "待办"
+
+interface DataItem {
+  name: TabName
+  type: "primary" | "success" | "warning" | "danger" | "info"
+  list: ListItem[]
+}
+
+/** 角标当前值 */
+const badgeValue = computed(() => {
+  return data.value.reduce((sum, item) => sum + item.list.length, 0)
+})
+/** 角标最大值 */
+const badgeMax = 99
+/** 面板宽度 */
+const popoverWidth = 350
+/** 当前 Tab */
+const activeName = ref<TabName>("通知")
+/** 所有数据 */
+const data = ref<DataItem[]>([
+  // 通知数据
+  {
+    name: "通知",
+    type: "primary",
+    list: notifyData
+  },
+  // 消息数据
+  {
+    name: "消息",
+    type: "danger",
+    list: messageData
+  },
+  // 待办数据
+  {
+    name: "待办",
+    type: "warning",
+    list: todoData
+  }
+])
+
+const handleHistory = () => {
+  ElMessage.success(`跳转到${activeName.value}历史页面`)
+}
+</script>
+
+<template>
+  <div class="notify">
+    <el-popover placement="bottom" :width="popoverWidth" trigger="click">
+      <template #reference>
+        <el-badge :value="badgeValue" :max="badgeMax" :hidden="badgeValue === 0">
+          <el-tooltip effect="dark" content="消息通知" placement="bottom">
+            <el-icon :size="20">
+              <Bell />
+            </el-icon>
+          </el-tooltip>
+        </el-badge>
+      </template>
+      <template #default>
+        <el-tabs v-model="activeName" class="demo-tabs" stretch>
+          <el-tab-pane v-for="(item, index) in data" :name="item.name" :key="index">
+            <template #label>
+              {{ item.name }}
+              <el-badge :value="item.list.length" :max="badgeMax" :type="item.type" />
+            </template>
+            <el-scrollbar height="400px">
+              <NotifyList :list="item.list" />
+            </el-scrollbar>
+          </el-tab-pane>
+        </el-tabs>
+        <div class="notify-history">
+          <el-button link @click="handleHistory">查看{{ activeName }}历史</el-button>
+        </div>
+      </template>
+    </el-popover>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.notify {
+  margin-right: 10px;
+  color: var(--el-text-color-regular);
+}
+.notify-history {
+  text-align: center;
+  padding-top: 12px;
+  border-top: 1px solid var(--el-border-color);
+}
+</style>

+ 92 - 0
v3-admin-vite-main/src/components/Screenfull/index.vue

@@ -0,0 +1,92 @@
+<script lang="ts" setup>
+import { computed, ref, watchEffect } from "vue"
+import { ElMessage } from "element-plus"
+import screenfull from "screenfull"
+
+interface Props {
+  /** 全屏的元素,默认是 html */
+  element?: string
+  /** 打开全屏提示语 */
+  openTips?: string
+  /** 关闭全屏提示语 */
+  exitTips?: string
+  /** 是否只针对内容区 */
+  content?: boolean
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  element: "html",
+  openTips: "全屏",
+  exitTips: "退出全屏",
+  content: false
+})
+
+//#region 全屏
+const isFullscreen = ref<boolean>(false)
+const fullscreenTips = computed(() => {
+  return isFullscreen.value ? props.exitTips : props.openTips
+})
+const fullscreenSvgName = computed(() => {
+  return isFullscreen.value ? "fullscreen-exit" : "fullscreen"
+})
+const handleFullscreenClick = () => {
+  const dom = document.querySelector(props.element) || undefined
+  screenfull.isEnabled ? screenfull.toggle(dom) : ElMessage.warning("您的浏览器无法工作")
+}
+const handleFullscreenChange = () => {
+  isFullscreen.value = screenfull.isFullscreen
+}
+watchEffect((onCleanup) => {
+  // 挂载组件时自动执行
+  screenfull.on("change", handleFullscreenChange)
+  // 卸载组件时自动执行
+  onCleanup(() => {
+    screenfull.isEnabled && screenfull.off("change", handleFullscreenChange)
+  })
+})
+//#endregion
+
+//#region 内容区
+const isContentLarge = ref<boolean>(false)
+const contentLargeTips = computed(() => {
+  return isContentLarge.value ? "内容区复原" : "内容区放大"
+})
+const contentLargeSvgName = computed(() => {
+  return isContentLarge.value ? "fullscreen-exit" : "fullscreen"
+})
+const handleContentLargeClick = () => {
+  document.body.className = !isContentLarge.value ? "content-large" : ""
+  isContentLarge.value = !isContentLarge.value
+}
+//#endregion
+</script>
+
+<template>
+  <div>
+    <!-- 全屏 -->
+    <el-tooltip v-if="!content" effect="dark" :content="fullscreenTips" placement="bottom">
+      <SvgIcon :name="fullscreenSvgName" @click="handleFullscreenClick" />
+    </el-tooltip>
+    <!-- 内容区 -->
+    <el-dropdown v-else>
+      <SvgIcon :name="contentLargeSvgName" />
+      <template #dropdown>
+        <el-dropdown-menu>
+          <!-- 内容区放大 -->
+          <el-dropdown-item @click="handleContentLargeClick">{{ contentLargeTips }}</el-dropdown-item>
+          <!-- 内容区全屏 -->
+          <el-dropdown-item @click="handleFullscreenClick" :disabled="isFullscreen">内容区全屏</el-dropdown-item>
+        </el-dropdown-menu>
+      </template>
+    </el-dropdown>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.svg-icon {
+  font-size: 20px;
+  &:focus {
+    outline: none;
+  }
+}
+</style>

+ 54 - 0
v3-admin-vite-main/src/components/SearchMenu/SearchFooter.vue

@@ -0,0 +1,54 @@
+<script lang="ts" setup>
+import { useDevice } from "@/hooks/useDevice"
+
+interface Props {
+  total: number
+}
+
+const props = defineProps<Props>()
+
+const { isMobile } = useDevice()
+</script>
+
+<template>
+  <div class="search-footer">
+    <template v-if="!isMobile">
+      <span class="search-footer-item">
+        <SvgIcon name="keyboard-enter" />
+        <span>确认</span>
+      </span>
+      <span class="search-footer-item">
+        <SvgIcon name="keyboard-up" />
+        <SvgIcon name="keyboard-down" />
+        <span>切换</span>
+      </span>
+      <span class="search-footer-item">
+        <SvgIcon name="keyboard-esc" />
+        <span>关闭</span>
+      </span>
+    </template>
+    <span class="search-footer-total">共 {{ props.total }} 项</span>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.search-footer {
+  display: flex;
+  color: var(--el-text-color-secondary);
+  font-size: 14px;
+  &-item {
+    display: flex;
+    align-items: center;
+    margin-right: 12px;
+    .svg-icon {
+      margin-right: 5px;
+      padding: 2px;
+      font-size: 20px;
+      background-color: var(--el-fill-color);
+    }
+  }
+  &-total {
+    margin: 0 0 0 auto;
+  }
+}
+</style>

+ 202 - 0
v3-admin-vite-main/src/components/SearchMenu/SearchModal.vue

@@ -0,0 +1,202 @@
+<script lang="ts" setup>
+import { computed, ref, shallowRef } from "vue"
+import { type RouteRecordName, type RouteRecordRaw, useRouter } from "vue-router"
+import { usePermissionStore } from "@/store/modules/permission"
+import SearchResult from "./SearchResult.vue"
+import SearchFooter from "./SearchFooter.vue"
+import { ElMessage, ElScrollbar } from "element-plus"
+import { cloneDeep, debounce } from "lodash-es"
+import { useDevice } from "@/hooks/useDevice"
+import { isExternal } from "@/utils/validate"
+
+/** 控制 modal 显隐 */
+const modelValue = defineModel<boolean>({ required: true })
+
+const router = useRouter()
+const { isMobile } = useDevice()
+
+const inputRef = ref<HTMLInputElement | null>(null)
+const scrollbarRef = ref<InstanceType<typeof ElScrollbar> | null>(null)
+const searchResultRef = ref<InstanceType<typeof SearchResult> | null>(null)
+
+const keyword = ref<string>("")
+const resultList = shallowRef<RouteRecordRaw[]>([])
+const activeRouteName = ref<RouteRecordName | undefined>(undefined)
+/** 是否按下了上键或下键(用于解决和 mouseenter 事件的冲突) */
+const isPressUpOrDown = ref<boolean>(false)
+
+/** 控制搜索对话框宽度 */
+const modalWidth = computed(() => (isMobile.value ? "80vw" : "40vw"))
+/** 树形菜单 */
+const menusData = computed(() => cloneDeep(usePermissionStore().routes))
+
+/** 搜索(防抖) */
+const handleSearch = debounce(() => {
+  const flatMenusData = flatTree(menusData.value)
+  resultList.value = flatMenusData.filter((menu) =>
+    keyword.value ? menu.meta?.title?.toLocaleLowerCase().includes(keyword.value.toLocaleLowerCase().trim()) : false
+  )
+  // 默认选中搜索结果的第一项
+  const length = resultList.value?.length
+  activeRouteName.value = length > 0 ? resultList.value[0].name : undefined
+}, 500)
+
+/** 将树形菜单扁平化为一维数组,用于菜单搜索 */
+const flatTree = (arr: RouteRecordRaw[], result: RouteRecordRaw[] = []) => {
+  arr.forEach((item) => {
+    result.push(item)
+    item.children && flatTree(item.children, result)
+  })
+  return result
+}
+
+/** 关闭搜索对话框 */
+const handleClose = () => {
+  modelValue.value = false
+  // 延时处理防止用户看到重置数据的操作
+  setTimeout(() => {
+    keyword.value = ""
+    resultList.value = []
+  }, 200)
+}
+
+/** 根据下标位置进行滚动 */
+const scrollTo = (index: number) => {
+  if (!searchResultRef.value) return
+  const scrollTop = searchResultRef.value.getScrollTop(index)
+  // 手动控制 el-scrollbar 滚动条滚动,设置滚动条到顶部的距离
+  scrollbarRef.value?.setScrollTop(scrollTop)
+}
+
+/** 键盘上键 */
+const handleUp = () => {
+  isPressUpOrDown.value = true
+  const { length } = resultList.value
+  if (length === 0) return
+  // 获取该 name 在菜单中第一次出现的位置
+  const index = resultList.value.findIndex((item) => item.name === activeRouteName.value)
+  // 如果已处在顶部
+  if (index === 0) {
+    const bottomName = resultList.value[length - 1].name
+    // 如果顶部和底部的 bottomName 相同,且长度大于 1,就再跳一个位置(可解决遇到首尾两个相同 name 导致的上键不能生效的问题)
+    if (activeRouteName.value === bottomName && length > 1) {
+      activeRouteName.value = resultList.value[length - 2].name
+      scrollTo(length - 2)
+    } else {
+      // 跳转到底部
+      activeRouteName.value = bottomName
+      scrollTo(length - 1)
+    }
+  } else {
+    activeRouteName.value = resultList.value[index - 1].name
+    scrollTo(index - 1)
+  }
+}
+
+/** 键盘下键 */
+const handleDown = () => {
+  isPressUpOrDown.value = true
+  const { length } = resultList.value
+  if (length === 0) return
+  // 获取该 name 在菜单中最后一次出现的位置(可解决遇到连续两个相同 name 导致的下键不能生效的问题)
+  const index = resultList.value.map((item) => item.name).lastIndexOf(activeRouteName.value)
+  // 如果已处在底部
+  if (index === length - 1) {
+    const topName = resultList.value[0].name
+    // 如果底部和顶部的 topName 相同,且长度大于 1,就再跳一个位置(可解决遇到首尾两个相同 name 导致的下键不能生效的问题)
+    if (activeRouteName.value === topName && length > 1) {
+      activeRouteName.value = resultList.value[1].name
+      scrollTo(1)
+    } else {
+      // 跳转到顶部
+      activeRouteName.value = topName
+      scrollTo(0)
+    }
+  } else {
+    activeRouteName.value = resultList.value[index + 1].name
+    scrollTo(index + 1)
+  }
+}
+
+/** 键盘回车键 */
+const handleEnter = () => {
+  const { length } = resultList.value
+  if (length === 0) return
+  const name = activeRouteName.value
+  const path = resultList.value.find((item) => item.name === name)?.path
+  if (path && isExternal(path)) {
+    window.open(path, "_blank", "noopener, noreferrer")
+    return
+  }
+  if (!name) {
+    ElMessage.warning("无法通过搜索进入该菜单,请为对应的路由设置唯一的 Name")
+    return
+  }
+  try {
+    router.push({ name })
+  } catch {
+    ElMessage.error("该菜单有必填的动态参数,无法通过搜索进入")
+    return
+  }
+  handleClose()
+}
+
+/** 释放上键或下键 */
+const handleReleaseUpOrDown = () => {
+  isPressUpOrDown.value = false
+}
+</script>
+
+<template>
+  <el-dialog
+    v-model="modelValue"
+    @opened="inputRef?.focus()"
+    @closed="inputRef?.blur()"
+    @keydown.up="handleUp"
+    @keydown.down="handleDown"
+    @keydown.enter="handleEnter"
+    @keyup.up.down="handleReleaseUpOrDown"
+    :before-close="handleClose"
+    :width="modalWidth"
+    top="5vh"
+    class="search-modal__private"
+    append-to-body
+  >
+    <el-input ref="inputRef" v-model="keyword" @input="handleSearch" placeholder="搜索菜单" size="large" clearable>
+      <template #prefix>
+        <SvgIcon name="search" />
+      </template>
+    </el-input>
+    <el-empty v-if="resultList.length === 0" description="暂无搜索结果" :image-size="100" />
+    <template v-else>
+      <p>搜索结果</p>
+      <el-scrollbar ref="scrollbarRef" max-height="40vh" always>
+        <SearchResult
+          ref="searchResultRef"
+          v-model="activeRouteName"
+          :list="resultList"
+          :isPressUpOrDown="isPressUpOrDown"
+          @click="handleEnter"
+        />
+      </el-scrollbar>
+    </template>
+    <template #footer>
+      <SearchFooter :total="resultList.length" />
+    </template>
+  </el-dialog>
+</template>
+
+<style lang="scss">
+.search-modal__private {
+  .svg-icon {
+    font-size: 18px;
+  }
+  .el-dialog__header {
+    display: none;
+  }
+  .el-dialog__footer {
+    border-top: 1px solid var(--el-border-color);
+    padding: var(--el-dialog-padding-primary);
+  }
+}
+</style>

+ 110 - 0
v3-admin-vite-main/src/components/SearchMenu/SearchResult.vue

@@ -0,0 +1,110 @@
+<script lang="ts" setup>
+import { getCurrentInstance, onBeforeMount, onBeforeUnmount, onMounted, ref } from "vue"
+import { type RouteRecordName, type RouteRecordRaw } from "vue-router"
+
+interface Props {
+  list: RouteRecordRaw[]
+  isPressUpOrDown: boolean
+}
+
+/** 选中的菜单 */
+const modelValue = defineModel<RouteRecordName | undefined>({ required: true })
+const props = defineProps<Props>()
+
+const instance = getCurrentInstance()
+const scrollbarHeight = ref<number>(0)
+
+/** 菜单的样式 */
+const itemStyle = (item: RouteRecordRaw) => {
+  const flag = item.name === modelValue.value
+  return {
+    background: flag ? "var(--el-color-primary)" : "",
+    color: flag ? "#fff" : ""
+  }
+}
+
+/** 鼠标移入 */
+const handleMouseenter = (item: RouteRecordRaw) => {
+  // 如果上键或下键与 mouseenter 事件同时生效,则以上下键为准,不执行该函数的赋值逻辑
+  if (props.isPressUpOrDown) return
+  modelValue.value = item.name
+}
+
+/** 计算滚动可视区高度 */
+const getScrollbarHeight = () => {
+  // el-scrollbar max-height="40vh"
+  scrollbarHeight.value = Number((window.innerHeight * 0.4).toFixed(1))
+}
+
+/** 根据下标计算到顶部的距离 */
+const getScrollTop = (index: number) => {
+  const currentInstance = instance?.proxy?.$refs[`resultItemRef${index}`] as HTMLDivElement[]
+  if (!currentInstance) return 0
+  const currentRef = currentInstance[0]
+  const scrollTop = currentRef.offsetTop + 128 // 128 = 两个 result-item (56 + 56 = 112)高度与上下 margin(8 + 8 = 16)大小之和
+  return scrollTop > scrollbarHeight.value ? scrollTop - scrollbarHeight.value : 0
+}
+
+/** 在组件挂载前添加窗口大小变化事件监听器 */
+onBeforeMount(() => {
+  window.addEventListener("resize", getScrollbarHeight)
+})
+
+/** 在组件挂载时立即计算滚动可视区高度 */
+onMounted(() => {
+  getScrollbarHeight()
+})
+
+/** 在组件卸载前移除窗口大小变化事件监听器 */
+onBeforeUnmount(() => {
+  window.removeEventListener("resize", getScrollbarHeight)
+})
+
+defineExpose({ getScrollTop })
+</script>
+
+<template>
+  <!-- 外层 div 不能删除,是用来接收父组件 click 事件的 -->
+  <div>
+    <div
+      v-for="(item, index) in list"
+      :key="index"
+      :ref="`resultItemRef${index}`"
+      class="result-item"
+      :style="itemStyle(item)"
+      @mouseenter="handleMouseenter(item)"
+    >
+      <SvgIcon v-if="item.meta?.svgIcon" :name="item.meta.svgIcon" />
+      <component v-else-if="item.meta?.elIcon" :is="item.meta.elIcon" class="el-icon" />
+      <span class="result-item-title">
+        {{ item.meta?.title }}
+      </span>
+      <SvgIcon v-if="modelValue && modelValue === item.name" name="keyboard-enter" />
+    </div>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.result-item {
+  display: flex;
+  align-items: center;
+  height: 56px;
+  padding: 0 15px;
+  margin-top: 8px;
+  border: 1px solid var(--el-border-color);
+  border-radius: 4px;
+  cursor: pointer;
+  .svg-icon {
+    min-width: 1em;
+    font-size: 18px;
+  }
+  .el-icon {
+    width: 1em;
+    font-size: 18px;
+  }
+  &-title {
+    flex: 1;
+    margin-left: 12px;
+  }
+}
+</style>

+ 29 - 0
v3-admin-vite-main/src/components/SearchMenu/index.vue

@@ -0,0 +1,29 @@
+<script lang="ts" setup>
+import { ref } from "vue"
+import SearchModal from "./SearchModal.vue"
+
+/** 控制 modal 显隐 */
+const modalVisible = ref<boolean>(false)
+/** 打开 modal */
+const handleOpen = () => {
+  modalVisible.value = true
+}
+</script>
+
+<template>
+  <div>
+    <el-tooltip effect="dark" content="搜索菜单" placement="bottom">
+      <SvgIcon name="search" @click="handleOpen" />
+    </el-tooltip>
+    <SearchModal v-model="modalVisible" />
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.svg-icon {
+  font-size: 20px;
+  &:focus {
+    outline: none;
+  }
+}
+</style>

+ 29 - 0
v3-admin-vite-main/src/components/SvgIcon/index.vue

@@ -0,0 +1,29 @@
+<script lang="ts" setup>
+import { computed } from "vue"
+
+interface Props {
+  prefix?: string
+  name: string
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  prefix: "icon"
+})
+
+const symbolId = computed(() => `#${props.prefix}-${props.name}`)
+</script>
+
+<template>
+  <svg class="svg-icon" aria-hidden="true">
+    <use :href="symbolId" />
+  </svg>
+</template>
+
+<style lang="scss" scoped>
+.svg-icon {
+  width: 1em;
+  height: 1em;
+  fill: currentColor;
+  overflow: hidden;
+}
+</style>

+ 30 - 0
v3-admin-vite-main/src/components/ThemeSwitch/index.vue

@@ -0,0 +1,30 @@
+<script lang="ts" setup>
+import { useTheme } from "@/hooks/useTheme"
+import { MagicStick } from "@element-plus/icons-vue"
+
+const { themeList, activeThemeName, setTheme } = useTheme()
+</script>
+
+<template>
+  <el-dropdown trigger="click" @command="setTheme">
+    <div>
+      <el-tooltip effect="dark" content="主题模式" placement="bottom">
+        <el-icon :size="20">
+          <MagicStick />
+        </el-icon>
+      </el-tooltip>
+    </div>
+    <template #dropdown>
+      <el-dropdown-menu>
+        <el-dropdown-item
+          v-for="(theme, index) in themeList"
+          :key="index"
+          :disabled="activeThemeName === theme.name"
+          :command="theme.name"
+        >
+          <span>{{ theme.title }}</span>
+        </el-dropdown-item>
+      </el-dropdown-menu>
+    </template>
+  </el-dropdown>
+</template>

+ 49 - 0
v3-admin-vite-main/src/config/layouts.ts

@@ -0,0 +1,49 @@
+import { getConfigLayout } from "@/utils/cache/local-storage"
+import { LayoutModeEnum } from "@/constants/app-key"
+
+/** 项目配置类型 */
+export interface LayoutSettings {
+  /** 是否显示 Settings Panel */
+  showSettings: boolean
+  /** 布局模式 */
+  layoutMode: LayoutModeEnum
+  /** 是否显示标签栏 */
+  showTagsView: boolean
+  /** 是否显示 Logo */
+  showLogo: boolean
+  /** 是否固定 Header */
+  fixedHeader: boolean
+  /** 是否显示页脚 Footer */
+  showFooter: boolean
+  /** 是否显示消息通知 */
+  showNotify: boolean
+  /** 是否显示切换主题按钮 */
+  showThemeSwitch: boolean
+  /** 是否显示全屏按钮 */
+  showScreenfull: boolean
+  /** 是否显示搜索按钮 */
+  showSearchMenu: boolean
+  /** 是否缓存标签栏 */
+  cacheTagsView: boolean
+  /** 是否显示灰色模式 */
+  showGreyMode: boolean
+}
+
+/** 默认配置 */
+const defaultSettings: LayoutSettings = {
+  layoutMode: LayoutModeEnum.Left,
+  showSettings: true,
+  showTagsView: true,
+  fixedHeader: true,
+  showFooter: true,
+  showLogo: true,
+  showNotify: true,
+  showThemeSwitch: true,
+  showScreenfull: true,
+  showSearchMenu: true,
+  cacheTagsView: false,
+  showGreyMode: false,
+}
+
+/** 项目配置 */
+export const layoutSettings: LayoutSettings = { ...defaultSettings, ...getConfigLayout() }

+ 28 - 0
v3-admin-vite-main/src/config/route.ts

@@ -0,0 +1,28 @@
+/** 路由配置 */
+interface RouteSettings {
+  /**
+   * 是否开启动态路由功能?
+   * 1. 开启后需要后端配合,在查询用户详情接口返回当前用户可以用来判断并加载动态路由的字段(该项目用的是角色 roles 字段)
+   * 2. 假如项目不需要根据不同的用户来显示不同的页面,则应该将 dynamic: false
+   */
+  dynamic: boolean
+  /** 当动态路由功能关闭时:
+   * 1. 应该将所有路由都写到常驻路由里面(表明所有登录的用户能访问的页面都是一样的)
+   * 2. 系统自动给当前登录用户赋值一个没有任何作用的默认角色
+   */
+  defaultRoles: Array<string>
+  /**
+   * 是否开启三级及其以上路由缓存功能?
+   * 1. 开启后会进行路由降级(把三级及其以上的路由转化为二级路由)
+   * 2. 由于都会转成二级路由,所以二级及其以上路由有内嵌子路由将会失效
+   */
+  thirdLevelRouteCache: boolean
+}
+
+const routeSettings: RouteSettings = {
+  dynamic: true,
+  defaultRoles: ["DEFAULT_ROLE"],
+  thirdLevelRouteCache: false
+}
+
+export default routeSettings

+ 15 - 0
v3-admin-vite-main/src/config/white-list.ts

@@ -0,0 +1,15 @@
+import { type RouteLocationNormalized } from "vue-router"
+
+/** 免登录白名单(匹配路由 path) */
+const whiteListByPath: string[] = ["/login"]
+
+/** 免登录白名单(匹配路由 name) */
+const whiteListByName: string[] = []
+
+/** 判断是否在白名单 */
+const isWhiteList = (to: RouteLocationNormalized) => {
+  // path 和 name 任意一个匹配上即可
+  return whiteListByPath.indexOf(to.path) !== -1 || whiteListByName.indexOf(to.name as any) !== -1
+}
+
+export default isWhiteList

+ 20 - 0
v3-admin-vite-main/src/constants/app-key.ts

@@ -0,0 +1,20 @@
+/** 设备类型 */
+export enum DeviceEnum {
+  Mobile,
+  Desktop
+}
+
+/** 布局模式 */
+export enum LayoutModeEnum {
+  Left = "left",
+  Top = "top",
+  LeftTop = "left-top"
+}
+
+/** 侧边栏打开状态常量 */
+export const SIDEBAR_OPENED = "opened"
+/** 侧边栏关闭状态常量 */
+export const SIDEBAR_CLOSED = "closed"
+
+export type SidebarOpened = typeof SIDEBAR_OPENED
+export type SidebarClosed = typeof SIDEBAR_CLOSED

+ 13 - 0
v3-admin-vite-main/src/constants/cache-key.ts

@@ -0,0 +1,13 @@
+const SYSTEM_NAME = "v3-admin-vite"
+
+/** 缓存数据时用到的 Key */
+class CacheKey {
+  static readonly TOKEN = `${SYSTEM_NAME}-token-key`
+  static readonly CONFIG_LAYOUT = `${SYSTEM_NAME}-config-layout-key`
+  static readonly SIDEBAR_STATUS = `${SYSTEM_NAME}-sidebar-status-key`
+  static readonly ACTIVE_THEME_NAME = `${SYSTEM_NAME}-active-theme-name-key`
+  static readonly VISITED_VIEWS = `${SYSTEM_NAME}-visited-views-key`
+  static readonly CACHED_VIEWS = `${SYSTEM_NAME}-cached-views-key`
+}
+
+export default CacheKey

+ 7 - 0
v3-admin-vite-main/src/directives/index.ts

@@ -0,0 +1,7 @@
+import { type App } from "vue"
+import { permission } from "./permission"
+
+/** 挂载自定义指令 */
+export function loadDirectives(app: App) {
+  app.directive("permission", permission)
+}

+ 17 - 0
v3-admin-vite-main/src/directives/permission/index.ts

@@ -0,0 +1,17 @@
+import { type Directive } from "vue"
+import { useUserStoreHook } from "@/store/modules/user"
+
+/** 权限指令,和权限判断函数 checkPermission 功能类似 */
+export const permission: Directive = {
+  mounted(el, binding) {
+    const { value: permissionRoles } = binding
+    const { roles } = useUserStoreHook()
+    if (Array.isArray(permissionRoles) && permissionRoles.length > 0) {
+      const hasPermission = roles.some((role) => permissionRoles.includes(role))
+      // hasPermission || (el.style.display = "none") // 隐藏
+      hasPermission || el.parentNode?.removeChild(el) // 销毁
+    } else {
+      throw new Error(`need roles! Like v-permission="['admin','editor']"`)
+    }
+  }
+}

+ 11 - 0
v3-admin-vite-main/src/hooks/useDevice.ts

@@ -0,0 +1,11 @@
+import { computed } from "vue"
+import { useAppStore } from "@/store/modules/app"
+import { DeviceEnum } from "@/constants/app-key"
+
+const appStore = useAppStore()
+const isMobile = computed(() => appStore.device === DeviceEnum.Mobile)
+const isDesktop = computed(() => appStore.device === DeviceEnum.Desktop)
+
+export function useDevice() {
+  return { isMobile, isDesktop }
+}

+ 49 - 0
v3-admin-vite-main/src/hooks/useFetchSelect.ts

@@ -0,0 +1,49 @@
+import { ref, onMounted } from "vue"
+
+type OptionValue = string | number
+
+/** Select 需要的数据格式 */
+interface SelectOption {
+  value: OptionValue
+  label: string
+  disabled?: boolean
+}
+
+/** 接口响应格式 */
+type ApiData = ApiResponseData<SelectOption[]>
+
+/** 入参格式,暂时只需要传递 api 函数即可 */
+interface FetchSelectProps {
+  api: () => Promise<ApiData>
+}
+
+export function useFetchSelect(props: FetchSelectProps) {
+  const { api } = props
+
+  const loading = ref<boolean>(false)
+  const options = ref<SelectOption[]>([])
+  const value = ref<OptionValue>("")
+
+  /** 调用接口获取数据 */
+  const loadData = () => {
+    loading.value = true
+    options.value = []
+    api()
+      .then((res) => {
+        options.value = res.data
+      })
+      .finally(() => {
+        loading.value = false
+      })
+  }
+
+  onMounted(() => {
+    loadData()
+  })
+
+  return {
+    loading,
+    options,
+    value
+  }
+}

+ 35 - 0
v3-admin-vite-main/src/hooks/useFullscreenLoading.ts

@@ -0,0 +1,35 @@
+import { type LoadingOptions, ElLoading } from "element-plus"
+
+const defaultOptions = {
+  lock: true,
+  text: "加载中..."
+}
+
+interface LoadingInstance {
+  close: () => void
+}
+
+interface UseFullscreenLoading {
+  <T extends (...args: any[]) => ReturnType<T>>(
+    fn: T,
+    options?: LoadingOptions
+  ): (...args: Parameters<T>) => Promise<ReturnType<T>>
+}
+
+/**
+ * 传入一个函数 fn,在它执行周期内,加上「全屏」loading
+ * @param fn 要执行的函数
+ * @param options LoadingOptions
+ * @returns 返回一个新的函数,该函数返回一个 Promise
+ */
+export const useFullscreenLoading: UseFullscreenLoading = (fn, options = {}) => {
+  let loadingInstance: LoadingInstance
+  return async (...args) => {
+    try {
+      loadingInstance = ElLoading.service({ ...defaultOptions, ...options })
+      return await fn(...args)
+    } finally {
+      loadingInstance?.close()
+    }
+  }
+}

+ 16 - 0
v3-admin-vite-main/src/hooks/useLayoutMode.ts

@@ -0,0 +1,16 @@
+import { computed } from "vue"
+import { useSettingsStore } from "@/store/modules/settings"
+import { LayoutModeEnum } from "@/constants/app-key"
+
+const settingsStore = useSettingsStore()
+const isLeft = computed(() => settingsStore.layoutMode === LayoutModeEnum.Left)
+const isTop = computed(() => settingsStore.layoutMode === LayoutModeEnum.Top)
+const isLeftTop = computed(() => settingsStore.layoutMode === LayoutModeEnum.LeftTop)
+
+const setLayoutMode = (mode: LayoutModeEnum) => {
+  settingsStore.layoutMode = mode
+}
+
+export function useLayoutMode() {
+  return { isLeft, isTop, isLeftTop, setLayoutMode }
+}

+ 42 - 0
v3-admin-vite-main/src/hooks/usePagination.ts

@@ -0,0 +1,42 @@
+import { reactive } from "vue"
+
+interface DefaultPaginationData {
+  total: number
+  currentPage: number
+  pageSizes: number[]
+  pageSize: number
+  layout: string
+  offset?: number
+}
+
+interface PaginationData {
+  total?: number
+  currentPage?: number
+  pageSizes?: number[]
+  pageSize?: number
+  layout?: string
+}
+
+/** 默认的分页参数 */
+const defaultPaginationData: DefaultPaginationData = {
+  total: 0,
+  currentPage: 1,
+  pageSizes: [10, 20, 50],
+  pageSize: 10,
+  layout: "total, sizes, prev, pager, next, jumper"
+}
+
+export function usePagination(initialPaginationData: PaginationData = {}) {
+  /** 合并分页参数 */
+  const paginationData = reactive({ ...defaultPaginationData, ...initialPaginationData })
+  /** 改变当前页码 */
+  const handleCurrentChange = (value: number) => {
+    paginationData.currentPage = value
+  }
+  /** 改变页面大小 */
+  const handleSizeChange = (value: number) => {
+    paginationData.pageSize = value
+  }
+
+  return { paginationData, handleCurrentChange, handleSizeChange }
+}

+ 48 - 0
v3-admin-vite-main/src/hooks/useRouteListener.ts

@@ -0,0 +1,48 @@
+import { onBeforeUnmount } from "vue"
+import mitt, { type Handler } from "mitt"
+import { type RouteLocationNormalized } from "vue-router"
+
+/** 回调函数的类型 */
+type Callback = (route: RouteLocationNormalized) => void
+
+const emitter = mitt()
+const key = Symbol("ROUTE_CHANGE")
+let latestRoute: RouteLocationNormalized
+
+/** 设置最新的路由信息,触发路由变化事件 */
+export const setRouteChange = (to: RouteLocationNormalized) => {
+  // 触发事件
+  emitter.emit(key, to)
+  // 缓存最新的路由信息
+  latestRoute = to
+}
+
+/** 单独监听路由会浪费渲染性能,使用发布订阅模式去进行分发管理 */
+export function useRouteListener() {
+  /** 回调函数集合 */
+  const callbackList: Callback[] = []
+
+  /** 监听路由变化(可以选择立即执行) */
+  const listenerRouteChange = (callback: Callback, immediate = false) => {
+    // 缓存回调函数
+    callbackList.push(callback)
+    // 监听事件
+    emitter.on(key, callback as Handler)
+    // 可以选择立即执行一次回调函数
+    immediate && latestRoute && callback(latestRoute)
+  }
+
+  /** 移除路由变化事件监听器 */
+  const removeRouteListener = (callback: Callback) => {
+    emitter.off(key, callback as Handler)
+  }
+
+  /** 组件销毁前移除监听器 */
+  onBeforeUnmount(() => {
+    for (let i = 0; i < callbackList.length; i++) {
+      removeRouteListener(callbackList[i])
+    }
+  })
+
+  return { listenerRouteChange, removeRouteListener }
+}

+ 57 - 0
v3-admin-vite-main/src/hooks/useTheme.ts

@@ -0,0 +1,57 @@
+import { ref, watchEffect } from "vue"
+import { getActiveThemeName, setActiveThemeName } from "@/utils/cache/local-storage"
+
+const DEFAULT_THEME_NAME = "normal"
+type DefaultThemeName = typeof DEFAULT_THEME_NAME
+
+/** 注册的主题名称, 其中 DefaultThemeName 是必填的 */
+export type ThemeName = DefaultThemeName | "dark" | "dark-blue"
+
+interface ThemeList {
+  title: string
+  name: ThemeName
+}
+
+/** 主题列表 */
+const themeList: ThemeList[] = [
+  {
+    title: "默认",
+    name: DEFAULT_THEME_NAME
+  },
+  {
+    title: "黑暗",
+    name: "dark"
+  },
+  {
+    title: "深蓝",
+    name: "dark-blue"
+  }
+]
+
+/** 正在应用的主题名称 */
+const activeThemeName = ref<ThemeName>(getActiveThemeName() || DEFAULT_THEME_NAME)
+
+/** 设置主题 */
+const setTheme = (value: ThemeName) => {
+  activeThemeName.value = value
+}
+
+/** 在 html 根元素上挂载 class */
+const setHtmlRootClassName = (value: ThemeName) => {
+  document.documentElement.className = value
+}
+
+/** 初始化 */
+const initTheme = () => {
+  // watchEffect 来收集副作用
+  watchEffect(() => {
+    const value = activeThemeName.value
+    setHtmlRootClassName(value)
+    setActiveThemeName(value)
+  })
+}
+
+/** 主题 hook */
+export function useTheme() {
+  return { themeList, activeThemeName, initTheme, setTheme }
+}

+ 23 - 0
v3-admin-vite-main/src/hooks/useTitle.ts

@@ -0,0 +1,23 @@
+import { ref, watch } from "vue"
+
+/** 项目标题 */
+const VITE_APP_TITLE = import.meta.env.VITE_APP_TITLE ?? "爱养牛"
+
+/** 动态标题 */
+const dynamicTitle = ref<string>("")
+
+/** 设置标题 */
+const setTitle = (title?: string) => {
+  dynamicTitle.value = title ? `${VITE_APP_TITLE} | ${title}` : VITE_APP_TITLE
+}
+
+/** 监听标题变化 */
+watch(dynamicTitle, (value, oldValue) => {
+  if (document && value !== oldValue) {
+    document.title = value
+  }
+})
+
+export function useTitle() {
+  return { setTitle }
+}

+ 7 - 0
v3-admin-vite-main/src/icons/index.ts

@@ -0,0 +1,7 @@
+import { type App } from "vue"
+import SvgIcon from "@/components/SvgIcon/index.vue" // Svg Component
+import "virtual:svg-icons-register"
+
+export function loadSvg(app: App) {
+  app.component("SvgIcon", SvgIcon)
+}

Разница между файлами не показана из-за своего большого размера
+ 1 - 0
v3-admin-vite-main/src/icons/svg/404.svg


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
v3-admin-vite-main/src/icons/svg/bug.svg


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
v3-admin-vite-main/src/icons/svg/component.svg


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
v3-admin-vite-main/src/icons/svg/dashboard.svg


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
v3-admin-vite-main/src/icons/svg/fullscreen-exit.svg


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
v3-admin-vite-main/src/icons/svg/fullscreen.svg


+ 1 - 0
v3-admin-vite-main/src/icons/svg/keyboard-down.svg

@@ -0,0 +1 @@
+<svg width="15" height="15" aria-label="Arrow down" role="img"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2"><path d="M7.5 3.5v8M10.5 8.5l-3 3-3-3"></path></g></svg>

+ 1 - 0
v3-admin-vite-main/src/icons/svg/keyboard-enter.svg

@@ -0,0 +1 @@
+<svg width="15" height="15" aria-label="Enter key" role="img"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2"><path d="M12 3.53088v3c0 1-1 2-2 2H4M7 11.53088l-3-3 3-3"></path></g></svg>

Разница между файлами не показана из-за своего большого размера
+ 1 - 0
v3-admin-vite-main/src/icons/svg/keyboard-esc.svg


+ 1 - 0
v3-admin-vite-main/src/icons/svg/keyboard-up.svg

@@ -0,0 +1 @@
+<svg width="15" height="15" aria-label="Arrow up" role="img"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2"><path d="M7.5 11.5v-8M10.5 6.5l-3-3-3 3"></path></g></svg>

Разница между файлами не показана из-за своего большого размера
+ 1 - 0
v3-admin-vite-main/src/icons/svg/link.svg


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
v3-admin-vite-main/src/icons/svg/lock.svg


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
v3-admin-vite-main/src/icons/svg/menu.svg


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
v3-admin-vite-main/src/icons/svg/search.svg


+ 5 - 0
v3-admin-vite-main/src/icons/svg/unocss.svg

@@ -0,0 +1,5 @@
+<svg width="220" height="220" viewBox="0 0 220 220" xmlns="http://www.w3.org/2000/svg">
+<path d="M117.444 167.888C117.444 140.273 139.83 117.888 167.444 117.888V117.888C195.058 117.888 217.444 140.273 217.444 167.888V167.888C217.444 195.502 195.058 217.888 167.444 217.888V217.888C139.83 217.888 117.444 195.502 117.444 167.888V167.888Z"/>
+<path d="M117.444 53C117.444 25.3858 139.83 3 167.444 3V3C195.058 3 217.444 25.3858 217.444 53V98C217.444 100.761 215.205 103 212.444 103H122.444C119.683 103 117.444 100.761 117.444 98V53Z"/>
+<path d="M102 167.888C102 195.502 79.6142 217.888 52 217.888V217.888C24.3858 217.888 2 195.502 2 167.888L2.00001 122.888C2.00001 120.126 4.23859 117.888 7.00001 117.888L97 117.888C99.7614 117.888 102 120.126 102 122.888L102 167.888Z"/>
+</svg>

+ 168 - 0
v3-admin-vite-main/src/layouts/LeftMode.vue

@@ -0,0 +1,168 @@
+<script lang="ts" setup>
+import { computed } from "vue"
+import { storeToRefs } from "pinia"
+import { useAppStore } from "@/store/modules/app"
+import { useSettingsStore } from "@/store/modules/settings"
+import { AppMain, NavigationBar, Sidebar, TagsView } from "./components"
+import { useDevice } from "@/hooks/useDevice"
+
+const { isMobile } = useDevice()
+const appStore = useAppStore()
+const settingsStore = useSettingsStore()
+const { showTagsView, fixedHeader } = storeToRefs(settingsStore)
+
+/** 定义计算属性 layoutClasses,用于控制布局的类名 */
+const layoutClasses = computed(() => {
+  return {
+    hideSidebar: !appStore.sidebar.opened,
+    openSidebar: appStore.sidebar.opened,
+    withoutAnimation: appStore.sidebar.withoutAnimation,
+    mobile: isMobile.value
+  }
+})
+
+/** 用于处理点击 mobile 端侧边栏遮罩层的事件 */
+const handleClickOutside = () => {
+  appStore.closeSidebar(false)
+}
+</script>
+
+<template>
+  <div :class="layoutClasses" class="app-wrapper">
+    <!-- mobile 端侧边栏遮罩层 -->
+    <div v-if="layoutClasses.mobile && layoutClasses.openSidebar" class="drawer-bg" @click="handleClickOutside" />
+    <!-- 左侧边栏 -->
+    <Sidebar class="sidebar-container" />
+    <!-- 主容器 -->
+    <div :class="{ hasTagsView: showTagsView }" class="main-container">
+      <!-- 头部导航栏和标签栏 -->
+      <div :class="{ 'fixed-header': fixedHeader }" class="layout-header">
+        <NavigationBar />
+        <TagsView v-show="showTagsView" />
+      </div>
+      <!-- 页面主体内容 -->
+      <AppMain class="app-main" />
+    </div>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+@import "@/styles/mixins.scss";
+$transition-time: 0.35s;
+
+.app-wrapper {
+  @extend %clearfix;
+  position: relative;
+  width: 100%;
+}
+
+.drawer-bg {
+  background-color: #000;
+  opacity: 0.3;
+  width: 100%;
+  top: 0;
+  height: 100%;
+  position: absolute;
+  z-index: 999;
+}
+
+.sidebar-container {
+  background-color: var(--v3-sidebar-menu-bg-color);
+  transition: width $transition-time;
+  width: var(--v3-sidebar-width) !important;
+  height: 100%;
+  position: fixed;
+  top: 0;
+  bottom: 0;
+  left: 0;
+  z-index: 1001;
+  overflow: hidden;
+}
+
+.main-container {
+  min-height: 100%;
+  transition: margin-left $transition-time;
+  margin-left: var(--v3-sidebar-width);
+  position: relative;
+}
+
+.fixed-header {
+  position: fixed !important;
+  top: 0;
+  right: 0;
+  z-index: 9;
+  width: calc(100% - var(--v3-sidebar-width));
+  transition: width $transition-time;
+}
+
+.layout-header {
+  position: relative;
+  z-index: 9;
+  box-shadow: var(--el-box-shadow-lighter);
+}
+
+.app-main {
+  min-height: calc(100vh - var(--v3-navigationbar-height));
+  position: relative;
+  overflow: hidden;
+}
+
+.fixed-header + .app-main {
+  padding-top: var(--v3-navigationbar-height);
+  height: 100vh;
+  overflow: auto;
+}
+
+.hasTagsView {
+  .app-main {
+    min-height: calc(100vh - var(--v3-header-height));
+  }
+  .fixed-header + .app-main {
+    padding-top: var(--v3-header-height);
+  }
+}
+
+.hideSidebar {
+  .sidebar-container {
+    width: var(--v3-sidebar-hide-width) !important;
+  }
+  .main-container {
+    margin-left: var(--v3-sidebar-hide-width);
+  }
+  .fixed-header {
+    width: calc(100% - var(--v3-sidebar-hide-width));
+  }
+}
+
+// 适配 mobile 端
+.mobile {
+  .sidebar-container {
+    transition: transform $transition-time;
+    width: var(--v3-sidebar-width) !important;
+  }
+  .main-container {
+    margin-left: 0px;
+  }
+  .fixed-header {
+    width: 100%;
+  }
+  &.openSidebar {
+    position: fixed;
+    top: 0;
+  }
+  &.hideSidebar {
+    .sidebar-container {
+      pointer-events: none;
+      transition-duration: 0.3s;
+      transform: translate3d(calc(0px - var(--v3-sidebar-width)), 0, 0);
+    }
+  }
+}
+
+.withoutAnimation {
+  .sidebar-container,
+  .main-container {
+    transition: none;
+  }
+}
+</style>

+ 109 - 0
v3-admin-vite-main/src/layouts/LeftTopMode.vue

@@ -0,0 +1,109 @@
+<script lang="ts" setup>
+import { computed } from "vue"
+import { storeToRefs } from "pinia"
+import { useAppStore } from "@/store/modules/app"
+import { useSettingsStore } from "@/store/modules/settings"
+import { AppMain, NavigationBar, Sidebar, TagsView, Logo } from "./components"
+
+const appStore = useAppStore()
+const settingsStore = useSettingsStore()
+const { showTagsView, showLogo } = storeToRefs(settingsStore)
+
+/** 定义计算属性 layoutClasses,用于控制布局的类名 */
+const layoutClasses = computed(() => {
+  return {
+    hideSidebar: !appStore.sidebar.opened
+  }
+})
+</script>
+
+<template>
+  <div :class="layoutClasses" class="app-wrapper">
+    <!-- 头部导航栏和标签栏 -->
+    <div class="fixed-header layout-header">
+      <Logo v-if="showLogo" :collapse="false" class="logo" />
+      <div class="content">
+        <NavigationBar />
+        <TagsView v-show="showTagsView" />
+      </div>
+    </div>
+    <!-- 主容器 -->
+    <div :class="{ hasTagsView: showTagsView }" class="main-container">
+      <!-- 左侧边栏 -->
+      <Sidebar class="sidebar-container" />
+      <!-- 页面主体内容 -->
+      <AppMain class="app-main" />
+    </div>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+@import "@/styles/mixins.scss";
+$transition-time: 0.35s;
+
+.app-wrapper {
+  @extend %clearfix;
+  width: 100%;
+}
+
+.fixed-header {
+  position: fixed;
+  top: 0;
+  z-index: 1002;
+  width: 100%;
+  display: flex;
+  .logo {
+    width: var(--v3-sidebar-width);
+  }
+  .content {
+    flex: 1;
+    position: relative;
+  }
+}
+
+.layout-header {
+  background-color: var(--v3-header-bg-color);
+  box-shadow: var(--el-box-shadow-lighter);
+}
+
+.main-container {
+  min-height: 100%;
+}
+
+.sidebar-container {
+  transition: width $transition-time;
+  width: var(--v3-sidebar-width) !important;
+  height: 100%;
+  position: fixed;
+  left: 0;
+  z-index: 1001;
+  overflow: hidden;
+  padding-top: var(--v3-navigationbar-height);
+}
+
+.app-main {
+  transition: padding-left $transition-time;
+  padding-top: var(--v3-navigationbar-height);
+  padding-left: var(--v3-sidebar-width);
+  height: 100vh;
+  overflow: auto;
+}
+
+.hideSidebar {
+  .sidebar-container {
+    width: var(--v3-sidebar-hide-width) !important;
+  }
+  .app-main {
+    padding-left: var(--v3-sidebar-hide-width);
+  }
+}
+
+.hasTagsView {
+  .sidebar-container {
+    padding-top: var(--v3-header-height);
+  }
+  .app-main {
+    padding-top: var(--v3-header-height);
+  }
+}
+</style>

+ 75 - 0
v3-admin-vite-main/src/layouts/TopMode.vue

@@ -0,0 +1,75 @@
+<script lang="ts" setup>
+import { storeToRefs } from "pinia"
+import { useSettingsStore } from "@/store/modules/settings"
+import { AppMain, NavigationBar, TagsView, Logo } from "./components"
+
+const settingsStore = useSettingsStore()
+const { showTagsView, showLogo } = storeToRefs(settingsStore)
+</script>
+
+<template>
+  <div class="app-wrapper">
+    <!-- 头部导航栏和标签栏 -->
+    <div class="fixed-header layout-header">
+      <div class="content">
+        <Logo v-if="showLogo" :collapse="false" class="logo" />
+        <NavigationBar class="navigation-bar" />
+      </div>
+      <TagsView v-show="showTagsView" />
+    </div>
+    <!-- 主容器 -->
+    <div :class="{ hasTagsView: showTagsView }" class="main-container">
+      <!-- 页面主体内容 -->
+      <AppMain class="app-main" />
+    </div>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+@import "@/styles/mixins.scss";
+$transition-time: 0.35s;
+
+.app-wrapper {
+  @extend %clearfix;
+  width: 100%;
+}
+
+.fixed-header {
+  position: fixed;
+  top: 0;
+  z-index: 1002;
+  width: 100%;
+  .logo {
+    width: var(--v3-sidebar-width);
+  }
+  .content {
+    display: flex;
+    .navigation-bar {
+      flex: 1;
+      background: transparent;
+    }
+  }
+}
+
+.layout-header {
+  background-color: var(--v3-header-bg-color);
+  box-shadow: var(--el-box-shadow-lighter);
+}
+
+.main-container {
+  min-height: 100%;
+}
+
+.app-main {
+  transition: padding-left $transition-time;
+  padding-top: var(--v3-navigationbar-height);
+  height: 100vh;
+  overflow: auto;
+}
+
+.hasTagsView {
+  .app-main {
+    padding-top: var(--v3-header-height);
+  }
+}
+</style>

+ 50 - 0
v3-admin-vite-main/src/layouts/components/AppMain.vue

@@ -0,0 +1,50 @@
+<script lang="ts" setup>
+import { useTagsViewStore } from "@/store/modules/tags-view"
+import { useSettingsStore } from "@/store/modules/settings"
+import Footer from "./Footer/index.vue"
+
+const tagsViewStore = useTagsViewStore()
+const settingsStore = useSettingsStore()
+</script>
+
+<template>
+  <section class="app-main">
+    <div class="app-scrollbar">
+      <!-- key 采用 route.path 和 route.fullPath 有着不同的效果,大多数时候 path 更通用 -->
+      <router-view v-slot="{ Component, route }">
+        <transition name="el-fade-in" mode="out-in">
+          <keep-alive :include="tagsViewStore.cachedViews">
+            <component :is="Component" :key="route.path" class="app-container-grow" />
+          </keep-alive>
+        </transition>
+      </router-view>
+      <!-- 页脚 -->
+      <Footer v-if="settingsStore.showFooter" />
+    </div>
+    <!-- 返回顶部 -->
+    <el-backtop />
+    <!-- 返回顶部(固定 Header 情况下) -->
+    <el-backtop target=".app-scrollbar" />
+  </section>
+</template>
+
+<style lang="scss" scoped>
+@import "@/styles/mixins.scss";
+
+.app-main {
+  width: 100%;
+  background-color: var(--v3-body-bg-color);
+  display: flex;
+}
+
+.app-scrollbar {
+  flex-grow: 1;
+  overflow: auto;
+  @extend %scrollbar;
+  display: flex;
+  flex-direction: column;
+  .app-container-grow {
+    flex-grow: 1;
+  }
+}
+</style>

+ 71 - 0
v3-admin-vite-main/src/layouts/components/Breadcrumb/index.vue

@@ -0,0 +1,71 @@
+<script lang="ts" setup>
+import { ref } from "vue"
+import { type RouteLocationMatched, useRoute, useRouter } from "vue-router"
+import { useRouteListener } from "@/hooks/useRouteListener"
+import { compile } from "path-to-regexp"
+
+const route = useRoute()
+const router = useRouter()
+const { listenerRouteChange } = useRouteListener()
+
+/** 定义响应式数据 breadcrumbs,用于存储面包屑导航信息 */
+const breadcrumbs = ref<RouteLocationMatched[]>([])
+
+/** 获取面包屑导航信息 */
+const getBreadcrumb = () => {
+  breadcrumbs.value = route.matched.filter((item) => item.meta?.title && item.meta?.breadcrumb !== false)
+}
+
+/** 编译路由路径 */
+const pathCompile = (path: string) => {
+  const toPath = compile(path)
+  return toPath(route.params)
+}
+
+/** 处理面包屑导航点击事件 */
+const handleLink = (item: RouteLocationMatched) => {
+  const { redirect, path } = item
+  if (redirect) {
+    router.push(redirect as string)
+    return
+  }
+  router.push(pathCompile(path))
+}
+
+/** 监听路由变化,更新面包屑导航信息 */
+listenerRouteChange((route) => {
+  if (route.path.startsWith("/redirect/")) return
+  getBreadcrumb()
+}, true)
+</script>
+
+<template>
+  <el-breadcrumb class="app-breadcrumb">
+    <el-breadcrumb-item v-for="(item, index) in breadcrumbs" :key="item.path">
+      <span v-if="item.redirect === 'noRedirect' || index === breadcrumbs.length - 1" class="no-redirect">
+        {{ item.meta.title }}
+      </span>
+      <a v-else @click.prevent="handleLink(item)">
+        {{ item.meta.title }}
+      </a>
+    </el-breadcrumb-item>
+  </el-breadcrumb>
+</template>
+
+<style lang="scss" scoped>
+.el-breadcrumb__inner,
+.el-breadcrumb__inner a {
+  font-weight: 400 !important;
+}
+
+.app-breadcrumb.el-breadcrumb {
+  display: inline-block;
+  font-size: 14px;
+  line-height: var(--v3-navigationbar-height);
+  margin-left: 8px;
+  .no-redirect {
+    color: #97a8be;
+    cursor: text;
+  }
+}
+</style>

+ 67 - 0
v3-admin-vite-main/src/layouts/components/CompConsumer/index.ts

@@ -0,0 +1,67 @@
+import { type VNode, cloneVNode, createVNode, defineComponent, h, KeepAlive } from "vue"
+import { useRoute } from "vue-router"
+import { useTagsViewStore } from "@/store/modules/tags-view"
+
+interface CompConsumerProps {
+  component: VNode
+}
+
+/** 定义 compMap 对象,用于存储路由名称和对应的组件 */
+const compMap = new Map<string, VNode>()
+
+/**
+ * CompConsumer 组件
+ * 用法:替换 <keep-alive> 标签以及内部代码,变成:<CompConsumer :component="Component" />
+ * 优点:缓存路由时只需写路由 Name,无需再写组件 Name
+ * 缺点:当路由表有动态路由匹配时(指向同一个组件),会出现复用组件的情况(例如修改 /info/1 时 /info/2 也会跟着改变)
+ */
+export const CompConsumer = defineComponent(
+  (props: CompConsumerProps) => {
+    const tagsViewStore = useTagsViewStore()
+    const route = useRoute()
+    return () => {
+      // 获取传入的组件
+      const component = props.component
+      // 判断当前是否包含 name,如果不包含 name,那就直接处理掉 name
+      if (!route.name) return component
+      // 获取当前组件的名称
+      const compName = (component.type as any)?.name
+      // 获取当前路由的名称
+      const routeName = route.name as string
+      let Comp: VNode
+      // 检查 compMap 中是否已经存在对应的组件
+      if (compMap.has(routeName)) {
+        // 如果存在,则直接使用该组件进行渲染
+        Comp = compMap.get(routeName)!
+      } else {
+        // 如果不存在,则克隆传入的组件并创建一个新的组件,将其添加到 compMap 中
+        const node = cloneVNode(component)
+        if (compName && compName === routeName) {
+          ;(node.type as any).name = `__${compName}__CUSTOM_NAME`
+        }
+        // @ts-expect-error this is VNode
+        Comp = defineComponent({
+          name: routeName,
+          setup() {
+            return () => node
+          }
+        })
+        compMap.set(routeName, Comp)
+      }
+      // 使用 createVNode 函数创建一个 KeepAlive 组件,并缓存 cachedViews 数组中对应的组件
+      return createVNode(
+        KeepAlive,
+        {
+          include: tagsViewStore.cachedViews
+        },
+        {
+          default: () => h(Comp)
+        }
+      )
+    }
+  },
+  {
+    name: "CompConsumer",
+    props: ["component"]
+  }
+)

+ 18 - 0
v3-admin-vite-main/src/layouts/components/Footer/index.vue

@@ -0,0 +1,18 @@
+<script lang="ts" setup>
+const VITE_APP_TITLE = import.meta.env.VITE_APP_TITLE
+</script>
+
+<template>
+  <footer class="layout-footer">MIT © 2024-AIYANGNIU {{ VITE_APP_TITLE }}</footer>
+</template>
+
+<style lang="scss" scoped>
+.layout-footer {
+  width: 100%;
+  min-height: 50px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #c0c4cc;
+}
+</style>

+ 35 - 0
v3-admin-vite-main/src/layouts/components/Hamburger/index.vue

@@ -0,0 +1,35 @@
+<script lang="ts" setup>
+import { Expand, Fold } from "@element-plus/icons-vue"
+
+interface Props {
+  isActive?: boolean
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  isActive: false
+})
+
+/** Vue 3.3+ defineEmits 语法 */
+const emit = defineEmits<{
+  toggleClick: []
+}>()
+
+const toggleClick = () => {
+  emit("toggleClick")
+}
+</script>
+
+<template>
+  <div @click="toggleClick">
+    <el-icon :size="20" class="icon">
+      <Fold v-if="props.isActive" />
+      <Expand v-else />
+    </el-icon>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.icon {
+  vertical-align: middle;
+}
+</style>

+ 66 - 0
v3-admin-vite-main/src/layouts/components/Logo/index.vue

@@ -0,0 +1,66 @@
+<script lang="ts" setup>
+import { useLayoutMode } from "@/hooks/useLayoutMode"
+import logo from "@/assets/layouts/logo.png?url"
+import logoText1 from "@/assets/layouts/logo-text-1.png?url"
+import logoText2 from "@/assets/layouts/logo-text-2.png?url"
+
+interface Props {
+  collapse?: boolean
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  collapse: true
+})
+
+const { isLeft, isTop } = useLayoutMode()
+</script>
+
+<template>
+  <div class="layout-logo-container" :class="{ collapse: props.collapse, 'layout-mode-top': isTop }">
+    <transition name="layout-logo-fade">
+      <router-link v-if="props.collapse" key="collapse" to="/">
+        <h2>爱养牛</h2>
+        <!-- <img :src="logo" class="layout-logo" /> -->
+      </router-link>
+      <router-link v-else key="expand" to="/">
+        <img :src="!isLeft ? logoText2 : logoText1" class="layout-logo-text" />
+      </router-link>
+    </transition>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.layout-logo-container {
+  position: relative;
+  width: 100%;
+  height: var(--v3-header-height);
+  line-height: var(--v3-header-height);
+  background-color: transparent;
+  text-align: center;
+  overflow: hidden;
+  .layout-logo {
+    display: none;
+  }
+  .layout-logo-text {
+    height: 100%;
+    vertical-align: middle;
+  }
+}
+
+.layout-mode-top {
+  height: var(--v3-navigationbar-height);
+  line-height: var(--v3-navigationbar-height);
+}
+
+.collapse {
+  .layout-logo {
+    width: 32px;
+    height: 32px;
+    vertical-align: middle;
+    display: inline-block;
+  }
+  .layout-logo-text {
+    display: none;
+  }
+}
+</style>

+ 128 - 0
v3-admin-vite-main/src/layouts/components/NavigationBar/index.vue

@@ -0,0 +1,128 @@
+<script lang="ts" setup>
+import { useRouter } from "vue-router"
+import { storeToRefs } from "pinia"
+import { useAppStore } from "@/store/modules/app"
+import { useSettingsStore } from "@/store/modules/settings"
+import { useUserStore } from "@/store/modules/user"
+import { UserFilled } from "@element-plus/icons-vue"
+import Hamburger from "../Hamburger/index.vue"
+import Breadcrumb from "../Breadcrumb/index.vue"
+import Sidebar from "../Sidebar/index.vue"
+import Notify from "@/components/Notify/index.vue"
+import ThemeSwitch from "@/components/ThemeSwitch/index.vue"
+import Screenfull from "@/components/Screenfull/index.vue"
+import SearchMenu from "@/components/SearchMenu/index.vue"
+import { useDevice } from "@/hooks/useDevice"
+import { useLayoutMode } from "@/hooks/useLayoutMode"
+
+const { isMobile } = useDevice()
+const { isTop } = useLayoutMode()
+const router = useRouter()
+const appStore = useAppStore()
+const userStore = useUserStore()
+const settingsStore = useSettingsStore()
+const { showNotify, showThemeSwitch, showScreenfull, showSearchMenu } = storeToRefs(settingsStore)
+
+/** 切换侧边栏 */
+const toggleSidebar = () => {
+  appStore.toggleSidebar(false)
+}
+
+/** 登出 */
+const logout = () => {
+  userStore.logout()
+  router.push("/login")
+}
+</script>
+
+<template>
+  <div class="navigation-bar">
+    <Hamburger
+      v-if="!isTop || isMobile"
+      :is-active="appStore.sidebar.opened"
+      class="hamburger"
+      @toggle-click="toggleSidebar"
+    />
+    <Breadcrumb v-if="!isTop || isMobile" class="breadcrumb" />
+    <Sidebar v-if="isTop && !isMobile" class="sidebar" />
+    <div class="right-menu">
+      <SearchMenu v-if="showSearchMenu" class="right-menu-item" />
+      <Screenfull v-if="showScreenfull" class="right-menu-item" />
+      <ThemeSwitch v-if="showThemeSwitch" class="right-menu-item" />
+      <Notify v-if="showNotify" class="right-menu-item" />
+      <el-dropdown class="right-menu-item">
+        <div class="right-menu-avatar">
+          <el-avatar :icon="UserFilled" :size="30" />
+          <span>{{ userStore.username }}</span>
+        </div>
+        <template #dropdown>
+          <el-dropdown-menu>
+            <el-dropdown-item  @click="logout">
+              <span style="display: block">退出登录</span>
+            </el-dropdown-item>
+          </el-dropdown-menu>
+        </template>
+      </el-dropdown>
+    </div>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.navigation-bar {
+  height: var(--v3-navigationbar-height);
+  overflow: hidden;
+  background: var(--v3-header-bg-color);
+  display: flex;
+  justify-content: space-between;
+  .hamburger {
+    display: flex;
+    align-items: center;
+    height: 100%;
+    padding: 0 15px;
+    cursor: pointer;
+  }
+  .breadcrumb {
+    flex: 1;
+    // 参考 Bootstrap 的响应式设计将宽度设置为 576
+    @media screen and (max-width: 576px) {
+      display: none;
+    }
+  }
+  .sidebar {
+    flex: 1;
+    // 设置 min-width 是为了让 Sidebar 里的 el-menu 宽度自适应
+    min-width: 0px;
+    :deep(.el-menu) {
+      background-color: transparent;
+    }
+    :deep(.el-sub-menu) {
+      &.is-active {
+        .el-sub-menu__title {
+          color: var(--el-menu-active-color) !important;
+        }
+      }
+    }
+  }
+  .right-menu {
+    margin-right: 10px;
+    height: 100%;
+    display: flex;
+    align-items: center;
+    color: #606266;
+    .right-menu-item {
+      padding: 0 10px;
+      cursor: pointer;
+      .right-menu-avatar {
+        display: flex;
+        align-items: center;
+        .el-avatar {
+          margin-right: 10px;
+        }
+        span {
+          font-size: 16px;
+        }
+      }
+    }
+  }
+}
+</style>

+ 45 - 0
v3-admin-vite-main/src/layouts/components/RightPanel/index.vue

@@ -0,0 +1,45 @@
+<script lang="ts" setup>
+import { ref } from "vue"
+import { Setting } from "@element-plus/icons-vue"
+
+interface Props {
+  buttonTop?: number
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  buttonTop: 350
+})
+
+const buttonTopCss = props.buttonTop + "px"
+const show = ref(false)
+</script>
+
+<template>
+  <div class="handle-button" @click="show = true">
+    <el-icon :size="24">
+      <Setting />
+    </el-icon>
+  </div>
+  <el-drawer v-model="show" size="300px" :with-header="false">
+    <slot />
+  </el-drawer>
+</template>
+
+<style lang="scss" scoped>
+.handle-button {
+  width: 48px;
+  height: 48px;
+  background-color: var(--v3-rightpanel-button-bg-color);
+  position: fixed;
+  top: v-bind(buttonTopCss);
+  right: 0;
+  border-radius: 6px 0 0 6px;
+  z-index: 10;
+  cursor: pointer;
+  pointer-events: auto;
+  color: #fff;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+</style>

+ 103 - 0
v3-admin-vite-main/src/layouts/components/Settings/SelectLayoutMode.vue

@@ -0,0 +1,103 @@
+<script lang="ts" setup>
+import { useLayoutMode } from "@/hooks/useLayoutMode"
+import { LayoutModeEnum } from "@/constants/app-key"
+
+const { isLeft, isTop, isLeftTop, setLayoutMode } = useLayoutMode()
+</script>
+
+<template>
+  <div class="select-layout-mode">
+    <el-tooltip content="左侧模式">
+      <el-container class="layout-mode left" :class="{ active: isLeft }" @click="setLayoutMode(LayoutModeEnum.Left)">
+        <el-aside />
+        <el-container>
+          <el-header />
+          <el-main />
+        </el-container>
+      </el-container>
+    </el-tooltip>
+    <el-tooltip content="顶部模式">
+      <el-container class="layout-mode top" :class="{ active: isTop }" @click="setLayoutMode(LayoutModeEnum.Top)">
+        <el-header />
+        <el-main />
+      </el-container>
+    </el-tooltip>
+    <el-tooltip content="混合模式">
+      <el-container
+        class="layout-mode left-top"
+        :class="{ active: isLeftTop }"
+        @click="setLayoutMode(LayoutModeEnum.LeftTop)"
+      >
+        <el-header />
+        <el-container>
+          <el-aside />
+          <el-main />
+        </el-container>
+      </el-container>
+    </el-tooltip>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.select-layout-mode {
+  display: flex;
+  justify-content: space-between;
+}
+
+.layout-mode {
+  width: 60px;
+  flex-grow: 0;
+  overflow: hidden;
+  cursor: pointer;
+  border-radius: 6px;
+  border: 2px solid #00000000;
+  &:hover {
+    border: 2px solid var(--el-color-primary);
+  }
+}
+
+.active {
+  border: 2px solid var(--el-color-primary);
+}
+
+.el-header {
+  height: 12px;
+}
+
+.el-aside {
+  width: 16px;
+}
+
+.left {
+  .el-header {
+    background-color: var(--el-border-color);
+  }
+  .el-aside {
+    background-color: var(--el-color-primary);
+  }
+  .el-main {
+    background-color: var(--el-fill-color);
+  }
+}
+
+.top {
+  .el-header {
+    background-color: var(--el-color-primary);
+  }
+  .el-main {
+    background-color: var(--el-fill-color);
+  }
+}
+
+.left-top {
+  .el-header {
+    background-color: var(--el-border-color);
+  }
+  .el-aside {
+    background-color: var(--el-color-primary);
+  }
+  .el-main {
+    background-color: var(--el-fill-color);
+  }
+}
+</style>

+ 82 - 0
v3-admin-vite-main/src/layouts/components/Settings/index.vue

@@ -0,0 +1,82 @@
+<script lang="ts" setup>
+import { watchEffect } from "vue"
+import { storeToRefs } from "pinia"
+import { useSettingsStore } from "@/store/modules/settings"
+import { useLayoutMode } from "@/hooks/useLayoutMode"
+import { resetConfigLayout } from "@/utils"
+import SelectLayoutMode from "./SelectLayoutMode.vue"
+import { Refresh } from "@element-plus/icons-vue"
+
+const { isLeft } = useLayoutMode()
+const settingsStore = useSettingsStore()
+
+/** 使用 storeToRefs 将提取的属性保持其响应性 */
+const {
+  showTagsView,
+  showLogo,
+  fixedHeader,
+  showFooter,
+  showNotify,
+  showThemeSwitch,
+  showScreenfull,
+  showSearchMenu,
+  cacheTagsView,
+  showGreyMode,
+} = storeToRefs(settingsStore)
+
+/** 定义 switch 设置项 */
+const switchSettings = {
+  显示标签栏: showTagsView,
+  "显示 Logo": showLogo,
+  "固定 Header": fixedHeader,
+  "显示页脚 Footer": showFooter,
+  显示消息通知: showNotify,
+  显示切换主题按钮: showThemeSwitch,
+  显示全屏按钮: showScreenfull,
+  显示搜索按钮: showSearchMenu,
+  是否缓存标签栏: cacheTagsView,
+  显示灰色模式: showGreyMode,
+}
+
+/** 非左侧模式时,Header 都是 fixed 布局 */
+watchEffect(() => {
+  isLeft.value && (fixedHeader.value = true)
+})
+</script>
+
+<template>
+  <div class="setting-container">
+    <h4>布局配置</h4>
+    <SelectLayoutMode />
+    <el-divider />
+    <h4>功能配置</h4>
+    <div class="setting-item" v-for="(settingValue, settingName, index) in switchSettings" :key="index">
+      <span class="setting-name">{{ settingName }}</span>
+      <el-switch v-model="settingValue.value" :disabled="!isLeft && settingName === '固定 Header'" />
+    </div>
+    <el-button type="danger" :icon="Refresh" @click="resetConfigLayout">重 置</el-button>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+@import "@/styles/mixins.scss";
+
+.setting-container {
+  padding: 20px;
+  .setting-item {
+    font-size: 14px;
+    color: var(--el-text-color-regular);
+    padding: 5px 0;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    .setting-name {
+      @extend %ellipsis;
+    }
+  }
+  .el-button {
+    margin-top: 40px;
+    width: 100%;
+  }
+}
+</style>

+ 120 - 0
v3-admin-vite-main/src/layouts/components/Sidebar/SidebarItem.vue

@@ -0,0 +1,120 @@
+<script lang="ts" setup>
+import { computed } from "vue"
+import { type RouteRecordRaw } from "vue-router"
+import SidebarItemLink from "./SidebarItemLink.vue"
+import { isExternal } from "@/utils/validate"
+import path from "path-browserify"
+
+interface Props {
+  item: RouteRecordRaw
+  isCollapse?: boolean
+  isTop?: boolean
+  isFirstLevel?: boolean
+  basePath?: string
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  isCollapse: false,
+  isTop: false,
+  isFirstLevel: true,
+  basePath: ""
+})
+
+/** 是否始终显示根菜单 */
+const alwaysShowRootMenu = computed(() => props.item.meta?.alwaysShow)
+
+/** 显示的子菜单 */
+const showingChildren = computed(() => {
+  return props.item.children?.filter((child) => !child.meta?.hidden) ?? []
+})
+
+/** 显示的子菜单数量 */
+const showingChildNumber = computed(() => {
+  return showingChildren.value.length
+})
+
+/** 唯一的子菜单项 */
+const theOnlyOneChild = computed(() => {
+  const number = showingChildNumber.value
+  switch (true) {
+    case number > 1:
+      return null
+    case number === 1:
+      return showingChildren.value[0]
+    default:
+      return { ...props.item, path: "" }
+  }
+})
+
+/** 解析路径 */
+const resolvePath = (routePath: string) => {
+  switch (true) {
+    case isExternal(routePath):
+      return routePath
+    case isExternal(props.basePath):
+      return props.basePath
+    default:
+      return path.resolve(props.basePath, routePath)
+  }
+}
+</script>
+
+<template>
+  <div :class="{ 'simple-mode': props.isCollapse && !isTop, 'first-level': props.isFirstLevel }">
+    <template v-if="!alwaysShowRootMenu && theOnlyOneChild && !theOnlyOneChild.children">
+      <SidebarItemLink v-if="theOnlyOneChild.meta" :to="resolvePath(theOnlyOneChild.path)">
+        <el-menu-item :index="resolvePath(theOnlyOneChild.path)">
+          <SvgIcon v-if="theOnlyOneChild.meta.svgIcon" :name="theOnlyOneChild.meta.svgIcon" />
+          <component v-else-if="theOnlyOneChild.meta.elIcon" :is="theOnlyOneChild.meta.elIcon" class="el-icon" />
+          <template v-if="theOnlyOneChild.meta.title" #title>
+            {{ theOnlyOneChild.meta.title }}
+          </template>
+        </el-menu-item>
+      </SidebarItemLink>
+    </template>
+    <el-sub-menu v-else :index="resolvePath(props.item.path)" teleported>
+      <template #title>
+        <SvgIcon v-if="props.item.meta?.svgIcon" :name="props.item.meta.svgIcon" />
+        <component v-else-if="props.item.meta?.elIcon" :is="props.item.meta.elIcon" class="el-icon" />
+        <span v-if="props.item.meta?.title">{{ props.item.meta.title }}</span>
+      </template>
+      <template v-if="props.item.children">
+        <SidebarItem
+          v-for="child in showingChildren"
+          :key="child.path"
+          :item="child"
+          :is-collapse="props.isCollapse"
+          :is-first-level="false"
+          :base-path="resolvePath(child.path)"
+        />
+      </template>
+    </el-sub-menu>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.svg-icon {
+  min-width: 1em;
+  margin-right: 12px;
+  font-size: 18px;
+}
+
+.el-icon {
+  width: 1em;
+  margin-right: 12px;
+  font-size: 18px;
+}
+
+.simple-mode {
+  &.first-level {
+    :deep(.el-sub-menu) {
+      .el-sub-menu__icon-arrow {
+        display: none;
+      }
+      span {
+        visibility: hidden;
+      }
+    }
+  }
+}
+</style>

+ 0 - 0
v3-admin-vite-main/src/layouts/components/Sidebar/SidebarItemLink.vue


Некоторые файлы не были показаны из-за большого количества измененных файлов