Pārlūkot izejas kodu

build: project init

chaooo 2 gadi atpakaļ
vecāks
revīzija
c1405f1b13
100 mainītis faili ar 3025 papildinājumiem un 0 dzēšanām
  1. 14 0
      .editorconfig
  2. 8 0
      .env.development
  3. 5 0
      .env.production
  4. 6 0
      .env.staging
  5. 14 0
      .eslintignore
  6. 269 0
      .eslintrc-auto-import.json
  7. 33 0
      .eslintrc.cjs
  8. 4 0
      .husky/commit-msg
  9. 4 0
      .husky/pre-commit
  10. 10 0
      .prettierignore
  11. 36 0
      .prettierrc.cjs
  12. 10 0
      .stylelintignore
  13. 43 0
      .stylelintrc.cjs
  14. 93 0
      commitlint.config.cjs
  15. 15 0
      index.html
  16. 108 0
      package.json
  17. BIN
      public/favicon.ico
  18. 11 0
      src/App.vue
  19. 27 0
      src/api/auth/index.ts
  20. 47 0
      src/api/auth/types.ts
  21. 24 0
      src/api/dashboard/index.ts
  22. 31 0
      src/api/dashboard/types.ts
  23. 89 0
      src/api/school/index.ts
  24. 102 0
      src/api/school/types.ts
  25. BIN
      src/assets/404/404.png
  26. BIN
      src/assets/404/back.png
  27. BIN
      src/assets/empty.png
  28. BIN
      src/assets/equipment/JM.png
  29. BIN
      src/assets/equipment/KL.png
  30. BIN
      src/assets/equipment/NJ.png
  31. BIN
      src/assets/equipment/PPC.png
  32. BIN
      src/assets/equipment/SC.png
  33. BIN
      src/assets/equipment/SUV.png
  34. BIN
      src/assets/equipment/SW.png
  35. BIN
      src/assets/equipment/UFO.png
  36. BIN
      src/assets/evaluate/focus.png
  37. BIN
      src/assets/evaluate/student.png
  38. BIN
      src/assets/evaluate/training.png
  39. BIN
      src/assets/example/example.jpg
  40. BIN
      src/assets/example/huiwen.png
  41. BIN
      src/assets/example/shisha.png
  42. 1 0
      src/assets/icons/board.svg
  43. 0 0
      src/assets/icons/class.svg
  44. 1 0
      src/assets/icons/close.svg
  45. 1 0
      src/assets/icons/close_all.svg
  46. 1 0
      src/assets/icons/close_left.svg
  47. 1 0
      src/assets/icons/close_other.svg
  48. 1 0
      src/assets/icons/close_right.svg
  49. 1 0
      src/assets/icons/compare.svg
  50. 1 0
      src/assets/icons/equipment.svg
  51. 1 0
      src/assets/icons/evaluation.svg
  52. 1 0
      src/assets/icons/exit-fullscreen.svg
  53. 1 0
      src/assets/icons/exit.svg
  54. 1 0
      src/assets/icons/eye-open.svg
  55. 1 0
      src/assets/icons/eye.svg
  56. 1 0
      src/assets/icons/fullscreen.svg
  57. 1 0
      src/assets/icons/password.svg
  58. 0 0
      src/assets/icons/student.svg
  59. 1 0
      src/assets/icons/teacher.svg
  60. 0 0
      src/assets/icons/training.svg
  61. 1 0
      src/assets/icons/username.svg
  62. BIN
      src/assets/index/equipments.png
  63. BIN
      src/assets/index/grade.png
  64. BIN
      src/assets/index/students.png
  65. BIN
      src/assets/index/teachers.png
  66. BIN
      src/assets/index/trainings.png
  67. BIN
      src/assets/login/avatar.png
  68. BIN
      src/assets/login/login.jpg
  69. BIN
      src/assets/logo-icon.png
  70. BIN
      src/assets/logo.png
  71. BIN
      src/assets/student/stars.png
  72. 103 0
      src/components/Breadcrumb/index.vue
  73. 46 0
      src/components/Hamburger/index.vue
  74. 88 0
      src/components/Pagination/index.vue
  75. 45 0
      src/components/SvgIcon/index.vue
  76. 9 0
      src/directive/index.ts
  77. 56 0
      src/directive/permission/index.ts
  78. 25 0
      src/lang/index.ts
  79. 22 0
      src/lang/package/en.ts
  80. 22 0
      src/lang/package/zh-cn.ts
  81. 121 0
      src/layout/admin.vue
  82. 44 0
      src/layout/components/AppMain.vue
  83. 45 0
      src/layout/components/Navbar/Admin/index.vue
  84. 44 0
      src/layout/components/Navbar/Fullscreen.vue
  85. 48 0
      src/layout/components/Navbar/School/index.vue
  86. 119 0
      src/layout/components/Navbar/SchoolSelect.vue
  87. 69 0
      src/layout/components/Navbar/UserInfo.vue
  88. 39 0
      src/layout/components/Sidebar/Link.vue
  89. 56 0
      src/layout/components/Sidebar/Logo.vue
  90. 163 0
      src/layout/components/Sidebar/SidebarItem.vue
  91. 41 0
      src/layout/components/Sidebar/index.vue
  92. 9 0
      src/layout/components/Sidebar/types.ts
  93. 121 0
      src/layout/components/TagsView/ScrollPane.vue
  94. 368 0
      src/layout/components/TagsView/index.vue
  95. 4 0
      src/layout/components/index.ts
  96. 121 0
      src/layout/school.vue
  97. 26 0
      src/main.ts
  98. 61 0
      src/permission.ts
  99. 47 0
      src/router/index.ts
  100. 43 0
      src/settings.ts

+ 14 - 0
.editorconfig

@@ -0,0 +1,14 @@
+# http://editorconfig.org
+root = true
+
+# 表示所有文件适用
+[*]
+charset = utf-8 # 设置文件字符集为 utf-8
+end_of_line = lf # 控制换行类型(lf | cr | crlf)
+indent_style = tab # 缩进风格(tab | space)
+insert_final_newline = true # 始终在文件末尾插入一个新行
+
+# 表示仅 md 文件适用以下规则
+[*.md]
+max_line_length = off # 关闭最大行长度限制
+trim_trailing_whitespace = false # 关闭末尾空格修剪

+ 8 - 0
.env.development

@@ -0,0 +1,8 @@
+## 开发环境
+
+# 变量必须以 VITE_ 为前缀才能暴露给外部读取
+NODE_ENV='development'
+
+VITE_APP_TITLE = 'shuimuai-dashboard-h5'
+VITE_APP_PORT = 3000
+VITE_APP_BASE_API = '/dev-api'

+ 5 - 0
.env.production

@@ -0,0 +1,5 @@
+## 生产环境
+
+VITE_APP_TITLE = 'shuimuai-dashboard-h5'
+VITE_APP_PORT = 3000
+VITE_APP_BASE_API = '/prod-api'

+ 6 - 0
.env.staging

@@ -0,0 +1,6 @@
+## 模拟环境
+NODE_ENV='staging'
+
+VITE_APP_TITLE = 'shuimuai-dashboard-h5'
+VITE_APP_PORT = 3000
+VITE_APP_BASE_API = '/prod--api'

+ 14 - 0
.eslintignore

@@ -0,0 +1,14 @@
+dist
+node_modules
+public
+.husky
+.vscode
+.idea
+*.sh
+*.md
+
+src/assets
+
+.eslintrc.cjs
+.prettierrc.cjs
+.stylelintrc.cjs

+ 269 - 0
.eslintrc-auto-import.json

@@ -0,0 +1,269 @@
+{
+  "globals": {
+    "EffectScope": true,
+    "ElForm": true,
+    "ElMessage": true,
+    "ElMessageBox": true,
+    "ElTree": true,
+    "asyncComputed": true,
+    "autoResetRef": true,
+    "computed": true,
+    "computedAsync": true,
+    "computedEager": true,
+    "computedInject": true,
+    "computedWithControl": true,
+    "controlledComputed": true,
+    "controlledRef": true,
+    "createApp": true,
+    "createEventHook": true,
+    "createGlobalState": true,
+    "createInjectionState": true,
+    "createReactiveFn": true,
+    "createSharedComposable": true,
+    "createUnrefFn": true,
+    "customRef": true,
+    "debouncedRef": true,
+    "debouncedWatch": true,
+    "defineAsyncComponent": true,
+    "defineComponent": true,
+    "eagerComputed": true,
+    "effectScope": true,
+    "extendRef": true,
+    "getCurrentInstance": true,
+    "getCurrentScope": true,
+    "h": true,
+    "ignorableWatch": true,
+    "inject": true,
+    "isDefined": true,
+    "isProxy": true,
+    "isReactive": true,
+    "isReadonly": true,
+    "isRef": true,
+    "makeDestructurable": true,
+    "markRaw": true,
+    "nextTick": true,
+    "onActivated": true,
+    "onBeforeMount": true,
+    "onBeforeUnmount": true,
+    "onBeforeUpdate": true,
+    "onClickOutside": true,
+    "onDeactivated": true,
+    "onErrorCaptured": true,
+    "onKeyStroke": true,
+    "onLongPress": true,
+    "onMounted": true,
+    "onRenderTracked": true,
+    "onRenderTriggered": true,
+    "onScopeDispose": true,
+    "onServerPrefetch": true,
+    "onStartTyping": true,
+    "onUnmounted": true,
+    "onUpdated": true,
+    "pausableWatch": true,
+    "provide": true,
+    "reactify": true,
+    "reactifyObject": true,
+    "reactive": true,
+    "reactiveComputed": true,
+    "reactiveOmit": true,
+    "reactivePick": true,
+    "readonly": true,
+    "ref": true,
+    "refAutoReset": true,
+    "refDebounced": true,
+    "refDefault": true,
+    "refThrottled": true,
+    "refWithControl": true,
+    "resolveComponent": true,
+    "resolveDirective": true,
+    "resolveRef": true,
+    "resolveUnref": true,
+    "shallowReactive": true,
+    "shallowReadonly": true,
+    "shallowRef": true,
+    "syncRef": true,
+    "syncRefs": true,
+    "templateRef": true,
+    "throttledRef": true,
+    "throttledWatch": true,
+    "toRaw": true,
+    "toReactive": true,
+    "toRef": true,
+    "toRefs": true,
+    "triggerRef": true,
+    "tryOnBeforeMount": true,
+    "tryOnBeforeUnmount": true,
+    "tryOnMounted": true,
+    "tryOnScopeDispose": true,
+    "tryOnUnmounted": true,
+    "unref": true,
+    "unrefElement": true,
+    "until": true,
+    "useActiveElement": true,
+    "useArrayEvery": true,
+    "useArrayFilter": true,
+    "useArrayFind": true,
+    "useArrayFindIndex": true,
+    "useArrayFindLast": true,
+    "useArrayJoin": true,
+    "useArrayMap": true,
+    "useArrayReduce": true,
+    "useArraySome": true,
+    "useArrayUnique": true,
+    "useAsyncQueue": true,
+    "useAsyncState": true,
+    "useAttrs": true,
+    "useBase64": true,
+    "useBattery": true,
+    "useBluetooth": true,
+    "useBreakpoints": true,
+    "useBroadcastChannel": true,
+    "useBrowserLocation": true,
+    "useCached": true,
+    "useClipboard": true,
+    "useCloned": true,
+    "useColorMode": true,
+    "useConfirmDialog": true,
+    "useCounter": true,
+    "useCssModule": true,
+    "useCssVar": true,
+    "useCssVars": true,
+    "useCurrentElement": true,
+    "useCycleList": true,
+    "useDark": true,
+    "useDateFormat": true,
+    "useDebounce": true,
+    "useDebounceFn": true,
+    "useDebouncedRefHistory": true,
+    "useDeviceMotion": true,
+    "useDeviceOrientation": true,
+    "useDevicePixelRatio": true,
+    "useDevicesList": true,
+    "useDisplayMedia": true,
+    "useDocumentVisibility": true,
+    "useDraggable": true,
+    "useDropZone": true,
+    "useElementBounding": true,
+    "useElementByPoint": true,
+    "useElementHover": true,
+    "useElementSize": true,
+    "useElementVisibility": true,
+    "useEventBus": true,
+    "useEventListener": true,
+    "useEventSource": true,
+    "useEyeDropper": true,
+    "useFavicon": true,
+    "useFetch": true,
+    "useFileDialog": true,
+    "useFileSystemAccess": true,
+    "useFocus": true,
+    "useFocusWithin": true,
+    "useFps": true,
+    "useFullscreen": true,
+    "useGamepad": true,
+    "useGeolocation": true,
+    "useIdle": true,
+    "useImage": true,
+    "useInfiniteScroll": true,
+    "useIntersectionObserver": true,
+    "useInterval": true,
+    "useIntervalFn": true,
+    "useKeyModifier": true,
+    "useLastChanged": true,
+    "useLocalStorage": true,
+    "useMagicKeys": true,
+    "useManualRefHistory": true,
+    "useMediaControls": true,
+    "useMediaQuery": true,
+    "useMemoize": true,
+    "useMemory": true,
+    "useMounted": true,
+    "useMouse": true,
+    "useMouseInElement": true,
+    "useMousePressed": true,
+    "useMutationObserver": true,
+    "useNavigatorLanguage": true,
+    "useNetwork": true,
+    "useNow": true,
+    "useObjectUrl": true,
+    "useOffsetPagination": true,
+    "useOnline": true,
+    "usePageLeave": true,
+    "useParallax": true,
+    "usePermission": true,
+    "usePointer": true,
+    "usePointerLock": true,
+    "usePointerSwipe": true,
+    "usePreferredColorScheme": true,
+    "usePreferredContrast": true,
+    "usePreferredDark": true,
+    "usePreferredLanguages": true,
+    "usePreferredReducedMotion": true,
+    "usePrevious": true,
+    "useRafFn": true,
+    "useRefHistory": true,
+    "useResizeObserver": true,
+    "useScreenOrientation": true,
+    "useScreenSafeArea": true,
+    "useScriptTag": true,
+    "useScroll": true,
+    "useScrollLock": true,
+    "useSessionStorage": true,
+    "useShare": true,
+    "useSlots": true,
+    "useSorted": true,
+    "useSpeechRecognition": true,
+    "useSpeechSynthesis": true,
+    "useStepper": true,
+    "useStorage": true,
+    "useStorageAsync": true,
+    "useStyleTag": true,
+    "useSupported": true,
+    "useSwipe": true,
+    "useTemplateRefsList": true,
+    "useTextDirection": true,
+    "useTextSelection": true,
+    "useTextareaAutosize": true,
+    "useThrottle": true,
+    "useThrottleFn": true,
+    "useThrottledRefHistory": true,
+    "useTimeAgo": true,
+    "useTimeout": true,
+    "useTimeoutFn": true,
+    "useTimeoutPoll": true,
+    "useTimestamp": true,
+    "useTitle": true,
+    "useToNumber": true,
+    "useToString": true,
+    "useToggle": true,
+    "useTransition": true,
+    "useUrlSearchParams": true,
+    "useUserMedia": true,
+    "useVModel": true,
+    "useVModels": true,
+    "useVibrate": true,
+    "useVirtualList": true,
+    "useWakeLock": true,
+    "useWebNotification": true,
+    "useWebSocket": true,
+    "useWebWorker": true,
+    "useWebWorkerFn": true,
+    "useWindowFocus": true,
+    "useWindowScroll": true,
+    "useWindowSize": true,
+    "watch": true,
+    "watchArray": true,
+    "watchAtMost": true,
+    "watchDebounced": true,
+    "watchEffect": true,
+    "watchIgnorable": true,
+    "watchOnce": true,
+    "watchPausable": true,
+    "watchPostEffect": true,
+    "watchSyncEffect": true,
+    "watchThrottled": true,
+    "watchTriggerable": true,
+    "watchWithFilter": true,
+    "whenever": true
+  }
+}

