Browse Source

校友管理完成

soft5566 1 year ago
parent
commit
6565c84a5b
100 changed files with 9354 additions and 1 deletions
  1. 25 0
      .editorconfig
  2. 4 0
      .env
  3. 10 0
      .env.development
  4. 10 0
      .env.production
  5. 10 0
      .env.staging
  6. 8 0
      .eslintignore
  7. 75 0
      .eslintrc.cjs
  8. 35 0
      .gitignore
  9. 1 0
      .husky/pre-commit
  10. 8 0
      .npmrc
  11. 8 0
      .prettierignore
  12. 9 0
      .vscode/extensions.json
  13. 16 0
      .vscode/hook.code-snippets
  14. 30 0
      .vscode/settings.json
  15. 14 0
      .vscode/vue.code-snippets
  16. 64 1
      README.md
  17. 17 0
      index.html
  18. 61 0
      package.json
  19. 4906 0
      pnpm-lock.yaml
  20. 22 0
      prettier.config.js
  21. 45 0
      public/app-loading.css
  22. 5 0
      public/detect-ie.js
  23. 15 0
      src/App.vue
  24. 71 0
      src/api/alumniManager/index.js
  25. 56 0
      src/api/alumniOrganization/index.js
  26. 18 0
      src/api/login/index.js
  27. 36 0
      src/api/table/index.js
  28. 1 0
      src/assets/error-page/403.svg
  29. 1 0
      src/assets/error-page/404.svg
  30. BIN
      src/assets/image.png
  31. BIN
      src/assets/layouts/admin.png
  32. 12 0
      src/assets/layouts/logo.svg
  33. 230 0
      src/assets/login/logo.svg
  34. 6 0
      src/assets/login/name.svg
  35. 6 0
      src/assets/login/title-left.svg
  36. 6 0
      src/assets/login/title-right.svg
  37. 6 0
      src/assets/login/title.svg
  38. 31 0
      src/components/SvgIcon/index.vue
  39. 11 0
      src/config/layouts.js
  40. 7 0
      src/config/route.js
  41. 13 0
      src/config/white-list.js
  42. 10 0
      src/constants/app-key.js
  43. 11 0
      src/constants/cache-key.js
  44. 6 0
      src/directives/index.js
  45. 16 0
      src/directives/permission/index.js
  46. 11 0
      src/hooks/useDevice.js
  47. 20 0
      src/hooks/useGreyAndColorWeakness.js
  48. 25 0
      src/hooks/usePagination.js
  49. 44 0
      src/hooks/useRouteListener.js
  50. 23 0
      src/hooks/useTitle.js
  51. 223 0
      src/hooks/useWatermark.js
  52. 6 0
      src/icons/index.js
  53. 6 0
      src/icons/svg/account-management.svg
  54. 6 0
      src/icons/svg/activity-management.svg
  55. 8 0
      src/icons/svg/alumni-album.svg
  56. 4 0
      src/icons/svg/alumni-management.svg
  57. 4 0
      src/icons/svg/alumni-organization.svg
  58. 6 0
      src/icons/svg/news-focus.svg
  59. 8 0
      src/icons/svg/school-endorsement.svg
  60. 49 0
      src/layouts/components/AppMain.vue
  61. 18 0
      src/layouts/components/Footer/index.vue
  62. 43 0
      src/layouts/components/Hamburger/index.vue
  63. 53 0
      src/layouts/components/Logo/index.vue
  64. 119 0
      src/layouts/components/NavigationBar/index.vue
  65. 104 0
      src/layouts/components/Sidebar/SidebarItem.vue
  66. 19 0
      src/layouts/components/Sidebar/SidebarItemLink.vue
  67. 139 0
      src/layouts/components/Sidebar/index.vue
  68. 143 0
      src/layouts/components/TagsView/ScrollPane.vue
  69. 253 0
      src/layouts/components/TagsView/index.vue
  70. 5 0
      src/layouts/components/index.js
  71. 52 0
      src/layouts/hooks/useResize.js
  72. 190 0
      src/layouts/index.vue
  73. 28 0
      src/main.js
  74. 8 0
      src/plugins/element-plus-icon/index.js
  75. 6 0
      src/plugins/element-plus/index.js
  76. 7 0
      src/plugins/index.js
  77. 62 0
      src/router/helper.js
  78. 268 0
      src/router/index.js
  79. 61 0
      src/router/permission.js
  80. 3 0
      src/store/index.js
  81. 51 0
      src/store/modules/app.js
  82. 58 0
      src/store/modules/permission.js
  83. 25 0
      src/store/modules/settings.js
  84. 101 0
      src/store/modules/tags-view.js
  85. 71 0
      src/store/modules/user.js
  86. 116 0
      src/styles/element-plus.scss
  87. 58 0
      src/styles/index.scss
  88. 42 0
      src/styles/mixins.scss
  89. 25 0
      src/styles/transition.scss
  90. 44 0
      src/styles/variables.css
  91. 14 0
      src/utils/cache/cookies.js
  92. 34 0
      src/utils/cache/local-storage.js
  93. 18 0
      src/utils/css.js
  94. 10 0
      src/utils/datetime.js
  95. 12 0
      src/utils/permission.js
  96. 122 0
      src/utils/service.js
  97. 84 0
      src/utils/validate.js
  98. 214 0
      src/views/account-management/index.vue
  99. 279 0
      src/views/activity-management/audit-list.vue
  100. 0 0
      src/views/activity-management/index.vue

+ 25 - 0
.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
.env

@@ -0,0 +1,4 @@
+# 所有环境自定义的环境变量(命名必须以 VITE_ 开头)
+
+## 项目标题
+VITE_APP_TITLE = 校友生态系统

+ 10 - 0
.env.development

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

+ 10 - 0
.env.production

@@ -0,0 +1,10 @@
+# 生产环境自定义的环境变量(命名必须以 VITE_ 开头)
+
+## 后端接口公共路径(如果解决跨域问题采用 CORS 就需要写全路径)
+VITE_BASE_API = 'https://chtech.ncjti.edu.cn/alumnus/alumni_api'
+
+## 路由模式 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
.env.staging

@@ -0,0 +1,10 @@
+# 预发布环境自定义的环境变量(命名必须以 VITE_ 开头)
+
+## 后端接口公共路径(如果解决跨域问题采用 CORS 就需要写全路径)
+VITE_BASE_API = 'https://chtech.ncjti.edu.cn/alumnus/alumni_api'
+
+## 路由模式 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
.eslintignore

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

+ 75 - 0
.eslintrc.cjs

@@ -0,0 +1,75 @@
+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: {
+    // JS/TS
+    "@typescript-eslint/no-unused-expressions": "off",
+    "@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"
+      }
+    ]
+  }
+}

+ 35 - 0
.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

+ 1 - 0
.husky/pre-commit

@@ -0,0 +1 @@
+npx lint-staged

+ 8 - 0
.npmrc

@@ -0,0 +1,8 @@
+# China mirror of npm
+registry = https://registry.npmmirror.com
+
+# 通过该配置兜底解决组件没有类型提示的问题
+shamefully-hoist = true
+
+# 安装依赖时锁定版本号
+save-exact = true

+ 8 - 0
.prettierignore

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

+ 9 - 0
.vscode/extensions.json

@@ -0,0 +1,9 @@
+{
+  "recommendations": [
+    "editorconfig.editorconfig",
+    "dbaeumer.vscode-eslint",
+    "esbenp.prettier-vscode",
+    "vue.volar",
+    "wiensss.region-highlighter"
+  ]
+}

+ 16 - 0
.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"
+  }
+}

+ 30 - 0
.vscode/settings.json

@@ -0,0 +1,30 @@
+{
+  "prettier.enable": true,
+  "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
.vscode/vue.code-snippets

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

+ 64 - 1
README.md

@@ -1 +1,64 @@
-#school_ecology_manage
+## 简介
+
+校友生态系统
+
+## 开发
+
+```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` 开发中

+ 17 - 0
index.html

@@ -0,0 +1,17 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <link rel="icon" href="/src/assets/layouts/logo.svg" />
+    <link rel="stylesheet" href="/app-loading.css" />
+    <title>%VITE_APP_TITLE%</title>
+    <script src="/detect-ie.js" defer></script>
+  </head>
+  <body>
+    <div id="app">
+      <div id="app-loading"></div>
+    </div>
+    <script type="module" src="/src/main.js"></script>
+  </body>
+</html>

+ 61 - 0
package.json

@@ -0,0 +1,61 @@
+{
+  "name": "alumni-ecosystem",
+  "version": "0.0.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build:stage": "vite build --mode staging",
+    "build:prod": "vite build",
+    "preview:stage": "pnpm build:stage && vite preview",
+    "preview:prod": "pnpm build:prod && vite preview",
+    "lint:eslint": "eslint --cache --max-warnings 0 \"{src}/**/*.{vue,js}\" --fix",
+    "lint:prettier": "prettier --write \"{src}/**/*.{vue,js,json,css,scss,html,md}\"",
+    "lint": "pnpm lint:eslint && pnpm lint:prettier",
+    "prepare": "husky"
+  },
+  "dependencies": {
+    "@element-plus/icons-vue": "2.3.1",
+    "axios": "1.7.7",
+    "dayjs": "1.11.13",
+    "element-plus": "2.8.8",
+    "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",
+    "pinia": "2.2.6",
+    "vue": "3.5.13",
+    "vue-router": "4.4.5"
+  },
+  "devDependencies": {
+    "@typescript-eslint/eslint-plugin": "8.12.2",
+    "@typescript-eslint/parser": "8.12.2",
+    "@vitejs/plugin-vue": "5.2.0",
+    "@vue/eslint-config-prettier": "9.0.0",
+    "@vue/eslint-config-typescript": "13.0.0",
+    "eslint": "8.57.1",
+    "eslint-plugin-prettier": "5.2.1",
+    "eslint-plugin-vue": "9.31.0",
+    "husky": "9.1.6",
+    "lint-staged": "15.2.10",
+    "prettier": "3.3.3",
+    "sass": "1.78.0",
+    "vite": "5.4.11",
+    "vite-plugin-svg-icons": "2.0.1",
+    "vite-svg-loader": "5.1.0",
+    "vue-eslint-parser": "9.4.3"
+  },
+  "lint-staged": {
+    "*.{vue,js}": [
+      "eslint --fix",
+      "prettier --write"
+    ],
+    "*.{css,scss,html,md}": [
+      "prettier --write"
+    ],
+    "package.json": [
+      "prettier --write"
+    ]
+  }
+}

File diff suppressed because it is too large
+ 4906 - 0
pnpm-lock.yaml


+ 22 - 0
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
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;
+  }
+}

+ 5 - 0
public/detect-ie.js

@@ -0,0 +1,5 @@
+// Tip: Simple judgments may not fully cover
+if (/MSIE\s|Trident\//.test(window.navigator.userAgent)) {
+  document.body.innerHTML =
+    "<strong>Sorry, this browser is currently not supported. We recommend using the latest version of a modern browser. For example, Chrome/Firefox/Edge.</strong>"
+}

+ 15 - 0
src/App.vue

@@ -0,0 +1,15 @@
+<script setup>
+import { useGreyAndColorWeakness } from "@/hooks/useGreyAndColorWeakness"
+import zhCn from "element-plus/es/locale/lang/zh-cn" // Element Plus 中文包
+
+const { initGreyAndColorWeakness } = useGreyAndColorWeakness()
+
+/** 初始化灰色模式和色弱模式 */
+initGreyAndColorWeakness()
+</script>
+
+<template>
+  <el-config-provider :locale="zhCn">
+    <router-view />
+  </el-config-provider>
+</template>

+ 71 - 0
src/api/alumniManager/index.js

@@ -0,0 +1,71 @@
+import { request } from "@/utils/service"
+
+// /** 增 */
+// export function createTableDataApi(data) {
+//   return request({
+//     url: "table",
+//     method: "post",
+//     data
+//   })
+// }
+
+// /** 删 */
+// export function deleteTableDataApi(id) {
+//   return request({
+//     url: `table/${id}`,
+//     method: "delete"
+//   })
+// }
+
+// /** 改 */
+// export function updateTableDataApi(data) {
+//   return request({
+//     url: "table",
+//     method: "put",
+//     data
+//   })
+// }
+
+/** 查 表格数据 */
+export function getQueryUsersPageApi(params) {
+  return request({
+    url: "alumniUser/queryUsersPage",
+    method: "get",
+    params
+  })
+}
+
+/** 查 学院 */
+export function getQueryCollegesApi() {
+  return request({
+    url: "alumniOrg/queryColleges",
+    method: "get"
+  })
+}
+
+/** 查 学段 */
+export function getQueryPeriodsApi(params) {
+  return request({
+    url: "alumniOrg/queryPeriods",
+    method: "get",
+    params
+  })
+}
+
+/** 查 专业 */
+export function getQueryMajorsApi(params) {
+  return request({
+    url: "alumniOrg/queryMajors",
+    method: "get",
+    params
+  })
+}
+
+/** 查 班级 */
+export function getQueryClassesApi(params) {
+  return request({
+    url: "alumniOrg/queryClasses",
+    method: "get",
+    params
+  })
+}

+ 56 - 0
src/api/alumniOrganization/index.js

@@ -0,0 +1,56 @@
+import { request } from "@/utils/service"
+
+/** 增 */
+export function createTableDataApi(data) {
+  return request({
+    url: "table",
+    method: "post",
+    data
+  })
+}
+
+/** 删 */
+export function deleteTableDataApi(id) {
+  return request({
+    url: `table/${id}`,
+    method: "delete"
+  })
+}
+
+/** 改 */
+export function updateTableDataApi(data) {
+  return request({
+    url: "table",
+    method: "put",
+    data
+  })
+}
+
+/**
+ * 创建组织
+ */
+export function insertClubDataApi(data) {
+  return request({
+    url: "alumniClub/insertClubData",
+    method: "post",
+    data
+  })
+}
+
+/** 查 表格数据 */
+export function getTableDataApi(params) {
+  return request({
+    url: "alumniClub/queryClubPage",
+    method: "get",
+    params
+  })
+}
+
+/** 查 分类下拉列表 */
+export function getQueryCategoryPageApi(params) {
+  return request({
+    url: "alumniCategory/queryCategoryPage",
+    method: "get",
+    params
+  })
+}

+ 18 - 0
src/api/login/index.js

@@ -0,0 +1,18 @@
+import { request } from "@/utils/service"
+
+/** 登录并返回 Token */
+export function loginApi(data) {
+  return request({
+    url: "login/Login",
+    method: "post",
+    data
+  })
+}
+
+/** 获取用户详情 */
+export function getUserInfoApi() {
+  return request({
+    url: "users/info",
+    method: "get"
+  })
+}

+ 36 - 0
src/api/table/index.js

@@ -0,0 +1,36 @@
+import { request } from "@/utils/service"
+
+/** 增 */
+export function createTableDataApi(data) {
+  return request({
+    url: "table",
+    method: "post",
+    data
+  })
+}
+
+/** 删 */
+export function deleteTableDataApi(id) {
+  return request({
+    url: `table/${id}`,
+    method: "delete"
+  })
+}
+
+/** 改 */
+export function updateTableDataApi(data) {
+  return request({
+    url: "table",
+    method: "put",
+    data
+  })
+}
+
+/** 查 */
+export function getTableDataApi(params) {
+  return request({
+    url: "table",
+    method: "get",
+    params
+  })
+}

