فهرست منبع

初步搭建PC端项目脚手架

刘洋 1 سال پیش
کامیت
a0b7cdab7d
79فایلهای تغییر یافته به همراه4327 افزوده شده و 0 حذف شده
  1. 10 0
      .env
  2. 25 0
      .env-config.js
  3. 2 0
      .env.development
  4. 8 0
      .env.production
  5. 7 0
      .env.test
  6. 3 0
      .eslintignore
  7. 69 0
      .eslintrc.js
  8. 8 0
      .gitignore
  9. 8 0
      .prettierrc.js
  10. 8 0
      auto-imports.d.ts
  11. 3 0
      babel.config.js
  12. 8 0
      build/config/define.js
  13. 2 0
      build/config/index.js
  14. 18 0
      build/config/proxy.js
  15. 3 0
      build/index.js
  16. 8 0
      build/plugins/autoImport.js
  17. 6 0
      build/plugins/compress.js
  18. 13 0
      build/plugins/html.js
  19. 37 0
      build/plugins/index.js
  20. 9 0
      build/plugins/lazyloadCom.js
  21. 16 0
      build/plugins/lazyloadStyle.js
  22. 27 0
      build/plugins/legacy.js
  23. 7 0
      build/plugins/visualizer.js
  24. 6 0
      build/plugins/vue.js
  25. 20 0
      build/utils/index.js
  26. 46 0
      components.d.ts
  27. 13 0
      index.html
  28. 16 0
      jsconfig.json
  29. 75 0
      package.json
  30. 5 0
      postcss.config.js
  31. 18 0
      src/App.vue
  32. 38 0
      src/api/user.js
  33. 176 0
      src/assets/404.svg
  34. BIN
      src/assets/imgs/bg.jpg
  35. BIN
      src/assets/imgs/loginbg.png
  36. 8 0
      src/assets/logo.svg
  37. 49 0
      src/components/global/chart/index.vue
  38. 42 0
      src/components/global/index.js
  39. 15 0
      src/components/global/my-dialog/index.vue
  40. 117 0
      src/components/global/search-form/components/search-form-item.vue
  41. 254 0
      src/components/global/search-form/index.vue
  42. 57 0
      src/config/color.js
  43. 7 0
      src/config/global.js
  44. 27 0
      src/directives/copy.js
  45. 7 0
      src/directives/index.js
  46. 13 0
      src/hooks/useDialog.js
  47. 45 0
      src/hooks/useFetchTable.js
  48. 101 0
      src/hooks/useTableCrud.js
  49. 20 0
      src/layout/404.vue
  50. 39 0
      src/layout/children-menu.vue
  51. 3 0
      src/layout/empty.vue
  52. 91 0
      src/layout/index.vue
  53. 99 0
      src/layout/left-menu.vue
  54. 27 0
      src/main.js
  55. 72 0
      src/mock/index.js
  56. 50 0
      src/router/asyncRoutes.js
  57. 77 0
      src/router/index.js
  58. 12 0
      src/router/modules/examManage.js
  59. 12 0
      src/router/modules/userManage.js
  60. 40 0
      src/router/routes.js
  61. 8 0
      src/store/index.js
  62. 31 0
      src/store/modules/app.js
  63. 151 0
      src/store/modules/user.js
  64. 50 0
      src/style/animation.less
  65. 59 0
      src/style/global.less
  66. 4 0
      src/style/index.less
  67. 399 0
      src/style/reset.less
  68. 123 0
      src/utils/request.js
  69. 318 0
      src/utils/tool.js
  70. 51 0
      src/views/examManage/addExamDialog.vue
  71. 141 0
      src/views/examManage/index.vue
  72. 98 0
      src/views/login/examSelect/index.vue
  73. 186 0
      src/views/login/index.vue
  74. 111 0
      src/views/login/subjectSelect/index.vue
  75. 54 0
      src/views/userManage/add-user-dialog.vue
  76. 55 0
      src/views/userManage/import-file-dialog.vue
  77. 288 0
      src/views/userManage/index.vue
  78. 120 0
      src/views/userManage/mult-add-dialog.vue
  79. 78 0
      vite.config.js

+ 10 - 0
.env

@@ -0,0 +1,10 @@
+VITE_BASE_URL=/
+
+VITE_APP_NAME=质控平台
+
+VITE_APP_TITLE=质控平台
+
+VITE_APP_TOKEN_PREFIX = token
+VITE_APP_PROXY_PREFIX = /api
+VITE_APP_BASE_URL=/
+VITE_HASH_ROUTE=Y

+ 25 - 0
.env-config.js

@@ -0,0 +1,25 @@
+/** 请求环境配置 */
+
+/** 不同服务的环境配置 */
+const serviceEnv = {
+  development: {
+    url: 'https://yunpos-manageapi.cs.kemai.com.cn',
+  },
+  // test: {
+  //   url: 'http://localhost:8080',
+  // },
+  production: {
+    url: '/',
+  },
+};
+
+/**
+ * 获取当前模式的环境配置
+ * @param env 环境
+ */
+export function getEnvConfig(env) {
+  const { VITE_ENV_TYPE = 'development', VITE_APP_PROXY_PREFIX } = env;
+  const envConfig = serviceEnv[VITE_ENV_TYPE];
+  envConfig.proxy = VITE_APP_PROXY_PREFIX;
+  return envConfig;
+}

+ 2 - 0
.env.development

@@ -0,0 +1,2 @@
+VITE_ENV_TYPE=development
+VITE_HTTP_PROXY=N

+ 8 - 0
.env.production

@@ -0,0 +1,8 @@
+VITE_ENV_TYPE=production
+
+VITE_VISUALIZER=N
+VITE_COMPRESS=N
+
+# gzip | brotliCompress | deflate | deflateRaw
+VITE_COMPRESS_TYPE=gzip
+VITE_HTTP_PROXY=N

+ 7 - 0
.env.test

@@ -0,0 +1,7 @@
+VITE_ENV_TYPE=test
+VITE_VISUALIZER=N
+VITE_COMPRESS=N
+
+# gzip | brotliCompress | deflate | deflateRaw
+VITE_COMPRESS_TYPE=gzip
+VITE_HTTP_PROXY=N

+ 3 - 0
.eslintignore

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

+ 69 - 0
.eslintrc.js

@@ -0,0 +1,69 @@
+// 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,
+  },
+  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',
+  ],
+  rules: {
+    'prettier/prettier': 1,
+    'vue/no-reserved-component-names': 0,
+    // 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/custom-event-name-casing': [2, 'camelCase'],
+    'vue/no-v-text': 1,
+    'vue/padding-line-between-blocks': 1,
+    'vue/require-direct-export': 1,
+    'vue/multi-word-component-names': 0,
+    'import/extensions': [
+      2,
+      'ignorePackages',
+      {
+        js: 'never',
+        jsx: 'never',
+        ts: 'never',
+        tsx: 'never',
+      },
+    ],
+    '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': 0,
+    'import/no-cycle': 0,
+    'no-underscore-dangle': 0,
+    'no-restricted-syntax': 0,
+    'no-lonely-if': 0,
+    'array-callback-return': 0,
+    'no-unused-vars': 0,
+    'no-explicit-any': 0,
+    'no-use-before-define': 0,
+    'consistent-return': 0,
+    'no-plusplus': 0,
+    'no-unused-expressions': 0,
+  },
+};

+ 8 - 0
.gitignore

@@ -0,0 +1,8 @@
+node_modules
+.DS_Store
+dist
+dist-ssr
+*.local
+*.history
+package-lock.json
+yarn.lock

+ 8 - 0
.prettierrc.js

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

+ 8 - 0
auto-imports.d.ts

@@ -0,0 +1,8 @@
+/* eslint-disable */
+/* prettier-ignore */
+// @ts-nocheck
+// Generated by unplugin-auto-import
+export {}
+declare global {
+
+}

+ 3 - 0
babel.config.js

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

+ 8 - 0
build/config/define.js

@@ -0,0 +1,8 @@
+import dayjs from 'dayjs';
+
+/** 项目构建时间 */
+const PROJECT_BUILD_TIME = JSON.stringify(dayjs().format('YYYY-MM-DD HH:mm:ss'));
+
+export const viteDefine = {
+  PROJECT_BUILD_TIME,
+};

+ 2 - 0
build/config/index.js

@@ -0,0 +1,2 @@
+export * from './define';
+export * from './proxy';

+ 18 - 0
build/config/proxy.js

@@ -0,0 +1,18 @@
+/**
+ * 设置网络代理
+ * @param isOpenProxy - 是否开启代理
+ * @param envConfig - env环境配置
+ */
+export function createViteProxy(isOpenProxy, envConfig) {
+  console.log('createViteProxy:', isOpenProxy, envConfig);
+  if (!isOpenProxy) return undefined;
+  const proxy = {
+    [envConfig.proxy]: {
+      target: envConfig.url,
+      changeOrigin: true,
+      rewrite: (path) => path.replace(new RegExp(`^${envConfig.proxy}`), ''),
+    },
+  };
+  console.log('proxy:', proxy);
+  return proxy;
+}

+ 3 - 0
build/index.js

@@ -0,0 +1,3 @@
+export * from './plugins';
+export * from './config';
+export * from './utils';

+ 8 - 0
build/plugins/autoImport.js

@@ -0,0 +1,8 @@
+import AutoImport from 'unplugin-auto-import/vite';
+import { TDesignResolver  } from 'unplugin-vue-components/resolvers';
+
+export default AutoImport({ 
+    resolvers: [TDesignResolver({
+        library: 'vue-next'
+    })],
+ });

+ 6 - 0
build/plugins/compress.js

@@ -0,0 +1,6 @@
+import ViteCompression from 'vite-plugin-compression';
+
+export default (viteEnv) => {
+  const { VITE_COMPRESS_TYPE = 'gzip' } = viteEnv;
+  return ViteCompression({ algorithm: VITE_COMPRESS_TYPE });
+};

+ 13 - 0
build/plugins/html.js

@@ -0,0 +1,13 @@
+import { createHtmlPlugin } from 'vite-plugin-html';
+
+export default (viteEnv) => {
+  return createHtmlPlugin({
+    minify: true,
+    inject: {
+      data: {
+        appName: viteEnv.VITE_APP_NAME,
+        appTitle: viteEnv.VITE_APP_TITLE,
+      },
+    },
+  });
+};

+ 37 - 0
build/plugins/index.js

@@ -0,0 +1,37 @@
+import requireTransform from 'vite-plugin-require-transform';
+import Unocss from 'unocss/vite';
+import vue from './vue';
+import html from './html';
+import visualizer from './visualizer';
+import compress from './compress';
+import lazyloadCom from './lazyloadCom';
+import lazyloadStyle from './lazyloadStyle';
+import AutoImport from './autoImport';
+import vueSetupExtend from 'unplugin-vue-setup-extend-plus/vite';
+
+// import legacyPlugin from './legacy';
+/**
+ * vite插件
+ * @param viteEnv - 环境变量配置
+ */
+export function setupVitePlugins(viteEnv) {
+  const plugins = [
+    ...vue,
+    Unocss(),
+    html(viteEnv),
+    lazyloadCom,
+    // lazyloadStyle,
+    AutoImport,
+    requireTransform({}),
+    vueSetupExtend(),
+  ];
+
+  if (viteEnv.VITE_VISUALIZER === 'Y') {
+    plugins.push(visualizer);
+  }
+  if (viteEnv.VITE_COMPRESS === 'Y') {
+    plugins.push(compress(viteEnv));
+  }
+
+  return plugins;
+}

+ 9 - 0
build/plugins/lazyloadCom.js

@@ -0,0 +1,9 @@
+import Components from 'unplugin-vue-components/vite';
+import { TDesignResolver } from 'unplugin-vue-components/resolvers';
+export default Components({
+  resolvers: [
+    TDesignResolver({
+      library: 'vue-next',
+    }),
+  ],
+});

+ 16 - 0
build/plugins/lazyloadStyle.js

@@ -0,0 +1,16 @@
+import { createStyleImportPlugin } from 'vite-plugin-style-import';
+
+export default createStyleImportPlugin({
+  libs: [
+    {
+      libraryName: '@arco-design/web-vue',
+      esModule: true,
+      resolveStyle: (name) => {
+        // css
+        // return `@arco-design/web-vue/es/${name}/style/css.js`;
+        // less
+        return `@arco-design/web-vue/es/${name}/style/index.js`;
+      },
+    },
+  ],
+});

+ 27 - 0
build/plugins/legacy.js

@@ -0,0 +1,27 @@
+import legacy from '@vitejs/plugin-legacy';
+
+export default legacy({
+  // 尝试兼容低版本浏览器(不包括ie11)
+  targets: ['chrome 52'],
+  additionalLegacyPolyfills: ['regenerator-runtime/runtime'],
+  renderLegacyChunks: true,
+  polyfills: [
+    'es.symbol',
+    'es.array.filter',
+    'es.promise',
+    'es.promise.finally',
+    'es/map',
+    'es/set',
+    'es.array.for-each',
+    'es.object.define-properties',
+    'es.object.define-property',
+    'es.object.get-own-property-descriptor',
+    'es.object.get-own-property-descriptors',
+    'es.object.keys',
+    'es.object.to-string',
+    'web.dom-collections.for-each',
+    'esnext.global-this',
+    'esnext.string.match-all',
+  ],
+  // modernPolyfills: ['es.string.replace-all']
+});

+ 7 - 0
build/plugins/visualizer.js

@@ -0,0 +1,7 @@
+import { visualizer } from 'rollup-plugin-visualizer';
+
+export default visualizer({
+  gzipSize: true,
+  brotliSize: true,
+  open: true,
+});

+ 6 - 0
build/plugins/vue.js

@@ -0,0 +1,6 @@
+import vue from '@vitejs/plugin-vue';
+import vueJsx from '@vitejs/plugin-vue-jsx';
+
+const plugins = [vue(), vueJsx()];
+
+export default plugins;

+ 20 - 0
build/utils/index.js

@@ -0,0 +1,20 @@
+import path from 'path';
+
+/**
+ * 获取项目根路径
+ * @descrition 末尾不带斜杠
+ */
+export function getRootPath() {
+  return path.resolve(process.cwd());
+}
+
+/**
+ * 获取项目src路径
+ * @param srcName - src目录名称(默认: "src")
+ * @descrition 末尾不带斜杠
+ */
+export function getSrcPath(srcName = 'src') {
+  const rootPath = getRootPath();
+
+  return `${rootPath}/${srcName}`;
+}

+ 46 - 0
components.d.ts

@@ -0,0 +1,46 @@
+// generated by unplugin-vue-components
+// We suggest you to commit this file into source control
+// Read more: https://github.com/vuejs/core/pull/3399
+import '@vue/runtime-core'
+
+export {}
+
+declare module '@vue/runtime-core' {
+  export interface GlobalComponents {
+    Chart: typeof import('./src/components/global/chart/index.vue')['default']
+    MyDialog: typeof import('./src/components/global/my-dialog/index.vue')['default']
+    RouterLink: typeof import('vue-router')['RouterLink']
+    RouterView: typeof import('vue-router')['RouterView']
+    SearchForm: typeof import('./src/components/global/search-form/index.vue')['default']
+    SearchFormItem: typeof import('./src/components/global/search-form/components/search-form-item.vue')['default']
+    TAside: typeof import('tdesign-vue-next')['Aside']
+    TButton: typeof import('tdesign-vue-next')['Button']
+    TCheckbox: typeof import('tdesign-vue-next')['Checkbox']
+    TConfigProvider: typeof import('tdesign-vue-next')['ConfigProvider']
+    TContent: typeof import('tdesign-vue-next')['Content']
+    TDatePicker: typeof import('tdesign-vue-next')['DatePicker']
+    TDateRangePicker: typeof import('tdesign-vue-next')['DateRangePicker']
+    TDialog: typeof import('tdesign-vue-next')['Dialog']
+    TDropdown: typeof import('tdesign-vue-next')['Dropdown']
+    TForm: typeof import('tdesign-vue-next')['Form']
+    TFormItem: typeof import('tdesign-vue-next')['FormItem']
+    THeader: typeof import('tdesign-vue-next')['Header']
+    TIcon: typeof import('tdesign-vue-next')['Icon']
+    TInput: typeof import('tdesign-vue-next')['Input']
+    TInputNumber: typeof import('tdesign-vue-next')['InputNumber']
+    TLayout: typeof import('tdesign-vue-next')['Layout']
+    TLink: typeof import('tdesign-vue-next')['Link']
+    TMenu: typeof import('tdesign-vue-next')['Menu']
+    TMenuItem: typeof import('tdesign-vue-next')['MenuItem']
+    TOption: typeof import('tdesign-vue-next')['Option']
+    TSelect: typeof import('tdesign-vue-next')['Select']
+    TSubmenu: typeof import('tdesign-vue-next')['Submenu']
+    TTable: typeof import('tdesign-vue-next')['Table']
+    TTabPanel: typeof import('tdesign-vue-next')['TabPanel']
+    TTabs: typeof import('tdesign-vue-next')['Tabs']
+    TTimePicker: typeof import('tdesign-vue-next')['TimePicker']
+    TTimeRangePicker: typeof import('tdesign-vue-next')['TimeRangePicker']
+    TTreeSelect: typeof import('tdesign-vue-next')['TreeSelect']
+    TUpload: typeof import('tdesign-vue-next')['Upload']
+  }
+}

+ 13 - 0
index.html

@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="shortcut icon" type="image/x-icon" href="https://unpkg.byted-static.com/latest/byted/arco-config/assets/favicon.ico">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title><%= appName %></title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.js"></script>
+  </body>
+</html>

+ 16 - 0
jsconfig.json

@@ -0,0 +1,16 @@
+{
+  "compilerOptions": {
+    "target": "es2015",
+    "module": "commonjs",
+    "checkJs": false,
+    "baseUrl": ".",
+    "paths": {
+      "@/*": ["src/*"]
+    }
+  },
+  "exclude": [
+    "node_modules",
+    "**/node_modules/*"
+  ],
+  "include": ["src/**/*"]
+}

+ 75 - 0
package.json

@@ -0,0 +1,75 @@
+{
+  "name": "sop-web",
+  "description": "",
+  "version": "1.0.0",
+  "private": true,
+  "author": "星辰大海",
+  "license": "MIT",
+  "scripts": {
+    "start": "npm run dev",
+    "dev": "cross-env VITE_ENV_TYPE=development vite --mode=development",
+    "dev:test": "cross-env VITE_ENV_TYPE=test vite --mode=test",
+    "dev:prod": "cross-env VITE_ENV_TYPE=production vite --mode=production",
+    "build": "cross-env VITE_ENV_TYPE=production vite build --mode=production",
+    "build:dev": "cross-env VITE_ENV_TYPE=development vite build --mode=development",
+    "build:test": "cross-env VITE_ENV_TYPE=test vite build --mode=test",
+    "preview": "vite preview",
+    "lint:eslint": "eslint \"src/**/*.{vue,ts,tsx,js,jsx}\" --fix",
+    "lint:prettier": "prettier --write --loglevel warn \"src/**/*.{js,json,tsx,jsx,css,less,scss,vue,html,md}\"",
+    "lint:stylelint": "stylelint --fix \"**/*.{vue,less,postcss,css,scss}\" --cache --cache-location node_modules/.cache/stylelint/"
+  },
+  "dependencies": {
+    "@vueuse/core": "^9.6.0",
+    "autoprefixer": "^10.4.14",
+    "axios": "^1.2.1",
+    "dayjs": "^1.11.7",
+    "echarts": "^5.4.2",
+    "element-resize-detector": "^1.2.4",
+    "lodash": "^4.17.21",
+    "mockjs": "^1.1.0",
+    "nprogress": "^0.2.0",
+    "pinia": "^2.0.27",
+    "tdesign-vue-next": "^1.3.8",
+    "tvision-color": "^1.5.0",
+    "unplugin-vue-setup-extend-plus": "^1.0.0",
+    "vue": "^3.3.4",
+    "vue-clipboard3": "^2.0.0",
+    "vue-echarts": "^6.5.4",
+    "vue-router": "4.1.5"
+  },
+  "devDependencies": {
+    "@commitlint/cli": "^17.3.0",
+    "@commitlint/config-conventional": "^17.3.0",
+    "@esbuild-plugins/node-globals-polyfill": "^0.2.3",
+    "@esbuild-plugins/node-modules-polyfill": "^0.2.2",
+    "@vitejs/plugin-vue": "^3.2.0",
+    "@vitejs/plugin-vue-jsx": "^2.1.1",
+    "@vue/babel-plugin-jsx": "^1.1.1",
+    "cross-env": "^7.0.3",
+    "eslint": "^8.29.0",
+    "eslint-config-airbnb-base": "^15.0.0",
+    "eslint-config-prettier": "^8.5.0",
+    "eslint-import-resolver-typescript": "^3.5.2",
+    "eslint-plugin-import": "^2.26.0",
+    "eslint-plugin-prettier": "^4.2.1",
+    "eslint-plugin-vue": "^9.8.0",
+    "husky": "^8.0.2",
+    "less": "^4.1.3",
+    "prettier": "^2.8.1",
+    "rollup-plugin-node-polyfills": "^0.2.1",
+    "rollup-plugin-visualizer": "^5.9.0",
+    "typescript": "^4.8.3",
+    "unocss": "^0.52.0",
+    "unplugin-auto-import": "^0.15.3",
+    "unplugin-vue-components": "^0.22.4",
+    "unplugin-vue-define-options": "^0.6.2",
+    "vite": "^3.2.5",
+    "vite-plugin-compression": "^0.5.1",
+    "vite-plugin-eslint": "^1.8.1",
+    "vite-plugin-html": "^3.2.0",
+    "vite-plugin-require-transform": "^1.0.12",
+    "vite-plugin-style-import": "^2.0.0",
+    "vite-svg-loader": "^3.6.0",
+    "vue-tsc": "^1.0.11"
+  }
+}

