소스 검색

feat: init

zhangjie 2 주 전
커밋
af05aaa3c1
100개의 변경된 파일5291개의 추가작업 그리고 0개의 파일을 삭제
  1. 3 0
      .env.development
  2. 0 0
      .env.production
  3. 3 0
      .eslintignore
  4. 81 0
      .eslintrc.js
  5. 16 0
      .gitignore
  6. 7 0
      .prettierignore
  7. 9 0
      .prettierrc.js
  8. 3 0
      babel.config.js
  9. 3 0
      commitlint.config.js
  10. 15 0
      components.d.ts
  11. 34 0
      config/plugin/compress.ts
  12. 37 0
      config/plugin/imagemin.ts
  13. 18 0
      config/plugin/visualizer.ts
  14. 9 0
      config/utils/index.ts
  15. 45 0
      config/vite.config.base.ts
  16. 33 0
      config/vite.config.dev.ts
  17. 30 0
      config/vite.config.prod.ts
  18. 7 0
      develop.md
  19. BIN
      favicon.ico
  20. 18 0
      index.html
  21. 21 0
      jenkins.sh
  22. 93 0
      package.json
  23. 10 0
      src/App.vue
  24. 104 0
      src/api/base.ts
  25. 167 0
      src/api/interceptor.ts
  26. 276 0
      src/api/order.ts
  27. 92 0
      src/api/types/base.ts
  28. 17 0
      src/api/types/common.ts
  29. 135 0
      src/api/types/order.ts
  30. 10 0
      src/api/types/user.ts
  31. 16 0
      src/api/user.ts
  32. BIN
      src/assets/images/bg-empty.png
  33. BIN
      src/assets/images/login-back.png
  34. BIN
      src/assets/images/login-theme.jpg
  35. BIN
      src/assets/images/upload-icon.png
  36. 12 0
      src/assets/logo.svg
  37. 138 0
      src/assets/style/arco-custom.less
  38. 221 0
      src/assets/style/base.css
  39. 272 0
      src/assets/style/base.less
  40. 222 0
      src/assets/style/home.less
  41. 6 0
      src/assets/style/index.less
  42. 308 0
      src/assets/style/pages.less
  43. 137 0
      src/assets/style/reset.less
  44. 90 0
      src/assets/style/var.less
  45. 10 0
      src/assets/svgs/icon-add.svg
  46. 6 0
      src/assets/svgs/icon-apply.svg
  47. 10 0
      src/assets/svgs/icon-assign.svg
  48. 12 0
      src/assets/svgs/icon-base.svg
  49. 10 0
      src/assets/svgs/icon-delete.svg
  50. 10 0
      src/assets/svgs/icon-error.svg
  51. 10 0
      src/assets/svgs/icon-file.svg
  52. 12 0
      src/assets/svgs/icon-home.svg
  53. 10 0
      src/assets/svgs/icon-import.svg
  54. 12 0
      src/assets/svgs/icon-logout.svg
  55. 12 0
      src/assets/svgs/icon-org.svg
  56. 10 0
      src/assets/svgs/icon-print.svg
  57. 10 0
      src/assets/svgs/icon-success.svg
  58. 7 0
      src/assets/svgs/icon-system.svg
  59. 12 0
      src/assets/svgs/icon-user.svg
  60. 229 0
      src/components/file-upload/index.vue
  61. 16 0
      src/components/file-upload/types.ts
  62. 19 0
      src/components/footer/index.vue
  63. 431 0
      src/components/import-dialog/index.vue
  64. 30 0
      src/components/index.ts
  65. 57 0
      src/components/select-range-datetime/index.vue
  66. 52 0
      src/components/select-range-time/index.vue
  67. 75 0
      src/components/select-task/index.vue
  68. 96 0
      src/components/select-teaching/index.vue
  69. 49 0
      src/components/status-tag/index.vue
  70. 59 0
      src/components/svg-icon/index.vue
  71. 237 0
      src/components/upload-button/index.vue
  72. 12 0
      src/constants/app.ts
  73. 25 0
      src/constants/enumerate.ts
  74. 12 0
      src/env.d.ts
  75. 31 0
      src/hooks/dict-option.ts
  76. 16 0
      src/hooks/loading.ts
  77. 22 0
      src/hooks/modal.ts
  78. 25 0
      src/hooks/request.ts
  79. 31 0
      src/hooks/responsive.ts
  80. 65 0
      src/hooks/sms.ts
  81. 67 0
      src/hooks/table.ts
  82. 24 0
      src/hooks/user.ts
  83. 16 0
      src/hooks/visible.ts
  84. 144 0
      src/layout/default-layout.vue
  85. 19 0
      src/main.ts
  86. 38 0
      src/mock/datas/user.ts
  87. 8 0
      src/mock/index.ts
  88. 157 0
      src/mock/task.ts
  89. 50 0
      src/mock/user.ts
  90. 7 0
      src/router/constants.ts
  91. 17 0
      src/router/guard/index.ts
  92. 38 0
      src/router/guard/permission.ts
  93. 22 0
      src/router/guard/userLoginInfo.ts
  94. 29 0
      src/router/index.ts
  95. 9 0
      src/router/routes/base.ts
  96. 14 0
      src/router/routes/externalModules/system.ts
  97. 24 0
      src/router/routes/index.ts
  98. 42 0
      src/router/routes/modules/base.ts
  99. 19 0
      src/router/routes/modules/login.ts
  100. 87 0
      src/router/routes/modules/order.ts

+ 3 - 0
.env.development

@@ -0,0 +1,3 @@
+# VUE_APP_DEV_PROXY='http://192.168.11.199:8080'
+# VUE_APP_DEV_PROXY='http://192.168.10.41:8080'
+VUE_APP_DEV_PROXY='http://apply-test.qmth.com.cn'

+ 0 - 0
.env.production


+ 3 - 0
.eslintignore

@@ -0,0 +1,3 @@
+/*.json
+/*.js
+dist

+ 81 - 0
.eslintrc.js

@@ -0,0 +1,81 @@
+// eslint-disable-next-line @typescript-eslint/no-var-requires
+const path = require('path');
+
+module.exports = {
+  root: true,
+  parser: 'vue-eslint-parser',
+  parserOptions: {
+    // Parser that checks the content of the <script> tag
+    parser: '@typescript-eslint/parser',
+    sourceType: 'module',
+    ecmaVersion: 2020,
+    ecmaFeatures: {
+      jsx: true,
+    },
+  },
+  env: {
+    'browser': true,
+    'node': true,
+    'vue/setup-compiler-macros': true,
+  },
+  plugins: ['@typescript-eslint'],
+  extends: [
+    // Airbnb JavaScript Style Guide https://github.com/airbnb/javascript
+    'airbnb-base',
+    'plugin:@typescript-eslint/recommended',
+    'plugin:import/recommended',
+    'plugin:import/typescript',
+    'plugin:vue/vue3-recommended',
+    'plugin:prettier/recommended',
+  ],
+  settings: {
+    'import/resolver': {
+      typescript: {
+        project: path.resolve(__dirname, './tsconfig.json'),
+      },
+    },
+  },
+  rules: {
+    'prettier/prettier': 1,
+    // Vue: Recommended rules to be closed or modify
+    'vue/require-default-prop': 0,
+    'vue/singleline-html-element-content-newline': 0,
+    'vue/max-attributes-per-line': 0,
+    // Vue: Add extra rules
+    'vue/custom-event-name-casing': [2, 'camelCase'],
+    'vue/no-v-text': 1,
+    'vue/no-v-html': 0,
+    'vue/padding-line-between-blocks': 1,
+    'vue/require-direct-export': 1,
+    'vue/multi-word-component-names': 0,
+    // Allow @ts-ignore comment
+    '@typescript-eslint/ban-ts-comment': 0,
+    '@typescript-eslint/no-unused-vars': 0,
+    '@typescript-eslint/no-empty-function': 0,
+    '@typescript-eslint/no-explicit-any': 0,
+    'import/extensions': [
+      2,
+      'ignorePackages',
+      {
+        js: 'never',
+        jsx: 'never',
+        ts: 'never',
+        tsx: 'never',
+      },
+    ],
+    'no-console': 'off',
+    'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0,
+    'no-param-reassign': 0,
+    'prefer-regex-literals': 0,
+    'import/no-extraneous-dependencies': 0,
+    'import/prefer-default-export': 'off',
+    'no-plusplus': 0,
+    'prefer-destructuring': 0,
+    'no-use-before-define': ['error', { functions: false }],
+    'no-empty-function': 0,
+    'no-loop-func': 0,
+    'guard-for-in': 0,
+    'no-restricted-syntax': 0,
+    'no-nested-ternary': 0,
+  },
+};

+ 16 - 0
.gitignore

@@ -0,0 +1,16 @@
+node_modules
+.DS_Store
+dist
+dist-ssr
+*.local
+
+# Log files
+*.log*
+
+# Editor directories and files
+.idea
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw*

+ 7 - 0
.prettierignore

@@ -0,0 +1,7 @@
+/dist/*
+.local
+.output.js
+/node_modules/**
+
+**/*.svg
+**/*.sh

+ 9 - 0
.prettierrc.js

@@ -0,0 +1,9 @@
+module.exports = {
+  tabWidth: 2,
+  semi: true,
+  printWidth: 80,
+  singleQuote: true,
+  quoteProps: 'consistent',
+  htmlWhitespaceSensitivity: 'strict',
+  vueIndentScriptAndStyle: true,
+};

+ 3 - 0
babel.config.js

@@ -0,0 +1,3 @@
+module.exports = {
+  plugins: ['@vue/babel-plugin-jsx'],
+};

+ 3 - 0
commitlint.config.js

@@ -0,0 +1,3 @@
+module.exports = {
+  extends: ['@commitlint/config-conventional'],
+};

+ 15 - 0
components.d.ts

@@ -0,0 +1,15 @@
+/* eslint-disable */
+/* prettier-ignore */
+// @ts-nocheck
+// Generated by unplugin-vue-components
+// Read more: https://github.com/vuejs/core/pull/3399
+import '@vue/runtime-core'
+
+export {}
+
+declare module '@vue/runtime-core' {
+  export interface GlobalComponents {
+    RouterLink: typeof import('vue-router')['RouterLink']
+    RouterView: typeof import('vue-router')['RouterView']
+  }
+}

+ 34 - 0
config/plugin/compress.ts

@@ -0,0 +1,34 @@
+/**
+ * Used to package and output gzip. Note that this does not work properly in Vite, the specific reason is still being investigated
+ * gzip压缩
+ * https://github.com/anncwb/vite-plugin-compression
+ */
+import type { Plugin } from 'vite';
+import compressPlugin from 'vite-plugin-compression';
+
+export default function configCompressPlugin(
+  compress: 'gzip' | 'brotli',
+  deleteOriginFile = false
+): Plugin | Plugin[] {
+  const plugins: Plugin[] = [];
+
+  if (compress === 'gzip') {
+    plugins.push(
+      compressPlugin({
+        ext: '.gz',
+        deleteOriginFile,
+      })
+    );
+  }
+
+  if (compress === 'brotli') {
+    plugins.push(
+      compressPlugin({
+        ext: '.br',
+        algorithm: 'brotliCompress',
+        deleteOriginFile,
+      })
+    );
+  }
+  return plugins;
+}

+ 37 - 0
config/plugin/imagemin.ts

@@ -0,0 +1,37 @@
+/**
+ * Image resource files used to compress the output of the production environment
+ * 图片压缩
+ * https://github.com/anncwb/vite-plugin-imagemin
+ */
+import viteImagemin from 'vite-plugin-imagemin';
+
+export default function configImageminPlugin() {
+  const imageminPlugin = viteImagemin({
+    gifsicle: {
+      optimizationLevel: 7,
+      interlaced: false,
+    },
+    optipng: {
+      optimizationLevel: 7,
+    },
+    mozjpeg: {
+      quality: 20,
+    },
+    pngquant: {
+      quality: [0.8, 0.9],
+      speed: 4,
+    },
+    svgo: {
+      plugins: [
+        {
+          name: 'removeViewBox',
+        },
+        {
+          name: 'removeEmptyAttrs',
+          active: false,
+        },
+      ],
+    },
+  });
+  return imageminPlugin;
+}

+ 18 - 0
config/plugin/visualizer.ts

@@ -0,0 +1,18 @@
+/**
+ * Generation packaging analysis
+ * 生成打包分析
+ */
+import visualizer from 'rollup-plugin-visualizer';
+import { isReportMode } from '../utils';
+
+export default function configVisualizerPlugin() {
+  if (isReportMode()) {
+    return visualizer({
+      filename: './node_modules/.cache/visualizer/stats.html',
+      open: true,
+      gzipSize: true,
+      brotliSize: true,
+    });
+  }
+  return [];
+}

+ 9 - 0
config/utils/index.ts

@@ -0,0 +1,9 @@
+/**
+ * Whether to generate package preview
+ * 是否生成打包报告
+ */
+export default {};
+
+export function isReportMode(): boolean {
+  return process.env.REPORT === 'true';
+}

+ 45 - 0
config/vite.config.base.ts

@@ -0,0 +1,45 @@
+import { resolve } from 'path';
+import { defineConfig } from 'vite';
+import vue from '@vitejs/plugin-vue';
+import svgLoader from 'vite-svg-loader';
+import inject from '@rollup/plugin-inject';
+
+export default defineConfig({
+  plugins: [
+    vue(),
+    svgLoader({ svgoConfig: {} }),
+  ],
+  resolve: {
+    alias: [
+      {
+        find: '@',
+        replacement: resolve(__dirname, '../src'),
+      },
+      {
+        find: 'assets',
+        replacement: resolve(__dirname, '../src/assets'),
+      },
+      {
+        find: 'vue',
+        replacement: 'vue/dist/vue.esm-bundler.js', // compile template
+      },
+    ],
+    extensions: ['.ts', '.js'],
+  },
+  define: {
+    'process.env': {},
+    '__VUE_PROD_HYDRATION_MISMATCH_DETAILS__': false,
+  },
+  css: {
+    preprocessorOptions: {
+      less: {
+        modifyVars: {
+          hack: `true; @import (reference) "${resolve(
+            'src/assets/style/var.less'
+          )}";`,
+        },
+        javascriptEnabled: true,
+      },
+    },
+  },
+});

+ 33 - 0
config/vite.config.dev.ts

@@ -0,0 +1,33 @@
+import { mergeConfig, loadEnv } from 'vite';
+import eslint from 'vite-plugin-eslint';
+import baseConfig from './vite.config.base';
+
+const env = loadEnv('development', process.cwd(), '');
+
+export default mergeConfig(
+  {
+    mode: 'development',
+    server: {
+      port: 8150,
+      open: false,
+      fs: {
+        strict: false,
+      },
+      proxy: {
+        '/api/': env.VUE_APP_DEV_PROXY,
+      },
+      hmr: {
+        overlay: false,
+      },
+      host: '0.0.0.0',
+    },
+    // plugins: [
+    //   eslint({
+    //     cache: false,
+    //     include: ['src/**/*.ts', 'src/**/*.tsx', 'src/**/*.vue'],
+    //     exclude: ['node_modules'],
+    //   }),
+    // ],
+  },
+  baseConfig
+);

+ 30 - 0
config/vite.config.prod.ts

@@ -0,0 +1,30 @@
+import { mergeConfig } from 'vite';
+import baseConfig from './vite.config.base';
+import configCompressPlugin from './plugin/compress';
+import configVisualizerPlugin from './plugin/visualizer';
+import configArcoResolverPlugin from './plugin/arcoResolver';
+import configImageminPlugin from './plugin/imagemin';
+
+export default mergeConfig(
+  {
+    mode: 'production',
+    plugins: [
+      configCompressPlugin('gzip'),
+      configVisualizerPlugin(),
+      configArcoResolverPlugin(),
+      configImageminPlugin(),
+    ],
+    build: {
+      rollupOptions: {
+        output: {
+          manualChunks: {
+            arco: ['@arco-design/web-vue'],
+            vue: ['vue', 'vue-router', 'pinia', '@vueuse/core'],
+          },
+        },
+      },
+      chunkSizeWarningLimit: 2000,
+    },
+  },
+  baseConfig
+);

+ 7 - 0
develop.md

@@ -0,0 +1,7 @@
+## 开发服
+
+- 地址:http://192.168.10.41:8080
+- 文档:http://192.168.10.41:8080/doc.html
+- 账号:
+  - 学校管理员:admin/123456
+  - 教学点管理员 gzkd/123456

BIN
favicon.ico


+ 18 - 0
index.html

@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+  <meta charset="UTF-8" />
+  <link rel="shortcut icon" type="image/x-icon" href="./favicon.ico">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  <title>
+    云阅卷
+  </title>
+</head>
+
+<body>
+  <div id="app"></div>
+  <script type="module" src="/src/main.ts"></script>
+</body>
+
+</html>

+ 21 - 0
jenkins.sh