File diff suppressed because it is too large
+ 1 - 0
src/assets/error-page/403.svg


File diff suppressed because it is too large
+ 1 - 0
src/assets/error-page/404.svg


BIN
src/assets/image.png


BIN
src/assets/layouts/admin.png


File diff suppressed because it is too large
+ 12 - 0
src/assets/layouts/logo.svg


File diff suppressed because it is too large
+ 230 - 0
src/assets/login/logo.svg


File diff suppressed because it is too large
+ 6 - 0
src/assets/login/name.svg


+ 6 - 0
src/assets/login/title-left.svg

@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="31" height="48" viewBox="0 0 31 48" fill="none">
+<path     fill="#FFFFFF"  d="M4.28131 17.1746L0 48L5.77574 48C8.77051 48 11.3067 45.7917 11.7187 42.8254L16 12L10.2243 12C7.22949 12 4.69329 14.2083 4.28131 17.1746Z">
+</path>
+<path     fill="#FFFFFF"  d="M20.2965 5.19485L14.5 48L19.2577 48C22.2603 48 24.8006 45.7805 25.2035 42.8052L31 0L26.2423 -5.82655e-16C23.2397 -9.5036e-16 20.6994 2.21946 20.2965 5.19485Z">
+</path>
+</svg>

+ 6 - 0
src/assets/login/title-right.svg

@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="31" height="48" viewBox="0 0 31 48" fill="none">
+<path     fill="#FFFFFF"  d="M4.28131 5.17459L0 36L5.77574 36C8.77051 36 11.3067 33.7917 11.7187 30.8254L16 0L10.2243 -7.07324e-16C7.22949 -1.07408e-15 4.69329 2.20829 4.28131 5.17459Z">
+</path>
+<path     fill="#FFFFFF"  d="M20.2965 5.19485L14.5 48L19.2577 48C22.2603 48 24.8006 45.7805 25.2035 42.8052L31 0L26.2423 -5.82655e-16C23.2397 -9.5036e-16 20.6994 2.21946 20.2965 5.19485Z">
+</path>
+</svg>

File diff suppressed because it is too large
+ 6 - 0
src/assets/login/title.svg


+ 31 - 0
src/components/SvgIcon/index.vue

@@ -0,0 +1,31 @@
+<script setup>
+import { computed } from "vue"
+
+const props = defineProps({
+  prefix: {
+    type: String,
+    default: "icon"
+  },
+  name: {
+    type: String,
+    required: true
+  }
+})
+
+const symbolId = computed(() => `#${props.prefix}-${props.name}`)
+</script>
+
+<template>
+  <svg class="svg-icon">
+    <use :href="symbolId" />
+  </svg>
+</template>
+
+<style lang="scss" scoped>
+.svg-icon {
+  width: 1em;
+  height: 1em;
+  fill: currentColor;
+  overflow: hidden;
+}
+</style>

+ 11 - 0
src/config/layouts.js

@@ -0,0 +1,11 @@
+/** 项目配置 */
+export const layoutSettings = {
+  showTagsView: false,
+  fixedHeader: true,
+  showFooter: false,
+  showLogo: true,
+  cacheTagsView: false,
+  showWatermark: false,
+  showGreyMode: false,
+  showColorWeakness: false
+}

+ 7 - 0
src/config/route.js

@@ -0,0 +1,7 @@
+const routeSettings = {
+  dynamic: true,
+  defaultRoles: ["DEFAULT_ROLE"],
+  thirdLevelRouteCache: false
+}
+
+export default routeSettings

+ 13 - 0
src/config/white-list.js

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

+ 10 - 0
src/constants/app-key.js

@@ -0,0 +1,10 @@
+/** 设备类型 */
+export const DeviceEnum = {
+  Mobile: "mobile",
+  Desktop: "desktop"
+}
+
+/** 侧边栏打开状态常量 */
+export const SIDEBAR_OPENED = "opened"
+/** 侧边栏关闭状态常量 */
+export const SIDEBAR_CLOSED = "closed"

+ 11 - 0
src/constants/cache-key.js

@@ -0,0 +1,11 @@
+const SYSTEM_NAME = "alumni-ecosystem"
+
+/** 缓存数据时用到的 Key */
+const CacheKey = {
+  TOKEN: `${SYSTEM_NAME}-token-key`,
+  SIDEBAR_STATUS: `${SYSTEM_NAME}-sidebar-status-key`,
+  VISITED_VIEWS: `${SYSTEM_NAME}-visited-views-key`,
+  CACHED_VIEWS: `${SYSTEM_NAME}-cached-views-key`
+}
+
+export default CacheKey

+ 6 - 0
src/directives/index.js

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

+ 16 - 0
src/directives/permission/index.js

@@ -0,0 +1,16 @@
+import { useUserStore } from "@/store/modules/user"
+
+/** 权限指令,和权限判断函数 checkPermission 功能类似 */
+export const permission = {
+  mounted(el, binding) {
+    const { value: permissionRoles } = binding
+    const { roles } = useUserStore()
+    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
src/hooks/useDevice.js

@@ -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 }
+}

+ 20 - 0
src/hooks/useGreyAndColorWeakness.js

@@ -0,0 +1,20 @@
+import { watchEffect } from "vue"
+import { useSettingsStore } from "@/store/modules/settings"
+
+const GREY_MODE = "grey-mode"
+const COLOR_WEAKNESS = "color-weakness"
+const classList = document.documentElement.classList
+
+/** 初始化 */
+const initGreyAndColorWeakness = () => {
+  const settingsStore = useSettingsStore()
+  watchEffect(() => {
+    classList.toggle(GREY_MODE, settingsStore.showGreyMode)
+    classList.toggle(COLOR_WEAKNESS, settingsStore.showColorWeakness)
+  })
+}
+
+/** 灰色模式和色弱模式 hook */
+export function useGreyAndColorWeakness() {
+  return { initGreyAndColorWeakness }
+}

+ 25 - 0
src/hooks/usePagination.js

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

+ 44 - 0
src/hooks/useRouteListener.js

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

+ 23 - 0
src/hooks/useTitle.js

@@ -0,0 +1,23 @@
+import { ref, watch } from "vue"
+
+/** 项目标题 */
+const VITE_APP_TITLE = import.meta.env.VITE_APP_TITLE ?? "校友生态系统"
+
+/** 动态标题 */
+const dynamicTitle = ref("")
+
+/** 设置标题 */
+const setTitle = (title) => {
+  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 }
+}

+ 223 - 0
src/hooks/useWatermark.js

@@ -0,0 +1,223 @@
+import { onBeforeUnmount, ref } from "vue"
+import { debounce } from "lodash-es"
+
+/** 默认配置 */
+const defaultConfig = {
+  /** 防御(默认开启,能防御水印被删除或隐藏,但可能会有性能损耗) */
+  defense: true,
+  /** 文本颜色 */
+  color: "#c0c4cc",
+  /** 文本透明度 */
+  opacity: 0.5,
+  /** 文本字体大小 */
+  size: 16,
+  /** 文本字体 */
+  family: "serif",
+  /** 文本倾斜角度 */
+  angle: -20,
+  /** 一处水印所占宽度(数值越大水印密度越低) */
+  width: 300,
+  /** 一处水印所占高度(数值越大水印密度越低) */
+  height: 200
+}
+
+/** body 元素 */
+const bodyEl = ref(document.body)
+
+/**
+ * 创建水印
+ * 1. 可以选择传入挂载水印的容器元素,默认是 body
+ * 2. 做了水印防御,能有效防御别人打开控制台删除或隐藏水印
+ */
+export function useWatermark(parentEl = bodyEl) {
+  /** 备份文本 */
+  let backupText
+  /** 最终配置 */
+  let mergeConfig
+  /** 水印元素 */
+  let watermarkEl = null
+  /** 观察器 */
+  const observer = {
+    watermarkElMutationObserver: undefined,
+    parentElMutationObserver: undefined,
+    parentElResizeObserver: undefined
+  }
+
+  /** 设置水印 */
+  const setWatermark = (text, config = {}) => {
+    if (!parentEl.value) {
+      console.warn("请在 DOM 挂载完成后再调用 setWatermark 方法设置水印")
+      return
+    }
+    // 备份文本
+    backupText = text
+    // 合并配置
+    mergeConfig = { ...defaultConfig, ...config }
+    // 创建或更新水印元素
+    watermarkEl ? updateWatermarkEl() : createWatermarkEl()
+    // 监听水印元素和容器元素的变化
+    addElListener(parentEl.value)
+  }
+
+  /** 创建水印元素 */
+  const createWatermarkEl = () => {
+    const isBody = parentEl.value.tagName.toLowerCase() === bodyEl.value.tagName.toLowerCase()
+    const watermarkElPosition = isBody ? "fixed" : "absolute"
+    const parentElPosition = isBody ? "" : "relative"
+    watermarkEl = document.createElement("div")
+    watermarkEl.style.pointerEvents = "none"
+    watermarkEl.style.top = "0"
+    watermarkEl.style.left = "0"
+    watermarkEl.style.position = watermarkElPosition
+    watermarkEl.style.zIndex = "99999"
+    const { clientWidth, clientHeight } = parentEl.value
+    updateWatermarkEl({ width: clientWidth, height: clientHeight })
+    // 设置水印容器为相对定位
+    parentEl.value.style.position = parentElPosition
+    // 将水印元素添加到水印容器中
+    parentEl.value.appendChild(watermarkEl)
+  }
+
+  /** 更新水印元素 */
+  const updateWatermarkEl = (options = {}) => {
+    if (!watermarkEl) return
+    backupText && (watermarkEl.style.background = `url(${createBase64()}) left top repeat`)
+    options.width && (watermarkEl.style.width = `${options.width}px`)
+    options.height && (watermarkEl.style.height = `${options.height}px`)
+  }
+
+  /** 创建 base64 图片 */
+  const createBase64 = () => {
+    const { color, opacity, size, family, angle, width, height } = mergeConfig
+    const canvasEl = document.createElement("canvas")
+    canvasEl.width = width
+    canvasEl.height = height
+    const ctx = canvasEl.getContext("2d")
+    if (ctx) {
+      ctx.fillStyle = color
+      ctx.globalAlpha = opacity
+      ctx.font = `${size}px ${family}`
+      ctx.rotate((Math.PI / 180) * angle)
+      ctx.fillText(backupText, 0, height / 2)
+    }
+    return canvasEl.toDataURL()
+  }
+
+  /** 清除水印 */
+  const clearWatermark = () => {
+    if (!parentEl.value || !watermarkEl) return
+    // 移除对水印元素和容器元素的监听
+    removeListener()
+    // 移除水印元素
+    try {
+      parentEl.value.removeChild(watermarkEl)
+    } catch {
+      // 比如在无防御情况下,用户打开控制台删除了这个元素
+      console.warn("水印元素已不存在,请重新创建")
+    } finally {
+      watermarkEl = null
+    }
+  }
+
+  /** 刷新水印(防御时调用) */
+  const updateWatermark = debounce(() => {
+    clearWatermark()
+    createWatermarkEl()
+    addElListener(parentEl.value)
+  }, 100)
+
+  /** 监听水印元素和容器元素的变化(DOM 变化 & DOM 大小变化) */
+  const addElListener = (targetNode) => {
+    // 判断是否开启防御
+    if (mergeConfig.defense) {
+      // 防止重复添加监听
+      if (!observer.watermarkElMutationObserver && !observer.parentElMutationObserver) {
+        // 监听 DOM 变化
+        addMutationListener(targetNode)
+      }
+    } else {
+      // 无防御时不需要 mutation 监听
+      removeListener("mutation")
+    }
+    // 防止重复添加监听
+    if (!observer.parentElResizeObserver) {
+      // 监听 DOM 大小变化
+      addResizeListener(targetNode)
+    }
+  }
+
+  /** 移除对水印元素和容器元素的监听,传参可指定要移除哪个监听,不传默认移除全部监听 */
+  const removeListener = (kind = "all") => {
+    // 移除 mutation 监听
+    if (kind === "mutation" || kind === "all") {
+      observer.watermarkElMutationObserver?.disconnect()
+      observer.watermarkElMutationObserver = undefined
+      observer.parentElMutationObserver?.disconnect()
+      observer.parentElMutationObserver = undefined
+    }
+    // 移除 resize 监听
+    if (kind === "resize" || kind === "all") {
+      observer.parentElResizeObserver?.disconnect()
+      observer.parentElResizeObserver = undefined
+    }
+  }
+
+  /** 监听 DOM 变化 */
+  const addMutationListener = (targetNode) => {
+    // 当观察到变动时执行的回调
+    const mutationCallback = debounce((mutationList) => {
+      // 水印的防御(防止用户手动删除水印元素或通过 CSS 隐藏水印)
+      mutationList.forEach(
+        debounce((mutation) => {
+          switch (mutation.type) {
+            case "attributes":
+              mutation.target === watermarkEl && updateWatermark()
+              break
+            case "childList":
+              mutation.removedNodes.forEach((item) => {
+                item === watermarkEl && targetNode.appendChild(watermarkEl)
+              })
+              break
+          }
+        }, 100)
+      )
+    }, 100)
+    // 创建观察器实例并传入回调
+    observer.watermarkElMutationObserver = new MutationObserver(mutationCallback)
+    observer.parentElMutationObserver = new MutationObserver(mutationCallback)
+    // 以上述配置开始观察目标节点
+    observer.watermarkElMutationObserver.observe(watermarkEl, {
+      // 观察目标节点属性是否变动,默认为 true
+      attributes: true,
+      // 观察目标子节点是否有添加或者删除,默认为 false
+      childList: false,
+      // 是否拓展到观察所有后代节点,默认为 false
+      subtree: false
+    })
+    observer.parentElMutationObserver.observe(targetNode, {
+      attributes: false,
+      childList: true,
+      subtree: false
+    })
+  }
+
+  /** 监听 DOM 大小变化 */
+  const addResizeListener = (targetNode) => {
+    // 当 targetNode 元素大小变化时去更新整个水印的大小
+    const resizeCallback = debounce(() => {
+      const { clientWidth, clientHeight } = targetNode
+      updateWatermarkEl({ width: clientWidth, height: clientHeight })
+    }, 500)
+    // 创建一个观察器实例并传入回调
+    observer.parentElResizeObserver = new ResizeObserver(resizeCallback)
+    // 开始观察目标节点
+    observer.parentElResizeObserver.observe(targetNode)
+  }
+
+  /** 在组件卸载前移除水印以及各种监听 */
+  onBeforeUnmount(() => {
+    clearWatermark()
+  })
+
+  return { setWatermark, clearWatermark }
+}

+ 6 - 0
src/icons/index.js

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

File diff suppressed because it is too large
+ 6 - 0
src/icons/svg/account-management.svg


File diff suppressed because it is too large
+ 6 - 0
src/icons/svg/activity-management.svg


File diff suppressed because it is too large
+ 8 - 0
src/icons/svg/alumni-album.svg


File diff suppressed because it is too large
+ 4 - 0
src/icons/svg/alumni-management.svg