+ 5 - 0
postcss.config.js

@@ -0,0 +1,5 @@
+module.exports = {
+  plugins: {
+    autoprefixer: {},
+  },
+};

+ 18 - 0
src/App.vue

@@ -0,0 +1,18 @@
+<template>
+  <t-config-provider :global-config="config">
+    <router-view />
+  </t-config-provider>
+</template>
+
+<script setup>
+import { globalConfig } from '@/config/global';
+import enUs from 'tdesign-vue-next/es/locale/en_US';
+import zhCn from 'tdesign-vue-next/es/locale/zh_CN';
+import { ref } from 'vue';
+import { useAppStore } from './store';
+const appStore = useAppStore();
+// const lang = ref(appStore.language === 'zh_CN' ? cn : en);
+const config = ref(
+  Object.assign(appStore.language === 'zh_CN' ? zhCn : enUs, globalConfig)
+);
+</script>

+ 38 - 0
src/api/user.js

@@ -0,0 +1,38 @@
+import { request } from '@/utils/request.js';
+
+export default {
+  login(data = {}) {
+    return request({
+      url: '/api/login',
+      method: 'post',
+      data,
+    });
+  },
+  logout(data = {}) {
+    return request({
+      url: '/api/logout',
+      method: 'post',
+      data,
+    });
+  },
+  getMenus() {
+    return request({
+      url: '/api/getMenus',
+      method: 'get',
+    });
+  },
+  addUser(data = {}) {
+    return request({
+      url: '/api/add',
+      method: 'post',
+      data,
+    });
+  },
+  editUser(data = {}) {
+    return request({
+      url: '/api/edit',
+      method: 'post',
+      data,
+    });
+  },
+};

+ 176 - 0
src/assets/404.svg