@@ -0,0 +1,21 @@
+#!/bin/bash
+pwd
+
+yarn
+npm run build
+
+mkdir -p ~/project/exam-reserve/web/temp
+cp -r  dist ~/project/exam-reserve/web/temp
+
+cd ~/project/exam-reserve/web
+if [ -d "dist" ]; then
+  currentTime=`date "+%Y%m%d%H%M%S"`
+  echo "dist backup... $currentTime"
+  tar cf web-bak-$currentTime.tar.gz dist
+  rm -rf dist/*
+fi
+
+mv temp/dist .
+rm -rf temp
+echo "ok..."
+

+ 93 - 0
package.json

@@ -0,0 +1,93 @@
+{
+  "name": "markingcloud-admin-web",
+  "description": "markingcloud admin web",
+  "version": "2.0.0",
+  "author": "qmth",
+  "license": "MIT",
+  "scripts": {
+    "start": "vite --config ./config/vite.config.dev.ts",
+    "dev": "vite --config ./config/vite.config.dev.ts",
+    "build": "vite build --config ./config/vite.config.prod.ts",
+    "report": "cross-env REPORT=true npm run build",
+    "preview": "npm run build && vite preview --host",
+    "type:check": "vue-tsc --noEmit --skipLibCheck",
+    "lint-staged": "npx lint-staged"
+  },
+  "lint-staged": {
+    "*.{js,ts,jsx,tsx}": [
+      "prettier --write",
+      "eslint --fix"
+    ],
+    "*.vue": [
+      "prettier --write",
+      "eslint --fix"
+    ],
+    "*.{less,css}": [
+      "prettier --write"
+    ]
+  },
+  "dependencies": {
+    "element-plus": "^2.7.0",
+    "@vueuse/core": "^9.3.0",
+    "axios": "^1.9.0",
+    "crypto-js": "^4.2.0",
+    "dayjs": "^1.11.5",
+    "js-md5": "^0.8.3",
+    "lodash": "^4.17.21",
+    "mitt": "^3.0.0",
+    "nprogress": "^0.2.0",
+    "pinia": "^2.0.23",
+    "pinia-plugin-persistedstate": "^3.2.1",
+    "query-string": "^8.0.3",
+    "vue": "^3.2.40",
+    "vue-ls": "^4.2.0",
+    "vue-router": "^4.0.14"
+  },
+  "devDependencies": {
+    "@commitlint/cli": "^17.1.2",
+    "@commitlint/config-conventional": "^17.1.0",
+    "@rollup/plugin-inject": "^5.0.5",
+    "@types/crypto-js": "^4.2.1",
+    "@types/lodash": "^4.14.186",
+    "@types/mockjs": "^1.0.7",
+    "@types/nprogress": "^0.2.0",
+    "@types/vue-ls": "^3.2.7",
+    "@typescript-eslint/eslint-plugin": "^5.40.0",
+    "@typescript-eslint/parser": "^5.40.0",
+    "@vitejs/plugin-vue": "^3.1.2",
+    "@vitejs/plugin-vue-jsx": "^2.0.1",
+    "@vue/babel-plugin-jsx": "^1.1.1",
+    "consola": "^2.15.3",
+    "cross-env": "^7.0.3",
+    "eslint": "^8.25.0",
+    "eslint-config-airbnb-base": "^15.0.0",
+    "eslint-config-prettier": "^8.5.0",
+    "eslint-import-resolver-typescript": "^3.5.1",
+    "eslint-plugin-import": "^2.26.0",
+    "eslint-plugin-prettier": "^4.2.1",
+    "eslint-plugin-vue": "^9.6.0",
+    "less": "^4.1.3",
+    "lint-staged": "^13.0.3",
+    "mockjs": "^1.1.0",
+    "postcss-html": "^1.5.0",
+    "prettier": "^2.7.1",
+    "rollup": "^3.9.1",
+    "rollup-plugin-visualizer": "^5.8.2",
+    "typescript": "^4.8.4",
+    "unplugin-vue-components": "^0.24.1",
+    "vite": "^3.2.5",
+    "vite-plugin-compression": "^0.5.1",
+    "vite-plugin-eslint": "^1.8.1",
+    "vite-plugin-imagemin": "^0.6.1",
+    "vite-svg-loader": "^3.6.0",
+    "vue-tsc": "^1.0.14"
+  },
+  "engines": {
+    "node": ">=14.0.0"
+  },
+  "resolutions": {
+    "bin-wrapper": "npm:bin-wrapper-china",
+    "rollup": "^2.56.3",
+    "gifsicle": "5.2.0"
+  }
+}

+ 10 - 0
src/App.vue

@@ -0,0 +1,10 @@
+<template>
+  <a-config-provider>
+    <template #empty>
+      <div class="empty-none"> </div>
+    </template>
+    <router-view />
+  </a-config-provider>
+</template>
+
+<script lang="ts" setup></script>

+ 104 - 0
src/api/base.ts

@@ -0,0 +1,104 @@
+import axios, { AxiosResponse } from 'axios';
+import type {
+  TeachingListPageParam,
+  TeachingListPageRes,
+  TeachingUpdateParams,
+  AgentListPageParam,
+  AgentListPageRes,
+  AgentUpdateParams,
+  AgentGuideUpdateParams,
+  RoomListPageParam,
+  RoomListPageRes,
+  RoomUpdateParams,
+} from './types/base';
+import { AbleParams } from './types/common';
+
+// 教学点管理
+// 教学点管理-查询
+export function teachingListPage(
+  params: TeachingListPageParam
+): Promise<TeachingListPageRes> {
+  return axios.post('/api/admin/teaching/page', params);
+}
+// 教学点管理-新增编辑
+export function updateTeaching(
+  datas: TeachingUpdateParams
+): Promise<{ id: number }> {
+  return axios.post('/api/admin/teaching/save', datas);
+}
+// 教学点管理-启用禁用
+export function ableTeaching(params: AbleParams): Promise<boolean> {
+  return axios.post('/api/admin/teaching/enable', {}, { params });
+}
+// 教学点管理-导入模板下载
+export function teachingTemplate(): Promise<AxiosResponse<Blob>> {
+  return axios.post(
+    '/api/admin/teaching/import/template',
+    {},
+    {
+      responseType: 'blob',
+    }
+  );
+}
+
+// 考点管理
+// 考点管理-查询
+export function agentListPage(
+  params: AgentListPageParam
+): Promise<AgentListPageRes> {
+  return axios.post('/api/admin/site/page', params);
+}
+// 考点管理-新增编辑
+export function updateAgent(datas: AgentUpdateParams): Promise<{ id: number }> {
+  return axios.post('/api/admin/site/save', datas);
+}
+// 考点管理-考点指引
+export function agentGuideDetail(id: number): Promise<string> {
+  return axios.post('/api/admin/site/guide', {}, { params: { id } });
+}
+// 考点管理-编辑指引
+export function updateAgentGuide(
+  datas: AgentGuideUpdateParams
+): Promise<{ id: number }> {
+  return axios.post('/api/admin/site/guide/save', datas);
+}
+// 考点管理-启用禁用
+export function ableAgent(params: AbleParams): Promise<boolean> {
+  return axios.post('/api/admin/site/enable', {}, { params });
+}
+// 考点管理-导入模板下载
+export function agentTemplate(): Promise<AxiosResponse<Blob>> {
+  return axios.post(
+    '/api/admin/site/import/template',
+    {},
+    {
+      responseType: 'blob',
+    }
+  );
+}
+
+// 考场管理
+// 考场管理-查询
+export function roomListPage(
+  params: RoomListPageParam
+): Promise<RoomListPageRes> {
+  return axios.post('/api/admin/room/page', params);
+}
+// 考场管理-新增编辑
+export function updateRoom(datas: RoomUpdateParams): Promise<{ id: number }> {
+  return axios.post('/api/admin/room/save', datas);
+}
+// 考场管理-启用禁用
+export function ableRoom(params: AbleParams): Promise<boolean> {
+  return axios.post('/api/admin/room/enable', {}, { params });
+}
+// 考场管理-导入模板下载
+export function roomTemplate(): Promise<AxiosResponse<Blob>> {
+  return axios.post(
+    '/api/admin/room/import/template',
+    {},
+    {
+      responseType: 'blob',
+    }
+  );
+}

+ 167 - 0
src/api/interceptor.ts

@@ -0,0 +1,167 @@
+import axios from 'axios';
+import type { AxiosRequestConfig, AxiosResponse } from 'axios';
+import { ElMessage, ElMessageBox, ElNotification } from 'element-plus';
+import type { MessageHandler } from 'element-plus';
+import { initSyncTime, fetchTime } from '../utils/syncServerTime';
+import { getAuthorization } from '../utils/crypto';
+import { DEVICE_ID, PLATFORM } from '../constants/app';
+import { objTypeOf } from '../utils/utils';
+import { useUserStore } from '../store';
+
+axios.defaults.timeout = 60 * 1000;
+
+let load: MessageHandler | null = null;
+// 同一时间有多个请求时,会形成队列。在第一个请求创建loading,在最后一个响应关闭loading
+const queue: Array<string | undefined> = [];
+// 防止鉴权失效之后多次弹窗。
+let unauthMsgBoxIsShow = false;
+
+const modifyConfig = (config: AxiosRequestConfig): void => {
+  const userStore = useUserStore();
+  if (!config.headers) config.headers = {};
+
+  if (userStore.token) {
+    config.headers.userId = String(userStore.id);
+
+    // 新版鉴权
+    const timestamp = fetchTime();
+    const Authorization = getAuthorization(
+      {
+        token: userStore.token,
+        timestamp,
+        account: userStore.sessionId,
+        uri: config.url?.split('?')[0] || '',
+        method: config.method as string,
+      },
+      'token'
+    );
+    config.headers.Authorization = Authorization;
+    config.headers.time = String(timestamp);
+  }
+  config.headers.deviceId = DEVICE_ID;
+  config.headers.platform = PLATFORM;
+  config.headers.domain = window.location.origin;
+};
+
+axios.interceptors.request.use(
+  (config: AxiosRequestConfig) => {
+    // 显示loading提示
+    if (!queue.length) {
+      load = ElMessage.loading({
+        message: '加载中...',
+        duration: 0,
+        showClose: false, // 根据需要调整
+      });
+    }
+    queue.push(config.url);
+
+    modifyConfig(config);
+    return config;
+  },
+  (error) => {
+    // 关闭loading提示
+    setTimeout(() => {
+      queue.shift();
+      if (!queue.length) load?.close();
+    }, 100);
+
+    ElMessage.error({
+      message: error.message || '请求错误',
+      duration: 5 * 1000,
+    });
+    return Promise.reject(error);
+  }
+);
+// add response interceptors
+axios.interceptors.response.use(
+  (response: AxiosResponse) => {
+    initSyncTime(new Date(response.headers.date).getTime());
+    // 关闭loading提示
+    setTimeout(() => {
+      queue.shift();
+      if (!queue.length) load?.close();
+    }, 100);
+
+    if (response.config.responseType === 'blob') return response;
+
+    return response.data;
+  },
+  (error) => {
+    // 关闭loading提示
+    setTimeout(() => {
+      queue.shift();
+      if (!queue.length) load?.close();
+    }, 100);
+
+    // console.dir(error);
+
+    if (!error.response) {
+      let message = error.message || '无响应错误';
+      if (message.indexOf('timeout') > -1) {
+        message = '响应超时';
+      }
+      ElMessage.error({
+        message: message,
+        duration: 5 * 1000,
+      });
+      return Promise.reject(error);
+    }
+
+    const { response } = error;
+    initSyncTime(new Date(response.headers.date).getTime());
+
+    if (objTypeOf(response.data) === 'blob')
+      return Promise.reject(error.response.data);
+
+    const errorData = response.data;
+    let message = errorData.message || '响应未知错误';
+    const unauthStatus = [401, 403];
+
+    if (!unauthStatus.includes(response.status)) {
+      ElNotification.error({
+        title: '错误提示',
+        message: message,
+        // closable: true, // Element Plus 默认 closable 为 true
+      });
+      return Promise.reject(error);
+    }
+
+    if (errorData.code === 401001 && message === '没有权限') {
+      ElNotification.error({
+        title: '错误提示',
+        message: message,
+        // closable: true, // Element Plus 默认 closable 为 true
+      });
+      return Promise.reject(error);
+    }
+
+    if (unauthMsgBoxIsShow) return Promise.reject(error);
+
+    const exposeMsgs = ['系统授权信息已过期,请联系系统管理员激活!'];
+    unauthMsgBoxIsShow = true;
+    message = exposeMsgs.includes(message)
+      ? message
+      : '身份验证失效,请重新登录!';
+
+    ElMessageBox.confirm(message, '重新登陆?', {
+      confirmButtonText: '确定',
+      showCancelButton: false,
+      type: 'warning',
+      autofocus: false,
+      closeOnClickModal: false,
+      closeOnPressEscape: false,
+      showClose: false,
+    })
+      .then(() => {
+        unauthMsgBoxIsShow = false;
+        const userStore = useUserStore();
+        userStore.logout();
+      })
+      .catch(() => {
+        // 用户点击了关闭按钮或者按了 ESC,理论上不会发生,因为 showClose 和 closeOnPressEscape 都是 false
+        unauthMsgBoxIsShow = false; // 也需要重置状态
+      });
+
+    return Promise.reject(error);
+  }
+);

+ 276 - 0
src/api/order.ts

@@ -0,0 +1,276 @@
+import axios, { AxiosResponse } from 'axios';
+import type {
+  OptionItem,
+  TaskListPageParam,
+  TaskListPageRes,
+  TaskItemDetail,
+  TaskRuleUpdateParams,
+  TaskTimeUpdateParams,
+  TaskNoticeUpdateParams,
+  OrderRecordListPageRes,
+  OrderRecordListPageParam,
+  StudentExportListPageRes,
+  OrderRecordPrintTimeItem,
+  OrderRecordPrintParam,
+  ExportOrderRecordDetailParam,
+  AgentQueryParam,
+  RoomQueryParam,
+  OrderPeriodSetItem,
+  TimePeriodItem,
+} from './types/order';
+import { AbleParams, PageParams } from './types/common';
+
+// 通用查询
+// 通用查询-任务查询
+export function taskQuery(): Promise<OptionItem[]> {
+  return axios.post('/api/admin/apply/task/list', {});
+}
+// 通用查询-教学点查询
+export function teachingQuery(params: {
+  flag?: boolean;
+}): Promise<OptionItem[]> {
+  return axios.post('/api/admin/apply/teaching/list', {}, { params });
+}
+// 通用查询-考点查询
+export function agentQuery(params: AgentQueryParam): Promise<OptionItem[]> {
+  return axios.post('/api/admin/apply/agent/list', {}, { params });
+}
+// 通用查询-考场查询
+export function roomQuery(params: RoomQueryParam): Promise<OptionItem[]> {
+  return axios.post('/api/admin/room/list', {}, { params });
+}
+// 通用查询-城市查询
+export function cityQuery(): Promise<OptionItem[]> {
+  return axios.post('/api/admin/teaching/city/list', {});
+}
+
+// 预约任务管理
+// 预约任务管理-查询
+export function taskListPage(
+  params: TaskListPageParam
+): Promise<TaskListPageRes> {
+  return axios.post('/api/admin/apply/task/page', params);
+}
+// 预约任务管理-详情
+export function taskDetailInfo(id: number): Promise<TaskItemDetail> {
+  return axios.post('/api/admin/apply/task/find', {}, { params: { id } });
+}
+// 预约任务管理-新增编辑规则
+export function updateTaskRule(
+  datas: TaskRuleUpdateParams
+): Promise<{ id: number }> {
+  return axios.post('/api/admin/apply/task/rule/save', datas);
+}
+// 预约任务管理-编辑时段
+export function updateTaskTime(
+  datas: TaskTimeUpdateParams
+): Promise<{ id: number }> {
+  return axios.post('/api/admin/apply/task/time/save', datas);
+}
+// 预约任务管理-编辑说明
+export function updateTaskNotice(
+  datas: TaskNoticeUpdateParams
+): Promise<{ id: number }> {
+  return axios.post('/api/admin/apply/task/notice/save', datas);
+}
+// 预约任务管理-启用禁用
+export function ableTask(params: AbleParams): Promise<boolean> {
+  return axios.post('/api/admin/apply/task/enable', {}, { params });
+}
+// 预约任务管理-删除时段
+export function deleteTaskTime(timeId: number): Promise<boolean> {
+  return axios.post(
+    '/api/admin/apply/task/rule/delete',
+    {},
+    { params: { timeId } }
+  );
+}
+
+// 考生信息导入
+// 考生信息导入分页
+export function studentImportListPage(
+  params: PageParams
+): Promise<StudentExportListPageRes> {
+  return axios.post('/api/admin/std/page', params);
+}
+// 考生信息导入-导入模板下载
+export function studentInfoTemplate(): Promise<AxiosResponse<Blob>> {
+  return axios.get('/api/admin/std/import/template', {
+    responseType: 'blob',
+  });
+}
+
+// 预约名单
+// 预约名单详情分页
+export function orderRecordListPage(
+  params: OrderRecordListPageParam
+): Promise<OrderRecordListPageRes> {
+  return axios.post('/api/admin/apply/std/page', params);
+}
+
+export function orderRecordListPage2(
+  params: OrderRecordListPageParam
+): Promise<OrderRecordListPageRes> {
+  return axios.post('/api/admin/student/page', params);
+}
+
+// 预约名单详情-取消预约
+export function orderRecordCancel(id: number): Promise<boolean> {
+  return axios.post('/api/admin/apply/std/cancel', {}, { params: { id } });
+}
+// 删除预约
+export function orderRecordDelete(ids: string) {
+  return axios.post('/api/admin/student/delete', {}, { params: { ids } });
+}
+// 预约名单详情-一键自动分配
+export function orderRecordAutoAssign(params: {
+  taskId: number;
+}): Promise<boolean> {
+  return axios.post('/api/admin/apply/std/auto/assign', {}, { params });
+}
+// 预约名单详情-导入模板下载
+export function orderRecordTemplate(): Promise<AxiosResponse<Blob>> {
+  return axios.post(
+    '/api/admin/apply/imp/template',
+    {},
+    {
+      responseType: 'blob',
+    }
+  );
+}
+
+export function orderDetailExport(data: any): Promise<AxiosResponse<Blob>> {
+  return axios.post('/api/admin/apply/detail/export', data, {
+    responseType: 'blob',
+  });
+}
+
+// 预约名单详情分页
+export function orderRecordPrintTimeListPage(): Promise<
+  OrderRecordPrintTimeItem[]
+> {
+  return axios.post('/api/admin/apply/sign/in/date', {});
+}
+// 预约名单详情-打印签到表
+export function orderRecordPrint(
+  params: OrderRecordPrintParam
+): Promise<AxiosResponse<Blob>> {
+  return axios.post(
+    '/api/admin/apply/std/auto/sign/in/print',
+    {},
+    {
+      responseType: 'blob',
+      params,
+    }
+  );
+}
+// 预约名单详情-导出考场预约情况表
+export function exportOrderRecordDetail(
+  params: ExportOrderRecordDetailParam
+): Promise<AxiosResponse<Blob>> {
+  return axios.post(
+    '/api/admin/apply/export/teaching/available',
+    {},
+    {
+      responseType: 'blob',
+      params,
+    }
+  );
+}
+
+// 时段列表(对应表格第一行)
+export function getTimeSliceList(params: {
+  taskId: string;
+}): Promise<string[]> {
+  return axios.post('/api/admin/time/period/exam/site/list', {}, { params });
+}
+// 时段设置列表
+export function getDateAndTimeList(params: any) {
+  return axios.post(
+    '/api/admin/time/period/exam/site/detail/list',
+    {},
+    { params }
+  );
+}
+
+// 预约设置保存
+export function saveReservation(data: any, params: any) {
+  return axios.post('/api/admin/time/period/exam/site/save', data, { params });
+}
+
+// 开启/关闭 自主预约
+export function toggleSelfYYStatus(params: any) {
+  return axios.post('/api/admin/teaching/selfApplyEnable', {}, { params });
+}
+
+// 管理开始关闭自主预约
+// 查询状态
+export function adminSwitchReservationStatus() {
+  return axios.post(
+    '/api/admin/time/period/exam/site/switch/find',
+    {},
+    {
+      params: {
+        propKey: 'APPLY_SWITCH',
+      },
+    }
+  );
+}
+// 修改状态
+export function adminSwitchReservation(status: boolean) {
+  return axios.post(
+    '/api/admin/time/period/exam/site/switch/enable',
+    {},
+    { params: { status } }
+  );
+}
+
+// 考场排版设置
+// 预约时段设置列表
+export function getRoomDateAndTimeList(
+  examRoomId: number
+): Promise<OrderPeriodSetItem[]> {
+  return axios.post(
+    '/api/admin/time/period/exam/room/detail/list',
+    {},
+    { params: { examRoomId } }
+  );
+}
+// 预约时段设置保存
+export function saveScheduling(data: TimePeriodItem[], examRoomId: number) {
+  return axios.post('/api/admin/time/period/exam/room/save', data, {
+    params: { examRoomId },
+  });
+}
+
+export function getUserList(data: any): any {
+  return axios.post('/api/admin/user/page', data);
+}
+
+export function addUser(data: any): any {
+  return axios.post('/api/admin/user/save', data);
+}
+export function ableUser(params: any): any {
+  return axios.post('/api/admin/user/enable', {}, { params });
+}
+export function resetUserPwd(params: any): any {
+  return axios.post('/api/admin/user/reset/password', {}, { params });
+}
+export function getMyTasks(data: any): any {
+  return axios.post('/api/admin/async/task/page', data);
+}
+
+export function getTaskTypes(): any {
+  return axios.post('/api/admin/async/task/type/list');
+}
+
+export function studentExport(params: any): Promise<AxiosResponse<Blob>> {
+  return axios.post(
+    '/api/admin/student/export',
+    {},
+    {
+      responseType: 'blob',
+      params,
+    }
+  );
+}

+ 92 - 0
src/api/types/base.ts

@@ -0,0 +1,92 @@
+import { PageResult, PageParams } from './common';
+
+export interface TeachingListFilter {
+  name: string;
+  code: string;
+  enable: boolean;
+}
+export type TeachingListPageParam = PageParams<TeachingListFilter>;
+
+export interface TeachingItem {
+  id: number;
+  name: string;
+  code: string;
+  cityId: number;
+  cityName: string;
+  capacity: number;
+  enable: boolean;
+}
+
+export type TeachingListPageRes = PageResult<TeachingItem>;
+
+export interface TeachingUpdateParams {
+  id?: number;
+  name: string;
+  code: string;
+  cityId: number | null;
+}
+
+export interface AgentListFilter {
+  teachingId: number | null;
+  name: string;
+  enable: boolean | undefined;
+}
+export type AgentListPageParam = PageParams<AgentListFilter>;
+
+export interface AgentItem {
+  id: number;
+  name: string;
+  code: string;
+  address: string;
+  teachingId: number;
+  teachingName: string;
+  capacity: number;
+  enable: boolean;
+}
+
+export type AgentListPageRes = PageResult<AgentItem>;
+
+export interface AgentUpdateParams {
+  id?: number;
+  name: string;
+  code: string;
+  address: string;
+  teachingId: number | null;
+}
+export interface AgentGuideUpdateParams {
+  id: number;
+  guide: string;
+}
+
+export interface RoomListFilter {
+  teachingId: number | null;
+  examSiteId: number | null;
+  name: string;
+  enable: boolean | undefined;
+}
+export type RoomListPageParam = PageParams<RoomListFilter>;
+
+export interface RoomItem {
+  id: number;
+  name: string;
+  code: string;
+  address: string;
+  teachingId: number;
+  teachingName: string;
+  examSiteId: number;
+  examSiteName: string;
+  capacity: number;
+  enable: boolean;
+}
+
+export type RoomListPageRes = PageResult<RoomItem>;
+
+export interface RoomUpdateParams {
+  id?: number;
+  name: string;
+  code: string;
+  address: string;
+  capacity: number | undefined;
+  teachingId: number | null;
+  examSiteId: number | null;
+}

+ 17 - 0
src/api/types/common.ts

@@ -0,0 +1,17 @@
+export interface PageResult<T = unknown> {
+  pageSize: number;
+  pageNumber: number;
+  totalCount: number;
+  pageCount: number;
+  result: Array<T>;
+}
+export type PageParams<T = unknown> = T & {
+  pageNumber: number;
+  pageSize: number;
+};
+
+// page
+export interface AbleParams {
+  id: number;
+  enable: boolean;
+}

+ 135 - 0
src/api/types/order.ts

@@ -0,0 +1,135 @@
+import { PageResult, PageParams } from './common';
+
+export interface OptionItem {
+  id: number;
+  name: string;
+}
+
+export interface AgentQueryParam {
+  id: number; // teachingId
+  flag?: boolean;
+}
+export interface RoomQueryParam {
+  examSiteId: number;
+}
+
+export interface TaskListFilter {
+  name: string;
+}
+export type TaskListPageParam = PageParams<TaskListFilter>;
+
+export interface TaskItem {
+  id: number;
+  name: string;
+  selfApplyStartTime: number;
+  selfApplyEndTime: number;
+  openApplyStartTime: number;
+  openApplyEndTime: number;
+  enable: number; // 1:启用;0:禁用
+  updateTime: number;
+}
+
+export type TaskListPageRes = PageResult<TaskItem>;
+
+export interface TaskItemDetail {
+  id: number;
+  name: string;
+  allowApplyDays: number;
+  allowApplyCancelDays: number;
+  selfApplyStartTime: number;
+  selfApplyEndTime: number;
+  openApplyStartTime: number;
+  openApplyEndTime: number;
+  timeList: Array<{
+    id: number | null;
+    startTime: number | undefined;
+    endTime: number | undefined;
+  }>;
+  notice: string;
+}
+
+export interface TaskRuleUpdateParams {
+  id: number | null;
+  name: string;
+  allowApplyCancelDays: number;
+  selfApplyStartTime: number | undefined;
+  selfApplyEndTime: number | undefined;
+  openApplyStartTime: number | undefined;
+  openApplyEndTime: number | undefined;
+  enable: boolean;
+}
+
+export interface TaskTimeUpdateParams {
+  id: number;
+  timeJson: string;
+}
+
+export interface TaskNoticeUpdateParams {
+  id: number;
+  notice: string;
+}
+
+export interface OrderRecordItem {
+  id: number;
+  name: string;
+  identityNumber: string;
+  studentCode: string;
+  teachingName: string; // 教学点
+  agentName: string; // 考点
+  roomName: string; // 考场
+  seatNumber: string; // 座位号
+  cancel: boolean; // 是否取消
+  endTime: number;
+  startTime: number;
+  updateTime: number; // 操作时间
+  userName: string; // 操作人
+}
+export type OrderRecordListPageRes = PageResult<OrderRecordItem>;
+export interface OrderRecordListFilter {
+  taskId: number | null;
+  teachingId: number | null;
+  agentId: number | null;
+  name: string;
+  identityNumber: string;
+  studentCode: string;
+  startDate: number | undefined;
+  endDate: number | undefined;
+}
+export type OrderRecordListPageParam = PageParams<OrderRecordListFilter>;
+
+export interface StudentExportItem {
+  id: number;
+  name: string;
+  fileName: string;
+  status: string;
+  message: string | null;
+  uploadTime: number;
+}
+
+export type StudentExportListPageRes = PageResult<StudentExportItem>;
+
+export interface OrderRecordPrintTimeItem {
+  examDate: number;
+}
+
+export interface OrderRecordPrintParam {
+  teachingId: number | null;
+  agentId: number | null;
+  examDate: number | undefined;
+}
+export interface ExportOrderRecordDetailParam {
+  teachingId: number | null;
+}
+
+export interface TimePeriodItem {
+  id: number; // 注意:教学点管理员第一次设置和学校管理员新增了时段的情况下为空
+  timePeriodId: number;
+  timePeriodStr: string; // 如:08:00-09:30,作用:和原型中第一行做比较
+  enable: boolean; // false:关闭,true:开启
+  editable: boolean; // false:不可编辑,true:可编辑
+}
+export interface OrderPeriodSetItem {
+  dateStr: string;
+  timePeriodList: TimePeriodItem[];
+  batchStatus?: boolean; // false:未批量设置,true:已批量设置
+}

+ 10 - 0
src/api/types/user.ts

@@ -0,0 +1,10 @@
+export interface LoginData {
+  account: string;
+  password: string;
+}
+
+export interface UpdatePwdData {
+  // id: number;
+  // oldPassword: string;
+  password: string;
+}

+ 16 - 0
src/api/user.ts

@@ -0,0 +1,16 @@
+import axios from 'axios';
+import { UserState } from '@/store/modules/user/types';
+import type { LoginData, UpdatePwdData } from './types/user';
+
+// 登录
+export function login(data: LoginData): Promise<UserState> {
+  return axios.post('/api/user/login', data);
+}
+// 修改密码
+export function updatePwd(datas: UpdatePwdData): Promise<UserState> {
+  return axios.post('/api/user/password/modify', {}, { params: datas });
+}
+// 退出登录
+export function userLogout() {
+  return axios.post('/api/user/logout', {});
+}

BIN
src/assets/images/bg-empty.png


BIN
src/assets/images/login-back.png


BIN
src/assets/images/login-theme.jpg


BIN
src/assets/images/upload-icon.png


+ 12 - 0
src/assets/logo.svg

@@ -0,0 +1,12 @@
+<svg width="33" height="33" viewBox="0 0 33 33" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0)">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M5.37754 16.9795L12.7498 9.43027C14.7163 7.41663 17.9428 7.37837 19.9564 9.34482C19.9852 9.37297 20.0137 9.40145 20.0418 9.43027L20.1221 9.51243C22.1049 11.5429 22.1049 14.7847 20.1221 16.8152L12.7498 24.3644C10.7834 26.378 7.55686 26.4163 5.54322 24.4498C5.5144 24.4217 5.48592 24.3932 5.45777 24.3644L5.37754 24.2822C3.39468 22.2518 3.39468 19.0099 5.37754 16.9795Z" fill="#12D2AC"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M20.0479 9.43034L27.3399 16.8974C29.3674 18.9735 29.3674 22.2883 27.3399 24.3644C25.3735 26.3781 22.147 26.4163 20.1333 24.4499C20.1045 24.4217 20.076 24.3933 20.0479 24.3644L12.7558 16.8974C10.7284 14.8213 10.7284 11.5065 12.7558 9.43034C14.7223 7.4167 17.9488 7.37844 19.9624 9.34489C19.9912 9.37304 20.0197 9.40152 20.0479 9.43034Z" fill="#307AF2"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M20.1321 9.52163L23.6851 13.1599L16.3931 20.627L9.10103 13.1599L12.6541 9.52163C14.6707 7.45664 17.9794 7.4174 20.0444 9.434C20.074 9.46286 20.1032 9.49207 20.1321 9.52163Z" fill="#0057FE"/>
+</g>
+<defs>
+<clipPath id="clip0">
+<rect width="26" height="19" fill="white" transform="translate(3.5 7)"/>
+</clipPath>
+</defs>
+</svg>

+ 138 - 0
src/assets/style/arco-custom.less

@@ -0,0 +1,138 @@
+// arco-btn
+.arco-btn + .arco-btn {
+  margin-left: 10px;
+}
+.arco-btn-text {
+  &:not(.arco-btn-only-icon) .arco-btn-icon {
+    margin-right: 4px;
+  }
+}
+// .arco-pagination
+.arco-table {
+  .arco-table-pagination {
+    display: block;
+    margin-top: 16px;
+  }
+  .arco-pagination {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+
+    .arco-pagination-item {
+      border: 1px solid var(--color-border);
+      line-height: 30px;
+    }
+    .arco-pagination-item-active {
+      border-color: var(--color-primary);
+      background-color: var(--color-primary);
+      color: #fff;
+    }
+    .arco-select-view-single {
+      border-color: var(--color-border);
+    }
+    .arco-pagination-total {
+      flex-grow: 2;
+    }
+  }
+}
+
+// arco-table
+.action-column {
+  .arco-btn {
+    height: 20px;
+    padding: 0;
+    border: none !important;
+    outline: none !important;
+    line-height: 20px;
+    margin: 0;
+
+    &:not(:first-child) {
+      margin-left: 10px;
+    }
+    &:not(.arco-btn-disabled):hover {
+      transform: scale(1.1);
+    }
+  }
+}
+
+// arco-input
+// .arco-input-wrapper,
+// .arco-select-view-single {
+//   border-color: #d9d9d9;
+//   background-color: transparent;
+//   &:hover {
+//     border-color: #bebebe;
+//   }
+// }
+
+// arco-modal
+.arco-modal-wrapper {
+  .arco-modal {
+    border-radius: 8px;
+
+    .arco-modal-close-btn {
+      font-size: 20px;
+    }
+    .arco-icon-hover::before {
+      width: 24px;
+      height: 24px;
+      border-radius: 4px;
+    }
+    .arco-modal-header {
+      padding: 20px;
+      border-bottom: 1px solid var(--color-border);
+      margin: 0;
+      height: auto;
+      text-align: left;
+    }
+    .arco-modal-title {
+      color: var(--color-text-dark);
+    }
+    .arco-modal-close {
+      width: 24px;
+      height: 24px;
+    }
+    .arco-modal-body {
+      padding: 20px;
+    }
+    .arco-modal-footer {
+      padding: 0 20px 20px;
+      margin: 0;
+      border: none;
+
+      .arco-btn:not(:nth-child(1)) {
+        margin-left: 8px;
+      }
+    }
+
+    &.arco-modal-simple {
+      border-radius: 8px;
+      padding: 0;
+      .arco-modal-header {
+        border: none;
+        padding-bottom: 8px;
+      }
+      .arco-modal-body {
+        padding: 0 20px 20px 48px;
+      }
+      .arco-modal-footer {
+        text-align: right;
+      }
+    }
+  }
+}
+
+// .arco-select
+.arco-select-dropdown {
+  .arco-select-option {
+    &.arco-select-option-selected {
+      color: var(--color-primary);
+
+      &.arco-select-option-active {
+        &:hover {
+          color: var(--color-primary);
+        }
+      }
+    }
+  }
+}

+ 221 - 0
src/assets/style/base.css

@@ -0,0 +1,221 @@
+.box-justify {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+/* part */
+.part-box {
+  margin-bottom: 16px;
+  background-color: #fff;
+  border-radius: var(--border-radius);
+  padding: 16px;
+}
+.part-box.is-border {
+  border: 1px solid var(--color-border);
+}
+.part-box.is-border-bold {
+  border: 1px solid var(--color-border-bold);
+}
+.part-box.is-nopad {
+  padding: 0;
+}
+.part-box.is-filter {
+  padding-bottom: 4px;
+}
+.part-box-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  min-height: 30px;
+  margin: -10px 0 10px -10px;
+  color: var(--app-main-text-color);
+}
+.part-box-head > h3 {
+  font-size: 18px;
+}
+.filter-line .arco-input-wrapper,
+.filter-line .arco-select {
+  width: 200px;
+  padding-left: 8px;
+}
+.filter-line .arco-select-view-prefix,
+.filter-line .arco-input-prefix {
+  padding-right: 16px;
+  position: relative;
+  color: var(--color-text-dark-1);
+}
+.filter-line .arco-select-view-prefix::after,
+.filter-line .arco-input-prefix::after {
+  content: '';
+  display: block;
+  position: absolute;
+  width: 1px;
+  height: 14px;
+  background: var(--color-border);
+  right: 8px;
+  top: 50%;
+  margin-top: -7px;
+}
+.part-action {
+  margin-bottom: 16px;
+}
+.part-action .arco-btn-text {
+  padding: 5px 8px;
+  font-weight: 400;
+}
+.part-action .arco-space-item:not(:first-child) .arco-btn-text {
+  color: var(--color-text-dark);
+}
+.part-action .arco-divider {
+  margin: 0;
+  border-color: var(--color-border);
+}
+.page-table .arco-table-tr .arco-table-th {
+  color: var(--color-text-gray);
+  font-weight: 400;
+  background-color: #f7f7f7;
+  border-bottom: 1px solid var(--color-border);
+}
+.page-table .arco-table-tr .arco-table-td {
+  border-color: var(--color-border);
+  color: var(--color-text-dark);
+}
+.page-table .arco-table-cell {
+  padding: 12px;
+}
+.action-more .ant-btn-block {
+  display: block;
+  padding: 5px 8px;
+}
+.action-more .ant-popover-inner {
+  border-radius: 6px;
+  padding: 6px;
+  box-shadow: 0px 2px 12px 0px rgba(0, 0, 0, 0.12);
+}
+.empty-none {
+  text-align: center;
+  padding: 10px 0;
+  height: 95px;
+  background-image: url(../images/bg-empty.png);
+  background-size: 98px 74px;
+  background-position: center;
+  background-repeat: no-repeat;
+}
+/* table */
+.table {
+  width: 100%;
+  border-spacing: 0;
+  border-collapse: collapse;
+  text-align: left;
+}
+.table.table-white {
+  background-color: #fff;
+}
+.table th {
+  padding: 12px;
+  line-height: 1.2;
+  letter-spacing: 1px;
+  color: var(--color-text-gray);
+  border: 1px solid var(--color-border);
+}
+.table td {
+  padding: 14px;
+  line-height: 1.2;
+  color: var(--color-text-dark);
+  border: 1px solid var(--color-border);
+}
+.table td.td-link span {
+  cursor: pointer;
+}
+.table td.td-link span:hover {
+  color: var(--color-text-gray);
+}
+.table .td-th {
+  font-weight: 600;
+  color: var(--color-text-gray);
+}
+.table--border {
+  border: 1px solid var(--color-border);
+  border-radius: 10px;
+}
+.table--border th {
+  background-color: #fcfcfd;
+  border: none;
+  border-bottom: 1px solid var(--color-border);
+}
+.table--border td {
+  border: none;
+  border-bottom: 1px solid var(--color-border);
+}
+/* tab-btns */
+.tab-btns {
+  margin-bottom: 15px;
+}
+.tab-btns .arco-btn {
+  border-top-right-radius: 8px;
+  border-top-left-radius: 8px;
+  border-bottom-right-radius: 0;
+  border-bottom-left-radius: 0;
+}
+.tab-btns .arco-btn:first-child {
+  border-bottom-left-radius: 8px;
+}
+.tab-btns .arco-btn:last-child {
+  border-bottom-right-radius: 8px;
+}
+/* btn */
+.btn-danger.arco-btn-text {
+  padding: 0 !important;
+  background-color: transparent !important;
+}
+.btn-danger.arco-btn-text:not(.arco-btn-disabled) {
+  color: var(--color-danger) !important;
+}
+.btn-danger.arco-btn-text:not(.arco-btn-disabled):hover {
+  font-weight: 600;
+}
+.btn-danger.arco-btn-disabled {
+  color: var(--color-text-gray-2);
+}
+.btn-primary.arco-btn-text {
+  padding: 0 !important;
+  background-color: transparent !important;
+}
+.btn-primary.arco-btn-text:not(.arco-btn-disabled):hover {
+  font-weight: 600;
+  color: var(--color-primary) !important;
+}
+.tips-info {
+  font-size: 14px;
+  line-height: 20px;
+  color: var(--color-text-gray);
+}
+.tips-info > i {
+  margin-right: 2px;
+}
+.tips-dark {
+  color: var(--color-text-gray);
+}
+.tips-success {
+  color: var(--color-success);
+}
+.tips-error {
+  color: var(--color-danger);
+}
+.tips-icon {
+  display: inline-block;
+  vertical-align: middle;
+  color: var(--color-text-gray-1);
+  font-size: 18px;
+  margin: 0 10px;
+  cursor: pointer;
+}
+.align-right {
+  text-align: right;
+}
+.mr-10 {
+  margin-right: 10px;
+}
+.ml-10 {
+  margin-left: 10px;
+}