File diff suppressed because it is too large
+ 4 - 0
src/icons/svg/alumni-organization.svg


File diff suppressed because it is too large
+ 6 - 0
src/icons/svg/news-focus.svg


File diff suppressed because it is too large
+ 8 - 0
src/icons/svg/school-endorsement.svg


+ 49 - 0
src/layouts/components/AppMain.vue

@@ -0,0 +1,49 @@
+<script 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%;
+  display: flex;
+}
+
+.app-scrollbar {
+  flex-grow: 1;
+  overflow: auto;
+  @extend %scrollbar;
+  display: flex;
+  flex-direction: column;
+  .app-container-grow {
+    flex-grow: 1;
+  }
+}
+</style>

+ 18 - 0
src/layouts/components/Footer/index.vue

@@ -0,0 +1,18 @@
+<script setup>
+const VITE_APP_TITLE = import.meta.env.VITE_APP_TITLE
+</script>
+
+<template>
+  <footer class="layout-footer">MIT © 2021-PRESENT {{ 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: var(--el-text-color-placeholder);
+}
+</style>

+ 43 - 0
src/layouts/components/Hamburger/index.vue

@@ -0,0 +1,43 @@
+<script setup>
+import { Expand, Fold } from "@element-plus/icons-vue"
+
+const props = defineProps({
+  isActive: {
+    type: Boolean,
+    default: false
+  }
+})
+
+const emit = defineEmits(["toggleClick"])
+
+const VITE_APP_TITLE = import.meta.env.VITE_APP_TITLE
+
+const toggleClick = () => {
+  emit("toggleClick")
+}
+</script>
+
+<template>
+  <div>
+    <el-icon :size="36" class="icon" @click="toggleClick">
+      <Fold v-if="props.isActive" />
+      <Expand v-else />
+    </el-icon>
+    <span class="title">{{ VITE_APP_TITLE }}</span>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.icon {
+  vertical-align: middle;
+  color: var(--v3-hamburger-text-color);
+  cursor: pointer;
+}
+
+.title {
+  margin-left: 15px;
+  font-size: 36px;
+  font-weight: 500;
+  color: var(--v3-hamburger-text-color);
+}
+</style>

+ 53 - 0
src/layouts/components/Logo/index.vue

@@ -0,0 +1,53 @@
+<script setup>
+import logo from "@/assets/layouts/logo.svg?url"
+
+const props = defineProps({
+  collapse: {
+    type: Boolean,
+    default: true
+  }
+})
+</script>
+
+<template>
+  <div class="layout-logo-container" :class="{ collapse: props.collapse }">
+    <transition name="layout-logo-fade">
+      <router-link v-if="props.collapse" key="collapse" to="/">
+        <img :src="logo" class="layout-logo" />
+      </router-link>
+      <router-link v-else key="expand" to="/">
+        <img :src="logo" 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);
+  text-align: center;
+  overflow: hidden;
+  .layout-logo {
+    display: none;
+  }
+  .layout-logo-text {
+    height: 53px;
+    vertical-align: middle;
+  }
+}
+
+.collapse {
+  .layout-logo {
+    width: 43px;
+    height: 43px;
+    vertical-align: middle;
+    display: inline-block;
+  }
+  .layout-logo-text {
+    display: none;
+  }
+}
+</style>

+ 119 - 0
src/layouts/components/NavigationBar/index.vue

@@ -0,0 +1,119 @@
+<script setup>
+import { ref, onUnmounted } from "vue"
+import { useRouter } from "vue-router"
+import { useAppStore } from "@/store/modules/app"
+import { useUserStore } from "@/store/modules/user"
+import Hamburger from "../Hamburger/index.vue"
+import { formatDateTime } from "@/utils/datetime"
+
+const router = useRouter()
+const appStore = useAppStore()
+const userStore = useUserStore()
+
+/** 切换侧边栏 */
+const toggleSidebar = () => {
+  appStore.toggleSidebar(false)
+}
+
+/** 登出 */
+const logout = () => {
+  userStore.logout()
+  router.push("/login")
+}
+
+const datetime = ref()
+const timer = setInterval(() => {
+  datetime.value = formatDateTime(new Date(), "YYYY-MM-DD dddd HH:mm:ss")
+}, 1000)
+// 释放
+onUnmounted(() => {
+  clearInterval(timer)
+})
+</script>
+
+<template>
+  <div class="navigation-bar">
+    <Hamburger :is-active="appStore.sidebar.opened" class="hamburger" @toggle-click="toggleSidebar" />
+    <div class="right-menu">
+      <div class="right-menu-item right-menu-avatar">
+        <span class="time">{{ datetime }}</span>
+        <el-avatar src="/src/assets/layouts/admin.png" :size="53" />
+        <span>{{ userStore.account }}</span>
+        <span>|</span>
+        <span @click="logout" class="logout">
+          <el-icon :size="16">
+            <SwitchButton />
+          </el-icon>
+          <span>退出</span>
+        </span>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.navigation-bar {
+  height: var(--v3-navigationbar-height);
+  overflow: hidden;
+  color: var(--v3-navigationbar-text-color);
+  display: flex;
+  justify-content: space-between;
+  .hamburger {
+    display: flex;
+    align-items: center;
+    height: 100%;
+    padding: 0 20px;
+  }
+  .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-color-primary) !important;
+        }
+      }
+    }
+  }
+  .right-menu {
+    margin-right: 10px;
+    height: 100%;
+    display: flex;
+    align-items: center;
+    .right-menu-item {
+      padding: 0 10px;
+    }
+    .right-menu-avatar {
+      display: flex;
+      align-items: center;
+      gap: 12px;
+      .time {
+        font-size: 18px;
+        font-weight: 400;
+        color: rgba(102, 102, 102, 1);
+        margin-right: 27px;
+      }
+      .el-avatar {
+        font-size: 26px;
+      }
+      .logout {
+        cursor: pointer;
+        .el-icon {
+          margin-right: 6px;
+        }
+      }
+      span {
+        font-size: 14px;
+        font-weight: 500;
+        color: rgba(51, 51, 51, 1);
+        display: flex;
+        align-items: center;
+      }
+    }
+  }
+}
+</style>

+ 104 - 0
src/layouts/components/Sidebar/SidebarItem.vue

@@ -0,0 +1,104 @@
+<script setup>
+import { computed } from "vue"
+import SidebarItemLink from "./SidebarItemLink.vue"
+import { isExternal } from "@/utils/validate"
+import path from "path-browserify"
+
+const props = defineProps({
+  item: {
+    type: Object,
+    required: true
+  },
+  basePath: {
+    type: String,
+    default: ""
+  }
+})
+
+/** 是否始终显示根菜单 */
+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) => {
+  switch (true) {
+    case isExternal(routePath):
+      return routePath
+    case isExternal(props.basePath):
+      return props.basePath
+    default:
+      return path.resolve(props.basePath, routePath)
+  }
+}
+</script>
+
+<template>
+  <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>
+          <span class="title">{{ theOnlyOneChild.meta.title }}</span>
+        </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" class="title">{{ props.item.meta.title }}</span>
+    </template>
+    <template v-if="props.item.children">
+      <SidebarItem
+        v-for="child in showingChildren"
+        :key="child.path"
+        :item="child"
+        :base-path="resolvePath(child.path)"
+      />
+    </template>
+  </el-sub-menu>
+</template>
+
+<style lang="scss" scoped>
+.svg-icon {
+  min-width: 1em;
+  margin-right: 30px;
+  font-size: 20px;
+  z-index: 1;
+}
+
+.el-icon {
+  width: 1em !important;
+  margin-right: 30px !important;
+  font-size: 20px;
+  z-index: 1;
+}
+
+.title {
+  z-index: 1;
+}
+</style>

+ 19 - 0
src/layouts/components/Sidebar/SidebarItemLink.vue

@@ -0,0 +1,19 @@
+<script setup>
+import { isExternal } from "@/utils/validate"
+
+const props = defineProps({
+  to: {
+    type: String,
+    required: true
+  }
+})
+</script>
+
+<template>
+  <a v-if="isExternal(props.to)" :href="props.to" target="_blank" rel="noopener">
+    <slot />
+  </a>
+  <router-link v-else :to="props.to">
+    <slot />
+  </router-link>
+</template>

+ 139 - 0
src/layouts/components/Sidebar/index.vue

@@ -0,0 +1,139 @@
+<script setup>
+import { computed } from "vue"
+import { useRoute } from "vue-router"
+import { useAppStore } from "@/store/modules/app"
+import { usePermissionStore } from "@/store/modules/permission"
+import { useSettingsStore } from "@/store/modules/settings"
+import SidebarItem from "./SidebarItem.vue"
+import Logo from "../Logo/index.vue"
+import { getCssVar } from "@/utils/css"
+
+const v3SidebarMenuBgColor = getCssVar("--v3-sidebar-menu-bg-color")
+const v3SidebarMenuTextColor = getCssVar("--v3-sidebar-menu-text-color")
+const v3SidebarMenuActiveTextColor = getCssVar("--v3-sidebar-menu-active-text-color")
+
+const route = useRoute()
+const appStore = useAppStore()
+const permissionStore = usePermissionStore()
+const settingsStore = useSettingsStore()
+
+const activeMenu = computed(() => {
+  const {
+    meta: { activeMenu },
+    path
+  } = route
+  return activeMenu ? activeMenu : path
+})
+const noHiddenRoutes = computed(() => permissionStore.routes.filter((item) => !item.meta?.hidden))
+const isCollapse = computed(() => !appStore.sidebar.opened)
+</script>
+
+<template>
+  <div :class="{ 'has-logo': settingsStore.showLogo }">
+    <Logo v-if="settingsStore.showLogo" :collapse="isCollapse" />
+    <el-scrollbar wrap-class="scrollbar-wrapper">
+      <el-menu
+        :default-active="activeMenu"
+        :collapse="isCollapse"
+        :background-color="v3SidebarMenuBgColor"
+        :text-color="v3SidebarMenuTextColor"
+        :active-text-color="v3SidebarMenuActiveTextColor"
+        :unique-opened="true"
+        :collapse-transition="false"
+        mode="vertical"
+      >
+        <SidebarItem v-for="route in noHiddenRoutes" :key="route.path" :item="route" :base-path="route.path" />
+      </el-menu>
+    </el-scrollbar>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+%tip-line {
+  &::before {
+    content: "";
+    position: absolute;
+    background: linear-gradient(90deg, rgba(32, 106, 255, 1) 0%, rgba(102, 182, 255, 1) 100%);
+    border-radius: 10px;
+    inset: 12px 18px;
+  }
+  &::after {
+    content: "";
+    position: absolute;
+    background: linear-gradient(90deg, rgba(38, 151, 255, 1) 0%, rgba(102, 182, 255, 1) 100%);
+    box-shadow: 4px 0px 8px rgba(0, 0, 0, 0.1);
+    border-radius: 10px 0 0 10px;
+    inset: 12px 18px;
+    width: 45px;
+  }
+}
+
+.has-logo {
+  .el-scrollbar {
+    height: calc(100% - var(--v3-header-height));
+  }
+}
+
+.el-scrollbar {
+  height: 100%;
+  border-top: 1px solid #ffffff80;
+  :deep(.scrollbar-wrapper) {
+    // 限制水平宽度
+    overflow-x: hidden !important;
+  }
+  // 滚动条
+  :deep(.el-scrollbar__bar) {
+    &.is-horizontal {
+      // 隐藏水平滚动条
+      display: none;
+    }
+  }
+}
+
+.el-menu {
+  border: none;
+  width: 100% !important;
+  --el-menu-base-level-padding: 30px; // 保证菜单收缩后 icon 居中
+  --el-menu-level-padding: calc(20px + 15px); // 保证子菜单文字与父级文字对齐
+  margin-top: 20px;
+}
+
+:deep(.el-menu-item),
+:deep(.el-sub-menu__title),
+:deep(.el-sub-menu .el-menu-item) {
+  height: var(--v3-sidebar-menu-item-height);
+  line-height: var(--v3-sidebar-menu-item-height);
+  font-size: 18px;
+  &.is-active,
+  &:hover {
+    background-color: transparent;
+  }
+}
+
+:deep(.el-sub-menu .el-menu-item) {
+  height: calc(var(--v3-sidebar-menu-item-height) * 0.8);
+  line-height: calc(var(--v3-sidebar-menu-item-height) * 0.8);
+}
+
+:deep(.el-sub-menu) {
+  &.is-active {
+    > .el-sub-menu__title {
+      color: v-bind(v3SidebarMenuActiveTextColor) !important;
+      @extend %tip-line;
+    }
+    .el-menu-item.is-active {
+      color: rgba(89, 168, 255, 1);
+      &::before {
+        content: none;
+      }
+      &::after {
+        content: none;
+      }
+    }
+  }
+}
+
+:deep(.el-menu-item.is-active) {
+  @extend %tip-line;
+}
+</style>

+ 143 - 0
src/layouts/components/TagsView/ScrollPane.vue