+ 33 - 0
.eslintrc.cjs

@@ -0,0 +1,33 @@
+module.exports = {
+  env: {
+    browser: true,
+    es2021: true,
+    node: true,
+  },
+  parser: "vue-eslint-parser",
+  extends: [
+    // 参考vuejs官方的eslint配置: https://eslint.vuejs.org/user-guide/#usage
+    "plugin:vue/vue3-recommended",
+    "./.eslintrc-auto-import.json",
+    "prettier",
+  ],
+  parserOptions: {
+    ecmaVersion: "latest",
+    sourceType: "module",
+    parser: "@typescript-eslint/parser",
+  },
+  plugins: ["vue", "@typescript-eslint"],
+  rules: {
+    "vue/multi-word-component-names": "off", // 关闭组件名必须多字: https://eslint.vuejs.org/rules/multi-word-component-names.html
+    "@typescript-eslint/no-empty-function": "off", // 关闭空方法检查
+    "@typescript-eslint/no-explicit-any": "off", // 关闭any类型的警告
+    "vue/no-v-model-argument": "off",
+    "@typescript-eslint/no-non-null-assertion": "off",
+		"vue/comment-directive": "off",
+  },
+  // https://eslint.org/docs/latest/use/configure/language-options#specifying-globals
+  globals: {
+    DialogOption: "readonly",
+    OptionType: "readonly",
+  },
+};

+ 4 - 0
.husky/commit-msg

@@ -0,0 +1,4 @@
+#!/bin/sh
+. "$(dirname "$0")/_/husky.sh"
+
+npx --no-install commitlint --edit $1

+ 4 - 0
.husky/pre-commit

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

+ 10 - 0
.prettierignore

@@ -0,0 +1,10 @@
+dist
+node_modules
+public
+.husky
+.vscode
+.idea
+*.sh
+*.md
+
+src/assets

+ 36 - 0
.prettierrc.cjs

@@ -0,0 +1,36 @@
+module.exports = {
+  // (x)=>{},单个参数箭头函数是否显示小括号。(always:始终显示;avoid:省略括号。默认:always)
+  arrowParens: "always",
+  // 开始标签的右尖括号是否跟随在最后一行属性末尾,默认false
+  bracketSameLine: false,
+  // 对象字面量的括号之间打印空格 (true - Example: { foo: bar } ; false - Example: {foo:bar})
+  bracketSpacing: true,
+  // 是否格式化一些文件中被嵌入的代码片段的风格(auto|off;默认auto)
+  embeddedLanguageFormatting: "auto",
+  // 指定 HTML 文件的空格敏感度 (css|strict|ignore;默认css)
+  htmlWhitespaceSensitivity: "css",
+  // 当文件已经被 Prettier 格式化之后,是否会在文件顶部插入一个特殊的 @format 标记,默认false
+  insertPragma: false,
+  // 在 JSX 中使用单引号替代双引号,默认false
+  jsxSingleQuote: false,
+  // 每行最多字符数量,超出换行(默认80)
+  printWidth: 80,
+  // 超出打印宽度 (always | never | preserve )
+  proseWrap: "preserve",
+  // 对象属性是否使用引号(as-needed | consistent | preserve;默认as-needed:对象的属性需要加引号才添加;)
+  quoteProps: "as-needed",
+  // 是否只格式化在文件顶部包含特定注释(@prettier| @format)的文件,默认false
+  requirePragma: false,
+  // 结尾添加分号
+  semi: true,
+  // 使用单引号 (true:单引号;false:双引号)
+  singleQuote: false,
+  // 缩进空格数,默认2个空格
+  tabWidth: 2,
+  // 元素末尾是否加逗号,默认es5: ES5中的 objects, arrays 等会添加逗号,TypeScript 中的 type 后不加逗号
+  trailingComma: "es5",
+  // 指定缩进方式,空格或tab,默认false,即使用空格
+  useTabs: false,
+  // vue 文件中是否缩进 <style> 和 <script> 标签,默认 false
+  vueIndentScriptAndStyle: false,
+};

+ 10 - 0
.stylelintignore

@@ -0,0 +1,10 @@
+dist
+node_modules
+public
+.husky
+.vscode
+.idea
+*.sh
+*.md
+
+src/assets

+ 43 - 0
.stylelintrc.cjs

@@ -0,0 +1,43 @@
+module.exports = {
+  // 继承推荐规范配置
+  extends: [
+    "stylelint-config-standard",
+    "stylelint-config-recommended-scss",
+    "stylelint-config-recommended-vue/scss",
+    "stylelint-config-html/vue",
+    "stylelint-config-recess-order",
+  ],
+  // 指定不同文件对应的解析器
+  overrides: [
+    {
+      files: ["**/*.{vue,html}"],
+      customSyntax: "postcss-html",
+    },
+    {
+      files: ["**/*.{css,scss}"],
+      customSyntax: "postcss-scss",
+    },
+  ],
+  // 自定义规则
+  rules: {
+    "import-notation": "string", // 指定导入CSS文件的方式("string"|"url")
+    "selector-class-pattern": null, // 选择器类名命名规则
+    "custom-property-pattern": null, // 自定义属性命名规则
+    "keyframes-name-pattern": null, // 动画帧节点样式命名规则
+    "no-descending-specificity": null, // 允许无降序特异性
+    // 允许 global 、export 、deep伪类
+    "selector-pseudo-class-no-unknown": [
+      true,
+      {
+        ignorePseudoClasses: ["global", "export", "deep"],
+      },
+    ],
+    // 允许未知属性
+    "property-no-unknown": [
+      true,
+      {
+        ignoreProperties: ["menuBg", "menuText", "menuActiveText"],
+      },
+    ],
+  },
+};

+ 93 - 0
commitlint.config.cjs

@@ -0,0 +1,93 @@
+module.exports = {
+  // 继承的规则
+  extends: ["@commitlint/config-conventional"],
+  // 自定义规则
+  rules: {
+    // @see https://commitlint.js.org/#/reference-rules
+
+    // 提交类型枚举,git提交type必须是以下类型
+    "type-enum": [
+      2,
+      "always",
+      [
+        "feat", // 新增功能
+        "fix", // 修复缺陷
+        "docs", // 文档变更
+        "style", // 代码格式(不影响功能,例如空格、分号等格式修正)
+        "refactor", // 代码重构(不包括 bug 修复、功能新增)
+        "perf", // 性能优化
+        "test", // 添加疏漏测试或已有测试改动
+        "build", // 构建流程、外部依赖变更(如升级 npm 包、修改 webpack 配置等)
+        "ci", // 修改 CI 配置、脚本
+        "revert", // 回滚 commit
+        "chore", // 对构建过程或辅助工具和库的更改(不影响源文件、测试用例)
+      ],
+    ],
+    "subject-case": [0], // subject大小写不做校验
+  },
+
+  prompt: {
+    messages: {
+      type: "选择你要提交的类型 :",
+      scope: "选择一个提交范围(可选):",
+      customScope: "请输入自定义的提交范围 :",
+      subject: "填写简短精炼的变更描述 :\n",
+      body: '填写更加详细的变更描述(可选)。使用 "|" 换行 :\n',
+      breaking: '列举非兼容性重大的变更(可选)。使用 "|" 换行 :\n',
+      footerPrefixesSelect: "选择关联issue前缀(可选):",
+      customFooterPrefix: "输入自定义issue前缀 :",
+      footer: "列举关联issue (可选) 例如: #31, #I3244 :\n",
+      generatingByAI: "正在通过 AI 生成你的提交简短描述...",
+      generatedSelectByAI: "选择一个 AI 生成的简短描述:",
+      confirmCommit: "是否提交或修改commit ?",
+    },
+    // prettier-ignore
+    types: [
+      { value: "feat",     name: "特性:     ✨  新增功能", emoji: ":sparkles:" },
+      { value: "fix",      name: "修复:     🐛  修复缺陷", emoji: ":bug:" },
+      { value: "docs",     name: "文档:     📝  文档变更", emoji: ":memo:" },
+      { value: "style",    name: "格式:     🌈  代码格式(不影响功能,例如空格、分号等格式修正)", emoji: ":lipstick:" },
+      { value: "refactor", name: "重构:     🔄  代码重构(不包括 bug 修复、功能新增)", emoji: ":recycle:" },
+      { value: "perf",     name: "性能:     🚀  性能优化", emoji: ":zap:" },
+      { value: "test",     name: "测试:     🧪  添加疏漏测试或已有测试改动", emoji: ":white_check_mark:"},
+      { value: "build",    name: "构建:     📦️  构建流程、外部依赖变更(如升级 npm 包、修改 vite 配置等)", emoji: ":package:"},
+      { value: "ci",       name: "集成:     ⚙️  修改 CI 配置、脚本",  emoji: ":ferris_wheel:"},
+      { value: "revert",   name: "回退:     ↩️  回滚 commit",emoji: ":rewind:"},
+      { value: "chore",    name: "其他:     🛠️  对构建过程或辅助工具和库的更改(不影响源文件、测试用例)", emoji: ":hammer:"},
+    ],
+    useEmoji: true,
+    emojiAlign: "center",
+    useAI: false,
+    aiNumber: 1,
+    themeColorCode: "",
+    scopes: [],
+    allowCustomScopes: true,
+    allowEmptyScopes: true,
+    customScopesAlign: "bottom",
+    customScopesAlias: "custom",
+    emptyScopesAlias: "empty",
+    upperCaseSubject: false,
+    markBreakingChangeMode: false,
+    allowBreakingChanges: ["feat", "fix"],
+    breaklineNumber: 100,
+    breaklineChar: "|",
+    skipQuestions: [],
+    issuePrefixes: [
+      { value: "closed", name: "closed:   ISSUES has been processed" },
+    ],
+    customIssuePrefixAlign: "top",
+    emptyIssuePrefixAlias: "skip",
+    customIssuePrefixAlias: "custom",
+    allowCustomIssuePrefix: true,
+    allowEmptyIssuePrefix: true,
+    confirmColorize: true,
+    maxHeaderLength: Infinity,
+    maxSubjectLength: Infinity,
+    minSubjectLength: 0,
+    scopeOverrides: undefined,
+    defaultBody: "",
+    defaultIssues: "",
+    defaultScope: "",
+    defaultSubject: "",
+  },
+};

+ 15 - 0
index.html

@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<html lang="zh-cn">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" href="/favicon.ico" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <meta name="description" content="水母星球数据看板系统" />
+    <meta name="keywords" content="水母星球,数据看板,后台系统" />
+    <title>水母星球数据看板系统</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="./src/main.ts"></script>
+  </body>
+</html>

+ 108 - 0
package.json

@@ -0,0 +1,108 @@
+{
+  "name": "shuimu-dashboard-h5",
+  "private": true,
+  "version": "2.4.0",
+  "type": "module",
+  "scripts": {
+    "preinstall": "npx only-allow pnpm",
+    "dev": "vite serve --mode development",
+    "build:prod": "vite build --mode production &&vue-tsc --noEmit",
+    "prepare": "husky install",
+    "lint:eslint": "eslint  --fix --ext .ts,.js,.vue ./src ",
+    "lint:prettier": "prettier --write \"**/*.{js,cjs,ts,json,tsx,css,less,scss,vue,html,md}\"",
+    "lint:stylelint": "stylelint  \"**/*.{css,scss,vue}\" --fix",
+    "lint:lint-staged": "lint-staged",
+    "commit": "git-cz"
+  },
+  "config": {
+    "commitizen": {
+      "path": "node_modules/cz-git"
+    }
+  },
+  "lint-staged": {
+    "*.{js,ts}": [
+      "eslint --fix",
+      "prettier --write"
+    ],
+    "*.{cjs,json}": [
+      "prettier --write"
+    ],
+    "*.{vue,html}": [
+      "eslint --fix",
+      "prettier --write",
+      "stylelint --fix"
+    ],
+    "*.{scss,css}": [
+      "stylelint --fix",
+      "prettier --write"
+    ],
+    "*.md": [
+      "prettier --write"
+    ]
+  },
+  "dependencies": {
+    "@vitejs/plugin-vue": "^4.2.3",
+    "@vueuse/core": "^10.1.2",
+    "@wangeditor/editor": "^5.1.23",
+    "@wangeditor/editor-for-vue": "5.1.10",
+    "axios": "^1.4.0",
+    "codemirror": "^5.65.13",
+    "echarts": "^5.2.2",
+    "echarts-liquidfill": "^3.1.0",
+    "element-plus": "^2.3.6",
+    "lodash-es": "^4.17.21",
+    "nprogress": "^0.2.0",
+    "path-browserify": "^1.0.1",
+    "path-to-regexp": "^6.2.0",
+    "pinia": "^2.0.33",
+    "screenfull": "^6.0.0",
+    "vue": "^3.3.1",
+    "vue-i18n": "9.2.2",
+    "vue-router": "^4.2.0"
+  },
+  "devDependencies": {
+    "@commitlint/cli": "^17.6.3",
+    "@commitlint/config-conventional": "^17.6.3",
+    "@iconify-json/ep": "^1.1.10",
+    "@element-plus/icons-vue": "^2.1.0",
+    "@types/codemirror": "^5.60.7",
+    "@types/lodash": "^4.14.195",
+    "@types/nprogress": "^0.2.0",
+    "@types/path-browserify": "^1.0.0",
+    "@typescript-eslint/eslint-plugin": "^5.59.6",
+    "@typescript-eslint/parser": "^5.59.6",
+    "autoprefixer": "^10.4.14",
+    "commitizen": "^4.3.0",
+    "cz-git": "^1.6.1",
+    "eslint": "^8.40.0",
+    "eslint-config-prettier": "^8.8.0",
+    "eslint-plugin-prettier": "^4.2.1",
+    "eslint-plugin-vue": "^9.13.0",
+    "fast-glob": "^3.2.11",
+    "husky": "^8.0.3",
+    "lint-staged": "^13.2.2",
+    "postcss": "^8.4.23",
+    "postcss-html": "^1.5.0",
+    "postcss-scss": "^4.0.6",
+    "prettier": "^2.8.8",
+    "sass": "^1.58.3",
+    "stylelint": "^15.5.0",
+    "stylelint-config-html": "^1.1.0",
+    "stylelint-config-recess-order": "^4.0.0",
+    "stylelint-config-recommended-scss": "11.0.0 ",
+    "stylelint-config-recommended-vue": "^1.4.0",
+    "stylelint-config-standard": "^33.0.0",
+    "stylelint-config-standard-scss": "^9.0.0",
+    "typescript": "^5.0.4",
+    "unocss": "^0.51.13",
+    "unplugin-auto-import": "^0.15.3",
+    "unplugin-icons": "^0.16.1",
+    "unplugin-vue-components": "^0.24.1",
+    "vite": "^4.3.9",
+    "vite-plugin-svg-icons": "^2.0.1",
+    "vue-tsc": "^1.6.5 "
+  },
+  "repository": "http://120.78.146.64:3000/shuimuai/shuimu-dashboard-h5/src/master",
+  "author": "索隆",
+  "license": "MIT"
+}