@@ -0,0 +1,176 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="770px" height="456px" viewBox="0 0 770 456" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 60.1 (88133) - https://sketch.com -->
+    <title>6</title>
+    <desc>Created with Sketch.</desc>
+    <defs>
+        <path d="M451.167458,89.4511247 C403.062267,29.8170416 338.891681,0 258.655699,0 C138.301726,0 69.263862,60.1782766 27.9579265,152.101254 C-13.3480089,244.024231 -12.6661889,369.858107 55.6494632,409.696073 C123.965115,449.534039 210.08756,459.743134 340.957927,438.489218 C471.828293,417.235303 508.472089,464.890133 589.496232,451.689675 C670.520376,438.489218 748.359885,414.0324 766.111966,329.133852 C783.864046,244.235303 714.426288,177.226358 677.67078,152.101254 C640.915272,126.97615 569.728461,175.208649 519.030321,160.235303 C485.231561,150.253072 462.610607,126.658346 451.167458,89.4511247 Z" id="path-1"></path>
+        <path d="M0.816264722,0 L370.714266,0 L370.714266,180.257104 L402.92544,180.257104 C424.638356,218.017298 440.878062,240.166012 451.644559,246.703245 L119.609274,243.521057 C112.14379,243.449507 105.100966,240.045172 100.407325,234.239285 C89.3772632,220.595444 81.4909058,210.013897 76.7482527,202.494643 C68.1135311,188.804698 66.7639588,180.257104 51.9095874,180.257104 C37.055216,180.257104 30.8879728,215.663472 26.2206784,229.536211 C21.5533841,243.408951 4.54747351e-13,240.685607 4.54747351e-13,229.536211 C4.54747351e-13,222.103281 0.272088241,145.59121 0.816264722,0 Z" id="path-3"></path>
+        <polygon id="path-5" points="0 25.9764499 26.0411111 2.29150032e-13 52.9088048 25.9764499"></polygon>
+        <polygon id="path-7" points="-2.27373675e-13 28.2395915 28.1433883 3.41060513e-13 54.0330976 28.2395915"></polygon>
+        <path d="M3.53184776,0 L61.4681522,0 C63.1250065,4.9985658e-15 64.4681522,1.34314575 64.4681522,3 C64.4681522,3.16257855 64.4549364,3.32488807 64.4286352,3.48532508 L55.4122418,58.4853251 C55.1745077,59.9355031 53.921294,61 52.4517588,61 L12.5482412,61 C11.078706,61 9.82549232,59.9355031 9.58775821,58.4853251 L0.571364767,3.48532508 C0.303327126,1.85029547 1.41149307,0.307554646 3.04652268,0.0395170047 C3.20695969,0.0132158559 3.36926922,-1.30240244e-15 3.53184776,0 Z" id="path-9"></path>
+        <path d="M-1.42108547e-14,115.48446 C1.32743544,94.0102656 2.89289856,78.9508436 4.69638937,70.3061937 C8.43003277,52.4097675 15.5176097,37.8448008 19.4787027,30.195863 C29.7253967,10.409323 39.7215535,5.31301339 44.6820442,2.63347577 C49.6425348,-0.0460618448 60.3007481,-1.62222357 66.327433,2.63347577 C72.3541179,6.88917511 74.5668372,13.0533931 73.7454921,23.1564165 C72.924147,33.2594398 65.469448,39.1497458 58.0193289,42.7343523 C50.5692098,46.3189588 31.0128594,60.1734323 19.4787027,74.1118722 C11.7892649,83.4041655 5.29636401,97.195028 -1.42108547e-14,115.48446 Z" id="path-11"></path>
+        <path d="M0,61.382873 C12.627563,35.4721831 22.8842273,18.9178104 30.7699929,11.7197549 C42.5986412,0.922671591 57.9238693,-1.5327187 66.3547392,0.814866828 C74.7856091,3.16245236 78.9526569,14.6315037 74.3469666,21.3628973 C69.7412762,28.0942909 65.4378728,28.0568843 50.8423324,30.6914365 C36.246792,33.3259886 29.5659376,36.8930178 23.8425136,39.4010039 C21.5824174,40.3913708 15.331987,43.4769377 10.1725242,48.4356558 C7.80517763,50.7108935 4.41433624,55.0266325 0,61.382873 Z" id="path-13"></path>
+        <path d="M-2.08995462,65.6474954 C12.5975781,38.2270573 23.8842273,20.9178104 31.7699929,13.7197549 C43.5986412,2.92267159 58.9238693,0.467281299 67.3547392,2.81486683 C75.7856091,5.16245236 79.9526569,16.6315037 75.3469666,23.3628973 C70.7412762,30.0942909 66.4378728,30.0568843 51.8423324,32.6914365 C37.246792,35.3259886 30.5659376,38.8930178 24.8425136,41.4010039 C22.5824174,42.3913708 13.2420323,47.7415601 8.08256956,52.7002782 C5.71522301,54.9755159 2.32438162,59.2912549 -2.08995462,65.6474954 Z" id="path-15"></path>
+        <path d="M70.3618111,117.305105 C65.1514723,93.5149533 59.5592828,76.7727476 53.5852425,67.0784883 C44.6241821,52.5370993 33.2521675,43.1631445 21.9273327,38.7089848 C10.6024978,34.2548251 1.37005489,28.3143707 0.166250333,19.5991494 C-1.03755422,10.8839281 4.30184276,1.89650161 15.9982131,0.359853321 C27.6945835,-1.17679496 39.680528,1.89650161 50.3232751,15.6556441 C60.9660221,29.4147866 71.7898492,71.0503233 71.7898492,87.5111312 C71.7898492,98.4850031 71.3138365,108.416328 70.3618111,117.305105 Z" id="path-17"></path>
+        <path d="M40.4361627,109.727577 C42.2080966,71.0333394 41.2052946,44.753324 37.4277569,30.8875312 C31.7614504,10.088842 22.8541813,-1.27827958 11.3728741,0.114578571 C-0.108432993,1.50743672 -2.5866861,11.539269 2.54272088,19.2423116 C7.67212787,26.9453541 22.1964111,48.5363293 27.3543068,61.4631547 C30.7929039,70.0810384 35.1535225,86.1691793 40.4361627,109.727577 Z" id="path-19"></path>
+        <path d="M86.8630745,43.7959111 C72.5806324,23.5140129 56.8667378,10.125403 39.7213908,3.6300812 C14.0033702,-6.11290144 -7.10542736e-15,5.90110838 -7.10542736e-15,14.52167 C-7.10542736e-15,23.1422316 6.80949202,28.0268155 17.0489556,28.0268155 C27.2884192,28.0268155 43.7234658,26.0070237 58.8280258,34.5737997 C68.8977326,40.2849837 79.1842128,49.927944 89.6874666,63.5026805 L86.8630745,43.7959111 Z" id="path-21"></path>
+        <circle id="path-23" cx="42" cy="42" r="42"></circle>
+    </defs>
+    <g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="画板" transform="translate(-2046.000000, -1809.000000)">
+            <g id="6" transform="translate(2046.223123, 1809.764697)">
+                <g id="编组-89" transform="translate(0.109175, 0.235303)">
+                    <mask id="mask-2" fill="white">
+                        <use xlink:href="#path-1"></use>
+                    </mask>
+                    <use id="路径-307" fill="#F3F7FF" xlink:href="#path-1"></use>
+                    <rect id="矩形" fill="#D0DEFE" mask="url(#mask-2)" x="0" y="362" width="791" height="112"></rect>
+                    <rect id="矩形" fill="#C4D6FF" mask="url(#mask-2)" transform="translate(395.500000, 353.500000) scale(1, -1) translate(-395.500000, -353.500000) " x="0" y="345" width="791" height="17"></rect>
+                </g>
+                <rect id="矩形" stroke="#979797" fill="#D8D8D8" x="632.609175" y="381.735303" width="39" height="10"></rect>
+                <rect id="矩形" stroke="#979797" fill="#D8D8D8" x="632.609175" y="402.735303" width="39" height="10"></rect>
+                <rect id="矩形" stroke="#979797" fill="#D8D8D8" x="628.609175" y="392.735303" width="39" height="10"></rect>
+                <g id="编组-88" transform="translate(547.109175, 141.235303)">
+                    <rect id="矩形" fill="#BCD4FF" x="0" y="0" width="144" height="281"></rect>
+                    <rect id="矩形" fill="#EDF4FF" x="5" y="10.5" width="131" height="262"></rect>
+                    <rect id="矩形" fill="#9EBEF8" x="106" y="10.5" width="30" height="262"></rect>
+                    <rect id="矩形" fill="#BCD4FF" x="56" y="136" width="80" height="8"></rect>
+                    <rect id="矩形" fill="#BCD4FF" x="56" y="203" width="80" height="8"></rect>
+                    <g id="编组-87" transform="translate(63.000000, 153.000000)">
+                        <rect id="矩形" fill="#FFECC8" x="29" y="0" width="40" height="50"></rect>
+                        <rect id="矩形" fill="#FFE2AC" x="58" y="0" width="11" height="50"></rect>
+                        <rect id="矩形" fill="#D3E1FF" x="14" y="0" width="40" height="50"></rect>
+                        <rect id="矩形" fill="#BDD2FF" x="43" y="0" width="11" height="50"></rect>
+                        <rect id="矩形" fill="#FFECC8" x="0" y="8" width="40" height="42"></rect>
+                        <rect id="矩形" fill="#FFE2AC" x="29" y="8" width="11" height="42"></rect>
+                    </g>
+                    <g id="编组-87" transform="translate(63.000000, 222.000000)">
+                        <rect id="矩形" fill="#FFECC8" x="29" y="0" width="40" height="50"></rect>
+                        <rect id="矩形" fill="#FFE2AC" x="58" y="0" width="11" height="50"></rect>
+                        <rect id="矩形" fill="#D3E1FF" x="14" y="0" width="40" height="50"></rect>
+                        <rect id="矩形" fill="#BDD2FF" x="43" y="0" width="11" height="50"></rect>
+                        <rect id="矩形" fill="#FFECC8" x="0" y="8" width="40" height="42"></rect>
+                        <rect id="矩形" fill="#FFE2AC" x="29" y="8" width="11" height="42"></rect>
+                    </g>
+                    <g id="编组-87" transform="translate(63.000000, 86.000000)">
+                        <rect id="矩形" fill="#FFECC8" x="29" y="0" width="40" height="50"></rect>
+                        <rect id="矩形" fill="#FFE2AC" x="58" y="0" width="11" height="50"></rect>
+                        <rect id="矩形" fill="#D3E1FF" x="14" y="0" width="40" height="50"></rect>
+                        <rect id="矩形" fill="#BDD2FF" x="43" y="0" width="11" height="50"></rect>
+                        <rect id="矩形" fill="#FFECC8" x="0" y="8" width="40" height="42"></rect>
+                        <rect id="矩形" fill="#FFE2AC" x="29" y="8" width="11" height="42"></rect>
+                    </g>
+                </g>
+                <path d="M206.109175,130.235303 L586.109175,130.235303 C596.0503,130.235303 604.109175,138.294177 604.109175,148.235303 L604.109175,417.235303 L604.109175,417.235303 L188.109175,417.235303 L188.109175,148.235303 C188.109175,138.294177 196.168049,130.235303 206.109175,130.235303 Z" id="矩形" fill="#DDE9FF"></path>
+                <path d="M206.109175,130.235303 L586.109175,130.235303 C596.0503,130.235303 604.109175,138.294177 604.109175,148.235303 L604.109175,163.235303 L604.109175,163.235303 L188.109175,163.235303 L188.109175,148.235303 C188.109175,138.294177 196.168049,130.235303 206.109175,130.235303 Z" id="矩形" fill="#FFECC8"></path>
+                <path d="M206.109175,130.235303 L586.109175,130.235303 C596.0503,130.235303 604.109175,138.294177 604.109175,148.235303 L604.109175,160.235303 L604.109175,160.235303 L188.109175,160.235303 L188.109175,148.235303 C188.109175,138.294177 196.168049,130.235303 206.109175,130.235303 Z" id="矩形" fill="#A4C3FC"></path>
+                <circle id="椭圆形" fill="#FFBB3C" cx="210" cy="146" r="4"></circle>
+                <circle id="椭圆形" fill="#ECF2FF" cx="223.109175" cy="145.235303" r="4"></circle>
+                <g id="编组-86" transform="translate(210.109175, 178.235303)">
+                    <mask id="mask-4" fill="white">
+                        <use xlink:href="#path-3"></use>
+                    </mask>
+                    <use id="路径-289" fill="#FFFFFF" xlink:href="#path-3"></use>
+                    <rect id="矩形" fill="#ECF2FF" mask="url(#mask-4)" x="50.8162647" y="180.401344" width="412" height="87"></rect>
+                    <polygon id="路径" fill="#FFEAC2" fill-rule="nonzero" mask="url(#mask-4)" points="361.449861 8.85304449 361.449861 180.761462 360.449861 180.761462 360.449 9.853 14.449 9.853 14.449 223.853 27.9199219 223.853044 27.9199219 224.853044 13.4498606 224.853044 13.4498606 8.85304449"></polygon>
+                </g>
+                <path d="M333.259175,333.235303 L333.259175,308.935303 L350.659175,308.935303 L350.659175,298.885303 L333.259175,298.885303 L333.259175,226.135303 L321.709175,226.135303 L267.709175,297.235303 L267.709175,308.935303 L321.559175,308.935303 L321.559175,333.235303 L333.259175,333.235303 Z M321.559175,298.885303 L278.059175,298.885303 L321.109175,242.185303 L321.559175,242.185303 L321.559175,298.885303 Z M399.109175,335.335303 C411.859175,335.335303 421.459175,329.635303 428.059175,318.385303 C433.759175,308.785303 436.609175,295.885303 436.609175,279.685303 C436.609175,263.485303 433.759175,250.585303 428.059175,240.985303 C421.459175,229.585303 411.859175,224.035303 399.109175,224.035303 C386.209175,224.035303 376.609175,229.585303 370.159175,240.985303 C364.459175,250.585303 361.609175,263.485303 361.609175,279.685303 C361.609175,295.885303 364.459175,308.785303 370.159175,318.385303 C376.609175,329.635303 386.209175,335.335303 399.109175,335.335303 Z M399.109175,324.835303 C389.509175,324.835303 382.609175,319.585303 378.409175,309.385303 C375.409175,302.035303 373.909175,292.135303 373.909175,279.685303 C373.909175,267.085303 375.409175,257.185303 378.409175,249.985303 C382.609175,239.635303 389.509175,234.535303 399.109175,234.535303 C408.709175,234.535303 415.609175,239.635303 419.809175,249.985303 C422.809175,257.185303 424.459175,267.085303 424.459175,279.685303 C424.459175,292.135303 422.809175,302.035303 419.809175,309.385303 C415.609175,319.585303 408.709175,324.835303 399.109175,324.835303 Z M513.259175,333.235303 L513.259175,308.935303 L530.659175,308.935303 L530.659175,298.885303 L513.259175,298.885303 L513.259175,226.135303 L501.709175,226.135303 L447.709175,297.235303 L447.709175,308.935303 L501.559175,308.935303 L501.559175,333.235303 L513.259175,333.235303 Z M501.559175,298.885303 L458.059175,298.885303 L501.109175,242.185303 L501.559175,242.185303 L501.559175,298.885303 Z" id="404" fill="#FFEAC2" fill-rule="nonzero"></path>
+                <path d="M330.259175,330.235303 L330.259175,305.935303 L347.659175,305.935303 L347.659175,295.885303 L330.259175,295.885303 L330.259175,223.135303 L318.709175,223.135303 L264.709175,294.235303 L264.709175,305.935303 L318.559175,305.935303 L318.559175,330.235303 L330.259175,330.235303 Z M318.559175,295.885303 L275.059175,295.885303 L318.109175,239.185303 L318.559175,239.185303 L318.559175,295.885303 Z M396.109175,332.335303 C408.859175,332.335303 418.459175,326.635303 425.059175,315.385303 C430.759175,305.785303 433.609175,292.885303 433.609175,276.685303 C433.609175,260.485303 430.759175,247.585303 425.059175,237.985303 C418.459175,226.585303 408.859175,221.035303 396.109175,221.035303 C383.209175,221.035303 373.609175,226.585303 367.159175,237.985303 C361.459175,247.585303 358.609175,260.485303 358.609175,276.685303 C358.609175,292.885303 361.459175,305.785303 367.159175,315.385303 C373.609175,326.635303 383.209175,332.335303 396.109175,332.335303 Z M396.109175,321.835303 C386.509175,321.835303 379.609175,316.585303 375.409175,306.385303 C372.409175,299.035303 370.909175,289.135303 370.909175,276.685303 C370.909175,264.085303 372.409175,254.185303 375.409175,246.985303 C379.609175,236.635303 386.509175,231.535303 396.109175,231.535303 C405.709175,231.535303 412.609175,236.635303 416.809175,246.985303 C419.809175,254.185303 421.459175,264.085303 421.459175,276.685303 C421.459175,289.135303 419.809175,299.035303 416.809175,306.385303 C412.609175,316.585303 405.709175,321.835303 396.109175,321.835303 Z M510.259175,330.235303 L510.259175,305.935303 L527.659175,305.935303 L527.659175,295.885303 L510.259175,295.885303 L510.259175,223.135303 L498.709175,223.135303 L444.709175,294.235303 L444.709175,305.935303 L498.559175,305.935303 L498.559175,330.235303 L510.259175,330.235303 Z M498.559175,295.885303 L455.059175,295.885303 L498.109175,239.185303 L498.559175,239.185303 L498.559175,295.885303 Z" id="404" fill="#ACC9FF" fill-rule="nonzero"></path>
+                <polygon id="路径-298" fill="#6EA1FF" fill-rule="nonzero" points="369.741481 26.3549544 369.741481 145.784171 368.741481 145.784171 368.741481 26.3549544"></polygon>
+                <g id="编组-113" transform="translate(343.200370, 145.784171)">
+                    <mask id="mask-6" fill="white">
+                        <use xlink:href="#path-5"></use>
+                    </mask>
+                    <use id="路径-299" fill="#FFD078" xlink:href="#path-5"></use>
+                    <polygon id="路径-299" fill="#FFBB3C" mask="url(#mask-6)" points="-3 25.9764499 23.0411111 1.77635684e-15 49.9088048 25.9764499"></polygon>
+                </g>
+                <polygon id="路径-300" fill="#6EA1FF" fill-rule="nonzero" points="254.30695 -0.00143864693 255.306945 0.00143864694 255.109173 68.7367415 254.109177 68.7338642"></polygon>
+                <g id="编组-112" transform="translate(226.663559, 65.717848)">
+                    <mask id="mask-8" fill="white">
+                        <use xlink:href="#path-7"></use>
+                    </mask>
+                    <use id="路径-301" fill="#D2E2FF" xlink:href="#path-7"></use>
+                    <polygon id="路径-301" fill="#A4C3FC" mask="url(#mask-8)" points="-3 28.2395915 25.1433883 -1.13686838e-13 51.0330976 28.2395915"></polygon>
+                </g>
+                <path d="M464.109175,72.2353029 L574.109175,72.2353029 C578.527453,72.2353029 582.109175,75.8170249 582.109175,80.2353029 L582.109175,152.269143 L582.109175,152.269143 L602.747625,174.760621 L464.163722,175.18059 C454.222643,175.210716 446.139383,167.1763 446.109258,157.23522 C446.109203,157.217038 446.109175,157.198855 446.109175,157.180672 L446.109175,90.2353029 C446.109175,80.2941774 454.168049,72.2353029 464.109175,72.2353029 Z" id="矩形" fill="#FFECC8"></path>
+                <path d="M460.109175,69.2353029 L570.109175,69.2353029 C574.527453,69.2353029 578.109175,72.8170249 578.109175,77.2353029 L578.109175,149.269143 L578.109175,149.269143 L598.747625,171.760621 L460.163722,172.18059 C450.222643,172.210716 442.139383,164.1763 442.109258,154.23522 C442.109203,154.217038 442.109175,154.198855 442.109175,154.180672 L442.109175,87.2353029 C442.109175,77.2941774 450.168049,69.2353029 460.109175,69.2353029 Z" id="矩形" fill="#EBF2FF"></path>
+                <rect id="矩形" fill="#FFFFFF" x="480" y="95" width="7" height="64"></rect>
+                <rect id="矩形" fill="#FFFFFF" x="497.109175" y="95" width="7" height="64"></rect>
+                <rect id="矩形" fill="#FFFFFF" x="514.109175" y="95" width="7" height="64"></rect>
+                <rect id="矩形" fill="#FFFFFF" x="530.109175" y="95" width="7" height="64"></rect>
+                <rect id="矩形" fill="#FFFFFF" x="546.109175" y="95" width="7" height="64"></rect>
+                <polygon id="路径-302" fill="#A4C3FC" fill-rule="nonzero" points="466.970801 86.0695627 466.97 158.272 566.971883 158.272396 566.971883 159.272396 465.970801 159.272396 465.970801 86.0695627"></polygon>
+                <polygon id="路径-304" fill="#979797" fill-rule="nonzero" points="559.240013 152.472555 559.909745 151.729952 567.687435 158.744424 559.937708 166.917681 559.21205 166.229626 566.256 158.8"></polygon>
+                <path d="M547.776877,151.235303 L657.776877,151.235303 C662.195155,151.235303 665.776877,154.817025 665.776877,159.235303 L665.776877,231.269143 L665.776877,231.269143 L686.415326,253.760621 L547.831424,254.18059 C537.890344,254.210716 529.807085,246.1763 529.776959,236.23522 C529.776904,236.217038 529.776877,236.198855 529.776877,236.180672 L529.776877,169.235303 C529.776877,159.294177 537.835751,151.235303 547.776877,151.235303 Z" id="矩形" fill="#A4C3FC" transform="translate(608.096101, 202.735303) scale(-1, 1) translate(-608.096101, -202.735303) "></path>
+                <path d="M542.776877,150.235303 L652.776877,150.235303 C657.195155,150.235303 660.776877,153.817025 660.776877,158.235303 L660.776877,230.269143 L660.776877,230.269143 L681.415326,252.760621 L542.831424,253.18059 C532.890344,253.210716 524.807085,245.1763 524.776959,235.23522 C524.776904,235.217038 524.776877,235.198855 524.776877,235.180672 L524.776877,168.235303 C524.776877,158.294177 532.835751,150.235303 542.776877,150.235303 Z" id="矩形" fill="#FFCA67" transform="translate(603.096101, 201.735303) scale(-1, 1) translate(-603.096101, -201.735303) "></path>
+                <path d="M551.888365,105.031459 C555.290806,103.139777 558.513795,102.897668 562.237517,104.467631 L562.588104,104.620129 L562.17909,105.532657 C558.599379,103.928154 555.612548,104.105059 552.37429,105.905459 C550.368282,107.020755 548.58771,108.45472 545.16394,111.609463 L541.614214,114.898486 C538.015181,118.209826 536.087942,119.845252 533.11225,122.086913 C532.782184,122.335559 532.450805,122.581445 532.117803,122.824718 C528.104792,125.756407 523.934988,126.987135 519.313532,126.876779 C516.035171,126.798495 513.270144,126.221396 508.17289,124.737029 L505.737532,124.022849 C497.810115,121.733418 494.471662,121.366012 490.348408,122.889971 C482.286296,125.869735 475.026188,137.650266 468.664891,158.243664 L468.457777,158.918311 L467.501306,158.626481 C473.997113,137.336567 481.463921,125.107569 490.001728,121.951987 C494.55996,120.267261 498.129316,120.741123 506.945337,123.333314 L508.921,123.912647 C513.638819,125.272395 516.27064,125.803833 519.337404,125.877064 C523.744193,125.982294 527.697642,124.815424 531.527904,122.017241 L532.020296,121.654721 L532.510552,121.288189 C535.634194,118.935074 537.593599,117.254212 541.615416,113.537015 L544.497926,110.863327 C547.974881,107.659837 549.794043,106.195856 551.888365,105.031459 Z" id="路径-303" fill="#FFBB3C" fill-rule="nonzero"></path>
+                <polygon id="路径-305" fill="#A4C3FC" fill-rule="nonzero" points="458.750713 92.4640098 466.468831 85.3932668 474.275626 92.4620486 473.604426 93.2033249 466.472 86.745 459.42622 93.2013637"></polygon>
+                <g id="编组-81" transform="translate(50.109175, 134.235303)">
+                    <g id="编组-63" transform="translate(63.914093, 222.107327)">
+                        <mask id="mask-10" fill="white">
+                            <use xlink:href="#path-9"></use>
+                        </mask>
+                        <use id="矩形" fill="#DDE9FF" xlink:href="#path-9"></use>
+                        <path d="M1.20882698,0 L63.4052217,0 C64.4023405,3.36954592e-15 65.3342971,0.495421402 65.8920292,1.32196893 L67.6990928,4 C68.491521,5.17436234 68.1819023,6.76876112 67.0075399,7.56118935 C66.5836904,7.84719165 66.0840393,8 65.5727217,8 L2.06042429,8 C0.819095645,8 -0.294028853,7.23549708 -0.7396257,6.076903 L-1.5384054,4 C-2.12194354,2.48274545 -1.36501684,0.779716485 0.152237704,0.196178338 C0.489411271,0.0665009295 0.84757604,9.54539142e-16 1.20882698,0 Z" id="矩形" fill="#FFBB3C" mask="url(#mask-10)"></path>
+                    </g>
+                    <g id="编组-103" transform="translate(90.000000, 84.000000)">
+                        <mask id="mask-12" fill="white">
+                            <use xlink:href="#path-11"></use>
+                        </mask>
+                        <use id="路径-246" fill="#EAFFF3" xlink:href="#path-11"></use>
+                        <path d="M-1.42108547e-14,119.48446 C1.32743544,98.0102656 2.89289856,82.9508436 4.69638937,74.3061937 C8.43003277,56.4097675 15.5176097,41.8448008 19.4787027,34.195863 C29.7253967,14.409323 39.7215535,9.31301339 44.6820442,6.63347577 C49.6425348,3.95393816 60.3007481,2.37777643 66.327433,6.63347577 C72.3541179,10.8891751 74.5668372,17.0533931 73.7454921,27.1564165 C72.924147,37.2594398 65.469448,43.1497458 58.0193289,46.7343523 C50.5692098,50.3189588 31.0128594,64.1734323 19.4787027,78.1118722 C11.7892649,87.4041655 5.29636401,101.195028 -1.42108547e-14,119.48446 Z" id="路径-246" fill="#D3F0E0" mask="url(#mask-12)"></path>
+                        <path d="M61.0623172,22.0501917 C61.3287364,21.9775593 61.6035919,22.1346545 61.6762243,22.4010736 C61.7488567,22.6674927 61.5917615,22.9423483 61.3253424,23.0149807 C30.6460939,31.3788982 10.4195539,62.2160822 0.685726462,115.62224 C0.636212326,115.893907 0.375843567,116.073997 0.104176562,116.024483 C-0.167490443,115.974969 -0.347580926,115.7146 -0.29806679,115.442933 C9.49743654,61.6983811 29.9375632,30.535565 61.0623172,22.0501917 Z" id="路径-251" fill="#9FC8B1" fill-rule="nonzero" mask="url(#mask-12)"></path>
+                        <path d="M53.2988281,45.3242187 C23.2203776,62.4189453 5.8733724,84.8946126 1.2578125,112.751221 C31.0439453,72.423808 48.9638672,50.9959922 55.0175781,48.4677734 C61.0712891,45.9395547 60.4983724,44.8917031 53.2988281,45.3242187 Z" id="路径-358" fill="#C4E0D1" mask="url(#mask-12)"></path>
+                    </g>
+                    <g id="编组-102" transform="translate(112.698267, 46.543175)">
+                        <mask id="mask-14" fill="white">
+                            <use xlink:href="#path-13"></use>
+                        </mask>
+                        <use id="路径-247" fill="#EAFFF3" xlink:href="#path-13"></use>
+                        <mask id="mask-16" fill="white">
+                            <use xlink:href="#path-15"></use>
+                        </mask>
+                        <use id="路径-247" fill="#D3F0E0" xlink:href="#path-15"></use>
+                        <path d="M60.5426357,11.3128799 C60.8171154,11.2826219 61.064154,11.4806027 61.094412,11.7550823 C61.12467,12.0295619 60.9266892,12.2766006 60.6522096,12.3068585 C39.729997,14.6132741 18.9462607,31.6462845 -1.67037213,63.4563407 C-1.8205596,63.6880697 -2.13016408,63.7541722 -2.36189309,63.6039847 C-2.5936221,63.4537972 -2.65972458,63.1441927 -2.50953711,62.9124637 C18.2541689,30.8754836 39.2620175,13.6588053 60.5426357,11.3128799 Z" id="路径-253" fill="#9FC8B1" fill-rule="nonzero" mask="url(#mask-16)"></path>
+                        <path d="M77,14.004188 C76.1582967,19.0507483 74.4123575,22.6778642 71.7621824,24.8855357 C67.7869198,28.1970428 61.7621824,29.629188 56.5004637,30.6914365 C51.2387449,31.753685 28.3095457,36.4578462 17.2245848,45.7839732 L39.1176512,38.3640513 L70.2081638,30.6914365 L79.9838621,20.4621958 L77,14.004188 Z" id="路径-357" fill="#C4E0D1" mask="url(#mask-16)"></path>
+                    </g>
+                    <g id="编组-105" transform="translate(17.048956, 30.887531)">
+                        <mask id="mask-18" fill="white">
+                            <use xlink:href="#path-17"></use>
+                        </mask>
+                        <use id="路径-248" fill="#EAFFF3" xlink:href="#path-17"></use>
+                        <path d="M69.3618111,119.305105 C64.1514723,95.5149533 58.5592828,78.7727476 52.5852425,69.0784883 C43.6241821,54.5370993 32.2521675,45.1631445 20.9273327,40.7089848 C9.60249781,36.2548251 0.370054887,30.3143707 -0.833749667,21.5991494 C-2.03755422,12.8839281 3.30184276,3.89650161 14.9982131,2.35985332 C26.6945835,0.823205037 38.680528,3.89650161 49.3232751,17.6556441 C59.9660221,31.4147866 70.7898492,73.0503233 70.7898492,89.5111312 C70.7898492,100.485003 70.3138365,110.416328 69.3618111,119.305105 Z" id="路径-248" fill="#D3F0E0" mask="url(#mask-18)"></path>
+                        <path d="M23.8031025,13.6524169 C23.9507563,13.4197035 24.2596222,13.3495935 24.4929738,13.4972473 C53.619825,31.9273315 69.2261583,67.7464847 71.3453163,120.899853 C71.3559107,121.175776 71.1411468,121.408372 70.8652236,121.419775 C70.5893003,121.43037 70.3567042,121.215606 70.3457056,120.939683 C68.238827,68.0842278 52.7653698,32.5700476 23.9582721,14.3422882 C23.7249205,14.1946344 23.6554487,13.8857685 23.8031025,13.6524169 Z" id="路径-254" fill="#9FC8B1" fill-rule="nonzero" mask="url(#mask-18)"></path>
+                        <path d="M2.10955328,9.39371881 C-0.468124531,21.2459323 4.83890245,29.8251967 18.0306342,35.1315118 C37.8182319,43.0909844 45.0262397,50.6209933 54.8953803,65.9239922 C61.4748074,76.1259915 66.5415392,93.4846608 70.0955756,118 L-6.37610406,29.466961 L2.10955328,9.39371881 Z" id="路径-354" fill="#C4E0D1" mask="url(#mask-18)"></path>
+                    </g>
+                    <g id="编组-104" transform="translate(48.402642, -0.000000)">
+                        <mask id="mask-20" fill="white">
+                            <use xlink:href="#path-19"></use>
+                        </mask>
+                        <use id="路径-249" fill="#EAFFF3" xlink:href="#path-19"></use>
+                        <path d="M38.4361627,109.727577 C40.2080966,71.0333394 39.2052946,44.753324 35.4277569,30.8875312 C29.7614504,10.088842 20.8541813,-1.27827958 9.37287413,0.114578571 C-2.10843299,1.50743672 -4.5866861,11.539269 0.542720885,19.2423116 C5.67212787,26.9453541 20.1964111,48.5363293 25.3543068,61.4631547 C28.7929039,70.0810384 33.1535225,86.1691793 38.4361627,109.727577 Z" id="路径-249" fill="#D3F0E0" mask="url(#mask-20)" transform="translate(18.642412, 54.863789) rotate(-2.000000) translate(-18.642412, -54.863789) "></path>
+                        <path d="M1.96015082,4.87988281 C0.585845275,10.6699219 1.44620769,15.6948242 4.54123807,19.9545898 C9.18378363,26.3442383 28.9997016,54.6122295 32.8727485,77.5431753 L3.69550238,25.6259766 L-2.5784234,12.7954102 L1.96015082,4.87988281 Z" id="路径-356" fill="#C4E0D1" mask="url(#mask-20)"></path>
+                    </g>
+                    <g id="编组-106" transform="translate(-0.000000, 140.484501)">
+                        <mask id="mask-22" fill="white">
+                            <use xlink:href="#path-21"></use>
+                        </mask>
+                        <use id="路径-250" fill="#EAFFF3" xlink:href="#path-21"></use>
+                        <path d="M86.8630745,45.7959111 C72.5806324,25.5140129 56.8667378,12.125403 39.7213908,5.6300812 C14.0033702,-4.11290144 -7.10542736e-15,7.90110838 -7.10542736e-15,16.52167 C-7.10542736e-15,25.1422316 6.80949202,30.0268155 17.0489556,30.0268155 C27.2884192,30.0268155 43.7234658,28.0070237 58.8280258,36.5737997 C68.8977326,42.2849837 79.1842128,51.927944 89.6874666,65.5026805 L86.8630745,45.7959111 Z" id="路径-250" fill="#D3F0E0" mask="url(#mask-22)"></path>
+                        <path d="M1.1501319,20.1112023 C3.60224814,22.8815414 6.77648803,24.6116846 10.6728516,25.301632 C16.5173969,26.3365529 23.2104492,26.0726281 29.4458008,26.0726281 C47.5162559,26.0726281 66.1025391,30.4051476 89.6874666,63.5026805 L73.4389648,56.501339 L2.37988281,27.6380577 L1.1501319,20.1112023 Z" id="路径-355" fill="#C4E0D1" mask="url(#mask-22)"></path>
+                    </g>
+                    <path d="M63.5963011,11.6405216 C63.8094921,11.4650103 64.1245976,11.4955556 64.3001089,11.7087466 C74.8415791,24.513311 82.3205992,48.9543952 86.3331527,79.7677903 L86.4536838,80.7034746 C89.7765293,106.781999 90.2171879,135.285754 87.8968469,153.563607 L87.8038207,154.279761 C84.7801018,177.032488 87.8036828,199.576221 96.8773487,221.919195 C96.9812512,222.175044 96.858074,222.466681 96.6022247,222.570583 C96.3463753,222.674486 96.0547388,222.551308 95.9508363,222.295459 C86.945111,200.119782 83.8552445,177.733575 86.6839023,155.144723 L86.812536,154.148024 C89.2247883,135.99643 88.8179579,107.170593 85.4617038,80.8298697 L85.2198735,78.9727728 C81.2011719,48.7675619 73.814123,24.8386348 63.5280761,12.3443293 C63.3525648,12.1311384 63.3831102,11.8160329 63.5963011,11.6405216 Z" id="路径-245" fill="#9FC8B1" fill-rule="nonzero"></path>
+                    <path d="M19.210295,152.513695 C50.2079907,155.741553 73.2591803,169.485692 88.3189956,193.730479 C88.4647019,193.965052 88.3926616,194.273329 88.1580891,194.419035 C87.9235165,194.564741 87.6152396,194.492701 87.4695333,194.258128 C72.5772256,170.283012 49.8045783,156.704952 19.1067228,153.508317 C18.8320656,153.479717 18.6325973,153.233878 18.6611979,152.95922 C18.6866207,152.715081 18.8836888,152.530349 19.1200684,152.512399 L19.210295,152.513695 Z" id="路径-252" fill="#9FC8B1" fill-rule="nonzero"></path>
+                </g>
+                <g id="编组-76" transform="translate(570.109175, 160.235303)">
+                    <mask id="mask-24" fill="white">
+                        <use xlink:href="#path-23"></use>
+                    </mask>
+                    <use id="椭圆形" fill="#FFFFFF" xlink:href="#path-23"></use>
+                    <polygon id="路径-306" fill="#CCDEFF" mask="url(#mask-24)" points="42 44.0199101 77.9801299 18.2088648 90.719119 40.5 84 59.9151825"></polygon>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>