@@ -0,0 +1,143 @@
+<script setup>
+import { ref, nextTick } from "vue"
+import { useRoute } from "vue-router"
+import { useRouteListener } from "@/hooks/useRouteListener"
+import { ElScrollbar } from "element-plus"
+import { ArrowLeft, ArrowRight } from "@element-plus/icons-vue"
+
+const props = defineProps({
+  tagRefs: {
+    type: Array,
+    required: true
+  }
+})
+
+const route = useRoute()
+const { listenerRouteChange } = useRouteListener()
+
+/** 滚动条组件元素的引用 */
+const scrollbarRef = ref()
+/** 滚动条内容元素的引用 */
+const scrollbarContentRef = ref()
+
+/** 当前滚动条距离左边的距离 */
+let currentScrollLeft = 0
+/** 每次滚动距离 */
+const translateDistance = 200
+
+/** 滚动时触发 */
+const scroll = ({ scrollLeft }) => {
+  currentScrollLeft = scrollLeft
+}
+
+/** 鼠标滚轮滚动时触发 */
+const wheelScroll = ({ deltaY }) => {
+  if (/^-/.test(deltaY.toString())) {
+    scrollTo("left")
+  } else {
+    scrollTo("right")
+  }
+}
+
+/** 获取可能需要的宽度 */
+const getWidth = () => {
+  /** 可滚动内容的长度 */
+  const scrollbarContentRefWidth = scrollbarContentRef.value.clientWidth
+  /** 滚动可视区宽度 */
+  const scrollbarRefWidth = scrollbarRef.value.wrapRef.clientWidth
+  /** 最后剩余可滚动的宽度 */
+  const lastDistance = scrollbarContentRefWidth - scrollbarRefWidth - currentScrollLeft
+
+  return { scrollbarContentRefWidth, scrollbarRefWidth, lastDistance }
+}
+
+/** 左右滚动 */
+const scrollTo = (direction, distance = translateDistance) => {
+  let scrollLeft = 0
+  const { scrollbarContentRefWidth, scrollbarRefWidth, lastDistance } = getWidth()
+  // 没有横向滚动条,直接结束
+  if (scrollbarRefWidth > scrollbarContentRefWidth) return
+  if (direction === "left") {
+    scrollLeft = Math.max(0, currentScrollLeft - distance)
+  } else {
+    scrollLeft = Math.min(currentScrollLeft + distance, currentScrollLeft + lastDistance)
+  }
+  scrollbarRef.value.setScrollLeft(scrollLeft)
+}
+
+/** 移动到目标位置 */
+const moveTo = () => {
+  const tagRefs = props.tagRefs
+  for (let i = 0; i < tagRefs.length; i++) {
+    if (route.path === tagRefs[i].$props.to.path) {
+      const el = tagRefs[i].$el
+      const offsetWidth = el.offsetWidth
+      const offsetLeft = el.offsetLeft
+      const { scrollbarRefWidth } = getWidth()
+      // 当前 tag 在可视区域左边时
+      if (offsetLeft < currentScrollLeft) {
+        const distance = currentScrollLeft - offsetLeft
+        scrollTo("left", distance)
+        return
+      }
+      // 当前 tag 在可视区域右边时
+      const width = scrollbarRefWidth + currentScrollLeft - offsetWidth
+      if (offsetLeft > width) {
+        const distance = offsetLeft - width
+        scrollTo("right", distance)
+        return
+      }
+    }
+  }
+}
+
+/** 监听路由变化,移动到目标位置 */
+listenerRouteChange(() => {
+  nextTick(moveTo)
+})
+</script>
+
+<template>
+  <div class="scroll-container">
+    <el-icon class="arrow left" @click="scrollTo('left')">
+      <ArrowLeft />
+    </el-icon>
+    <el-scrollbar ref="scrollbarRef" @wheel.passive="wheelScroll" @scroll="scroll">
+      <div ref="scrollbarContentRef" class="scrollbar-content">
+        <slot />
+      </div>
+    </el-scrollbar>
+    <el-icon class="arrow right" @click="scrollTo('right')">
+      <ArrowRight />
+    </el-icon>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.scroll-container {
+  height: 100%;
+  user-select: none;
+  display: flex;
+  justify-content: space-between;
+  .arrow {
+    width: 40px;
+    height: 100%;
+    font-size: 18px;
+    cursor: pointer;
+    &.left {
+      box-shadow: 5px 0 5px -6px var(--el-border-color-darker);
+    }
+    &.right {
+      box-shadow: -5px 0 5px -6px var(--el-border-color-darker);
+    }
+  }
+  .el-scrollbar {
+    flex: 1;
+    // 防止换行(超出宽度时,显示滚动条)
+    white-space: nowrap;
+    .scrollbar-content {
+      display: inline-block;
+    }
+  }
+}
+</style>

+ 253 - 0
src/layouts/components/TagsView/index.vue

@@ -0,0 +1,253 @@
+<script setup>
+import { ref, watch } from "vue"
+import { RouterLink, useRoute, useRouter } from "vue-router"
+import { useTagsViewStore } from "@/store/modules/tags-view"
+import { usePermissionStore } from "@/store/modules/permission"
+import { useRouteListener } from "@/hooks/useRouteListener"
+import path from "path-browserify"
+import ScrollPane from "./ScrollPane.vue"
+import { Close } from "@element-plus/icons-vue"
+
+const router = useRouter()
+const route = useRoute()
+const tagsViewStore = useTagsViewStore()
+const permissionStore = usePermissionStore()
+const { listenerRouteChange } = useRouteListener()
+
+/** 标签页组件元素的引用数组 */
+const tagRefs = ref([])
+
+/** 右键菜单的状态 */
+const visible = ref(false)
+/** 右键菜单的 top 位置 */
+const top = ref(0)
+/** 右键菜单的 left 位置 */
+const left = ref(0)
+/** 当前正在右键操作的标签页 */
+const selectedTag = ref({})
+/** 固定的标签页 */
+let affixTags = []
+
+/** 判断标签页是否激活 */
+const isActive = (tag) => {
+  return tag.path === route.path
+}
+
+/** 判断标签页是否固定 */
+const isAffix = (tag) => {
+  return tag.meta?.affix
+}
+
+/** 筛选出固定标签页 */
+const filterAffixTags = (routes, basePath = "/") => {
+  const tags = []
+  routes.forEach((route) => {
+    if (isAffix(route)) {
+      const tagPath = path.resolve(basePath, route.path)
+      tags.push({
+        fullPath: tagPath,
+        path: tagPath,
+        name: route.name,
+        meta: { ...route.meta }
+      })
+    }
+    if (route.children) {
+      const childTags = filterAffixTags(route.children, route.path)
+      tags.push(...childTags)
+    }
+  })
+  return tags
+}
+
+/** 初始化标签页 */
+const initTags = () => {
+  affixTags = filterAffixTags(permissionStore.routes)
+  for (const tag of affixTags) {
+    // 必须含有 name 属性
+    tag.name && tagsViewStore.addVisitedView(tag)
+  }
+}
+
+/** 添加标签页 */
+const addTags = (route) => {
+  if (route.name) {
+    tagsViewStore.addVisitedView(route)
+    tagsViewStore.addCachedView(route)
+  }
+}
+
+/** 刷新当前正在右键操作的标签页 */
+const refreshSelectedTag = (view) => {
+  tagsViewStore.delCachedView(view)
+  router.replace({ path: "/redirect" + view.path, query: view.query })
+}
+
+/** 关闭当前正在右键操作的标签页 */
+const closeSelectedTag = (view) => {
+  tagsViewStore.delVisitedView(view)
+  tagsViewStore.delCachedView(view)
+  isActive(view) && toLastView(tagsViewStore.visitedViews, view)
+}
+
+/** 关闭其他标签页 */
+const closeOthersTags = () => {
+  const fullPath = selectedTag.value.fullPath
+  if (fullPath !== route.path && fullPath !== undefined) {
+    router.push(fullPath)
+  }
+  tagsViewStore.delOthersVisitedViews(selectedTag.value)
+  tagsViewStore.delOthersCachedViews(selectedTag.value)
+}
+
+/** 关闭所有标签页 */
+const closeAllTags = (view) => {
+  tagsViewStore.delAllVisitedViews()
+  tagsViewStore.delAllCachedViews()
+  if (affixTags.some((tag) => tag.path === route.path)) return
+  toLastView(tagsViewStore.visitedViews, view)
+}
+
+/** 跳转到最后一个标签页 */
+const toLastView = (visitedViews, view) => {
+  const latestView = visitedViews.slice(-1)[0]
+  const fullPath = latestView?.fullPath
+  if (fullPath !== undefined) {
+    router.push(fullPath)
+  } else {
+    // 如果 TagsView 全部被关闭了,则默认重定向到主页
+    if (view.name === "Dashboard") {
+      // 重新加载主页
+      router.push({ path: "/redirect" + view.path, query: view.query })
+    } else {
+      router.push("/")
+    }
+  }
+}
+
+/** 打开右键菜单面板 */
+const openMenu = (tag, e) => {
+  const menuMinWidth = 100
+  // 当前页面宽度
+  const offsetWidth = document.body.offsetWidth
+  // 面板的最大左边距
+  const maxLeft = offsetWidth - menuMinWidth
+  // 面板距离鼠标指针的距离
+  const left15 = e.clientX + 10
+  left.value = left15 > maxLeft ? maxLeft : left15
+  top.value = e.clientY
+  // 显示面板
+  visible.value = true
+  // 更新当前正在右键操作的标签页
+  selectedTag.value = tag
+}
+
+/** 关闭右键菜单面板 */
+const closeMenu = () => {
+  visible.value = false
+}
+
+watch(visible, (value) => {
+  value ? document.body.addEventListener("click", closeMenu) : document.body.removeEventListener("click", closeMenu)
+})
+
+initTags()
+
+/** 监听路由变化 */
+listenerRouteChange((route) => {
+  addTags(route)
+}, true)
+</script>
+
+<template>
+  <div class="tags-view-container">
+    <ScrollPane class="tags-view-wrapper" :tag-refs="tagRefs">
+      <router-link
+        ref="tagRefs"
+        v-for="tag in tagsViewStore.visitedViews"
+        :key="tag.path"
+        :class="{ active: isActive(tag) }"
+        class="tags-view-item"
+        :to="{ path: tag.path, query: tag.query }"
+        @click.middle="!isAffix(tag) && closeSelectedTag(tag)"
+        @contextmenu.prevent="openMenu(tag, $event)"
+      >
+        {{ tag.meta?.title }}
+        <el-icon v-if="!isAffix(tag)" :size="12" @click.prevent.stop="closeSelectedTag(tag)">
+          <Close />
+        </el-icon>
+      </router-link>
+    </ScrollPane>
+    <ul v-show="visible" class="contextmenu" :style="{ left: left + 'px', top: top + 'px' }">
+      <li @click="refreshSelectedTag(selectedTag)">刷新</li>
+      <li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)">关闭</li>
+      <li @click="closeOthersTags">关闭其它</li>
+      <li @click="closeAllTags(selectedTag)">关闭所有</li>
+    </ul>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.tags-view-container {
+  height: var(--v3-tagsview-height);
+  width: 100%;
+  color: var(--v3-tagsview-text-color);
+  overflow: hidden;
+  .tags-view-wrapper {
+    .tags-view-item {
+      display: inline-block;
+      position: relative;
+      cursor: pointer;
+      height: 26px;
+      line-height: 26px;
+      border: 1px solid var(--v3-tagsview-tag-border-color);
+      border-radius: var(--v3-tagsview-tag-border-radius);
+      background-color: var(--v3-tagsview-tag-bg-color);
+      padding: 0 8px;
+      font-size: 12px;
+      margin-left: 5px;
+      margin-top: 4px;
+      &:first-of-type {
+        margin-left: 5px;
+      }
+      &:last-of-type {
+        margin-right: 5px;
+      }
+      &.active {
+        background-color: var(--v3-tagsview-tag-active-bg-color);
+        color: var(--v3-tagsview-tag-active-text-color);
+        border-color: var(--v3-tagsview-tag-active-border-color);
+      }
+      .el-icon {
+        margin: 0 2px;
+        vertical-align: middle;
+        border-radius: 50%;
+        &:hover {
+          background-color: var(--v3-tagsview-tag-icon-hover-bg-color);
+          color: var(--v3-tagsview-tag-icon-hover-color);
+        }
+      }
+    }
+  }
+  .contextmenu {
+    margin: 0;
+    z-index: 3000;
+    position: fixed;
+    list-style-type: none;
+    padding: 5px 0;
+    border-radius: 4px;
+    font-size: 12px;
+    color: var(--v3-tagsview-contextmenu-text-color);
+    background-color: var(--v3-tagsview-contextmenu-bg-color);
+    box-shadow: var(--v3-tagsview-contextmenu-box-shadow);
+    li {
+      margin: 0;
+      padding: 7px 16px;
+      cursor: pointer;
+      &:hover {
+        color: var(--v3-tagsview-contextmenu-hover-text-color);
+        background-color: var(--v3-tagsview-contextmenu-hover-bg-color);
+      }
+    }
+  }
+}
+</style>

+ 5 - 0
src/layouts/components/index.js

@@ -0,0 +1,5 @@
+export { default as AppMain } from "./AppMain.vue"
+export { default as NavigationBar } from "./NavigationBar/index.vue"
+export { default as Sidebar } from "./Sidebar/index.vue"
+export { default as TagsView } from "./TagsView/index.vue"
+export { default as Logo } from "./Logo/index.vue"

+ 52 - 0
src/layouts/hooks/useResize.js

@@ -0,0 +1,52 @@
+import { onBeforeMount, onMounted, onBeforeUnmount } from "vue"
+import { useAppStore } from "@/store/modules/app"
+import { useRouteListener } from "@/hooks/useRouteListener"
+import { DeviceEnum } from "@/constants/app-key"
+
+/** 参考 Bootstrap 的响应式设计将最大移动端宽度设置为 992 */
+const MAX_MOBILE_WIDTH = 992
+
+/** 根据浏览器宽度变化,变换 Layout 布局 */
+export default () => {
+  const appStore = useAppStore()
+  const { listenerRouteChange } = useRouteListener()
+
+  /** 用于判断当前设备是否为移动端 */
+  const _isMobile = () => {
+    const rect = document.body.getBoundingClientRect()
+    return rect.width - 1 < MAX_MOBILE_WIDTH
+  }
+
+  /** 用于处理窗口大小变化事件 */
+  const _resizeHandler = () => {
+    if (!document.hidden) {
+      const isMobile = _isMobile()
+      appStore.toggleDevice(isMobile ? DeviceEnum.Mobile : DeviceEnum.Desktop)
+      isMobile && appStore.closeSidebar(true)
+    }
+  }
+  /** 监听路由变化,根据设备类型调整布局 */
+  listenerRouteChange(() => {
+    if (appStore.device === DeviceEnum.Mobile && appStore.sidebar.opened) {
+      appStore.closeSidebar(false)
+    }
+  })
+
+  /** 在组件挂载前添加窗口大小变化事件监听器 */
+  onBeforeMount(() => {
+    window.addEventListener("resize", _resizeHandler)
+  })
+
+  /** 在组件挂载后根据窗口大小判断设备类型并调整布局 */
+  onMounted(() => {
+    if (_isMobile()) {
+      appStore.toggleDevice(DeviceEnum.Mobile)
+      appStore.closeSidebar(true)
+    }
+  })
+
+  /** 在组件卸载前移除窗口大小变化事件监听器 */
+  onBeforeUnmount(() => {
+    window.removeEventListener("resize", _resizeHandler)
+  })
+}

+ 190 - 0
src/layouts/index.vue

@@ -0,0 +1,190 @@
+<script setup>
+import { computed, watchEffect } from "vue"
+import { storeToRefs } from "pinia"
+import { useAppStore } from "@/store/modules/app"
+import { useSettingsStore } from "@/store/modules/settings"
+import useResize from "./hooks/useResize"
+import { useWatermark } from "@/hooks/useWatermark"
+import { useDevice } from "@/hooks/useDevice"
+import { AppMain, NavigationBar, Sidebar, TagsView } from "./components"
+import { getCssVar, setCssVar } from "@/utils/css"
+
+/** Layout 布局响应式 */
+useResize()
+
+const { setWatermark, clearWatermark } = useWatermark()
+const { isMobile } = useDevice()
+const appStore = useAppStore()
+const settingsStore = useSettingsStore()
+const { showTagsView, showWatermark, fixedHeader } = storeToRefs(settingsStore)
+
+//#region 隐藏标签栏时删除其高度,是为了让 Logo 组件高度和 Header 区域高度始终一致
+const cssVarName = "--v3-tagsview-height"
+const v3TagsviewHeight = getCssVar(cssVarName)
+watchEffect(() => {
+  showTagsView.value ? setCssVar(cssVarName, v3TagsviewHeight) : setCssVar(cssVarName, "0px")
+})
+//#endregion
+
+/** 开启或关闭系统水印 */
+watchEffect(() => {
+  showWatermark.value ? setWatermark(import.meta.env.VITE_APP_TITLE) : clearWatermark()
+})
+
+/** 定义计算属性 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: rgba(0, 0, 0, 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;
+  border-right: var(--v3-sidebar-border-right);
+}
+
+.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;
+  background-color: var(--v3-header-bg-color);
+  box-shadow: var(--v3-header-box-shadow);
+  border-bottom: var(--v3-header-border-bottom);
+}
+
+.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>

+ 28 - 0
src/main.js

@@ -0,0 +1,28 @@
+// core
+import App from "@/App.vue"
+import { createApp } from "vue"
+import { pinia } from "@/store"
+import { router } from "@/router"
+import "@/router/permission"
+// load
+import { loadSvg } from "@/icons"
+import { loadPlugins } from "@/plugins"
+import { loadDirectives } from "@/directives"
+// css
+import "normalize.css"
+import "element-plus/dist/index.css"
+import "@/styles/index.scss"
+
+const app = createApp(App)
+
+/** 加载插件 */
+loadPlugins(app)
+/** 加载全局 SVG */
+loadSvg(app)
+/** 加载自定义指令 */
+loadDirectives(app)
+
+app.use(pinia).use(router)
+router.isReady().then(() => {
+  app.mount("#app")
+})