BIN
public/favicon.ico


+ 11 - 0
src/App.vue

@@ -0,0 +1,11 @@
+<script setup lang="ts">
+import { ElConfigProvider } from "element-plus";
+import { useAppStore } from "@/store/modules/app";
+const appStore = useAppStore();
+</script>
+
+<template>
+  <el-config-provider :locale="appStore.locale" :size="appStore.size">
+    <router-view />
+  </el-config-provider>
+</template>

+ 27 - 0
src/api/auth/index.ts

@@ -0,0 +1,27 @@
+import request from "@/utils/request";
+import { AxiosPromise } from "axios";
+import { LoginData, LoginResult } from "./types";
+
+/**
+ * 登录API
+ *
+ * @param data {LoginData}
+ * @returns
+ */
+export function loginApi(data: LoginData): AxiosPromise<LoginResult> {
+  return request({
+    url: "/board/v1/login",
+    method: "post",
+    data: data,
+  });
+}
+
+/**
+ * 注销API
+ */
+export function logoutApi() {
+  return request({
+    url: "/board/v1/logout",
+    method: "post",
+  });
+}

+ 47 - 0
src/api/auth/types.ts

@@ -0,0 +1,47 @@
+/**
+ * 登录请求参数
+ */
+export interface LoginData {
+  /**
+   * 手机号
+   */
+  phone?: string;
+  /**
+   * 密码
+   */
+  password?: string;
+}
+
+/**
+ * 登录响应
+ */
+export interface LoginResult {
+  /**
+   * 访问token
+   */
+  token: string;
+  /**
+   * 姓名
+   */
+  name: string;
+  /**
+   * 加密后的电话号码
+   */
+  phone: string;
+  /**
+   * 学校号
+   */
+  num: string;
+  /**
+   * 头像
+   */
+  avatar: string;
+  /**
+   * 角色
+   */
+  role: string;
+  /**
+   * 权限
+   */
+  perms: string[];
+}

+ 24 - 0
src/api/dashboard/index.ts

@@ -0,0 +1,24 @@
+import request from "@/utils/request";
+import { AxiosPromise } from "axios";
+import { DashboardCard, DashboardData } from "./types";
+
+/**
+ * 获取学校列表
+ */
+export function getDashboardTop(id: number): AxiosPromise<DashboardCard> {
+  return request({
+    url: "/board/v1/top",
+    method: "get",
+    params: { school_id: id },
+  });
+}
+/**
+ * 获取班级列表
+ */
+export function getDashboardData(id: number): AxiosPromise<DashboardData> {
+  return request({
+    url: "/board/v1/bottom",
+    method: "get",
+    params: { grade_id: id },
+  });
+}

+ 31 - 0
src/api/dashboard/types.ts

@@ -0,0 +1,31 @@
+/**
+ * 首页顶部数据
+ */
+export interface DashboardCard {
+  grade: number;
+  teacher: number;
+  student: number;
+  equipment: number;
+  game: number;
+}
+/**
+ * 初期分期占比分析
+ */
+interface Proportion {
+  num: number[];
+  percentage: number[];
+}
+export interface DashboardData {
+  // 初期专注力估值
+  frontAverage: number;
+  // 近期专注力估值
+  afterAverage: number;
+  // 初期50分以上的占比
+  front: number;
+  // 近期50分以上的占比
+  after: number;
+  // 初期分期占比分析
+  frontProportion: Proportion;
+  // 近期分期占比分析
+  afterProportion: Proportion;
+}

+ 89 - 0
src/api/school/index.ts

@@ -0,0 +1,89 @@
+import request from "@/utils/request";
+import { AxiosPromise } from "axios";
+import {
+  SchoolList,
+  GradeList,
+  GradeItem,
+  StudentList,
+  TeacherList,
+  TeacherEquipment,
+} from "./types";
+
+/**
+ * 获取学校列表
+ */
+export function getSchoolSelect(): AxiosPromise<SchoolList[]> {
+  return request({
+    url: "/board/v1/schools",
+    method: "get",
+  });
+}
+/**
+ * 获取班级列表(班级select下拉列表)
+ */
+export function getGradeSelect(id: number): AxiosPromise<GradeList[]> {
+  return request({
+    url: "/board/v1/choose-grade",
+    method: "get",
+    params: { school_id: id },
+  });
+}
+/**
+ * 班级管理
+ * status: 0全部1未结课2已结课
+ */
+export function getGradeList(
+  id: number,
+  status: number
+): AxiosPromise<GradeItem[]> {
+  return request({
+    url: "/board/v1/grade",
+    method: "get",
+    params: { school_id: id, status: status },
+  });
+}
+/**
+ * 班级管理-学生列表
+ */
+export function getGradeStudents(id: number): AxiosPromise<StudentList[]> {
+  return request({
+    url: "/board/v1/students",
+    method: "get",
+    params: { grade_id: id },
+  });
+}
+/**
+ * 获取教师列表
+ */
+export function getTeacherList(
+  id: number,
+  keyword: string
+): AxiosPromise<TeacherList[]> {
+  return request({
+    url: "/board/v1/teacher",
+    method: "get",
+    params: { school_id: id, search: keyword },
+  });
+}
+/**
+ * 获取教师设备
+ */
+export function getTeacherEquipment(
+  id: number
+): AxiosPromise<TeacherEquipment> {
+  return request({
+    url: "/board/v1/teacher-equipment",
+    method: "get",
+    params: { teacher_id: id },
+  });
+}
+/**
+ * 获取教师班级
+ */
+export function getTeacherGrade(id: number): AxiosPromise<GradeList[]> {
+  return request({
+    url: "/board/v1/teacher-class",
+    method: "get",
+    params: { teacher_id: id },
+  });
+}

+ 102 - 0
src/api/school/types.ts

@@ -0,0 +1,102 @@
+/**
+ * 学校列表
+ */
+export interface SchoolList {
+  // 学校id
+  school_id: number;
+  // 学校名称
+  name: string;
+  // 学校号
+  num: string;
+}
+/**
+ * 班级列表
+ */
+export interface GradeList {
+  // 班级id
+  id: number;
+  // 班级名称
+  name: string;
+}
+/**
+ * 班级数据
+ */
+export interface GradeItem {
+  // 班级id
+  id: number;
+  // 班级名称
+  name: string;
+  // 0未结课1已结课
+  grade_status: number;
+  // 班级号
+  num: string;
+  // 学生数量
+  count: number;
+  // 教师名称
+  teacher_name: string;
+  // 结课时间
+  grade_time: string;
+  // 折叠面板使用
+  active: number[];
+  // 学生列表
+  students: StudentList[];
+}
+/**
+ * 学生列表
+ */
+export interface StudentList {
+  // 学生id
+  id: number;
+  // 学生名称
+  name: string;
+  // 电话号码
+  phone: number;
+  // 训练次数
+  count: number;
+}
+/**
+ * 教师列表
+ */
+export interface TeacherList {
+  lists: TeacherItem[];
+  // 数量
+  count: number;
+}
+export interface TeacherItem {
+  // 学生id
+  id: number;
+  // 学生名称
+  name: string;
+  // 注册时间
+  create_time: string;
+  // 折叠面板使用
+  active: number[];
+  // 教师的设备
+  equipment: TeacherEquipment;
+  // 负责班级
+  grades: GradeList[];
+  // 表格行数
+  lines: number;
+}
+export interface TeacherEquipment {
+  // 脑机
+  AI: Equipment[];
+  // 水舞
+  SW: Equipment[];
+  // 恐龙
+  KL: Equipment[];
+  // 碰碰车
+  PP: Equipment[];
+  // SUV
+  SU: Equipment[];
+  // 赛车
+  SC: Equipment[];
+  // UFO
+  UF: Equipment[];
+  // 积木
+  JM: Equipment[];
+}
+export interface Equipment {
+  // 设备号
+  sn: string;
+}

BIN
src/assets/404/404.png


BIN
src/assets/404/back.png


BIN
src/assets/empty.png


BIN
src/assets/equipment/JM.png


BIN
src/assets/equipment/KL.png


BIN
src/assets/equipment/NJ.png


BIN
src/assets/equipment/PPC.png


BIN
src/assets/equipment/SC.png


BIN
src/assets/equipment/SUV.png


BIN
src/assets/equipment/SW.png


BIN
src/assets/equipment/UFO.png


BIN
src/assets/evaluate/focus.png


BIN
src/assets/evaluate/student.png


BIN
src/assets/evaluate/training.png


BIN
src/assets/example/example.jpg


BIN
src/assets/example/huiwen.png


BIN
src/assets/example/shisha.png


+ 1 - 0
src/assets/icons/board.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1686730626431" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1503" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M856.27 841.14H167.73c-11.84 0-21.44 9.6-21.44 21.44v30.26c0 11.84 9.6 21.44 21.44 21.44h688.54c11.84 0 21.44-9.6 21.44-21.44v-30.26c0-11.84-9.6-21.44-21.44-21.44z m3.11-731.43H164.62c-30.32 0-54.9 24.58-54.9 54.9V713.1c0 30.32 24.58 54.9 54.9 54.9h694.76c30.32 0 54.9-24.58 54.9-54.9V164.62c0.01-30.33-24.57-54.91-54.9-54.91z m-530.24 512c0 20.2-16.37 36.57-36.57 36.57S256 641.9 256 621.71V475.43c0-20.2 16.37-36.57 36.57-36.57s36.57 16.37 36.57 36.57v146.28z m146.29 0c0 20.2-16.37 36.57-36.57 36.57s-36.57-16.38-36.57-36.57V256c0-20.2 16.37-36.57 36.57-36.57s36.57 16.37 36.57 36.57v365.71z m146.28 0c0 20.2-16.37 36.57-36.57 36.57s-36.57-16.38-36.57-36.57v-73.14c0-20.2 16.37-36.57 36.57-36.57s36.57 16.37 36.57 36.57v73.14z m146.29 0c0 20.2-16.37 36.57-36.57 36.57s-36.57-16.38-36.57-36.57V329.14c0-20.2 16.37-36.57 36.57-36.57S768 308.94 768 329.14v292.57z" p-id="1504"></path></svg>

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
src/assets/icons/class.svg


+ 1 - 0
src/assets/icons/close.svg

@@ -0,0 +1 @@
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" preserveAspectRatio="xMidYMid meet" viewBox="0 0 36 36"><path d="M19.41 18l8.29-8.29a1 1 0 0 0-1.41-1.41L18 16.59l-8.29-8.3a1 1 0 0 0-1.42 1.42l8.3 8.29l-8.3 8.29A1 1 0 1 0 9.7 27.7l8.3-8.29l8.29 8.29a1 1 0 0 0 1.41-1.41z" fill="currentColor"></path></svg>

+ 1 - 0
src/assets/icons/close_all.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" preserveAspectRatio="xMidYMid meet" viewBox="0 0 36 36"><path d="M26 17H10a1 1 0 0 0 0 2h16a1 1 0 0 0 0-2z" fill="currentColor"></path></svg>

+ 1 - 0
src/assets/icons/close_left.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"><g fill="none"><path d="M7 12l7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path><path d="M7 12l7-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path><path d="M21 12H7.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" ></path><path d="M3 3v18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path></g></svg>

+ 1 - 0
src/assets/icons/close_other.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" preserveAspectRatio="xMidYMid meet" viewBox="0 0 20 20"><path d="M3 5h14V3H3v2zm12 8V7H5v6h10zM3 17h14v-2H3v2z" fill="currentColor"></path></svg>

+ 1 - 0
src/assets/icons/close_right.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"><g transform="translate(24 0) scale(-1 1)"><g fill="none"><path d="M7 12l7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path><path d="M7 12l7-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path><path d="M21 12H7.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path><path d="M3 3v18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path></g></g></svg>

+ 1 - 0
src/assets/icons/compare.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1687312779365" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1182" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M245.76 0h245.76l491.52 532.48-450.56 491.52H286.72l450.56-491.52zM40.96 163.84h163.84l327.68 368.64-327.68 327.68H40.96l368.64-327.68z" fill="#EC482D" p-id="1183"></path></svg>

+ 1 - 0
src/assets/icons/equipment.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1686730619355" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1368" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M886.19732092 304.39957657l-43.00487869 43.15484125a117.73772039 117.73772039 0 0 1-166.45768698-167.209151l43.00570266-43.00487869c23.75765744-23.75765744 7.9694816-51.72664911-34.88543288-45.110133A215.47757867 215.47757867 0 0 0 507.27038657 350.41031382L146.38849368 711.2946778A117.58775702 117.58775702 0 0 0 312.54460596 878.05064358l360.88271603-360.88271685a215.17600397 215.17600397 0 0 0 257.42941783-177.43385111c7.21801675-43.15484207-20.90176226-59.09380527-44.6594189-35.336147zM257.05898469 822.56502231a39.24590652 39.24590652 0 1 1-1e-8-55.48562046 39.24590652 39.24590652 0 0 1 0 55.48562046z m-39.09594316-511.24982767L353.29426614 447.39870807l55.48562045-55.63640864-135.33122461-135.33040063-30.07342199-58.19237704L145.78699222 128.46865433l-55.48644443 55.48644525 69.32015333 97.13670887z m455.01274237 288.85580695a19.54797199 19.54797199 0 0 0-27.66741695 0l-83.30382477 83.30382477a19.54797199 19.54797199 0 0 0 0 27.66741614L758.38486379 907.52421207a78.49181387 78.49181387 0 1 0 110.97124174-110.670491z" p-id="1369"></path></svg>