BIN
src/assets/imgs/bg.jpg


BIN
src/assets/imgs/loginbg.png


+ 8 - 0
src/assets/logo.svg

@@ -0,0 +1,8 @@
+<svg version="1.1" id="图层_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="183.5988680322423" height="124.28541139686087" viewBox="0 0 181.70001220703125 123" style="enable-background:new 0 0 181.7 123;" xml:space="preserve" class="style-removed" preserveAspectRatio="none" data-parent="shape_V4XxDvPqhq">
+
+<path d="M98.4,93.7c9.3,0,16.9-7.6,16.9-16.9c0-9.3-7.6-16.9-16.9-16.9H81.5v16.9C81.5,86.1,89.1,93.7,98.4,93.7z" fill="#ECBA21" stroke-width="0"></path>
+<path d="M146.1,44c0-0.1,0-0.1,0-0.2c0-11.4-9.2-20.6-20.6-20.6c-2.9,0-5.6,0.6-8.1,1.6C110.8,11,97.4,1.3,81.5,0v34.1
+	H63.9V2.2c-16.8,5.7-29,21.5-29.5,40.1h63.9c19,0,34.5,15.5,34.5,34.5c0,19-15.5,34.5-34.5,34.5S63.9,95.8,63.9,76.8V59.9H13.2
+	C5.2,66.4,0,76.3,0,87.5C0,107.1,15.9,123,35.5,123l105.7,0c0.3,0,2.6,0,2.6,0c21.1-0.9,37.9-18.2,37.9-39.5
+	C181.7,62.8,166.1,45.9,146.1,44z" fill="#E95513" stroke-width="0"></path>
+</svg>

+ 49 - 0
src/components/global/chart/index.vue

@@ -0,0 +1,49 @@
+<template>
+  <v-charts
+    v-if="renderChart"
+    :option="options"
+    :autoresize="autoresize"
+    :style="{ width, height }"
+  />
+</template>
+
+<script setup>
+import { ref, computed, nextTick } from 'vue';
+import VCharts from 'vue-echarts';
+import { useAppStore } from '@/store';
+
+const props = defineProps({
+  options: {
+    type: Object,
+    default() {
+      return {};
+    },
+  },
+  autoresize: {
+    type: Boolean,
+    default: true,
+  },
+  width: {
+    type: String,
+    default: '100%',
+  },
+  height: {
+    type: String,
+    default: '100%',
+  },
+});
+
+const appStore = useAppStore();
+
+const mode = computed(() => {
+  return appStore.mode === 'dark' ? 'dark' : 'auto';
+});
+
+const renderChart = ref(false);
+
+nextTick(() => {
+  renderChart.value = true;
+});
+</script>
+
+<style scoped lang="less"></style>

+ 42 - 0
src/components/global/index.js

@@ -0,0 +1,42 @@
+import { use } from 'echarts/core';
+import { CanvasRenderer } from 'echarts/renderers';
+import {
+  BarChart,
+  LineChart,
+  PieChart,
+  RadarChart,
+  GaugeChart,
+} from 'echarts/charts';
+import {
+  GridComponent,
+  TooltipComponent,
+  LegendComponent,
+  DataZoomComponent,
+  GraphicComponent,
+} from 'echarts/components';
+
+import Chart from './chart/index.vue';
+import SearchForm from './search-form/index.vue';
+import MyDialog from './my-dialog/index.vue';
+
+use([
+  CanvasRenderer,
+  BarChart,
+  LineChart,
+  PieChart,
+  RadarChart,
+  GaugeChart,
+  GridComponent,
+  TooltipComponent,
+  LegendComponent,
+  DataZoomComponent,
+  GraphicComponent,
+]);
+
+export default {
+  install(Vue) {
+    Vue.component('MyChart', Chart);
+    Vue.component('SearchForm', SearchForm);
+    Vue.component('MyDialog', MyDialog);
+  },
+};

+ 15 - 0
src/components/global/my-dialog/index.vue

@@ -0,0 +1,15 @@
+<template>
+  <t-dialog v-bind="attrs">
+    <template #body>
+      <slot></slot>
+    </template>
+    <template #footer v-if="slots.foot">
+      <slot name="foot"></slot>
+    </template>
+  </t-dialog>
+</template>
+<script setup name="MyDialog">
+import { useAttrs, useSlots } from 'vue';
+const attrs = useAttrs();
+const slots = useSlots();
+</script>

+ 117 - 0
src/components/global/search-form/components/search-form-item.vue

@@ -0,0 +1,117 @@
+<template>
+  <!-- 按钮群组 -->
+  <template v-if="item.type === 'buttons'">
+    <template v-for="(v, i) in item.children" :key="i">
+      <t-button v-bind="v.attrs" @click="v.onClick" v-if="v.type === 'button'">
+        {{ v.text }}
+      </t-button>
+      <t-dropdown
+        v-bind="v.attrs"
+        @click="v.onClick"
+        v-if="v.type === 'dropdown'"
+      >
+        <t-button v-bind="v.buttonAttrs"
+          >{{ v.text }}
+          <template #suffix><chevron-down-icon size="16" /></template>
+        </t-button>
+      </t-dropdown>
+    </template>
+  </template>
+  <!-- 按钮 -->
+  <template v-if="item.type === 'button'">
+    <t-button v-bind="attrs" @click="item.onClick">
+      {{ item.text }}
+    </t-button>
+  </template>
+  <!-- 下拉菜单按钮 -->
+  <template v-if="item.type === 'dropdown'"
+    ><t-dropdown v-bind="attrs" @click="item.onClick">
+      <t-button
+        >{{ item.text }}
+        <template #suffix><chevron-down-icon size="16" /></template>
+      </t-button> </t-dropdown
+  ></template>
+  <!-- 文本框 -->
+  <template v-if="item.type == undefined || item.type == 'text'">
+    <t-input
+      v-model="searchParam[item.prop]"
+      placeholder="请输入"
+      v-bind="attrs"
+    />
+  </template>
+  <!-- 下拉选择框 -->
+  <template v-if="item.type == 'select' || item.type == 'multipleSelect'">
+    <t-select
+      v-model="searchParam[item.prop]"
+      :multiple="item.type == 'multipleSelect'"
+      placeholder="请选择"
+      v-bind="attrs"
+    >
+      <t-option
+        v-for="itemValue in item.options"
+        :key="itemValue.value"
+        :label="itemValue.label"
+        :value="itemValue.value"
+        :disabled="itemValue.disabled"
+      />
+    </t-select>
+  </template>
+  <!-- 下拉树形选择框 -->
+  <template
+    v-if="item.type == 'treeSelect' || item.type == 'multipleTreeSelect'"
+  >
+    <t-tree-select
+      v-model="searchParam[item.prop]"
+      :multiple="item.type == 'multipleTreeSelect'"
+      :data="item.options"
+      v-bind="attrs"
+    />
+  </template>
+  <!-- 日期选择 -->
+  <template v-if="item.type == 'date'">
+    <t-date-picker
+      v-model="searchParam[item.prop]"
+      :mode="item.mode || 'date'"
+      value-format="YYYY-MM-DD"
+      type="date"
+      :placeholder="item.placeholder || '请选择日期'"
+      v-bind="attrs"
+    />
+  </template>
+  <!-- 时间选择 -->
+  <template v-if="item.type == 'time'">
+    <t-time-picker
+      v-model="searchParam[item.prop]"
+      format="HH:mm:ss"
+      v-bind="attrs"
+    />
+  </template>
+  <!-- 时间范围选择 -->
+  <template v-if="item.type == 'timerange'">
+    <t-time-range-picker
+      v-model="searchParam[item.prop]"
+      format="HH:mm:ss"
+      v-bind="attrs"
+    />
+  </template>
+  <!-- 日期范围选择 -->
+  <template v-if="item.type == 'daterange'">
+    <t-date-range-picker v-model="searchParam[item.prop]" v-bind="attrs" />
+  </template>
+</template>
+
+<script setup name="SearchFormItem">
+import { computed } from 'vue';
+import { ChevronDownIcon } from 'tdesign-icons-vue-next';
+const props = defineProps({
+  item: Object,
+  searchParam: Object,
+});
+const attrs = computed(() => {
+  let obj = {};
+  Object.entries(props.item.attrs || {}).forEach((v) => {
+    obj[v[0]] = v[1];
+  });
+  return obj;
+});
+</script>

+ 254 - 0
src/components/global/search-form/index.vue

@@ -0,0 +1,254 @@
+//该组件适用于表格的查询条件等不太复杂的表单场景,通过透传props配置来驱动表单的渲染。
+//该组件具备栅格功能,既可以配置model,也可以配置宽度
+//其他较复杂的主页面表单场景还是简易手写组件和业务(便于可读性和维护)
+<template>
+  <div
+    v-if="columns?.length"
+    class="table-search"
+    :class="{ 'in-padding': inPadding }"
+  >
+    <t-form ref="formRef" :model="searchParam" :inline="true">
+      <div class="first-row flex">
+        <t-form-item
+          v-for="item in firstLineItemsFilter"
+          :key="item.prop"
+          :label="item.label || ''"
+          :labelWidth="item.labelWidth || 0"
+          :style="{ width: colToWidth(item.colSpan || 0) }"
+          :class="{ 'buttons-wrap': item.type == 'buttons' }"
+        >
+          <SearchFormItem :item="item" :search-param="searchParam" />
+        </t-form-item>
+        <div class="flex-1"></div>
+        <t-form-item
+          v-for="item in firstLineItemsIsRight"
+          :key="item.prop"
+          :label="item.label || ''"
+          :labelWidth="item.labelWidth || 0"
+          :style="{ width: colToWidth(item.colSpan || 0) }"
+          :class="{ 'buttons-wrap': item.type == 'buttons' }"
+        >
+          <SearchFormItem :item="item" :search-param="searchParam" />
+        </t-form-item>
+        <div v-if="showExpandBtn && !showAll" class="flex-1 text-right">
+          <t-button
+            theme="primary"
+            shape="square"
+            variant="base"
+            class="m-l-5px"
+            @click="showMore = !showMore"
+          >
+            <ChevronDownIcon slot="icon" v-if="!showMore" />
+            <ChevronUpIcon slot="icon" v-else />
+          </t-button>
+        </div>
+        <!-- </div> -->
+      </div>
+      <div
+        ref="otherRows"
+        class="other-rows flex"
+        :style="{ height: otherHeight }"
+      >
+        <t-form-item
+          v-for="item in otherLineItems"
+          :key="item.prop"
+          :label="item.label || ''"
+          :labelWidth="item.labelWidth || 0"
+          :style="{ width: colToWidth(item.colSpan || 0) }"
+          :class="{ 'buttons-wrap': item.type == 'buttons' }"
+        >
+          <SearchFormItem :item="item" :search-param="searchParam" />
+        </t-form-item>
+      </div>
+    </t-form>
+  </div>
+</template>
+
+<script setup name="searchForm">
+import {
+  ref,
+  computed,
+  onMounted,
+  getCurrentInstance,
+  onUnmounted,
+  watch,
+  nextTick,
+} from 'vue';
+import { ChevronDownIcon, ChevronUpIcon } from 'tdesign-icons-vue-next';
+import SearchFormItem from './components/search-form-item.vue';
+import elementResizeDetectorMaker from 'element-resize-detector';
+import { cloneDeep } from 'lodash';
+import { useAppStore } from '@/store';
+const otherRows = ref(null);
+const appStore = useAppStore();
+let comWidth = ref(0);
+const { proxy } = getCurrentInstance();
+const listen = () => {
+  comWidth.value = proxy.$el.clientWidth;
+};
+onMounted(() => {
+  window.addEventListener('resize', listen);
+  var erd = elementResizeDetectorMaker();
+  let ele = document.querySelector('.left-menu');
+  if (!ele) return false;
+  erd.listenTo(ele, function (element) {
+    listen();
+  });
+});
+onUnmounted(() => {
+  window.removeEventListener('resize', listen);
+});
+
+// 默认值
+const props = defineProps({
+  columns: Object, // 搜索配置列,见下方示例
+  searchParam: Object, // 搜索参数
+  showAll: Boolean, //是否要求直接展开,无需折叠换行按钮
+  inPadding: Boolean,
+});
+//columns示例:(目前支持控件:input、select、dropdown按钮、treeSelect、date选择框、time选择框、timerange时间范围选择框、daterange日期范围选择框...后期可以按需求扩展)
+/*
+const exampleColumnsProp = [
+  {
+    prop: 'b',
+    label: '姓名',
+    labelWidth: '60px',
+    colSpan: 3.6,
+  },
+  {
+    prop: 'c',
+    label: '来源',
+    type: 'select',
+    labelWidth: '60px',
+    colSpan: 3.6,
+  },
+  {
+    type: 'buttons',
+    colSpan: 6,
+    children: [
+      {
+        type: 'button',
+        text: '查询',
+        attrs: {
+          theme: 'primary',
+        },
+        onClick: () => {},
+      },
+      {
+        type: 'dropdown',
+        text: '导入',
+        attrs: {
+          options: [
+            { content: '科组长', value: 1 },
+            { content: '复核员', value: 2 },
+          ],
+        },
+        buttonAttrs: {
+          theme: 'default',
+        },
+        onClick: (data) => {},
+      },
+    ],
+  },
+    {
+    type: 'button',
+    text: '新增',
+    position: 'right',    //搜索条件中可能会有按钮在首行中居右,可以使用position:'right'
+    attrs: {
+      theme: 'success',
+    },
+    onClick: () => {},
+  },
+];
+*/
+const showExpandBtn = computed(() => {
+  let allColSpanNum = props.columns.reduce((total, item) => {
+    return total + item.colSpan;
+  }, 0);
+  return allColSpanNum > 24;
+});
+// const maxWidth = ref(1260);
+
+// 是否展开更多搜索项
+const showMore = ref(false);
+const otherHeight = props.showAll ? ref('auto') : ref(0);
+const cutIndex = ref(0);
+
+watch(showMore, (val) => {
+  if (val) {
+    otherHeight.value = 'auto';
+    nextTick(() => {
+      let targetHeight = window.getComputedStyle(otherRows.value).height;
+      otherHeight.value = 0;
+      setTimeout(() => {
+        otherHeight.value = targetHeight;
+      }, 0);
+    });
+  } else {
+    otherHeight.value = 0;
+  }
+});
+
+// 根据是否展开配置搜索项长度
+const firstLineItems = computed(() => {
+  let init = 0;
+  let arr = [];
+  for (let i = 0; i < props.columns.length; i++) {
+    let column = props.columns[i];
+    if (init + (column.colSpan || 0) > 24) {
+      cutIndex.value = i;
+      break;
+    } else {
+      arr.push(cloneDeep(column));
+      init += column.colSpan || 0;
+    }
+  }
+  return arr;
+});
+const firstLineItemsFilter = computed(() => {
+  return firstLineItems.value.filter((item) => item.position !== 'right');
+});
+const firstLineItemsIsRight = computed(() => {
+  return firstLineItems.value.filter((item) => item.position === 'right');
+});
+
+const otherLineItems = computed(() => {
+  return props.columns?.slice(cutIndex.value) || [];
+});
+const colToWidth = (colSpan) => {
+  if (colSpan === 0) {
+    return 'auto';
+  }
+  let canUseWidth = comWidth.value - (props.showAll ? 20 : 60);
+  return Math.floor((canUseWidth * colSpan) / 24) + 'px';
+};
+</script>
+<style lang="less" scoped>
+.table-search {
+  padding: 10px;
+  background-color: #fff;
+  :deep(.t-form__item.buttons-wrap) {
+    .t-form__controls-content {
+      .t-button {
+        margin-left: 10px;
+      }
+    }
+  }
+  .other-rows {
+    height: 0;
+    overflow: hidden;
+    transition: all 0.2s;
+  }
+  :deep(
+      .other-rows
+        .t-form__item.buttons-wrap
+        .t-form__controls-content
+        .t-button:first-child
+    ) {
+    margin-left: 0;
+  }
+  &.in-padding {
+    margin: -10px;
+  }
+}
+</style>

+ 57 - 0
src/config/color.js

@@ -0,0 +1,57 @@
+export function generateColorMap(theme, colorPalette, mode = 'light') {
+  const isDarkMode = mode === 'dark';
+  let brandColorIdx = colorPalette.indexOf(theme);
+
+  if (isDarkMode) {
+    // eslint-disable-next-line no-use-before-define
+    colorPalette.reverse().map((color) => {
+      const [h, s, l] = Color.colorTransform(color, 'hex', 'hsl');
+      return Color.colorTransform([h, Number(s) - 4, l], 'hsl', 'hex');
+    });
+    brandColorIdx = 5;
+    colorPalette[0] = `${colorPalette[brandColorIdx]}20`;
+  }
+
+  const colorMap = {
+    '--td-brand-color': colorPalette[brandColorIdx], // 主题色
+    '--td-brand-color-1': colorPalette[0], // light
+    '--td-brand-color-2': colorPalette[1], // focus
+    '--td-brand-color-3': colorPalette[2], // disabled
+    '--td-brand-color-4': colorPalette[3],
+    '--td-brand-color-5': colorPalette[4],
+    '--td-brand-color-6': colorPalette[5],
+    '--td-brand-color-7':
+      brandColorIdx > 0 ? colorPalette[brandColorIdx - 1] : theme, // hover
+    '--td-brand-color-8': colorPalette[brandColorIdx], // 主题色
+    '--td-brand-color-9':
+      brandColorIdx > 8 ? theme : colorPalette[brandColorIdx + 1], // click
+    '--td-brand-color-10': colorPalette[9],
+  };
+  return colorMap;
+}
+export function insertThemeStylesheet(theme, colorMap, mode = 'light') {
+  console.log(theme, colorMap);
+  const isDarkMode = mode === 'dark';
+  const root = !isDarkMode
+    ? `:root[theme-color='${theme}']`
+    : `:root[theme-color='${theme}'][theme-mode='dark']`;
+
+  const styleSheet = document.createElement('style');
+  styleSheet.type = 'text/css';
+  //   styleSheet.innerText = `${root}{
+  styleSheet.innerText = `:root{
+      --td-brand-color: ${colorMap['--td-brand-color']};
+      --td-brand-color-1: ${colorMap['--td-brand-color-1']};
+      --td-brand-color-2: ${colorMap['--td-brand-color-2']};
+      --td-brand-color-3: ${colorMap['--td-brand-color-3']};
+      --td-brand-color-4: ${colorMap['--td-brand-color-4']};
+      --td-brand-color-5: ${colorMap['--td-brand-color-5']};
+      --td-brand-color-6: ${colorMap['--td-brand-color-6']};
+      --td-brand-color-7: ${colorMap['--td-brand-color-7']};
+      --td-brand-color-8: ${colorMap['--td-brand-color-8']};
+      --td-brand-color-9: ${colorMap['--td-brand-color-9']};
+      --td-brand-color-10: ${colorMap['--td-brand-color-10']};
+    }`;
+
+  document.head.appendChild(styleSheet);
+}

+ 7 - 0
src/config/global.js

@@ -0,0 +1,7 @@
+// Tdesign全局特性配置
+// 可以在此处定义更多自定义配置,具体可配置内容参看 API 文档 https://tdesign.tencent.com/vue-next/config
+export const globalConfig = {
+  calendar: {},
+  table: {},
+  pagination: {}
+};

+ 27 - 0
src/directives/copy.js