+ 8 - 0
src/plugins/element-plus-icon/index.js

@@ -0,0 +1,8 @@
+import * as ElementPlusIconsVue from "@element-plus/icons-vue"
+
+export function loadElementPlusIcon(app) {
+  /** 注册所有 Element Plus Icon */
+  for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
+    app.component(key, component)
+  }
+}

+ 6 - 0
src/plugins/element-plus/index.js

@@ -0,0 +1,6 @@
+import ElementPlus from "element-plus"
+
+export function loadElementPlus(app) {
+  /** Element Plus 组件完整引入 */
+  app.use(ElementPlus)
+}

+ 7 - 0
src/plugins/index.js

@@ -0,0 +1,7 @@
+import { loadElementPlus } from "./element-plus"
+import { loadElementPlusIcon } from "./element-plus-icon"
+
+export function loadPlugins(app) {
+  loadElementPlus(app)
+  loadElementPlusIcon(app)
+}

+ 62 - 0
src/router/helper.js

@@ -0,0 +1,62 @@
+import { createRouter, createWebHashHistory, createWebHistory } from "vue-router"
+import { cloneDeep, omit } from "lodash-es"
+
+/** 路由模式 */
+export const history =
+  import.meta.env.VITE_ROUTER_HISTORY === "hash"
+    ? createWebHashHistory(import.meta.env.VITE_PUBLIC_PATH)
+    : createWebHistory(import.meta.env.VITE_PUBLIC_PATH)
+
+/** 路由降级(把三级及其以上的路由转化为二级路由) */
+export const flatMultiLevelRoutes = (routes) => {
+  const routesMirror = cloneDeep(routes)
+  routesMirror.forEach((route) => {
+    // 如果路由是三级及其以上路由,对其进行降级处理
+    isMultipleRoute(route) && promoteRouteLevel(route)
+  })
+  return routesMirror
+}
+
+/** 判断路由层级是否大于 2 */
+const isMultipleRoute = (route) => {
+  const children = route.children
+  if (children?.length) {
+    // 只要有一个子路由的 children 长度大于 0,就说明是三级及其以上路由
+    return children.some((child) => child.children?.length)
+  }
+  return false
+}
+
+/** 生成二级路由 */
+const promoteRouteLevel = (route) => {
+  // 创建 router 实例是为了获取到当前传入的 route 的所有路由信息
+  let router = createRouter({
+    history,
+    routes: [route]
+  })
+  const routes = router.getRoutes()
+  // 在 addToChildren 函数中使用上面获取到的路由信息来更新 route 的 children
+  addToChildren(routes, route.children || [], route)
+  router = null
+  // 转为二级路由后,去除所有子路由中的 children
+  route.children = route.children?.map((item) => omit(item, "children"))
+}
+
+/** 将给定的子路由添加到指定的路由模块中 */
+const addToChildren = (routes, children, routeModule) => {
+  children.forEach((child) => {
+    const route = routes.find((item) => item.name === child.name)
+    if (route) {
+      // 初始化 routeModule 的 children
+      routeModule.children = routeModule.children || []
+      // 如果 routeModule 的 children 属性中不包含该路由,则将其添加进去
+      if (!routeModule.children.includes(route)) {
+        routeModule.children.push(route)
+      }
+      // 如果该子路由还有自己的子路由,则递归调用此函数将它们也添加进去
+      if (child.children?.length) {
+        addToChildren(routes, child.children, routeModule)
+      }
+    }
+  })
+}

+ 268 - 0
src/router/index.js

@@ -0,0 +1,268 @@
+import { createRouter } from "vue-router"
+import { history, flatMultiLevelRoutes } from "./helper"
+import routeSettings from "@/config/route"
+
+const Layouts = () => import("@/layouts/index.vue")
+
+/**
+ * 常驻路由
+ * 除了 redirect/403/404/login 等隐藏页面,其他页面建议设置 Name 属性
+ */
+export const constantRoutes = [
+  {
+    path: "/redirect",
+    component: Layouts,
+    meta: {
+      hidden: true
+    },
+    children: [
+      {
+        path: ":path(.*)",
+        component: () => import("@/views/redirect/index.vue")
+      }
+    ]
+  },
+  {
+    path: "/403",
+    component: () => import("@/views/error-page/403.vue"),
+    meta: {
+      hidden: true
+    }
+  },
+  {
+    path: "/404",
+    component: () => import("@/views/error-page/404.vue"),
+    meta: {
+      hidden: true
+    },
+    alias: "/:pathMatch(.*)*"
+  },
+  {
+    path: "/login",
+    component: () => import("@/views/login/index.vue"),
+    meta: {
+      hidden: true
+    }
+  },
+  {
+    path: "/",
+    component: Layouts,
+    redirect: "/alumni-management",
+    children: [
+      {
+        path: "alumni-management",
+        component: () => import("@/views/alumni-management/index.vue"),
+        name: "AlumniManagement",
+        meta: {
+          title: "校友管理",
+          svgIcon: "alumni-management",
+          affix: true
+        }
+      }
+    ]
+  },
+  {
+    path: "/alumni-organization",
+    component: Layouts,
+    redirect: "/alumni-organization/index",
+    name: "AlumniOrganization",
+    meta: {
+      title: "校友组织",
+      svgIcon: "alumni-organization"
+    },
+    children: [
+      {
+        path: "index",
+        component: () => import("@/views/alumni-organization/index.vue"),
+        name: "AlumniOrganizationIndex",
+        meta: {
+          title: "校友组织"
+        }
+      },
+      {
+        path: "classification-management",
+        component: () => import("@/views/alumni-organization/classification-management.vue"),
+        name: "AlumniOrganizationClassificationManagement",
+        meta: {
+          title: "分类管理"
+        }
+      },
+      {
+        path: "audit-list",
+        component: () => import("@/views/alumni-organization/audit-list.vue"),
+        name: "AlumniOrganizationAuditList",
+        meta: {
+          title: "审核列表"
+        }
+      }
+    ]
+  },
+  {
+    path: "/alumni-album",
+    component: Layouts,
+    redirect: "/alumni-album/index",
+    name: "AlumniAlbum",
+    meta: {
+      title: "校友相册",
+      svgIcon: "alumni-album"
+    },
+    children: [
+      {
+        path: "index",
+        component: () => import("@/views/alumni-album/index.vue"),
+        name: "AlumniAlbumIndex",
+        meta: {
+          title: "校友相册"
+        }
+      },
+      {
+        path: "classification-management",
+        component: () => import("@/views/alumni-album/classification-management.vue"),
+        name: "AlumniAlbumClassificationManagement",
+        meta: {
+          title: "分类管理"
+        }
+      },
+      {
+        path: "audit-list",
+        component: () => import("@/views/alumni-album/audit-list.vue"),
+        name: "AlumniAlbumAuditList",
+        meta: {
+          title: "审核列表"
+        }
+      }
+    ]
+  },
+  {
+    path: "/activity-management",
+    component: Layouts,
+    redirect: "/activity-management/index",
+    name: "ActivityManagement",
+    meta: {
+      title: "活动管理",
+      svgIcon: "activity-management"
+    },
+    children: [
+      {
+        path: "index",
+        component: () => import("@/views/activity-management/index.vue"),
+        name: "ActivityManagementIndex",
+        meta: {
+          title: "活动管理"
+        }
+      },
+      {
+        path: "audit-list",
+        component: () => import("@/views/activity-management/audit-list.vue"),
+        name: "ActivityManagementAuditList",
+        meta: {
+          title: "审核列表"
+        }
+      }
+    ]
+  },
+  {
+    path: "/school-endorsement",
+    component: Layouts,
+    redirect: "/school-endorsement/index",
+    name: "SchoolEndorsement",
+    meta: {
+      title: "母校代言",
+      svgIcon: "school-endorsement"
+    },
+    children: [
+      {
+        path: "index",
+        component: () => import("@/views/school-endorsement/index.vue"),
+        name: "SchoolEndorsementIndex",
+        meta: {
+          title: "母校代言"
+        }
+      },
+      {
+        path: "audit-list",
+        component: () => import("@/views/school-endorsement/audit-list.vue"),
+        name: "SchoolEndorsementAuditList",
+        meta: {
+          title: "审核列表"
+        }
+      }
+    ]
+  },
+  {
+    path: "/news-focus",
+    component: Layouts,
+    redirect: "/news-focus/index",
+    name: "NewsFocus",
+    meta: {
+      title: "新闻聚焦",
+      svgIcon: "news-focus"
+    },
+    children: [
+      {
+        path: "index",
+        component: () => import("@/views/news-focus/index.vue"),
+        name: "NewsFocusIndex",
+        meta: {
+          title: "新闻聚焦"
+        }
+      },
+      {
+        path: "news-classification",
+        component: () => import("@/views/news-focus/news-classification.vue"),
+        name: "NewsFocusNewsClassification",
+        meta: {
+          title: "新闻分类"
+        }
+      }
+    ]
+  },
+  {
+    path: "/account-management",
+    component: Layouts,
+    redirect: "/account-management/index",
+    name: "AccountManagement",
+    meta: {
+      title: "新闻聚焦"
+    },
+    children: [
+      {
+        path: "index",
+        component: () => import("@/views/account-management/index.vue"),
+        name: "AccountManagementIndex",
+        meta: {
+          title: "账号管理",
+          svgIcon: "account-management"
+        }
+      }
+    ]
+  }
+]
+
+/**
+ * 动态路由
+ * 用来放置有权限 (Roles 属性) 的路由
+ * 必须带有 Name 属性
+ */
+export const dynamicRoutes = []
+
+export const router = createRouter({
+  history,
+  routes: routeSettings.thirdLevelRouteCache ? flatMultiLevelRoutes(constantRoutes) : constantRoutes
+})
+
+/** 重置路由 */
+export function resetRouter() {
+  // 注意:所有动态路由路由必须带有 Name 属性,否则可能会不能完全重置干净
+  try {
+    router.getRoutes().forEach((route) => {
+      const { name, meta } = route
+      if (name && meta.roles?.length) {
+        router.hasRoute(name) && router.removeRoute(name)
+      }
+    })
+  } catch {
+    // 强制刷新浏览器也行,只是交互体验不是很好
+    window.location.reload()
+  }
+}

+ 61 - 0
src/router/permission.js

@@ -0,0 +1,61 @@
+import { router } from "@/router"
+import { useUserStoreHook } from "@/store/modules/user"
+import { usePermissionStoreHook } from "@/store/modules/permission"
+import { ElMessage } from "element-plus"
+import { setRouteChange } from "@/hooks/useRouteListener"
+import { useTitle } from "@/hooks/useTitle"
+import { getToken } from "@/utils/cache/cookies"
+import routeSettings from "@/config/route"
+import isWhiteList from "@/config/white-list"
+import NProgress from "nprogress"
+import "nprogress/nprogress.css"
+
+NProgress.configure({ showSpinner: false })
+const { setTitle } = useTitle()
+const userStore = useUserStoreHook()
+const permissionStore = usePermissionStoreHook()
+
+router.beforeEach(async (to, _from, next) => {
+  NProgress.start()
+  // 如果没有登陆
+  if (!getToken()) {
+    // 如果在免登录的白名单中,则直接进入
+    if (isWhiteList(to)) return next()
+    // 其他没有访问权限的页面将被重定向到登录页面
+    return next("/login")
+  }
+
+  // 如果已经登录,并准备进入 Login 页面,则重定向到主页
+  if (to.path === "/login") {
+    return next({ path: "/" })
+  }
+
+  permissionStore.setAllRoutes()
+  return next()
+  // // 如果用户已经获得其权限角色
+  // if (userStore.roles.length !== 0) return next()
+
+  // // 否则要重新获取权限角色
+  // try {
+  //   await userStore.getInfo()
+  //   // 注意:角色必须是一个数组! 例如: ["admin"] 或 ["developer", "editor"]
+  //   const roles = userStore.roles
+  //   // 生成可访问的 Routes
+  //   routeSettings.dynamic ? permissionStore.setRoutes(roles) : permissionStore.setAllRoutes()
+  //   // 将 "有访问权限的动态路由" 添加到 Router 中
+  //   permissionStore.addRoutes.forEach((route) => router.addRoute(route))
+  //   // 设置 replace: true, 因此导航将不会留下历史记录
+  //   next({ ...to, replace: true })
+  // } catch (error) {
+  //   // 过程中发生任何错误,都直接重置 Token,并重定向到登录页面
+  //   userStore.resetToken()
+  //   ElMessage.error(error.message || "路由守卫过程发生错误")
+  //   next("/login")
+  // }
+})
+
+router.afterEach((to) => {
+  setRouteChange(to)
+  setTitle(to.meta.title)
+  NProgress.done()
+})

+ 3 - 0
src/store/index.js

@@ -0,0 +1,3 @@
+import { createPinia } from "pinia"
+
+export const pinia = createPinia()

+ 51 - 0
src/store/modules/app.js

@@ -0,0 +1,51 @@
+import { reactive, ref, watch } from "vue"
+import { pinia } from "@/store"
+import { defineStore } from "pinia"
+import { getSidebarStatus, setSidebarStatus } from "@/utils/cache/local-storage"
+import { DeviceEnum, SIDEBAR_OPENED, SIDEBAR_CLOSED } from "@/constants/app-key"
+
+/** 设置侧边栏状态本地缓存 */
+function handleSidebarStatus(opened) {
+  opened ? setSidebarStatus(SIDEBAR_OPENED) : setSidebarStatus(SIDEBAR_CLOSED)
+}
+
+export const useAppStore = defineStore("app", () => {
+  /** 侧边栏状态 */
+  const sidebar = reactive({
+    opened: getSidebarStatus() !== SIDEBAR_CLOSED,
+    withoutAnimation: false
+  })
+  /** 设备类型 */
+  const device = ref(DeviceEnum.Desktop)
+
+  /** 监听侧边栏 opened 状态 */
+  watch(
+    () => sidebar.opened,
+    (opened) => handleSidebarStatus(opened)
+  )
+
+  /** 切换侧边栏 */
+  const toggleSidebar = (withoutAnimation) => {
+    sidebar.opened = !sidebar.opened
+    sidebar.withoutAnimation = withoutAnimation
+  }
+  /** 关闭侧边栏 */
+  const closeSidebar = (withoutAnimation) => {
+    sidebar.opened = false
+    sidebar.withoutAnimation = withoutAnimation
+  }
+  /** 切换设备类型 */
+  const toggleDevice = (value) => {
+    device.value = value
+  }
+
+  return { device, sidebar, toggleSidebar, closeSidebar, toggleDevice }
+})
+
+/**
+ * 在 SPA 应用中可用于在 pinia 实例被激活前使用 store
+ * 在 SSR 应用中可用于在 setup 外使用 store
+ */
+export function useAppStoreHook() {
+  return useAppStore(pinia)
+}