+ 1 - 0
src/assets/icons/evaluation.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1686730630601" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1638" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M439.9775 239.6534375a44.8003125 44.8003125 0 0 1 4.24125 13.9378125l11.851875 175.479375 5.8828125 88.2a90.3375 90.3375 0 0 0 4.24125 26.7440625c7.10625 16.8075 24.1996875 27.489375 42.796875 26.745l283.381875-18.4575c12.2709375-0.2015625 24.121875 4.36875 32.9428125 12.7040625 7.3509375 6.946875 12.0975 16.035 13.5928125 25.809375l0.5015625 5.934375C827.684375 758.44625 708.423125 893.3121875 546.378125 928.12625c-162.045 34.8140625-328.2140625-38.7290625-408.28875-180.7003125-23.0859375-41.24625-37.5046875-86.581875-42.4115625-133.3471875a254.025 254.025 0 0 1-2.6990625-41.8125c-0.253125-173.3540625 123.721875-323.225625 297.2625-359.3559375 20.8865625-3.2390625 41.3625 7.771875 49.73625 26.7440625zM553.551875 87.321875l0.7471875 0.0253125C749.493125 92.3046875 913.5471875 232.413125 945.125 421.1290625l-0.301875 1.3921875-0.8615625 2.025 0.12 5.559375c-0.4471875 7.3640625-3.2953125 14.4496875-8.2040625 20.173125-5.113125 5.9625-12.099375 10.021875-19.7934375 11.5978125l-4.69125 0.643125-328.790625 21.264375c-10.9359375 1.0771875-21.825-2.443125-29.9578125-9.684375-6.778125-6.0346875-11.11125-14.180625-12.3346875-22.9575l-22.06875-327.72a5.2125 5.2125 0 0 1 0-3.418125c0.3009375-9.03375 4.284375-17.5725 11.060625-23.709375 6.5925-5.97 15.2934375-9.181875 24.2503125-8.971875z" p-id="1639"></path></svg>

+ 1 - 0
src/assets/icons/exit-fullscreen.svg

@@ -0,0 +1 @@
+<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg"><path d="M49.217 41.329l-.136-35.24c-.06-2.715-2.302-4.345-5.022-4.405h-3.65c-2.712-.06-4.866 2.303-4.806 5.016l.152 19.164-24.151-23.79a6.698 6.698 0 0 0-9.499 0 6.76 6.76 0 0 0 0 9.526l23.93 23.713-18.345.074c-2.712-.069-5.228 1.813-5.64 5.02v3.462c.069 2.721 2.31 4.97 5.022 5.03l35.028-.207c.052.005.087.025.133.025l2.457.054a4.626 4.626 0 0 0 3.436-1.38c.88-.874 1.205-2.096 1.169-3.462l-.262-2.465c0-.048.182-.081.182-.136h.002zm52.523 51.212l18.32-.073c2.713.06 5.224-1.609 5.64-4.815v-3.462c-.068-2.722-2.317-4.97-5.021-5.04l-34.58.21c-.053 0-.086-.021-.138-.021l-2.451-.06a4.64 4.64 0 0 0-3.445 1.381c-.885.868-1.201 2.094-1.174 3.46l.27 2.46c.005.06-.177.095-.177.141l.141 34.697c.069 2.713 2.31 4.338 5.022 4.397l3.45.006c2.705.062 4.867-2.31 4.8-5.026l-.153-18.752 24.151 23.946a6.69 6.69 0 0 0 9.494 0 6.747 6.747 0 0 0 0-9.523L101.74 92.54v.001zM48.125 80.662a4.636 4.636 0 0 0-3.437-1.382l-2.457.06c-.05 0-.082.022-.137.022l-35.025-.21c-2.712.07-4.957 2.318-5.022 5.04v3.462c.409 3.206 2.925 4.874 5.633 4.814l18.554.06-24.132 23.928c-2.62 2.626-2.62 6.89 0 9.524a6.694 6.694 0 0 0 9.496 0l24.155-23.79-.155 18.866c-.06 2.722 2.094 5.093 4.801 5.025h3.65c2.72-.069 4.962-1.685 5.022-4.406l.141-34.956c0-.05-.182-.082-.182-.136l.262-2.46c.03-1.366-.286-2.592-1.166-3.46h-.001zM80.08 47.397a4.62 4.62 0 0 0 3.443 1.374l2.45-.054c.055 0 .088-.02.143-.028l35.08.21c2.712-.062 4.953-2.312 5.021-5.033l.009-3.463c-.417-3.211-2.937-5.084-5.64-5.025l-18.615-.073 23.917-23.715c2.63-2.623 2.63-6.879.008-9.513a6.691 6.691 0 0 0-9.494 0L92.251 26.016l.155-19.312c.065-2.713-2.097-5.085-4.802-5.025h-3.45c-2.713.069-4.954 1.693-5.022 4.406l-.139 35.247c0 .054.18.088.18.136l-.267 2.465c-.028 1.366.288 2.588 1.174 3.463v.001z"/></svg>

+ 1 - 0
src/assets/icons/exit.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1686730594680" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1092" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M138.5 939.5L642.05 939.5c24.75 0 45-20.25 45-45s-20.25-45-45.00000001-45L183.5 849.5l0-675L642.04999999 174.5c24.75 0 45-20.25 45.00000001-45s-20.25-45-45-45L138.5 84.5c-24.75 0-45 20.25-45 45l0 765c0 24.75 20.25 45 45 45z" fill="" p-id="1093"></path><path d="M683.9 375.2L776.6 467l-347.4 0c-24.75 0-45 20.25-45 45s20.25 45 45 45L776.15 557l-92.25 91.35c-17.55 17.55-18 45.9-0.45 63.44999999 17.55 18 45.9 18 63.9 0.90000001L917 544.4c8.55-8.55 13.5-19.8 13.5-31.95s-4.95-23.4-13.5-31.95l-170.1-168.75a45.045 45.045 0 0 0-63.45-1e-8 44.82 44.82 0 0 0 0.45 63.45000001z" fill="" p-id="1094"></path></svg>

+ 1 - 0
src/assets/icons/eye-open.svg

@@ -0,0 +1 @@
+<svg class="icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="128" height="128"><defs><style/></defs><path d="M512 128q69.675 0 135.51 21.163t115.498 54.997 93.483 74.837 73.685 82.006 51.67 74.837 32.17 54.827L1024 512q-2.347 4.992-6.315 13.483T998.87 560.17t-31.658 51.669-44.331 59.99-56.832 64.34-69.504 60.16-82.347 51.5-94.848 34.687T512 896q-69.675 0-135.51-21.163t-115.498-54.826-93.483-74.326-73.685-81.493-51.67-74.496-32.17-54.997L0 513.707q2.347-4.992 6.315-13.483t18.816-34.816 31.658-51.84 44.331-60.33 56.832-64.683 69.504-60.331 82.347-51.84 94.848-34.816T512 128.085zm0 85.333q-46.677 0-91.648 12.331t-81.152 31.83-70.656 47.146-59.648 54.485-48.853 57.686-37.675 52.821-26.325 43.99q12.33 21.674 26.325 43.52t37.675 52.351 48.853 57.003 59.648 53.845T339.2 767.02t81.152 31.488T512 810.667t91.648-12.331 81.152-31.659 70.656-46.848 59.648-54.186 48.853-57.344 37.675-52.651T927.957 512q-12.33-21.675-26.325-43.648t-37.675-52.65-48.853-57.345-59.648-54.186-70.656-46.848-81.152-31.659T512 213.334zm0 128q70.656 0 120.661 50.006T682.667 512 632.66 632.661 512 682.667 391.339 632.66 341.333 512t50.006-120.661T512 341.333zm0 85.334q-35.328 0-60.33 25.002T426.666 512t25.002 60.33T512 597.334t60.33-25.002T597.334 512t-25.002-60.33T512 426.666z"/></svg>

+ 1 - 0
src/assets/icons/eye.svg

@@ -0,0 +1 @@
+<svg width="128" height="64" xmlns="http://www.w3.org/2000/svg"><path d="M127.072 7.994c1.37-2.208.914-5.152-.914-6.87-2.056-1.717-4.797-1.226-6.396.982-.229.245-25.586 32.382-55.74 32.382-29.24 0-55.74-32.382-55.968-32.627-1.6-1.963-4.57-2.208-6.397-.49C-.17 3.086-.399 6.275 1.2 8.238c.457.736 5.94 7.36 14.62 14.72L4.17 35.96c-1.828 1.963-1.6 5.152.228 6.87.457.98 1.6 1.471 2.742 1.471s2.284-.49 3.198-1.472l12.564-13.983c5.94 4.416 13.021 8.587 20.788 11.53l-4.797 17.418c-.685 2.699.686 5.397 3.198 6.133h1.37c2.057 0 3.884-1.472 4.341-3.68L52.6 42.83c3.655.736 7.538 1.227 11.422 1.227 3.883 0 7.767-.49 11.422-1.227l4.797 17.173c.457 2.208 2.513 3.68 4.34 3.68.457 0 .914 0 1.143-.246 2.513-.736 3.883-3.434 3.198-6.133l-4.797-17.172c7.767-2.944 14.848-7.114 20.788-11.53l12.336 13.738c.913.981 2.056 1.472 3.198 1.472s2.284-.49 3.198-1.472c1.828-1.963 1.828-4.906.228-6.87l-11.65-13.001c9.366-7.36 14.849-14.474 14.849-14.474z"/></svg>

+ 1 - 0
src/assets/icons/fullscreen.svg

@@ -0,0 +1 @@
+<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg"><path d="M38.47 52L52 38.462l-23.648-23.67L43.209 0H.035L0 43.137l14.757-14.865L38.47 52zm74.773 47.726L89.526 76 76 89.536l23.648 23.672L84.795 128h43.174L128 84.863l-14.757 14.863zM89.538 52l23.668-23.648L128 43.207V.038L84.866 0 99.73 14.76 76 38.472 89.538 52zM38.46 76L14.792 99.651 0 84.794v43.173l43.137.033-14.865-14.757L52 89.53 38.46 76z"/></svg>

+ 1 - 0
src/assets/icons/password.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1687143470427" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1288" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M509.12 336.32c-63.36 0-121.92 20.16-170.88 54.72v-91.2c0-96 76.8-174.72 170.88-174.72S680 203.84 680 299.84c0 14.4 11.52 25.92 24.96 25.92 14.4 0 24.96-11.52 24.96-25.92 0-124.8-99.84-226.56-221.76-226.56-120.96 0-220.8 101.76-220.8 226.56v136.32c-48.96 54.72-79.68 127.68-79.68 208.32 0 169.92 135.36 308.16 301.44 308.16 166.08 0 301.44-138.24 301.44-308.16 0-169.92-135.36-308.16-301.44-308.16z m0 564.48c-138.24 0-250.56-115.2-250.56-256.32s112.32-256.32 250.56-256.32c138.24 0 250.56 115.2 250.56 256.32 0 142.08-112.32 256.32-250.56 256.32z" p-id="1289"></path><path d="M509.12 561.92c-15.36 0-27.84 12.48-27.84 27.84v126.72c0 15.36 12.48 27.84 27.84 27.84s27.84-12.48 27.84-27.84V589.76c0.96-15.36-12.48-27.84-27.84-27.84z" p-id="1290"></path></svg>

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
src/assets/icons/student.svg


+ 1 - 0
src/assets/icons/teacher.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1686730645482" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2044" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M237.9634956 461.50958516A106.58732959 106.58732959 0 0 0 131.34628408 568.04681797V855.36257363A106.59699756 106.59699756 0 0 0 237.9634956 961.99033203h232.85579503a10.1512582 10.1512582 0 0 0 9.6502869-13.30473955l-156.52185029-480.16768448a10.1512582 10.1512582 0 0 0-9.65028691-7.00832284z m548.10289073 0h-76.26363223a10.1512582 10.1512582 0 0 0-9.65028691 7.00744395l-156.46296446 480.16856337a10.1512582 10.1512582 0 0 0 9.65028691 13.30473955H786.06638633a106.59699756 106.59699756 0 0 0 106.58732959-106.60754443V568.06703193a106.58732959 106.58732959 0 0 0-106.58732959-106.55744677z m26.31944355 334.72784356H677.49626622a14.93685088 14.93685088 0 0 1-1e-8-29.87194484h134.8781379a14.93685088 14.93685088 0 0 1-1e-8 29.87282374z m-103.33365381-537.26082013A196.92649424 196.92649424 0 0 0 512.04394443 62.00001758C403.30507607 62.00001758 315.00846758 150.09711612 315.00846758 258.97660859S403.2954081 455.94353164 512.04394443 455.94353164s197.00735273-88.25705771 197.00735274-196.96692305zM505.45749219 492.95387568l-84.53229346 70.94806435a10.1512582 10.1512582 0 0 0-3.12359941 10.92205109L502.34443965 834.11084902a10.1512582 10.1512582 0 0 0 19.3111207 1e-8l84.59381602-259.28597901a10.1512582 10.1512582 0 0 0-3.12447832-10.93171817l-84.59293711-70.94806523a10.1512582 10.1512582 0 0 0-13.07446875 0.00966797z m6.5275664-5.47552705" p-id="2045"></path></svg>

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
src/assets/icons/training.svg


+ 1 - 0
src/assets/icons/username.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1687143462995" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1152" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M524 714.8c-171.1 0-310.2-139.2-310.2-310.2S353 94.4 524 94.4s310.2 139.2 310.2 310.2S695 714.8 524 714.8z m0-557.1c-136.1 0-246.9 110.7-246.9 246.9S387.9 651.4 524 651.4c136.1 0 246.8-110.7 246.8-246.9S660.1 157.7 524 157.7z m148.3 773.2c-17.5 0-31.7-14.2-31.7-31.7 0-64.3-52.3-116.7-116.6-116.7s-116.7 52.3-116.7 116.7c0 17.5-14.2 31.7-31.7 31.7s-31.7-14.2-31.7-31.7c0-99.3 80.8-180 180-180s180 80.8 180 180c0.1 17.5-14.1 31.7-31.6 31.7z" p-id="1153"></path><path d="M524 567.5c-71.8 0-128-43.2-128-98.4 0-17.5 14.2-31.7 31.7-31.7s31.7 14.2 31.7 31.7c0 16.2 28.2 35.1 64.6 35.1 36.4 0 64.6-18.9 64.6-35.1 0-17.5 14.2-31.7 31.7-31.7s31.7 14.2 31.7 31.7c0 55.2-56.2 98.4-128 98.4z" p-id="1154"></path></svg>