@@ -0,0 +1,27 @@
+import useClipboard from 'vue-clipboard3';
+import { MessagePlugin as Message } from 'tdesign-vue-next';
+
+const copy = (el, binding) => {
+  const { value } = binding;
+  el.addEventListener('click', async () => {
+    if (value && value !== '') {
+      try {
+        await useClipboard().toClipboard(value);
+        Message.success('已成功复制到剪切板');
+      } catch (e) {
+        Message.error('复制失败');
+      }
+    } else {
+      throw new Error(`need for copy content! Like v-copy="Hello World"`);
+    }
+  });
+};
+
+export default {
+  mounted(el, binding) {
+    copy(el, binding);
+  },
+  updated(el, binding) {
+    copy(el, binding);
+  },
+};

+ 7 - 0
src/directives/index.js

@@ -0,0 +1,7 @@
+import copy from './copy';
+
+export default {
+  install(Vue) {
+    Vue.directive('copy', copy);
+  },
+};

+ 13 - 0
src/hooks/useDialog.js

@@ -0,0 +1,13 @@
+import { computed } from 'vue';
+
+export default function useDialog(props, emit) {
+  const show = computed({
+    get() {
+      return props.visible;
+    },
+    set(v) {
+      emit('update:visible', v);
+    },
+  });
+  return { show };
+}

+ 45 - 0
src/hooks/useFetchTable.js

@@ -0,0 +1,45 @@
+import { ref, reactive, watch } from 'vue';
+
+export default function useFetchTable(apiFn, data = {}) {
+  let requestData = { ...data };
+  let pagination = reactive({
+    page: 1,
+    pageSize: data.pageSize || 10,
+    pageCount: 0,
+    showSizePicker: true,
+    pageSizes: [10, 20, 50],
+    onChange: (page) => {
+      pagination.page = page;
+    },
+    onUpdatePageSize: (pageSize) => {
+      pagination.pageSize = pageSize;
+      pagination.page = 1;
+    },
+  });
+  const loading = ref(false);
+  const tableData = ref([]);
+  const fetchData = async (requestParams) => {
+    loading.value = true;
+    if (requestParams) {
+      requestData = Object.assign({}, requestData, requestParams);
+    }
+    try {
+      let params = {
+        pageNow: pagination.page,
+        pageSize: pagination.pageSize,
+        ...requestData,
+      };
+      let res = await apiFn(params);
+      let list = Array.isArray(res.list) ? res.list : [];
+      tableData.value = list;
+      pagination.pageCount = res.totalPage;
+      loading.value = false;
+    } catch (err) {}
+  };
+  fetchData(requestData);
+  watch([() => pagination.page, () => pagination.pageSize], () => {
+    fetchData(requestData);
+  });
+
+  return { loading, pagination, tableData, fetchData };
+}

+ 101 - 0
src/hooks/useTableCrud.js

@@ -0,0 +1,101 @@
+import { computed, ref } from 'vue';
+
+const ACTIONS = {
+  edit: '编辑',
+  add: '新增',
+};
+
+export default function (
+  { name, doCreate, doDelete, doUpdate, refresh, initForm = {} },
+  formDialogRef
+) {
+  const visible = ref(false);
+  const type = ref('');
+  const title = computed(() => ACTIONS[type.value] + name);
+  const loading = ref(false);
+  let formRef = ref(null);
+  const formData = ref({ ...initForm });
+
+  //新增
+  function handleAdd() {
+    type.value = 'add';
+    visible.value = true;
+    formData.value = { ...initForm };
+  }
+
+  //编辑
+  function handleEdit(row) {
+    type.value = 'edit';
+    visible.value = true;
+    formData.value = { ...row };
+  }
+
+  //保存
+  function handleSave() {
+    if (!['edit', 'add'].includes(type.value)) {
+      visible.value = false;
+      return;
+    }
+    if (formDialogRef) {
+      formRef = formDialogRef.value.formRef;
+    }
+    formRef?.validate().then(async (res) => {
+      if (res !== true) return;
+      const actions = {
+        add: {
+          api: () => doCreate(formData.value),
+          cb: () => $message.success('新增成功'),
+        },
+        edit: {
+          api: () => doUpdate(formData.value),
+          cb: () => $message.success('编辑成功'),
+        },
+      };
+      const action = actions[type.value];
+
+      try {
+        loading.value = true;
+        const data = await action.api();
+        action.cb();
+        loading.value = visible.value = false;
+        refresh(data);
+      } catch (error) {
+        loading.value = false;
+      }
+    });
+  }
+
+  //删除
+  function handleDelete(id, confirmOptions) {
+    if (!id) return;
+    $dialog.confirm({
+      content: '确定删除?',
+      title: '提示',
+      async confirm() {
+        try {
+          loading.value = true;
+          const data = await doDelete(id);
+          $message.success('删除成功');
+          loading.value = false;
+          refresh(data);
+        } catch (error) {
+          loading.value = false;
+        }
+      },
+      ...confirmOptions,
+    });
+  }
+
+  return {
+    visible,
+    type,
+    title,
+    loading,
+    handleAdd,
+    handleDelete,
+    handleEdit,
+    handleSave,
+    formData,
+    formRef,
+  };
+}

+ 20 - 0
src/layout/404.vue

@@ -0,0 +1,20 @@
+<template>
+  <div class="page">
+    <div class="bg mx-auto">
+      <img src="@/assets/404.svg" />
+    </div>
+  </div>
+</template>
+
+<style scoped lang="less">
+.page {
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  transform: translateX(-50%) translateY(-70%);
+}
+.bg,
+.bg img {
+  width: 390px;
+}
+</style>

+ 39 - 0
src/layout/children-menu.vue

@@ -0,0 +1,39 @@
+<template>
+  <template v-for="menu in modelValue" :key="menu.name">
+    <t-menu-item
+      v-if="!menu.children || menu.children.length === 0"
+      :value="menu.name"
+      @click="routerPush(menu)"
+    >
+      <template v-if="menu.meta.icon" #icon>
+        <t-icon :name="menu.meta.icon"></t-icon>
+      </template>
+      {{ menu.meta.title }}</t-menu-item
+    >
+    <t-submenu v-else :value="menu.name">
+      <template v-if="menu.meta.icon" #icon>
+        <t-icon :name="menu.meta.icon"></t-icon>
+      </template>
+      <template #title>
+        {{ menu.meta.title }}
+      </template>
+      <template v-if="menu.children">
+        <children-menu v-model="menu.children" />
+      </template>
+    </t-submenu>
+  </template>
+</template>
+
+<script setup name="ChildrenMenu">
+import { useRouter } from 'vue-router';
+
+defineProps({ modelValue: Array });
+const router = useRouter();
+const routerPush = (menu) => {
+  if (menu.meta && menu.meta.type === 'L') {
+    window.open(menu.path);
+  } else {
+    router.push(menu.path);
+  }
+};
+</script>

+ 3 - 0
src/layout/empty.vue

@@ -0,0 +1,3 @@
+<template>
+  <router-view />
+</template>

+ 91 - 0
src/layout/index.vue

@@ -0,0 +1,91 @@
+<template>
+  <t-layout class="h-full app-layout">
+    <!-- <t-header class="layout-header">
+      <div class="header-wrap flex items-center">页头</div>
+    </t-header> -->
+    <t-aside
+      :width="
+        (appStore.menuCollapse ? appStore.collapseWidth : appStore.menuWidth) +
+        'px'
+      "
+    >
+      <!-- <div class="logo-box flex items-center justify-center">
+        <img src="../assets/logo.svg" />
+      </div> -->
+      <left-menu></left-menu>
+    </t-aside>
+    <t-layout class="right-content">
+      <t-header class="layout-header">
+        <div class="h-full header-wrap flex items-center justify-between">
+          <div class="app-name">质控平台</div>
+          <div class="header-right flex items-center">
+            <t-dropdown
+              :options="colorOptions"
+              trigger="click"
+              @click="colorChoose"
+            >
+              <t-button theme="default" variant="outline" shape="square">
+                <t-icon name="logo-windows-filled" size="16"
+              /></t-button>
+            </t-dropdown>
+          </div>
+        </div>
+      </t-header>
+      <t-content class="layout-content overflow-auto">
+        <!-- <transition name="slide-down" mode="out-in">
+          <router-view></router-view>
+        </transition> -->
+
+        <router-view v-slot="{ Component }">
+          <transition name="slide-down" mode="out-in" appear>
+            <component :is="Component" />
+          </transition>
+        </router-view>
+      </t-content>
+    </t-layout>
+  </t-layout>
+</template>
+
+<script setup name="Layout">
+import { ref } from 'vue';
+import { useAppStore, useUserStore } from '@/store';
+import LeftMenu from './left-menu.vue';
+import { Color } from 'tvision-color';
+import { generateColorMap, insertThemeStylesheet } from '@/config/color';
+
+const appStore = useAppStore();
+const colorOptions = ref([
+  { content: '默认主题', value: '#0052d9' },
+  { content: '天蓝主题', value: '#2fa4e7' },
+  { content: '橙色主题', value: '#e78b24' },
+  { content: '红色主题', value: '#dd4814' },
+]);
+const colorChoose = (data) => {
+  console.log('data:', data);
+  const hex = data.value;
+  const newPalette = Color.getPaletteByGradation({
+    colors: [hex],
+    step: 10,
+  })[0];
+  const colorMap = generateColorMap(hex, newPalette);
+  insertThemeStylesheet(hex, colorMap);
+};
+</script>
+
+<style lang="less" scoped>
+.app-layout {
+  .t-layout__sider {
+    //重写过渡时长,让其余menu的折叠动画保持同步
+    transition: all 0.28s cubic-bezier(0.645, 0.045, 0.355, 1);
+  }
+  .layout-header {
+    border-bottom: 1px solid #eee;
+    .header-wrap {
+      padding: 0 10px;
+    }
+  }
+  .layout-content {
+    flex: 1;
+  }
+}
+</style>

+ 99 - 0
src/layout/left-menu.vue

@@ -0,0 +1,99 @@
+<template>
+  <div class="left-menu">
+    <t-menu
+      theme="dark"
+      v-model:expanded="openKeys"
+      :default-value="activeKey"
+      :collapsed="appStore.menuCollapse"
+      :width="
+        (appStore.menuCollapse ? appStore.collapseWidth : appStore.menuWidth) +
+        'px'
+      "
+    >
+      <template #logo>
+        <div class="logo-box flex justify-center items-center">
+          <img src="../assets/logo.svg" />
+        </div>
+      </template>
+      <children-menu v-model="userStore.menus" />
+      <template #operations>
+        <div
+          class="flex justify-center items-center cursor-pointer h-full"
+          @click="onCollapse"
+        >
+          <t-icon
+            v-if="appStore.menuCollapse"
+            name="chevron-right-double"
+            color="#fff"
+          />
+          <t-icon v-else name="chevron-left-double" color="#fff" />
+        </div>
+      </template>
+    </t-menu>
+  </div>
+</template>
+
+<script setup name="LeftMenu">
+import { ref, watch, onBeforeMount } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+import { useAppStore, useUserStore } from '@/store';
+import childrenMenu from './children-menu.vue';
+
+const route = useRoute();
+const router = useRouter();
+// const menus = ref([]);
+const activeKey = ref('');
+const openKeys = ref([]);
+const appStore = useAppStore();
+const userStore = useUserStore();
+
+const title = ref('');
+
+onBeforeMount(() => {
+  activeKey.value = route.name;
+  setOpenMenu();
+});
+const onCollapse = () => {
+  appStore.toggleMenu();
+};
+// watch(
+//   () => route,
+//   (v) => {
+//     initMenu();
+//   },
+//   { deep: true }
+// );
+const setOpenMenu = () => {
+  openKeys.value = [];
+  if (route.matched.length && route.matched.length > 1) {
+    const last = route.matched[route.matched.length - 1];
+    if (last.meta && Array.isArray(last.meta.breadcrumb))
+      last.meta.breadcrumb.map((item) => {
+        openKeys.value.push(item.name);
+      });
+  }
+};
+</script>
+
+<style lang="less" scoped>
+.left-menu {
+  height: 100%;
+  :deep(.t-menu__operations) {
+    padding: 0 !important;
+    height: 40px;
+  }
+  .logo-box {
+    padding: 10px;
+    height: 100%;
+    width: 100%;
+    span {
+      font-size: 22px;
+      color: #ccc;
+    }
+    img {
+      max-height: 100%;
+      max-width: 100%;
+    }
+  }
+}
+</style>

+ 27 - 0
src/main.js

@@ -0,0 +1,27 @@
+import { createApp } from 'vue';
+
+import globalComponents from '@/components/global';
+import App from './App.vue';
+import router from './router';
+import store from './store';
+import directives from './directives';
+import { capsule } from '@/utils/tool';
+
+import 'tdesign-vue-next/es/style/index.css';
+// import 'tdesign-vue-next/dist/reset.css';
+import './style/index.less';
+import './style/global.less';
+import 'uno.css';
+
+import packageJson from '../package.json';
+import './mock/index';
+
+const app = createApp(App);
+
+app.use(router).use(store).use(directives).use(globalComponents);
+
+app.config.globalProperties.$title = import.meta.env.VITE_APP_TITLE;
+
+app.mount('#app');
+
+capsule('质控平台', `v${packageJson.version} release`);

+ 72 - 0
src/mock/index.js

@@ -0,0 +1,72 @@
+import Mock from 'mockjs';
+
+const menusList = [
+  // {
+  //   id: 1,
+  //   title: '一级菜单1',
+  //   parentId: -1,
+  //   url: '/menu1',
+  //   sort: 1,
+  //   icon: null,
+  //   name: 'Menu1',
+  // },
+  // {
+  //   id: 2,
+  //   title: '二级菜单1-1',
+  //   parentId: 1,
+  //   url: '/menu1/menu1-1',
+  //   sort: 2,
+  //   icon: null,
+  //   name: 'Menu1-1',
+  // },
+  // {
+  //   id: 3,
+  //   title: '二级菜单1-2',
+  //   parentId: 1,
+  //   url: '/menu1/menu1-2',
+  //   sort: 3,
+  //   icon: null,
+  //   name: 'Menu1-2',
+  // },
+  {
+    id: 4,
+    title: '用户管理',
+    parentId: -1,
+    url: '/userManage',
+    sort: 1,
+    icon: null,
+    name: 'UserManage',
+  },
+  {
+    id: 5,
+    title: '考试管理',
+    parentId: -1,
+    url: '/examManage',
+    sort: 2,
+    icon: null,
+    name: 'ExamManage',
+  },
+];
+
+export const menusApi = Mock.mock('/api/getMenus', 'get', () => {
+  return menusList;
+});
+
+export const loginApi = Mock.mock('/api/login', 'post', (data) => {
+  return {
+    userName: '路人甲',
+    id: Math.floor(Math.random() * 100 + 1),
+    token: '123',
+  };
+});
+
+export const addApi = Mock.mock('/api/add', 'post', (data) => {
+  return {
+    ok: true,
+  };
+});
+export const editApi = Mock.mock('/api/edit', 'post', (data) => {
+  return {
+    ok: true,
+  };
+});

+ 50 - 0
src/router/asyncRoutes.js

@@ -0,0 +1,50 @@
+import userManage from './modules/userManage';
+import examManage from './modules/examManage';
+
+export default [
+  ...userManage,
+  ...examManage,
+  // {
+  //   name: 'Menu1',
+  //   path: '/menu1',
+  //   component: () => import('@/views/menu1/index.vue'),
+  //   meta: {
+  //     title: '一级菜单1',
+  //     sort: 1,
+  //     icon: 'home',
+  //   },
+  //   redirect: '/menu1/menu1-1',
+  //   children: [
+  //     {
+  //       name: 'Menu1-1',
+  //       path: '/menu1/menu1-1',
+  //       component: () => import('@/views/menu1/menu1-1/index.vue'),
+  //       meta: {
+  //         title: '二级菜单1-1',
+  //         sort: 1,
+  //         icon: null,
+  //       },
+  //     },
+  //     {
+  //       name: 'Menu1-2',
+  //       path: '/menu1/menu1-2',
+  //       component: () => import('@/views/menu1/menu1-2/index.vue'),
+  //       meta: {
+  //         title: '二级菜单1-2',
+  //         sort: 2,
+  //         icon: null,
+  //       },
+  //     },
+  //   ],
+  // },
+  // {
+  //   name: 'Menu2',
+  //   path: '/menu2',
+  //   component: () => import('@/views/menu2/index.vue'),
+  //   meta: {
+  //     title: '一级菜单2',
+  //     sort: 2,
+  //     icon: 'home',
+  //   },
+  // },
+];

+ 77 - 0
src/router/index.js

@@ -0,0 +1,77 @@
+import {
+  createRouter,
+  createWebHashHistory,
+  createWebHistory,
+} from 'vue-router';
+import NProgress from 'nprogress';
+import { useUserStore } from '@/store';
+import { local } from '@/utils/tool';
+import 'nprogress/nprogress.css';
+
+import routes from './routes';
+
+const title = import.meta.env.VITE_APP_TITLE;
+const defaultRoutePath = '/';
+const whiteRoutes = ['Login'];
+
+const { VITE_HASH_ROUTE = 'N' } = import.meta.env;
+
+const router = createRouter({
+  // history: createWebHashHistory(),
+  history:
+    VITE_HASH_ROUTE === 'Y' ? createWebHashHistory() : createWebHistory(),
+  routes,
+  scrollBehavior: () => ({ left: 0, top: 0 }),
+});
+
+router.beforeEach(async (to, from, next) => {
+  NProgress.start();
+  const userStore = useUserStore();
+  const toTitle = to.meta.title ? to.meta.title : to.name;
+  document.title = `${toTitle} - ${title}`;
+  const token = local.get(import.meta.env.VITE_APP_TOKEN_PREFIX);
+  if (whiteRoutes.includes(to.name)) {
+    next();
+    return;
+  }
+  // 登录状态下
+  if (token) {
+    // if (to.name === 'Login') {
+    //   next({ path: defaultRoutePath });
+    //   return;
+    // }
+
+    if (!userStore.routers) {
+      const data = await userStore.requestUserMenu();
+      if (!data) {
+        userStore.clearToken();
+        next(
+          to.fullPath === '/'
+            ? { name: 'Login' }
+            : { name: 'Login', query: { redirect: to.fullPath } }
+        );
+      } else {
+        next({ path: to.path, query: to.query });
+      }
+    } else {
+      next();
+    }
+  } else {
+    next(
+      to.fullPath === '/'
+        ? { name: 'Login' }
+        : { name: 'Login', query: { redirect: to.fullPath } }
+    );
+  }
+});
+
+router.afterEach((to, from) => {
+  NProgress.done();
+});
+
+router.onError((error) => {
+  console.log(error);
+  NProgress.done();
+});
+
+export default router;

+ 12 - 0
src/router/modules/examManage.js

@@ -0,0 +1,12 @@
+export default [
+  {
+    name: 'ExamManage',
+    path: '/examManage',
+    component: () => import('@/views/examManage/index.vue'),
+    meta: {
+      title: '考试管理',
+      sort: 2,
+      icon: 'bulletpoint',
+    },
+  },
+];

+ 12 - 0
src/router/modules/userManage.js

@@ -0,0 +1,12 @@
+export default [
+  {
+    name: 'UserManage',
+    path: '/userManage',
+    component: () => import('@/views/userManage/index.vue'),
+    meta: {
+      title: '用户管理',
+      sort: 1,
+      icon: 'user',
+    },
+  },
+];

+ 40 - 0
src/router/routes.js

@@ -0,0 +1,40 @@
+// 系统路由
+const routes = [
+  {
+    name: 'Layout',
+    path: '/',
+    component: () => import('@/layout/index.vue'),
+    redirect: '/userManage',
+    children: [],
+    meta: {
+      title: '',
+    },
+  },
+  {
+    name: 'Login',
+    path: '/login',
+    component: () => import('@/views/login/index.vue'),
+    meta: { title: '登录' },
+    children: [
+      {
+        name: 'ExamSelect',
+        path: '/login/examSelect',
+        component: () => import('@/views/login/examSelect/index.vue'),
+      },
+      {
+        name: 'subjectSelect',
+        path: '/login/subject-select',
+        component: () => import('@/views/login/subjectSelect/index.vue'),
+      },
+    ],
+  },
+
+  {
+    path: '/:pathMatch(.*)*',
+    hidden: true,
+    meta: { title: '访问的页面不存在' },
+    component: () => import('@/layout/404.vue'),
+  },
+];
+
+export default routes;

+ 8 - 0
src/store/index.js

@@ -0,0 +1,8 @@
+import { createPinia } from 'pinia';
+import useAppStore from './modules/app';
+import useUserStore from './modules/user';
+
+const pinia = createPinia();
+
+export { useAppStore, useUserStore };
+export default pinia;

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

@@ -0,0 +1,31 @@
+import { defineStore } from 'pinia';
+
+const useAppStore = defineStore('app', {
+  state: () => ({
+    menuCollapse: false,
+    menuWidth: 220,
+    collapseWidth: 64,
+    language: 'zh_CN',
+  }),
+
+  getters: {
+    appCurrentSetting(state) {
+      return { ...state };
+    },
+  },
+
+  actions: {
+    updateSettings(partial) {
+      this.$patch(partial);
+    },
+
+    toggleMenu() {
+      this.menuCollapse = !this.menuCollapse;
+    },
+    changeLanguage(language) {
+      this.language = language;
+    },
+  },
+});
+
+export default useAppStore;

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

@@ -0,0 +1,151 @@
+import { defineStore } from 'pinia';
+import { cloneDeep } from 'lodash';
+import userApi from '@/api/user';
+import router from '@/router/index';
+import asyncRoutes from '@/router/asyncRoutes';
+import { getTreeList, local } from '@/utils/tool';
+
+// 路由扁平化
+const flatAsyncRoutes = (routes, breadcrumb = []) => {
+  const res = [];
+  routes.forEach((route) => {
+    const tmp = { ...route };
+    if (tmp.children) {
+      const childrenBreadcrumb = [...breadcrumb];
+      childrenBreadcrumb.push(route);
+      const tmpRoute = { ...route };
+      tmpRoute.meta.breadcrumb = childrenBreadcrumb;
+      delete tmpRoute.children;
+      res.push(tmpRoute);
+      const childrenRoutes = flatAsyncRoutes(tmp.children, childrenBreadcrumb);
+      childrenRoutes.map((item) => {
+        res.push(item);
+      });
+    } else {
+      const tmpBreadcrumb = [...breadcrumb];
+      tmpBreadcrumb.push(tmp);
+      tmp.meta.breadcrumb = tmpBreadcrumb;
+      res.push(tmp);
+    }
+  });
+  return res;
+};
+
+const views = import.meta.glob('../../views/**/**.vue');
+const empty = import.meta.glob('../../layout/empty.vue');
+const findRouterItemByName = (name, arr = asyncRoutes) => {
+  for (let i = 0; i < arr.length; i++) {
+    const item = arr[i];
+    if (item.name === name) {
+      return item;
+    }
+    if (item.children) {
+      return findRouterItemByName(name, item.children);
+    }
+  }
+  return null;
+};
+// 菜单转换路由
+const filterAsyncRouter = (routerMap) => {
+  const accessedRouters = [];
+  routerMap.forEach((item) => {
+    const target = findRouterItemByName(item.name);
+    if (target) {
+      const route = {
+        path: target.path,
+        name: target.name,
+        // hidden: item.hidden == 1,
+        meta: target.meta,
+        children: item.children ? filterAsyncRouter(item.children) : null,
+        component: views[`../../views${target.path}/index.vue`],
+        redirect: target.redirect || null,
+      };
+      accessedRouters.push(route);
+    }
+  });
+  return accessedRouters;
+};
+
+const useUserStore = defineStore('user', {
+  state: () => ({
+    routers: undefined,
+    user: undefined,
+    menus: [],
+  }),
+
+  actions: {
+    setToken(token) {
+      local.set(import.meta.env.VITE_APP_TOKEN_PREFIX, token);
+    },
+
+    getToken() {
+      return local.get(import.meta.env.VITE_APP_TOKEN_PREFIX);
+    },
+
+    clearToken() {
+      local.remove(import.meta.env.VITE_APP_TOKEN_PREFIX);
+    },
+    setState(data) {
+      this.$patch(data);
+    },
+    setInfo(data) {
+      this.user = data;
+    },
+
+    resetUserInfo() {
+      this.$reset();
+    },
+
+    setMenu(data) {
+      const menus = filterAsyncRouter(getTreeList(data));
+      this.menus = menus;
+      const routers = flatAsyncRoutes(cloneDeep(menus));
+      this.routers = routers;
+      routers.map((item) => router.addRoute('Layout', item));
+    },
+
+    requestUserMenu() {
+      return new Promise((resolve, reject) => {
+        userApi
+          .getMenus()
+          .then((response) => {
+            if (!response) {
+              router.push({ name: 'Login' });
+              return;
+            }
+            this.setMenu(response);
+            resolve(response);
+          })
+          .catch((_) => {
+            this.clearToken();
+            router.push({ name: 'Login' });
+          });
+      }).catch((error) => {
+        this.clearToken();
+        router.push({ name: 'Login' });
+      });
+    },
+
+    login(form) {
+      return userApi
+        .login(form)
+        .then((r) => {
+          this.setToken(r.token);
+          return true;
+        })
+        .catch((e) => {
+          console.error(e);
+          return false;
+        });
+    },
+
+    async logout() {
+      await userApi.logout();
+      local.remove('tags');
+      this.clearToken();
+      this.resetUserInfo();
+    },
+  },
+});
+
+export default useUserStore;

+ 50 - 0
src/style/animation.less

@@ -0,0 +1,50 @@
+.slide-down-enter-active,
+.slide-down-leave-active,
+.slide-up-enter-active,
+.slide-up-leave-active {
+  will-change: transform;
+  transition: all 0.15s ease;
+}
+// slide-down
+.slide-down-enter-from {
+  opacity: 0;
+  transform: translateY(-10px);
+}
+.slide-down-leave-to {
+  opacity: 0;
+  transform: translateY(10px);
+}
+
+// slide-up
+.slide-up-enter-from {
+  &:extend(.slide-down-leave-to);
+}
+.slide-up-leave-to {
+  &:extend(.slide-down-enter-from);
+}
+
+.zoom-fade-enter-active,
+.zoom-fade-leave-active {
+  transition: transform 0.1s, opacity 0.1s ease-out;
+}
+
+.zoom-fade-enter-from {
+  opacity: 0;
+  transform: scale(0.92);
+}
+
+.zoom-fade-leave-to {
+  opacity: 0;
+  transform: scale(1.06);
+}
+
+.drop-down-enter-active,
+.drop-down-leave-active {
+  transition: all 0.2s ease-out;
+}
+.drop-down-enter-from {
+  height: 0;
+}
+.drop-down-leave-top {
+  height: 100px;
+}

+ 59 - 0
src/style/global.less

@@ -0,0 +1,59 @@
+@import 'animation.less';
+@import 'reset.less';
+html,
+body {
+  height: 100%;
+  background: #f5f7f9 url(../assets/imgs/bg.jpg) 0 0 no-repeat;
+  background-size: cover;
+  font-size: 13px;
+  color: #333;
+}
+#app {
+  height: 100%;
+  min-width: 1200px;
+}
+
+.t-table {
+  margin-top: 10px;
+}
+.t-form__item {
+  margin-bottom: 15px;
+}
+.t-dialog--default {
+  padding-top: 20px;
+}
+.t-dialog__body {
+  padding-top: 25px;
+}
+
+.t-date-picker__panel .t-pagination-mini .t-pagination-mini__current {
+  display: none;
+}
+
+.layout-header {
+  width: 100%;
+  // box-shadow: 1px 1px 2px var(--color-neutral-2);
+}
+
+.table-search {
+  .t-form__item {
+    margin-bottom: 10px;
+    .t-form__label {
+      padding-right: var(--td-comp-paddingLR-m);
+    }
+    .t-form__controls-content {
+      & > .t-date-picker,
+      & > .t-time-picker {
+        width: 100%;
+      }
+    }
+  }
+}
+.page-wrap {
+  margin: 10px;
+  border-radius: 6px;
+  background-color: #fff;
+  padding: 15px;
+  min-height: 100%;
+  overflow: auto;
+}

+ 4 - 0
src/style/index.less

@@ -0,0 +1,4 @@
+/* ./src/index.css */
+@tailwind base;
+@tailwind components;
+@tailwind utilities;

+ 399 - 0
src/style/reset.less

@@ -0,0 +1,399 @@
+/*
+1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
+2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
+*/
+*,
+::before,
+::after {
+  box-sizing: border-box; /* 1 */
+  border-width: 0; /* 2 */
+  border-style: solid; /* 2 */
+  border-color: currentColor; /* 2 */
+}
+
+/*
+1. Use a consistent sensible line-height in all browsers.
+2. Prevent adjustments of font size after orientation changes in iOS.
+3. Use a more readable tab size.
+4. Use the user's configured `sans` font-family by default.
+*/
+
+html {
+  //line-height: 1.5; /* 1 */
+  line-height: 1;
+  -webkit-text-size-adjust: 100%; /* 2 */
+  -moz-tab-size: 4; /* 3 */
+  tab-size: 4; /* 3 */
+  font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
+    'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif,
+    'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; /* 4 */
+}
+html,
+body,
+#app {
+  height: 100%;
+}
+
+/*
+1. Remove the margin in all browsers.
+2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
+*/
+
+body {
+  margin: 0; /* 1 */
+  //line-height: inherit; /* 2 */
+  line-height: 1;
+}
+
+/*
+1. Add the correct height in Firefox.
+2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
+3. Ensure horizontal rules are visible by default.
+*/
+
+hr {
+  height: 0; /* 1 */
+  color: inherit; /* 2 */
+  border-top-width: 1px; /* 3 */
+}
+
+/*
+Add the correct text decoration in Chrome, Edge, and Safari.
+*/
+
+abbr:where([title]) {
+  text-decoration: underline dotted;
+}
+
+/*
+Remove the default font size and weight for headings.
+*/
+
+//h1,
+//h2,
+//h3,
+//h4,
+//h5,
+//h6 {
+//  font-size: inherit;
+//  font-weight: inherit;
+//}
+
+/*
+Reset links to optimize for opt-in styling instead of opt-out.
+*/
+
+a {
+  color: inherit;
+  text-decoration: inherit;
+}
+
+/*
+Add the correct font weight in Edge and Safari.
+*/
+
+b,
+strong {
+  font-weight: bolder;
+}
+
+/*
+1. Use the user's configured `mono` font family by default.
+2. Correct the odd `em` font sizing in all browsers.
+*/
+
+code,
+kbd,
+samp,
+pre {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
+    'Liberation Mono', 'Courier New', monospace; /* 1 */
+  font-size: 1em; /* 2 */
+}
+
+/*
+Add the correct font size in all browsers.
+*/
+
+small {
+  font-size: 80%;
+}
+
+/*
+Prevent `sub` and `sup` elements from affecting the line height in all browsers.
+*/
+
+sub,
+sup {
+  font-size: 75%;
+  line-height: 0;
+  position: relative;
+  vertical-align: baseline;
+}
+
+sub {
+  bottom: -0.25em;
+}
+
+sup {
+  top: -0.5em;
+}
+
+/*
+1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
+2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
+3. Remove gaps between table borders by default.
+*/
+
+table {
+  text-indent: 0; /* 1 */
+  border-color: inherit; /* 2 */
+  border-collapse: collapse; /* 3 */
+}
+
+/*
+1. Change the font styles in all browsers.
+2. Remove the margin in Firefox and Safari.
+3. Remove default padding in all browsers.
+*/
+
+button,
+input,
+optgroup,
+select,
+textarea {
+  font-family: inherit; /* 1 */
+  font-size: 100%; /* 1 */
+  line-height: inherit; /* 1 */
+  color: inherit; /* 1 */
+  margin: 0; /* 2 */
+  padding: 0; /* 3 */
+}
+
+/*
+Remove the inheritance of text transform in Edge and Firefox.
+*/
+
+button,
+select {
+  text-transform: none;
+}
+
+/*
+1. Correct the inability to style clickable types in iOS and Safari.
+2. Remove default button styles.
+*/
+
+button,
+[type='button'],
+[type='reset'],
+[type='submit'] {
+  -webkit-appearance: button; /* 1 */
+  /* background-color: transparent; 2 */
+  background-image: none; /* 2 */
+}
+
+/*
+Use the modern Firefox focus style for all focusable elements.
+*/
+
+:-moz-focusring {
+  outline: auto;
+}
+
+/*
+Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
+*/
+
+:-moz-ui-invalid {
+  box-shadow: none;
+}
+
+/*
+Add the correct vertical alignment in Chrome and Firefox.
+*/
+
+progress {
+  vertical-align: baseline;
+}
+
+/*
+Correct the cursor style of increment and decrement buttons in Safari.
+*/
+
+::-webkit-inner-spin-button,
+::-webkit-outer-spin-button {
+  height: auto;
+}
+
+/*
+1. Correct the odd appearance in Chrome and Safari.
+2. Correct the outline style in Safari.
+*/
+
+[type='search'] {
+  -webkit-appearance: textfield; /* 1 */
+  outline-offset: -2px; /* 2 */
+}
+
+/*
+Remove the inner padding in Chrome and Safari on macOS.
+*/
+
+::-webkit-search-decoration {
+  -webkit-appearance: none;
+}
+
+/*
+1. Correct the inability to style clickable types in iOS and Safari.
+2. Change font properties to `inherit` in Safari.
+*/
+
+::-webkit-file-upload-button {
+  -webkit-appearance: button; /* 1 */
+  font: inherit; /* 2 */
+}
+
+/*
+Add the correct display in Chrome and Safari.
+*/
+
+summary {
+  display: list-item;
+}
+
+/*
+Removes the default spacing and border for appropriate elements.
+*/
+
+blockquote,
+dl,
+dd,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+hr,
+figure,
+p,
+pre {
+  margin: 0;
+}
+
+fieldset {
+  margin: 0;
+  padding: 0;
+}
+
+legend {
+  padding: 0;
+}
+
+ol,
+ul,
+menu {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+}
+
+/*
+Prevent resizing textareas horizontally by default.
+*/
+
+textarea {
+  resize: vertical;
+}
+
+/*
+1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
+2. Set the default placeholder color to the user's configured gray 400 color.
+*/
+
+input::placeholder,
+textarea::placeholder {
+  opacity: 1; /* 1 */
+  //color: #9ca3af; /* 2 */
+}
+
+/*
+Set the default cursor for buttons.
+*/
+
+button,
+[role='button'] {
+  cursor: pointer;
+}
+
+/*
+Make sure disabled buttons don't get the pointer cursor.
+*/
+:disabled {
+  cursor: default;
+}
+
+/*
+1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
+2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
+   This can trigger a poorly considered lint error in some tools but is included by design.
+*/
+
+img,
+svg,
+video,
+canvas,
+audio,
+iframe,
+embed,
+object {
+  //display: block; /* 1 */
+  //vertical-align: middle; /* 2 */
+}
+
+/*
+Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
+*/
+
+img,
+video {
+  max-width: 100%;
+  max-height: 100%;
+  height: auto;
+}
+
+/*
+Ensure the default browser behavior of the `hidden` attribute.
+*/
+
+[hidden] {
+  display: none;
+}
+
+/*---滚动条默认显示样式--*/
+::-webkit-scrollbar-thumb {
+  background-color: #e6e6e6;
+  border-radius: 6px;
+}
+/*---鼠标点击滚动条显示样式--*/
+::-webkit-scrollbar-thumb:hover {
+  background-color: #e6e6e6;
+  border-radius: 6px;
+}
+/*---滚动条大小--*/
+::-webkit-scrollbar {
+  width: 6px;
+  height: 6px;
+}
+
+/*---滚动框背景样式--*/
+::-webkit-scrollbar-track-piece {
+  background-color: rgba(0, 0, 0, 0);
+  border-radius: 0;
+}
+
+html {
+  scrollbar-width: thin;
+  scrollbar-color: #e6e6e6 transparent;
+}

+ 123 - 0
src/utils/request.js

@@ -0,0 +1,123 @@
+import axios from 'axios';
+import { MessagePlugin as Message } from 'tdesign-vue-next';
+import { local, download } from '@/utils/tool';
+import { get, isEmpty } from 'lodash';
+import qs from 'qs';
+import { h } from 'vue';
+function createService() {
+  // 创建一个 axios 实例
+  const service = axios.create();
+
+  // HTTP request 拦截器
+  service.interceptors.request.use(
+    (config) => {
+      if (config.download) {
+        config.responseType ??= 'blob';
+      }
+      return config;
+    },
+    (error) => {
+      // 失败
+      return Promise.reject(error);
+    }
+  );
+
+  // HTTP response 拦截器
+  service.interceptors.response.use(
+    (response) => {
+      // 以下代码看后端是否有统一在接口里增加外层code的规范
+      // if (response.data.code && response.data.code !== 200) {
+      //   Message.error({
+      //     content: response.data.message,
+      //     icon: () => h(IconFaceFrownFill),
+      //   });
+      // }
+      if (response.config.download && response.config.responseType === 'blob') {
+        downlowd(response);
+      }
+      return response.data;
+    },
+    (error) => {
+      const err = (text) => {
+        Message.error({
+          content:
+            error.response && error.response.data && error.response.data.message
+              ? error.response.data.message
+              : text,
+        });
+      };
+      if (error.response) {
+        switch (error.response.status) {
+          case 404:
+            err(`${error?.config?.url} 服务器资源不存在`);
+            break;
+          case 500:
+            err(`${error?.config?.url} 服务器内部错误`);
+            break;
+          case 401:
+            err('登录状态已过期,需要重新登录');
+            local.clear();
+            window.location.href = '/';
+            break;
+          case 403:
+            err(`${error?.config?.url} 没有权限访问该资源`);
+            break;
+          default:
+            err('未知错误!');
+        }
+      } else {
+        err('请求超时,服务器无响应!');
+      }
+      return Promise.reject(
+        error.response && error.response.data ? error.response.data : null
+      );
+    }
+  );
+  return service;
+}
+
+function stringify(data) {
+  return qs.stringify(data, { allowDots: true, encode: false });
+}
+
+/**
+ * @description 创建请求方法
+ * @param {Object} service axios 实例
+ */
+function createRequest(service) {
+  return function (config) {
+    console.log('import.meta.env:', import.meta.env);
+    const { env } = import.meta;
+    const token = local.get(env.VITE_APP_TOKEN_PREFIX);
+    const configDefault = {
+      headers: {
+        'Authorization': `Bearer ${token}`,
+        'Content-Type': get(
+          config,
+          'headers.Content-Type',
+          'application/json;charset=UTF-8'
+        ),
+      },
+
+      timeout: 60000,
+      baseURL:
+        env.VITE_HTTP_PROXY === 'Y'
+          ? env.VITE_APP_PROXY_PREFIX
+          : env.VITE_APP_BASE_URL,
+      data: {},
+    };
+    const option = Object.assign(configDefault, config);
+
+    // json
+    if (!isEmpty(option.params)) {
+      option.url = `${option.url}?${stringify(option.params)}`;
+      option.params = {};
+    }
+
+    return service(option);
+  };
+}
+
+// 用于真实网络请求的实例和请求方法
+export const service = createService();
+export const request = createRequest(service);

+ 318 - 0
src/utils/tool.js

@@ -0,0 +1,318 @@
+import { cloneDeep } from 'lodash';
+
+export const extractFileName = (str) => {
+  if (/filename=([^;\s]*)/gi.test(str)) {
+    return decodeURIComponent(RegExp.$1);
+  }
+  return '下载文件';
+};
+
+const typeColor = (type = 'default') => {
+  let color = '';
+  switch (type) {
+    case 'default':
+      color = '#35495E';
+      break;
+    case 'primary':
+      color = '#3488ff';
+      break;
+    case 'success':
+      color = '#43B883';
+      break;
+    case 'warning':
+      color = '#e6a23c';
+      break;
+    case 'danger':
+      color = '#f56c6c';
+      break;
+    default:
+      break;
+  }
+  return color;
+};
+
+/**
+ * LocalStorage
+ */
+export const local = {
+  set(table, settings) {
+    const _set = JSON.stringify(settings);
+    return localStorage.setItem(table, _set);
+  },
+  get(table) {
+    let data = localStorage.getItem(table);
+    try {
+      data = JSON.parse(data);
+    } catch (err) {
+      return null;
+    }
+    return data;
+  },
+  remove(table) {
+    return localStorage.removeItem(table);
+  },
+  clear() {
+    return localStorage.clear();
+  },
+};
+
+/**
+ * SessionStorage
+ */
+export const session = {
+  set(table, settings) {
+    const _set = JSON.stringify(settings);
+    return sessionStorage.setItem(table, _set);
+  },
+  get(table) {
+    let data = sessionStorage.getItem(table);
+    try {
+      data = JSON.parse(data);
+    } catch (err) {
+      return null;
+    }
+    return data;
+  },
+  remove(table) {
+    return sessionStorage.removeItem(table);
+  },
+  clear() {
+    return sessionStorage.clear();
+  },
+};
+
+/**
+ * CookieStorage
+ */
+export const cookie = {
+  set(name, value, config = {}) {
+    const cfg = {
+      expires: null,
+      path: null,
+      domain: null,
+      secure: false,
+      httpOnly: false,
+      ...config,
+    };
+    let cookieStr = `${name}=${escape(value)}`;
+    if (cfg.expires) {
+      const exp = new Date();
+      exp.setTime(exp.getTime() + parseInt(cfg.expires) * 1000);
+      cookieStr += `;expires=${exp.toGMTString()}`;
+    }
+    if (cfg.path) {
+      cookieStr += `;path=${cfg.path}`;
+    }
+    if (cfg.domain) {
+      cookieStr += `;domain=${cfg.domain}`;
+    }
+    document.cookie = cookieStr;
+  },
+  get(name) {
+    const arr = document.cookie.match(new RegExp(`(^| )${name}=([^;]*)(;|$)`));
+    if (arr != null) {
+      return unescape(arr[2]);
+    }
+    return null;
+  },
+  remove(name) {
+    const exp = new Date();
+    exp.setTime(exp.getTime() - 1);
+    document.cookie = `${name}=;expires=${exp.toGMTString()}`;
+  },
+};
+
+/* Fullscreen */
+export const screen = (element) => {
+  const isFull = !!(
+    document.webkitIsFullScreen ||
+    document.mozFullScreen ||
+    document.msFullscreenElement ||
+    document.fullscreenElement
+  );
+  if (isFull) {
+    if (document.exitFullscreen) {
+      document.exitFullscreen();
+    } else if (document.msExitFullscreen) {
+      document.msExitFullscreen();
+    } else if (document.mozCancelFullScreen) {
+      document.mozCancelFullScreen();
+    } else if (document.webkitExitFullscreen) {
+      document.webkitExitFullscreen();
+    }
+  } else if (element.requestFullscreen) {
+    element.requestFullscreen();
+  } else if (element.msRequestFullscreen) {
+    element.msRequestFullscreen();
+  } else if (element.mozRequestFullScreen) {
+    element.mozRequestFullScreen();
+  } else if (element.webkitRequestFullscreen) {
+    element.webkitRequestFullscreen();
+  }
+};
+
+/* 复制对象 */
+export const objCopy = (obj) => {
+  if (obj === undefined) {
+    return undefined;
+  }
+  return JSON.parse(JSON.stringify(obj));
+};
+
+export const generateId = function () {
+  return Math.floor(
+    Math.random() * 100000 + Math.random() * 20000 + Math.random() * 5000
+  );
+};
+
+/* 日期格式化 */
+export const dateFormat = (
+  date,
+  fmt = 'yyyy-MM-dd hh:mm:ss',
+  isDefault = '-'
+) => {
+  if (date.toString().length === 10) {
+    date *= 1000;
+  }
+  date = new Date(date);
+
+  if (date.valueOf() < 1) {
+    return isDefault;
+  }
+  const o = {
+    'M+': date.getMonth() + 1, // 月份
+    'd+': date.getDate(), // 日
+    'h+': date.getHours(), // 小时
+    'm+': date.getMinutes(), // 分
+    's+': date.getSeconds(), // 秒
+    'q+': Math.floor((date.getMonth() + 3) / 3), // 季度
+    'S': date.getMilliseconds(), // 毫秒
+  };
+  if (/(y+)/.test(fmt)) {
+    fmt = fmt.replace(
+      RegExp.$1,
+      `${date.getFullYear()}`.substr(4 - RegExp.$1.length)
+    );
+  }
+  for (const k in o) {
+    if (new RegExp(`(${k})`).test(fmt)) {
+      fmt = fmt.replace(
+        RegExp.$1,
+        RegExp.$1.length === 1 ? o[k] : `00${o[k]}`.substr(`${o[k]}`.length)
+      );
+    }
+  }
+  return fmt;
+};
+
+/* 千分符 */
+export const groupSeparator = (num) => {
+  num += '';
+  if (!num.includes('.')) {
+    num += '.';
+  }
+  return num
+    .replace(/(\d)(?=(\d{3})+\.)/g, function ($0, $1) {
+      return `${$1},`;
+    })
+    .replace(/\.$/, '');
+};
+
+export const capsule = (title, info, type = 'primary') => {
+  console.log(
+    `%c ${title} %c ${info} %c`,
+    'background:#35495E; padding: 1px; border-radius: 3px 0 0 3px; color: #fff;',
+    `background:${typeColor(
+      type
+    )}; padding: 1px; border-radius: 0 3px 3px 0;  color: #fff;`,
+    'background:transparent'
+  );
+};
+
+export const download = (res, downName = '') => {
+  const aLink = document.createElement('a');
+  let fileName = downName;
+  let blob = res; // 第三方请求返回blob对象
+
+  // 通过后端接口返回
+  if (res.headers && res.data) {
+    blob = new Blob([res.data], {
+      type: res.headers['content-type'].replace(';charset=utf8', ''),
+    });
+    if (!downName) {
+      fileName = extractFileName(res.headers?.['content-disposition']);
+    }
+  }
+
+  aLink.href = URL.createObjectURL(blob);
+  // 设置下载文件名称
+  aLink.setAttribute('download', fileName);
+  document.body.appendChild(aLink);
+  aLink.click();
+  document.body.removeChild(aLink);
+  URL.revokeObjectURL(aLink.href);
+};
+
+/**
+ * 对象转url参数
+ * @param {*} data
+ * @param {*} isPrefix
+ */
+export const httpBuild = (data, isPrefix = false) => {
+  const prefix = isPrefix ? '?' : '';
+  const _result = [];
+  for (const key in data) {
+    const value = data[key];
+    // 去掉为空的参数
+    if (['', undefined, null].includes(value)) {
+      continue;
+    }
+    if (value.constructor === Array) {
+      value.forEach((_value) => {
+        _result.push(
+          `${encodeURIComponent(key)}[]=${encodeURIComponent(_value)}`
+        );
+      });
+    } else {
+      _result.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
+    }
+  }
+
+  return _result.length ? prefix + _result.join('&') : '';
+};
+
+export const getRequestParams = (url) => {
+  const theRequest = new Object();
+  if (url.indexOf('?') != -1) {
+    const params = url.split('?')[1].split('&');
+    for (let i = 0; i < params.length; i++) {
+      const param = params[i].split('=');
+      theRequest[param[0]] = decodeURIComponent(param[1]);
+    }
+  }
+  return theRequest;
+};
+
+export const getTreeList = (oldDataList, sortField = '') => {
+  if (!Array.isArray(oldDataList)) {
+    throw new TypeError(`${oldDataList}不是数组`);
+  }
+  const dataList = cloneDeep(oldDataList);
+  const formatObj = dataList.reduce((pre, cur) => {
+    return { ...pre, [cur.id]: cur };
+  }, {});
+  const sortArray = sortField
+    ? dataList.sort((a, b) => a.sort - b.sort)
+    : dataList;
+  const formatArray = sortArray.reduce((arr, cur) => {
+    const pid = cur.parentId ? cur.parentId : 0;
+    const parent = formatObj[pid];
+    if (parent) {
+      parent.children ? parent.children.push(cur) : (parent.children = [cur]);
+    } else {
+      arr.push(cur);
+    }
+    return arr;
+  }, []);
+  return formatArray;
+};

+ 51 - 0
src/views/examManage/addExamDialog.vue

@@ -0,0 +1,51 @@
+<template>
+  <my-dialog
+    :visible="visible"
+    @close="emit('update:visible', false)"
+    :header="title"
+    :width="700"
+    :closeOnOverlayClick="false"
+  >
+    <t-form ref="formRef" :model="formData" labelWidth="160px">
+      <t-form-item label="考试名称">
+        <t-input v-model="formData.a"></t-input>
+      </t-form-item>
+      <t-form-item label="类型">
+        <t-input v-model="formData.b"></t-input>
+      </t-form-item>
+      <t-form-item label="考试日期">
+        <t-date-picker
+          mode="date"
+          v-model="formData.c"
+          clearable
+          :presets="presets"
+        />
+      </t-form-item>
+    </t-form>
+    <template #foot>
+      <t-button theme="default" @click="emit('update:visible', false)"
+        >取消</t-button
+      >
+      <t-button theme="primary" @click="handleSave">保存</t-button>
+    </template>
+  </my-dialog>
+</template>
+<script setup name="AddExamDialog">
+import { ref } from 'vue';
+import dayjs from 'dayjs';
+const emit = defineEmits(['update:visible']);
+const formRef = ref(null);
+const presets = ref({
+  今天: dayjs(),
+});
+const props = defineProps({
+  visible: Boolean,
+  title: String,
+  type: String,
+  handleSave: Function,
+  formData: Object,
+});
+defineExpose({
+  formRef,
+});
+</script>

+ 141 - 0
src/views/examManage/index.vue

@@ -0,0 +1,141 @@
+<template>
+  <div class="exam-manage flex flex-col">
+    <SearchForm
+      :columns="searchColumns"
+      :searchParam="searchParam"
+    ></SearchForm>
+    <div class="flex-1 page-wrap">
+      <t-table
+        size="small"
+        row-key="id"
+        :columns="columns"
+        :data="tableData"
+        bordered
+      >
+      </t-table>
+    </div>
+    <AddExamDialog
+      v-model:visible="showAddExamDialog"
+      :title="title"
+      :type="type"
+      :handleSave="handleSave"
+      :formData="formData"
+      ref="formDialogRef"
+    ></AddExamDialog>
+  </div>
+</template>
+<script setup lang="jsx" name="ExamManage">
+import { ref, reactive } from 'vue';
+import useTableCrud from '@/hooks/useTableCrud';
+import AddExamDialog from './addExamDialog.vue';
+const formDialogRef = ref();
+const tableData = ref([
+  { a: '222', b: '222', c: '222', d: '222', e: '222', f: '222' },
+]);
+const add = async () => {
+  await 1;
+  alert(1);
+};
+const update = async () => {};
+const refresh = async () => {};
+
+const {
+  visible: showAddExamDialog,
+  type,
+  title,
+  loading,
+  handleAdd,
+  handleDelete,
+  handleEdit,
+  handleSave,
+  formData,
+  formRef,
+} = useTableCrud(
+  {
+    name: '用户',
+    doCreate: add,
+    doUpdate: update,
+    refresh: refresh,
+  },
+  formDialogRef
+);
+const columns = [
+  { colKey: 'a', title: 'ID' },
+  { colKey: 'b', title: '名称' },
+  { colKey: 'c', title: '类型' },
+  { colKey: 'd', title: '考试时间' },
+  { colKey: 'e', title: '强制标记' },
+  { colKey: 'f', title: '状态' },
+  {
+    title: '操作',
+    colKey: 'operate',
+    width: 150,
+    cell: (h, { row }) => {
+      return (
+        <div class="table-operations">
+          <t-link theme="primary" hover="color">
+            详情
+          </t-link>
+          <t-link
+            theme="primary"
+            class="m-l-15px"
+            hover="color"
+            onClick={(e) => {
+              e.stopPropagation();
+              handleEdit(row);
+            }}
+          >
+            编辑
+          </t-link>
+        </div>
+      );
+    },
+  },
+];
+const searchColumns = ref([
+  {
+    prop: 'a',
+    label: '名称',
+    labelWidth: '60px',
+    colSpan: 4,
+  },
+  {
+    prop: 'b',
+    label: '状态',
+    labelWidth: '60px',
+    type: 'select',
+    colSpan: 4,
+  },
+  {
+    prop: 'c',
+    label: '类型',
+    labelWidth: '60px',
+    type: 'select',
+    colSpan: 4,
+  },
+  {
+    type: 'button',
+    text: '查询',
+    attrs: {
+      class: 'm-l-10px',
+    },
+  },
+  {
+    type: 'button',
+    text: '创建考试',
+    position: 'right',
+    attrs: {
+      theme: 'success',
+    },
+    onClick: () => {
+      handleAdd();
+    },
+  },
+]);
+const searchParam = reactive({
+  a: '',
+  b: '',
+  c: '',
+});
+</script>
+<style lang="less" scoped></style>

+ 98 - 0
src/views/login/examSelect/index.vue

@@ -0,0 +1,98 @@
+<template>
+  <div class="exam-select">
+    <h1>请选择考试批次</h1>
+
+    <t-form
+      ref="form"
+      :data="formData"
+      :label-width="0"
+      class="form"
+      :rules="rules"
+    >
+      <t-form-item name="examId">
+        <t-select
+          v-model="formData.examId"
+          placeholder="请选择考试"
+          :options="options"
+          filterable
+          :onChange="examChange"
+        />
+      </t-form-item>
+    </t-form>
+    <div class="btns">
+      <a class="confirm" @click="confirm">确 定</a>
+      <a class="cancel" @click="cancel">退 出</a>
+    </div>
+  </div>
+</template>
+
+<script setup name="Login">
+import { ref, reactive } from 'vue';
+import { useRouter } from 'vue-router';
+const form = ref(null);
+const router = useRouter();
+const { replace } = router;
+const formData = reactive({
+  examId: '',
+});
+const examChange = () => {};
+const rules = {
+  examId: [
+    { required: true, message: '请选择考试', type: 'error', trigger: 'change' },
+  ],
+};
+const options = ref([]);
+const confirm = () => {
+  form.value.validate().then(async (result) => {
+    if (result === true) {
+    }
+    setTimeout(() => {
+      router.push('/');
+    }, 100);
+  });
+};
+const cancel = () => {
+  replace('/login');
+};
+</script>
+<style lang="less" scoped>
+.exam-select {
+  position: absolute;
+  left: 0;
+  top: 0;
+  width: 100%;
+  height: 100%;
+  padding: 95px 130px;
+  z-index: 10;
+  .btns {
+    margin-top: 40px;
+  }
+  .btns a {
+    display: inline-block;
+    width: 160px;
+    height: 54px;
+    text-align: center;
+    line-height: 54px;
+    border-radius: 26px;
+
+    font-size: 20px;
+    color: #fff;
+    font-weight: 700;
+    cursor: pointer;
+    &.confirm {
+      background-image: linear-gradient(to right, #88d3fc, #66ade8);
+    }
+    &.cancel {
+      background: #c7cacc;
+      margin-left: 5px;
+    }
+  }
+  .form {
+    margin-top: 40px;
+  }
+  & > h1 {
+    font-size: 23px;
+    text-align: center;
+  }
+}
+</style>

+ 186 - 0
src/views/login/index.vue

@@ -0,0 +1,186 @@
+<template>
+  <div class="login flex justify-center items-center h-full">
+    <div class="login-box">
+      <div class="left">
+        <div class="app-info">
+          <a href="http://www.qmth.com.cn" target="_blank"
+            >Copyright © 2021 启明泰和 v1.3.12 beta20230601</a
+          >
+          <br />
+          <a href="https://beian.miit.gov.cn/" target="_blank"
+            >鄂ICP备12000033号-3</a
+          >
+        </div>
+      </div>
+      <div class="right">
+        <router-view></router-view>
+        <template v-if="isLoginPage">
+          <h1>高校考试管理平台</h1>
+
+          <div class="tab-box">
+            <t-tabs v-model="loginMode" class="tabs">
+              <t-tab-panel value="admin" label="管理员登录"> </t-tab-panel>
+              <t-tab-panel value="mark" label="评卷员登录"> </t-tab-panel>
+            </t-tabs>
+          </div>
+          <t-form
+            ref="form"
+            :data="formData"
+            :label-width="0"
+            class="login-form"
+            :rules="rules"
+          >
+            <t-form-item name="a">
+              <t-input
+                v-model="formData.a"
+                clearable
+                placeholder="账号"
+                size="large"
+              >
+                <template #prefix-icon>
+                  <desktop-icon />
+                </template>
+              </t-input>
+            </t-form-item>
+
+            <t-form-item name="b">
+              <t-input
+                v-model="formData.b"
+                type="password"
+                clearable
+                placeholder="密码"
+                size="large"
+              >
+                <template #prefix-icon>
+                  <lock-on-icon />
+                </template>
+              </t-input>
+            </t-form-item>
+
+            <a class="submit-btn" @click="loginHandle">登 录</a>
+          </t-form>
+        </template>
+      </div>
+    </div>
+  </div>
+  <!-- <button @click="loginHandle" class="m-t-10px">登录!</button> -->
+</template>
+
+<script setup name="Login">
+import { ref, reactive, computed } from 'vue';
+import { DesktopIcon, LockOnIcon } from 'tdesign-icons-vue-next';
+import { useRoute, useRouter } from 'vue-router';
+import { useUserStore } from '@/store';
+import { MessagePlugin } from 'tdesign-vue-next';
+
+const form = ref(null);
+
+const route = useRoute();
+const router = useRouter();
+const isLoginPage = computed(() => {
+  return route.name === 'Login';
+});
+const loginMode = ref('admin');
+const toggleMode = (m) => {
+  loginMode.value = m;
+};
+
+const formData = reactive({
+  a: '',
+  b: '',
+});
+const rules = {
+  a: [
+    { required: true, message: '请输入账号', type: 'error', trigger: 'change' },
+  ],
+  b: [
+    { required: true, message: '请输入密码', type: 'error', trigger: 'change' },
+  ],
+};
+
+const userStore = useUserStore();
+// const loginHandler = async () => {
+//   try {
+//     await userStore.login({});
+//     router.push(redirect);
+//   } catch (e) {
+//     console.log(e);
+//   }
+// };
+const loginHandle = () => {
+  form.value.validate().then(async (result) => {
+    const redirect = route.query.redirect
+      ? route.query.redirect
+      : loginMode.value === 'admin'
+      ? '/login/examSelect'
+      : '/login/subjectSelect';
+    if (result === true) {
+      await userStore.login({});
+      router.push(redirect);
+    }
+  });
+};
+</script>
+<style lang="less" scoped>
+.login {
+  .login-box {
+    width: 920px;
+    height: 520px;
+    background: rgba(255, 255, 255, 0.75);
+    box-shadow: 0px 15px 15px 0px rgba(203, 205, 211, 0.3);
+    .left {
+      width: 320px;
+      float: left;
+      height: 100%;
+      position: relative;
+      background: #142862 url(../../assets/imgs/loginbg.png) no-repeat 0 0;
+      .app-info {
+        position: absolute;
+        left: 0;
+        bottom: 40px;
+        padding: 0 38px;
+        a {
+          color: rgba(255, 255, 255, 0.65);
+          line-height: 1.5;
+        }
+      }
+    }
+    .right {
+      padding: 95px 130px;
+      margin-left: 320px;
+      height: 100%;
+      position: relative;
+      .submit-btn {
+        display: block;
+        width: 160px;
+        height: 54px;
+        text-align: center;
+        line-height: 54px;
+        border-radius: 26px;
+        background-image: linear-gradient(to right, #88d3fc, #66ade8);
+        margin: 40px auto 0 auto;
+        font-size: 20px;
+        color: #fff;
+        font-weight: 700;
+        cursor: pointer;
+      }
+      .login-form {
+        margin-top: 30px;
+      }
+      & > h1 {
+        font-size: 23px;
+        text-align: center;
+      }
+      .tab-box {
+        margin-top: 30px;
+        width: 204px;
+        margin-left: auto;
+        margin-right: auto;
+        .t-tabs {
+          background-color: transparent !important;
+        }
+      }
+    }
+  }
+}
+</style>

+ 111 - 0
src/views/login/subjectSelect/index.vue

@@ -0,0 +1,111 @@
+<template>
+  <div class="subject-select">
+    <h1>请选择评卷科目</h1>
+
+    <t-form
+      ref="form"
+      :data="formData"
+      :label-width="0"
+      class="form"
+      :rules="rules"
+    >
+      <t-form-item name="examId">
+        <t-select
+          v-model="formData.selectId"
+          placeholder="请选择考试"
+          :options="options1"
+          filterable
+          :onChange="examChange"
+        />
+      </t-form-item>
+      <t-form-item name="subjectId">
+        <t-select
+          v-model="formData.subjectId"
+          placeholder="请选择考试"
+          :options="options2"
+          filterable
+          :onChange="subjectChange"
+        />
+      </t-form-item>
+      <t-form-item name="groupId">
+        <t-select
+          v-model="formData.groupId"
+          placeholder="请选择分组"
+          :options="options3"
+          filterable
+        />
+      </t-form-item>
+    </t-form>
+    <div class="btns">
+      <a class="confirm" @click="confirm">确 定</a>
+      <a class="cancel" @click="cancel">退 出</a>
+    </div>
+  </div>
+</template>
+
+<script setup name="Login">
+import { ref, reactive } from 'vue';
+import { useRouter } from 'vue-router';
+
+const { replace } = useRouter();
+const formData = reactive({
+  examId: '',
+  subjectId: '',
+  groupId: '',
+});
+const examChange = () => {};
+const subjectChange = () => {};
+const rules = {
+  examId: [
+    { required: true, message: '请选择考试', type: 'error', trigger: 'change' },
+  ],
+};
+const options1 = ref([]);
+const options2 = ref([]);
+const options3 = ref([]);
+const confirm = () => {};
+const cancel = () => {
+  replace('/login');
+};
+</script>
+<style lang="less" scoped>
+.subject-select {
+  position: absolute;
+  left: 0;
+  top: 0;
+  width: 100%;
+  height: 100%;
+  padding: 95px 130px;
+  z-index: 10;
+  .btns {
+    margin-top: 40px;
+  }
+  .btns a {
+    display: inline-block;
+    width: 160px;
+    height: 54px;
+    text-align: center;
+    line-height: 54px;
+    border-radius: 26px;
+
+    font-size: 20px;
+    color: #fff;
+    font-weight: 700;
+    cursor: pointer;
+    &.confirm {
+      background-image: linear-gradient(to right, #88d3fc, #66ade8);
+    }
+    &.cancel {
+      background: #c7cacc;
+      margin-left: 5px;
+    }
+  }
+  .form {
+    margin-top: 40px;
+  }
+  & > h1 {
+    font-size: 23px;
+    text-align: center;
+  }
+}
+</style>

+ 54 - 0
src/views/userManage/add-user-dialog.vue

@@ -0,0 +1,54 @@
+<template>
+  <my-dialog
+    :visible="visible"
+    @close="emit('update:visible', false)"
+    :header="title"
+    :width="450"
+    :closeOnOverlayClick="false"
+  >
+    <t-form ref="formRef" :model="formData" labelWidth="80px">
+      <t-form-item label="登录名">
+        <t-input v-model="formData.a"></t-input>
+      </t-form-item>
+      <t-form-item label="名称">
+        <t-input v-model="formData.b"></t-input>
+      </t-form-item>
+      <t-form-item label="工号">
+        <t-input v-model="formData.c"></t-input>
+      </t-form-item>
+      <t-form-item label="密码">
+        <t-input v-model="formData.d" type="password"></t-input>
+      </t-form-item>
+      <t-form-item label="状态">
+        <t-select v-model="formData.e">
+          <t-option>启用</t-option>
+          <t-option>禁用</t-option>
+        </t-select>
+      </t-form-item>
+      <t-form-item label="角色">
+        <t-select v-model="formData.f"> </t-select>
+      </t-form-item>
+    </t-form>
+    <template #foot>
+      <t-button theme="default" @click="emit('update:visible', false)"
+        >取消</t-button
+      >
+      <t-button theme="primary" @click="handleSave">保存</t-button>
+    </template>
+  </my-dialog>
+</template>
+<script setup name="AddUserDialog">
+import { ref } from 'vue';
+const emit = defineEmits(['update:visible']);
+const formRef = ref(null);
+const props = defineProps({
+  visible: Boolean,
+  title: String,
+  type: String,
+  handleSave: Function,
+  formData: Object,
+});
+defineExpose({
+  formRef,
+});
+</script>

+ 55 - 0
src/views/userManage/import-file-dialog.vue

@@ -0,0 +1,55 @@
+<template>
+  <my-dialog
+    :visible="visible"
+    @close="emit('update:visible', false)"
+    :header="`导入${title}`"
+    :width="450"
+    :closeOnOverlayClick="false"
+  >
+    <t-upload
+      ref="uploadRef"
+      v-model="files"
+      :request-method="requestMethod"
+      placeholder="导入文件不能超过5M,仅允许导入“xls”或“xlsx”格式文件!"
+      :size-limit="{ size: 5, unit: 'MB' }"
+      :on-fail="handleRequestFail"
+      accept=".xls,.xlsx"
+    ></t-upload>
+    <template #foot>
+      <t-button>导入</t-button>
+      <t-link theme="primary" class="m-l-5px">下载模板</t-link>
+    </template>
+  </my-dialog>
+</template>
+<script setup name="UserImportFile">
+import { ref } from 'vue';
+import { MessagePlugin } from 'tdesign-vue-next';
+const emit = defineEmits(['update:visible']);
+const props = defineProps({
+  visible: Boolean,
+  title: String,
+  importType: String,
+});
+
+const files = ref([]);
+const requestMethod = (file) => {
+  return new Promise((resolve) => {
+    // file.percent 用于控制上传进度,如果不希望显示上传进度,则不对 file.percent 设置值即可。
+    // 如果代码规范不能设置 file.percent,也可以设置 files
+    file.percent = 0;
+    const timer = setTimeout(() => {
+      // resolve 参数为关键代码
+      resolve({
+        status: 'success',
+        response: { url: 'https://tdesign.gtimg.com/site/avatar.jpg' },
+      });
+      file.percent = 100;
+      clearTimeout(timer);
+    }, 10000);
+  });
+};
+const handleRequestFail = (e) => {
+  MessagePlugin.error('上传失败');
+};
+</script>
+<style lang="less" scoped></style>

+ 288 - 0
src/views/userManage/index.vue

@@ -0,0 +1,288 @@
+<template>
+  <div class="user-manage flex flex-col">
+    <SearchForm
+      :columns="searchColumns"
+      :searchParam="searchParam"
+    ></SearchForm>
+
+    <div class="flex-1 page-wrap">
+      <t-table
+        bordered
+        size="small"
+        row-key="id"
+        :columns="columns"
+        :data="tableData"
+        :selected-row-keys="selectedRowKeys"
+        select-on-row-click
+        @select-change="rehandleSelectChange"
+      >
+      </t-table>
+    </div>
+
+    <AddUserDialog
+      v-model:visible="showAddUserDialog"
+      :title="title"
+      :type="type"
+      :handleSave="handleSave"
+      :formData="formData"
+      ref="formDialogRef"
+    ></AddUserDialog>
+
+    <ImportFileDialog
+      v-model:visible="showImportFileDialog"
+      :importType="importType"
+      :title="importTitle"
+    >
+    </ImportFileDialog>
+    <MultAddUserDialog
+      v-model:visible="showMultAddUserDialog"
+    ></MultAddUserDialog>
+  </div>
+</template>
+<script lang="jsx" setup name="UserManage">
+import useTableCrud from '@/hooks/useTableCrud';
+import { ref, reactive } from 'vue';
+import AddUserDialog from './add-user-dialog.vue';
+import ImportFileDialog from './import-file-dialog.vue';
+import MultAddUserDialog from './mult-add-dialog.vue';
+
+const formDialogRef = ref(null);
+const showMultAddUserDialog = ref(false);
+const showImportFileDialog = ref(false);
+const importType = ref('');
+const importTitle = ref('');
+
+const selectedRowKeys = ref([]);
+const rehandleSelectChange = (value, { selectedRowData }) => {
+  selectedRowKeys.value = value;
+};
+const columns = [
+  {
+    colKey: 'row-select',
+    type: 'multiple',
+    width: 50,
+  },
+  { colKey: 'a', title: '登录名' },
+  { colKey: 'b', title: '名称' },
+  { colKey: 'c', title: '来源' },
+  { colKey: 'd', title: '角色' },
+  { colKey: 'e', title: '状态' },
+  { colKey: 'f', title: '关联账号' },
+  {
+    title: '操作',
+    colKey: 'operate',
+    width: 150,
+    cell: (h, { row }) => {
+      return (
+        <div class="table-operations">
+          <t-link
+            theme="primary"
+            hover="color"
+            data-id={row.id}
+            onClick={(e) => {
+              e.stopPropagation();
+              handleEdit(row);
+            }}
+          >
+            编辑
+          </t-link>
+        </div>
+      );
+    },
+  },
+];
+const tableData = [
+  { id: 'a', a: '111', b: '111', c: '111', d: '111', e: '111', f: '111' },
+  { id: 'b', a: '111', b: '111', c: '111', d: '111', e: '111', f: '111' },
+  { id: 'c', a: '111', b: '111', c: '111', d: '111', e: '111', f: '111' },
+  { id: 'd', a: '111', b: '111', c: '111', d: '111', e: '111', f: '111' },
+  { id: 'e', a: '111', b: '111', c: '111', d: '111', e: '111', f: '111' },
+  { id: 'f', a: '111', b: '111', c: '111', d: '111', e: '111', f: '111' },
+  { id: 'g', a: '111', b: '111', c: '111', d: '111', e: '111', f: '111' },
+];
+const add = async () => {
+  await 1;
+  alert(1);
+};
+const del = async () => {};
+const update = async () => {};
+const refresh = async () => {};
+
+const {
+  visible: showAddUserDialog,
+  type,
+  title,
+  loading,
+  handleAdd,
+  handleDelete,
+  handleEdit,
+  handleSave,
+  formData,
+  formRef,
+} = useTableCrud(
+  {
+    name: '用户',
+    doCreate: add,
+    doDelete: del,
+    doUpdate: update,
+    refresh: refresh,
+  },
+  formDialogRef
+);
+const searchColumns = ref([
+  {
+    prop: 'a',
+    label: '登录名',
+    labelWidth: '60px',
+    colSpan: 3.6,
+  },
+  {
+    prop: 'b',
+    label: '姓名',
+    labelWidth: '60px',
+    colSpan: 3.6,
+  },
+  {
+    prop: 'c',
+    label: '来源',
+    type: 'select',
+    labelWidth: '60px',
+    colSpan: 3.6,
+  },
+  {
+    prop: 'd',
+    label: '角色',
+    type: 'select',
+    labelWidth: '60px',
+    colSpan: 3.6,
+  },
+  {
+    prop: 'e',
+    label: '状态',
+    type: 'select',
+    labelWidth: '60px',
+    colSpan: 3.6,
+  },
+  {
+    type: 'buttons',
+    colSpan: 6,
+    children: [
+      {
+        type: 'button',
+        text: '查询',
+        attrs: {
+          theme: 'primary',
+        },
+        onClick: () => {
+          alert(1);
+        },
+      },
+      {
+        type: 'button',
+        text: '新建',
+        attrs: {
+          theme: 'default',
+          // variant: 'outline',
+        },
+        onClick: handleAdd,
+      },
+      {
+        type: 'dropdown',
+        text: '导入',
+        attrs: {
+          options: [
+            { content: '科组长', value: 1 },
+            { content: '复核员', value: 2 },
+          ],
+        },
+        buttonAttrs: {
+          theme: 'default',
+        },
+        onClick: (data) => {
+          showImportFileDialog.value = true;
+          importTitle.value = data.content;
+        },
+      },
+    ],
+  },
+  {
+    type: 'buttons',
+    colSpan: 24,
+    children: [
+      {
+        type: 'button',
+        text: '导入评卷员班级',
+        attrs: {
+          theme: 'default',
+        },
+        onClick: () => {
+          alert(1);
+        },
+      },
+      {
+        type: 'dropdown',
+        text: '导出',
+        attrs: {
+          options: [
+            { content: '导出', value: 1 },
+            { content: '按考试导出', value: 2 },
+          ],
+        },
+        buttonAttrs: {
+          theme: 'default',
+        },
+        onClick: (data) => {
+          alert(data.content);
+        },
+      },
+      {
+        type: 'button',
+        text: '批量创建',
+        attrs: {
+          theme: 'default',
+        },
+        onClick: () => {
+          alert(1);
+        },
+      },
+      {
+        type: 'button',
+        text: '启用',
+        attrs: {
+          theme: 'default',
+        },
+        onClick: () => {
+          alert(1);
+        },
+      },
+      {
+        type: 'button',
+        text: '禁用',
+        attrs: {
+          theme: 'default',
+        },
+        onClick: () => {
+          alert(1);
+        },
+      },
+      {
+        type: 'button',
+        text: '重置密码',
+        attrs: {
+          theme: 'default',
+        },
+        onClick: () => {
+          alert(1);
+        },
+      },
+    ],
+  },
+]);
+const searchParam = reactive({
+  a: '',
+  b: '',
+  c: '',
+  d: '',
+  e: '',
+});
+</script>

+ 120 - 0
src/views/userManage/mult-add-dialog.vue

@@ -0,0 +1,120 @@
+<template>
+  <my-dialog
+    :visible="visible"
+    @close="emit('update:visible', false)"
+    header="批量创建账号"
+    :width="550"
+    :closeOnOverlayClick="false"
+  >
+    <t-form ref="form" :model="formData" labelWidth="130px">
+      <t-form-item label="考试名称:"> XXXXXXX考试!</t-form-item>
+      <t-form-item label="角色:">
+        <t-select v-model="formData.a"></t-select>
+      </t-form-item>
+      <t-form-item label="命名规则:">机构ID+科目代码+流水号</t-form-item>
+      <t-form-item label="每分组账号数:">
+        <t-input-number
+          v-model="formData.b"
+          theme="column"
+          :decimalPlaces="0"
+          align="center"
+          :max="100"
+          :min="1"
+          style="width: 150px"
+          :status="numStatus"
+          :tips="numTips"
+        ></t-input-number>
+      </t-form-item>
+      <t-form-item label="随机密码:">
+        <t-checkbox v-model="formData.c"> </t-checkbox>
+      </t-form-item>
+      <t-form-item label="密码:">
+        <t-input v-model="formData.d"></t-input>
+      </t-form-item>
+      <t-form-item label="选择科目:">
+        <t-link theme="primary" underline @click="chooseSubject">设置</t-link>
+      </t-form-item>
+    </t-form>
+  </my-dialog>
+  <my-dialog
+    v-model:visible="showSubjectDialog"
+    :width="700"
+    header="选择科目"
+    :on-confirm="onConfirm"
+  >
+    <t-table
+      bordered
+      size="small"
+      row-key="id"
+      :columns="columns"
+      :data="tableData"
+      :selected-row-keys="selectedRowKeys"
+      select-on-row-click
+      @select-change="rehandleSelectChange"
+    >
+    </t-table>
+  </my-dialog>
+</template>
+
+<script setup name="MultAddUserDialog">
+import { reactive, computed, ref } from 'vue';
+const emit = defineEmits(['update:visible']);
+const props = defineProps({
+  visible: Boolean,
+});
+const showSubjectDialog = ref(false);
+
+const chooseSubject = () => {
+  showSubjectDialog.value = true;
+};
+const numStatus = computed(() => {
+  if (
+    (formData.b > 0 && formData.b <= 100) ||
+    formData.b === '' ||
+    formData.b === undefined
+  ) {
+    return '';
+  } else {
+    return 'error';
+  }
+});
+const numTips = computed(() => {
+  if (
+    (formData.b > 0 && formData.b <= 100) ||
+    formData.b === '' ||
+    formData.b === undefined
+  ) {
+    return '';
+  } else {
+    return '只能填1~100的整数';
+  }
+});
+const formData = reactive({
+  a: '',
+  b: '',
+  c: true,
+  d: '',
+});
+
+const tableData = ref([{ a: '哈哈哈', b: 0, c: 1, d: 2, e: '正常' }]);
+const selectedRowKeys = ref([]);
+const rehandleSelectChange = (value, { selectedRowData }) => {
+  selectedRowKeys.value = value;
+};
+const columns = [
+  {
+    colKey: 'row-select',
+    type: 'multiple',
+    width: 50,
+  },
+  { colKey: 'a', title: '科目名称' },
+  { colKey: 'b', title: '客观总分' },
+  { colKey: 'c', title: '主观总分' },
+  { colKey: 'd', title: '试卷总分' },
+  { colKey: 'e', title: '状态' },
+];
+const onConfirm = () => {
+  alert(1);
+  showSubjectDialog.value = false;
+};
+</script>

+ 78 - 0
vite.config.js

@@ -0,0 +1,78 @@
+import { defineConfig, loadEnv } from 'vite';
+import {
+  getRootPath,
+  getSrcPath,
+  viteDefine,
+  setupVitePlugins,
+  createViteProxy,
+} from './build';
+import { getEnvConfig } from './.env-config.js';
+// yarn add --dev @esbuild-plugins/node-globals-polyfill
+import { NodeGlobalsPolyfillPlugin } from '@esbuild-plugins/node-globals-polyfill';
+// yarn add --dev @esbuild-plugins/node-modules-polyfill
+import { NodeModulesPolyfillPlugin } from '@esbuild-plugins/node-modules-polyfill';
+// You don't need to add this to deps, it's included by @esbuild-plugins/node-modules-polyfill
+import rollupNodePolyFill from 'rollup-plugin-node-polyfills';
+
+export default defineConfig((configEnv) => {
+  const viteEnv = loadEnv(configEnv.mode, process.cwd());
+  console.log('viteEnv:', viteEnv);
+
+  const processEnvValues = {
+    'process.env': Object.entries(viteEnv).reduce((prev, [key, val]) => {
+      return {
+        ...prev,
+        [key]: val,
+      };
+    }, {}),
+  };
+
+  const rootPath = getRootPath();
+  const srcPath = getSrcPath();
+
+  const isOpenProxy = viteEnv.VITE_HTTP_PROXY === 'Y';
+  const envConfig = getEnvConfig(viteEnv);
+
+  return {
+    base: viteEnv.VITE_BASE_URL,
+    resolve: {
+      alias: {
+        '~': rootPath,
+        '@': srcPath,
+        'qs': 'rollup-plugin-node-polyfills/polyfills/qs',
+      },
+      extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue']
+    },
+
+    define: { ...viteDefine, ...processEnvValues },
+    plugins: [
+      ...setupVitePlugins(viteEnv),
+      NodeModulesPolyfillPlugin(),
+      NodeGlobalsPolyfillPlugin({
+        process: true,
+        buffer: true,
+      }),
+    ],
+    server: {
+      host: '0.0.0.0',
+      port: 8888,
+      open: true,
+      proxy: createViteProxy(isOpenProxy, envConfig),
+    },
+    preview: {
+      port: 5050,
+    },
+    build: {
+      reportCompressedSize: false,
+      sourcemap: false,
+      commonjsOptions: {
+        ignoreTryCatch: false,
+      },
+      // target: ['chrome52'],
+      // cssTarget: ['chrome52'],
+      // rollupOptions: {
+      //   plugins: [rollupNodePolyFill()],
+      // },
+    },
+  };
+});