+ 58 - 0
src/store/modules/permission.js

@@ -0,0 +1,58 @@
+import { ref } from "vue"
+import { pinia } from "@/store"
+import { defineStore } from "pinia"
+import { constantRoutes, dynamicRoutes } from "@/router"
+import { flatMultiLevelRoutes } from "@/router/helper"
+import routeSettings from "@/config/route"
+
+const hasPermission = (roles, route) => {
+  const routeRoles = route.meta?.roles
+  return routeRoles ? roles.some((role) => routeRoles.includes(role)) : true
+}
+
+const filterDynamicRoutes = (routes, roles) => {
+  const res = []
+  routes.forEach((route) => {
+    const tempRoute = { ...route }
+    if (hasPermission(roles, tempRoute)) {
+      if (tempRoute.children) {
+        tempRoute.children = filterDynamicRoutes(tempRoute.children, roles)
+      }
+      res.push(tempRoute)
+    }
+  })
+  return res
+}
+
+export const usePermissionStore = defineStore("permission", () => {
+  /** 可访问的路由 */
+  const routes = ref([])
+  /** 有访问权限的动态路由 */
+  const addRoutes = ref([])
+
+  /** 根据角色生成可访问的 Routes(可访问的路由 = 常驻路由 + 有访问权限的动态路由) */
+  const setRoutes = (roles) => {
+    const accessedRoutes = filterDynamicRoutes(dynamicRoutes, roles)
+    _set(accessedRoutes)
+  }
+
+  /** 所有路由 = 所有常驻路由 + 所有动态路由 */
+  const setAllRoutes = () => {
+    _set(dynamicRoutes)
+  }
+
+  const _set = (accessedRoutes) => {
+    routes.value = constantRoutes.concat(accessedRoutes)
+    addRoutes.value = routeSettings.thirdLevelRouteCache ? flatMultiLevelRoutes(accessedRoutes) : accessedRoutes
+  }
+
+  return { routes, addRoutes, setRoutes, setAllRoutes }
+})
+
+/**
+ * 在 SPA 应用中可用于在 pinia 实例被激活前使用 store
+ * 在 SSR 应用中可用于在 setup 外使用 store
+ */
+export function usePermissionStoreHook() {
+  return usePermissionStore(pinia)
+}

+ 25 - 0
src/store/modules/settings.js

@@ -0,0 +1,25 @@
+import { ref } from "vue"
+import { pinia } from "@/store"
+import { defineStore } from "pinia"
+import { layoutSettings } from "@/config/layouts"
+
+export const useSettingsStore = defineStore("settings", () => {
+  /** 状态对象 */
+  const state = {}
+  // 遍历 layoutSettings 对象的键值对
+  for (const [key, value] of Object.entries(layoutSettings)) {
+    // 使用类型断言来指定 key 的类型,将 value 包装在 ref 函数中,创建一个响应式变量
+    const refValue = ref(value)
+    state[key] = refValue
+  }
+
+  return state
+})
+
+/**
+ * 在 SPA 应用中可用于在 pinia 实例被激活前使用 store
+ * 在 SSR 应用中可用于在 setup 外使用 store
+ */
+export function useSettingsStoreHook() {
+  return useSettingsStore(pinia)
+}

+ 101 - 0
src/store/modules/tags-view.js

@@ -0,0 +1,101 @@
+import { ref, watchEffect } from "vue"
+import { pinia } from "@/store"
+import { defineStore } from "pinia"
+import { useSettingsStore } from "./settings"
+import { getVisitedViews, setVisitedViews, getCachedViews, setCachedViews } from "@/utils/cache/local-storage"
+
+export const useTagsViewStore = defineStore("tags-view", () => {
+  const { cacheTagsView } = useSettingsStore()
+  const visitedViews = ref(cacheTagsView ? getVisitedViews() : [])
+  const cachedViews = ref(cacheTagsView ? getCachedViews() : [])
+
+  /** 缓存标签栏数据 */
+  watchEffect(() => {
+    setVisitedViews(visitedViews.value)
+    setCachedViews(cachedViews.value)
+  })
+
+  //#region add
+  const addVisitedView = (view) => {
+    // 检查是否已经存在相同的 visitedView
+    const index = visitedViews.value.findIndex((v) => v.path === view.path)
+    if (index !== -1) {
+      // 防止 query 参数丢失
+      visitedViews.value[index].fullPath !== view.fullPath && (visitedViews.value[index] = { ...view })
+    } else {
+      // 添加新的 visitedView
+      visitedViews.value.push({ ...view })
+    }
+  }
+
+  const addCachedView = (view) => {
+    if (typeof view.name !== "string") return
+    if (cachedViews.value.includes(view.name)) return
+    if (view.meta?.keepAlive) cachedViews.value.push(view.name)
+  }
+  //#endregion
+
+  //#region del
+  const delVisitedView = (view) => {
+    const index = visitedViews.value.findIndex((v) => v.path === view.path)
+    if (index !== -1) visitedViews.value.splice(index, 1)
+  }
+
+  const delCachedView = (view) => {
+    if (typeof view.name !== "string") return
+    const index = cachedViews.value.indexOf(view.name)
+    if (index !== -1) cachedViews.value.splice(index, 1)
+  }
+  //#endregion
+
+  //#region delOthers
+  const delOthersVisitedViews = (view) => {
+    visitedViews.value = visitedViews.value.filter((v) => {
+      return v.meta?.affix || v.path === view.path
+    })
+  }
+
+  const delOthersCachedViews = (view) => {
+    if (typeof view.name !== "string") return
+    const index = cachedViews.value.indexOf(view.name)
+    if (index !== -1) {
+      cachedViews.value = cachedViews.value.slice(index, index + 1)
+    } else {
+      // 如果 index = -1, 没有缓存的 tags
+      cachedViews.value = []
+    }
+  }
+  //#endregion
+
+  //#region delAll
+  const delAllVisitedViews = () => {
+    // 保留固定的 tags
+    visitedViews.value = visitedViews.value.filter((tag) => tag.meta?.affix)
+  }
+
+  const delAllCachedViews = () => {
+    cachedViews.value = []
+  }
+  //#endregion
+
+  return {
+    visitedViews,
+    cachedViews,
+    addVisitedView,
+    addCachedView,
+    delVisitedView,
+    delCachedView,
+    delOthersVisitedViews,
+    delOthersCachedViews,
+    delAllVisitedViews,
+    delAllCachedViews
+  }
+})
+
+/**
+ * 在 SPA 应用中可用于在 pinia 实例被激活前使用 store
+ * 在 SSR 应用中可用于在 setup 外使用 store
+ */
+export function useTagsViewStoreHook() {
+  return useTagsViewStore(pinia)
+}

+ 71 - 0
src/store/modules/user.js

@@ -0,0 +1,71 @@
+import { ref } from "vue"
+import { pinia } from "@/store"
+import { defineStore } from "pinia"
+import { useTagsViewStore } from "./tags-view"
+import { useSettingsStore } from "./settings"
+import { getToken, removeToken, setToken } from "@/utils/cache/cookies"
+import { resetRouter } from "@/router"
+import { loginApi, getUserInfoApi } from "@/api/login"
+import routeSettings from "@/config/route"
+
+export const useUserStore = defineStore("user", () => {
+  const token = ref(getToken() || "")
+  const roles = ref([])
+  const account = ref("")
+
+  const tagsViewStore = useTagsViewStore()
+  const settingsStore = useSettingsStore()
+
+  /** 登录 */
+  const login = async ({ account, password, code }) => {
+    const { data } = await loginApi({ account, password, code })
+    setToken(data.token)
+    token.value = data.token
+  }
+  /** 获取用户详情 */
+  const getInfo = async () => {
+    // const { data } = await getUserInfoApi()
+    // account.value = data.account
+    // // 验证返回的 roles 是否为一个非空数组,否则塞入一个没有任何作用的默认角色,防止路由守卫逻辑进入无限循环
+    // roles.value = data.roles?.length > 0 ? data.roles : routeSettings.defaultRoles
+  }
+  /** 模拟角色变化 */
+  const changeRoles = async (role) => {
+    const newToken = "token-" + role
+    token.value = newToken
+    setToken(newToken)
+    // 用刷新页面代替重新登录
+    window.location.reload()
+  }
+  /** 登出 */
+  const logout = () => {
+    removeToken()
+    token.value = ""
+    roles.value = []
+    resetRouter()
+    _resetTagsView()
+  }
+  /** 重置 Token */
+  const resetToken = () => {
+    removeToken()
+    token.value = ""
+    roles.value = []
+  }
+  /** 重置 Visited Views 和 Cached Views */
+  const _resetTagsView = () => {
+    if (!settingsStore.cacheTagsView) {
+      tagsViewStore.delAllVisitedViews()
+      tagsViewStore.delAllCachedViews()
+    }
+  }
+
+  return { token, roles, account, login, getInfo, changeRoles, logout, resetToken }
+})
+
+/**
+ * 在 SPA 应用中可用于在 pinia 实例被激活前使用 store
+ * 在 SSR 应用中可用于在 setup 外使用 store
+ */
+export function useUserStoreHook() {
+  return useUserStore(pinia)
+}

+ 116 - 0
src/styles/element-plus.scss

@@ -0,0 +1,116 @@
+/** 自定义 Element Plus 样式 */
+
+// 分页
+.el-pagination {
+  // 参考 Bootstrap 的响应式设计 WIDTH = 768
+  @media screen and (max-width: 768px) {
+    .el-pagination__total,
+    .el-pagination__sizes,
+    .el-pagination__jump,
+    .btn-prev,
+    .btn-next {
+      display: none !important;
+    }
+  }
+}
+
+// 表单项 label
+.el-form-item__label {
+  font-size: 16px;
+  font-weight: 400;
+  color: #000;
+}
+
+// 按钮
+.toolbar-wrapper {
+  .el-button--primary {
+    --el-button-border-color: "";
+    background: linear-gradient(90deg, rgba(33, 107, 255, 1) 0%, rgba(102, 182, 255, 1) 100%);
+  }
+  .el-button.is-plain {
+    --el-button-border-color: rgba(33, 107, 255, 1);
+    --el-button-text-color: rgba(33, 107, 255, 1);
+  }
+}
+.el-dialog__footer {
+  .el-button--primary {
+    --el-button-border-color: "";
+    background: rgba(0, 97, 255, 1);
+  }
+}
+
+// link
+.el-link--primary {
+  color: #366fff;
+}
+.el-link--warning {
+  color: #ff5733;
+}
+.el-link--danger {
+  color: #d43030;
+}
+.el-link--success {
+  color: rgba(9, 101, 98, 1);
+}
+
+// 表格
+.el-table {
+  --el-table-text-color: rgba(19, 21, 35, 1);
+  --el-table-header-bg-color: rgba(245, 246, 250, 1);
+  --el-table-header-text-color: rgba(90, 96, 127, 1);
+  .el-table__header {
+    height: 44px;
+  }
+  .el-table__body {
+    .el-table__row {
+      height: 54px;
+    }
+  }
+}
+
+// 卡片
+.el-card {
+  .el-card__header {
+    height: 96px;
+    display: flex;
+    align-items: center;
+    font-size: 24px;
+    font-weight: 500;
+    padding: 0 24px;
+  }
+  .el-card__body {
+    padding: 36px 24px;
+  }
+  &.is-always-shadow {
+    box-shadow: 0px 3px 10px rgba(0, 97, 255, 0.2);
+  }
+}
+
+// 对话框
+.el-dialog {
+  padding: 0;
+  border-radius: 10px;
+  overflow: hidden;
+  .el-dialog__headerbtn {
+    height: 61.5px;
+    font-size: 18px;
+    .el-dialog__close {
+      color: #808080;
+    }
+  }
+  .el-dialog__header {
+    background-color: #edf1f5;
+    padding-left: 35px;
+    padding-top: 21px;
+    font-weight: 500;
+    .el-dialog__title {
+      font-size: 20px;
+    }
+  }
+  .el-dialog__body {
+    padding: 40px 60px;
+  }
+  .el-dialog__footer {
+    padding: 0 60px 40px 60px;
+  }
+}

+ 58 - 0
src/styles/index.scss

@@ -0,0 +1,58 @@
+// 全局 CSS 变量
+@import "./variables.css";
+// Transition
+@import "./transition.scss";
+// Element Plus
+@import "./element-plus.scss";
+// Mixins
+@import "./mixins.scss";
+
+// 业务页面几乎都应该在根元素上挂载 class="app-container",以保持页面美观
+.app-container {
+  padding: 20px;
+}
+
+html {
+  height: 100%;
+  // 灰色模式
+  &.grey-mode {
+    filter: grayscale(1);
+  }
+  // 色弱模式
+  &.color-weakness {
+    filter: invert(0.8);
+  }
+}
+
+body {
+  height: 100%;
+  color: var(--v3-body-text-color);
+  background-color: var(--v3-body-bg-color);
+  -moz-osx-font-smoothing: grayscale;
+  -webkit-font-smoothing: antialiased;
+  font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", Arial,
+    sans-serif;
+  @extend %scrollbar;
+}
+
+#app {
+  height: 100%;
+}
+
+*,
+*::before,
+*::after {
+  box-sizing: border-box;
+}
+
+a,
+a:focus,
+a:hover {
+  color: inherit;
+  outline: none;
+  text-decoration: none;
+}
+
+div:focus {
+  outline: none;
+}

+ 42 - 0
src/styles/mixins.scss

@@ -0,0 +1,42 @@
+/** 清除浮动 */
+%clearfix {
+  &::after {
+    content: "";
+    display: table;
+    clear: both;
+  }
+}
+
+/** 美化原生滚动条 */
+%scrollbar {
+  // 整个滚动条
+  &::-webkit-scrollbar {
+    width: 8px;
+    height: 8px;
+  }
+  // 滚动条上的滚动滑块
+  &::-webkit-scrollbar-thumb {
+    border-radius: 4px;
+    background-color: #90939955;
+  }
+  &::-webkit-scrollbar-thumb:hover {
+    background-color: #90939977;
+  }
+  &::-webkit-scrollbar-thumb:active {
+    background-color: #90939999;
+  }
+  // 当同时有垂直滚动条和水平滚动条时交汇的部分
+  &::-webkit-scrollbar-corner {
+    background-color: transparent;
+  }
+}
+
+/** 文本溢出时显示省略号 */
+%ellipsis {
+  // 隐藏溢出的文本
+  overflow: hidden;
+  // 防止文本换行
+  white-space: nowrap;
+  // 文本内容溢出容器时,文本末尾显示省略号
+  text-overflow: ellipsis;
+}

+ 25 - 0
src/styles/transition.scss