BIN
src/assets/index/equipments.png


BIN
src/assets/index/grade.png


BIN
src/assets/index/students.png


BIN
src/assets/index/teachers.png


BIN
src/assets/index/trainings.png


BIN
src/assets/login/avatar.png


BIN
src/assets/login/login.jpg


BIN
src/assets/logo-icon.png


BIN
src/assets/logo.png


BIN
src/assets/student/stars.png


+ 103 - 0
src/components/Breadcrumb/index.vue

@@ -0,0 +1,103 @@
+<template>
+  <el-breadcrumb class="h-[50px] flex items-center">
+    <transition-group name="breadcrumb">
+      <el-breadcrumb-item v-for="(item, index) in breadcrumbs" :key="item.path">
+        <span
+          v-if="
+            item.redirect === 'noredirect' || index === breadcrumbs.length - 1
+          "
+          class="text-[var(--el-disabled-text-color)]"
+          >{{ translateRouteTitleI18n(item.meta.title) }}</span
+        >
+        <a v-else @click.prevent="handleLink(item)">
+          {{ translateRouteTitleI18n(item.meta.title) }}
+        </a>
+      </el-breadcrumb-item>
+    </transition-group>
+  </el-breadcrumb>
+</template>
+
+<script setup lang="ts">
+import { onBeforeMount, ref, watch } from "vue";
+import { useRoute, RouteLocationMatched } from "vue-router";
+import { compile } from "path-to-regexp";
+import router from "@/router";
+import { translateRouteTitleI18n } from "@/utils/i18n";
+
+const currentRoute = useRoute();
+const pathCompile = (path: string) => {
+  const { params } = currentRoute;
+  const toPath = compile(path);
+  return toPath(params);
+};
+
+const breadcrumbs = ref([] as Array<RouteLocationMatched>);
+
+function getBreadcrumb() {
+  let matched = currentRoute.matched.filter(
+    (item) => item.meta && item.meta.title
+  );
+  const first = matched[0];
+  if (!isDashboard(first)) {
+    matched = [
+      { path: "/dashboard", meta: { title: "dashboard" } } as any,
+    ].concat(matched);
+  }
+  breadcrumbs.value = matched.filter((item) => {
+    return item.meta && item.meta.title && item.meta.breadcrumb !== false;
+  });
+}
+
+function isDashboard(route: RouteLocationMatched) {
+  const name = route && route.name;
+  if (!name) {
+    return false;
+  }
+  return (
+    name.toString().trim().toLocaleLowerCase() ===
+    "Dashboard".toLocaleLowerCase()
+  );
+}
+
+function handleLink(item: any) {
+  const { redirect, path } = item;
+  if (redirect) {
+    router.push(redirect).catch((err) => {
+      console.warn(err);
+    });
+    return;
+  }
+  router.push(pathCompile(path)).catch((err) => {
+    console.warn(err);
+  });
+}
+
+watch(
+  () => currentRoute.path,
+  (path) => {
+    if (path.startsWith("/redirect/")) {
+      return;
+    }
+    getBreadcrumb();
+  }
+);
+
+onBeforeMount(() => {
+  getBreadcrumb();
+});
+</script>
+
+<style lang="scss" scoped>
+.app-breadcrumb.el-breadcrumb {
+  display: inline-block;
+  margin-left: 8px;
+  font-size: 14px;
+  line-height: 50px;
+}
+
+// 覆盖 element-plus 的样式
+.el-breadcrumb__inner,
+.el-breadcrumb__inner a {
+  font-weight: 400 !important;
+}
+</style>

+ 46 - 0
src/components/Hamburger/index.vue

@@ -0,0 +1,46 @@
+<template>
+  <div
+    class="px-[15px] hover:bg-gray-50 cursor-pointer h-[50px] leading-[50px] dark:hover:bg-[var(--el-fill-color-light)]"
+    @click="toggleClick"
+  >
+    <svg
+      :class="{ 'is-active': isActive }"
+      class="hamburger"
+      viewBox="0 0 1024 1024"
+      xmlns="http://www.w3.org/2000/svg"
+      style="color: #fff !important"
+    >
+      <path
+        d="M408 442h480c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H408c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8zm-8 204c0 4.4 3.6 8 8 8h480c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H408c-4.4 0-8 3.6-8 8v56zm504-486H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zm0 632H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zM142.4 642.1L298.7 519a8.84 8.84 0 0 0 0-13.9L142.4 381.9c-5.8-4.6-14.4-.5-14.4 6.9v246.3a8.9 8.9 0 0 0 14.4 7z"
+      />
+    </svg>
+  </div>
+</template>
+
+<script setup lang="ts">
+defineProps({
+  isActive: {
+    required: true,
+    type: Boolean,
+    default: false,
+  },
+});
+
+const emit = defineEmits(["toggleClick"]);
+
+function toggleClick() {
+  emit("toggleClick");
+}
+</script>
+
+<style lang="scss" scoped>
+.hamburger {
+  width: 20px;
+  height: 20px;
+  vertical-align: -4px;
+
+  &.is-active {
+    transform: rotate(180deg);
+  }
+}
+</style>

+ 88 - 0
src/components/Pagination/index.vue