+ 272 - 0
src/assets/style/base.less

@@ -0,0 +1,272 @@
+.box-justify {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+/* part */
+.part-box {
+  margin-bottom: 16px;
+  background-color: #fff;
+  border-radius: var(--border-radius);
+  padding: 16px;
+
+  &.is-border {
+    border: 1px solid var(--color-border);
+  }
+  &.is-border-bold {
+    border: 1px solid var(--color-border-bold);
+  }
+  &.is-nopad {
+    padding: 0;
+  }
+  &.is-filter {
+    padding-bottom: 4px;
+  }
+
+  &-head {
+    .box-justify;
+    min-height: 30px;
+    margin: -10px 0 10px -10px;
+    color: var(--app-main-text-color);
+
+    > h3 {
+      font-size: 18px;
+    }
+  }
+}
+
+.filter-line {
+  .arco-select-view-value {
+    // display: block;
+  }
+  .arco-input-wrapper,
+  .arco-select {
+    width: 200px;
+    padding-left: 8px;
+  }
+
+  .arco-select-view-prefix,
+  .arco-input-prefix {
+    padding-right: 16px;
+    position: relative;
+    color: var(--color-text-dark-1);
+
+    &::after {
+      content: '';
+      display: block;
+      position: absolute;
+      width: 1px;
+      height: 14px;
+      background: var(--color-border);
+      right: 8px;
+      top: 50%;
+      margin-top: -7px;
+    }
+  }
+}
+
+.part-action {
+  margin-bottom: 16px;
+  .arco-btn-text {
+    padding: 5px 8px;
+    font-weight: 400;
+  }
+  .arco-space-item:not(:first-child) {
+    .arco-btn-text {
+      color: var(--color-text-dark);
+    }
+  }
+  .arco-divider {
+    margin: 0;
+    border-color: var(--color-border);
+  }
+}
+
+// page-table
+.page-table {
+  .arco-table-tr {
+    .arco-table-th {
+      color: var(--color-text-gray);
+      font-weight: 400;
+      background-color: #f7f7f7;
+      border-bottom: 1px solid var(--color-border);
+    }
+    .arco-table-td {
+      border-color: var(--color-border);
+      color: var(--color-text-dark);
+    }
+  }
+  .arco-table-cell {
+    padding: 12px;
+  }
+}
+
+// action-more
+.action-more {
+  .ant-btn-block {
+    display: block;
+    padding: 5px 8px;
+  }
+
+  .ant-popover-inner {
+    border-radius: 6px;
+    padding: 6px;
+    box-shadow: 0px 2px 12px 0px rgba(0, 0, 0, 0.12);
+  }
+}
+
+// empty-none
+.empty-none {
+  text-align: center;
+  padding: 10px 0;
+  height: 95px;
+
+  background-image: url(../images/bg-empty.png);
+  background-size: 98px 74px;
+  background-position: center;
+  background-repeat: no-repeat;
+}
+
+/* table */
+.table {
+  width: 100%;
+  border-spacing: 0;
+  border-collapse: collapse;
+  text-align: left;
+
+  &.table-white {
+    background-color: #fff;
+  }
+
+  th {
+    padding: 12px;
+    line-height: 1.2;
+    letter-spacing: 1px;
+    color: var(--color-text-gray);
+    border: 1px solid var(--color-border);
+  }
+  td {
+    padding: 14px;
+    line-height: 1.2;
+    color: var(--color-text-dark);
+    border: 1px solid var(--color-border);
+
+    &.td-link {
+      span {
+        cursor: pointer;
+        &:hover {
+          color: var(--color-text-gray);
+        }
+      }
+    }
+  }
+  .td-th {
+    font-weight: 600;
+    color: var(--color-text-gray);
+  }
+
+  &--border {
+    border: 1px solid var(--color-border);
+    border-radius: 10px;
+    th {
+      background-color: #fcfcfd;
+      border: none;
+      border-bottom: 1px solid var(--color-border);
+    }
+    td {
+      border: none;
+      border-bottom: 1px solid var(--color-border);
+    }
+  }
+}
+/* tab-btns */
+.tab-btns {
+  margin-bottom: 15px;
+  .arco-btn {
+    border-top-right-radius: 8px;
+    border-top-left-radius: 8px;
+    border-bottom-right-radius: 0;
+    border-bottom-left-radius: 0;
+
+    &:first-child {
+      border-bottom-left-radius: 8px;
+    }
+
+    &:last-child {
+      border-bottom-right-radius: 8px;
+    }
+  }
+}
+
+/* btn */
+.btn-danger {
+  &.arco-btn-text {
+    padding: 0 !important;
+    background-color: transparent !important;
+  }
+
+  &.arco-btn-text:not(.arco-btn-disabled) {
+    color: var(--color-danger) !important;
+
+    &:hover {
+      font-weight: 600;
+    }
+  }
+  &.arco-btn-disabled {
+    color: var(--color-text-gray-2);
+  }
+}
+.btn-primary {
+  &.arco-btn-text {
+    padding: 0 !important;
+    background-color: transparent !important;
+  }
+  &.arco-btn-text:not(.arco-btn-disabled) {
+    // color: var(--color-text-dark-1) !important;
+    &:hover {
+      font-weight: 600;
+      color: var(--color-primary) !important;
+    }
+  }
+}
+
+// other
+.tips-info {
+  font-size: 14px;
+  line-height: 20px;
+  color: var(--color-text-gray);
+
+  > i {
+    margin-right: 2px;
+  }
+}
+.tips-dark {
+  color: var(--color-text-gray);
+}
+.tips-success {
+  color: var(--color-success);
+}
+.tips-error {
+  color: var(--color-danger);
+}
+.tips-icon {
+  display: inline-block;
+  vertical-align: middle;
+  color: var(--color-text-gray-1);
+  font-size: 18px;
+  margin: 0 10px;
+  cursor: pointer;
+}
+.align-right {
+  text-align: right;
+}
+.mr-10 {
+  margin-right: 10px;
+}
+.ml-10 {
+  margin-left: 10px;
+}
+.mb-10 {
+  margin-bottom: 10px;
+}

+ 222 - 0
src/assets/style/home.less

@@ -0,0 +1,222 @@
+/* home */
+.home-body {
+  background: var(--color-background);
+  padding: 76px 20px 50px 240px;
+  min-height: 100vh;
+  position: relative;
+}
+
+/* navs */
+.home-navs {
+  position: fixed;
+  width: 220px;
+  top: 56px;
+  left: 0;
+  bottom: 0;
+  z-index: 100;
+  overflow: auto;
+  font-size: 14px;
+  background: var(--color-white);
+  .arco-menu-home {
+    padding: 16px 8px;
+
+    .arco-menu-inner {
+      padding: 0;
+    }
+
+    .arco-menu-inline {
+      margin-bottom: 20px;
+    }
+    .svg-icon {
+      margin-top: -3px;
+    }
+
+    .arco-menu-inline-header {
+      padding: 9px 40px !important;
+      min-height: 38px;
+      line-height: 20px;
+      font-weight: 300;
+
+      .arco-menu-icon {
+        position: absolute;
+        left: 16px;
+        font-size: 16px;
+      }
+
+      &.arco-menu-selected {
+        &:hover {
+          background-color: var(--color-primary-light);
+        }
+      }
+    }
+
+    .arco-menu-item {
+      height: auto;
+      min-height: 38px;
+      line-height: 20px;
+      padding: 9px 40px !important;
+      white-space: normal;
+      .arco-menu-indent-list {
+        display: none;
+      }
+
+      &.arco-menu-selected {
+        font-weight: 500;
+        color: var(--color-primary);
+        background-color: var(--color-primary-light);
+      }
+    }
+  }
+}
+
+/* head */
+.home-header {
+  position: fixed;
+  width: 100%;
+  height: 56px;
+  top: 0;
+  left: 0;
+  z-index: 99;
+  padding: 12px 16px 11px;
+  background-color: #fff;
+
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  border-bottom: 1px solid var(--color-border);
+
+  .home-title {
+    font-size: 20px;
+    font-weight: bold;
+  }
+
+  .home-action {
+    &-item {
+      display: inline-block;
+      vertical-align: middle;
+      height: 32px;
+      line-height: 32px;
+      padding: 0 8px;
+      font-weight: 400;
+      border-radius: var(--border-radius-small);
+
+      &:not(:first-child) {
+        margin-left: 8px;
+      }
+
+      .svg-icon {
+        font-size: 18px;
+      }
+
+      .svg-icon + span {
+        margin-left: 6px;
+      }
+      > span {
+        display: inline-block;
+        vertical-align: middle;
+      }
+
+      &.cursor {
+        cursor: pointer;
+
+        &:hover {
+          background-color: var(--color-background);
+        }
+      }
+    }
+  }
+}
+
+// menu-dialog
+.menu-dialog {
+  .arco-dialog.is-fullscreen {
+    border-radius: 0;
+    box-shadow: none;
+
+    .arco-dialog__body {
+      padding: 10px;
+
+      &::after {
+        display: none;
+      }
+    }
+  }
+
+  .menu-logout {
+    padding: 10px;
+    width: 52px;
+    height: 52px;
+    margin: 0 auto;
+    border: 1px solid var(--color-text-gray-1);
+    border-radius: 50%;
+    font-size: 30px;
+    text-align: center;
+    color: var(--color-text-gray-1);
+    cursor: pointer;
+
+    &:hover {
+      border-color: var(--color-danger);
+      color: var(--color-danger);
+    }
+  }
+}
+
+// home-breadcrumb
+.home-breadcrumb {
+  margin-bottom: 16px;
+
+  .breadcrumb-tips {
+    display: inline-block;
+    vertical-align: middle;
+    color: var(--color-text-gray);
+
+    > .svg-icon {
+      margin-top: -3px;
+      margin-right: 5px;
+      font-size: 14px;
+    }
+  }
+
+  .arco-breadcrumb {
+    display: inline-block;
+    vertical-align: middle;
+
+    .arco-breadcrumb-item {
+      line-height: 22px;
+      color: var(--color-text-gray);
+    }
+
+    .arco-breadcrumb-item-separator {
+      margin: 0;
+    }
+    .arco-breadcrumb-item:last-child {
+      font-weight: normal;
+    }
+  }
+}
+
+// home-view
+
+/* view-footer */
+.home-footer {
+  position: absolute;
+  width: 100%;
+  height: 30px;
+  bottom: 0;
+  left: 0;
+  z-index: 99;
+  padding: 5px 0;
+  line-height: 20px;
+  color: var(--color-text-gray-1);
+  text-align: center;
+  font-size: 13px;
+  background-color: var(--color-background);
+
+  a {
+    color: var(--color-text-gray-1);
+  }
+
+  a:hover {
+    color: var(--color-text-gray);
+  }
+}

+ 6 - 0
src/assets/style/index.less

@@ -0,0 +1,6 @@
+@import url('./var.less');
+@import url('./reset.less');
+@import url('./arco-custom.less');
+@import url('./base.less');
+@import url('./home.less');
+@import url('./pages.less');

+ 308 - 0
src/assets/style/pages.less

@@ -0,0 +1,308 @@
+/* login */
+.login-home {
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  z-index: 8;
+  overflow: auto;
+  background-image: url(assets/images/login-back.png);
+  background-repeat: no-repeat;
+  background-size: cover;
+}
+
+.login-footer {
+  position: absolute;
+  bottom: 0;
+  width: 100%;
+  padding: 10px;
+  color: var(--color-text-gray);
+  text-align: center;
+
+  a {
+    margin: 0 5px;
+
+    &:hover {
+      color: var(--color-primary);
+    }
+  }
+}
+
+.login-box {
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  width: 800px;
+  height: 500px;
+  transform: translate(-50%, -50%);
+  overflow: hidden;
+  border-radius: 30px;
+  background-color: #fff;
+  backdrop-filter: blur(10px);
+}
+.login-theme {
+  width: 400px;
+  height: 100%;
+  border-radius: 30px 0 0 30px;
+  background-image: url(assets/images/login-theme.jpg);
+  background-size: 100% 100%;
+  float: left;
+  position: relative;
+
+  > h2 {
+    position: absolute;
+    top: 192px;
+    left: 48px;
+    font-weight: bold;
+    font-size: 42px;
+    color: #ffffff;
+    line-height: 56px;
+    font-style: normal;
+  }
+
+  .login-webinfo {
+    position: absolute;
+    bottom: 48px;
+    left: 48px;
+
+    font-weight: 400;
+    font-size: 14px;
+    color: #e5e5e5;
+    line-height: 22px;
+
+    a:hover {
+      opacity: 0.8;
+    }
+  }
+}
+.login-body {
+  margin-left: 400px;
+  height: 100%;
+  overflow: hidden;
+  padding: 126px 40px;
+}
+.login-title {
+  margin-bottom: 24px;
+
+  h1 {
+    height: 32px;
+    font-weight: bold;
+    font-size: 24px;
+    line-height: 32px;
+    margin-bottom: 4px;
+  }
+  p {
+    height: 28px;
+    font-weight: 400;
+    font-size: 16px;
+    color: var(--color-text-gray);
+    line-height: 28px;
+  }
+
+  img {
+    display: block;
+    max-width: 160px;
+    height: 40px;
+    margin: 0 auto;
+  }
+}
+.login-form {
+  .login-submit-btn {
+    width: 100%;
+    height: 48px;
+    border-radius: 24px;
+    font-size: 18px;
+  }
+}
+
+/* page */
+.page {
+  display: block;
+}
+
+// privilege-set
+.privilege-set {
+  overflow: auto;
+  .table {
+    td,
+    th {
+      padding: 8px 10px;
+    }
+
+    th:nth-of-type(4),
+    td:nth-of-type(4) {
+      text-align: center;
+    }
+  }
+  .cell-check-list {
+    text-align: left;
+  }
+}
+
+// label-edit
+.label-edit {
+  min-height: 60px;
+  .label-item {
+    display: inline-block;
+    vertical-align: top;
+    border: 1px solid var(--color-text-gray-4);
+    border-radius: 3px;
+    padding: 4px 40px 5px 10px;
+    position: relative;
+    margin: 0 10px 10px 0;
+    line-height: 20px;
+  }
+  .label-item-content {
+    margin: 0;
+    line-height: 20px;
+    vertical-align: middle;
+  }
+  .label-item-delete {
+    position: absolute;
+    right: 10px;
+    top: 50%;
+    transform: translateY(-50%);
+    z-index: 99;
+    font-size: 16px;
+    color: var(--color-text-gray-3);
+    cursor: pointer;
+    &:hover {
+      color: var(--color-danger);
+    }
+  }
+  .label-add {
+    display: inline-block;
+    vertical-align: top;
+    border: 1px solid var(--color-text-gray-4);
+    border-radius: 3px;
+    padding: 0 10px;
+    color: var(--color-text-gray);
+    line-height: 31px;
+    cursor: pointer;
+    &:hover {
+      border-color: var(--color-primary);
+      color: var(--color-primary);
+    }
+  }
+}
+// field-transfer
+.field-transfer {
+  display: flex;
+  align-items: stretch;
+  .field-part-source {
+    width: 200px;
+    border-radius: 10px;
+    border: 1px solid var(--color-border-bold);
+    overflow: hidden;
+  }
+  .field-part-action {
+    width: 120px;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+
+    .action-body {
+      width: 80px;
+      height: 100px;
+      line-height: 50px;
+      text-align: center;
+    }
+
+    .arco-btn {
+      margin: 0;
+    }
+  }
+  .field-title {
+    background-color: var(--color-background);
+    padding: 10px 15px;
+    line-height: 20px;
+    border-bottom: 1px solid var(--color-border-bold);
+  }
+  .field-body {
+    padding: 6px 15px;
+    overflow-x: hidden;
+    overflow-y: auto;
+    height: 246px;
+  }
+  .field-item {
+    position: relative;
+    padding: 5px 0;
+    line-height: 20px;
+
+    &.after-drop {
+      &::after {
+        content: '';
+        display: block;
+        position: absolute;
+        width: 100%;
+        border-bottom: 2px solid var(--color-primary);
+        bottom: -2px;
+        left: 0;
+        z-index: 9;
+      }
+    }
+    &.before-drop {
+      &::before {
+        content: '';
+        display: block;
+        position: absolute;
+        width: 100%;
+        border-top: 2px solid var(--color-primary);
+        top: -2px;
+        left: 0;
+        z-index: 9;
+      }
+    }
+  }
+}
+
+// card-title-rule-edit
+.card-title-rule-edit {
+  .field-item {
+    display: inline-block;
+    vertical-align: top;
+    margin: 0 10px 10px 0;
+    padding: 8px 10px;
+    border-radius: 4px;
+    line-height: 1;
+    background-color: var(--color-background);
+    cursor: pointer;
+
+    &:hover {
+      color: var(--color-primary);
+    }
+    &.is-act {
+      background-color: var(--color-primary);
+      color: #fff !important;
+    }
+    &.is-disabled {
+      cursor: not-allowed;
+
+      &:hover {
+        color: var(--color-text-dark);
+      }
+    }
+  }
+  .field-textarea {
+    border-radius: var(--border-radius);
+    border: 1px solid var(--color-text-gray-2);
+    min-height: 60px;
+    padding: 2px;
+    overflow: hidden;
+
+    &:focus {
+      border-color: var(--color-primary);
+    }
+
+    span.var-field {
+      display: inline-block;
+      vertical-align: middle;
+      padding: 3px 5px;
+      background-color: var(--color-primary);
+      color: var(--color-white);
+      line-height: 1;
+      border-radius: 3px;
+    }
+  }
+}

+ 137 - 0
src/assets/style/reset.less

@@ -0,0 +1,137 @@
+/* reset */
+body,
+div,
+ul,
+ol,
+li,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+input,
+p,
+tr,
+th,
+td,
+span,
+a,
+header,
+footer,
+i {
+  box-sizing: border-box;
+  margin: 0;
+  padding: 0;
+  -webkit-tap-highlight-color: rgb(255 255 255);
+}
+
+em,
+i,
+u {
+  font-style: normal;
+}
+
+input {
+  font-family: var(--font-family);
+  border: none;
+  outline: none;
+}
+
+input::placeholder {
+  color: var(--color-text-gray-2) !important;
+  font-weight: 400;
+}
+
+button,
+textarea {
+  font-family: var(--font-family);
+}
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+  font-size: 100%;
+}
+
+fieldset,
+img {
+  border: 0;
+}
+
+abbr {
+  font-variant: normal;
+  border: 0;
+}
+
+a {
+  color: inherit;
+  text-decoration: none;
+}
+
+img {
+  vertical-align: middle;
+}
+
+/* common-style */
+input:-webkit-autofill {
+  box-shadow: 0 0 0 1000px white inset;
+}
+
+input[type='text']:focus,
+input[type='password']:focus,
+input[type='number']:focus,
+textarea:focus {
+  box-shadow: 0 0 0 1000px white inset;
+}
+
+/* browse style */
+::-webkit-scrollbar {
+  width: 8px;
+  height: 8px;
+  background: transparent;
+}
+
+::-webkit-scrollbar-button {
+  display: none;
+}
+
+::-webkit-scrollbar-track {
+  background: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+  background: #666;
+  border-radius: 8px;
+}
+
+::-webkit-scrollbar-corner {
+  background: transparent;
+}
+
+::-webkit-scrollbar-resizer {
+  background: transparent;
+}
+
+body {
+  color: var(--color-text-dark);
+  font-size: var(--font-size-base);
+  font-family: var(--font-family);
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+  background-color: var(--color-background);
+}
+// svg-icon
+.svg-icon {
+  display: inline-block;
+  vertical-align: middle;
+  width: 1em;
+  height: 1em;
+  color: inherit;
+  path {
+    fill: inherit;
+  }
+}

+ 90 - 0
src/assets/style/var.less

@@ -0,0 +1,90 @@
+body {
+  /* color -------------------> */
+  --color-text-dark: #262626;
+  --color-text-dark-1: #595959;
+  --color-text-gray: #8c8c8c;
+  --color-text-gray-1: #aaa;
+  --color-text-gray-2: #bfbfbf;
+  --color-text-gray-3: #d3d5e0;
+  --color-text-gray-4: #e0e1eb;
+  --color-border: #e5e5e5;
+  --color-border-bold: #d5d5d5;
+  --color-background: #f2f3f5;
+  --form-color-border: #d9d9d9;
+
+  /* status */
+  --color-primary: #165dff;
+  --color-primary-light: #e8f3ff;
+  --color-success: #00b42a;
+  --color-success-light: #32cf8a;
+  --color-warning: #ff9427;
+  --color-danger: #fe5d4e;
+  --color-cyan: #2abcff;
+  --color-cyan-light: #5fc9fa;
+  --color-blue: #3491fa;
+  --color-blue-dark: #172666;
+  --color-purple: #9877ff;
+  --color-white: #fff;
+  --color-dark: #262626;
+  --color-transparent: transparent;
+
+  /* shadow */
+  --shadow-light: 0 0 1px rgba(0 0 0 0.15);
+
+  /* size -------------------> */
+  --font-size-base: 14px;
+  --font-size-medium: 16px;
+  --font-size-large: 18px;
+  --border-radius: 8px;
+  --border-radius-large: 12px;
+  --border-radius-huge: 20px;
+
+  /* font-family */
+  --font-family: SourceHanSansCN, 'Helvetica Neue', Helvetica, 'PingFang SC',
+    'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
+
+  // arco
+  --border-radius-small: 4px;
+  --color-text-1: --color-text-dark-1;
+}
+
+// 自定义var
+@input-color-border: var(--form-color-border);
+@input-color-border_disabled: var(--form-color-border);
+@input-color-border_hover: var(--form-color-border);
+@input-color-border_error: var(--form-color-border);
+@input-color-border_error_hover: var(--form-color-border);
+
+@input-color-bg: var(--color-transparent);
+@input-color-bg_hover: var(--color-transparent);
+@input-color-bg_focus: var(--color-transparent);
+
+@input-color-bg_error: var(--color-transparent);
+@input-color-bg_error_hover: var(--color-transparent);
+
+@form-color-bg_error: var(--color-transparent);
+@form-color-bg_error_hover: var(--color-transparent);
+@form-color-bg_error_focus: var(--color-transparent);
+@form-color-bg_warning: var(--color-transparent);
+@form-color-bg_warning_hover: var(--color-transparent);
+@form-color-bg_warning_focus: var(--color-transparent);
+@form-color-bg_success: var(--color-transparent);
+@form-color-bg_success_hover: var(--color-transparent);
+@form-color-bg_success_focus: var(--color-transparent);
+
+@form-color-border_validating: var(--form-color-border);
+@form-color-border_validating_hover: var(--color-primary-6);
+@form-color-border_error: rgb(var(--danger-6));
+@form-color-border_error_hover: rgb(var(--danger-6));
+@form-color-border_success: rgb(var(--success-6));
+@form-color-border_success_hover: rgb(var(--success-6));
+@form-color-border_warning: rgb(var(--warning-6));
+@form-color-border_warning_hover: rgb(var(--warning-6));
+
+@picker-color-border: var(--form-color-border);
+@picker-color-border_hover: var(--form-color-border);
+@picker-color-border_disabled: var(--color-border);
+@picker-color-border_error: rgb(var(--danger-6));
+@picker-color-border_error_hover: rgb(var(--danger-6));
+@picker-color-bg: var(--color-transparent);
+@picker-color-bg_hover: var(--color-transparent);

+ 10 - 0
src/assets/svgs/icon-add.svg

@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>icon-新增</title>
+    <g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="icon-新增">
+            <rect id="add-circle-(Background)" opacity="0" x="0" y="0" width="16" height="16"></rect>
+            <path d="M4.5,8.5 L4.5,7.5 L7.5,7.5 L7.5,4.5 L8.5,4.5 L8.5,7.5 L11.5,7.5 L11.5,8.5 L8.5,8.5 L8.5,11.5 L7.5,11.5 L7.5,8.5 L4.5,8.5 Z M15,8 C15,4.13400674 11.8659935,1 8,1 C4.13400674,1 1,4.13400674 1,8 C1,11.8659935 4.13400674,15 8,15 C11.8659935,15 15,11.8659935 15,8 Z" id="add-circle" ></path>
+        </g>
+    </g>
+</svg>

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 6 - 0
src/assets/svgs/icon-apply.svg


+ 10 - 0
src/assets/svgs/icon-assign.svg

@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg  viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>icon-分配</title>
+    <g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="icon-分配">
+            <rect id="fork-(Background)" opacity="0" x="0" y="0" width="16" height="16"></rect>
+            <path d="M6.5,2 L9.5,2 C9.77614212,2 10,2.22385764 10,2.5 L10,5.5 C10,5.77614236 9.77614212,6 9.5,6 L8.5,6 L8.5,8 L11.5,8 C12.0522852,8 12.5,8.44771528 12.5,9 L12.5,10 L13.5,10 C13.7761421,10 14,10.2238579 14,10.5 L14,13.5 C14,13.7761421 13.7761421,14 13.5,14 L10.5,14 C10.2238579,14 10,13.7761421 10,13.5 L10,10.5 C10,10.2238579 10.2238579,10 10.5,10 L11.5,10 L11.5,9 L4.5,9 L4.5,10 L5.5,10 C5.77614236,10 6,10.2238579 6,10.5 L6,13.5 C6,13.7761421 5.77614236,14 5.5,14 L2.5,14 C2.22385764,14 2,13.7761421 2,13.5 L2,10.5 C2,10.2238579 2.22385764,10 2.5,10 L3.5,10 L3.5,9 C3.5,8.44771528 3.94771528,8 4.5,8 L7.5,8 L7.5,6 L6.5,6 C6.2238574,6 6,5.77614236 6,5.5 L6,2.5 C6,2.22385764 6.2238574,2 6.5,2 Z M7,3 L9,3 L9,5 L7,5 L7,3 Z M3,13 L5,13 L5,11 L3,11 L3,13 Z M13,11 L13,13 L11,13 L11,11 L13,11 Z" id="fork"></path>
+        </g>
+    </g>
+</svg>

+ 12 - 0
src/assets/svgs/icon-base.svg

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>database-filled@3x</title>
+    <g id="图标" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="Icon" transform="translate(-352, -912)" fill="#000000">
+            <g id="database-filled" transform="translate(352, 912)">
+                <rect id="矩形" opacity="0" x="0" y="0" width="16" height="16"></rect>
+                <path d="M13,1 L3,1 C2.7234375,1 2.5,1.2234375 2.5,1.5 L2.5,5 L13.5,5 L13.5,1.5 C13.5,1.2234375 13.2765625,1 13,1 Z M4.5,3.625 C4.1546875,3.625 3.875,3.3453125 3.875,3 C3.875,2.6546875 4.1546875,2.375 4.5,2.375 C4.8453125,2.375 5.125,2.6546875 5.125,3 C5.125,3.3453125 4.8453125,3.625 4.5,3.625 Z M2.5,14.5 C2.5,14.7765625 2.7234375,15 3,15 L13,15 C13.2765625,15 13.5,14.7765625 13.5,14.5 L13.5,11 L2.5,11 L2.5,14.5 Z M4.5,12.375 C4.8453125,12.375 5.125,12.6546875 5.125,13 C5.125,13.3453125 4.8453125,13.625 4.5,13.625 C4.1546875,13.625 3.875,13.3453125 3.875,13 C3.875,12.6546875 4.1546875,12.375 4.5,12.375 Z M2.5,10 L13.5,10 L13.5,6 L2.5,6 L2.5,10 Z M4.5,7.375 C4.8453125,7.375 5.125,7.6546875 5.125,8 C5.125,8.3453125 4.8453125,8.625 4.5,8.625 C4.1546875,8.625 3.875,8.3453125 3.875,8 C3.875,7.6546875 4.1546875,7.375 4.5,7.375 Z" id="形状"></path>
+            </g>
+        </g>
+    </g>
+</svg>

+ 10 - 0
src/assets/svgs/icon-delete.svg

@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg  viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>icon-删除</title>
+    <g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="icon-删除">
+            <rect id="delete-(Background)" opacity="0" x="0" y="0" width="16" height="16"></rect>
+            <path d="M6,6 L7,6 L7,12 L6,12 L6,6 Z M9,12 L10,12 L10,6 L9,6 L9,12 Z M14,3 L14,4 L13,4 L13,14 C13,14.5522852 12.5522842,15 12,15 L4,15 C3.44771504,15 3,14.5522842 3,14 L3,4 L2,4 L2,3 L5.5,3 L5.5,1.80000073 C5.5,1.35817304 5.85817218,1 6.30000019,1 L9.69999981,1 C10.1418276,1 10.5,1.35817215 10.5,1.80000013 L10.5,3 L14,3 Z M6.5,2 L9.5,2 L9.5,3 L6.5,3 L6.5,2 Z M4,14 L12,14 L12,4 L4,4 L4,14 Z" id="delete" ></path>
+        </g>
+    </g>
+</svg>

+ 10 - 0
src/assets/svgs/icon-error.svg

@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg  viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>warning-circle-filled@3x</title>
+    <g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="warning-circle-filled">
+            <rect id="矩形" fill="#000000" opacity="0" x="0" y="0" width="16" height="16"></rect>
+            <path d="M8,1 C4.134375,1 1,4.134375 1,8 C1,11.865625 4.134375,15 8,15 C11.865625,15 15,11.865625 15,8 C15,4.134375 11.865625,1 8,1 Z M7.5,4.625 C7.5,4.55625 7.55625,4.5 7.625,4.5 L8.375,4.5 C8.44375,4.5 8.5,4.55625 8.5,4.625 L8.5,8.875 C8.5,8.94375 8.44375,9 8.375,9 L7.625,9 C7.55625,9 7.5,8.94375 7.5,8.875 L7.5,4.625 Z M8,11.5 C7.5859375,11.5 7.25,11.1640625 7.25,10.75 C7.25,10.3359375 7.5859375,10 8,10 C8.4140625,10 8.75,10.3359375 8.75,10.75 C8.75,11.1640625 8.4140625,11.5 8,11.5 Z" id="形状" fill="#F53F3F"></path>
+        </g>
+    </g>
+</svg>

+ 10 - 0
src/assets/svgs/icon-file.svg

@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>icon-导入文档图标</title>
+    <g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="icon-导入文档图标">
+            <rect id="file-excel-(Background)" opacity="0" x="0" y="0" width="16" height="16"></rect>
+            <path d="M10.3363,8.952392 L10.3363,9.792102 C10.3363,9.822342 10.3466,9.851672 10.3656,9.875242 L11.4181,11.185122 L12.4707,9.875242 C12.4896,9.851672 12.5,9.822342 12.5,9.792102 L12.5,8.952392 L13.5,8.952392 L13.5,9.792102 C13.5,10.050122 13.4119,10.300522 13.2502,10.501622 L12.0596,11.983322 L13.2502,13.465022 C13.4119,13.666122 13.5,13.916522 13.5,14.174522 L13.5,15.014222 L12.5,15.014222 L12.5,14.174522 C12.5,14.144322 12.4896,14.114922 12.4707,14.091422 L11.4181,12.781522 L10.3656,14.091422 C10.3466,14.114922 10.3363,14.144322 10.3363,14.174522 L10.3363,15.014222 L9.3363,15.014222 L9.3363,14.174522 C9.3363,13.916522 9.42442,13.666122 9.58607,13.465022 L10.7767,11.983322 L9.58607,10.501622 C9.42442,10.300522 9.3363,10.050122 9.3363,9.792102 L9.3363,8.952392 L10.3363,8.952392 Z M8.86589,1 C9.13442,1 9.39167,1.108002 9.57972,1.299692 L13.2138,5.004002 C13.3973,5.190952 13.5,5.442412 13.5,5.704312 L13.5,7.5 L12.5,7.5 L12.5,6.012752 L8.50008,6.012752 L8.50008,2 L3.5,2 L3.5,14.000022 L8,14.000022 L8,15.000022 L3.49534,15.000022 C3.02005,15.000022 2.5,14.662722 2.5,14.078122 L2.5,1.921912 C2.5,1.337272 3.02005,1 3.49534,1 L8.86589,1 Z M9.50008,2.646452 L9.50008,5.012752 L11.8215,5.012752 L9.50008,2.646452 Z" id="形状结合"></path>
+        </g>
+    </g>
+</svg>

+ 12 - 0
src/assets/svgs/icon-home.svg

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>icon-主页</title>
+    <g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="02.01-授权管理-授权管理" transform="translate(-236, -75)">
+            <g id="icon-主页" transform="translate(236, 75)">
+                <rect id="home-(Background)" opacity="0" x="0" y="0" width="16" height="16"></rect>
+                <path d="M6.00003767,12.0000153 L10.0000381,12.0000153 L10.0000381,11.0000153 L6.00003767,11.0000153 L6.00003767,12.0000153 Z M14.8535919,8.14646196 L14.1464844,8.85356855 L13.0000381,7.70712233 L13.0000381,13.5000153 C13.0000381,14.0523 12.5523229,14.5000153 12.0000381,14.5000153 L4.00003779,14.5000153 C3.44775304,14.5000153 3.00003779,14.0523 3.00003779,13.5000153 L3.00003779,7.70712185 L3.00003779,7.70712185 L1.8535912,8.85356855 L1.14648438,8.14646196 L7.64648428,1.64646111 C7.84174641,1.45119895 8.1583289,1.45119892 8.35359106,1.64646106 C8.35359107,1.64646106 8.35359108,1.64646107 8.35359109,1.64646108 L14.8535919,8.14646196 L14.8535919,8.14646196 Z M12.0000381,6.70712233 L12.0000381,13.5000153 L4.00003767,13.5000153 L4.00003767,6.70712185 L8.00003767,2.70712197 L12.0000381,6.70712233 Z" id="home" fill-opacity="0.9"></path>
+            </g>
+        </g>
+    </g>
+</svg>

+ 10 - 0
src/assets/svgs/icon-import.svg

@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg  viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>icon-导入</title>
+    <g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="icon-导入">
+            <rect id="download-(Background)" opacity="0" x="0" y="0" width="16" height="16"></rect>
+            <path d="M8.5,9.57746124 L8.49998093,0.5 L7.49998093,0.5 L7.5,9.57745647 L3.73641205,5.81387091 L3.02930534,6.5209775 L7.64644661,11.1381191 C7.84170875,11.3333813 8.15829124,11.3333813 8.35355339,11.1381192 C8.35355339,11.1381191 8.3535534,11.1381191 8.3535534,11.1381191 L12.9706955,6.52097702 L12.9706955,6.52097702 L12.2635889,5.81386995 L8.5,9.57746124 Z M2,13.0000238 C2,13.5523081 2.44771534,14.0000238 3,14.0000238 L13,14.0000238 C13.5522842,14.0000238 14,13.552309 14,13.0000238 L14,11.0000238 L13,11.0000238 L13,13.0000238 L3,13.0000238 L3,11.0000238 L2,11.0000238 L2,13.0000238 Z" id="download" ></path>
+        </g>
+    </g>
+</svg>

+ 12 - 0
src/assets/svgs/icon-logout.svg

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg viewBox="0 0 18 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>icon-退出</title>
+    <g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="02.01-授权管理-授权管理" transform="translate(-1398, -19)">
+            <g id="icon-退出" transform="translate(1398, 19)">
+                <rect id="logout-(Background)" opacity="0" x="0" y="0" width="18" height="18"></rect>
+                <path d="M10.6871798,2.25 C10.9978269,2.25 11.2496609,2.5018425 11.2496609,2.8125 L11.2496609,5.625 L10.1246986,5.625 L10.1246986,3.375 L2.24996233,3.375 L2.24996233,14.625 L10.1246986,14.625 L10.1246986,12.375 L11.2496609,12.375 L11.2496609,15.1875 C11.2496609,15.4981125 10.9978269,15.75 10.6871798,15.75 L1.68748116,15.75 C1.37683407,15.75 1.125,15.4981125 1.125,15.1875 L1.125,2.8125 C1.125,2.5018425 1.37683407,2.25 1.68748116,2.25 Z M13.4309636,5.11551 L16.9177359,8.60237858 C17.1374522,8.82205308 17.1374522,9.17821692 16.9177359,9.39789142 L13.4309636,12.88476 L12.6354625,12.0892472 L15.1620308,9.56265292 L6.74974414,9.56263042 L6.74974414,8.43759458 L15.1620308,8.43761708 L12.6354625,5.91103409 L13.4309636,5.11551 Z" id="logout" fill="#595959"></path>
+            </g>
+        </g>
+    </g>
+</svg>

+ 12 - 0
src/assets/svgs/icon-org.svg

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg viewBox="0 0 18 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>icon-机构</title>
+    <g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="02.01-授权管理-授权管理" transform="translate(-1132, -19)" fill-rule="nonzero">
+            <g id="icon-机构" transform="translate(1132, 19)">
+                <rect id="矩形" fill="#000000" opacity="0" x="0" y="0" width="18" height="18"></rect>
+                <path d="M15.7148438,8.12109375 C16.2580078,8.12109375 16.4847656,7.42324219 16.0435547,7.1015625 L9.33046875,2.21835937 C9.13373237,2.07421898 8.86626763,2.07421898 8.66953125,2.21835937 L1.95644531,7.1015625 C1.51523438,7.42148437 1.74199219,8.12109375 2.28691406,8.12109375 L3.375,8.12109375 L3.375,14.6953125 L2.109375,14.6953125 C2.03203125,14.6953125 1.96875,14.7585937 1.96875,14.8359375 L1.96875,15.75 C1.96875,15.8273437 2.03203125,15.890625 2.109375,15.890625 L15.890625,15.890625 C15.9679688,15.890625 16.03125,15.8273437 16.03125,15.75 L16.03125,14.8359375 C16.03125,14.7585937 15.9679688,14.6953125 15.890625,14.6953125 L14.625,14.6953125 L14.625,8.12109375 L15.7148438,8.12109375 Z M6.69726563,14.6953125 L4.640625,14.6953125 L4.640625,8.12109375 L6.69726563,8.12109375 L6.69726563,14.6953125 Z M10.0195313,14.6953125 L7.96289063,14.6953125 L7.96289063,8.12109375 L10.0195313,8.12109375 L10.0195313,14.6953125 Z M13.359375,14.6953125 L11.2851563,14.6953125 L11.2851563,8.12109375 L13.359375,8.12109375 L13.359375,14.6953125 Z" id="形状" fill="#BFBFBF"></path>
+            </g>
+        </g>
+    </g>
+</svg>

+ 10 - 0
src/assets/svgs/icon-print.svg

@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>icon-打印</title>
+    <g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="icon-打印">
+            <rect id="print-(Background)" opacity="0" x="0" y="0" width="16" height="16"></rect>
+            <path d="M3.99902344,3.99890137 L2.99902344,3.99890137 C2.44288105,3.99890137 1.99902344,4.44275904 1.99902344,4.99890137 L1.99902344,9.99890137 C1.99902344,10.5550442 2.44288105,10.9989014 2.99902344,10.9989014 L3.99902344,10.9989014 L3.99902344,13.9989014 L11.9990234,13.9989014 L11.9990234,10.9989014 L12.9990234,10.9989014 C13.5551662,10.9989014 13.9990234,10.5550442 13.9990234,9.99890137 L13.9990234,4.99890137 C13.9990234,4.44275904 13.5551662,3.99890137 12.9990234,3.99890137 L11.9990234,3.99890137 L11.9990234,1.99890137 L3.99902344,1.99890137 L3.99902344,3.99890137 Z M10.9990234,2.99890137 L4.99902344,2.99890137 L4.99902344,3.99890137 L10.9990234,3.99890137 L10.9990234,2.99890137 Z M2.99902344,9.99890137 L3.99902344,9.99890137 L3.99902344,7.99890137 L11.9990234,7.99890137 L11.9990234,9.99890137 L12.9990234,9.99890137 L12.9990234,4.99890137 L2.99902344,4.99890137 L2.99902344,9.99890137 Z M4.99902344,8.99890137 L10.9990234,8.99890137 L10.9990234,12.9989014 L4.99902344,12.9989014 L4.99902344,8.99890137 Z" id="print" ></path>
+        </g>
+    </g>
+</svg>

+ 10 - 0
src/assets/svgs/icon-success.svg

@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg  viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>icon-上传成功</title>
+    <g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="icon-上传成功">
+            <rect id="check-circle-filled-(Background)" opacity="0" x="0" y="0" width="16" height="16"></rect>
+            <path d="M15,8 C15,4.13400674 11.8659935,1 8,1 C4.13400674,1 1,4.13400674 1,8 C1,11.8659935 4.13400674,15 8,15 C11.8659935,15 15,11.8659935 15,8 Z M7,10.7069998 L11.5,6.20749998 L10.7924995,5.5 L7,9.29300022 L5.20650005,7.5 L4.5,8.20650005 L7,10.7069998 Z" id="check-circle-filled" ></path>
+        </g>
+    </g>
+</svg>

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 7 - 0
src/assets/svgs/icon-system.svg


+ 12 - 0
src/assets/svgs/icon-user.svg

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg viewBox="0 0 18 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>icon-用户</title>
+    <g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="02.01-授权管理-授权管理" transform="translate(-1244, -19)">
+            <g id="icon-用户" transform="translate(1244, 19)">
+                <polygon id="Rectangle-4117" opacity="0" transform="translate(9, 9) rotate(0) translate(-9, -9)" points="3.14721972e-06 3.14721974e-06 18.0000031 3.14721974e-06 18.0000031 18.0000031 3.14721972e-06 18.0000031"></polygon>
+                <path d="M9.00000629,9.5625 C6.82538495,9.5625 5.06250603,7.79962134 5.06250603,5.62499973 C5.06250603,3.45037839 6.82538495,1.6875 9.00000629,1.6875 C11.1746276,1.6875 12.9375063,3.45037839 12.9375063,5.62499973 C12.9375063,7.79962134 11.1746276,9.5625 9.00000629,9.5625 Z M16.3125063,13.190207 L16.3125063,15.75 C16.3125063,16.0606599 16.0606662,16.3125 15.7500063,16.3125 L2.25000777,16.3125 C1.93934758,16.3125 1.68750629,16.0606599 1.68750629,15.75 L1.68750629,13.190207 C1.68750629,12.7739882 1.91589921,12.3887544 2.29147854,12.2093714 C4.33058476,11.2354603 6.59982888,10.6875 9.00000683,10.6875 C11.4001843,10.6875 13.6694286,11.2354603 15.7085351,12.2093714 C16.0841141,12.3887544 16.3125063,12.7739882 16.3125063,13.190207 Z" id="Union" ></path>
+            </g>
+        </g>
+    </g>
+</svg>

+ 229 - 0
src/components/file-upload/index.vue

@@ -0,0 +1,229 @@
+<template>
+  <div class="file-upload">
+    <el-input
+      v-model.trim="attachmentName"
+      placeholder="文件名称"
+      readonly
+    ></el-input>
+    <el-upload
+      ref="uploadRef"
+      :action="uploadUrl"
+      :headers="headers"
+      :limit="1"
+      :data="uploadDataDict"
+      :show-file-list="false"
+      :auto-upload="autoUpload"
+      :http-request="customRequest"
+      :disabled="disabled"
+      :before-upload="handleBeforeUpload"
+      :on-change="handleFileChange"
+      :on-error="handleError"
+      :on-success="handleSuccess"
+    >
+      <template #trigger>
+        <el-button type="primary" :disabled="loading">选择</el-button>
+      </template>
+      <el-button
+        v-if="!autoUpload"
+        type="primary"
+        :loading="loading"
+        :disabled="!canUpload"
+        @click.stop="startUpload"
+        style="margin-left: 10px;"
+        >开始上传</el-button
+      >
+    </el-upload>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { ref } from 'vue';
+  import { fileMD5 } from '@/utils/md5';
+  import { ElMessage } from 'element-plus';
+  import type { UploadProps, UploadRequestOptions, UploadFile, UploadFiles } from 'element-plus';
+  import axios from 'axios';
+
+  defineOptions({
+    name: 'FileUpload',
+  });
+
+  const props = withDefaults(
+    defineProps<{
+      uploadUrl: string;
+      format?: string[];
+      uploadData?: Record<string, any>;
+      maxSize?: number;
+      uploadFileAlias?: string;
+      autoUpload?: boolean;
+      disabled?: boolean;
+    }>(),
+    {
+      uploadUrl: '',
+      format: () => ['xls', 'xlsx'],
+      uploadData: () => {
+        return {};
+      },
+      maxSize: 20 * 1024 * 1024,
+      uploadFileAlias: 'file',
+      autoUpload: true,
+      disabled: false,
+    }
+  );
+
+  const emit = defineEmits([
+    'uploading',
+    'uploadError',
+    'uploadSuccess',
+    'validError',
+  ]);
+
+  const uploadRef = ref();
+  const attachmentName = ref('');
+  const canUpload = ref(false);
+  const uploadDataDict = ref({});
+  const headers = ref({ md5: '' });
+  const result = ref({ success: true, message: '' });
+  const loading = ref(false);
+
+  function startUpload() {
+    loading.value = true;
+    uploadRef.value?.submit();
+  }
+
+  function checkFileFormat(fileType: string) {
+    const fileFormat = fileType.split('.').pop()?.toLocaleLowerCase();
+    return props.format.some((item) => item.toLocaleLowerCase() === fileFormat);
+  }
+
+  function handleFileChange(uploadFile: UploadFile, uploadFiles: UploadFiles) {
+    if (props.autoUpload || !uploadFiles.length) return;
+
+    attachmentName.value = uploadFile.name || '';
+    // Element Plus 的 status 有 ready, uploading, success, fail
+    canUpload.value = uploadFile.status === 'ready';
+  }
+
+  async function handleBeforeUpload(file: File): Promise<boolean | File> {
+    uploadDataDict.value = {
+      ...props.uploadData,
+      filename: file.name,
+    };
+
+    if (file.size > props.maxSize) {
+      handleExceededSize();
+      return Promise.reject(result.value);
+    }
+
+    if (!checkFileFormat(file.name)) {
+      handleFormatError();
+      return Promise.reject(result.value);
+    }
+
+    const md5 = await fileMD5(file);
+    headers.value.md5 = md5;
+
+    if (props.autoUpload) loading.value = true;
+
+    return true;
+  }
+
+  function customRequest(options: UploadRequestOptions): XMLHttpRequest {
+    const { onProgress, onError, onSuccess, file, data } = options;
+
+    const formData = new FormData();
+    const paramData: Record<string, any> = data || {};
+    Object.entries(paramData).forEach(([k, v]) => {
+      formData.append(k, v);
+    });
+    formData.append(props.uploadFileAlias, file as File);
+    emit('uploading');
+    const uploadController = new AbortController();
+
+    axios
+      .post(option.action as string, formData, {
+        headers: option.headers,
+        signal: uploadController.signal,
+        onUploadProgress: ({ loaded, total }) => {
+          onProgress({ percent: Math.floor((100 * loaded) / (total || 0)) });
+        },
+      })
+      .then((res) => {
+        onSuccess(res);
+      })
+      .catch((error: Error) => {
+        onError(error);
+      });
+
+    return {
+      abort: uploadController.abort,
+    };
+  }
+
+  function handleError() {
+    canUpload.value = false;
+    loading.value = false;
+    result.value = {
+      success: false,
+      message: '上传失败',
+    };
+    emit('uploadError', result.value);
+  }
+  function handleSuccess(response: any, uploadFile: UploadFile) {
+    canUpload.value = false;
+    loading.value = false;
+    result.value = {
+      success: true,
+      message: '上传成功!',
+    };
+    ElMessage.success('上传成功!');
+    emit('uploadSuccess', {
+      ...result.value,
+      filename: uploadFile.name,
+      response: response,
+    });
+  }
+
+  function handleFormatError() {
+    const content = `只支持文件格式为${props.format.join('/')}`;
+    result.value = {
+      success: false,
+      message: content,
+    };
+    loading.value = false;
+    ElMessage.error(content);
+    emit('validError', result.value);
+  }
+  function handleExceededSize() {
+    const content = `文件大小不能超过${Math.floor(props.maxSize / 1024)}M`;
+    result.value = {
+      success: false,
+      message: content,
+    };
+    loading.value = false;
+    ElMessage.error(content);
+    emit('validError', result.value);
+  }
+  function setAttachmentName(name: string) {
+    attachmentName.value = name;
+  }
+
+  defineExpose({ setAttachmentName });
+</script>
+
+<style lang="less">
+  .file-upload {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    width: 100%;
+
+    .arco-input-wrapper {
+      flex-grow: 2;
+      margin-right: 10px;
+    }
+    .arco-upload {
+      flex-grow: 0;
+      flex-shrink: 0;
+    }
+  }
+</style>

+ 16 - 0
src/components/file-upload/types.ts

@@ -0,0 +1,16 @@
+export interface UploadResponseData {
+  id: number;
+  updateTime: number;
+  url: string;
+  pages: number;
+}
+
+interface UploadResult {
+  success: boolean;
+  message: string;
+}
+
+export interface UploadSuccessData extends UploadResult {
+  response: UploadResponseData;
+  filename: string;
+}

+ 19 - 0
src/components/footer/index.vue

@@ -0,0 +1,19 @@
+<template>
+  <div class="view-footer">
+    <p>
+      <a href="http://www.qmth.com.cn" target="_blank"
+        >Copyright © 2022 启明泰和</a
+      >
+      <a href="https://beian.miit.gov.cn/" target="_blank"
+        >鄂ICP备12000033号-9</a
+      >
+      <span v-if="appStore.version"> v{{ appStore.version }}</span>
+    </p>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { useAppStore } from '@/store';
+
+  const appStore = useAppStore();
+</script>

+ 431 - 0
src/components/import-dialog/index.vue

@@ -0,0 +1,431 @@
+<template>
+  <el-dialog
+    v-model="visible"
+    :width="422"
+    :title="title"
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+    @open="modalBeforeOpen"
+  >
+    <slot></slot>
+    <div class="import-box">
+      <div class="import-temp">
+        <span>模板下载:</span>
+        <div class="temp-btn">
+          <a v-if="downloadUrl" :href="downloadUrl" :download="dfilename">{{
+            dfilename
+          }}</a>
+          <el-button
+            v-else-if="downloadHandle"
+            type="primary" link
+            @click="downloadHandle"
+            >{{ dfilename }}</el-button
+          >
+        </div>
+      </div>
+      <div class="import-body">
+        <el-upload
+          v-if="visible"
+          ref="uploadRef"
+          drag
+          :action="uploadUrl"
+          :headers="headers"
+          :accept="accept"
+          :data="uploadDataDict"
+          :show-file-list="true"
+          :auto-upload="autoUpload"
+          :http-request="customRequest"
+          :disabled="disabled"
+          :before-upload="handleBeforeUpload"
+          :file-list="customFileList"
+          :on-change="handleFileChange"
+          :on-error="handleError"
+          :on-success="handleSuccess"
+          :limit="1"
+        >
+          <el-icon class="el-icon--upload"><upload-filled /></el-icon>
+          <div class="el-upload__text">
+            将文件拖到此处,或<em>点击上传</em>
+          </div>
+        </el-upload>
+        <p
+          v-if="result.message && !result.success"
+          class="tips-info tips-error"
+        >
+          {{ result.message }}
+        </p>
+      </div>
+    </div>
+
+    <template v-if="!autoUpload" #footer>
+      <el-button @click="close">取消</el-button>
+      <el-button
+        type="primary"
+        :disabled="loading || !canUpload"
+        @click="confirm"
+        >确认</el-button
+      >
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+  import { computed, ref } from 'vue';
+  import { fileMD5 } from '@/utils/md5';
+  import { ElMessage, ElMessageBox } from 'element-plus';
+  import type { UploadProps, UploadRequestOptions, UploadFile, UploadFiles } from 'element-plus';
+  import { UploadFilled } from '@element-plus/icons-vue';
+  import axios, { AxiosError } from 'axios';
+
+  import useModal from '@/hooks/modal';
+
+  defineOptions({
+    name: 'ImportDialog',
+  });
+
+  /* modal */
+  const { visible, open, close } = useModal();
+  defineExpose({ open, close });
+
+  interface PromiseFunc {
+    (): Promise<void>;
+  }
+
+  const props = withDefaults(
+    defineProps<{
+      title: string;
+      uploadUrl: string;
+      format?: string[];
+      uploadData?: Record<string, any>;
+      maxSize?: number;
+      uploadFilename?: string;
+      autoUpload?: boolean;
+      disabled?: boolean;
+      uploadFileAlias?: string;
+      downloadUrl?: string;
+      downloadFilename?: string;
+      downloadHandle?: PromiseFunc;
+      beforeSubmitHandle?: PromiseFunc;
+      successMessage?: string;
+    }>(),
+    {
+      title: '文件上传',
+      uploadUrl: '',
+      format: () => ['xls', 'xlsx'],
+      uploadData: () => {
+        return {};
+      },
+      maxSize: 20 * 1024 * 1024,
+      uploadFileAlias: 'file',
+      autoUpload: true,
+      disabled: false,
+      successMessage: '上传成功',
+    }
+  );
+
+  const emit = defineEmits([
+    'uploading',
+    'uploadError',
+    'uploadSuccess',
+    'validError',
+  ]);
+
+  const uploadRef = ref();
+  const canUpload = ref(false);
+  const uploadDataDict = ref({});
+  const headers = ref({ md5: '' });
+  const result = ref({ success: true, message: '' });
+  const loading = ref(false);
+  const customFileList = ref<UploadFile[]>([]);
+
+  const dfilename = computed(() => {
+    if (props.downloadFilename) return props.downloadFilename;
+    return props.downloadUrl ? props.downloadUrl.split('/').pop() : '';
+  });
+  const accept = computed(() => {
+    return props.format.map((el) => `.${el}`).join();
+  });
+
+  async function confirm() {
+    loading.value = true;
+    if (props.beforeSubmitHandle) {
+      let handleResult = true;
+      await props.beforeSubmitHandle().catch(() => {
+        handleResult = false;
+      });
+      if (!handleResult) return;
+    }
+    uploadRef.value?.submit();
+  }
+
+  function checkFileFormat(fileType: string) {
+    const fileFormat = fileType.split('.').pop()?.toLocaleLowerCase();
+    return props.format.some((item) => item.toLocaleLowerCase() === fileFormat);
+  }
+
+  function handleFileChange(uploadFile: UploadFile, uploadFiles: UploadFiles) {
+    if (uploadFiles.length) {
+      // Element Plus 的 onChange 会在文件状态改变时都触发,包括移除
+      // 这里只处理添加新文件的场景,移除由 el-upload 内部处理
+      if (uploadFile.status === 'ready') {
+        customFileList.value = [uploadFile];
+      } else if (uploadFile.status === 'success' || uploadFile.status === 'fail') {
+        // 如果是上传成功或失败,也更新列表以显示状态
+        const index = customFileList.value.findIndex(f => f.uid === uploadFile.uid);
+        if (index !== -1) {
+          customFileList.value.splice(index, 1, uploadFile);
+        } else {
+          customFileList.value = [uploadFile]; // 如果之前列表为空
+        }
+      }
+      // 清理旧的 result message
+      const currentFileInList = customFileList.value.find(f => f.uid === uploadFile.uid);
+      if (currentFileInList && currentFileInList.uid !== uploadFile.uid) { // 理论上这里 customFileList 只有一个文件
+        result.value = {
+          success: true,
+          message: '',
+        };
+      }
+    } else {
+      customFileList.value = [];
+      result.value = {
+        success: true,
+        message: '',
+      };
+    }
+    canUpload.value = customFileList.value[0]?.status === 'ready';
+  }
+
+  async function handleBeforeUpload(file: File): Promise<boolean | File> {
+    uploadDataDict.value = {
+      ...props.uploadData,
+      filename: file.name,
+    };
+
+    if (file.size > props.maxSize) {
+      handleExceededSize();
+      return Promise.reject(result.value);
+    }
+
+    if (!checkFileFormat(file.name)) {
+      handleFormatError();
+      return Promise.reject(result.value);
+    }
+
+    const md5 = await fileMD5(file);
+    headers.value.md5 = md5;
+
+    if (props.autoUpload) loading.value = true;
+
+    return true;
+  }
+
+  interface UploadResultType {
+    hasError: boolean;
+    failRecords: Array<{
+      msg: string;
+      lineNum: number;
+    }>;
+  }
+
+  function customRequest(options: UploadRequestOptions): XMLHttpRequest {
+    const { onProgress, onError, onSuccess, file, data } = options;
+
+    const formData = new FormData();
+    const paramData: Record<string, any> = data || {};
+    Object.entries(paramData).forEach(([k, v]) => {
+      formData.append(k, v);
+    });
+    formData.append(props.uploadFileAlias, file as File);
+    emit('uploading');
+    const uploadController = new AbortController();
+
+    (
+      axios.post(option.action as string, formData, {
+        headers: option.headers,
+        signal: uploadController.signal,
+        onUploadProgress: ({ loaded, total }) => {
+          onProgress({ percent: Math.floor((100 * loaded) / (total || 0)) });
+        },
+      }) as Promise<UploadResultType>
+    )
+      .then((res) => {
+        // 所有excel导入的特殊处理
+        if (res.hasError) {
+          const failRecords = res.failRecords;
+          const message = failRecords
+            .map((item) =>
+              item.lineNum ? `第${item.lineNum}行:${item.msg}` : item.msg
+            )
+            .join('。');
+
+          onError({ data: { message } });
+          return;
+        }
+        onSuccess(res);
+      })
+      .catch((error: AxiosError) => {
+        onError(error.response);
+      });
+
+    return {
+      abort: uploadController.abort,
+    };
+  }
+
+  function handleError(fileItem: FileItem) {
+    canUpload.value = false;
+    loading.value = false;
+    result.value = {
+      success: false,
+      message: fileItem.response.data?.message || '上传错误',
+    };
+    emit('uploadError', result.value);
+  }
+  function handleSuccess(response: any, uploadFile: UploadFile) {
+    canUpload.value = false;
+    loading.value = false;
+    result.value = {
+      success: true,
+      message: '上传成功!',
+    };
+    ElMessage.success(props.successMessage);
+    emit('uploadSuccess', {
+      ...result.value,
+      filename: uploadFile.name,
+      response: response,
+    });
+  }
+
+  function handleFormatError() {
+    const content = `只支持文件格式为${props.format.join('/')}`;
+    result.value = {
+      success: false,
+      message: content,
+    };
+    loading.value = false;
+    ElElMessage.error(content);
+    emit('validError', result.value);
+  }
+  function handleExceededSize() {
+    const content = `文件大小不能超过${Math.floor(
+      props.maxSize / (1024 * 1024)
+    )}M`;
+    result.value = {
+      success: false,
+      message: content,
+    };
+    loading.value = false;
+    ElElMessage.error(content);
+    emit('validError', result.value);
+  }
+
+  function modalBeforeOpen() {
+    canUpload.value = false;
+    result.value = {
+      success: true,
+      message: '',
+    };
+    headers.value = { md5: '' };
+    loading.value = false;
+    uploadDataDict.value = {};
+    customFileList.value = [];
+  }
+</script>
+
+<style lang="less">
+  .import-box {
+    .import-temp {
+      display: flex;
+      justify-content: space-between;
+      margin-bottom: 10px;
+
+      > span {
+        flex-grow: 0;
+        flex-shrink: 0;
+        height: 20px;
+        line-height: 20px;
+        display: block;
+      }
+
+      .temp-btn {
+        flex-grow: 2;
+        text-align: left;
+
+        > a {
+          flex-grow: 2;
+          line-height: 20px;
+          color: var(--color-primary);
+
+          &:hover {
+            text-decoration: underline;
+            opacity: 0.8;
+          }
+        }
+      }
+      .arco-btn {
+        line-height: 20px;
+        height: auto;
+        padding: 0;
+        background: transparent;
+        border: none;
+
+        &:hover {
+          text-decoration: underline;
+          opacity: 0.8;
+        }
+      }
+    }
+    .arco-upload-drag {
+      padding: 40px 0;
+      > div:first-child {
+        height: 54px;
+        background-image: url(assets/images/upload-icon.png);
+        background-size: auto 100%;
+        background-repeat: no-repeat;
+        background-position: center;
+        margin-bottom: 16px;
+      }
+      svg {
+        display: none;
+      }
+    }
+
+    .arco-upload-list-item {
+      margin-top: 8px !important;
+      background-color: var(--color-fill-1);
+      border-radius: var(--border-radius-small);
+      .arco-upload-list-item-operation {
+        margin: 0 12px;
+      }
+
+      .svg-icon {
+        vertical-align: -2px;
+      }
+      .arco-upload-list-item-file-icon {
+        margin-right: 6px;
+        color: inherit;
+      }
+
+      &.arco-upload-list-item-error {
+        .arco-upload-list-item-file-icon {
+          color: var(--color-danger);
+        }
+      }
+    }
+    .arco-upload-progress {
+      > * {
+        display: none;
+      }
+      .arco-upload-icon-success {
+        display: block;
+      }
+    }
+
+    .tips-info {
+      max-height: 100px;
+      overflow: hidden;
+      margin-top: 5px;
+    }
+  }
+</style>

+ 30 - 0
src/components/index.ts

@@ -0,0 +1,30 @@
+import { App } from 'vue';
+
+// selection
+import SvgIcon from './svg-icon/index.vue';
+import SelectTask from './select-task/index.vue';
+import SelectTeaching from './select-teaching/index.vue';
+import SelectAgent from './select-agent/index.vue';
+import SelectRoom from './select-room/index.vue';
+import SelectRangeDatetime from './select-range-datetime/index.vue';
+import SelectRangeTime from './select-range-time/index.vue';
+import SelectCity from './select-city/index.vue';
+import StatusTag from './status-tag/index.vue';
+import UploadButton from './upload-button/index.vue';
+import SelectRole from './select-role/index.vue';
+
+export default {
+  install(Vue: App) {
+    Vue.component('SvgIcon', SvgIcon);
+    Vue.component('SelectTask', SelectTask);
+    Vue.component('SelectTeaching', SelectTeaching);
+    Vue.component('SelectAgent', SelectAgent);
+    Vue.component('SelectRoom', SelectRoom);
+    Vue.component('SelectRangeDatetime', SelectRangeDatetime);
+    Vue.component('SelectRangeTime', SelectRangeTime);
+    Vue.component('SelectCity', SelectCity);
+    Vue.component('StatusTag', StatusTag);
+    Vue.component('UploadButton', UploadButton);
+    Vue.component('SelectRole', SelectRole);
+  },
+};

+ 57 - 0
src/components/select-range-datetime/index.vue

@@ -0,0 +1,57 @@
+<template>
+  <a-range-picker
+    v-model="selected"
+    :show-time="showTime"
+    value-format="timestamp"
+    format="YYYY-MM-DD HH:mm"
+    :time-picker-props="{
+      defaultValue: '00:00:00',
+    }"
+    :style="{ width: '340px' }"
+    unmount-on-close
+    v-bind="attrs"
+    @change="onChange"
+  />
+</template>
+
+<script setup lang="ts">
+  import { ref, watch, useAttrs } from 'vue';
+
+  defineOptions({
+    name: 'SelectRangeDatetime',
+  });
+
+  type TimeType = number | undefined;
+
+  const props = withDefaults(
+    defineProps<{
+      startTime: TimeType;
+      endTime: TimeType;
+      showTime?: boolean;
+    }>(),
+    {
+      showTime: true,
+    }
+  );
+  const emit = defineEmits(['update:startTime', 'update:endTime', 'change']);
+  const attrs = useAttrs();
+
+  const selected = ref<number[]>();
+
+  function onChange(value: any) {
+    const vals = (value || []) as TimeType[];
+    emit('update:startTime', vals[0]);
+    emit('update:endTime', vals[1]);
+    emit('change', vals);
+  }
+
+  watch(
+    () => [props.startTime, props.endTime],
+    (val) => {
+      selected.value = (val || []) as number[];
+    },
+    {
+      immediate: true,
+    }
+  );
+</script>

+ 52 - 0
src/components/select-range-time/index.vue

@@ -0,0 +1,52 @@
+<template>
+  <a-time-picker
+    v-model="selected"
+    type="time-range"
+    :format="format"
+    :style="{ width: '200px' }"
+    v-bind="attrs"
+    @change="onChange"
+  />
+</template>
+
+<script setup lang="ts">
+  import { ref, watch, useAttrs } from 'vue';
+
+  defineOptions({
+    name: 'SelectRangeDatetime',
+  });
+
+  type TimeType = string | undefined;
+
+  const props = withDefaults(
+    defineProps<{
+      startTime: TimeType;
+      endTime: TimeType;
+      format?: string;
+    }>(),
+    {
+      format: 'HH:mm',
+    }
+  );
+  const emit = defineEmits(['update:startTime', 'update:endTime', 'change']);
+  const attrs = useAttrs();
+
+  const selected = ref<string[]>();
+
+  function onChange(value: any) {
+    const vals = (value || []) as TimeType[];
+    emit('update:startTime', vals[0]);
+    emit('update:endTime', vals[1]);
+    emit('change', vals);
+  }
+
+  watch(
+    () => [props.startTime, props.endTime],
+    (val) => {
+      selected.value = [val[0] || '', val[1] || ''];
+    },
+    {
+      immediate: true,
+    }
+  );
+</script>

+ 75 - 0
src/components/select-task/index.vue

@@ -0,0 +1,75 @@
+<template>
+  <a-select
+    v-model="selected"
+    :placeholder="placeholder"
+    :allow-clear="clearable"
+    :disabled="disabled"
+    :options="optionList"
+    allow-search
+    popup-container="body"
+    v-bind="attrs"
+    :trigger-props="{ autoFitPopupMinWidth: true }"
+    @change="onChange"
+  >
+    <template v-if="prefix" #prefix>任务</template>
+  </a-select>
+</template>
+
+<script setup lang="ts">
+  import { ref, useAttrs, watch } from 'vue';
+  import { taskQuery } from '@/api/order';
+
+  defineOptions({
+    name: 'SelectTask',
+  });
+  type ValueType = number | Array<number> | null;
+
+  const props = defineProps<{
+    modelValue: ValueType;
+    clearable?: boolean;
+    disabled?: boolean;
+    placeholder?: string;
+    multiple?: boolean;
+    prefix?: boolean;
+  }>();
+  const emit = defineEmits(['update:modelValue', 'change']);
+  const attrs = useAttrs();
+
+  interface OptionListItem {
+    value: number;
+    label: string;
+  }
+
+  const selected = ref<number | Array<number> | undefined>();
+  const optionList = ref<OptionListItem[]>([]);
+  const search = async () => {
+    optionList.value = [];
+    const resData = await taskQuery();
+
+    optionList.value = (resData || []).map((item) => {
+      return { ...item, value: item.id, label: item.name };
+    });
+  };
+  search();
+
+  const onChange = () => {
+    const selectedData = props.multiple
+      ? optionList.value.filter(
+          (item) =>
+            selected.value && (selected.value as number[]).includes(item.value)
+        )
+      : optionList.value.filter((item) => selected.value === item.value);
+    emit('update:modelValue', selected.value || null);
+    emit('change', props.multiple ? selectedData : selectedData[0]);
+  };
+
+  watch(
+    () => props.modelValue,
+    (val) => {
+      selected.value = val || undefined;
+    },
+    {
+      immediate: true,
+    }
+  );
+</script>

+ 96 - 0
src/components/select-teaching/index.vue

@@ -0,0 +1,96 @@
+<template>
+  <a-select
+    v-model="selected"
+    :placeholder="placeholder"
+    :allow-clear="clearable"
+    :disabled="disabled"
+    :options="optionList"
+    allow-search
+    popup-container="body"
+    v-bind="attrs"
+    :trigger-props="{ autoFitPopupMinWidth: true }"
+    @change="onChange"
+  >
+    <template v-if="prefix || prefixStr" #prefix>{{
+      prefixStr || '教学点'
+    }}</template>
+  </a-select>
+</template>
+
+<script setup lang="ts">
+  import { ref, useAttrs, watch } from 'vue';
+  import { teachingQuery } from '@/api/order';
+
+  defineOptions({
+    name: 'SelectTeaching',
+  });
+
+  type ValueType = number | Array<number> | null;
+
+  const props = defineProps<{
+    modelValue: ValueType;
+    clearable?: boolean;
+    disabled?: boolean;
+    placeholder?: string;
+    multiple?: boolean;
+    prefix?: boolean;
+    prefixStr?: string;
+    flag?: boolean;
+    taskId?: any;
+  }>();
+  const emit = defineEmits(['update:modelValue', 'change', 'getOptions']);
+  const attrs = useAttrs();
+
+  interface OptionListItem {
+    value: number;
+    label: string;
+  }
+
+  const selected = ref<number | Array<number> | undefined>();
+  const optionList = ref<OptionListItem[]>([]);
+  const search = async () => {
+    optionList.value = [];
+    const datas = { flag: props.flag ? props.flag : undefined };
+
+    const resData = await teachingQuery(datas);
+    emit('getOptions', resData);
+
+    optionList.value = (resData || []).map((item) => {
+      return { ...item, value: item.id, label: item.name };
+    });
+  };
+  search();
+
+  const onChange = () => {
+    const selectedData = props.multiple
+      ? optionList.value.filter(
+          (item) =>
+            selected.value && (selected.value as number[]).includes(item.value)
+        )
+      : optionList.value.filter((item) => selected.value === item.value);
+    emit('update:modelValue', selected.value || null);
+    emit('change', props.multiple ? selectedData : selectedData[0]);
+  };
+
+  watch(
+    () => props.modelValue,
+    (val) => {
+      selected.value = val || undefined;
+    },
+    {
+      immediate: true,
+    }
+  );
+  watch(
+    () => props.taskId,
+    (val) => {
+      if (!val) {
+        optionList.value = [];
+        selected.value = undefined;
+        emit('update:modelValue', selected.value || null);
+      } else {
+        search();
+      }
+    }
+  );
+</script>

+ 49 - 0
src/components/status-tag/index.vue

@@ -0,0 +1,49 @@
+<template>
+  <a-tag :color="theme">{{ label }}</a-tag>
+</template>
+
+<script setup lang="ts">
+  import { computed } from 'vue';
+  import useDictOption from '@/hooks/dict-option';
+
+  defineOptions({
+    name: 'StatusTag',
+  });
+
+  const configs = {
+    enable: {
+      themeDict: { true: 'green', false: 'red' },
+      valFilter: useDictOption('ABLE_TYPE').getLabel,
+    },
+    taskStatus: {
+      themeDict: { RUNNING: 'blue', SUCCESS: 'green', FAILURE: 'red' },
+      valFilter: useDictOption('TASK_STATUS').getLabel,
+    },
+    recordCancel: {
+      themeDict: { true: 'red', false: 'green' },
+      valFilter: (val: boolean) => (val ? '已取消' : '正常'),
+    },
+  };
+  type ConfigKeyType = keyof typeof configs;
+
+  const props = defineProps({
+    value: { type: [Boolean, String, Number], default: '' },
+    type: { type: String },
+  });
+
+  function getConfig(type: ConfigKeyType) {
+    // @ts-ignore
+    return configs[type] || { themeDict: {}, valFilter: (val) => val };
+  }
+
+  const { themeDict, valFilter } = getConfig(props.type as ConfigKeyType);
+
+  const theme = computed(() => {
+    // @ts-ignore
+    return themeDict[props.value];
+  });
+  const label = computed(() => {
+    // @ts-ignore
+    return valFilter(props.value);
+  });
+</script>

+ 59 - 0
src/components/svg-icon/index.vue

@@ -0,0 +1,59 @@
+<template>
+  <component :is="iconComponent" class="svg-icon" :fill="fill"></component>
+</template>
+
+<script setup lang="ts">
+  import { snakeToHump } from '@/utils/utils';
+  import { computed } from 'vue';
+  import IconHome from '../../assets/svgs/icon-home.svg?component';
+  import IconLogout from '../../assets/svgs/icon-logout.svg?component';
+  import IconOrg from '../../assets/svgs/icon-org.svg?component';
+  import IconSystem from '../../assets/svgs/icon-system.svg?component';
+  import IconUser from '../../assets/svgs/icon-user.svg?component';
+  import IconImport from '../../assets/svgs/icon-import.svg?component';
+  import IconAdd from '../../assets/svgs/icon-add.svg?component';
+  import IconDelete from '../../assets/svgs/icon-delete.svg?component';
+  import IconApply from '../../assets/svgs/icon-apply.svg?component';
+  import IconAssign from '../../assets/svgs/icon-assign.svg?component';
+  import IconPrint from '../../assets/svgs/icon-print.svg?component';
+  import IconFile from '../../assets/svgs/icon-file.svg?component';
+  import IconSuccess from '../../assets/svgs/icon-success.svg?component';
+  import IconError from '../../assets/svgs/icon-error.svg?component';
+  import IconBase from '../../assets/svgs/icon-base.svg?component';
+
+  defineOptions({
+    name: 'SvgIcon',
+  });
+
+  const props = withDefaults(
+    defineProps<{
+      name: string;
+      fill?: string;
+    }>(),
+    {
+      fill: 'currentColor',
+    }
+  );
+
+  const icons = {
+    IconHome,
+    IconLogout,
+    IconOrg,
+    IconSystem,
+    IconUser,
+    IconImport,
+    IconAdd,
+    IconDelete,
+    IconApply,
+    IconAssign,
+    IconPrint,
+    IconFile,
+    IconSuccess,
+    IconError,
+    IconBase,
+  };
+
+  const iconName = snakeToHump(props.name) as keyof typeof icons;
+
+  const iconComponent = computed(() => icons[iconName]);
+</script>

+ 237 - 0
src/components/upload-button/index.vue

@@ -0,0 +1,237 @@
+<template>
+  <div class="file-upload">
+    <el-upload
+      ref="uploadRef"
+      :action="uploadUrl"
+      :headers="headers"
+      :data="uploadDataDict"
+      :show-file-list="false"
+      :auto-upload="autoUpload"
+      :http-request="customRequest"
+      :disabled="disabled"
+      :before-upload="handleBeforeUpload"
+      :accept="accept"
+      :multiple="multiple"
+      :on-exceed="handleExceededSize"
+      @change="handleFileChange"
+      @error="handleError"
+      @success="handleSuccess"
+    >
+      <template #trigger>
+        <el-button type="primary" :disabled="loading">{{ btnText }}</el-button>
+        <!-- <el-button
+          v-if="!autoUpload"
+          type="primary"
+          :loading="loading"
+          :disabled="!canUpload"
+          @click.stop="startUpload"
+          >开始上传</el-button
+        > -->
+      </template>
+    </el-upload>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { ref } from 'vue';
+  import { fileMD5 } from '@/utils/md5';
+  import { ElMessage } from 'element-plus';
+  import type {
+    UploadProps,
+    UploadRequestOptions,
+    UploadFile,
+    UploadFiles,
+    UploadRawFile,
+  } from 'element-plus';
+  import axios from 'axios';
+
+  defineOptions({
+    name: 'UploadButton',
+  });
+
+  const props = withDefaults(
+    defineProps<{
+      uploadUrl: string;
+      uploadData?: Record<string, any>;
+      maxSize?: number;
+      uploadFileAlias?: string;
+      autoUpload?: boolean;
+      disabled?: boolean;
+      btnText?: string;
+      accept?: string;
+      multiple?: boolean;
+    }>(),
+    {
+      uploadUrl: '',
+      uploadData: () => {
+        return {};
+      },
+      maxSize: 20 * 1024 * 1024,
+      uploadFileAlias: 'file',
+      autoUpload: true,
+      disabled: false,
+      btnText: '选择',
+      accept: '.xls,.xlsx',
+      multiple: false,
+    }
+  );
+
+  const emit = defineEmits([
+    'uploading',
+    'uploadError',
+    'uploadSuccess',
+    'validError',
+  ]);
+
+  const uploadRef = ref();
+  const attachmentName = ref('');
+  const canUpload = ref(false);
+  const uploadDataDict = ref({});
+  const headers = ref({ md5: '' });
+  const result = ref({ success: true, message: '' });
+  const loading = ref(false);
+
+  function startUpload() {
+    loading.value = true;
+    uploadRef.value?.submit();
+  }
+
+  function handleFileChange(uploadFile: UploadFile, uploadFiles: UploadFiles) {
+    if (props.autoUpload || !uploadFiles.length) return;
+    // Element Plus's status: ready, uploading, success, fail
+    // Arco's status: init, uploading, success, error
+    // We'll assume 'ready' is similar to 'init' for this logic, though direct mapping might not be perfect.
+    canUpload.value = uploadFiles[0]?.status === 'ready';
+  }
+
+  async function handleBeforeUpload(rawFile: UploadRawFile) {
+    uploadDataDict.value = {
+      ...props.uploadData,
+      filename: rawFile.name,
+    };
+
+    if (rawFile.size > props.maxSize) {
+      // Element Plus uses on-exceed for this, but we can also check here
+      // For consistency with original logic, we call handleExceededSize and reject
+      // However, on-exceed will also be triggered if :limit is set for file count, not size directly.
+      // Here, we manually trigger the message and prevent upload.
+      const content = `文件大小不能超过${Math.floor(props.maxSize / 1024 / 1024)}MB`;
+      ElMessage.error(content);
+      result.value = {
+        success: false,
+        message: content,
+      };
+      emit('validError', result.value);
+      return Promise.reject(new Error(content));
+    }
+
+    const md5 = await fileMD5(rawFile);
+    headers.value.md5 = md5;
+
+    if (props.autoUpload) loading.value = true;
+
+    return true;
+  }
+
+  function customRequest(options: UploadRequestOptions): XMLHttpRequest {
+    const { onProgress, onError, onSuccess, file, data, headers: reqHeaders, action } = options;
+
+    const formData = new FormData();
+    const paramData: Record<string, any> = data || {};
+    Object.entries(paramData).forEach(([k, v]) => {
+      formData.append(k, v as string | Blob);
+    });
+    formData.append(props.uploadFileAlias, file);
+    emit('uploading');
+
+    const xhr = new XMLHttpRequest();
+    xhr.open('POST', action, true);
+
+    if (reqHeaders) {
+      Object.keys(reqHeaders).forEach((key) => {
+        xhr.setRequestHeader(key, reqHeaders[key]);
+      });
+    }
+
+    xhr.upload.onprogress = (e) => {
+      if (e.lengthComputable) {
+        onProgress({ percent: Math.floor((e.loaded / e.total) * 100) } as any); // Cast to any to match UploadProgressEvent structure if needed
+      }
+    };
+
+    xhr.onload = () => {
+      if (xhr.status >= 200 && xhr.status < 300) {
+        onSuccess(JSON.parse(xhr.responseText));
+      } else {
+        onError(new Error(xhr.statusText || 'Upload failed'));
+      }
+    };
+
+    xhr.onerror = () => {
+      onError(new Error('Upload failed with network error'));
+    };
+
+    xhr.send(formData);
+
+    return xhr;
+  }
+
+  function handleError(error: Error, uploadFile: UploadFile, uploadFiles: UploadFiles) {
+    canUpload.value = false;
+    loading.value = false;
+    result.value = {
+      success: false,
+      message: error.message || '上传失败',
+    };
+    ElMessage.error(result.value.message);
+    emit('uploadError', result.value);
+  }
+  function handleSuccess(response: any, uploadFile: UploadFile, uploadFiles: UploadFiles) {
+    canUpload.value = false;
+    loading.value = false;
+    result.value = {
+      success: true,
+      message: '上传成功!',
+    };
+    ElMessage.success('上传成功!');
+    emit('uploadSuccess', {
+      ...result.value,
+      filename: uploadFile.name,
+      response: response,
+    });
+  }
+
+  function handleExceededSize(files: File[]) {
+    // This function is now primarily called by el-upload's on-exceed prop
+    // The before-upload also has a check, this is a fallback or for multiple files if `limit` (count) is exceeded.
+    const file = files[0]; // Assuming single file upload context for this message
+    const content = `文件 ${file.name} 大小超过限制 (${Math.floor(props.maxSize / 1024 / 1024)}MB)`;
+    result.value = {
+      success: false,
+      message: content,
+    };
+    loading.value = false;
+    ElMessage.error(content);
+    emit('validError', result.value);
+  }
+</script>
+
+<style lang="less">
+  .file-upload {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    width: 100%;
+    .arco-upload-hide {
+      display: inline-block;
+    }
+    .el-input__wrapper {
+      flex-grow: 2;
+      margin-right: 10px;
+    }
+    .arco-upload {
+      flex-grow: 0;
+      flex-shrink: 0;
+    }
+  }
+</style>

+ 12 - 0
src/constants/app.ts

@@ -0,0 +1,12 @@
+import { md5 } from 'js-md5';
+
+export const PLATFORM = 'WEB';
+
+const getDevice = () => {
+  return md5(`${Math.random()}-${Date.now()}`);
+};
+
+if (!localStorage.getItem('deviceId')) {
+  localStorage.setItem('deviceId', getDevice());
+}
+export const DEVICE_ID = localStorage.getItem('deviceId') as string;

+ 25 - 0
src/constants/enumerate.ts

@@ -0,0 +1,25 @@
+export const SYS_ADMIN_NAME = 'sysadmin';
+
+export const DEFAULT_LABEL = '--';
+
+// 通用 -------------->
+// 启用/禁用
+export const ABLE_TYPE = {
+  false: '禁用',
+  true: '启用',
+};
+
+// 基础 -------------->
+// 角色
+export const ROLE_TYPE = {
+  ADMIN: '学校管理员',
+  TEACHING: '教学点管理员',
+};
+
+export const TASK_STATUS = {
+  RUNNING: '进行中',
+  SUCCESS: '成功',
+  FAILURE: '失败',
+};
+
+export type RoleType = keyof typeof ROLE_TYPE;

+ 12 - 0
src/env.d.ts

@@ -0,0 +1,12 @@
+/// <reference types="vite/client" />
+/// <reference types="vite-svg-loader" />
+
+declare module '*.vue' {
+  import { DefineComponent } from 'vue';
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
+  const component: DefineComponent<{}, {}, any>;
+  export default component;
+}
+interface ImportMetaEnv {
+  readonly VITE_API_BASE_URL: string;
+}

+ 31 - 0
src/hooks/dict-option.ts

@@ -0,0 +1,31 @@
+import { ref } from 'vue';
+import {
+  DEFAULT_LABEL,
+  ABLE_TYPE,
+  ROLE_TYPE,
+  TASK_STATUS,
+} from '@/constants/enumerate';
+import { dictToOption } from '@/utils/utils';
+import { Options } from '@/types/global';
+
+const dicts = {
+  ABLE_TYPE,
+  ROLE_TYPE,
+  TASK_STATUS,
+};
+
+type DictTypeType = keyof typeof dicts;
+
+export default function useDictOption(dictType: DictTypeType) {
+  const optionList = ref<Options[]>(dictToOption(dicts[dictType] || {}));
+
+  function getLabel(val: string): string {
+    // @ts-ignore
+    return dicts[dictType] ? dicts[dictType][val] : DEFAULT_LABEL;
+  }
+
+  return {
+    optionList,
+    getLabel,
+  };
+}

+ 16 - 0
src/hooks/loading.ts

@@ -0,0 +1,16 @@
+import { ref } from 'vue';
+
+export default function useLoading(initValue = false) {
+  const loading = ref(initValue);
+  const setLoading = (value: boolean) => {
+    loading.value = value;
+  };
+  const toggle = () => {
+    loading.value = !loading.value;
+  };
+  return {
+    loading,
+    setLoading,
+    toggle,
+  };
+}

+ 22 - 0
src/hooks/modal.ts

@@ -0,0 +1,22 @@
+import { ref } from 'vue';
+
+export default function useModal() {
+  const visible = ref(false);
+
+  function open() {
+    visible.value = true;
+  }
+  function close() {
+    visible.value = false;
+  }
+  function toggle() {
+    visible.value = !visible.value;
+  }
+
+  return {
+    visible,
+    open,
+    close,
+    toggle,
+  };
+}

+ 25 - 0
src/hooks/request.ts

@@ -0,0 +1,25 @@
+import { ref, UnwrapRef } from 'vue';
+import { AxiosResponse } from 'axios';
+import useLoading from './loading';
+
+// use to fetch list
+// Don't use async function. It doesn't work in async function.
+// Use the bind function to add parameters
+// example: useRequest(api.bind(null, {}))
+
+export default function useRequest<T>(
+  api: () => Promise<AxiosResponse>,
+  defaultValue = [] as unknown as T,
+  isLoading = true
+) {
+  const { loading, setLoading } = useLoading(isLoading);
+  const response = ref<T>(defaultValue);
+  api()
+    .then((res) => {
+      response.value = res as unknown as UnwrapRef<T>;
+    })
+    .finally(() => {
+      setLoading(false);
+    });
+  return { loading, response };
+}

+ 31 - 0
src/hooks/responsive.ts

@@ -0,0 +1,31 @@
+import { onMounted, onBeforeMount, onBeforeUnmount } from 'vue';
+import { useDebounceFn } from '@vueuse/core';
+import { useAppStore } from '@/store';
+import { addEventListen, removeEventListen } from '@/utils/event';
+
+const WIDTH = 992; // https://arco.design/vue/component/grid#responsivevalue
+
+function queryDevice() {
+  const rect = document.body.getBoundingClientRect();
+  return rect.width - 1 < WIDTH;
+}
+
+export default function useResponsive(immediate?: boolean) {
+  const appStore = useAppStore();
+  function resizeHandler() {
+    if (!document.hidden) {
+      const isMobile = queryDevice();
+      appStore.toggleDevice(isMobile ? 'mobile' : 'desktop');
+    }
+  }
+  const debounceFn = useDebounceFn(resizeHandler, 100);
+  onMounted(() => {
+    if (immediate) debounceFn();
+  });
+  onBeforeMount(() => {
+    addEventListen(window, 'resize', debounceFn);
+  });
+  onBeforeUnmount(() => {
+    removeEventListen(window, 'resize', debounceFn);
+  });
+}

+ 65 - 0
src/hooks/sms.ts

@@ -0,0 +1,65 @@
+import { ref } from 'vue';
+import { lls } from '@/utils/storage';
+
+export default function useSms(name = 'sms') {
+  const defaultCodeWaitingTime = 60;
+  const codeWaitingTime = ref(60);
+  const isFetchingCode = ref(false);
+  const codeContent = ref('获取验证码');
+  const nameWaitTime = ref('');
+  let tSetT: NodeJS.Timer | null = null;
+
+  function setWaitingTime(wt: string) {
+    nameWaitTime.value = wt;
+    const codetime = lls.get(wt);
+    if (codetime) {
+      const num = Math.floor((codetime.expire - new Date().getTime()) / 1000);
+      if (num > 0) {
+        codeWaitingTime.value = num;
+        isFetchingCode.value = true;
+        changeContent();
+      }
+    }
+  }
+  setWaitingTime(name);
+
+  function changeContent() {
+    if (!isFetchingCode.value) return;
+    codeContent.value = `倒计时${codeWaitingTime.value}s`;
+
+    const circleTime = (time: number) => {
+      tSetT = setInterval(() => {
+        if (time > 1) {
+          time--;
+          const expire = new Date().getTime() + time * 1000;
+          lls.set(
+            nameWaitTime.value,
+            {
+              time,
+              expire,
+            },
+            expire
+          );
+          codeContent.value = `倒计时${time}s`;
+        } else {
+          clearSetContent();
+        }
+      }, 1e3);
+    };
+    circleTime(codeWaitingTime.value);
+  }
+
+  function clearSetContent() {
+    codeWaitingTime.value = defaultCodeWaitingTime;
+    lls.remove(nameWaitTime.value);
+    codeContent.value = '获取验证码';
+    isFetchingCode.value = false;
+    if (tSetT) clearInterval(tSetT);
+  }
+
+  return {
+    isFetchingCode,
+    codeContent,
+    changeContent,
+  };
+}

+ 67 - 0
src/hooks/table.ts

@@ -0,0 +1,67 @@
+import { ref, isRef } from 'vue';
+import { PageResult } from '@/api/types/common';
+
+export default function useTable<T extends Record<string, any>>(
+  apiFunc: (data: any) => Promise<PageResult<T>>,
+  searchModel: Record<string, any>,
+  initAutoFetch = false
+) {
+  const pageNumber = ref(1);
+  const pageSize = ref(10);
+  const total = ref(0);
+  const dataList = ref<T[]>();
+
+  async function getList() {
+    const datas = {
+      ...(isRef(searchModel || {}) ? searchModel.value : searchModel),
+      pageNumber: pageNumber.value,
+      pageSize: pageSize.value,
+    };
+    const data = await apiFunc(datas);
+    dataList.value = data.result;
+    total.value = data.totalCount;
+  }
+  if (initAutoFetch) getList();
+
+  async function toPage(page: number) {
+    pageNumber.value = page;
+    await getList();
+  }
+
+  async function pageSizeChange(size: number) {
+    pageSize.value = size;
+    await toPage(1);
+  }
+
+  function getRowIndex(index: number) {
+    return pageSize.value * (pageNumber.value - 1) + index + 1;
+  }
+
+  function deletePageLastItem(len = 1) {
+    let page = pageNumber.value || 1;
+    if (dataList.value && dataList.value.length === len) {
+      page = page > 1 ? page - 1 : 1;
+    }
+    toPage(page);
+  }
+
+  const pagination = ref({
+    total,
+    current: pageNumber,
+    pageSize,
+    showTotal: true,
+    showJumper: true,
+    showPageSize: true,
+    onChange: toPage,
+    onPageSizeChange: pageSizeChange,
+  });
+
+  return {
+    dataList,
+    pagination,
+    getRowIndex,
+    getList,
+    toPage,
+    deletePageLastItem,
+  };
+}

+ 24 - 0
src/hooks/user.ts

@@ -0,0 +1,24 @@
+import { useRouter } from 'vue-router';
+import { ElMessage } from 'element-plus';
+
+import { useUserStore } from '@/store';
+
+export default function useUser() {
+  const router = useRouter();
+  const userStore = useUserStore();
+  const logout = async (logoutTo?: string) => {
+    await userStore.logout();
+    const currentRoute = router.currentRoute.value;
+    ElMessage.success('登出成功');
+    router.push({
+      name: logoutTo && typeof logoutTo === 'string' ? logoutTo : 'login',
+      query: {
+        ...router.currentRoute.value.query,
+        redirect: currentRoute.name as string,
+      },
+    });
+  };
+  return {
+    logout,
+  };
+}

+ 16 - 0
src/hooks/visible.ts

@@ -0,0 +1,16 @@
+import { ref } from 'vue';
+
+export default function useVisible(initValue = false) {
+  const visible = ref(initValue);
+  const setVisible = (value: boolean) => {
+    visible.value = value;
+  };
+  const toggle = () => {
+    visible.value = !visible.value;
+  };
+  return {
+    visible,
+    setVisible,
+    toggle,
+  };
+}

+ 144 - 0
src/layout/default-layout.vue

@@ -0,0 +1,144 @@
+<template>
+  <div class="home">
+    <div class="home-header">
+      <div>
+        <h1 class="home-title">预约报名系统</h1>
+      </div>
+      <div class="home-action">
+        <!-- <div class="home-action-item">
+          <svg-icon name="icon-user" fill="#BFBFBF" />
+          <span :title="userStore.name">{{ userStore.name }}</span>
+        </div> -->
+        <a-tooltip content="修改密码" position="br">
+          <div class="home-action-item cursor" @click="toResetPwd">
+            <svg-icon name="icon-user" fill="#BFBFBF" />
+            <span :title="userStore.name">{{ userStore.name }}</span>
+          </div>
+        </a-tooltip>
+        <a-tooltip content="退出登录" position="br">
+          <div class="home-action-item cursor" @click="toLogout">
+            <svg-icon name="icon-logout" />
+          </div>
+        </a-tooltip>
+      </div>
+    </div>
+
+    <div class="home-navs">
+      <a-menu
+        v-if="appStore.appMenus && appStore.appMenus.length"
+        class="arco-menu-home"
+        :selected-keys="[curRouteName]"
+        auto-open
+        @menu-item-click="toMenuItem"
+      >
+        <template v-for="submenu in appStore.appMenus">
+          <a-sub-menu
+            v-if="submenu.children && submenu.children.length"
+            :key="submenu.url"
+          >
+            <template #icon>
+              <svg-icon :name="`icon-${submenu.url}`" />
+            </template>
+            <template #title>
+              <span>{{ submenu.name }}</span>
+            </template>
+
+            <a-menu-item v-for="nav in submenu.children" :key="nav.url">
+              <span>{{ nav.name }}</span>
+            </a-menu-item>
+          </a-sub-menu>
+          <a-menu-item v-else :key="submenu.url + 'menu'">
+            <span>{{ submenu.name }}</span>
+          </a-menu-item>
+        </template>
+      </a-menu>
+    </div>
+
+    <div class="home-body">
+      <div v-if="appStore.breadcrumbs.length" class="home-breadcrumb">
+        <span class="breadcrumb-tips">
+          <svg-icon name="icon-home" />
+          <span>当前所在位置:</span>
+        </span>
+        <a-breadcrumb>
+          <a-breadcrumb-item
+            v-for="(bread, index) in appStore.breadcrumbs"
+            :key="index"
+          >
+            {{ bread }}
+          </a-breadcrumb-item>
+        </a-breadcrumb>
+      </div>
+
+      <!-- home-view: page detail -->
+      <div class="home-view">
+        <router-view />
+      </div>
+
+      <Footer class="home-footer" />
+    </div>
+  </div>
+
+  <!-- ResetPwd -->
+  <ResetPwd ref="fesetPwdRef" @modified="resetPwdModified" />
+</template>
+
+<script lang="ts" setup>
+  import { onMounted, ref, watch } from 'vue';
+  import { useRoute, useRouter } from 'vue-router';
+  import { useAppStore, useUserStore } from '@/store';
+  import { modalConfirm } from '@/utils/arco';
+
+  import ResetPwd from '@/views/login/login/ResetPwd.vue';
+  import Footer from '@/components/footer/index.vue';
+
+  defineOptions({
+    name: 'DefaultLayout',
+  });
+
+  const appStore = useAppStore();
+  const userStore = useUserStore();
+  const route = useRoute();
+  const router = useRouter();
+
+  const curRouteName = ref('');
+  const fesetPwdRef = ref();
+
+  function initData() {
+    curRouteName.value = route.name as string;
+    // console.log(route.name);
+  }
+
+  function toMenuItem(val: string) {
+    router.push({ name: val });
+  }
+
+  function toResetPwd() {
+    fesetPwdRef.value?.open();
+  }
+
+  function resetPwdModified() {
+    setTimeout(() => {
+      userStore.logout();
+    }, 1000);
+  }
+
+  async function toLogout() {
+    const confirmRes = await modalConfirm('提示', `确定要退出登录吗?`).catch(
+      () => false
+    );
+    if (confirmRes !== 'confirm') return;
+    userStore.logout();
+  }
+
+  onMounted(() => {
+    initData();
+  });
+
+  watch(
+    () => route.name,
+    () => {
+      initData();
+    }
+  );
+</script>

+ 19 - 0
src/main.ts

@@ -0,0 +1,19 @@
+import { createApp } from 'vue';
+import globalComponents from '@/components';
+import router from './router';
+import store from './store';
+// import './mock';
+import App from './App.vue';
+import ElementPlus from 'element-plus';
+import 'element-plus/dist/index.css';
+import '@/assets/style/index.less';
+import '@/api/interceptor';
+
+const app = createApp(App);
+
+app.use(router);
+app.use(store);
+app.use(ElementPlus);
+app.use(globalComponents);
+
+app.mount('#app');

+ 38 - 0
src/mock/datas/user.ts

@@ -0,0 +1,38 @@
+export const menus = [
+  {
+    id: '1',
+    name: '考试预约管理',
+    url: 'base',
+    type: 'MENU',
+    parentId: '-1',
+    sequence: 1,
+    enable: true,
+  },
+  {
+    id: '2',
+    name: '预约任务管理',
+    url: 'TaskManage',
+    type: 'MENU',
+    parentId: '1',
+    sequence: 1,
+    enable: true,
+  },
+  {
+    id: '3',
+    name: '考生信息导入',
+    url: 'StudentManage',
+    type: 'MENU',
+    parentId: '1',
+    sequence: 2,
+    enable: true,
+  },
+  {
+    id: '4',
+    name: '预约名单详情',
+    url: 'OrderRecordManage',
+    type: 'MENU',
+    parentId: '1',
+    sequence: 3,
+    enable: true,
+  },
+];

+ 8 - 0
src/mock/index.ts

@@ -0,0 +1,8 @@
+import Mock from 'mockjs';
+
+import './user';
+import './task';
+
+Mock.setup({
+  timeout: '600-1000',
+});

+ 157 - 0
src/mock/task.ts

@@ -0,0 +1,157 @@
+import Mock from 'mockjs';
+import setupMock, {
+  pageListResponseWrap,
+  successResponseWrap,
+} from '@/utils/setup-mock';
+
+setupMock({
+  setup() {
+    // Mock.XHR.prototype.withCredentials = true;
+
+    // 教学点列表
+    Mock.mock(new RegExp('/api/apply/teaching/list'), () => {
+      return successResponseWrap([
+        {
+          id: '111',
+          name: '教学点11',
+        },
+        {
+          id: '222',
+          name: '教学点12',
+        },
+      ]);
+    });
+    // 考点列表
+    Mock.mock(new RegExp('/api/apply/agent/list'), () => {
+      return successResponseWrap([
+        {
+          id: '111',
+          name: '考点11',
+        },
+        {
+          id: '222',
+          name: '考点12',
+        },
+      ]);
+    });
+
+    // 预约任务查询
+    Mock.mock(new RegExp('/api/admin/apply/task/page'), () => {
+      return pageListResponseWrap([
+        {
+          id: '111',
+          name: '任务名称',
+          selfApplyStartTime: Date.now(),
+          selfApplyEndTime: Date.now(),
+          openApplyStartTime: Date.now(),
+          openApplyEndTime: Date.now(),
+          enable: true,
+          updateTime: Date.now(),
+        },
+      ]);
+    });
+
+    // 预约名单
+    // 预约名单详情分页
+    Mock.mock(new RegExp('/api/apply/std/page'), () => {
+      return pageListResponseWrap([
+        {
+          id: 1,
+          stdName: '张三',
+          identityNumber: '0120553551541',
+          studentCode: '123456789',
+          teachingName: '东莞学习中心',
+          agentName: 'A考点', // 未预约的时候为空
+          applyTimePeriod: '2024-03-01 08:00-12:00', // 未预约的时候为空
+          roomName: '考场', // 未排考为空
+          seatNumber: '1-1', // 未排考为空
+          operationTime: Date.now(), // 未预约的时候为空
+        },
+      ]);
+    });
+
+    // 教学点管理
+    // 教学点管理-查询
+    Mock.mock(new RegExp('/api/admin/teaching/query'), () => {
+      return pageListResponseWrap([
+        {
+          id: 1,
+          name: '教学点01',
+          code: 'jxd01',
+          cityId: 1,
+          cityName: '武汉',
+          capacity: 100,
+          enable: true,
+        },
+        {
+          id: 2,
+          name: '教学点02',
+          code: 'jxd02',
+          cityId: 2,
+          cityName: '长沙',
+          capacity: 100,
+          enable: true,
+        },
+      ]);
+    });
+    // 考点管理
+    // 考点管理-查询
+    Mock.mock(new RegExp('/api/admin/agent/query'), () => {
+      return pageListResponseWrap([
+        {
+          id: 1,
+          name: '考点点01',
+          code: 'kd01',
+          teachingId: 1,
+          teachingName: '教学点01',
+          address: '武汉市洪山区关山大道',
+          capacity: 150,
+          enable: true,
+          guide: '使用带斑马纹的表格,可以更容易区分出不同行的数据。',
+        },
+        {
+          id: 2,
+          name: '考点点02',
+          code: 'kd02',
+          teachingId: 2,
+          teachingName: '教学点02',
+          address: '武汉市洪山区光谷大道',
+          capacity: 100,
+          enable: true,
+          guide:
+            '用于展示多条结构类似的数据,可对数据进行排序、筛选、对比或其他自定义操作。',
+        },
+      ]);
+    });
+    // 考场管理
+    // 考场管理-查询
+    Mock.mock(new RegExp('/api/admin/room/query'), () => {
+      return pageListResponseWrap([
+        {
+          id: 1,
+          name: '考场01',
+          code: 'kc01',
+          teachingId: 1,
+          teachingName: '教学点01',
+          agentId: 1,
+          agentName: '考点01',
+          address: '武汉市洪山区关山大道',
+          capacity: 150,
+          enable: true,
+        },
+        {
+          id: 2,
+          name: '考场02',
+          code: 'kc02',
+          teachingId: 2,
+          teachingName: '教学点02',
+          agentId: 2,
+          agentName: '考点02',
+          address: '武汉市洪山区关山大道',
+          capacity: 200,
+          enable: true,
+        },
+      ]);
+    });
+  },
+});

+ 50 - 0
src/mock/user.ts

@@ -0,0 +1,50 @@
+import Mock from 'mockjs';
+import setupMock, {
+  successResponseWrap,
+  failResponseWrap,
+} from '@/utils/setup-mock';
+
+import { MockParams } from '@/types/mock';
+import { menus } from './datas/user';
+
+setupMock({
+  setup() {
+    // Mock.XHR.prototype.withCredentials = true;
+
+    // 登录
+    Mock.mock(new RegExp('/api/user/login'), (params: MockParams) => {
+      const { account, password } = JSON.parse(params.body);
+      if (!account) {
+        return failResponseWrap(null, '用户名不能为空', 50000);
+      }
+      if (!password) {
+        return failResponseWrap(null, '密码不能为空', 50000);
+      }
+
+      return successResponseWrap({
+        id: 1,
+        account,
+        name: '张三',
+        role: 'ADMIN',
+        sessionId: '123456',
+        token: 'abcdef',
+        orgId: 1,
+        categoryId: 1,
+        applyTaskId: 1,
+      });
+    });
+
+    // 登出
+    Mock.mock(new RegExp('/api/user/logout'), () => {
+      return successResponseWrap(null);
+    });
+
+    // 用户的服务端菜单
+    Mock.mock(new RegExp('/api/admin/sys/user/get_menu'), () => {
+      return successResponseWrap({
+        userId: 1,
+        privileges: menus,
+      });
+    });
+  },
+});

+ 7 - 0
src/router/constants.ts

@@ -0,0 +1,7 @@
+export const NOT_FOUND = {
+  name: 'NotFound',
+};
+
+export const DEFAULT_ROUTE_NAME = 'Workplace';
+
+export const WHITE_LIST = [NOT_FOUND, { name: 'Login' }];

+ 17 - 0
src/router/guard/index.ts

@@ -0,0 +1,17 @@
+import type { Router } from 'vue-router';
+import { setRouteEmitter } from '@/utils/route-listener';
+import setupUserLoginInfoGuard from './userLoginInfo';
+import setupPermissionGuard from './permission';
+
+function setupPageGuard(router: Router) {
+  router.beforeEach(async (to) => {
+    // emit route change
+    setRouteEmitter(to);
+  });
+}
+
+export default function createRouteGuard(router: Router) {
+  setupPageGuard(router);
+  setupUserLoginInfoGuard(router);
+  setupPermissionGuard(router);
+}

+ 38 - 0
src/router/guard/permission.ts

@@ -0,0 +1,38 @@
+import type { Router } from 'vue-router';
+import NProgress from 'nprogress'; // progress bar
+
+import { useAppStore, useUserStore } from '@/store';
+import { WHITE_LIST, NOT_FOUND, DEFAULT_ROUTE_NAME } from '../constants';
+
+export default function setupUserLoginInfoGuard(router: Router) {
+  router.beforeEach((to, from, next) => {
+    if (WHITE_LIST.find((el) => el.name === to.name)) {
+      next();
+      NProgress.done();
+      return;
+    }
+
+    const appStore = useAppStore();
+    if (!appStore.appMenus.length) {
+      const useStore = useUserStore();
+      appStore.fetchServerMenu(useStore.role);
+    }
+
+    if (to.name === DEFAULT_ROUTE_NAME) {
+      const destination = appStore.getMenuFirstRouter() || NOT_FOUND;
+      console.log(destination);
+
+      next(destination);
+      NProgress.done();
+      return;
+    }
+
+    if (!to.name || !appStore.validRoutes.includes(to.name as string)) {
+      next(NOT_FOUND);
+    } else {
+      next();
+    }
+
+    NProgress.done();
+  });
+}

+ 22 - 0
src/router/guard/userLoginInfo.ts

@@ -0,0 +1,22 @@
+import type { Router } from 'vue-router';
+import NProgress from 'nprogress'; // progress bar
+
+import { useUserStore } from '@/store';
+
+export default function setupUserLoginInfoGuard(router: Router) {
+  router.beforeEach(async (to, from, next) => {
+    NProgress.start();
+    const userStore = useUserStore();
+    if (!to.meta.requiresAuth) {
+      next();
+      return;
+    }
+    if (userStore.token) {
+      next();
+    } else {
+      next({
+        name: 'Login',
+      });
+    }
+  });
+}

+ 29 - 0
src/router/index.ts

@@ -0,0 +1,29 @@
+import { createRouter, createWebHistory } from 'vue-router';
+import NProgress from 'nprogress'; // progress bar
+import 'nprogress/nprogress.css';
+
+import { appRoutes, appExternalRoutes } from './routes';
+import { NOT_FOUND_ROUTE } from './routes/base';
+import createRouteGuard from './guard';
+
+NProgress.configure({ showSpinner: false }); // NProgress Configuration
+
+const router = createRouter({
+  history: createWebHistory(),
+  routes: [
+    {
+      path: '/',
+      redirect: { name: 'Login' },
+    },
+    ...appRoutes,
+    ...appExternalRoutes,
+    NOT_FOUND_ROUTE,
+  ],
+  scrollBehavior() {
+    return { top: 0 };
+  },
+});
+
+createRouteGuard(router);
+
+export default router;

+ 9 - 0
src/router/routes/base.ts

@@ -0,0 +1,9 @@
+import type { RouteRecordRaw } from 'vue-router';
+
+export const DEFAULT_LAYOUT = () => import('@/layout/default-layout.vue');
+
+export const NOT_FOUND_ROUTE: RouteRecordRaw = {
+  path: '/:pathMatch(.*)*',
+  name: 'NotFound',
+  component: () => import('@/views/system/not-found/index.vue'),
+};

+ 14 - 0
src/router/routes/externalModules/system.ts

@@ -0,0 +1,14 @@
+import { DEFAULT_ROUTE_NAME } from '../../constants';
+import { AppRouteRecordRaw } from '../types';
+
+const SYSTEM: AppRouteRecordRaw = {
+  path: '/workplace',
+  name: DEFAULT_ROUTE_NAME,
+  component: () => import('@/views/system/workplace/index.vue'),
+  meta: {
+    requiresAuth: true,
+    title: '主系统',
+  },
+};
+
+export default SYSTEM;

+ 24 - 0
src/router/routes/index.ts

@@ -0,0 +1,24 @@
+import type { RouteRecordNormalized } from 'vue-router';
+
+const modules = import.meta.glob('./modules/*.ts', { eager: true });
+const externalModules = import.meta.glob('./externalModules/*.ts', {
+  eager: true,
+});
+
+function formatModules(_modules: any, result: RouteRecordNormalized[]) {
+  Object.keys(_modules).forEach((key) => {
+    const defaultModule = _modules[key].default;
+    if (!defaultModule) return;
+    const moduleList = Array.isArray(defaultModule)
+      ? [...defaultModule]
+      : [defaultModule];
+    result.push(...moduleList);
+  });
+  return result;
+}
+
+export const appRoutes: RouteRecordNormalized[] = formatModules(modules, []);
+export const appExternalRoutes: RouteRecordNormalized[] = formatModules(
+  externalModules,
+  []
+);

+ 42 - 0
src/router/routes/modules/base.ts

@@ -0,0 +1,42 @@
+import { DEFAULT_LAYOUT } from '../base';
+import { AppRouteRecordRaw } from '../types';
+
+const routes: AppRouteRecordRaw = {
+  path: '/base',
+  name: 'base',
+  component: DEFAULT_LAYOUT,
+  meta: {
+    requiresAuth: true,
+  },
+  children: [
+    {
+      path: 'teaching-manage',
+      name: 'TeachingManage',
+      component: () => import('@/views/base/teaching-manage/index.vue'),
+      meta: {
+        title: '教学点管理',
+        requiresAuth: true,
+      },
+    },
+    {
+      path: 'agent-manage',
+      name: 'AgentManage',
+      component: () => import('@/views/base/agent-manage/index.vue'),
+      meta: {
+        title: '考点管理',
+        requiresAuth: true,
+      },
+    },
+    {
+      path: 'room-manage',
+      name: 'RoomManage',
+      component: () => import('@/views/base/room-manage/index.vue'),
+      meta: {
+        title: '考场管理',
+        requiresAuth: true,
+      },
+    },
+  ],
+};
+
+export default routes;

+ 19 - 0
src/router/routes/modules/login.ts

@@ -0,0 +1,19 @@
+import { AppRouteRecordRaw } from '../types';
+
+const routes: AppRouteRecordRaw = {
+  path: '/login-home',
+  component: () => import('@/views/login/home.vue'),
+  children: [
+    {
+      path: '/login',
+      name: 'Login',
+      component: () => import('@/views/login/login/index.vue'),
+      meta: {
+        title: '登录',
+        requiresAuth: false,
+      },
+    },
+  ],
+};
+
+export default routes;

+ 87 - 0
src/router/routes/modules/order.ts

@@ -0,0 +1,87 @@
+import { DEFAULT_LAYOUT } from '../base';
+import { AppRouteRecordRaw } from '../types';
+
+const routes: AppRouteRecordRaw = {
+  path: '/order',
+  name: 'order',
+  component: DEFAULT_LAYOUT,
+  meta: {
+    requiresAuth: true,
+  },
+  children: [
+    {
+      path: 'task-manage',
+      name: 'TaskManage',
+      component: () => import('@/views/order/task-manage/index.vue'),
+      meta: {
+        title: '预约任务管理',
+        requiresAuth: true,
+      },
+    },
+    {
+      path: 'reservation-set',
+      name: 'ReservationSet',
+      component: () => import('@/views/order/reservation-set/index.vue'),
+      meta: {
+        title: '考点预约设置',
+        requiresAuth: true,
+      },
+    },
+    {
+      path: 'room-scheduling-set',
+      name: 'RoomSchedulingSet',
+      component: () => import('@/views/order/room-scheduling-set/index.vue'),
+      meta: {
+        title: '考场排班设置',
+        requiresAuth: true,
+      },
+    },
+    {
+      path: 'student-import',
+      name: 'StudentImport',
+      component: () => import('@/views/order/student-import/index.vue'),
+      meta: {
+        title: '考生信息导入',
+        requiresAuth: true,
+      },
+    },
+    {
+      path: 'student-manage',
+      name: 'StudentManage',
+      component: () => import('@/views/order/student-manage/index.vue'),
+      meta: {
+        title: '考生管理',
+        requiresAuth: true,
+      },
+    },
+    {
+      path: 'order-record-manage',
+      name: 'OrderRecordManage',
+      component: () => import('@/views/order/order-record-manage/index.vue'),
+      meta: {
+        title: '预约名单详情',
+        requiresAuth: true,
+      },
+    },
+    {
+      path: 'user-manage',
+      name: 'AccountManage',
+      component: () => import('@/views/order/user-manage/index.vue'),
+      meta: {
+        title: '账号管理',
+        requiresAuth: true,
+      },
+    },
+    {
+      path: 'my-task',
+      name: 'MyTask',
+      component: () => import('@/views/order/my-task/index.vue'),
+      meta: {
+        title: '我的任务',
+        requiresAuth: true,
+      },
+    },
+  ],
+};
+
+export default routes;

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.