@@ -0,0 +1,25 @@
+// See https://cn.vuejs.org/guide/built-ins/transition.html for detail
+
+// fade-transform
+.fade-transform-leave-active,
+.fade-transform-enter-active {
+  transition: all 0.5s;
+}
+.fade-transform-enter {
+  opacity: 0;
+  transform: translateX(-30px);
+}
+.fade-transform-leave-to {
+  opacity: 0;
+  transform: translateX(30px);
+}
+
+// layout-logo-fade
+.layout-logo-fade-enter-active,
+.layout-logo-fade-leave-active {
+  transition: opacity 1.5s;
+}
+.layout-logo-fade-enter-from,
+.layout-logo-fade-leave-to {
+  opacity: 0;
+}

+ 44 - 0
src/styles/variables.css

@@ -0,0 +1,44 @@
+/** 全局 CSS 变量,这种变量不仅可以在 CSS 和 SCSS 中使用,还可以导入到 JS 中使用 */
+
+:root {
+  /** Body */
+  --v3-body-text-color: var(--el-text-color-primary);
+  --v3-body-bg-color: #fff;
+  /** Header 区域 = NavigationBar 组件 + TagsView 组件 */
+  --v3-header-height: calc(
+    var(--v3-navigationbar-height) + var(--v3-tagsview-height) + var(--v3-header-border-bottom-width)
+  );
+  --v3-header-bg-color: var(--el-bg-color);
+  --v3-header-box-shadow: var(--el-box-shadow-lighter);
+  --v3-header-border-bottom-width: 1px;
+  --v3-header-border-bottom: var(--v3-header-border-bottom-width) solid var(--el-fill-color);
+  /** NavigationBar 组件 */
+  --v3-navigationbar-height: 96px;
+  --v3-navigationbar-text-color: var(--el-text-color-regular);
+  /** Sidebar 组件 */
+  --v3-sidebar-width: 236px;
+  --v3-sidebar-hide-width: 80px;
+  --v3-sidebar-border-right: 1px solid var(--el-fill-color);
+  --v3-sidebar-menu-item-height: 70px;
+  --v3-sidebar-menu-bg-color: rgba(46, 64, 89, 1);
+  --v3-sidebar-menu-text-color: #ffffff;
+  --v3-sidebar-menu-active-text-color: #ffffff;
+  /** TagsView 组件 */
+  --v3-tagsview-height: 34px;
+  --v3-tagsview-text-color: var(--el-text-color-regular);
+  --v3-tagsview-tag-active-text-color: #ffffff;
+  --v3-tagsview-tag-bg-color: var(--el-bg-color);
+  --v3-tagsview-tag-active-bg-color: var(--el-color-primary);
+  --v3-tagsview-tag-border-radius: 2px;
+  --v3-tagsview-tag-border-color: var(--el-border-color-lighter);
+  --v3-tagsview-tag-active-border-color: var(--el-color-primary);
+  --v3-tagsview-tag-icon-hover-bg-color: #00000030;
+  --v3-tagsview-tag-icon-hover-color: #ffffff;
+  --v3-tagsview-contextmenu-text-color: var(--el-text-color-regular);
+  --v3-tagsview-contextmenu-hover-text-color: var(--el-text-color-primary);
+  --v3-tagsview-contextmenu-bg-color: var(--el-bg-color-overlay);
+  --v3-tagsview-contextmenu-hover-bg-color: var(--el-fill-color);
+  --v3-tagsview-contextmenu-box-shadow: var(--el-box-shadow);
+  /** Hamburger 组件 */
+  --v3-hamburger-text-color: rgba(0, 0, 0, 1);
+}

+ 14 - 0
src/utils/cache/cookies.js

@@ -0,0 +1,14 @@
+/** 统一处理 Cookie */
+
+import CacheKey from "@/constants/cache-key"
+import Cookies from "js-cookie"
+
+export const getToken = () => {
+  return Cookies.get(CacheKey.TOKEN)
+}
+export const setToken = (token) => {
+  Cookies.set(CacheKey.TOKEN, token)
+}
+export const removeToken = () => {
+  Cookies.remove(CacheKey.TOKEN)
+}

+ 34 - 0
src/utils/cache/local-storage.js

@@ -0,0 +1,34 @@
+/** 统一处理 localStorage */
+
+import CacheKey from "@/constants/cache-key"
+
+//#region 侧边栏状态
+export const getSidebarStatus = () => {
+  return localStorage.getItem(CacheKey.SIDEBAR_STATUS)
+}
+export const setSidebarStatus = (sidebarStatus) => {
+  localStorage.setItem(CacheKey.SIDEBAR_STATUS, sidebarStatus)
+}
+//#endregion
+
+//#region 标签栏
+export const getVisitedViews = () => {
+  const json = localStorage.getItem(CacheKey.VISITED_VIEWS)
+  return JSON.parse(json ?? "[]")
+}
+export const setVisitedViews = (views) => {
+  views.forEach((view) => {
+    // 删除不必要的属性,防止 JSON.stringify 处理到循环引用
+    delete view.matched
+    delete view.redirectedFrom
+  })
+  localStorage.setItem(CacheKey.VISITED_VIEWS, JSON.stringify(views))
+}
+export const getCachedViews = () => {
+  const json = localStorage.getItem(CacheKey.CACHED_VIEWS)
+  return JSON.parse(json ?? "[]")
+}
+export const setCachedViews = (views) => {
+  localStorage.setItem(CacheKey.CACHED_VIEWS, JSON.stringify(views))
+}
+//#endregion

+ 18 - 0
src/utils/css.js

@@ -0,0 +1,18 @@
+/** 获取指定元素(默认全局)上的 CSS 变量的值 */
+export const getCssVar = (varName, element = document.documentElement) => {
+  if (!varName?.startsWith("--")) {
+    console.warn("CSS 变量名应以 '--' 开头")
+    return ""
+  }
+  // 没有拿到值时,会返回空串
+  return getComputedStyle(element).getPropertyValue(varName)
+}
+
+/** 设置指定元素(默认全局)上的 CSS 变量的值 */
+export const setCssVar = (varName, value, element = document.documentElement) => {
+  if (!varName?.startsWith("--")) {
+    console.warn("CSS 变量名应以 '--' 开头")
+    return
+  }
+  element.style.setProperty(varName, value)
+}

+ 10 - 0
src/utils/datetime.js

@@ -0,0 +1,10 @@
+import dayjs from "dayjs"
+import "dayjs/locale/zh-cn"
+
+const INVALID_DATE = "N/A"
+
+/** 格式化日期时间 */
+export const formatDateTime = (datetime = "", template = "YYYY-MM-DD HH:mm:ss") => {
+  const day = dayjs(datetime).locale("zh-cn")
+  return day.isValid() ? day.format(template) : INVALID_DATE
+}

+ 12 - 0
src/utils/permission.js

@@ -0,0 +1,12 @@
+import { useUserStore } from "@/store/modules/user"
+
+/** 全局权限判断函数,和权限指令 v-permission 功能类似 */
+export const checkPermission = (permissionRoles) => {
+  if (Array.isArray(permissionRoles) && permissionRoles.length > 0) {
+    const { roles } = useUserStore()
+    return roles.some((role) => permissionRoles.includes(role))
+  } else {
+    console.error("need roles! Like checkPermission(['admin','editor'])")
+    return false
+  }
+}

+ 122 - 0
src/utils/service.js

@@ -0,0 +1,122 @@
+import axios from "axios"
+import { useUserStore } from "@/store/modules/user"
+import { ElMessage } from "element-plus"
+import { get, merge } from "lodash-es"
+import { getToken } from "./cache/cookies"
+
+/** 退出登录并强制刷新页面(会重定向到登录页) */
+function logout() {
+  useUserStore().logout()
+  location.reload()
+}
+
+/** 创建请求实例 */
+function createService() {
+  // 创建一个 axios 实例命名为 service
+  const service = axios.create()
+  // 请求拦截
+  service.interceptors.request.use(
+    (config) => config,
+    // 发送失败
+    (error) => Promise.reject(error)
+  )
+  // 响应拦截(可根据具体业务作出相应的调整)
+  service.interceptors.response.use(
+    (response) => {
+      // apiData 是 api 返回的数据
+      const apiData = response.data
+      // 二进制数据则直接返回
+      const responseType = response.request?.responseType
+      if (responseType === "blob" || responseType === "arraybuffer") return apiData
+      // 这个 code 是和后端约定的业务 code
+      const code = apiData.code
+      // 如果没有 code, 代表这不是项目后端开发的 api
+      if (code === undefined) {
+        ElMessage.error("非本系统的接口")
+        return Promise.reject(new Error("非本系统的接口"))
+      }
+      switch (code) {
+        case "200":
+          // 本系统采用 code === "200" 来表示没有业务错误
+          return apiData
+        case "401":
+          // Token 过期时
+          return logout()
+        default:
+          // 不是正确的 code
+          ElMessage.error(apiData.message || "Error")
+          return Promise.reject(new Error("Error"))
+      }
+    },
+    (error) => {
+      // status 是 HTTP 状态码
+      const status = get(error, "response.status")
+      switch (status) {
+        case 400:
+          error.message = "请求错误"
+          break
+        case 401:
+          // Token 过期时
+          logout()
+          break
+        case 403:
+          error.message = "拒绝访问"
+          break
+        case 404:
+          error.message = "请求地址出错"
+          break
+        case 408:
+          error.message = "请求超时"
+          break
+        case 500:
+          error.message = "服务器内部错误"
+          break
+        case 501:
+          error.message = "服务未实现"
+          break
+        case 502:
+          error.message = "网关错误"
+          break
+        case 503:
+          error.message = "服务不可用"
+          break
+        case 504:
+          error.message = "网关超时"
+          break
+        case 505:
+          error.message = "HTTP 版本不受支持"
+          break
+        default:
+          break
+      }
+      ElMessage.error(error.message)
+      return Promise.reject(error)
+    }
+  )
+  return service
+}
+
+/** 创建请求方法 */
+function createRequest(service) {
+  return function (config) {
+    const token = getToken()
+    const defaultConfig = {
+      headers: {
+        // 携带 Token
+        Token: token ? `${token}` : undefined,
+        "Content-Type": "application/json"
+      },
+      timeout: 5000,
+      baseURL: import.meta.env.VITE_BASE_API,
+      data: {}
+    }
+    // 将默认配置 defaultConfig 和传入的自定义配置 config 进行合并成为 mergeConfig
+    const mergeConfig = merge(defaultConfig, config)
+    return service(mergeConfig)
+  }
+}
+
+/** 用于网络请求的实例 */
+const service = createService()
+/** 用于网络请求的方法 */
+export const request = createRequest(service)

+ 84 - 0
src/utils/validate.js

@@ -0,0 +1,84 @@
+/** 判断是否为数组 */
+export const isArray = (arg) => {
+  return Array.isArray ? Array.isArray(arg) : Object.prototype.toString.call(arg) === "[object Array]"
+}
+
+/** 判断是否为字符串 */
+export const isString = (str) => {
+  return typeof str === "string" || str instanceof String
+}
+
+/** 判断是否为外链 */
+export const isExternal = (path) => {
+  const reg = /^(https?:|mailto:|tel:)/
+  return reg.test(path)
+}
+
+/** 判断是否为网址(带协议) */
+export const isUrl = (url) => {
+  const reg = /^(((ht|f)tps?):\/\/)?([^!@#$%^&*?.\s-]([^!@#$%^&*?.\s]{0,63}[^!@#$%^&*?.\s])?\.)+[a-z]{2,6}\/?/
+  return reg.test(url)
+}
+
+/** 判断是否为网址或 IP(带端口) */
+export const isUrlPort = (url) => {
+  const reg = /^((ht|f)tps?:\/\/)?[\w-]+(\.[\w-]+)+:\d{1,5}\/?$/
+  return reg.test(url)
+}
+
+/** 判断是否为域名(不带协议) */
+export const isDomain = (domain) => {
+  const reg = /^([0-9a-zA-Z-]{1,}\.)+([a-zA-Z]{2,})$/
+  return reg.test(domain)
+}
+
+/** 判断版本号格式是否为 X.Y.Z */
+export const isVersion = (version) => {
+  const reg = /^\d+(?:\.\d+){2}$/
+  return reg.test(version)
+}
+
+/** 判断时间格式是否为 24 小时制(HH:mm:ss) */
+export const is24H = (time) => {
+  const reg = /^(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d$/
+  return reg.test(time)
+}
+
+/** 判断是否为手机号(1 开头) */
+export const isPhoneNumber = (str) => {
+  const reg = /^(?:(?:\+|00)86)?1\d{10}$/
+  return reg.test(str)
+}
+
+/** 判断是否为第二代身份证(18 位) */
+export const isChineseIdCard = (str) => {
+  const reg = /^[1-9]\d{5}(?:18|19|20)\d{2}(?:0[1-9]|10|11|12)(?:0[1-9]|[1-2]\d|30|31)\d{3}[\dXx]$/
+  return reg.test(str)
+}
+
+/** 判断是否为 Email(支持中文邮箱) */
+export const isEmail = (email) => {
+  const reg = /^[A-Za-z0-9\u4e00-\u9fa5]+@[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$/
+  return reg.test(email)
+}
+
+/** 判断是否为 MAC 地址 */
+export const isMAC = (mac) => {
+  const reg =
+    /^(([a-f0-9][0,2,4,6,8,a,c,e]:([a-f0-9]{2}:){4})|([a-f0-9][0,2,4,6,8,a,c,e]-([a-f0-9]{2}-){4}))[a-f0-9]{2}$/i
+  return reg.test(mac)
+}
+
+/** 判断是否为 IPv4 地址 */
+export const isIPv4 = (ip) => {
+  const reg =
+    /^((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.){3}(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])(?::(?:[0-9]|[1-9][0-9]{1,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?$/
+  return reg.test(ip)
+}
+
+/** 判断是否为车牌(兼容新能源车牌) */
+export const isLicensePlate = (str) => {
+  const reg =
+    /^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领][A-HJ-NP-Z][A-HJ-NP-Z0-9]{4,5}[A-HJ-NP-Z0-9挂学警港澳]$/
+  return reg.test(str)
+}

+ 214 - 0
src/views/account-management/index.vue

@@ -0,0 +1,214 @@
+<script setup>
+import { reactive, ref, watch } from "vue"
+import { createTableDataApi, deleteTableDataApi, updateTableDataApi, getTableDataApi } from "@/api/table"
+import { ElMessage, ElMessageBox } from "element-plus"
+import { usePagination } from "@/hooks/usePagination"
+import { cloneDeep } from "lodash-es"
+
+const loading = ref(false)
+const { paginationData, handleCurrentChange, handleSizeChange } = usePagination()
+
+//#region 增
+const DEFAULT_FORM_DATA = {
+  id: undefined,
+  name: "",
+  todo: "",
+  realName: "",
+  password: "",
+  roles: []
+}
+const dialogVisible = ref(false)
+const formRef = ref(null)
+const formData = ref(cloneDeep(DEFAULT_FORM_DATA))
+const formRules = {
+  name: [{ required: true, trigger: "blur", message: "必填" }]
+}
+const handleCreateOrUpdate = () => {
+  formRef.value?.validate((valid, fields) => {
+    if (!valid) return console.error("表单校验不通过", fields)
+    loading.value = true
+    const api = formData.value.id === undefined ? createTableDataApi : updateTableDataApi
+    api(formData.value)
+      .then(() => {
+        ElMessage.success("操作成功")
+        dialogVisible.value = false
+        getTableData()
+      })
+      .catch(() => {
+        loading.value = false
+      })
+  })
+}
+const resetForm = () => {
+  formRef.value?.clearValidate()
+  formData.value = cloneDeep(DEFAULT_FORM_DATA)
+}
+//#endregion
+
+//#region 删
+const handleDelete = (row) => {
+  ElMessageBox.confirm("确认删除?", "提示", {
+    confirmButtonText: "确定",
+    cancelButtonText: "取消",
+    type: "warning"
+  }).then(() => {
+    deleteTableDataApi(row.id).then(() => {
+      ElMessage.success("删除成功")
+      getTableData()
+    })
+  })
+}
+//#endregion
+
+//#region 改
+const handleUpdate = (row) => {
+  dialogVisible.value = true
+  formData.value = cloneDeep(row)
+}
+//#endregion
+
+//#region 查
+const tableData = ref([])
+const searchFormRef = ref(null)
+const searchData = reactive({
+  name: undefined,
+  createTime: null
+})
+const getTableData = () => {
+  loading.value = true
+  getTableDataApi({
+    currentPage: paginationData.currentPage,
+    size: paginationData.pageSize,
+    name: searchData.name,
+    starTime: searchData.createTime ? searchData.createTime[0] : undefined,
+    endTime: searchData.createTime ? searchData.createTime[1] : undefined
+  })
+    .then(({ data }) => {
+      paginationData.total = data.total
+      tableData.value = data.list
+    })
+    .catch(() => {
+      tableData.value = []
+    })
+    .finally(() => {
+      loading.value = false
+    })
+}
+const handleSearch = () => {
+  paginationData.currentPage === 1 ? getTableData() : (paginationData.currentPage = 1)
+}
+const resetSearch = () => {
+  searchFormRef.value?.resetFields()
+  handleSearch()
+}
+//#endregion
+
+/** 监听分页参数的变化 */
+watch([() => paginationData.currentPage, () => paginationData.pageSize], getTableData, { immediate: true })
+</script>
+
+<template>
+  <div class="app-container">
+    <el-card v-loading="loading" header="账号管理">
+      <div class="toolbar-wrapper">
+        <el-form ref="searchFormRef" :inline="true" :model="searchData">
+          <el-form-item prop="name" label="账号名称">
+            <el-input v-model="searchData.name" placeholder="请输入" />
+          </el-form-item>
+          <el-form-item prop="createTime" label="创建时间">
+            <el-date-picker
+              v-model="searchData.createTime"
+              type="datetimerange"
+              start-placeholder="开始时间"
+              end-placeholder="结束时间"
+              value-format="x"
+            />
+          </el-form-item>
+          <el-form-item>
+            <el-button type="primary" @click="handleSearch">查询</el-button>
+            <el-button @click="resetSearch" plain>重置</el-button>
+          </el-form-item>
+        </el-form>
+        <div>
+          <el-button type="primary" @click="dialogVisible = true">新增</el-button>
+        </div>
+      </div>
+      <div class="table-wrapper">
+        <el-table :data="tableData" max-height="500">
+          <el-table-column type="index" label="序号" width="100" align="center" />
+          <el-table-column prop="name" label="账号名称" align="center" />
+          <el-table-column prop="role" label="角色名称" align="center" />
+          <el-table-column prop="realName" label="真实姓名" align="center" />
+          <el-table-column prop="createTime" label="创建时间" align="center" />
+          <el-table-column fixed="right" label="操作" width="200" align="center">
+            <template #default="scope">
+              <el-link type="primary" @click="handleUpdate(scope.row)">编辑</el-link>
+              <el-link type="danger" @click="handleDelete(scope.row)">删除</el-link>
+            </template>
+          </el-table-column>
+        </el-table>
+      </div>
+      <div class="pager-wrapper">
+        <el-pagination
+          background
+          :layout="paginationData.layout"
+          :page-sizes="paginationData.pageSizes"
+          :total="paginationData.total"
+          :page-size="paginationData.pageSize"
+          :currentPage="paginationData.currentPage"
+          @size-change="handleSizeChange"
+          @current-change="handleCurrentChange"
+        />
+      </div>
+    </el-card>
+    <!-- 新增/修改 -->
+    <el-dialog
+      v-model="dialogVisible"
+      :title="formData.id === undefined ? '创建' : '编辑'"
+      @closed="resetForm"
+      width="50%"
+    >
+      <el-form ref="formRef" :model="formData" :rules="formRules" label-width="auto" size="large">
+        <el-form-item prop="name" label="账号">
+          <el-input v-model="formData.name" placeholder="请输入" />
+        </el-form-item>
+        <el-form-item prop="todo" label="微校卡号">
+          <el-input v-model="formData.todo" placeholder="请输入" />
+        </el-form-item>
+        <el-form-item prop="realName" label="姓名">
+          <el-input v-model="formData.realName" placeholder="请输入" />
+        </el-form-item>
+        <el-form-item prop="password" label="密码">
+          <el-input v-model="formData.password" placeholder="请输入" />
+        </el-form-item>
+        <el-form-item prop="roles" label="角色">
+          <el-select v-model="formData.roles" placeholder="请选择" multiple>
+            <el-option label="todo" value="todo" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleCreateOrUpdate" :loading="loading">确定</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.toolbar-wrapper {
+  margin-bottom: 26px;
+}
+
+.table-wrapper {
+  margin-bottom: 29px;
+  .el-link {
+    margin-right: 15px;
+  }
+}
+
+.pager-wrapper {
+  display: flex;
+  justify-content: flex-end;
+}
+</style>

+ 279 - 0
src/views/activity-management/audit-list.vue

@@ -0,0 +1,279 @@
+<script setup>
+import { reactive, ref, watch } from "vue"
+import { getTableDataApi } from "@/api/table"
+import { ElMessageBox } from "element-plus"
+import { usePagination } from "@/hooks/usePagination"
+
+const loading = ref(false)
+const { paginationData, handleCurrentChange, handleSizeChange } = usePagination()
+
+//#region 详情
+const dialogVisible = ref(false)
+const formData = ref({})
+const handleUpdate = (row) => {
+  dialogVisible.value = true
+  formData.value = cloneDeep(row)
+}
+//#endregion
+
+//#region 查
+const tableData = ref([])
+const searchFormRef = ref(null)
+const searchData = reactive({
+  organizationName: undefined,
+  initiator: undefined,
+  theme: undefined,
+  college: undefined,
+  stage: undefined,
+  major: undefined,
+  class: undefined,
+  status: undefined,
+  createTime: null,
+  activityTime: null,
+  registrationTime: null
+})
+const getTableData = () => {
+  loading.value = true
+  getTableDataApi({
+    currentPage: paginationData.currentPage,
+    size: paginationData.pageSize,
+    organizationName: searchData.organizationName,
+    initiator: searchData.initiator,
+    theme: searchData.theme,
+    college: searchData.college,
+    stage: searchData.stage,
+    major: searchData.major,
+    class: searchData.class,
+    status: searchData.status,
+    starTime: searchData.createTime ? searchData.createTime[0] : undefined,
+    endTime: searchData.createTime ? searchData.createTime[1] : undefined,
+    starTime2: searchData.activityTime ? searchData.activityTime[0] : undefined,
+    endTime2: searchData.activityTime ? searchData.activityTime[1] : undefined,
+    starTime3: searchData.registrationTime ? searchData.registrationTime[0] : undefined,
+    endTime3: searchData.registrationTime ? searchData.registrationTime[1] : undefined
+  })
+    .then(({ data }) => {
+      paginationData.total = data.total
+      tableData.value = data.list
+    })
+    .catch(() => {
+      tableData.value = []
+    })
+    .finally(() => {
+      loading.value = false
+    })
+}
+const handleSearch = () => {
+  paginationData.currentPage === 1 ? getTableData() : (paginationData.currentPage = 1)
+}
+const resetSearch = () => {
+  searchFormRef.value?.resetFields()
+  handleSearch()
+}
+//#endregion
+
+/** 监听分页参数的变化 */
+watch([() => paginationData.currentPage, () => paginationData.pageSize], getTableData, { immediate: true })
+
+/** 通过 */
+const handlePass = (row) => {
+  ElMessageBox.confirm("确认通过?", "提示", {
+    confirmButtonText: "确定",
+    cancelButtonText: "取消",
+    type: "warning"
+  }).then(() => {
+    // todo 调用通过接口
+    console.log(row)
+  })
+}
+
+/** 拒绝 */
+const handleReject = (row) => {
+  ElMessageBox.confirm("确认拒绝?", "提示", {
+    confirmButtonText: "确定",
+    cancelButtonText: "取消",
+    type: "warning"
+  }).then(() => {
+    // todo 调用拒绝接口
+    console.log(row)
+  })
+}
+
+/** 导出 */
+const handleDownload = () => {
+  // todo 调用导出接口
+  console.log("导出")
+}
+</script>
+
+<template>
+  <div class="app-container">
+    <el-card v-loading="loading" header="审核列表">
+      <div class="toolbar-wrapper">
+        <el-form ref="searchFormRef" :inline="true" :model="searchData">
+          <el-form-item prop="organizationName" label="组织名称">
+            <el-input v-model="searchData.organizationName" placeholder="请输入" />
+          </el-form-item>
+          <el-form-item prop="initiator" label="发起人">
+            <el-input v-model="searchData.initiator" placeholder="请输入" />
+          </el-form-item>
+          <el-form-item prop="theme" label="活动主题">
+            <el-input v-model="searchData.theme" placeholder="请输入" />
+          </el-form-item>
+          <el-form-item prop="college" label="学院">
+            <el-select v-model="searchData.college" placeholder="请选择" style="width: 178px">
+              <el-option label="todo" value="todo" />
+            </el-select>
+          </el-form-item>
+          <el-form-item prop="stage" label="学段">
+            <el-select v-model="searchData.stage" placeholder="请选择" style="width: 178px">
+              <el-option label="todo" value="todo" />
+            </el-select>
+          </el-form-item>
+          <el-form-item prop="major" label="专业">
+            <el-select v-model="searchData.major" placeholder="请选择" style="width: 178px">
+              <el-option label="todo" value="todo" />
+            </el-select>
+          </el-form-item>
+          <el-form-item prop="class" label="班级">
+            <el-select v-model="searchData.class" placeholder="请选择" style="width: 178px">
+              <el-option label="todo" value="todo" />
+            </el-select>
+          </el-form-item>
+          <el-form-item prop="status" label="状态">
+            <el-select v-model="searchData.status" placeholder="请选择" style="width: 178px">
+              <el-option label="todo" value="todo" />
+            </el-select>
+          </el-form-item>
+          <el-form-item prop="createTime" label="创建时间">
+            <el-date-picker
+              v-model="searchData.createTime"
+              type="datetimerange"
+              start-placeholder="开始时间"
+              end-placeholder="结束时间"
+              value-format="x"
+            />
+          </el-form-item>
+          <el-form-item prop="activityTime" label="活动时间">
+            <el-date-picker
+              v-model="searchData.activityTime"
+              type="datetimerange"
+              start-placeholder="开始时间"
+              end-placeholder="结束时间"
+              value-format="x"
+            />
+          </el-form-item>
+          <el-form-item prop="registrationTime" label="报名时间">
+            <el-date-picker
+              v-model="searchData.registrationTime"
+              type="datetimerange"
+              start-placeholder="开始时间"
+              end-placeholder="结束时间"
+              value-format="x"
+            />
+          </el-form-item>
+          <el-form-item>
+            <el-button type="primary" @click="handleSearch">查询</el-button>
+            <el-button @click="resetSearch" plain>重置</el-button>
+          </el-form-item>
+        </el-form>
+        <div>
+          <el-button plain @click="handleDownload">导出</el-button>
+        </div>
+      </div>
+      <div class="table-wrapper">
+        <el-table :data="tableData" max-height="500">
+          <el-table-column type="index" label="序号" width="100" align="center" />
+          <el-table-column prop="initiator" label="发起人" align="center" />
+          <el-table-column prop="college" label="学院" align="center" />
+          <el-table-column prop="stage" label="学段" align="center" />
+          <el-table-column prop="major" label="专业" align="center" />
+          <el-table-column prop="class" label="班级" align="center" />
+          <el-table-column prop="organizationName" label="所属组织" align="center" />
+          <el-table-column prop="theme" label="活动主题" align="center" />
+          <el-table-column prop="activityTime" label="活动开始时间" align="center" />
+          <el-table-column prop="registrationTime" label="报名开始时间" align="center" />
+          <el-table-column prop="todo" label="已报名" align="center" />
+          <el-table-column prop="todo" label="已签到" align="center" />
+          <el-table-column prop="status" label="状态" align="center" />
+          <el-table-column prop="todo" label="审核人" align="center" />
+          <el-table-column prop="todo" label="审核时间" align="center" />
+          <el-table-column prop="createTime" label="创建时间" align="center" />
+          <el-table-column fixed="right" label="操作" width="200" align="center">
+            <template #default="scope">
+              <el-link type="primary" @click="handleUpdate(scope.row)">详情</el-link>
+              <el-link type="danger" @click="handleReject(scope.row)">拒绝</el-link>
+              <el-link type="success" @click="handlePass(scope.row)">同意</el-link>
+            </template>
+          </el-table-column>
+        </el-table>
+      </div>
+      <div class="pager-wrapper">
+        <el-pagination
+          background
+          :layout="paginationData.layout"
+          :page-sizes="paginationData.pageSizes"
+          :total="paginationData.total"
+          :page-size="paginationData.pageSize"
+          :currentPage="paginationData.currentPage"
+          @size-change="handleSizeChange"
+          @current-change="handleCurrentChange"
+        />
+      </div>
+    </el-card>
+    <!-- 详情 -->
+    <el-dialog v-model="dialogVisible" title="详情" @closed="resetForm" width="50%">
+      <div>
+        <h2>欢迎加入上海足球协会</h2>
+        <p>时间:2024-02-0216:53至2024-12-31 20:53</p>
+        <p>地点:上海市嘉定区安辰路999号</p>
+        <img src="@/assets/image.png" width="100%" />
+        <p>详情...</p>
+        <h3>已报名 (12 人)</h3>
+        <div class="user-list">
+          <div class="user-item" v-for="(item, index) in 10" :key="index">
+            <el-avatar :size="40" src="" />
+            <span>姓名</span>
+          </div>
+        </div>
+        <h3>签到人员 (12 人)</h3>
+        <div class="user-list">
+          <div class="user-item" v-for="(item, index) in 10" :key="index">
+            <el-avatar :size="40" src="" />
+            <span>姓名</span>
+          </div>
+        </div>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.toolbar-wrapper {
+  margin-bottom: 26px;
+}
+
+.table-wrapper {
+  margin-bottom: 29px;
+  .el-link {
+    margin-right: 15px;
+  }
+}
+
+.pager-wrapper {
+  display: flex;
+  justify-content: flex-end;
+}
+
+.user-list {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 15px 35px;
+  .user-item {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    gap: 7px;
+  }
+}
+</style>

+ 0 - 0
src/views/activity-management/index.vue


Some files were not shown because too many files changed in this diff