@@ -0,0 +1,88 @@
+<template>
+  <div :class="'pagination ' + { hidden: hidden }">
+    <el-pagination
+      v-model:current-page="currentPage"
+      v-model:page-size="pageSize"
+      :background="background"
+      :layout="layout"
+      :page-sizes="pageSizes"
+      :total="total"
+      @size-change="handleSizeChange"
+      @current-change="handleCurrentChange"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, PropType } from "vue";
+import { scrollTo } from "@/utils/scroll-to";
+
+const props = defineProps({
+  total: {
+    required: true,
+    type: Number as PropType<number>,
+    default: 0,
+  },
+  page: {
+    type: Number,
+    default: 1,
+  },
+  limit: {
+    type: Number,
+    default: 20,
+  },
+  pageSizes: {
+    type: Array as PropType<number[]>,
+    default() {
+      return [10, 20, 30, 50];
+    },
+  },
+  layout: {
+    type: String,
+    default: "total, sizes, prev, pager, next, jumper",
+  },
+  background: {
+    type: Boolean,
+    default: true,
+  },
+  autoScroll: {
+    type: Boolean,
+    default: true,
+  },
+  hidden: {
+    type: Boolean,
+    default: false,
+  },
+});
+
+const emit = defineEmits(["pagination", "update:page", "update:limit"]);
+
+const currentPage = useVModel(props, "page", emit);
+
+const pageSize = useVModel(props, "limit", emit);
+
+function handleSizeChange(val: number) {
+  emit("pagination", { page: currentPage, limit: val });
+  if (props.autoScroll) {
+    scrollTo(0, 800);
+  }
+}
+
+function handleCurrentChange(val: number) {
+  currentPage.value = val;
+  emit("pagination", { page: val, limit: props.limit });
+  if (props.autoScroll) {
+    scrollTo(0, 800);
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.pagination {
+  padding: 12px;
+
+  &.hidden {
+    display: none;
+  }
+}
+</style>

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

@@ -0,0 +1,45 @@
+<template>
+  <svg
+    aria-hidden="true"
+    class="svg-icon"
+    :style="'width:' + size + ';height:' + size"
+  >
+    <use :xlink:href="symbolId" :fill="color" />
+  </svg>
+</template>
+
+<script setup lang="ts">
+const props = defineProps({
+  prefix: {
+    type: String,
+    default: "icon",
+  },
+  iconClass: {
+    type: String,
+    required: false,
+    default: "",
+  },
+  color: {
+    type: String,
+    default: "",
+  },
+  size: {
+    type: String,
+    default: "1em",
+  },
+});
+
+const symbolId = computed(() => `#${props.prefix}-${props.iconClass}`);
+</script>
+
+<style scoped>
+.svg-icon {
+  display: inline-block;
+  width: 1em;
+  height: 1em;
+  overflow: hidden;
+  vertical-align: -0.15em; /* 因icon大小被设置为和字体大小一致,而span等标签的下边缘会和字体的基线对齐,故需设置一个往下的偏移比例,来纠正视觉上的未对齐效果 */
+  outline: none;
+  fill: currentcolor; /* 定义元素的颜色,currentColor是一个变量,这个变量的值就表示当前元素的color值,如果当前元素未设置color值,则从父元素继承 */
+}
+</style>

+ 9 - 0
src/directive/index.ts

@@ -0,0 +1,9 @@
+import type { App } from "vue";
+
+import { hasPerm } from "./permission";
+
+// 全局注册 directive
+export function setupDirective(app: App<Element>) {
+  // 使 v-hasPerm 在所有组件中都可用
+  app.directive("hasPerm", hasPerm);
+}

+ 56 - 0
src/directive/permission/index.ts

@@ -0,0 +1,56 @@
+import { useUserStoreHook } from "@/store/modules/user";
+import { Directive, DirectiveBinding } from "vue";
+
+/**
+ * 按钮权限
+ */
+export const hasPerm: Directive = {
+  mounted(el: HTMLElement, binding: DirectiveBinding) {
+    // 「超级管理员」拥有所有的按钮权限
+    const { role, perms } = useUserStoreHook();
+    if (role == "ROOT") {
+      return true;
+    }
+    // 「其他角色」按钮权限校验
+    const { value } = binding;
+    if (value) {
+      const requiredPerms = value; // DOM绑定需要的按钮权限标识
+
+      const hasPerm = perms?.some((perm) => {
+        return requiredPerms.includes(perm);
+      });
+
+      if (!hasPerm) {
+        el.parentNode && el.parentNode.removeChild(el);
+      }
+    } else {
+      throw new Error(
+        "need perms! Like v-has-perm=\"['sys:user:add','sys:user:edit']\""
+      );
+    }
+  },
+};
+
+/**
+ * 角色权限
+ */
+export const hasRole: Directive = {
+  mounted(el: HTMLElement, binding: DirectiveBinding) {
+    const { value } = binding;
+
+    if (value) {
+      const requiredRoles = value; // DOM绑定需要的角色编码
+      const { role } = useUserStoreHook();
+      // const hasRole = roles.some(perm => {
+      //   return requiredRoles.includes(perm);
+      // });
+      const hasRole = requiredRoles.includes(role);
+
+      if (!hasRole) {
+        el.parentNode && el.parentNode.removeChild(el);
+      }
+    } else {
+      throw new Error("need roles! Like v-has-role=\"['admin','test']\"");
+    }
+  },
+};

+ 25 - 0
src/lang/index.ts

@@ -0,0 +1,25 @@
+import { createI18n } from "vue-i18n";
+import { useAppStore } from "@/store/modules/app";
+
+const appStore = useAppStore();
+// 本地语言包
+import enLocale from "./package/en";
+import zhCnLocale from "./package/zh-cn";
+
+const messages = {
+  "zh-cn": {
+    ...zhCnLocale,
+  },
+  en: {
+    ...enLocale,
+  },
+};
+
+const i18n = createI18n({
+  legacy: false,
+  locale: appStore.language,
+  messages: messages,
+  globalInjection: true,
+});
+
+export default i18n;

+ 22 - 0
src/lang/package/en.ts

@@ -0,0 +1,22 @@
+export default {
+  // 路由国际化
+  route: {
+    dashboard: "Dashboard",
+    document: "Document",
+  },
+  // 登录页面国际化
+  login: {
+    title: "shuimuai-dashboard-h5",
+    username: "Username",
+    password: "Password",
+    login: "Login",
+    verifyCode: "Verify Code",
+  },
+  // 导航栏国际化
+  navbar: {
+    dashboard: "Dashboard",
+    logout: "Logout",
+    document: "Document",
+    gitee: "Gitee",
+  },
+};

+ 22 - 0
src/lang/package/zh-cn.ts

@@ -0,0 +1,22 @@
+export default {
+  // 路由国际化
+  route: {
+    dashboard: "首页",
+    document: "项目文档",
+  },
+  // 登录页面国际化
+  login: {
+    title: "shuimuai-dashboard-h5",
+    username: "用户名",
+    password: "密码",
+    login: "登 录",
+    verifyCode: "验证码",
+  },
+  // 导航栏国际化
+  navbar: {
+    dashboard: "首页",
+    logout: "注销",
+    document: "项目文档",
+    gitee: "码云",
+  },
+};

+ 121 - 0
src/layout/admin.vue

@@ -0,0 +1,121 @@
+<script setup lang="ts">
+import { computed, watchEffect } from "vue";
+import { useWindowSize } from "@vueuse/core";
+import { AppMain, TagsView, AdminNavbar } from "./components/index";
+import Sidebar from "./components/Sidebar/index.vue";
+
+import { useAppStore } from "@/store/modules/app";
+import { useSettingsStore } from "@/store/modules/settings";
+
+const { width } = useWindowSize();
+
+/**
+ * 响应式布局容器固定宽度
+ *
+ * 大屏(>=1200px)
+ * 中屏(>=992px)
+ * 小屏(>=768px)
+ */
+const WIDTH = 992;
+
+const appStore = useAppStore();
+const settingsStore = useSettingsStore();
+const fixedHeader = computed(() => settingsStore.fixedHeader);
+const showTagsView = computed(() => settingsStore.tagsView);
+
+const classObj = computed(() => ({
+  hideSidebar: !appStore.sidebar.opened,
+  openSidebar: appStore.sidebar.opened,
+  withoutAnimation: appStore.sidebar.withoutAnimation,
+  mobile: appStore.device === "mobile",
+}));
+
+watchEffect(() => {
+  if (width.value < WIDTH) {
+    appStore.toggleDevice("mobile");
+    appStore.closeSideBar(true);
+  } else {
+    appStore.toggleDevice("desktop");
+
+    if (width.value >= 1200) {
+      //大屏
+      appStore.openSideBar(true);
+    } else {
+      appStore.closeSideBar(true);
+    }
+  }
+});
+
+function handleOutsideClick() {
+  appStore.closeSideBar(false);
+}
+</script>
+
+<template>
+  <div :class="classObj" class="app-wrapper">
+    <!-- 手机设备侧边栏打开遮罩层 -->
+    <div
+      v-if="classObj.mobile && classObj.openSidebar"
+      class="drawer-bg"
+      @click="handleOutsideClick"
+    ></div>
+
+    <Sidebar class="sidebar-container" />
+
+    <div :class="{ hasTagsView: showTagsView }" class="main-container">
+      <div :class="{ 'fixed-header': fixedHeader }">
+        <AdminNavbar />
+        <tags-view v-if="showTagsView" />
+      </div>
+
+      <!--主页面-->
+      <app-main />
+    </div>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.app-wrapper {
+  &::after {
+    display: table;
+    clear: both;
+    content: "";
+  }
+
+  position: relative;
+  width: 100%;
+  height: 100%;
+
+  &.mobile.openSidebar {
+    position: fixed;
+    top: 0;
+  }
+}
+
+.fixed-header {
+  position: fixed;
+  top: 0;
+  right: 0;
+  z-index: 9;
+  width: calc(100% - #{$sideBarWidth});
+  transition: width 0.28s;
+}
+
+.hideSidebar .fixed-header {
+  width: calc(100% - 54px);
+}
+
+.mobile .fixed-header {
+  width: 100%;
+}
+
+.drawer-bg {
+  position: absolute;
+  top: 0;
+  z-index: 999;
+  width: 100%;
+  height: 100%;
+  background: #000;
+  opacity: 0.3;
+}
+</style>

+ 44 - 0
src/layout/components/AppMain.vue

@@ -0,0 +1,44 @@
+<script setup lang="ts">
+import { useTagsViewStore } from "@/store/modules/tagsView";
+
+const tagsViewStore = useTagsViewStore();
+</script>
+
+<template>
+  <section class="app-main">
+    <router-view v-slot="{ Component, route }">
+      <transition name="router-fade" mode="out-in">
+        <keep-alive :include="tagsViewStore.cachedViews">
+          <component :is="Component" :key="route.fullPath" />
+        </keep-alive>
+      </transition>
+    </router-view>
+  </section>
+</template>
+
+<style lang="scss" scoped>
+.app-main {
+  position: relative;
+  width: 100%;
+  /* 50= navbar  50  */
+  min-height: calc(100vh - 50px);
+  overflow: hidden;
+  background-color: #f3f6fd;
+}
+
+.fixed-header + .app-main {
+  padding-top: 50px;
+}
+
+.hasTagsView {
+  .app-main {
+    /* 84 = navbar + tags-view = 50 + 34 */
+    min-height: calc(100vh - 84px);
+  }
+
+  .fixed-header + .app-main {
+    min-height: 100vh;
+    padding-top: 84px;
+  }
+}
+</style>

+ 45 - 0
src/layout/components/Navbar/Admin/index.vue

@@ -0,0 +1,45 @@
+<script setup lang="ts">
+import { useAppStore } from "@/store/modules/app";
+import Fullscreen from "@/layout/components/Navbar/Fullscreen.vue";
+import UserInfo from "@/layout/components/Navbar/UserInfo.vue";
+
+const appStore = useAppStore();
+/**
+ * 左侧菜单栏显示/隐藏
+ */
+function toggleSideBar() {
+  appStore.toggleSidebar(true);
+}
+</script>
+
+<template>
+  <!-- 顶部导航栏 -->
+  <div class="navbar">
+    <!-- 左侧面包屑 -->
+    <div class="flex">
+      <Hamburger
+        :is-active="appStore.sidebar.opened"
+        @toggle-click="toggleSideBar"
+      />
+      <Breadcrumb />
+    </div>
+    <!-- 右侧导航设置 -->
+    <div class="flex">
+      <!-- 导航栏设置(窄屏隐藏),全屏等-->
+      <Fullscreen />
+      <!-- 用户信息 -->
+      <UserInfo />
+    </div>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.navbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  height: 70px;
+  background-color: #fff;
+  box-shadow: 0 0 1px #0003;
+}
+</style>

+ 44 - 0
src/layout/components/Navbar/Fullscreen.vue

@@ -0,0 +1,44 @@
+<script setup lang="ts">
+import SvgIcon from "@/components/SvgIcon/index.vue";
+import { storeToRefs } from "pinia";
+import { useAppStore } from "@/store/modules/app";
+
+const appStore = useAppStore();
+const { device } = storeToRefs(appStore); // 设备类型:desktop-宽屏设备 || mobile-窄屏设备
+/**
+ * vueUse 全屏
+ */
+const { isFullscreen, toggle } = useFullscreen();
+</script>
+
+<template>
+  <!-- 导航栏设置(窄屏隐藏)-->
+  <div v-if="device !== 'mobile'" class="setting-container">
+    <!--全屏 -->
+    <div class="setting-item" @click="toggle">
+      <svg-icon
+        :icon-class="isFullscreen ? 'exit-fullscreen' : 'fullscreen'"
+        size="16px"
+      />
+    </div>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.setting-container {
+  display: flex;
+  align-items: center;
+  .setting-item {
+    display: inline-block;
+    width: 30px;
+    height: 70px;
+    line-height: 70px;
+    color: #5a5e66;
+    text-align: center;
+    cursor: pointer;
+    &:hover {
+      background: rgb(249 250 251 / 100%);
+    }
+  }
+}
+</style>

+ 48 - 0
src/layout/components/Navbar/School/index.vue

@@ -0,0 +1,48 @@
+<script setup lang="ts">
+import { useAppStore } from "@/store/modules/app";
+import Fullscreen from "@/layout/components/Navbar/Fullscreen.vue";
+import SchoolSelect from "@/layout/components/Navbar/SchoolSelect.vue";
+import UserInfo from "@/layout/components/Navbar/UserInfo.vue";
+
+const appStore = useAppStore();
+/**
+ * 左侧菜单栏显示/隐藏
+ */
+function toggleSideBar() {
+  appStore.toggleSidebar(true);
+}
+</script>
+
+<template>
+  <!-- 顶部导航栏 -->
+  <div class="navbar">
+    <!-- 左侧面包屑 -->
+    <div class="flex">
+      <Hamburger
+        :is-active="appStore.sidebar.opened"
+        @toggle-click="toggleSideBar"
+      />
+      <Breadcrumb />
+    </div>
+    <!-- 右侧导航设置 -->
+    <div class="flex">
+      <!-- 导航栏设置(窄屏隐藏),全屏等-->
+      <Fullscreen />
+      <!-- 学校选择 -->
+      <SchoolSelect />
+      <!-- 用户信息 -->
+      <UserInfo />
+    </div>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.navbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  height: 70px;
+  background-color: #fff;
+  box-shadow: 0 0 1px #0003;
+}
+</style>

+ 119 - 0
src/layout/components/Navbar/SchoolSelect.vue

@@ -0,0 +1,119 @@
+<script setup lang="ts">
+import { SchoolList } from "@/api/school/types";
+import { getSchoolSelect } from "@/api/school";
+import { watch } from "vue";
+import { useUserStore } from "@/store/modules/user";
+
+const userStore = useUserStore();
+/**
+ * 学校数据
+ */
+const schoolData = ref<SchoolList[]>();
+const schoolId = ref(0);
+const schoolNum = ref("");
+async function getSchoolData() {
+  getSchoolSelect()
+    .then(({ data }) => {
+      schoolData.value = data;
+      if (schoolId.value == 0) {
+        schoolId.value = data[0].school_id;
+        schoolNum.value = data[0].num;
+        userStore.changeSchool(schoolId.value, schoolNum.value);
+      }
+    })
+    .catch((error) => {
+      console.log(error);
+    });
+}
+onMounted(() => {
+  getSchoolData();
+});
+watch(
+  () => schoolId.value,
+  (newValue) => {
+    let num: string = "";
+    schoolData.value?.some((school) => {
+      if (newValue == school.school_id) {
+        num = school.num;
+        return true;
+      }
+    });
+    userStore.changeSchool(newValue, num);
+  }
+);
+</script>
+<template>
+  <!-- 学校选择下拉框 -->
+  <div class="nav-select">
+    <el-select v-model="schoolId" size="large" placeholder="请选择学校">
+      <el-option
+        v-for="item in schoolData"
+        :key="item.school_id"
+        :label="item.name"
+        :value="item.school_id"
+      />
+    </el-select>
+    <span class="school">学校编码:{{ userStore.schoolNum }}</span>
+  </div>
+</template>
+<style scoped lang="scss">
+.nav-select {
+  display: flex;
+  align-items: center;
+  justify-items: center;
+  .school {
+    padding-left: 15px;
+  }
+}
+.svg-icon {
+  margin-bottom: -2px;
+}
+.el-select {
+  padding: 15px 0;
+  width: 280px;
+  margin-left: 12px;
+}
+//移动端兼容
+.mobile {
+  .navbar {
+    .nav-select {
+      position: absolute;
+      left: 0;
+      top: 80px;
+      z-index: 1;
+      width: 100%;
+      box-sizing: border-box;
+      padding: 0 24px;
+      .el-select {
+        width: 100%;
+        padding: 0;
+        margin: 0;
+      }
+      :deep(.el-input__wrapper) {
+        background: #ffffff;
+      }
+    }
+  }
+}
+/* 自定义 el-select 样式 */
+:deep(.el-input__wrapper) {
+  background: #efefef;
+  border-radius: 12px;
+}
+/* el-select 各种边框线隐藏**/
+:deep(.el-select) {
+  --el-select-input-focus-border-color: none !important;
+}
+:deep(.el-input__wrapper) {
+  box-shadow: none !important;
+}
+:deep(.el-select .el-input.is-focus .el-input__wrapper) {
+  box-shadow: none !important;
+}
+:deep(.el-select .el-input__wrapper.is-focus) {
+  box-shadow: none !important;
+}
+:deep(.el-select:hover:not(.el-select--disabled) .el-input__wrapper) {
+  box-shadow: none !important;
+}
+</style>

+ 69 - 0
src/layout/components/Navbar/UserInfo.vue

@@ -0,0 +1,69 @@
+<script setup lang="ts">
+import SvgIcon from "@/components/SvgIcon/index.vue";
+import { useUserStore } from "@/store/modules/user";
+import { useTagsViewStore } from "@/store/modules/tagsView";
+import { useRoute, useRouter } from "vue-router";
+
+const userStore = useUserStore();
+const tagsViewStore = useTagsViewStore();
+const route = useRoute();
+const router = useRouter();
+/**
+ * 注销
+ */
+function logout() {
+  ElMessageBox.confirm("确定退出系统吗?", "提示", {
+    confirmButtonText: "确定",
+    cancelButtonText: "取消",
+    type: "warning",
+  }).then(() => {
+    userStore
+      .logout()
+      .then(() => {
+        tagsViewStore.delAllViews();
+      })
+      .then(() => {
+        router.push(`/login?redirect=${route.fullPath}`);
+      });
+  });
+}
+</script>
+
+<template>
+  <!-- 用户头像 -->
+  <div class="avatar-container">
+    <span class="spl">|</span>
+    <img src="../../../assets/login/avatar.png" alt="头像" />
+    <span class="">{{ userStore.nickname + " " + userStore.phone }}</span>
+    <span @click="logout"
+      ><svg-icon icon-class="exit" color="#006eff" size="20px"
+    /></span>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.avatar-container {
+  display: flex;
+  align-items: center;
+  justify-items: center;
+  margin: 0 20px 0 0;
+  cursor: pointer;
+  img {
+    width: 40px;
+    height: 40px;
+    line-height: 40px;
+    text-align: center;
+    border-radius: 20px;
+    background: #494949;
+  }
+  span {
+    margin-left: 12px;
+    font-size: 16px;
+    color: #494949;
+    &.spl {
+      margin: 0 12px 0 12px;
+      color: #d7d8d8;
+    }
+  }
+}
+</style>

+ 39 - 0
src/layout/components/Sidebar/Link.vue

@@ -0,0 +1,39 @@
+<script lang="ts" setup>
+import { computed } from "vue";
+import { isExternal } from "@/utils";
+import { useRouter } from "vue-router";
+
+import { useAppStore } from "@/store/modules/app";
+const appStore = useAppStore();
+
+const sidebar = computed(() => appStore.sidebar);
+const device = computed(() => appStore.device);
+
+const props = defineProps({
+  to: {
+    type: String,
+    required: true,
+  },
+});
+
+const router = useRouter();
+function push() {
+  if (device.value === "mobile" && sidebar.value.opened == true) {
+    appStore.closeSideBar(false);
+  }
+  if (props.to) {
+    router.push(props.to).catch((err) => {
+      console.error(err);
+    });
+  }
+}
+</script>
+
+<template>
+  <a v-if="isExternal(to)" :href="to" target="_blank" rel="noopener">
+    <slot />
+  </a>
+  <div v-else @click="push">
+    <slot />
+  </div>
+</template>

+ 56 - 0
src/layout/components/Sidebar/Logo.vue

@@ -0,0 +1,56 @@
+<script lang="ts" setup>
+import { useSettingsStore } from "@/store/modules/settings";
+
+const settingsStore = useSettingsStore();
+
+defineProps({
+  collapse: {
+    type: Boolean,
+    required: true,
+  },
+});
+
+const logo = ref(new URL(`../../../assets/logo.png`, import.meta.url).href);
+const logoIco = ref(
+  new URL(`../../../assets/logo-icon.png`, import.meta.url).href
+);
+</script>
+
+<template>
+  <div class="w-full">
+    <transition name="sidebarLogoFade">
+      <router-link
+        v-if="collapse"
+        key="collapse"
+        class="logo w-full flex items-center justify-center"
+        to="/"
+      >
+        <img v-if="settingsStore.sidebarLogo" :src="logoIco" class="h-11" />
+      </router-link>
+
+      <router-link
+        v-else
+        key="expand"
+        class="logo w-full flex items-center justify-center"
+        to="/"
+      >
+        <img v-if="settingsStore.sidebarLogo" :src="logo" class="h-11" />
+      </router-link>
+    </transition>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.sidebarLogoFade-enter-active {
+  transition: opacity 2s;
+}
+.sidebarLogoFade-leave-active,
+.sidebarLogoFade-enter-from,
+.sidebarLogoFade-leave-to {
+  opacity: 0;
+}
+.logo {
+  height: 70px;
+  line-height: 70px;
+}
+</style>

+ 163 - 0
src/layout/components/Sidebar/SidebarItem.vue

@@ -0,0 +1,163 @@
+<script setup lang="ts">
+import { useRouter } from "vue-router";
+import path from "path-browserify";
+import { isExternal } from "@/utils";
+import AppLink from "./Link.vue";
+import SvgIcon from "@/components/SvgIcon/index.vue";
+import { SidebarRoutes } from "@/layout/components/Sidebar/types";
+
+const router = useRouter();
+const props = defineProps({
+  /**
+   * 路由(eg:level_3_1)
+   */
+  item: {
+    type: Object,
+    required: true,
+  },
+  /**
+   * 父层级完整路由路径(eg:/level/level_3/level_3_1)
+   */
+  basePath: {
+    type: String,
+    required: true,
+  },
+});
+const itemRoute: SidebarRoutes = reactive(props.item);
+const onlyOneChild = ref(); // 临时变量,唯一子路由
+/**
+ * 判断当前路由是否只有一个子路由
+ *
+ * 1:如果只有一个子路由: 返回 true
+ * 2:如果无子路由 :返回 true
+ *
+ * @param children 子路由数组
+ * @param parent 当前路由
+ */
+function hasOneShowingChild(children: any = [], parent: any) {
+  // 需要显示的子路由数组
+  const showingChildren: any = children.filter((item: any) => {
+    if (item.meta?.hidden) {
+      return false; // 过滤不显示的子路由
+    } else {
+      onlyOneChild.value = item; // 唯一子路由赋值(多个子路由情况 onlyOneChild 变量是用不上的)
+      return true;
+    }
+  });
+  // 1:如果无显示的子路由, 复制当前路由信息作为其子路由,满足只拥有一个子路由的条件,所以返回 true
+  if (showingChildren.length === 0) {
+    onlyOneChild.value = { ...parent, path: "", noShowingChildren: true };
+    return true;
+  }
+  // 2:如果只有一个显示的子路由, 判断子路由children
+  if (showingChildren.length === 1) {
+    if (showingChildren[0].children) {
+      // 是否还有可显示的子路由
+      if (hasOneShowingChild(showingChildren[0].children, showingChildren[0])) {
+        return true;
+      }
+    }
+    return true;
+  }
+  return false;
+}
+
+/**
+ * 解析路径
+ *
+ * @param routePath 路由路径
+ */
+function resolvePath(routePath: string): string {
+  if (isExternal(routePath)) {
+    return routePath;
+  }
+  if (isExternal(props.basePath || "")) {
+    return props.basePath || "";
+  }
+  // 完整路径 = 父级路径(/level/level_3) + 路由路径
+  // return fullPath 相对路径 → 绝对路径
+  return path.resolve(props.basePath || "", routePath);
+}
+
+/**
+ * 处理子页面时父级菜单高亮
+ */
+function addActiveClass(routePath: string) {
+  const currentBase: string = routePath.split("/")[1];
+  document.querySelectorAll(".el-menu-item").forEach((item) => {
+    const pathBase: string = item.attributes
+      .getNamedItem("mark")
+      .value.split("/")[1];
+    if (currentBase == pathBase) {
+      const active = document.querySelectorAll(".el-menu-item.is-active");
+      if (active && active.length) {
+        active.forEach((a) => {
+          if (a) {
+            a.classList.remove("is-active");
+          }
+        });
+      }
+      item.classList.add("is-active");
+      //console.log(currentBase, "is-active current路由");
+      return;
+    }
+  });
+}
+
+router.afterEach((to, from) => {
+  //console.log(from.fullPath, to.fullPath, "路由变动");
+  setTimeout(() => {
+    addActiveClass(to.fullPath);
+  }, 100);
+});
+if (onMounted) {
+  onMounted(() => {
+    addActiveClass(router.currentRoute.value.fullPath);
+    //console.log(router.currentRoute.value.fullPath,"当前路由");
+  });
+}
+</script>
+<template>
+  <div v-if="!itemRoute.meta || !itemRoute.meta.hidden">
+    <template
+      v-if="
+        hasOneShowingChild(itemRoute.children, itemRoute) &&
+        (!onlyOneChild.children || onlyOneChild.noShowingChildren)
+      "
+    >
+      <!-- 只包含一个子路由节点的路由,显示其【唯一子路由】 -->
+      <app-link v-if="onlyOneChild.meta" :to="resolvePath(onlyOneChild.path)">
+        <el-menu-item
+          :index="resolvePath(onlyOneChild.path)"
+          :mark="resolvePath(onlyOneChild.path)"
+        >
+          <svg-icon
+            v-if="onlyOneChild.meta && onlyOneChild.meta.icon"
+            :icon-class="onlyOneChild.meta.icon"
+          />
+          <template #title>
+            <span>{{ onlyOneChild.meta.title }}</span>
+          </template>
+        </el-menu-item>
+      </app-link>
+    </template>
+    <el-sub-menu v-else :index="resolvePath(itemRoute.path)" teleported>
+      <!-- 包含多个子路由 $route.path  -->
+      <template #title>
+        <svg-icon
+          v-if="itemRoute.meta && itemRoute.meta.icon"
+          :icon-class="itemRoute.meta.icon"
+        />
+        <span v-if="itemRoute.meta && itemRoute.meta.title">{{
+          itemRoute.meta.title
+        }}</span>
+      </template>
+      <sidebar-item
+        v-for="child in itemRoute.children"
+        :key="child.path"
+        :item="child"
+        :base-path="resolvePath(child.path)"
+      />
+    </el-sub-menu>
+  </div>
+</template>

+ 41 - 0
src/layout/components/Sidebar/index.vue

@@ -0,0 +1,41 @@
+<script setup lang="ts">
+import { useRoute } from "vue-router";
+import SidebarItem from "./SidebarItem.vue";
+import Logo from "./Logo.vue";
+
+import { useSettingsStore } from "@/store/modules/settings";
+import { usePermissionStore } from "@/store/modules/permission";
+import { useAppStore } from "@/store/modules/app";
+import { storeToRefs } from "pinia";
+import variables from "@/styles/variables.module.scss";
+
+const settingsStore = useSettingsStore();
+const permissionStore = usePermissionStore();
+const appStore = useAppStore();
+const currRoute = useRoute();
+const { sidebarLogo } = storeToRefs(settingsStore);
+</script>
+
+<template>
+  <div :class="{ 'has-logo': sidebarLogo }">
+    <logo v-if="sidebarLogo" :collapse="!appStore.sidebar.opened" />
+    <el-scrollbar>
+      <el-menu
+        size="large"
+        :default-active="currRoute.path"
+        :collapse="!appStore.sidebar.opened"
+        :unique-opened="false"
+        :collapse-transition="false"
+        mode="vertical"
+      >
+        <sidebar-item
+          v-for="route in permissionStore.routes"
+          :key="route.path"
+          :item="route"
+          :base-path="route.path"
+          :is-collapse="!appStore.sidebar.opened"
+        />
+      </el-menu>
+    </el-scrollbar>
+  </div>
+</template>

+ 9 - 0
src/layout/components/Sidebar/types.ts

@@ -0,0 +1,9 @@
+export interface SidebarRoutes {
+  meta: {
+    icon: string;
+    title: string;
+    hidden: boolean;
+  };
+  path: string;
+  children: SidebarRoutes[];
+}

+ 121 - 0
src/layout/components/TagsView/ScrollPane.vue

@@ -0,0 +1,121 @@
+<script setup lang="ts">
+import { useTagsViewStore, TagView } from "@/store/modules/tagsView";
+
+const tagAndTagSpacing = ref(4);
+const { proxy } = getCurrentInstance() as any;
+
+const emits = defineEmits(["scroll"]);
+const emitScroll = () => {
+  emits("scroll");
+};
+
+const tagsViewStore = useTagsViewStore();
+
+const scrollWrapper = computed(
+  () => proxy?.$refs.scrollContainer.$refs.wrapRef
+);
+
+onMounted(() => {
+  scrollWrapper.value.addEventListener("scroll", emitScroll, true);
+});
+onBeforeUnmount(() => {
+  scrollWrapper.value.removeEventListener("scroll", emitScroll);
+});
+
+function handleScroll(e: WheelEvent) {
+  const eventDelta = (e as any).wheelDelta || -e.deltaY * 40;
+  scrollWrapper.value.scrollLeft =
+    scrollWrapper.value.scrollLeft + eventDelta / 4;
+}
+
+function moveToTarget(currentTag: TagView) {
+  const $container = proxy.$refs.scrollContainer.$el;
+  const $containerWidth = $container.offsetWidth;
+  const $scrollWrapper = scrollWrapper.value;
+
+  let firstTag = null;
+  let lastTag = null;
+
+  // find first tag and last tag
+  if (tagsViewStore.visitedViews.length > 0) {
+    firstTag = tagsViewStore.visitedViews[0];
+    lastTag = tagsViewStore.visitedViews[tagsViewStore.visitedViews.length - 1];
+  }
+
+  if (firstTag === currentTag) {
+    $scrollWrapper.scrollLeft = 0;
+  } else if (lastTag === currentTag) {
+    $scrollWrapper.scrollLeft = $scrollWrapper.scrollWidth - $containerWidth;
+  } else {
+    const tagListDom = document.getElementsByClassName("tags-item");
+    const currentIndex = tagsViewStore.visitedViews.findIndex(
+      (item) => item === currentTag
+    );
+    let prevTag = null;
+    let nextTag = null;
+    for (const k in tagListDom) {
+      if (k !== "length" && Object.hasOwnProperty.call(tagListDom, k)) {
+        if (
+          (tagListDom[k] as any).dataset.path ===
+          tagsViewStore.visitedViews[currentIndex - 1].path
+        ) {
+          prevTag = tagListDom[k];
+        }
+        if (
+          (tagListDom[k] as any).dataset.path ===
+          tagsViewStore.visitedViews[currentIndex + 1].path
+        ) {
+          nextTag = tagListDom[k];
+        }
+      }
+    }
+
+    // the tag's offsetLeft after of nextTag
+    const afterNextTagOffsetLeft =
+      (nextTag as any).offsetLeft +
+      (nextTag as any).offsetWidth +
+      tagAndTagSpacing.value;
+
+    // the tag's offsetLeft before of prevTag
+    const beforePrevTagOffsetLeft =
+      (prevTag as any).offsetLeft - tagAndTagSpacing.value;
+    if (afterNextTagOffsetLeft > $scrollWrapper.scrollLeft + $containerWidth) {
+      $scrollWrapper.scrollLeft = afterNextTagOffsetLeft - $containerWidth;
+    } else if (beforePrevTagOffsetLeft < $scrollWrapper.scrollLeft) {
+      $scrollWrapper.scrollLeft = beforePrevTagOffsetLeft;
+    }
+  }
+}
+
+defineExpose({
+  moveToTarget,
+});
+</script>
+
+<template>
+  <el-scrollbar
+    ref="scrollContainer"
+    class="scroll-container"
+    :vertical="false"
+    @wheel.prevent="handleScroll"
+  >
+    <slot />
+  </el-scrollbar>
+</template>
+
+<style lang="scss" scoped>
+.scroll-container {
+  position: relative;
+  width: 100%;
+  overflow: hidden;
+  white-space: nowrap;
+
+  .el-scrollbar__bar {
+    bottom: 0;
+  }
+
+  .el-scrollbar__wrap {
+    height: 49px;
+  }
+}
+</style>

+ 368 - 0
src/layout/components/TagsView/index.vue

@@ -0,0 +1,368 @@
+<script setup lang="ts">
+import {
+  getCurrentInstance,
+  nextTick,
+  ref,
+  watch,
+  onMounted,
+  ComponentInternalInstance,
+} from "vue";
+import { storeToRefs } from "pinia";
+import path from "path-browserify";
+import { useRoute, useRouter } from "vue-router";
+import { usePermissionStore } from "@/store/modules/permission";
+import { useTagsViewStore, TagView } from "@/store/modules/tagsView";
+import ScrollPane from "./ScrollPane.vue";
+
+const { proxy } = getCurrentInstance() as ComponentInternalInstance;
+const router = useRouter();
+const route = useRoute();
+
+const permissionStore = usePermissionStore();
+const tagsViewStore = useTagsViewStore();
+
+const { visitedViews } = storeToRefs(tagsViewStore);
+
+const selectedTag = ref({});
+const scrollPaneRef = ref();
+const left = ref(0);
+const top = ref(0);
+const affixTags = ref<TagView[]>([]);
+
+watch(
+  route,
+  () => {
+    addTags();
+    moveToCurrentTag();
+  },
+  {
+    //初始化立即执行
+    immediate: true,
+  }
+);
+
+const tagMenuVisible = ref(false); // 标签操作菜单显示状态
+watch(tagMenuVisible, (value) => {
+  if (value) {
+    document.body.addEventListener("click", closeTagMenu);
+  } else {
+    document.body.removeEventListener("click", closeTagMenu);
+  }
+});
+
+function filterAffixTags(routes: any[], basePath = "/") {
+  let tags: TagView[] = [];
+
+  routes.forEach((route) => {
+    if (route.meta && route.meta.affix) {
+      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);
+      if (childTags.length >= 1) {
+        tags = tags.concat(childTags);
+      }
+    }
+  });
+  return tags;
+}
+
+function initTags() {
+  const tags: TagView[] = filterAffixTags(permissionStore.routes);
+  affixTags.value = tags;
+  for (const tag of tags) {
+    // Must have tag name
+    if (tag.name) {
+      tagsViewStore.addVisitedView(tag);
+    }
+  }
+}
+
+function addTags() {
+  if (route.name) {
+    tagsViewStore.addView(route);
+  }
+}
+
+function moveToCurrentTag() {
+  nextTick(() => {
+    for (const r of tagsViewStore.visitedViews) {
+      if (r.path === route.path) {
+        scrollPaneRef.value.moveToTarget(r);
+        // when query is different then update
+        if (r.fullPath !== route.fullPath) {
+          tagsViewStore.updateVisitedView(route);
+        }
+      }
+    }
+  });
+}
+
+function isActive(tag: TagView) {
+  return tag.path === route.path;
+}
+
+function isAffix(tag: TagView) {
+  return tag.meta && tag.meta.affix;
+}
+
+function isFirstView() {
+  try {
+    return (
+      (selectedTag.value as TagView).fullPath ===
+        tagsViewStore.visitedViews[1].fullPath ||
+      (selectedTag.value as TagView).fullPath === "/index"
+    );
+  } catch (err) {
+    return false;
+  }
+}
+
+function isLastView() {
+  try {
+    return (
+      (selectedTag.value as TagView).fullPath ===
+      tagsViewStore.visitedViews[tagsViewStore.visitedViews.length - 1].fullPath
+    );
+  } catch (err) {
+    return false;
+  }
+}
+
+function refreshSelectedTag(view: TagView) {
+  tagsViewStore.delCachedView(view);
+  const { fullPath } = view;
+  nextTick(() => {
+    router.replace({ path: "/redirect" + fullPath }).catch((err) => {
+      console.warn(err);
+    });
+  });
+}
+
+function toLastView(visitedViews: TagView[], view?: any) {
+  const latestView = visitedViews.slice(-1)[0];
+  if (latestView && latestView.fullPath) {
+    router.push(latestView.fullPath);
+  } else {
+    // now the default is to redirect to the home page if there is no tags-view,
+    // you can adjust it according to your needs.
+    if (view.name === "Dashboard") {
+      // to reload home page
+      router.replace({ path: "/redirect" + view.fullPath });
+    } else {
+      router.push("/");
+    }
+  }
+}
+
+function closeSelectedTag(view: TagView) {
+  tagsViewStore.delView(view).then((res: any) => {
+    if (isActive(view)) {
+      toLastView(res.visitedViews, view);
+    }
+  });
+}
+
+function closeLeftTags() {
+  tagsViewStore.delLeftViews(selectedTag.value).then((res: any) => {
+    if (
+      !res.visitedViews.find((item: any) => item.fullPath === route.fullPath)
+    ) {
+      toLastView(res.visitedViews);
+    }
+  });
+}
+function closeRightTags() {
+  tagsViewStore.delRightViews(selectedTag.value).then((res: any) => {
+    if (
+      !res.visitedViews.find((item: any) => item.fullPath === route.fullPath)
+    ) {
+      toLastView(res.visitedViews);
+    }
+  });
+}
+
+function closeOtherTags() {
+  router.push(selectedTag.value);
+  tagsViewStore.delOtherViews(selectedTag.value).then(() => {
+    moveToCurrentTag();
+  });
+}
+
+function closeAllTags(view: TagView) {
+  tagsViewStore.delAllViews().then((res: any) => {
+    toLastView(res.visitedViews, view);
+  });
+}
+
+function openTagMenu(tag: TagView, e: MouseEvent) {
+  const menuMinWidth = 105;
+
+  console.log("test", proxy?.$el);
+
+  const offsetLeft = proxy?.$el.getBoundingClientRect().left; // container margin left
+  const offsetWidth = proxy?.$el.offsetWidth; // container width
+  const maxLeft = offsetWidth - menuMinWidth; // left boundary
+  const l = e.clientX - offsetLeft + 15; // 15: margin right
+
+  if (l > maxLeft) {
+    left.value = maxLeft;
+  } else {
+    left.value = l;
+  }
+
+  top.value = e.clientY;
+  tagMenuVisible.value = true;
+  selectedTag.value = tag;
+}
+
+function closeTagMenu() {
+  tagMenuVisible.value = false;
+}
+
+function handleScroll() {
+  closeTagMenu();
+}
+
+onMounted(() => {
+  initTags();
+});
+</script>
+
+<template>
+  <div class="tags-container">
+    <scroll-pane ref="scrollPaneRef" @scroll="handleScroll">
+      <router-link
+        v-for="tag in visitedViews"
+        :key="tag.path"
+        :class="'tags-item ' + (isActive(tag) ? 'active' : '')"
+        :data-path="tag.path"
+        :to="{ path: tag.path, query: tag.query }"
+        @click.middle="!isAffix(tag) ? closeSelectedTag(tag) : ''"
+        @contextmenu.prevent="openTagMenu(tag, $event)"
+      >
+        {{ tag.meta?.title }}
+        <span
+          v-if="!isAffix(tag)"
+          class="tags-item-close"
+          @click.prevent.stop="closeSelectedTag(tag)"
+        >
+          <i-ep-close class="text-[10px]" />
+        </span>
+      </router-link>
+    </scroll-pane>
+
+    <!-- tag标签操作菜单 -->
+    <ul
+      v-show="tagMenuVisible"
+      class="tag-menu"
+      :style="{ left: left + 'px', top: top + 'px' }"
+    >
+      <li @click="refreshSelectedTag(selectedTag)">
+        <svg-icon icon-class="refresh" />
+        刷新
+      </li>
+      <li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)">
+        <svg-icon icon-class="close" />
+        关闭
+      </li>
+      <li @click="closeOtherTags">
+        <svg-icon icon-class="close_other" />
+        关闭其它
+      </li>
+      <li v-if="!isFirstView()" @click="closeLeftTags">
+        <svg-icon icon-class="close_left" />
+        关闭左侧
+      </li>
+      <li v-if="!isLastView()" @click="closeRightTags">
+        <svg-icon icon-class="close_right" />
+        关闭右侧
+      </li>
+      <li @click="closeAllTags(selectedTag)">
+        <svg-icon icon-class="close_all" />
+        关闭所有
+      </li>
+    </ul>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.tags-container {
+  width: 100%;
+  height: 34px;
+  background-color: var(--el-bg-color);
+  border: 1px solid var(--el-border-color-light);
+  box-shadow: 0 1px 1px var(--el-box-shadow-light);
+
+  .tags-item {
+    display: inline-block;
+    padding: 3px 8px;
+    margin: 4px 0 0 5px;
+    font-size: 12px;
+    cursor: pointer;
+    border: 1px solid var(--el-border-color-light);
+
+    &:first-of-type {
+      margin-left: 15px;
+    }
+
+    &:last-of-type {
+      margin-right: 15px;
+    }
+
+    &:hover {
+      color: var(--el-color-primary);
+    }
+
+    &.active {
+      color: #fff;
+      background-color: var(--el-color-primary);
+      border-color: var(--el-color-primary);
+
+      &::before {
+        display: inline-block;
+        width: 8px;
+        height: 8px;
+        margin-right: 5px;
+        content: "";
+        background: #fff;
+        border-radius: 50%;
+      }
+    }
+
+    &-close {
+      border-radius: 100%;
+
+      &:hover {
+        color: #fff;
+        background: rgb(0 0 0 / 16%);
+      }
+    }
+  }
+}
+
+.tag-menu {
+  position: absolute;
+  z-index: 99;
+  font-size: 12px;
+  background: var(--el-bg-color-overlay);
+  border-radius: 4px;
+  box-shadow: var(--el-box-shadow-light);
+
+  li {
+    padding: 8px 16px;
+    cursor: pointer;
+
+    &:hover {
+      background: var(--el-fill-color-light);
+    }
+  }
+}
+</style>

+ 4 - 0
src/layout/components/index.ts

@@ -0,0 +1,4 @@
+export { default as AppMain } from "./AppMain.vue";
+export { default as TagsView } from "./TagsView/index.vue";
+export { default as AdminNavbar } from "./Navbar/Admin/index.vue";
+export { default as SchoolNavbar } from "./Navbar/School/index.vue";

+ 121 - 0
src/layout/school.vue

@@ -0,0 +1,121 @@
+<script setup lang="ts">
+import { computed, watchEffect } from "vue";
+import { useWindowSize } from "@vueuse/core";
+import { AppMain, TagsView, SchoolNavbar } from "./components/index";
+import Sidebar from "./components/Sidebar/index.vue";
+
+import { useAppStore } from "@/store/modules/app";
+import { useSettingsStore } from "@/store/modules/settings";
+
+const { width } = useWindowSize();
+
+/**
+ * 响应式布局容器固定宽度
+ *
+ * 大屏(>=1200px)
+ * 中屏(>=992px)
+ * 小屏(>=768px)
+ */
+const WIDTH = 992;
+
+const appStore = useAppStore();
+const settingsStore = useSettingsStore();
+const fixedHeader = computed(() => settingsStore.fixedHeader);
+const showTagsView = computed(() => settingsStore.tagsView);
+
+const classObj = computed(() => ({
+  hideSidebar: !appStore.sidebar.opened,
+  openSidebar: appStore.sidebar.opened,
+  withoutAnimation: appStore.sidebar.withoutAnimation,
+  mobile: appStore.device === "mobile",
+}));
+
+watchEffect(() => {
+  if (width.value < WIDTH) {
+    appStore.toggleDevice("mobile");
+    appStore.closeSideBar(true);
+  } else {
+    appStore.toggleDevice("desktop");
+
+    if (width.value >= 1200) {
+      //大屏
+      appStore.openSideBar(true);
+    } else {
+      appStore.closeSideBar(true);
+    }
+  }
+});
+
+function handleOutsideClick() {
+  appStore.closeSideBar(false);
+}
+</script>
+
+<template>
+  <div :class="classObj" class="app-wrapper">
+    <!-- 手机设备侧边栏打开遮罩层 -->
+    <div
+      v-if="classObj.mobile && classObj.openSidebar"
+      class="drawer-bg"
+      @click="handleOutsideClick"
+    ></div>
+
+    <Sidebar class="sidebar-container" />
+
+    <div :class="{ hasTagsView: showTagsView }" class="main-container">
+      <div :class="{ 'fixed-header': fixedHeader }">
+        <SchoolNavbar />
+        <tags-view v-if="showTagsView" />
+      </div>
+
+      <!--主页面-->
+      <app-main />
+    </div>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.app-wrapper {
+  &::after {
+    display: table;
+    clear: both;
+    content: "";
+  }
+
+  position: relative;
+  width: 100%;
+  height: 100%;
+
+  &.mobile.openSidebar {
+    position: fixed;
+    top: 0;
+  }
+}
+
+.fixed-header {
+  position: fixed;
+  top: 0;
+  right: 0;
+  z-index: 9;
+  width: calc(100% - #{$sideBarWidth});
+  transition: width 0.28s;
+}
+
+.hideSidebar .fixed-header {
+  width: calc(100% - 54px);
+}
+
+.mobile .fixed-header {
+  width: 100%;
+}
+
+.drawer-bg {
+  position: absolute;
+  top: 0;
+  z-index: 999;
+  width: 100%;
+  height: 100%;
+  background: #000;
+  opacity: 0.3;
+}
+</style>

+ 26 - 0
src/main.ts

@@ -0,0 +1,26 @@
+import { createApp } from "vue";
+import App from "./App.vue";
+import router from "@/router";
+import { setupStore } from "@/store";
+import { setupDirective } from "@/directive";
+
+import "@/permission";
+
+// 本地SVG图标
+import "virtual:svg-icons-register";
+
+// 国际化
+import i18n from "@/lang/index";
+
+// 样式
+import "element-plus/theme-chalk/dark/css-vars.css";
+import "@/styles/index.scss";
+import "uno.css";
+
+const app = createApp(App);
+// 全局注册 自定义指令(directive)
+setupDirective(app);
+// 全局注册 状态管理(store)
+setupStore(app);
+
+app.use(router).use(i18n).mount("#app");

+ 61 - 0
src/permission.ts

@@ -0,0 +1,61 @@
+import router from "@/router";
+import { useUserStoreHook } from "@/store/modules/user";
+import { usePermissionStoreHook } from "@/store/modules/permission";
+
+import NProgress from "nprogress";
+import "nprogress/nprogress.css";
+NProgress.configure({ showSpinner: false }); // 进度条
+
+const permissionStore = usePermissionStoreHook();
+
+// 白名单路由
+const whiteList = ["/login"];
+
+router.beforeEach(async (to, from, next) => {
+  NProgress.start();
+  const hasToken = localStorage.getItem("accessToken");
+  // 已登录
+  if (hasToken) {
+    // 已登录访问/login,跳转首页
+    if (to.path === "/login") {
+      next({ path: "/" });
+      NProgress.done();
+    } else {
+      const userStore = useUserStoreHook();
+      // 未加载过动态路由
+      if (!userStore.routeStatus) {
+        try {
+          const role = await userStore.getInfo();
+          const accessRoutes = await permissionStore.generateRoutes(role);
+          accessRoutes.forEach((route) => {
+            router.addRoute(route);
+          });
+          // 设置路由加载标志
+          userStore.setRouteStatus(true);
+          next({ ...to, replace: true });
+        } catch (error) {
+          // 移除 token 并跳转登录页
+          await userStore.resetToken();
+          next(`/login?redirect=${to.path}`);
+          NProgress.done();
+        }
+      } else {
+        next();
+      }
+    }
+  }
+  // 未登录
+  else {
+    // 未登录可以访问白名单页面
+    if (whiteList.indexOf(to.path) !== -1) {
+      next();
+    } else {
+      next(`/login?redirect=${to.path}`);
+      NProgress.done();
+    }
+  }
+});
+
+router.afterEach(() => {
+  NProgress.done();
+});

+ 47 - 0
src/router/index.ts

@@ -0,0 +1,47 @@
+import { createRouter, createWebHashHistory, RouteRecordRaw } from "vue-router";
+
+export const Layout = () => import("@/layout/school.vue");
+
+// 静态路由
+export const constantRoutes: RouteRecordRaw[] = [
+  {
+    path: "/:pathMatch(.*)*", // 解决路由爆[Vue Router warn]: No match found for location with path
+    meta: { title: "找不到此页面", hidden: true },
+    component: () => import("@/views/error/404.vue"),
+  },
+  {
+    path: "/redirect",
+    component: Layout,
+    meta: { hidden: true },
+    children: [
+      {
+        path: "/redirect/:path(.*)",
+        component: () => import("@/views/login/redirect.vue"),
+      },
+    ],
+  },
+  {
+    path: "/login",
+    component: () => import("@/views/login/index.vue"),
+    meta: { title: "登录", hidden: true },
+  },
+];
+
+/**
+ * 创建路由
+ */
+const router = createRouter({
+  history: createWebHashHistory(),
+  routes: constantRoutes as RouteRecordRaw[],
+  // 刷新时,滚动条位置还原
+  scrollBehavior: () => ({ left: 0, top: 0 }),
+});
+
+/**
+ * 重置路由
+ */
+export function resetRouter() {
+  router.replace({ path: "/login" });
+}
+
+export default router;

+ 43 - 0
src/settings.ts

@@ -0,0 +1,43 @@
+// 系统设置
+interface DefaultSettings {
+	/**
+	 * 系统title
+	 */
+	title: string;
+	/**
+	 * 是否显示多标签导航
+	 */
+	tagsView: boolean;
+	/**
+	 *是否固定头部
+	 */
+	fixedHeader: boolean;
+	/**
+	 * 是否显示侧边栏Logo
+	 */
+	sidebarLogo: boolean;
+	/**
+	 * 导航栏布局
+	 */
+	layout: string;
+	/**
+	 * 主题模式
+	 */
+	theme: string;
+  /**
+   * 语言
+   */
+  language: string;
+}
+
+const defaultSettings: DefaultSettings = {
+	title: "水母星球数据看板系统",
+	tagsView: false,
+	fixedHeader: false,
+	sidebarLogo: true,
+	layout: "left",
+	theme: "light",
+	language: "zh-cn", // zh-cn| en
+};
+
+export default defaultSettings;

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels