sy před 4 měsíci
rodič
revize
8726f8da42
100 změnil soubory, kde provedl 7014 přidání a 0 odebrání
  1. 3 0
      .env.development
  2. 3 0
      .env.production
  3. 7 0
      .eslintignore
  4. 78 0
      .eslintrc.js
  5. 10 0
      .gitignore
  6. 11 0
      .prettierrc.js
  7. 30 0
      .stylelintrc.js
  8. 3 0
      babel.config.js
  9. 3 0
      commitlint.config.js
  10. 15 0
      components.d.ts
  11. 34 0
      config/plugin/compress.ts
  12. 37 0
      config/plugin/imagemin.ts
  13. 91 0
      config/plugin/styleImport.ts
  14. 18 0
      config/plugin/visualizer.ts
  15. 9 0
      config/utils/index.ts
  16. 57 0
      config/vite.config.base.ts
  17. 36 0
      config/vite.config.dev.ts
  18. 31 0
      config/vite.config.prod.ts
  19. 13 0
      index.html
  20. 111 0
      package.json
  21. 28 0
      src/App.vue
  22. 54 0
      src/api/acs/domain.ts
  23. 65 0
      src/api/acs/function.ts
  24. 59 0
      src/api/acs/module.ts
  25. 87 0
      src/api/acs/role.ts
  26. 137 0
      src/api/acs/user.ts
  27. 66 0
      src/api/base/base.ts
  28. 60 0
      src/api/base/org.ts
  29. 64 0
      src/api/base/region.ts
  30. 68 0
      src/api/base/staff.ts
  31. 82 0
      src/api/biz/customer.ts
  32. 162 0
      src/api/client.ts
  33. 59 0
      src/api/dashboard/project.ts
  34. 59 0
      src/api/project/apply.ts
  35. 126 0
      src/api/project/contract.ts
  36. 82 0
      src/api/project/document.ts
  37. 27 0
      src/api/project/flow.ts
  38. 136 0
      src/api/project/info.ts
  39. 98 0
      src/api/project/member.ts
  40. 112 0
      src/api/project/outcome.ts
  41. 208 0
      src/api/project/plan-task.ts
  42. 139 0
      src/api/project/week-report.ts
  43. 126 0
      src/api/project/work.ts
  44. binární
      src/assets/images/bg.png
  45. binární
      src/assets/images/login-banner.png
  46. binární
      src/assets/images/logo.png
  47. 0 0
      src/assets/images/logo1.svg
  48. 12 0
      src/assets/logo.svg
  49. 19 0
      src/assets/style/breakpoint.less
  50. 273 0
      src/assets/style/global.less
  51. 0 0
      src/assets/world.json
  52. 89 0
      src/components/breadcrumb/index.vue
  53. 47 0
      src/components/chart/index.vue
  54. 52 0
      src/components/custom-schema/index.vue
  55. 147 0
      src/components/dynamicForm/Download.vue
  56. 40 0
      src/components/dynamicForm/Object.vue
  57. 27 0
      src/components/dynamicForm/Select.vue
  58. 411 0
      src/components/dynamicForm/Table.vue
  59. 135 0
      src/components/dynamicForm/Upload.vue
  60. 151 0
      src/components/dynamicForm/index.vue
  61. 16 0
      src/components/footer/index.vue
  62. 66 0
      src/components/form/package.json
  63. 50 0
      src/components/form/src/config/utils.js
  64. 42 0
      src/components/form/src/config/widgets/CheckboxesWidget/index.js
  65. 15 0
      src/components/form/src/config/widgets/CheckboxesWidget/readme.md
  66. 29 0
      src/components/form/src/config/widgets/DatePickerWidget/index.js
  67. 30 0
      src/components/form/src/config/widgets/DateTimePickerWidget/index.js
  68. 35 0
      src/components/form/src/config/widgets/RadioWidget/index.js
  69. 53 0
      src/components/form/src/config/widgets/SelectWidget/index.js
  70. 24 0
      src/components/form/src/config/widgets/TimePickerWidget/index.js
  71. 123 0
      src/components/form/src/config/widgets/UploadWidget/index.js
  72. 41 0
      src/components/form/src/config/widgets/WIDGET_MAP.js
  73. 70 0
      src/components/form/src/config/widgets/index.js
  74. 182 0
      src/components/form/src/index.js
  75. 35 0
      src/components/form/src/style.css
  76. 31 0
      src/components/form/types/fieldProps.d.ts
  77. 45 0
      src/components/form/types/formUtils.d.ts
  78. 8 0
      src/components/form/types/getDefaultFormState.d.ts
  79. 16 0
      src/components/form/types/globalOptions.d.ts
  80. 7 0
      src/components/form/types/i18n.d.ts
  81. 24 0
      src/components/form/types/index.d.ts
  82. 9 0
      src/components/form/types/modelValueComponent.d.ts
  83. 22 0
      src/components/form/types/schemaValidate.d.ts
  84. 29 0
      src/components/form/types/vueForm.d.ts
  85. 24 0
      src/components/form/types/vueUtils.d.ts
  86. 79 0
      src/components/global-setting/block.vue
  87. 39 0
      src/components/global-setting/form-wrapper.vue
  88. 77 0
      src/components/global-setting/index.vue
  89. 336 0
      src/components/hover-editor-detail/index.vue
  90. 125 0
      src/components/hover-editor-detail/textarea.vue
  91. 35 0
      src/components/index.ts
  92. 220 0
      src/components/menu/index.vue
  93. 66 0
      src/components/menu/use-menu-tree.ts
  94. 125 0
      src/components/message-box/index.vue
  95. 170 0
      src/components/message-box/list.vue
  96. 127 0
      src/components/modal/index.vue
  97. 227 0
      src/components/navbar/index.vue
  98. 21 0
      src/components/page/index.ts
  99. 62 0
      src/components/page/index.vue
  100. 172 0
      src/components/tab-bar/index.vue

+ 3 - 0
.env.development

@@ -0,0 +1,3 @@
+VITE_API_VER=1.0
+VITE_APP_ID=f2741721-1c07-430e-9f01-5529692340f9
+VITE_API_URL=https://pmr.surkw.com:1443/

+ 3 - 0
.env.production

@@ -0,0 +1,3 @@
+VITE_API_VER=1.0
+VITE_APP_ID=f2741721-1c07-430e-9f01-5529692340f9
+VITE_API_URL=https://pmr.surkw.com:1443/

+ 7 - 0
.eslintignore

@@ -0,0 +1,7 @@
+/*.json
+/*.js
+*/enums/*.ts
+dist
+preload.ts
+main.ts
+

+ 78 - 0
.eslintrc.js

@@ -0,0 +1,78 @@
+// eslint-disable-next-line @typescript-eslint/no-var-requires
+const path = require('path');
+
+module.exports = {
+  root: true,
+  parser: 'vue-eslint-parser',
+  parserOptions: {
+    // Parser that checks the content of the <script> tag
+    parser: '@typescript-eslint/parser',
+    sourceType: 'module',
+    ecmaVersion: 2020,
+    ecmaFeatures: {
+      jsx: true,
+    },
+  },
+  env: {
+    'browser': true,
+    'node': true,
+    'vue/setup-compiler-macros': true,
+  },
+  plugins: ['@typescript-eslint'],
+  extends: [
+    // Airbnb JavaScript Style Guide https://github.com/airbnb/javascript
+    'airbnb-base',
+    'plugin:@typescript-eslint/recommended',
+    'plugin:import/recommended',
+    'plugin:import/typescript',
+    'plugin:vue/vue3-recommended',
+    'plugin:prettier/recommended',
+  ],
+  settings: {
+    'import/resolver': {
+      typescript: {
+        project: path.resolve(__dirname, './tsconfig.json'),
+      },
+    },
+  },
+  rules: {
+    'vue/no-v-html':0,
+    'prettier/prettier': 1,
+    // Vue: Recommended rules to be closed or modify
+    'vue/require-default-prop': 0,
+    'vue/singleline-html-element-content-newline': 0,
+    'vue/max-attributes-per-line': 0,
+    // Vue: Add extra rules
+    'vue/custom-event-name-casing': [2, 'camelCase'],
+    'vue/no-v-text': 1,
+    'vue/padding-line-between-blocks': 1,
+    'vue/require-direct-export': 1,
+    'vue/multi-word-component-names': 0,
+    // Allow @ts-ignore comment
+    '@typescript-eslint/ban-ts-comment': 0,
+    '@typescript-eslint/no-unused-vars': 1,
+    '@typescript-eslint/no-empty-function': 1,
+    '@typescript-eslint/no-explicit-any': 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,
+    "no-use-before-define": 0,
+    "import/prefer-default-export": 0,
+    "class-methods-use-this": 0,
+    "no-unused-expressions": 0,
+    "no-bitwise": 0,
+    "no-console": 0,
+    "@typescript-eslint/no-non-null-assertion": 0
+  },
+};

+ 10 - 0
.gitignore

@@ -0,0 +1,10 @@
+node_modules
+.DS_Store
+dist
+dist-ssr
+*.local
+node_modules
+.DS_Store
+dist
+dist-ssr
+*.local

+ 11 - 0
.prettierrc.js

@@ -0,0 +1,11 @@
+module.exports = {
+  singleQuote: true,
+  useTabs: false,
+  tabWidth: 2,
+  semi: false,
+  arrowParens: 'avoid',
+  bracketSpacing: true,
+  proseWrap: 'preserve',
+  trailingComma: 'none',
+  endOfLine: 'auto'
+}

+ 30 - 0
.stylelintrc.js

@@ -0,0 +1,30 @@
+module.exports = {
+  extends: [
+    'stylelint-config-standard',
+    'stylelint-config-rational-order',
+    'stylelint-config-prettier',
+    'stylelint-config-recommended-vue',
+  ],
+  defaultSeverity: 'warning',
+  plugins: ['stylelint-order'],
+  rules: {
+    'at-rule-no-unknown': [
+      true,
+      {
+        ignoreAtRules: ['plugin'],
+      },
+    ],
+    'rule-empty-line-before': [
+      'always',
+      {
+        except: ['after-single-line-comment', 'first-nested'],
+      },
+    ],
+    'selector-pseudo-class-no-unknown': [
+      true,
+      {
+        ignorePseudoClasses: ['deep'],
+      },
+    ],
+  },
+};

+ 3 - 0
babel.config.js

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

+ 3 - 0
commitlint.config.js

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

+ 15 - 0
components.d.ts

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

+ 34 - 0
config/plugin/compress.ts

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

+ 37 - 0
config/plugin/imagemin.ts

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

+ 91 - 0
config/plugin/styleImport.ts

@@ -0,0 +1,91 @@
+/**
+ * Introduces component library styles on demand.
+ * 按需引入组件库样式
+ * https://github.com/anncwb/vite-plugin-style-import
+ */
+
+import styleImport from 'vite-plugin-style-import'
+
+export default function configStyleImportPlugin() {
+  const styleImportPlugin = styleImport({
+    libs: [
+      {
+        libraryName: '@arco-design/web-vue',
+        esModule: true,
+        resolveStyle: name => {
+          // The use of this part of the component must depend on the parent, so it can be ignored directly.
+          // 这部分组件的使用必须依赖父级,所以直接忽略即可。
+          const ignoreList = [
+            'countdown',
+            'config-provider',
+            'anchor-link',
+            'sub-menu',
+            'menu-item',
+            'menu-item-group',
+            'breadcrumb-item',
+            'form-item',
+            'step',
+            'card-grid',
+            'card-meta',
+            'collapse-panel',
+            'collapse-item',
+            'descriptions-item',
+            'list-item',
+            'list-item-meta',
+            'table-column',
+            'table-column-group',
+            'tab-pane',
+            'tab-content',
+            'timeline-item',
+            'tree-node',
+            'skeleton-line',
+            'skeleton-shape',
+            'grid-item',
+            'carousel-item',
+            'doption',
+            'option',
+            'optgroup',
+            'icon'
+            // 'iconFont'
+          ]
+          // List of components that need to map imported styles
+          // 需要映射引入样式的组件列表
+          const replaceList = {
+            'typography-text': 'typography',
+            'typography-title': 'typography',
+            'typography-paragraph': 'typography',
+            'typography-link': 'typography',
+            'dropdown-button': 'dropdown',
+            'input-password': 'input',
+            'input-textarea': 'input',
+            'input-search': 'input',
+            'input-group': 'input',
+            'radio-group': 'radio',
+            'checkbox-group': 'checkbox',
+            'layout-sider': 'layout',
+            'layout-content': 'layout',
+            'layout-footer': 'layout',
+            'layout-header': 'layout',
+            'month-picker': 'date-picker',
+            'year-picker': 'date-picker',
+            'range-picker': 'date-picker',
+            row: 'grid',
+            col: 'grid',
+            icon: 'icon',
+            'avatar-group': 'avatar',
+            'image-preview': 'image',
+            'image-preview-group': 'image'
+          }
+          if (ignoreList.includes(name)) return ''
+          // eslint-disable-next-line no-prototype-builtins
+          return replaceList.hasOwnProperty(name)
+            ? `@arco-design/web-vue/es/${replaceList[name]}/style/css.js`
+            : `@arco-design/web-vue/es/${name}/style/css.js`
+          // less
+          // return `@arco-design/web-vue/es/${name}/style/index.js`;
+        }
+      }
+    ]
+  })
+  return styleImportPlugin
+}

+ 18 - 0
config/plugin/visualizer.ts

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

+ 9 - 0
config/utils/index.ts

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

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

@@ -0,0 +1,57 @@
+import { resolve } from 'path'
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import vueJsx from '@vitejs/plugin-vue-jsx'
+import svgLoader from 'vite-svg-loader'
+// import vitePluginForArco from '@arco-plugins/vite-vue'
+
+export default defineConfig({
+  // base: './',  使用electron 打包桌面程序时,修复资源文件404,需取消注释
+  plugins: [
+    vue(),
+    vueJsx(),
+    svgLoader({ svgoConfig: {} })
+    // vitePluginForArco({
+    //   theme: '@arco-themes/vue-emcrm'
+    // })
+  ],
+  resolve: {
+    alias: [
+      {
+        find: '@',
+        replacement: resolve(__dirname, '../src')
+      },
+      {
+        find: 'assets',
+        replacement: resolve(__dirname, '../src/assets')
+      },
+      {
+        find: 'vue-i18n',
+        replacement: 'vue-i18n/dist/vue-i18n.cjs.js' // Resolve the i18n warning issue
+      },
+      {
+        find: 'vue',
+        replacement: 'vue/dist/vue.esm-bundler.js' // compile template
+      }
+    ],
+    extensions: ['.ts', '.js']
+  },
+  define: {
+    'process.env': {}
+  },
+  optimizeDeps: {
+    include: ['axios', 'xlsx']
+  },
+  css: {
+    preprocessorOptions: {
+      less: {
+        modifyVars: {
+          hack: `true; @import (reference) "${resolve(
+            'src/assets/style/breakpoint.less'
+          )}";`
+        },
+        javascriptEnabled: true
+      }
+    }
+  }
+})

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

@@ -0,0 +1,36 @@
+import { mergeConfig } from 'vite'
+import eslint from 'vite-plugin-eslint'
+import { viteMockServe } from 'vite-plugin-mock'
+import baseConfig from './vite.config.base'
+
+export default mergeConfig(
+  {
+    mode: 'development',
+    server: {
+      proxy: {
+        '/api/': {
+          target: 'https://pmr.surkw.com:1443',
+          secure: false,
+          changeOrigin: true
+        }
+      }
+    },
+    plugins: [
+      viteMockServe({
+        ignore: /^_/,
+        mockPath: 'src/mock',
+        localEnabled: false,
+        prodEnabled: true,
+        injectCode: `
+        import { setupProdMockServer } from './mock/_createProductionServer'
+         setupProdMockServer()`
+      }),
+      eslint({
+        cache: false,
+        include: ['src/**/*.ts', 'src/**/*.tsx', 'src/**/*.vue'],
+        exclude: ['node_modules']
+      })
+    ]
+  },
+  baseConfig
+)

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

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

+ 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>牧云项目管理系统</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

+ 111 - 0
package.json

@@ -0,0 +1,111 @@
+{
+  "name": "my-porject-web-manager",
+  "description": "",
+  "version": "1.0.0",
+  "private": true,
+  "author": "Sun Yuan",
+  "license": "MIT",
+  "scripts": {
+    "dev": "vite --config ./config/vite.config.dev.ts",
+    "build": "vue-tsc --noEmit && vite build --config ./config/vite.config.prod.ts",
+    "report": "cross-env REPORT=true npm run build",
+    "preview": "npm run build && vite preview --host",
+    "type:check": "vue-tsc --noEmit --skipLibCheck",
+    "lint-staged": "npx lint-staged",
+    "prepare": "husky install"
+  },
+  "lint-staged": {
+    "*.{js,ts,jsx,tsx}": [
+      "prettier --write",
+      "eslint --fix"
+    ],
+    "*.vue": [
+      "stylelint --fix",
+      "prettier --write",
+      "eslint --fix"
+    ],
+    "*.{less,css}": [
+      "stylelint --fix",
+      "prettier --write"
+    ]
+  },
+  "dependencies": {
+    "@arco-design/web-vue": "^2.52.1",
+    "@vueuse/core": "^9.3.0",
+    "arco-design-pro-vue": "^2.7.2",
+    "axios": "^0.24.0",
+    "date-fns": "^2.29.1",
+    "dayjs": "^1.11.5",
+    "echarts": "^5.4.0",
+    "lodash": "^4.17.21",
+    "mitt": "^3.0.0",
+    "nprogress": "^0.2.0",
+    "pinia": "^2.0.23",
+    "qs": "^6.11.0",
+    "query-string": "^8.0.3",
+    "sortablejs": "^1.15.0",
+    "ts-md5": "^1.2.11",
+    "tsparticles": "^2.9.3",
+    "vue": "^3.2.40",
+    "vue-echarts": "^6.2.3",
+    "vue-i18n": "^9.2.2",
+    "vue-router": "^4.0.14",
+    "vuedraggable": "4.1.0"
+  },
+  "devDependencies": {
+    "@arco-plugins/vite-vue": "^1.4.5",
+    "@commitlint/cli": "^17.1.2",
+    "@commitlint/config-conventional": "^17.1.0",
+    "@types/crypto-js": "^4.1.1",
+    "@types/lodash": "^4.14.186",
+    "@types/nprogress": "^0.2.0",
+    "@types/qs": "^6.9.7",
+    "@types/sortablejs": "^1.15.0",
+    "@typescript-eslint/eslint-plugin": "^5.40.0",
+    "@typescript-eslint/parser": "^5.40.0",
+    "@vitejs/plugin-vue": "^3.1.2",
+    "@vitejs/plugin-vue-jsx": "^2.0.1",
+    "@vue/babel-plugin-jsx": "^1.1.1",
+    "consola": "^2.15.3",
+    "cross-env": "^7.0.3",
+    "crypto-js": "^4.1.1",
+    "eslint": "^8.25.0",
+    "eslint-config-airbnb-base": "^15.0.0",
+    "eslint-config-prettier": "^8.5.0",
+    "eslint-import-resolver-typescript": "^3.5.1",
+    "eslint-plugin-import": "^2.26.0",
+    "eslint-plugin-prettier": "^4.2.1",
+    "eslint-plugin-vue": "^9.6.0",
+    "husky": "^8.0.1",
+    "less": "^4.1.3",
+    "lint-staged": "^13.0.3",
+    "mockjs": "^1.1.0",
+    "postcss-html": "^1.5.0",
+    "prettier": "^2.7.1",
+    "rollup": "^3.9.1",
+    "rollup-plugin-visualizer": "^5.8.2",
+    "stylelint": "^14.13.0",
+    "stylelint-config-prettier": "^9.0.3",
+    "stylelint-config-rational-order": "^0.1.2",
+    "stylelint-config-recommended-vue": "^1.4.0",
+    "stylelint-config-standard": "^29.0.0",
+    "stylelint-order": "^5.0.0",
+    "typescript": "^4.8.4",
+    "unplugin-vue-components": "^0.24.1",
+    "vite": "^3.2.5",
+    "vite-plugin-compression": "^0.5.1",
+    "vite-plugin-eslint": "^1.8.1",
+    "vite-plugin-imagemin": "^0.6.1",
+    "vite-plugin-mock": "^2.9.6",
+    "vite-svg-loader": "^3.6.0",
+    "vue-tsc": "^1.0.14"
+  },
+  "engines": {
+    "node": ">=14.0.0"
+  },
+  "resolutions": {
+    "bin-wrapper": "npm:bin-wrapper-china",
+    "rollup": "^2.56.3",
+    "gifsicle": "5.2.0"
+  }
+}

+ 28 - 0
src/App.vue

@@ -0,0 +1,28 @@
+<template>
+  <a-config-provider :locale="locale">
+    <router-view />
+    <global-setting />
+  </a-config-provider>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue'
+import { RouteLocationNormalized } from 'vue-router'
+import zhCN from '@arco-design/web-vue/es/locale/lang/zh-cn'
+import GlobalSetting from '@/components/global-setting/index.vue'
+// import useLocale from '@/hooks/locale'
+
+import { useUserStore } from '@/store'
+import { listenerRouteChange } from './logics/mitt/routeChange'
+import { downloadFile } from './utils/download'
+
+const userStore = useUserStore()
+const { setRouter } = userStore
+// const { currentLocale } = useLocale()
+const locale = computed(() => {
+  return zhCN
+})
+listenerRouteChange(async (r: RouteLocationNormalized) => {
+  setRouter(r)
+})
+</script>

+ 54 - 0
src/api/acs/domain.ts

@@ -0,0 +1,54 @@
+import { request } from '@/utils/request'
+import { Domain } from '@/enums/api'
+import { DomainTreeResParams, AddDomainResParams } from '@/types/domain'
+
+/**
+ * 获取功能项列表
+ * @param pid 有此参数,只取此父域下的域树,否则获取自己权限下的域树。
+ */
+export const getDomainTree = (pid?: number) => {
+  return request.post<DomainTreeResParams>({
+    url: Domain.SYSTEM_DOMAIN_TREE,
+    data: { pid }
+  })
+}
+
+/**
+ * 添加域
+ * @param pid 父级域
+ * @param name 域名
+ * @param desc 域描述
+ */
+export const addDomain = (pid: number, name: string, desc?: string) => {
+  return request.post<AddDomainResParams>({
+    url: Domain.SYSTEM_DOMAIN_ADD,
+    data: { pid, name, desc }
+  })
+}
+/**
+ * 修改域
+ * @param domainId 当前选中域。id不可修改
+ * @param name 域名 可修改
+ * @param memo 域描述
+ */
+export const modifyDomain = (
+  domainId: number,
+  name?: string,
+  memo?: string
+) => {
+  return request.post<BaseResParams>({
+    url: Domain.SYSTEM_DOMAIN_MODIFY,
+    data: { id: domainId, name, memo }
+  })
+}
+
+/**
+ * 删除域
+ * @param domainId 当前选中域
+ */
+export const removeDomain = (domainId: number) => {
+  return request.post<BaseResParams>({
+    url: Domain.SYSTEM_DOMAIN_REMOVE,
+    data: { id: domainId }
+  })
+}

+ 65 - 0
src/api/acs/function.ts

@@ -0,0 +1,65 @@
+import { request } from '@/utils/request'
+import { Function } from '@/enums/api'
+import {
+  BaseResParams,
+  FuncListResParams,
+  AddFuncResParams
+} from '@/types/function'
+
+/**
+ * 获取功能项列表
+ * @param moduleId 功能模块id
+ */
+export const getFuncList = (moduleId: number) => {
+  return request.post<FuncListResParams>({
+    url: Function.SYSTEM_FUNC_LIST,
+    data: { module_id: moduleId }
+  })
+}
+
+/**
+ * 在选中功能模块下添加功能项
+ * @param moduleId 选中模块id
+ * @param name 功能项名称
+ * @param tag  功能项标记
+ * @param checkAuth 是否鉴权
+ */
+export const addFunc = (
+  moduleId: number,
+  name: string,
+  tag: string,
+  checkAuth: boolean
+) => {
+  return request.post<AddFuncResParams>({
+    url: Function.SYSTEM_FUNC_ADD,
+    data: { module_id: moduleId, name, tag, check_auth: checkAuth }
+  })
+}
+
+/**
+ * 修改当前功能项
+ * @param funcId 选中功能项id
+ * @param name 功能项名称
+ * @param checkAuth 是否鉴权
+ */
+export const modifyFunc = (
+  funcId: number,
+  name?: string,
+  checkAuth?: boolean
+) => {
+  return request.post<BaseResParams>({
+    url: Function.SYSTEM_FUNC_MODIFY,
+    data: { id: funcId, name, check_auth: checkAuth }
+  })
+}
+
+/**
+ * 删除功能项
+ * @param funcId 选中功能项id
+ */
+export const removeFunc = (funcId: number) => {
+  return request.post<BaseResParams>({
+    url: Function.SYSTEM_FUNC_REMOVE,
+    data: { id: funcId }
+  })
+}

+ 59 - 0
src/api/acs/module.ts

@@ -0,0 +1,59 @@
+import { request } from '@/utils/request'
+import { Module } from '@/enums/api'
+import { ModuleTreeResParams, AddModuleResParams } from '@/types/module'
+
+/**
+ * 获取功能模块树
+ */
+export const getModuleTree = () => {
+  return request.post<ModuleTreeResParams>({
+    url: Module.SYSTEM_MODULE_TREE,
+    data: {}
+  })
+}
+
+/**
+ * 添加功能模块
+ * @param pid 当前选中模块id,在此id下添加,最高级为0
+ * @param name 模块名称
+ * @param tag  模块标记
+ */
+export const addModule = (
+  pid: number,
+  name: string,
+  tag: string,
+  memo?: string
+) => {
+  return request.post<AddModuleResParams>({
+    url: Module.SYSTEM_MODULE_ADD,
+    data: { pid, name, tag, memo }
+  })
+}
+
+/**
+ * 修改功能模块信息
+ * @param moduleId 功能模块id
+ * @param name 名称
+ * @param memo 备注信息
+ */
+export const modifyModule = (
+  moduleId: number,
+  name?: string,
+  memo?: string
+) => {
+  return request.post<BaseResParams>({
+    url: Module.SYSTEM_MODULE_MODIFY,
+    data: { id: moduleId, name, memo }
+  })
+}
+
+/**
+ * 删除功能模块
+ * @param moduleId 模块id
+ */
+export const removeModule = (moduleId: number) => {
+  return request.post<BaseResParams>({
+    url: Module.SYSTEM_MODULE_REMOVE,
+    data: { id: moduleId }
+  })
+}

+ 87 - 0
src/api/acs/role.ts

@@ -0,0 +1,87 @@
+import { request } from '@/utils/request'
+import { Role } from '@/enums/api'
+import { RoleListResParams, RoleFunResParams } from '@/types/role'
+import { ModuleTreeResParams } from '@/types/module'
+
+/**
+ * 获取角色列表
+ * @param keyword 关键词,模糊匹配角色id,角色名称
+ */
+export const getRoleList = (keyword?: string) => {
+  return request.post<RoleListResParams>({
+    url: Role.SYSTEM_ROLE_LIST,
+    data: { keyword }
+  })
+}
+
+/**
+ * 添加角色
+ * @param roleId 角色id
+ * @param roleName 角色名称
+ */
+export const addRole = (roleId: string, roleName: string) => {
+  return request.post<BaseResParams>({
+    url: Role.SYSTEM_ROLE_ADD,
+    data: { role_id: roleId, role_name: roleName }
+  })
+}
+
+/**
+ * 修改角色
+ * @param roleId 角色id
+ * @param roleName 角色名称
+ */
+export const modifyRole = (roleId: string, roleName?: string) => {
+  return request.post<BaseResParams>({
+    url: Role.SYSTEM_ROLE_MODIFY,
+    data: { role_id: roleId, role_name: roleName }
+  })
+}
+
+/**
+ * 删除角色
+ * @param roleId 角色id
+ */
+export const removeRole = (roleId: string) => {
+  return request.post<BaseResParams>({
+    url: Role.SYSTEM_ROLE_REMOVE,
+    data: { role_id: roleId }
+  })
+}
+
+/**
+ * 设置角色对应的功能项
+ * @param roleId 角色id
+ * @param funcs 功能列表 id: 功能ID, enabled: 是否有权限使用
+ */
+export const setRoleFun = (
+  roleId: string,
+  funcs: Array<{ id: number; enabled: boolean }>
+) => {
+  return request.post<BaseResParams>({
+    url: Role.SYSTEM_ROLE_SET_F,
+    data: { role_id: roleId, funcs }
+  })
+}
+
+/**
+ * 获取角色对应的功能项。
+ * @param roleId 角色id
+ * @param moduleId 功能模块id
+ */
+export const getRoleFun = (roleId: string, moduleId: number) => {
+  return request.post<RoleFunResParams>({
+    url: Role.SYSTEM_ROLE_GET_F,
+    data: { role_id: roleId, module_id: moduleId }
+  })
+}
+
+/**
+ * 获取功能分组树,返回参数和模块树一样
+ */
+export const getFuncGroupTree = () => {
+  return request.post<ModuleTreeResParams>({
+    url: Role.SYSTEM_ROLE_GET_GROUP_TREE,
+    data: {}
+  })
+}

+ 137 - 0
src/api/acs/user.ts

@@ -0,0 +1,137 @@
+import { request } from '@/utils/request'
+import { User } from '@/enums/api'
+import {
+  ModifyUserParams,
+  UserListResParams,
+  UserGroupResParams,
+  UserRoleResParams
+} from '@/types/user'
+
+/**
+ * 添加用户
+ * @param groupId 将新账号添加到哪个域(分组)下
+ * @param name 账号名称
+ * @param pass 密码,MD5 32位 小写
+ * @param email 邮箱号
+ * @param mobile 手机号
+ * @param status 状态,0=锁定,1=正常
+ */
+export const addUser = (
+  groupId: number,
+  name: string,
+  pass: string,
+  status: number,
+  email?: string,
+  mobile?: string
+) => {
+  return request.post<BaseResParams>({
+    url: User.SYSTEM_USER_ADD,
+    data: {
+      user_id: mobile, // 新版本将用户手机号当唯一的user_id
+      domain_id: groupId,
+      name,
+      pass,
+      email,
+      mobile,
+      status
+    }
+  })
+}
+
+/**
+ * 修改用户信息
+ * @param params ModifyApplicationParams
+ */
+export const modifyUser = (params: ModifyUserParams) => {
+  return request.post<BaseResParams>({
+    url: User.SYSTEM_USER_MODIFY,
+    data: { ...params }
+  })
+}
+
+/**
+ * 删除用户
+ * @param userId 用户id
+ */
+export const removeUser = (userId: string) => {
+  return request.post<BaseResParams>({
+    url: User.SYSTEM_USER_REMOVE,
+    data: { user_id: userId }
+  })
+}
+
+/**
+ * 获取用户列表
+ * @param groupId 域(分组)id
+ * @param pageIndex 页码
+ * @param pageSize  每页条数
+ * @param keyword 搜索关键字,名称或用户id
+ */
+export const getUserList = (
+  groupId: number,
+  pageIndex: number,
+  pageSize: number,
+  keyword?: string
+) => {
+  return request.post<UserListResParams>({
+    url: User.SYSTEM_USER_LIST,
+    data: {
+      domain_id: groupId,
+      page_no: pageIndex,
+      page_size: pageSize,
+      keyword
+    }
+  })
+}
+
+/**
+ * 获取账号所对应的角色。包括已赋予的和可赋予但还没有赋予的
+ * @param userId 用户id
+ */
+export const getUserRoles = (userId: string) => {
+  return request.post<UserRoleResParams>({
+    url: User.SYSTEM_USER_GET_ROLES,
+    data: { user_id: userId }
+  })
+}
+
+/**
+ * 设置用户角色
+ * @param userId 用户id
+ * @param roles 角色列表。包括新赋予的角色和取消赋予的角色。不在列表中的角色保持不变。
+ */
+export const setUserRoles = (
+  userId: string,
+  roles: Array<{ id: string; name?: string; enabled: boolean }>
+) => {
+  return request.post<BaseResParams>({
+    url: User.SYSTEM_USER_SET_ROLES,
+    data: { user_id: userId, roles }
+  })
+}
+
+/**
+ * 获取账号所能访问的所有分组的id
+ * @param userId 用户id
+ */
+export const getUserGroupList = (userId: string) => {
+  return request.post<UserGroupResParams>({
+    url: User.SYSTEM_USER_GET_DOMAINS,
+    data: { user_id: userId }
+  })
+}
+
+/**
+ * 设置用户可访问分组
+ * @param userId 用户id
+ * @param domains 分组列表。包括新赋予的分组和取消赋予的分组。不在列表中的分组保持不变。
+ */
+export const setUserGroup = (
+  userId: string,
+  domains: Array<{ id: number; enabled: boolean }>
+) => {
+  return request.post<BaseResParams>({
+    url: User.SYSTEM_USER_SET_DOMAINS,
+    data: { user_id: userId, domains }
+  })
+}

+ 66 - 0
src/api/base/base.ts

@@ -0,0 +1,66 @@
+import { request } from '@/utils/request'
+import { Base } from '@/enums/api'
+import { DomainTreeResParams } from '@/types/domain'
+
+export interface BaseApiType {
+  list: { id: string; name: string }[]
+}
+
+export interface PrjPhaseList {
+  list: { id: string; name: string; color: string }[]
+}
+export interface DocTypeList {
+  list: { id: string; name: string; color: string; allow_upload: boolean }[]
+}
+
+/**
+ * 获取客户级别信息列表
+ * @returns
+ */
+export const getCustomerLevelList = () => {
+  return request.post<BaseApiType>({
+    url: Base.CFG_CUSTOMER_LEVEL_LIST,
+    data: {}
+  })
+}
+
+/**
+ * 获取行业分类信息列表
+ * @returns
+ */
+export const getIndustryTypeList = () => {
+  return request.post<BaseApiType>({
+    url: Base.CFG_INDUSTRY_TYPE_LIST,
+    data: {}
+  })
+}
+
+// 获取项目类型列表
+export const getPrjTypeList = () => {
+  return request.post<DomainTreeResParams>({
+    url: Base.CFG_PRJ_TYPE_TREE,
+    data: {}
+  })
+}
+
+// 获取项目阶段列表
+export const getPrjPhaseList = () => {
+  return request.post<PrjPhaseList>({
+    url: Base.CFG_PRJ_PHASE_GET_LIST,
+    data: {}
+  })
+}
+
+export const getMemberRoleList = () => {
+  return request.post<BaseApiType>({
+    url: Base.CFG_PRJ_ROLE_GET_LIST,
+    data: {}
+  })
+}
+
+export const getDocTypeList = () => {
+  return request.post<DocTypeList>({
+    url: Base.CFG_DOC_TYPE_LIST,
+    data: {}
+  })
+}

+ 60 - 0
src/api/base/org.ts

@@ -0,0 +1,60 @@
+import { request } from '@/utils/request'
+import { Base } from '@/enums/api'
+import { DomainTreeResParams, AddDomainResParams } from '@/types/domain'
+
+/**
+ * 获取组织树
+ */
+export const getOrgTree = () => {
+  return request.post<DomainTreeResParams>({
+    url: Base.CFG_ORG_TREE,
+    data: {}
+  })
+}
+
+/**
+ * 添加组织机构
+ * @param pid 上一级组织机构id,如果当前添加的是一级组织机构,此字段为空或为200
+ * @param name 新添加的组织机构名称
+ * @param memo 备注
+ */
+export const addOrg = (name: string, pid?: number, memo?: string) => {
+  return request.post<AddDomainResParams>({
+    url: Base.CFG_ORG_ADD,
+    data: {
+      pid,
+      name,
+      memo
+    }
+  })
+}
+
+/**
+ * 修改组织机构
+ * @param orgId 组织机构id
+ * @param name 修改后的组织机构名称
+ * @param memo 修改后的组织机构备注
+ */
+export const modifyOrg = (orgId: number, name?: string, memo?: string) => {
+  return request.post<BaseResParams>({
+    url: Base.CFG_ORG_MODIFY,
+    data: {
+      id: orgId,
+      name,
+      memo
+    }
+  })
+}
+
+/**
+ * 删除组织
+ * @param orgId 组织机构id
+ */
+export const removeOrg = (orgId: number) => {
+  return request.post<BaseResParams>({
+    url: Base.CFG_ORG_REMOVE,
+    data: {
+      id: orgId
+    }
+  })
+}

+ 64 - 0
src/api/base/region.ts

@@ -0,0 +1,64 @@
+import { request } from '@/utils/request'
+import { Base } from '@/enums/api'
+import { DomainTreeResParams, AddDomainResParams } from '@/types/domain'
+
+/**
+ * 获取区域树
+ */
+export const getRegionTree = () => {
+  return request.post<DomainTreeResParams>({
+    url: Base.CFG_REGION_TREE,
+    data: {}
+  })
+}
+
+/**
+ * 添加区域
+ * @param pid 上一级区域id,如果当前添加的是一级区域,此字段为空或为200
+ * @param name 新添加的区域名称
+ * @param memo 备注
+ */
+export const addRegion = (name: string, pid?: number, memo?: string) => {
+  return request.post<AddDomainResParams>({
+    url: Base.CFG_REGION_ADD,
+    data: {
+      pid,
+      name,
+      memo
+    }
+  })
+}
+
+/**
+ * 修改区域
+ * @param orgId 区域id
+ * @param name 修改后的区域名称
+ * @param memo 修改后的区域备注
+ */
+export const modifyRegion = (
+  regionId: number,
+  name?: string,
+  memo?: string
+) => {
+  return request.post<BaseResParams>({
+    url: Base.CFG_REGION_MODIFY,
+    data: {
+      id: regionId,
+      name,
+      memo
+    }
+  })
+}
+
+/**
+ * 删除区域
+ * @param regionId 区域id
+ */
+export const removeRegion = (regionId: number) => {
+  return request.post<BaseResParams>({
+    url: Base.CFG_REGION_REMOVE,
+    data: {
+      id: regionId
+    }
+  })
+}

+ 68 - 0
src/api/base/staff.ts

@@ -0,0 +1,68 @@
+import { request } from '@/utils/request'
+import { Base } from '@/enums/api'
+import {
+  StaffListResParams,
+  StaffListReqParams,
+  ModifyStaffReqParams,
+  AddStaffReqParams,
+  StaffRoleListResParams
+} from '@/types/staff'
+
+/**
+ * 获取员工列表
+ * @param params
+ * @returns
+ */
+export const getStaffList = (params: StaffListReqParams) => {
+  return request.post<StaffListResParams>({
+    url: Base.CFG_STAFF_LIST,
+    data: params
+  })
+}
+
+/**
+ * 修改员工信息
+ * @param params
+ * @returns
+ */
+export const modifyStaff = (params: ModifyStaffReqParams) => {
+  return request.post<BaseResParams>({
+    url: Base.CFG_STAFF_MODIFY,
+    data: params
+  })
+}
+
+/**
+ * 删除员工
+ * @param id
+ * @returns
+ */
+export const removeStaff = (id: string) => {
+  return request.post<BaseResParams>({
+    url: Base.CFG_STAFF_REMOVE,
+    data: { id }
+  })
+}
+
+/**
+ * 添加员工
+ * @param params
+ * @returns
+ */
+export const addStaff = (params: AddStaffReqParams) => {
+  return request.post<BaseResParams>({
+    url: Base.CFG_STAFF_ADD,
+    data: params
+  })
+}
+
+/**
+ * 获取员工职位列表
+ * @returns
+ */
+export const getStaffRoleList = () => {
+  return request.post<StaffRoleListResParams>({
+    url: Base.CFG_STAFF_ROLES,
+    data: {}
+  })
+}

+ 82 - 0
src/api/biz/customer.ts

@@ -0,0 +1,82 @@
+import { request } from '@/utils/request'
+import { Customer } from '@/enums/api'
+import {
+  AddCustomerReqParams,
+  CustomerListReqParams,
+  ModifyCustomerReqParams,
+  CustomerListResParams,
+  AddCustomerResParams,
+  CustomerDetailResParams,
+  CustomerStatResParams
+} from '@/types/customer'
+
+/**
+ * 获取客户信息列表
+ * @param params RequestCustomerListParams
+ * @returns
+ */
+export const getCustomerList = (params: CustomerListReqParams) => {
+  return request.post<CustomerListResParams>({
+    url: Customer.BIZ_CUSTOMER_LIST,
+    data: params
+  })
+}
+
+/**
+ * 添加客户
+ * @param params RequestAddCustomerParams
+ * @returns
+ */
+export const addCustomer = (params: AddCustomerReqParams) => {
+  return request.post<AddCustomerResParams>({
+    url: Customer.BIZ_CUSTOMER_ADD,
+    data: params
+  })
+}
+
+/**
+ * 修改客户信息
+ * @param params RequestModifyCustomerParams
+ * @returns
+ */
+export const modifyCustomer = (params: ModifyCustomerReqParams) => {
+  return request.post<BaseResParams>({
+    url: Customer.BIZ_CUSTOMER_MODIFY,
+    data: params
+  })
+}
+
+/**
+ * 删除客户
+ * @param id 客户编号
+ * @returns
+ */
+export const removeCustomer = (id: string) => {
+  return request.post<BaseResParams>({
+    url: Customer.BIZ_CUSTOMER_REMOVE,
+    data: { id }
+  })
+}
+
+/**
+ * 获取客户详情
+ * @param id 客户编号
+ * @returns
+ */
+export const getCustomerDetail = (id: string) => {
+  return request.post<CustomerDetailResParams>({
+    url: Customer.BIZ_CUSTOMER_DETAIL,
+    data: { id }
+  })
+}
+
+/**
+ * 获取客户统计信息
+ * @returns
+ */
+export const getCustomerStat = () => {
+  return request.post<CustomerStatResParams>({
+    url: Customer.BIZ_CUSTOMER_STAT,
+    data: {}
+  })
+}

+ 162 - 0
src/api/client.ts

@@ -0,0 +1,162 @@
+import {
+  BaseResParams,
+  ImageVodeResParams,
+  LoginResParams,
+  PermissonListResParams,
+  SelfInfoResParams
+} from '@/types/client'
+import { Client } from '@/enums/api'
+import { request } from '@/utils/request'
+import { UploadFileParams } from '@/types/axios'
+
+/**
+ * 网页客户端登录
+ * @param username 用户ID
+ * @param password 用户密码,必须为md5加密后
+ */
+export const doLogin = (
+  username: string,
+  password: string,
+  session?: string,
+  vcode?: string
+) => {
+  return request.post<LoginResParams>({
+    url: Client.LOGIN,
+    data: {
+      username,
+      password,
+      session,
+      vcode
+    }
+  })
+}
+
+/**
+ * 获取当前登录用户权限集
+ */
+export const getPermissions = () => {
+  return request.post<PermissonListResParams>({
+    url: Client.GET_PERMISSIONS,
+    data: {}
+  })
+}
+
+/**
+ * 获取当前登录用户个人信息
+ */
+export const getSelfInfo = () => {
+  return request.post<SelfInfoResParams>({
+    url: Client.GET_SELF_INFO,
+    data: {}
+  })
+}
+
+/**
+ * 设置当前登录用户个人信息
+ * @param params
+ */
+export const setSelfInfo = (params: {
+  name?: string
+  mobile?: string
+  email?: string
+  old_pass?: string
+  new_pass?: string
+}) => {
+  return request.post<BaseResParams>({
+    url: Client.SET_SELF_INFO,
+    data: params
+  })
+}
+
+/**
+ * 获取图片验证码
+ */
+export const getImageVcode = () => {
+  return request.post<ImageVodeResParams>({
+    url: Client.GET_IMAGE_VODE,
+    data: {}
+  })
+}
+
+/**
+ * 上传
+ * @param url
+ * @param params
+ * @returns
+ */
+export const uploadFile = (
+  url: string,
+  params: UploadFileParams,
+  uploadProgress?: (progressEvent: ProgressEvent) => void,
+  timeout?: number | 36000
+) => {
+  return request.uploadFile<any>(
+    {
+      url,
+      timeout
+    },
+    params,
+    uploadProgress
+  )
+}
+
+/**
+ * 使用axios下载文件,显示进度条
+ * @param url
+ * @param downloadProgress
+ * @returns
+ */
+export const downloadFile = async (
+  name: string,
+  url: string,
+  downloadProgress?: any,
+  otherParams?: object
+) => {
+  const response = await request.get<any>(
+    {
+      url,
+      timeout: 1000 * 60 * 60,
+      responseType: 'blob',
+      onDownloadProgress: (e: ProgressEvent) => downloadProgress(e, otherParams)
+    },
+    {
+      isReturnNativeResponse: true
+    }
+  )
+  const blob = response.data
+  const a = document.createElement('a')
+  document.body.appendChild(a)
+  a.style.display = 'none'
+  const downloadUrl = window.URL.createObjectURL(blob)
+  a.href = downloadUrl
+  a.download = name
+  a.click()
+  document.body.removeChild(a)
+  window.URL.revokeObjectURL(downloadUrl)
+}
+
+/**
+ * 获取区域树
+ */
+export const getGroupTree = () => {
+  return request.post<GroupTreeResParams>({
+    url: Client.INFO_DOMAIN_TREE,
+    data: {}
+  })
+}
+
+// 获取分组详情返回参数
+export interface GroupTreeResParams extends BaseResParams {
+  tree: Array<GroupTree>
+}
+
+// 功能模块具体参数
+export interface GroupTree {
+  id: number // 域id
+  memo?: string
+  key: string
+  name: string
+  title: string // 域名
+  children: Array<GroupTree>
+  showMore?: boolean
+}

+ 59 - 0
src/api/dashboard/project.ts

@@ -0,0 +1,59 @@
+import { request } from '@/utils/request'
+import { Dashboard } from '@/enums/api'
+import {
+  ProjectCategoryStatResParams,
+  ProjectMonthCountStatReqParams,
+  ProjectMonthCountStatResParams,
+  ProjectMultiStatResParams,
+  ProjectPlanStatReqParams,
+  ProjectPlanStatResParams
+} from '@/types/dashboard'
+
+/**
+ * 获取项目分类统计
+ * @returns
+ */
+export const getProjectCategoryStat = () => {
+  return request.post<ProjectCategoryStatResParams>({
+    url: Dashboard.REPORT_PRJOJECT_CAT_STAT,
+    data: {}
+  })
+}
+
+/**
+ * 获取项目计划统计
+ * @param ProjectPlanStatReqParams
+ * @returns
+ */
+export const getProjectPlanStat = (params: ProjectPlanStatReqParams) => {
+  return request.post<ProjectPlanStatResParams>({
+    url: Dashboard.REPORT_PRJOJECT_PLAN_STAT,
+    data: params
+  })
+}
+
+/**
+ * 获取项目数量累计,每月底计算一次累计值,每月一条数据
+ * @param ProjectMonthCountStatReqParams
+ * @returns
+ */
+export const getProjectMonthCountStat = (
+  params: ProjectMonthCountStatReqParams
+) => {
+  return request.post<ProjectMonthCountStatResParams>({
+    url: Dashboard.REPORT_PRJOJECT_COUNT_STAT,
+    data: params
+  })
+}
+
+/**
+ * 获取项目数量累计,每月底计算一次累计值,每月一条数据
+ * @param ProjectMonthCountStatReqParams
+ * @returns
+ */
+export const getProjectMultiStat = () => {
+  return request.post<ProjectMultiStatResParams>({
+    url: Dashboard.REPORT_PRJOJECT_MULTI_STAT,
+    data: {}
+  })
+}

+ 59 - 0
src/api/project/apply.ts

@@ -0,0 +1,59 @@
+import { request } from '@/utils/request'
+import { Project } from '@/enums/api'
+import {
+  StartApplyReqParams,
+  StartApplyResParams,
+  NewPlanApplyReqParams,
+  NewPlanApplyResParams,
+  ModifyPlanApplyReqParams,
+  ModifyPlanApplyResParams,
+  CancelApplyReqParams
+} from '@/types/apply'
+
+/**
+ * 申请立项
+ * @param params StartApplyReqParams
+ * @returns
+ */
+export const startApplyProject = (params: StartApplyReqParams) => {
+  return request.post<StartApplyResParams>({
+    url: Project.PROJECT_APPLY_START,
+    data: params
+  })
+}
+
+/**
+ * 申请审核新建任务计划
+ * @param params NewPlanApplyReqParams
+ * @returns
+ */
+export const newPlanApplyProject = (params: NewPlanApplyReqParams) => {
+  return request.post<NewPlanApplyResParams>({
+    url: Project.PROJECT_APPLY_NEW_PLAN,
+    data: params
+  })
+}
+
+/**
+ * 申请变更任务计划
+ * @param params ModifyPlanApplyReqParams
+ * @returns
+ */
+export const modifyPlanApplyProject = (params: ModifyPlanApplyReqParams) => {
+  return request.post<ModifyPlanApplyResParams>({
+    url: Project.PROJECT_APPLY_MODIFY_PLAN,
+    data: params
+  })
+}
+
+/**
+ * 撤回申请
+ * @param params CancelApplyReqParams
+ * @returns
+ */
+export const cancelApplyProject = (params: CancelApplyReqParams) => {
+  return request.post<BaseResParams>({
+    url: Project.PROJECT_APPLY_CANCEL,
+    data: params
+  })
+}

+ 126 - 0
src/api/project/contract.ts

@@ -0,0 +1,126 @@
+import { request } from '@/utils/request'
+import { Project } from '@/enums/api'
+import {
+  ContractListResParams,
+  ContractListReqParams,
+  AddContractReqParams,
+  AddContractResParams,
+  ModifyContractReqParams,
+  ContractDetailResParams,
+  ContractDownloadDocUrlReqParams,
+  ContractDownloadDocUrlResParams,
+  ContractUploadDocUrlReqParams,
+  ContractUploadDocUrlResParams,
+  RemoveContractDocUrlReqParams,
+  ContractStatResParams
+} from '@/types/contract'
+
+/**
+ * 获取合同列表
+ * @param params ContractListReqParams
+ */
+export const getContractList = (params: ContractListReqParams) => {
+  return request.post<ContractListResParams>({
+    url: Project.PROJECT_CONTRACT_LIST,
+    data: params
+  })
+}
+
+/**
+ * 添加合同信息
+ * @param params AddContractReqParams
+ * @returns
+ */
+export const addContract = (params: AddContractReqParams) => {
+  return request.post<AddContractResParams>({
+    url: Project.PROJECT_CONTRACT_ADD,
+    data: params
+  })
+}
+
+/**
+ * 修改合同信息
+ * @param params ModifyContractReqParams
+ * @returns
+ */
+export const modifyContract = (params: ModifyContractReqParams) => {
+  return request.post<BaseResParams>({
+    url: Project.PROJECT_CONTRACT_MODIFY,
+    data: params
+  })
+}
+
+/**
+ * 删除合同信息
+ * @param id 合同编号
+ * @returns
+ */
+export const removeContract = (id: string) => {
+  return request.post<BaseResParams>({
+    url: Project.PROJECT_CONTRACT_REMOVE,
+    data: { id }
+  })
+}
+
+/**
+ * 获取合同概览
+ * @param id 合同编号
+ * @returns
+ */
+export const getContractDetail = (id: string) => {
+  return request.post<ContractDetailResParams>({
+    url: Project.PROJECT_CONTRACT_DETAIL,
+    data: { id }
+  })
+}
+
+/**
+ * 获取上传合同附件的url。
+ * @param params ContractUploadDocUrlResParams
+ * @returns
+ */
+export const getUploadContractFileUrl = (
+  params: ContractUploadDocUrlReqParams
+) => {
+  return request.post<ContractUploadDocUrlResParams>({
+    url: Project.PROJECT_CONTRACT_UPLOAD_DOC,
+    data: params
+  })
+}
+
+/**
+ * 获取下载合同附件的url。
+ * @param params ContractDownloadDocUrlReqParams
+ * @returns
+ */
+export const getDownloadContractFileUrl = (
+  params: ContractDownloadDocUrlReqParams
+) => {
+  return request.post<ContractDownloadDocUrlResParams>({
+    url: Project.PROJECT_CONTRACT_DOWNLOAD_DOC,
+    data: params
+  })
+}
+
+/**
+ * 用于删除已上传的合同附件。已归档的附件不允许修改。
+ * @param params
+ * @returns
+ */
+export const removeContractFile = (params: RemoveContractDocUrlReqParams) => {
+  return request.post<BaseResParams>({
+    url: Project.PROJECT_CONTRACT_REMOVE_DOC,
+    data: params
+  })
+}
+
+/**
+ * 获取工作统计信息
+ * @returns
+ */
+export const getContractStat = () => {
+  return request.post<ContractStatResParams>({
+    url: Project.PROJECT_CONTRACT_STAT,
+    data: {}
+  })
+}

+ 82 - 0
src/api/project/document.ts

@@ -0,0 +1,82 @@
+import { request } from '@/utils/request'
+import { Project } from '@/enums/api'
+import {
+  DocumentListReqParams,
+  DocumentListResParams,
+  DownloadDocumentReqParams,
+  DownloadDocumentResParams,
+  UploadDocumentReqParams,
+  UploadDocumentResParams
+} from '@/types/document'
+
+/**
+ * 获取项目文档列表
+ * @param params DocumentListReqParams
+ * @returns DocumentListResParams
+ */
+export const getDocumentList = (params: DocumentListReqParams) => {
+  return request.post<DocumentListResParams>({
+    url: Project.PROJECT_DOC_LIST,
+    data: params
+  })
+}
+
+/**
+ * 获取上传项目文档Url
+ * @param params UploadDocumentReqParams
+ * @returns UploadDocumentResParams
+ */
+export const getUploadDocumentUrl = (params: UploadDocumentReqParams) => {
+  return request.post<UploadDocumentResParams>({
+    url: Project.PROJECT_DOC_UPLOAD,
+    data: params
+  })
+}
+
+/**
+ * 获取下载项目文档Url
+ * @param params DownloadDocumentReqParams
+ * @returns DownloadDocumentResParams
+ */
+export const getDownloadDocumentUrl = (params: DownloadDocumentReqParams) => {
+  return request.post<DownloadDocumentResParams>({
+    url: Project.PROJECT_DOC_DOWNLOAD,
+    data: params
+  })
+}
+
+/**
+ * 删除项目文档
+ * @param params
+ * @returns
+ */
+export const removeDocument = (params: { id: string }) => {
+  return request.post<BaseResParams>({
+    url: Project.PROJECT_DOC_REOMOVE,
+    data: params
+  })
+}
+
+// 归档文件
+export const archiveDocument = (params: { id: string; archive: boolean }) => {
+  return request.post<BaseResParams>({
+    url: Project.PROJECT_DOC_ARCHIVE,
+    data: params
+  })
+}
+
+// 复制文件
+export const copyDocument = (params: { id: string; target: string }) => {
+  return request.post<BaseResParams>({
+    url: Project.PROJECT_DOC_COPY,
+    data: params
+  })
+}
+
+// 重命名
+export const renameDocument = (params: { id: string; filename: string }) => {
+  return request.post<BaseResParams>({
+    url: Project.PROJECT_DOC_RENAME,
+    data: params
+  })
+}

+ 27 - 0
src/api/project/flow.ts

@@ -0,0 +1,27 @@
+import { request } from '@/utils/request'
+import { Project } from '@/enums/api'
+import { CheckStartProjectReqParams } from '@/types/project'
+
+/**
+ * 申请立项
+ * @param params 项目编号 立项说明
+ * @returns
+ */
+export const applyProject = (params: { id: string; memo?: string }) => {
+  return request.post<BaseResParams>({
+    url: Project.PROJECT_FLOW_APPLY,
+    data: params
+  })
+}
+
+/**
+ * 审核立项
+ * @param params RequestCheckStartProjectParams
+ * @returns
+ */
+export const checkStartProject = (params: CheckStartProjectReqParams) => {
+  return request.post<BaseResParams>({
+    url: Project.PROJECT_FLOW_CHECK_START,
+    data: params
+  })
+}

+ 136 - 0
src/api/project/info.ts

@@ -0,0 +1,136 @@
+import { request } from '@/utils/request'
+import { Project } from '@/enums/api'
+import {
+  ProjectListResParams,
+  ProjectListReqParams,
+  AddProjectReqParams,
+  AddProjectResParams,
+  ModifyProjectReqParams,
+  OverviewProjectResParams,
+  ProjectLogsReqParams,
+  ProjectLogsResParams,
+  ProjectMilestoneResParams,
+  ProjectCheckersResParams,
+  ProjectStatResParams
+} from '@/types/project'
+
+/**
+ * 获取项目列表
+ * @param params RequestProjectListParams
+ */
+export const getProjectList = (params: ProjectListReqParams) => {
+  return request.post<ProjectListResParams>({
+    url: Project.PROJECT_INFO_LIST,
+    data: params
+  })
+}
+
+/**
+ * 添加项目信息
+ * @param params
+ * @returns
+ */
+export const addProject = (params: AddProjectReqParams) => {
+  return request.post<AddProjectResParams>({
+    url: Project.PROJECT_INFO_ADD,
+    data: params
+  })
+}
+
+/**
+ * 修改项目信息
+ * @param params
+ * @returns
+ */
+export const modifyProject = (params: ModifyProjectReqParams) => {
+  return request.post<BaseResParams>({
+    url: Project.PROJECT_INFO_MODIFY,
+    data: params
+  })
+}
+
+/**
+ * 删除项目信息
+ * @param id 项目编号
+ * @returns
+ */
+export const removeProject = (id: string) => {
+  return request.post<BaseResParams>({
+    url: Project.PROJECT_INFO_REMOVE,
+    data: { id }
+  })
+}
+
+/**
+ * 获取项目概览
+ * @param id 项目编号
+ * @returns
+ */
+export const overviewProject = (id: string) => {
+  return request.post<OverviewProjectResParams>({
+    url: Project.PROJECT_INFO_OVERVIEW,
+    data: { id }
+  })
+}
+
+/**
+ * 获取项目动态列表
+ * @param params ProjectLogsReqParams
+ * @returns
+ */
+export const getProjectLogs = (params: ProjectLogsReqParams) => {
+  return request.post<ProjectLogsResParams>({
+    url: Project.PROJECT_INFO_LOGS,
+    data: params
+  })
+}
+
+/**
+ *
+ * @param params
+ * @returns
+ */
+export const getProjectMilestone = (params: { prj_id: string }) => {
+  return request.post<ProjectMilestoneResParams>({
+    url: Project.PROJECT_KANBAN_MILESTONE,
+    data: params
+  })
+}
+
+/**
+ * 获取项目审核人列表
+ * @param params
+ * @returns
+ */
+export const getProjectCheckers = (params: { id: string }) => {
+  return request.post<ProjectCheckersResParams>({
+    url: Project.PROJECT_INFO_GET_CHECKERS,
+    data: params
+  })
+}
+
+/**
+ * 获取项目审核人列表
+ * @param params
+ * @returns
+ */
+export const setProjectCheckers = (params: {
+  id: string
+  checkers: { id: string; name: string }[]
+}) => {
+  return request.post<BaseResParams>({
+    url: Project.PROJECT_INFO_SET_CHECKERS,
+    data: params
+  })
+}
+
+/**
+ * 获取项目统计信息
+ * @returns
+ */
+export const getProjectStat = () => {
+  return request.post<ProjectStatResParams>({
+    url: Project.PROJECT_INFO_STAT,
+    data: {}
+  })
+}

+ 98 - 0
src/api/project/member.ts

@@ -0,0 +1,98 @@
+import { request } from '@/utils/request'
+import { Project } from '@/enums/api'
+import {
+  MemberListReqParams,
+  MemberListResParams,
+  AddMemberReqParams,
+  ApplyRemoveMemberReqParams,
+  ApplyAddMemberResParams,
+  ApplyAddMemberReqParams,
+  ApplyRemoveMemberResParams,
+  ModifyMemberReqParams,
+  RemoveMemberReqParams,
+  CheckMemberReqParams
+} from '@/types/member'
+
+/**
+ * 获取项目组成员列表
+ * @param params MemberListReqParams
+ * @returns MemberListResParams
+ */
+export const getMemberList = (params: MemberListReqParams) => {
+  return request.post<MemberListResParams>({
+    url: Project.PROJECT_MEMBER_LIST,
+    data: params
+  })
+}
+
+/**
+ * 添加项目组成员
+ * @param params MemberListReqParams
+ * @returns BaseResParams
+ */
+export const addMember = (params: AddMemberReqParams) => {
+  return request.post<BaseResParams>({
+    url: Project.PROJECT_MEMBER_ADD,
+    data: params
+  })
+}
+
+/**
+ * 修改项目组成员
+ * @param params ModifyMemberReqParams
+ * @returns BaseResParams
+ */
+export const modifyMember = (params: ModifyMemberReqParams) => {
+  return request.post<BaseResParams>({
+    url: Project.PROJECT_MEMBER_MODIFY,
+    data: params
+  })
+}
+
+/**
+ * 删除项目组成员
+ * @param params ModifyMemberReqParams
+ * @returns BaseResParams
+ */
+export const removeMember = (params: RemoveMemberReqParams) => {
+  return request.post<BaseResParams>({
+    url: Project.PROJECT_MEMBER_REMOVE,
+    data: params
+  })
+}
+
+/**
+ * 申请添加项目组成员
+ * @param params ApplyAddMemberReqParams
+ * @returns ApplyAddMemberResParams
+ */
+export const applyAddMember = (params: ApplyAddMemberReqParams) => {
+  return request.post<ApplyAddMemberResParams>({
+    url: Project.PROJECT_MEMBER_APPLY_ADD,
+    data: params
+  })
+}
+
+/**
+ * 申请删除项目组成员
+ * @param params ApplyRemoveMemberReqParams
+ * @returns ApplyAddMemberResParams
+ */
+export const applyRemoveMember = (params: ApplyRemoveMemberReqParams) => {
+  return request.post<ApplyRemoveMemberResParams>({
+    url: Project.PROJECT_MEMBER_APPLY_REMOVE,
+    data: params
+  })
+}
+
+/**
+ * 申请添加或移除项目组成员后,审核是否通过。
+ * @param params CheckMemberReqParams
+ * @returns BaseResParams
+ */
+export const checkMember = (params: CheckMemberReqParams) => {
+  return request.post<BaseResParams>({
+    url: Project.PROJECT_MEMBER_CHECK,
+    data: params
+  })
+}

+ 112 - 0
src/api/project/outcome.ts

@@ -0,0 +1,112 @@
+import { request } from '@/utils/request'
+import { Project } from '@/enums/api'
+import {
+  OutcomeListResParams,
+  OutcomeListReqParams,
+  AddOutcomeReqParams,
+  AddOutcomeResParams,
+  RemoveOutcomeReqParams,
+  ModifyOutcomeReqParams,
+  OutcomeDetailReqParams,
+  OutcomeDetailResParams,
+  OutcomeUploadUrlReqParams,
+  OutcomeUploadUrlResParams,
+  RemoveOutcomeFileReqParams,
+  OutcomeDownloadUrlReqParams,
+  OutcomeDownloadUrlResParams
+} from '@/types/outcome'
+
+/**
+ * 获取任务交付物列表
+ * @param params OUTCOMEListReqParams
+ */
+export const getOutcomeList = (params: OutcomeListReqParams) => {
+  return request.post<OutcomeListResParams>({
+    url: Project.PROJECT_OUTCOME_LIST,
+    data: params
+  })
+}
+
+/**
+ * 添加任务交付物信息
+ * @param params AddOUTCOMEReqParams
+ * @returns
+ */
+export const addOutcome = (params: AddOutcomeReqParams) => {
+  return request.post<AddOutcomeResParams>({
+    url: Project.PROJECT_OUTCOME_ADD,
+    data: params
+  })
+}
+
+/**
+ * 修改任务交付物信息
+ * @param params ModifyOUTCOMEReqParams
+ * @returns
+ */
+export const modifyOutcome = (params: ModifyOutcomeReqParams) => {
+  return request.post<BaseResParams>({
+    url: Project.PROJECT_OUTCOME_MODIFY,
+    data: params
+  })
+}
+
+/**
+ * 删除任务交付物信息
+ * @param id 任务交付物编号
+ * @returns
+ */
+export const removeOutcome = (params: RemoveOutcomeReqParams) => {
+  return request.post<BaseResParams>({
+    url: Project.PROJECT_OUTCOME_REMOVE,
+    data: params
+  })
+}
+
+/**
+ * 获取任务交付物详情
+ * @param params OUTCOMEDetailReqParams
+ * @returns
+ */
+export const getOutcomeDetail = (params: OutcomeDetailReqParams) => {
+  return request.post<OutcomeDetailResParams>({
+    url: Project.PROJECT_OUTCOME_DETAIL,
+    data: params
+  })
+}
+
+/**
+ * 获取上传任务交付物附件的url。
+ * @param params OUTCOMEUploadDocUrlResParams
+ * @returns
+ */
+export const getUploadOutcomeUrl = (params: OutcomeUploadUrlReqParams) => {
+  return request.post<OutcomeUploadUrlResParams>({
+    url: Project.PROJECT_OUTCOME_UPLOAD,
+    data: params
+  })
+}
+
+/**
+ * 删除交付物文件
+ * @param params
+ * @returns
+ */
+export const removeOutcomeFile = (params: RemoveOutcomeFileReqParams) => {
+  return request.post<BaseResParams>({
+    url: Project.PROJECT_OUTCOME_REMOVE_FILE,
+    data: params
+  })
+}
+
+/**
+ * 获取上传任务交付物附件的url。
+ * @param params OUTCOMEUploadDocUrlResParams
+ * @returns
+ */
+export const getDownloadOutcomeUrl = (params: OutcomeDownloadUrlReqParams) => {
+  return request.post<OutcomeDownloadUrlResParams>({
+    url: Project.PROJECT_OUTCOME_DOWNLOAD,
+    data: params
+  })
+}

+ 208 - 0
src/api/project/plan-task.ts

@@ -0,0 +1,208 @@
+import { request } from '@/utils/request'
+import { Project } from '@/enums/api'
+import {
+  TaskListResParams,
+  TaskListReqParams,
+  AddTaskReqParams,
+  AddTaskResParams,
+  ModifyTaskReqParams,
+  TaskDetailResParams,
+  PlanDetailReqParams,
+  PlanDetailResParams,
+  RemoveTaskReqParams,
+  SortTaskReqParams,
+  CancelTaskReqParams,
+  CancelTaskResParams,
+  TaskDetailReqParams,
+  TaskLogsReqParams,
+  GetTaskFlowsResParams,
+  SetTaskFlowsReqParams,
+  GetPlanLogsReqParams,
+  GetPlanLogsResParams,
+  CrewTasksReqParams,
+  CrewTasksResParams,
+  CrewListResParams
+} from '@/types/plan-task'
+
+/**
+ * 获取计划任务列表
+ * @param params TaskListReqParams
+ */
+export const getPlanTaskList = (params: TaskListReqParams) => {
+  return request.post<TaskListResParams>({
+    url: Project.PROJECT_PLAN_TASK_LIST,
+    data: params
+  })
+}
+
+/**
+ * 添加计划任务信息
+ * @param params AddTaskReqParams
+ * @returns
+ */
+export const addPlanTask = (params: AddTaskReqParams) => {
+  return request.post<AddTaskResParams>({
+    url: Project.PROJECT_PLAN_TASK_ADD,
+    data: params
+  })
+}
+
+/**
+ * 获取计划详情
+ * @param params PlanDetailReqParams
+ * @returns
+ */
+export const getPlanDetail = (params: PlanDetailReqParams) => {
+  return request.post<PlanDetailResParams>({
+    url: Project.PROJECT_PLAN_DETAIL,
+    data: params
+  })
+}
+
+/**
+ * 修改计划任务信息
+ * @param params ModifyTaskReqParams
+ * @returns
+ */
+export const modifyPlanTask = (params: ModifyTaskReqParams) => {
+  return request.post<BaseResParams>({
+    url: Project.PROJECT_PLAN_TASK_MODIFY,
+    data: params
+  })
+}
+
+/**
+ * 删除计划任务信息
+ * @param id 计划任务编号
+ * @returns
+ */
+export const removePlanTask = (params: RemoveTaskReqParams) => {
+  return request.post<BaseResParams>({
+    url: Project.PROJECT_PLAN_TASK_REMOVE,
+    data: params
+  })
+}
+
+/**
+ * 调整任务项顺序
+ * @param params SortTaskReqParams
+ * @returns
+ */
+export const sortPlanTask = (params: SortTaskReqParams) => {
+  return request.post<BaseResParams>({
+    url: Project.PROJECT_PLAN_TASK_SORT,
+    data: params
+  })
+}
+
+/**
+ * 中止任务项
+ * @param params CancelTaskReqParams
+ * @returns
+ */
+export const cancelPlanTask = (params: CancelTaskReqParams) => {
+  return request.post<CancelTaskResParams>({
+    url: Project.PROJECT_PLAN_TASK_CANCEL,
+    data: params
+  })
+}
+
+/**
+ * 获取计划任务详情
+ * @param params TaskDetailReqParams
+ * @returns
+ */
+export const getPlanTaskDetail = (params: TaskDetailReqParams) => {
+  return request.post<TaskDetailResParams>({
+    url: Project.PROJECT_PLAN_TASK_DETAIL,
+    data: params
+  })
+}
+
+/**
+ * 获取项目计划中某个任务的执行动态,即日志。
+ * @param params TaskLogsReqParams
+ * @returns
+ */
+export const getPlanTaskLogs = (params: TaskLogsReqParams) => {
+  return request.post<TaskLogsReqParams>({
+    url: Project.PROJECT_PLAN_TASK_LOGS,
+    data: params
+  })
+}
+
+/**
+ * 获取项目计划中某个任务的执行动态,即日志。
+ * @param params TaskLogsReqParams
+ * @returns
+ */
+export const setPlanProgress = (params: {
+  task_id: string
+  progress: number
+}) => {
+  return request.post<BaseResParams>({
+    url: Project.PROJECT_PLAN_SET_PROGRESS,
+    data: params
+  })
+}
+
+/**
+ * 获取计划审核流程列表
+ * @param params
+ * @returns
+ */
+export const getPlanCheckFlow = (params: {
+  draft: boolean
+  task_id: string
+}) => {
+  return request.post<GetTaskFlowsResParams>({
+    url: Project.PROJECT_PLAN_GET_CHECK_FLOW,
+    data: params
+  })
+}
+
+/**
+ * 设置计划审核流程列表
+ * @param params
+ * @returns
+ */
+export const setPlanCheckFlow = (params: SetTaskFlowsReqParams) => {
+  return request.post<BaseResParams>({
+    url: Project.PROJECT_PLAN_SET_CHECK_FLOW,
+    data: params
+  })
+}
+
+/**
+ * 获取任务动态
+ * @param params
+ * @returns
+ */
+export const getPlanLogs = (params: GetPlanLogsReqParams) => {
+  return request.post<GetPlanLogsResParams>({
+    url: Project.PROJECT_PLAN_LOGS,
+    data: params
+  })
+}
+
+/**
+ * 获取组员任务列表
+ * @param params CrewTasksReqParams
+ */
+export const getCrewTaskList = (params: CrewTasksReqParams) => {
+  return request.post<CrewTasksResParams>({
+    url: Project.PROJECT_CREW_TASKS,
+    data: params
+  })
+}
+
+/**
+ * 获取组员任务列表
+ * @param params CrewTasksReqParams
+ */
+export const getCrewList = (keyword?: string) => {
+  return request.post<CrewListResParams>({
+    url: Project.PROJECT_CREW_LIST,
+    data: { keyword }
+  })
+}

+ 139 - 0
src/api/project/week-report.ts

@@ -0,0 +1,139 @@
+import { request } from '@/utils/request'
+import { Project } from '@/enums/api'
+import {
+  WeekReportListResParams,
+  WeekReportListReqParams,
+  AddWeekReportReqParams,
+  AddWeekReportResParams,
+  ModifyWeekReportReqParams,
+  WeekReportDetailResParams,
+  WeekReportDownloadFileUrlReqParams,
+  WeekReportDownloadFileUrlResParams,
+  WeekReportUploadFileUrlReqParams,
+  WeekReportUploadFileUrlResParams,
+  CommitWeekReportReqParams,
+  WithdrawWeekReportReqParams
+} from '@/types/week-report'
+
+/**
+ * 获取周报列表
+ * @param params WeekReportListReqParams
+ */
+export const getWeekReportList = (params: WeekReportListReqParams) => {
+  return request.post<WeekReportListResParams>({
+    url: Project.PROJECT_WEEK_REPORT_LIST,
+    data: params
+  })
+}
+
+/**
+ * 添加周报信息
+ * @param params AddWeekReportReqParams
+ * @returns
+ */
+export const addWeekReport = (params: AddWeekReportReqParams) => {
+  return request.post<AddWeekReportResParams>({
+    url: Project.PROJECT_WEEK_REPORT_ADD,
+    data: params
+  })
+}
+
+/**
+ * 修改周报信息
+ * @param params ModifyWeekReportReqParams
+ * @returns
+ */
+export const modifyWeekReport = (params: ModifyWeekReportReqParams) => {
+  return request.post<BaseResParams>({
+    url: Project.PROJECT_WEEK_REPORT_MODIFY,
+    data: params
+  })
+}
+
+/**
+ * 删除周报信息
+ * @param id 周报编号
+ * @returns
+ */
+export const removeWeekReport = (id: string) => {
+  return request.post<BaseResParams>({
+    url: Project.PROJECT_WEEK_REPORT_REMOVE,
+    data: { id }
+  })
+}
+
+/**
+ * 获取周报概览
+ * @param id 周报编号
+ * @returns
+ */
+export const getWeekReportDetail = (id: string) => {
+  return request.post<WeekReportDetailResParams>({
+    url: Project.PROJECT_WEEK_REPORT_DETAIL,
+    data: { id }
+  })
+}
+
+/**
+ * 获取上传周报附件的url。
+ * @param params WeekReportUploadFileUrlResParams
+ * @returns
+ */
+export const getUploadWeekReportFileUrl = (
+  params: WeekReportUploadFileUrlReqParams
+) => {
+  return request.post<WeekReportUploadFileUrlResParams>({
+    url: Project.PROJECT_WEEK_REPORT_UPLOAD_FILE,
+    data: params
+  })
+}
+
+/**
+ * 获取下载周报附件的url。
+ * @param params WeekReportDownloadFileUrlReqParams
+ * @returns
+ */
+export const getDownloadWeekReportFileUrl = (
+  params: WeekReportDownloadFileUrlReqParams
+) => {
+  return request.post<WeekReportDownloadFileUrlResParams>({
+    url: Project.PROJECT_WEEK_REPORT_DOWNLOAD_FILE,
+    data: params
+  })
+}
+
+/**
+ * 删除周报附件。
+ * @param params 周报附件id
+ * @returns
+ */
+export const removeReportFile = (id: string) => {
+  return request.post<BaseResParams>({
+    url: Project.PROJECT_WEEK_REPORT_REMOVE_FILE,
+    data: { id }
+  })
+}
+
+/**
+ * 创建后的项目周报,需要提交,才进入后续流程。
+ * @param params CommitWeekReportReqParams
+ * @returns
+ */
+export const commitWeekReportFile = (params: CommitWeekReportReqParams) => {
+  return request.post<BaseResParams>({
+    url: Project.PROJECT_WEEK_REPORT_COMMIT,
+    data: params
+  })
+}
+
+/**
+ * 提交后的项目周报,如还没有被审阅,可以撤回后重新修改。
+ * @param params WithdrawWeekReportReqParams
+ * @returns
+ */
+export const withdrawWeekReportFile = (params: WithdrawWeekReportReqParams) => {
+  return request.post<BaseResParams>({
+    url: Project.PROJECT_WEEK_REPORT_WITHDRAW,
+    data: params
+  })
+}

+ 126 - 0
src/api/project/work.ts

@@ -0,0 +1,126 @@
+import { request } from '@/utils/request'
+import { Project } from '@/enums/api'
+import {
+  WorkListReqParams,
+  WorkListResParams,
+  WorkDetailResParams,
+  WorkSubmitReqParams,
+  UploadWorkFileReqParams,
+  UploadWorkFileResParams,
+  WorkStatResParams,
+  WorkChainReqParams,
+  WorkChain
+} from '@/types/work'
+
+/**
+ * 获取我的任务列表
+ * @param params WorkListReqParams
+ * @returns
+ */
+export const getWorkList = (params: WorkListReqParams) => {
+  return request.post<WorkListResParams>({
+    url: Project.PROJECT_WORK_LIST,
+    data: params
+  })
+}
+
+/**
+ * 获取任务详情
+ * @param id id
+ * @returns
+ */
+export const getWorkDetail = (id: string) => {
+  return request.post<WorkDetailResParams>({
+    url: Project.PROJECT_WORK_DETAIL,
+    data: { id }
+  })
+}
+
+/**
+ * 提交我的待办任务信息
+ * @param parasm WorkSubmitReqParams
+ * @returns
+ */
+export const submitWork = (params: WorkSubmitReqParams) => {
+  return request.post<WorkDetailResParams>({
+    url: Project.PROJECT_WORK_SUBMIT,
+    data: params
+  })
+}
+
+/**
+ * 获取work表单提交上传文件url
+ * @param params
+ * @returns
+ */
+export const getUploadWorkFileUrl = (params: UploadWorkFileReqParams) => {
+  return request.post<UploadWorkFileResParams>({
+    url: Project.PROJECT_WORK_UPLOAD_FILE,
+    data: params
+  })
+}
+
+/**
+ * 获取工作统计信息
+ * @returns
+ */
+export const getWorkStat = () => {
+  return request.post<WorkStatResParams>({
+    url: Project.PROJECT_WORK_STAT,
+    data: {}
+  })
+}
+
+/**
+ * 获取工作统计信息
+ * @returns
+ */
+export const getWorkDoneStat = () => {
+  return request.post<WorkStatResParams>({
+    url: Project.PROJECT_WORK_DONE_STAT,
+    data: {}
+  })
+}
+
+/**
+ * 获取工作统计信息
+ * @returns
+ */
+export const getWorkUndoneStat = () => {
+  return request.post<WorkStatResParams>({
+    url: Project.PROJECT_WORK_UNDONE_STAT,
+    data: {}
+  })
+}
+
+/**
+ * 获取work表单提交上传文件url
+ * @param params
+ * @returns
+ */
+export const getDownloadWorkFileUrl = (params: { id: string }) => {
+  return request.post<UploadWorkFileResParams>({
+    url: Project.PROJECT_WORK_DOWNLOAD_FILE,
+    data: params
+  })
+}
+
+// 获取我的未完成工作数量
+export const getWorkUndoneCount = () => {
+  return request.post<{ undone_count: number }>({
+    url: Project.PROJECT_WORK_UNDONE_COUNT,
+    data: {}
+  })
+}
+
+/**
+ * 获取工作项链
+ * @param params
+ * @returns
+ */
+export const getWorkChain = (params: WorkChainReqParams) => {
+  return request.post<WorkChain[]>({
+    url: Project.PROJECT_WORK_GET_CHAIN,
+    data: params
+  })
+}

binární
src/assets/images/bg.png


binární
src/assets/images/login-banner.png


binární
src/assets/images/logo.png


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
src/assets/images/logo1.svg


+ 12 - 0
src/assets/logo.svg

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

+ 19 - 0
src/assets/style/breakpoint.less

@@ -0,0 +1,19 @@
+// ==============breakpoint============
+
+// Extra small screen / phone
+@screen-xs: 480px;
+
+// Small screen / tablet
+@screen-sm: 576px;
+
+// Medium screen / desktop
+@screen-md: 768px;
+
+// Large screen / wide desktop
+@screen-lg: 992px;
+
+// Extra large screen / full hd
+@screen-xl: 1200px;
+
+// Extra extra large screen / large desktop
+@screen-xxl: 1600px;

+ 273 - 0
src/assets/style/global.less

@@ -0,0 +1,273 @@
+* {
+  box-sizing: border-box;
+}
+
+html,
+body {
+  width: 100%;
+  height: 100%;
+  margin: 0;
+  padding: 0;
+  font-size: 14px;
+  background-color: var(--color-bg-1);
+  -moz-osx-font-smoothing: grayscale;
+  -webkit-font-smoothing: antialiased;
+
+  ::-webkit-scrollbar {
+    width: 12px;
+    height: 4px;
+  }
+
+  ::-webkit-scrollbar-thumb {
+    border: 4px solid transparent;
+    background-clip: padding-box;
+    border-radius: 7px;
+    background-color: var(--color-fill-3);
+  }
+}
+
+.echarts-tooltip-diy {
+  background: linear-gradient(304.17deg,
+      rgba(253, 254, 255, 0.6) -6.04%,
+      rgba(244, 247, 252, 0.6) 85.2%) !important;
+  border: none !important;
+  backdrop-filter: blur(10px) !important;
+  /* Note: backdrop-filter has minimal browser support */
+
+  border-radius: 6px !important;
+
+  .content-panel {
+    display: flex;
+    justify-content: space-between;
+    padding: 0 9px;
+    background: rgba(255, 255, 255, 0.8);
+    width: auto;
+    height: 32px;
+    line-height: 32px;
+    box-shadow: 6px 0px 20px rgba(34, 87, 188, 0.1);
+    border-radius: 4px;
+    margin-bottom: 4px;
+  }
+
+  .tooltip-title {
+    margin: 0 0 10px 0;
+  }
+
+  p {
+    margin: 0;
+  }
+
+  .tooltip-title,
+  .tooltip-value {
+    font-size: 13px;
+    line-height: 15px;
+    margin-left: 10px;
+    display: flex;
+    align-items: center;
+    text-align: right;
+    color: #1d2129;
+    font-weight: bold;
+  }
+
+  .tooltip-item-icon {
+    display: inline-block;
+    margin-right: 8px;
+    width: 10px;
+    height: 10px;
+    border-radius: 50%;
+  }
+}
+
+.general-card {
+  border-radius: 4px;
+  border: none;
+
+  &>.arco-card-header {
+    height: auto;
+    padding: 20px;
+    border: none;
+  }
+
+  &>.arco-card-body {
+    padding: 0 20px 20px 20px;
+  }
+
+  .mb0 {
+    margin-bottom: 0;
+  }
+}
+
+.split-line {
+  border-color: rgb(var(--gray-2));
+}
+
+.arco-table-cell {
+  .circle {
+    display: inline-block;
+    margin-right: 4px;
+    width: 6px;
+    height: 6px;
+    border-radius: 50%;
+    background-color: rgb(var(--blue-6));
+
+    &.pass {
+      background-color: rgb(var(--green-6));
+    }
+  }
+
+  .arco-btn-icon {
+    font-size: 16px;
+  }
+
+  .operate-text-button {
+     position: relative;
+    .arco-btn-text,
+    .arco-btn-text[type='button'],
+    .arco-btn-text[type='submit'] {
+      padding: 0 6px;
+    }
+
+    .arco-divider-vertical {
+      margin: 5px 2px;
+    }
+  }
+}
+
+.tree-select-scrollbar {
+  // min-height: 220px;
+  padding-bottom: 10px;
+}
+
+.arco-transfer-view {
+  width: 300px;
+  height: 300px;
+
+  .arco-transfer-list-item {
+    margin: 10px;
+    border: var(--color-border-1) 1px solid
+  }
+}
+
+.arco-btn-size-medium:not(.arco-btn-only-icon) .arco-btn-icon {
+  margin-right: 2px !important
+}
+
+.arco-btn+.arco-btn {
+  margin-left: 8px;
+}
+
+.arco-btn-group .arco-btn+.arco-btn {
+  margin-left: 0px;
+}
+
+//   }
+// }
+
+.editor-descriptions-container {
+  display: flex;
+  align-items: center;
+  position: relative;
+  width:100%;
+
+  .editor-descriptions-label {
+    display: flex;
+    color: rgb(var(--gray-8));
+    color: rgb(var(--gray-8));
+    padding-right: 10px;
+    font-weight: bold;
+    // white-space: nowrap;
+    min-width: 110px;
+    justify-content: end;
+  }
+
+  .editor-descriptions-content {
+    display: flex;
+    align-items: center;
+    color: var(--color-text-1);
+    font-weight: 400;
+    // white-space: nowrap;
+    // word-break: break-word;
+    position: relative;
+    max-width: calc(100% - 110px);
+    flex: 1 1 auto;
+    height: 30px;
+    // border:1px solid
+    // overflow-x: hidden;
+    // text-overflow: ellipsis;
+  }
+
+}
+.ribbon-wrapper {
+  width: 71px;
+  height: 51px;
+  overflow: hidden;
+  position: absolute;
+  top: -9px;
+  right: -16px;
+}
+
+.ribbon {
+  font: bold 15px Sans-Serif;
+  line-height: 12px;
+  font-size: 12px;
+  text-align: center;
+  text-transform: uppercase;
+  -webkit-transform: rotate(45deg);
+  -moz-transform: rotate(45deg);
+  -ms-transform: rotate(45deg);
+  -o-transform: rotate(45deg);
+  position: relative;
+  padding: 3px 0;
+  left: 20px;
+  top: 10px;
+  // background-color: #165dff;
+  color: #fff;
+  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+  letter-spacing: 0.5px;
+  box-shadow: -3px 5px 6px -5px rgba(0, 0, 0, 0.5);
+}
+
+.ribbon:before,
+.ribbon:after {
+  content: '';
+  // border-top: 4px solid #4e7c7d;
+  border-left: 4px solid transparent;
+  border-right: 4px solid transparent;
+  position: absolute;
+  bottom: -4px;
+}
+
+.ribbon:before {
+  content: '';
+  position: absolute;
+  left: 0px;
+  top: 100%;
+  // z-index: -1;
+  // border-left: 4px solid #4e7c7d;
+}
+
+.ribbon:after {
+  content: '';
+  position: absolute;
+  right: 0px;
+  top: 100%;
+  z-index: -1;
+}
+.ribbon-add {
+  background-color: rgb(var(--success-6));
+}
+.ribbon-modify {
+  background-color: rgb(var(--warning-6));
+}
+.ribbon-remove {
+  background-color: rgb(var(--danger-6));
+}
+.ribbon-sub-add {
+  background-color: rgb(var(--success-1));
+}
+.ribbon-sub-modify {
+  background-color: rgb(var(--warning-1));
+}
+.ribbon-sub-remove {
+  background-color: rgb(var(--danger-1));
+}

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
src/assets/world.json


+ 89 - 0
src/components/breadcrumb/index.vue

@@ -0,0 +1,89 @@
+<template>
+  <div :style="{ background: 'var(--color-fill-2)' }">
+    <a-page-header
+      :style="{ background: 'var(--color-bg-2)' }"
+      :title="title"
+      :subtitle="subtitle"
+      :show-back="showBack"
+      @back="goBack()"
+    >
+      <template #title>
+        <slot name="title"></slot>
+      </template>
+      <template #breadcrumb>
+        <a-breadcrumb v-if="items.length > 0" class="container-breadcrumb">
+          <a-breadcrumb-item> 首页 </a-breadcrumb-item>
+          <a-breadcrumb-item v-for="item in items" :key="item">
+            {{ item }}
+          </a-breadcrumb-item>
+        </a-breadcrumb>
+      </template>
+      <template #extra>
+        <slot name="extra"></slot>
+      </template>
+      <slot></slot>
+    </a-page-header>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { PropType } from 'vue'
+import { useRouter } from 'vue-router'
+
+const router = useRouter()
+defineProps({
+  items: {
+    type: Array as PropType<string[]>,
+    default() {
+      return []
+    }
+  },
+  title: {
+    type: String
+  },
+  subtitle: {
+    type: String,
+    required: false
+  },
+  showBack: {
+    type: Boolean,
+    default() {
+      return false
+    }
+  }
+})
+
+const goBack = () => {
+  router.go(-1)
+}
+</script>
+
+<style scoped lang="less">
+.container-breadcrumb {
+  margin: 10px 0;
+  :deep(.arco-breadcrumb-item) {
+    color: rgb(var(--gray-6));
+    &:last-child {
+      color: rgb(var(--gray-8));
+    }
+  }
+}
+:deep(.arco-page-header-with-breadcrumb) {
+  padding: 12px 0 0 0;
+}
+:deep(.arco-page-header-content) {
+  padding: 0;
+  border: none;
+  .arco-tabs {
+    padding-top: 10px;
+    padding-left: 5px;
+
+    .arco-tabs-nav-type-line .arco-tabs-tab {
+      margin: 0 20px;
+    }
+  }
+}
+:deep(.arco-page-header-title) {
+  font-size: 18px;
+}
+</style>

+ 47 - 0
src/components/chart/index.vue

@@ -0,0 +1,47 @@
+<template>
+  <VCharts
+    v-if="renderChart"
+    :option="options"
+    :autoresize="autoResize"
+    :style="{ width, height }"
+  />
+</template>
+
+<script lang="ts" setup>
+import { ref, nextTick } from 'vue'
+import VCharts from 'vue-echarts'
+// import { useAppStore } from '@/store';
+
+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 theme = computed(() => {
+//   if (appStore.theme === 'dark') return 'dark';
+//   return '';
+// });
+const renderChart = ref(false)
+// wait container expand
+nextTick(() => {
+  renderChart.value = true
+})
+</script>
+
+<style scoped lang="less"></style>

+ 52 - 0
src/components/custom-schema/index.vue

@@ -0,0 +1,52 @@
+<template>
+  <div class="mb-10">
+    <a-tabs :default-active-key="keys[0]">
+      <a-tab-pane
+        v-for="key in keys"
+        :key="key"
+        :title="props.schema.properties[key].title"
+      >
+        <a-form :model="form" :auto-label-width="true" label-align="right">
+          <DynamicForm
+            :parent="`formData.${key}`"
+            :cur-node-path="key"
+            :form-data="form.formData[key]"
+            :schema="props.schema.properties[key]"
+          ></DynamicForm>
+        </a-form>
+      </a-tab-pane>
+    </a-tabs>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import DynamicForm from '@/components/dynamicForm/index.vue'
+import { computed, reactive } from 'vue'
+import { vueUtils } from '@/components/form'
+import { orderProperties } from '@/utils'
+
+interface OptionsProps {
+  rootFormData: object
+  curNodePath: string
+  schema: { properties: { [key: string]: any }; [key: string]: any }
+  uiSchema: object
+}
+
+const props = defineProps<OptionsProps>()
+const keys = computed(() => {
+  return orderProperties(
+    Object.keys(props.schema?.properties),
+    props.schema['ui:order']
+  )
+})
+
+const form = reactive<{ [key: string]: any }>({
+  formData: vueUtils.getPathVal(props.rootFormData, props.curNodePath)
+})
+</script>
+
+<style scoped lang="less">
+.mb-10 {
+  margin-bottom: 10px;
+}
+</style>

+ 147 - 0
src/components/dynamicForm/Download.vue

@@ -0,0 +1,147 @@
+<template>
+  <!-- <a-space> -->
+  <!-- <a-upload
+    v-show="!showUploadProgress"
+    v-model:file-list="fileList"
+    v-bind="getBindValue"
+    accept=".doc, .docx, .pdf"
+    :auto-upload="false"
+    :show-file-list="true"
+    :show-upload-button="{
+      showOnExceedLimit: false
+    }"
+    :custom-icon="getCustomIcon()"
+    :on-before-upload="file => uploadocFileHandler(file)"
+    :on-before-remove="fileitem => removeFileHandler(fileitem)"
+  >
+  </a-upload>
+  <a-progress v-show="showUploadProgress" :percent="currentProgress / 100" /> -->
+  <!-- </a-space> -->
+  <div>
+    <a-space direction="vertical">
+      <template v-for="item in props.default" :key="item?.id">
+        <div class="download-container">
+          <div class="file-container">
+            <div class="filename">
+              {{ item.name }}
+            </div>
+          </div>
+          <div style="margin-left: 10px">
+            <a-tooltip content="下载文件">
+              <a-typography-text
+                style="cursor: pointer"
+                type="primary"
+                @click="downloadWorkFile(item)"
+              >
+                <icon-download :size="19" />
+              </a-typography-text>
+            </a-tooltip>
+          </div>
+        </div>
+        <div v-if="item.showDownloadProgress" class="download-cover">
+          <a-progress :percent="item.downloadProgress! / 100" />
+        </div>
+      </template>
+    </a-space>
+    <!-- <a-progress v-show="showUploadProgress" :percent="currentProgress / 100" /> -->
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { downloadFile } from '@/api/client'
+import { getDownloadWorkFileUrl } from '@/api/project/work'
+import { Message } from '@arco-design/web-vue'
+import { PropType } from 'vue'
+
+const props = defineProps({
+  modelValue: {
+    type: Array as PropType<string[]>
+  },
+  default: {
+    type: Array as PropType<
+      {
+        id: string
+        name: string
+        showDownloadProgress?: boolean
+        downloadProgress?: number
+      }[]
+    >
+  }
+})
+
+// 获取上传进度信息
+const getDownloadProgress = (
+  e: ProgressEvent,
+  item: {
+    id: string
+    name: string
+    showDownloadProgress?: boolean
+    downloadProgress?: number
+  }
+) => {
+  item.showDownloadProgress = true
+  item.downloadProgress = Number(((e.loaded / e.total) * 100).toFixed(0))
+}
+
+const downloadWorkFile = async (item: {
+  id: string
+  name: string
+  showDownloadProgress?: boolean
+  downloadProgress?: number
+}) => {
+  try {
+    const { url } = await getDownloadWorkFileUrl({ id: item.id })
+    // const downloadUrl = url.replace(/https:\/\/pmr\.surkw\.com:29000\//, '')
+    await downloadFile(item.name!, url, getDownloadProgress, item)
+    item.showDownloadProgress = false
+    item.downloadProgress = 0
+    Message.success('下载成功')
+  } catch (e) {
+    item.showDownloadProgress = false
+    item.downloadProgress = 0
+  }
+}
+</script>
+
+<style lang="less" scoped>
+:deep(.arco-upload-progress) {
+  display: none;
+}
+.download-container {
+  position: relative;
+  display: flex;
+  align-items: center;
+  box-sizing: border-box;
+  .file-container {
+    display: flex;
+    // flex: 1;
+    // flex-wrap: nowrap;
+    align-items: center;
+    // box-sizing: border-box;
+    overflow: hidden;
+    font-size: 14px;
+    border-radius: var(--border-radius-small);
+    transition: background-color 0.1s cubic-bezier(0, 0, 1, 1);
+    .filename {
+      //   display: flex;
+      padding: 8px 0px 8px 10px;
+      flex: 1;
+      width: 300px;
+      background-color: var(--color-fill-1);
+      align-items: center;
+      //   margin-right: 10px;
+      font-size: 13px;
+      overflow: hidden;
+      color: var(--color-text-1);
+      font-size: 14px;
+      line-height: 1.4286;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+    }
+    .file-extra {
+      margin-left: 20px;
+      color: var(--color-text-2);
+    }
+  }
+}
+</style>

+ 40 - 0
src/components/dynamicForm/Object.vue

@@ -0,0 +1,40 @@
+<template>
+  <a-divider orientation="center">{{ props.fields.title }}</a-divider>
+  <DynamicForm
+    :parent="`${parent}.${curNodePath}`"
+    :cur-node-path="curNodePath"
+    :form-data="formData"
+    :schema="fields"
+  ></DynamicForm>
+</template>
+
+<script lang="ts" setup>
+import { PropType } from 'vue'
+import DynamicForm from '@/components/dynamicForm/index.vue'
+
+const props = defineProps({
+  rootFormData: {
+    type: Object as PropType<object>
+  },
+  curNodePath: {
+    type: String
+  },
+  fields: {
+    type: Object as PropType<any>,
+    default: () => {
+      return {}
+    }
+  },
+  placeholder: {
+    type: String
+  },
+  formData: {
+    type: Array as PropType<any[]>,
+    default: () => []
+  },
+  parent: {
+    type: String,
+    default: () => 'formData'
+  }
+})
+</script>

+ 27 - 0
src/components/dynamicForm/Select.vue

@@ -0,0 +1,27 @@
+<template>
+  <a-select v-bind="getBindValue" :options="options"> </a-select>
+</template>
+
+<script lang="ts" setup>
+import { SelectOptionData } from '@arco-design/web-vue/es/select/interface'
+import { computed, useAttrs, PropType } from 'vue'
+
+const props = defineProps({
+  options: {
+    type: Array as PropType<SelectOptionData[]>,
+    default: () => []
+  },
+  placeholder: {
+    type: String
+  },
+  readyonly: {
+    type: Boolean,
+    default: () => false
+  }
+})
+const getBindValue = computed(() => {
+  const attrs = useAttrs()
+  const obj = { ...attrs, ...props }
+  return obj
+})
+</script>

+ 411 - 0
src/components/dynamicForm/Table.vue

@@ -0,0 +1,411 @@
+<template>
+  <div>
+    <a-space
+      align="center"
+      style="justify-content: space-between; width: 100%; margin-top: 10px"
+    >
+      <h3 style="color: var(--color-text-2)">
+        {{ props.fields.title
+        }}<span
+          style="margin: 10px 10px; color: var(--color-text-3); font-size: 12px"
+          >{{ props.fields.description }}</span
+        >
+      </h3>
+      <a-button long type="dashed" @click="showAddFormData()">
+        <template #icon> <icon-plus /> </template
+        >{{ props.fields.title }}</a-button
+      >
+    </a-space>
+
+    <a-table
+      :columns="tableColumnsData"
+      :data="tableData"
+      :pagination="pagination"
+      class="modalTable"
+      style="margin: 10px 0"
+      table-layout-fixed
+      @page-change="pageChangeHandler"
+    >
+      <template
+        v-for="key in orderProperties(
+          Object.keys(props.fields.items.properties),
+          props.fields.items['ui:order']
+        )"
+        :key="key"
+        #[key]="{ record }"
+      >
+        <component
+          :is="getFieldComponent(props.fields.items.properties[key])"
+          v-model="record[key]"
+        ></component>
+      </template>
+
+      <template #empty>
+        <span
+          style="margin: 10px 10px; color: var(--color-text-3); font-size: 12px"
+          >暂无数据</span
+        >
+      </template>
+      <template #operator="record">
+        <a-button
+          type="text"
+          status="success"
+          title="删除信息"
+          size="small"
+          @click="modifyData(record)"
+        >
+          <template #icon>
+            <icon-edit :size="18" />
+          </template>
+        </a-button>
+        <a-button
+          type="text"
+          status="danger"
+          title="删除信息"
+          size="small"
+          @click="removeData(record)"
+        >
+          <template #icon>
+            <icon-delete :size="18" />
+          </template>
+        </a-button>
+        <a-button
+          type="text"
+          title="复制信息"
+          size="small"
+          @click="copyData(record)"
+        >
+          <template #icon>
+            <icon-copy :size="18" />
+          </template>
+        </a-button>
+      </template>
+    </a-table>
+    <vModal
+      v-model:visible="visibleModal"
+      :title="modalTitle"
+      :width="600"
+      @modal-handle-ok="modelHandleOk"
+      @modal-handle-cancel="modelHandleCancel"
+    >
+      <a-form :model="form" :auto-label-width="true" label-align="right">
+        <DynamicForm
+          :cur-node-path="props.curNodePath"
+          :form-data="form.formData"
+          :schema="form.schema"
+        ></DynamicForm>
+      </a-form>
+    </vModal>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import {
+  PaginationProps,
+  TableColumnData,
+  Input,
+  InputNumber,
+  Switch,
+  TimePicker,
+  Textarea,
+  DatePicker,
+  MonthPicker,
+  YearPicker
+} from '@arco-design/web-vue'
+import { ref, computed, PropType, onMounted, reactive, watch, h } from 'vue'
+import { orderProperties } from '@/utils/index'
+import DynamicForm from '@/components/dynamicForm/index.vue'
+import vModal from '@/components/modal/index.vue'
+import { useEmitt } from '@/logics/mitt/useEmitt'
+
+import CustomSelect from './Select.vue'
+
+const pagination = reactive<PaginationProps>({
+  showTotal: false,
+  current: 1,
+  pageSize: 10
+})
+const modalType = ref<number>(0)
+const rowIndex = ref<number>(0)
+const { emitter } = useEmitt()
+const props = defineProps({
+  rootFormData: {
+    type: Object as PropType<object>
+  },
+  curNodePath: {
+    type: String
+  },
+  fields: {
+    type: Object as PropType<any>,
+    default: () => {
+      return {}
+    }
+  },
+  placeholder: {
+    type: String
+  },
+  formData: {
+    type: Array as PropType<any[]>,
+    default: () => []
+  }
+})
+
+const form = reactive<{
+  formData: { [key: string]: any }
+  schema: { [key: string]: any }
+}>({
+  formData: {},
+  schema: {}
+})
+
+const tableData = reactive<any[]>([]) // 设备列表
+const tableColumnsData = ref<TableColumnData[]>([])
+const type = computed(() => {
+  return props.fields.items.type
+})
+const visibleModal = ref<boolean>(false)
+const selectedKeys = ref<string[]>([])
+const modalTitle = ref<string>('')
+
+const pageChangeHandler = (current: number) => {
+  pagination.current = current
+}
+
+const getTableColumnsData = (): TableColumnData[] => {
+  const { items } = props.fields
+  if (type.value === 'string') {
+    return [
+      {
+        align: 'center',
+        title: props.fields.title,
+        dataIndex: 'key',
+        bodyCellClass: 'miniCellPadding'
+      }
+    ]
+  }
+  const keys = orderProperties(Object.keys(items.properties), items['ui:order'])
+  return keys.map((item: string) => {
+    return {
+      align: 'left',
+      title: items.properties[item].title,
+      slotName: item,
+      ellipsis: true,
+      tooltip: true,
+      dataIndex: item,
+      bodyCellClass: 'miniCellPadding'
+    }
+  })
+}
+
+/**
+ * 显示添加form data
+ */
+const showAddFormData = () => {
+  modalTitle.value = props.fields.title
+  if (props.fields.items.type === 'object') {
+    form.schema = props.fields.items
+  } else {
+    const object: { [key: string]: any } = { ...props.fields }
+    object.type = props.fields.items.type
+    const properties: { [key: string]: any } = {}
+    properties.key = object
+    form.schema = {
+      title: props.fields.title,
+      required: ['key'],
+      properties
+    }
+  }
+  modalType.value = 0
+  visibleModal.value = true
+}
+
+const modifyData = (data: any) => {
+  rowIndex.value = data.rowIndex
+  modalTitle.value = props.fields.title
+  if (props.fields.items.type === 'object') {
+    form.schema = props.fields.items
+  } else {
+    const object: { [key: string]: any } = { ...props.fields }
+    object.type = props.fields.items.type
+    const properties: { [key: string]: any } = {}
+    properties.key = object
+    form.schema = {
+      title: props.fields.title,
+      required: ['key'],
+      properties
+    }
+  }
+  modalType.value = 1
+  form.formData = data.record
+  visibleModal.value = true
+}
+
+// 删除列表数据
+const removeData = (data: any) => {
+  const index = (pagination.current! - 1) * pagination.pageSize! + data.rowIndex
+  tableData.splice(index, 1)
+}
+
+// 复制列表数据
+const copyData = (data: any) => {
+  const { record } = data
+  // console.log(record)
+  // const index = (pagination.current! - 1) * pagination.pageSize! + data.rowIndex
+  tableData.push({ ...record })
+  const len = tableData.length
+  const current = Math.ceil(len / 10)
+  pagination.current = current
+}
+
+/**
+ * 隐藏model 对话框,重置表单
+ */
+const modelHandleCancel = () => {
+  form.formData = {}
+}
+
+/**
+ * 点击确认增加list
+ * @param cb
+ */
+const modelHandleOk = async (cb: (closed: boolean) => void) => {
+  if (modalType.value === 0) {
+    tableData.push(form.formData)
+  } else {
+    const index = rowIndex.value
+    tableData[index] = form.formData
+  }
+  return cb(true)
+}
+
+const getFieldComponent = (field: { [key: string]: any }): any => {
+  if (field.enum) {
+    return h(CustomSelect, {
+      options: field.enum.map((item: any) => {
+        return {
+          key: item.value || item,
+          value: item.value || item,
+          label: `${item}`
+        }
+      }),
+      multiple: field.multiple,
+      placeholder: `请输入${field.title}`
+    })
+  }
+  switch (field.type) {
+    case 'string':
+      switch (field.format) {
+        case 'time':
+          return h(TimePicker, { placeholder: `请选择${field.title}` })
+        case 'date':
+          return h(DatePicker, {
+            placeholder: `请选择${field.title}`
+          })
+        case 'datetime':
+          return h(DatePicker, { showTime: true })
+        case 'month':
+          return h(MonthPicker, {
+            placeholder: `请选择${field.title}`
+          })
+        case 'year':
+          return h(YearPicker, {
+            placeholder: `请选择${field.title}`
+          })
+        case 'textarea':
+          return h(Textarea, {
+            placeholder: `请输入${field.title}`
+          })
+        default:
+          return h(Input, {
+            placeholder: `请输入${field.title}`,
+            allowClear: true
+          })
+      }
+
+    case 'integer':
+    case 'number':
+      return h(InputNumber, {
+        precision: field.type === 'integer' ? 0 : 2,
+        min: field.minimum,
+        max: field.maximum,
+        placeholder: `请输入${field.title}`
+      })
+    case 'boolean':
+      return Switch
+    default:
+      return null
+  }
+}
+
+watch(
+  () => tableData,
+  value => {
+    emitter.emit('setCustomeFormData', {
+      rootFormData: props.rootFormData!,
+      curNodePath: props.curNodePath!,
+      value:
+        type.value === 'object'
+          ? value
+          : value.map(item => {
+              return item.key
+            })
+    })
+  },
+  { deep: true }
+)
+
+onMounted(() => {
+  const operator: TableColumnData[] = [
+    {
+      title: '操作',
+      align: 'center',
+      slotName: 'operator',
+      width: 100,
+      fixed: 'right',
+      bodyCellClass: 'miniCellPadding'
+    }
+  ]
+  tableColumnsData.value = getTableColumnsData()
+  tableColumnsData.value = tableColumnsData.value.concat(operator)
+  // console.log(props.formData)
+  if (props.formData?.length > 0) {
+    if (type.value === 'object') {
+      tableData.push(...props.formData)
+    } else {
+      const list = {
+        ...props.formData.map(item => {
+          return { key: item }
+        })
+      }
+      tableData.push(...list)
+    }
+  }
+})
+</script>
+
+<style scoped lang="less">
+:deep(.arco-table-th) {
+  &:last-child {
+    .arco-table-th-item-title {
+      margin-left: 16px;
+    }
+  }
+}
+:deep(.arco-table-td-content) {
+  .arco-checkbox {
+    padding-left: 0;
+  }
+}
+.modalTable {
+  .arco-btn + .arco-btn {
+    margin-left: 0px;
+  }
+
+  // }
+}
+:deep(.miniCellPadding) {
+  .arco-table-cell {
+    padding: 6px 2px 6px 3px;
+  }
+}
+</style>

+ 135 - 0
src/components/dynamicForm/Upload.vue

@@ -0,0 +1,135 @@
+<template>
+  <!-- <a-space> -->
+  <a-upload
+    v-show="!showUploadProgress"
+    v-model:file-list="fileList"
+    v-bind="getBindValue"
+    accept=".doc, .docx, .pdf"
+    :auto-upload="false"
+    :show-file-list="true"
+    :show-upload-button="{
+      showOnExceedLimit: false
+    }"
+    :custom-icon="getCustomIcon()"
+    :on-before-upload="file => uploadocFileHandler(file)"
+    :on-before-remove="fileitem => removeFileHandler(fileitem)"
+  >
+  </a-upload>
+  <a-progress v-show="showUploadProgress" :percent="currentProgress / 100" />
+  <!-- </a-space> -->
+</template>
+
+<script lang="ts" setup>
+import { uploadFile } from '@/api/client'
+import { getUploadWorkFileUrl } from '@/api/project/work'
+import { FileItem } from '@arco-design/web-vue'
+import IconDelete from '@arco-design/web-vue/es/icon/icon-delete'
+import { computed, useAttrs, h, ref, PropType } from 'vue'
+
+const fileList = ref<FileItem[]>([])
+const objectNameList = ref<{ id: string; name: string }[]>([])
+const currentProgress = ref(0) // 控制进度条
+const showUploadProgress = ref(false)
+
+const emits = defineEmits(['update:modelValue'])
+const getCustomIcon = () => {
+  return {
+    removeIcon: () => h(IconDelete, { size: 20, style: { color: 'red' } }),
+    fileName: (file: any) => {
+      return `${file.name}`
+    }
+  }
+}
+
+const props = defineProps({
+  modelValue: {
+    type: Array as PropType<string[]>
+  },
+  workId: {
+    type: String
+  },
+  accept: {
+    type: String
+  },
+  limit: {
+    type: Number,
+    default: () => 0
+  }
+})
+
+const getBindValue = computed(() => {
+  const attrs = useAttrs()
+  const obj = { ...attrs, ...props }
+  return obj
+})
+
+/**
+ *
+ * @param url
+ * @param data
+ * @param file
+ */
+const uploadWorkFile = async (
+  url: string,
+  data: { [key: string]: any },
+  file: File
+) => {
+  await uploadFile(
+    url,
+    {
+      data,
+      file
+    },
+    e => {
+      currentProgress.value = Number(((e.loaded / e.total) * 100).toFixed(0))
+    }, // 调用进度更新函数
+    1000 * 60 * 60
+  )
+}
+
+// 上传项目文档
+const uploadocFileHandler = async (
+  currentFile: File
+): Promise<boolean | File> => {
+  try {
+    const {
+      url,
+      form_data: formData,
+      object_name: id
+    } = await getUploadWorkFileUrl({
+      work_id: props.workId!,
+      filename: currentFile.name
+    })
+    showUploadProgress.value = true
+    await uploadWorkFile(url, formData, currentFile)
+    currentProgress.value = 0
+    objectNameList.value.push({
+      id,
+      name: currentFile.name
+    })
+    emits('update:modelValue', objectNameList.value)
+    showUploadProgress.value = false
+    return true
+  } catch (_e) {
+    currentProgress.value = 0
+    showUploadProgress.value = false
+    return false
+  }
+}
+
+/**
+ * 删除文件触发
+ * @param currentFile 当然删除文件
+ */
+const removeFileHandler = async (currentFile: FileItem): Promise<boolean> => {
+  const index = fileList.value.findIndex(item => item.uid === currentFile.uid)
+  objectNameList.value.splice(index, 1)
+  return true
+}
+</script>
+
+<style lang="less" scoped>
+:deep(.arco-upload-progress) {
+  display: none;
+}
+</style>

+ 151 - 0
src/components/dynamicForm/index.vue

@@ -0,0 +1,151 @@
+<template>
+  <template v-for="(field, index) in keys" :key="index">
+    <a-form-item
+      v-if="!props.schema?.properties[field]['ui:hidden'] && props.schema!.properties[field].type!=='array' && props.schema!.properties[field]['ui:field']!=='CustomObjectSchema'"
+      :validate-trigger="['blur', 'change', 'focus', 'input']"
+      :label="props.schema!.properties[field].title"
+      :field="`${field}`"
+      :required="getRequired(props.schema?.required, field)"
+      :rules="[{ required: getRequired(props.schema?.required, field), message: `${props.schema!.properties[field].title}不能为空`}]"
+    >
+      <template #extra>
+        <span>{{ props.schema!.properties[field].description }}</span>
+      </template>
+      <component
+        :is="getFieldComponent(props.schema!.properties[field])"
+        v-model="props.formData![field]"
+      ></component>
+    </a-form-item>
+  </template>
+</template>
+
+<script setup lang="ts">
+import { h, computed, PropType } from 'vue'
+import {
+  Input,
+  InputNumber,
+  Switch,
+  TimePicker,
+  Textarea,
+  DatePicker,
+  MonthPicker,
+  YearPicker
+} from '@arco-design/web-vue'
+import { orderProperties } from '@/utils'
+import CustomSelect from './Select.vue'
+import Upload from './Upload.vue'
+import Download from './Download.vue'
+// 状态下拉框列表枚举值
+
+const props = defineProps({
+  workId: {
+    type: String
+  },
+  formData: {
+    type: Object as PropType<{ [key: string]: any }>
+  },
+  schema: {
+    type: Object as PropType<{ [key: string]: any }>,
+    default: () => {
+      return {}
+    }
+  }
+})
+
+const keys = computed(() => {
+  return orderProperties(
+    Object.keys(props.schema?.properties),
+    props.schema['ui:order']
+  )
+})
+
+const getRequired = (fields: string[], field: string) => {
+  if (!fields) {
+    return false
+  }
+  return fields.includes(field)
+}
+
+const getFieldComponent = (field: { [key: string]: any }): any => {
+  if (field.enum) {
+    return h(CustomSelect, {
+      options: field.enum.map((item: any, index: number) => {
+        return {
+          key: item.value || item,
+          value: item.value || item,
+          label: field.enumNames[index] || item
+        }
+      }),
+      multiple: field.multiple,
+      readonly: field.readonly,
+      placeholder: `请输入${field.title}`
+    })
+  }
+  switch (field.type) {
+    case 'string':
+      switch (field.format) {
+        case 'time':
+          return h(TimePicker, {
+            placeholder: `请选择${field.title}`,
+            readonly: field.readonly
+          })
+        case 'date':
+          return h(DatePicker, {
+            placeholder: `请选择${field.title}`,
+            readonly: field.readonly
+          })
+        case 'datetime':
+          return h(DatePicker, { showTime: true })
+        case 'month':
+          return h(MonthPicker, {
+            placeholder: `请选择${field.title}`,
+            readonly: field.readonly
+          })
+        case 'year':
+          return h(YearPicker, {
+            placeholder: `请选择${field.title}`,
+            readonly: field.readonly
+          })
+        case 'textarea':
+          return h(Textarea, {
+            autoSize: {
+              minRows: field.minRows || 10
+            },
+            placeholder: `请输入${field.title}`,
+            readonly: field.readonly
+          })
+
+        default:
+          return h(Input, {
+            placeholder: `请输入${field.title}`,
+            allowClear: true,
+            readonly: field.readonly
+          })
+      }
+    case 'upload':
+      return h(Upload, {
+        accept: field.accept,
+        workId: props.workId,
+        limit: field.limitCount || 0
+      })
+    case 'download':
+      return h(Download, {
+        default: field.default || []
+      })
+    case 'integer':
+    case 'number':
+      return h(InputNumber, {
+        precision: field.type === 'integer' ? 0 : 2,
+        min: field.minimum,
+        max: field.maximum,
+        allowClear: true,
+        readonly: field.readonly,
+        placeholder: `请输入${field.title}`
+      })
+    case 'boolean':
+      return Switch
+    default:
+      return null
+  }
+}
+</script>

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

@@ -0,0 +1,16 @@
+<template>
+  <a-layout-footer class="footer"></a-layout-footer>
+</template>
+
+<script lang="ts" setup></script>
+
+<style lang="less" scoped>
+.footer {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 40px;
+  color: var(--color-text-2);
+  text-align: center;
+}
+</style>

+ 66 - 0
src/components/form/package.json

@@ -0,0 +1,66 @@
+{
+    "name": "@lljj/vue3-form-ant",
+    "version": "1.14.2",
+    "description": "基于 Vue3 、Antdv、JsonSchema快速构建一个带完整校验的form表单",
+    "main": "dist/vue3-form-ant.umd.js",
+    "module": "dist/vue3-form-ant.esm.js",
+    "scripts": {
+        "watch": "node scripts/watch.js",
+        "build": "node scripts/build.js"
+    },
+    "keywords": [
+        "vue",
+        "vuejs",
+        "form",
+        "jsonSchema"
+    ],
+    "dependencies": {
+        "@lljj/vjsf-utils": "1.14.0",
+        "@lljj/vue3-form-core": "1.14.1"
+    },
+    "typings": "types/index.d.ts",
+    "files": [
+        "dist/*.js",
+        "types/*.d.ts"
+    ],
+    "repository": "https://github.com/lljj-x/vue-json-schema-form",
+    "homepage": "https://github.com/lljj-x/vue-json-schema-form",
+    "license": "Apache-2.0",
+    "publishConfig": {
+        "access": "public"
+    },
+    "devDependencies": {
+        "@lljj/babel-preset": "^0.1.0",
+        "@lljj/eslint-config": "0.1.0",
+        "babel-eslint": "^10.0.3",
+        "cssnano": "^4.1.10",
+        "eslint": "^5.16.0",
+        "eslint-plugin-import": "^2.20.2",
+        "eslint-plugin-vue": "^5.0.0",
+        "postcss-color-mod-function": "^3.0.3",
+        "postcss-cssnext": "^3.1.0",
+        "postcss-import": "^12.0.1",
+        "postcss-mixins": "^6.2.0",
+        "postcss-nested": "^4.1.0",
+        "rollup": "^2.12.0",
+        "rollup-plugin-babel": "^4.4.0",
+        "rollup-plugin-buble": "^0.19.8",
+        "rollup-plugin-commonjs": "^10.1.0",
+        "rollup-plugin-delete": "^1.2.0",
+        "rollup-plugin-eslint": "^7.0.0",
+        "rollup-plugin-filesize": "^9.0.0",
+        "rollup-plugin-json": "^4.0.0",
+        "rollup-plugin-node-resolve": "^5.2.0",
+        "rollup-plugin-postcss": "^3.1.1",
+        "rollup-plugin-terser": "^6.1.0",
+        "rollup-plugin-visualizer": "^4.0.4",
+        "rollup-plugin-vue": "^6.0.0"
+    },
+    "author": "Liu.Jun",
+    "browserslist": [
+        "> 1%",
+        "last 2 versions",
+        "not ie <= 8"
+    ],
+    "gitHead": "92795075169c879e1c1fabfe26f1d3c10b861060"
+}

+ 50 - 0
src/components/form/src/config/utils.js

@@ -0,0 +1,50 @@
+/* eslint-disable no-nested-ternary */
+/**
+ * Created by Liu.Jun on 2021/2/21 9:38 下午.
+ */
+
+import { defineComponent, h } from 'vue'
+import {
+  resolveComponent,
+  modelValueComponent
+} from '@lljj/vjsf-utils/vue3Utils'
+
+const numberTimeComponent = component =>
+  defineComponent({
+    inheritAttrs: false,
+    setup(props, { attrs, slots }) {
+      return () => {
+        const { isNumberValue, isRange, value, ...otherAttrs } = attrs
+
+        // antdv moment format 必须接受字符串时间戳
+        const newValue = isNumberValue
+          ? isRange
+            ? (value || []).map(item =>
+                typeof item === 'number' ? String(item) : item
+              )
+            : typeof value === 'number'
+            ? String(value)
+            : value
+          : value
+
+        const trueAttrs = {
+          ...attrs,
+          value: newValue,
+          'onUpdate:value': function updateValue(upValue) {
+            if (isNumberValue) {
+              upValue = isRange ? upValue.map(item => +item) : +upValue
+            }
+            otherAttrs['onUpdate:value'].call(this, upValue)
+          }
+        }
+
+        return h(resolveComponent(component), trueAttrs, slots)
+      }
+    }
+  })
+
+export {
+  // 转换antdv 非moduleValue的v-model组件
+  modelValueComponent,
+  numberTimeComponent
+}

+ 42 - 0
src/components/form/src/config/widgets/CheckboxesWidget/index.js

@@ -0,0 +1,42 @@
+/**
+ * Created by Liu.Jun on 2021/2/23 10:21 下午.
+ */
+
+import { h } from 'vue'
+import { resolveComponent } from '@lljj/vjsf-utils/vue3Utils'
+import { modelValueComponent } from '../../utils'
+
+const baseComponent = {
+  name: 'CheckboxesWidget',
+  props: {
+    enumOptions: {
+      default: () => [],
+      type: [Array]
+    }
+  },
+  setup(props, { attrs }) {
+    return () =>
+      h(resolveComponent('a-checkbox-group'), attrs, {
+        default() {
+          return props.enumOptions.map((item, index) =>
+            h(
+              resolveComponent('a-checkbox'),
+              {
+                key: index,
+                value: item.value
+              },
+              {
+                default: () => item.label
+              }
+            )
+          )
+        }
+      })
+  }
+}
+
+const moduleValeComponent = modelValueComponent(baseComponent, {
+  model: 'value'
+})
+
+export default moduleValeComponent

+ 15 - 0
src/components/form/src/config/widgets/CheckboxesWidget/readme.md

@@ -0,0 +1,15 @@
+### 文件说明
+> 默认widget直接使用element的组件,这里附加一些不能直接满足场景而使用的组件
+
+### 组件说明
+单文件夹为单个组件,组件需要统一为v-model双向绑定值
+
+#### CheckboxesWidget
+说明:多选列表,接受value 和 enumOptions参数
+> value - array,选中的值
+> enumOptions - Array ,下拉选项
+
+示例:
+```js
+console.log(1);
+```

+ 29 - 0
src/components/form/src/config/widgets/DatePickerWidget/index.js

@@ -0,0 +1,29 @@
+/**
+ * Created by Liu.Jun on 2021/2/23 10:21 下午.
+ */
+
+import { h } from 'vue';
+import { resolveComponent } from '@lljj/vjsf-utils/vue3Utils';
+import { modelValueComponent, numberTimeComponent } from '../../utils';
+
+const baseComponent = {
+    name: 'DatePickerWidget',
+    inheritAttrs: false,
+    setup(props, { attrs }) {
+        return () => {
+            const { isNumberValue, isRange, ...otherAttrs } = attrs;
+            return h(resolveComponent(isRange ? 'a-range-picker' : 'a-date-picker'), {
+                valueFormat: isNumberValue ? 'x' : 'YYYY-MM-DD',
+                ...otherAttrs
+            });
+        };
+    }
+};
+
+const timeNumberComponent = numberTimeComponent(baseComponent);
+
+const moduleValeComponent = modelValueComponent(timeNumberComponent, {
+    model: 'value'
+});
+
+export default moduleValeComponent;

+ 30 - 0
src/components/form/src/config/widgets/DateTimePickerWidget/index.js

@@ -0,0 +1,30 @@
+/**
+ * Created by Liu.Jun on 2021/2/23 10:21 下午.
+ */
+
+import { h } from 'vue';
+import { resolveComponent } from '@lljj/vjsf-utils/vue3Utils';
+import { modelValueComponent, numberTimeComponent } from '../../utils';
+
+const baseComponent = {
+    name: 'DatePickerWidget',
+    inheritAttrs: false,
+    setup(props, { attrs }) {
+        return () => {
+            const { isNumberValue, isRange, ...otherAttrs } = attrs;
+            return h(resolveComponent(isRange ? 'a-range-picker' : 'a-date-picker'), {
+                valueFormat: isNumberValue ? 'x' : 'YYYY-MM-DDTHH:mm:ssZ',
+                showTime: true,
+                ...otherAttrs
+            });
+        };
+    }
+};
+
+const timeNumberComponent = numberTimeComponent(baseComponent);
+
+const moduleValeComponent = modelValueComponent(timeNumberComponent, {
+    model: 'value'
+});
+
+export default moduleValeComponent;

+ 35 - 0
src/components/form/src/config/widgets/RadioWidget/index.js

@@ -0,0 +1,35 @@
+/**
+ * Created by Liu.Jun on 2021/2/23 10:21 下午.
+ */
+
+import { h } from 'vue';
+import { resolveComponent } from '@lljj/vjsf-utils/vue3Utils';
+import { modelValueComponent } from '../../utils';
+
+const baseComponent = {
+    name: 'RadioWidget',
+    props: {
+        enumOptions: {
+            default: () => [],
+            type: [Array]
+        }
+    },
+    setup(props, { attrs }) {
+        return () => h(resolveComponent('a-radio-group'), attrs, {
+            default() {
+                return props.enumOptions.map((item, index) => h(resolveComponent('a-radio'), {
+                    key: index,
+                    value: item.value
+                }, {
+                    default: () => item.label
+                }));
+            }
+        });
+    }
+};
+
+const moduleValeComponent = modelValueComponent(baseComponent, {
+    model: 'value'
+});
+
+export default moduleValeComponent;

+ 53 - 0
src/components/form/src/config/widgets/SelectWidget/index.js

@@ -0,0 +1,53 @@
+/**
+ * Created by Liu.Jun on 2021/2/23 10:21 下午.
+ */
+
+import { h } from 'vue'
+import { resolveComponent } from '@lljj/vjsf-utils/vue3Utils'
+import { modelValueComponent } from '../../utils'
+
+const baseComponent = {
+  name: 'SelectWidget',
+  props: {
+    enumOptions: {
+      default: () => [],
+      type: [Array]
+    }
+  },
+  setup(props, { attrs }) {
+    return () =>
+      h(
+        resolveComponent('a-select'),
+        {
+          ...(attrs.multiple
+            ? {
+                mode: 'multiple'
+              }
+            : {}),
+          ...attrs
+        },
+        {
+          default() {
+            return props.enumOptions.map((item, index) =>
+              h(
+                resolveComponent('a-option'),
+                {
+                  key: index,
+                  value: item.value
+                },
+                {
+                  default: () => item.label
+                }
+              )
+            )
+          }
+        }
+      )
+  }
+}
+
+const moduleValeComponent = modelValueComponent(baseComponent, {
+  model: 'value'
+})
+
+export default moduleValeComponent

+ 24 - 0
src/components/form/src/config/widgets/TimePickerWidget/index.js

@@ -0,0 +1,24 @@
+/**
+ * Created by Liu.Jun on 2020/7/22 13:22.
+ */
+
+import { h } from 'vue';
+import { resolveComponent } from '@lljj/vjsf-utils/vue3Utils';
+import { modelValueComponent } from '../../utils';
+
+const baseComponent = {
+    name: 'TimePickerWidget',
+    inheritAttrs: false,
+    setup(props, { attrs }) {
+        return () => h(resolveComponent('a-time-picker'), {
+            'value-format': 'HH:mm:ss',
+            ...attrs
+        });
+    }
+};
+
+const moduleValeComponent = modelValueComponent(baseComponent, {
+    model: 'value'
+});
+
+export default moduleValeComponent;

+ 123 - 0
src/components/form/src/config/widgets/UploadWidget/index.js

@@ -0,0 +1,123 @@
+/**
+ * Created by Liu.Jun on 2020/11/26 10:01 下午.
+ */
+
+import { h, ref } from 'vue';
+import { resolveComponent } from '@lljj/vjsf-utils/vue3Utils';
+
+// mock
+// https://run.mocky.io/v3/518d7af7-204f-45ab-9628-a6e121dab8ca
+
+export default {
+    name: 'UploadWidget',
+    props: {
+        modelValue: {
+            default: null,
+            type: [String, Array]
+        },
+        responseFileUrl: {
+            default: () => res => (res ? (res.url || (res.data && res.data.url)) : ''),
+            type: [Function]
+        },
+        btnText: {
+            type: String,
+            default: '点击上传'
+        },
+        // 传入 VNode
+        slots: {
+            type: null,
+            default: null
+        }
+    },
+    inheritAttrs: false,
+    setup(props, { attrs, emit }) {
+        // 设置默认 fileList
+        const curModelValue = props.modelValue;
+        const isArrayValue = Array.isArray(curModelValue);
+
+        const defaultFileList = attrs.fileList || (() => {
+            if (isArrayValue) {
+                return curModelValue.map((item, index) => ({
+                    uid: String(index),
+                    status: 'done',
+                    name: `已上传文件(${index + 1})`,
+                    url: item
+                }));
+            }
+            if (curModelValue) {
+                return [{
+                    uid: '1',
+                    status: 'done',
+                    name: '已上传文件',
+                    url: curModelValue
+                }];
+            }
+
+            return [];
+        })();
+
+
+        // fileList
+        const fileListRef = ref(defaultFileList);
+
+        const getUrl = fileItem => (
+            fileItem
+            && ((fileItem.response && props.responseFileUrl(fileItem.response)) || fileItem.url))
+            || '';
+
+        const emitValue = (emitFileList) => {
+            // v-model
+            let curValue;
+
+            if (isArrayValue) {
+                curValue = emitFileList.length ? emitFileList.reduce((pre, item) => {
+                    const url = getUrl(item);
+                    if (url) {
+                        item.url = url;
+                        pre.push(url);
+                    }
+                    return pre;
+                }, []) : [];
+            } else {
+                const fileItem = emitFileList[emitFileList.length - 1];
+                curValue = getUrl(fileItem);
+                if (fileItem && curValue) {
+                    fileItem.url = curValue;
+                    fileListRef.value = [fileItem];
+                } else {
+                    fileListRef.value = [];
+                }
+            }
+
+            emit('update:modelValue', curValue);
+        };
+
+        return () => h(resolveComponent('a-upload'), {
+            ...attrs,
+            fileList: fileListRef.value,
+            'onUpdate:fileList': function updateFileList(val) {
+                fileListRef.value = val;
+            },
+            onChange(changeData) {
+                if (changeData.file.status !== 'uploading') {
+                    emitValue(changeData.fileList);
+                }
+
+                if (attrs.onChange) {
+                    attrs.onChange.call(this, changeData);
+                }
+            }
+        }, {
+            default: () => h(
+                resolveComponent('a-button'),
+                {
+                    type: 'primary'
+                },
+                {
+                    default: () => props.btnText
+                }
+            ),
+            ...(props.slots || {}),
+        });
+    }
+};

+ 41 - 0
src/components/form/src/config/widgets/WIDGET_MAP.js

@@ -0,0 +1,41 @@
+/**
+ * Created by Liu.Jun on 2020/4/21 18:23.
+ */
+
+// widget 组件对应elementUi 配置表
+
+import widgetComponents from './index'
+
+const {
+  InputWidget,
+  InputNumberWidget,
+  SwitchWidget,
+  CheckboxesWidget,
+  RadioWidget,
+  SelectWidget,
+  TimePickerWidget,
+  DatePickerWidget,
+  DateTimePickerWidget,
+  ColorWidget
+} = widgetComponents
+
+export default {
+  types: {
+    boolean: SwitchWidget,
+    string: InputWidget,
+    number: InputNumberWidget,
+    integer: InputNumberWidget
+  },
+  formats: {
+    color: ColorWidget,
+    time: TimePickerWidget, // 20:20:39+00:00
+    date: DatePickerWidget, // 2018-11-13
+    'date-time': DateTimePickerWidget // 2018-11-13T20:20:39+00:00
+  },
+  common: {
+    select: SelectWidget,
+    radioGroup: RadioWidget,
+    checkboxGroup: CheckboxesWidget
+  },
+  widgetComponents
+}

+ 70 - 0
src/components/form/src/config/widgets/index.js

@@ -0,0 +1,70 @@
+/**
+ * Created by Liu.Jun on 2020/5/17 10:41 下午.
+ */
+
+import { h } from 'vue'
+import CheckboxesWidget from './CheckboxesWidget'
+import RadioWidget from './RadioWidget'
+import SelectWidget from './SelectWidget'
+import DatePickerWidget from './DatePickerWidget'
+import DateTimePickerWidget from './DateTimePickerWidget'
+import TimePickerWidget from './TimePickerWidget'
+import UploadWidget from './UploadWidget'
+
+import { modelValueComponent } from '../utils'
+
+const widgetComponents = {
+  CheckboxesWidget,
+  RadioWidget,
+  SelectWidget,
+  TimePickerWidget,
+  DatePickerWidget,
+  DateTimePickerWidget,
+  UploadWidget,
+  InputWidget: modelValueComponent('a-input'),
+  ColorWidget: {
+    setup(props, { attrs }) {
+      return () =>
+        h(
+          widgetComponents.InputWidget,
+          {
+            ...attrs,
+            style: {
+              ...(attrs.style || {}),
+              maxWidth: '180px'
+            }
+          },
+          {
+            addonAfter: () =>
+              h('input', {
+                disabled: attrs.disabled,
+                readonly: attrs.readonly,
+                value: attrs.modelValue,
+                onInput(e) {
+                  console.log(e)
+                  attrs['onUpdate:modelValue'](e.target.value)
+                },
+                onChange(e) {
+                  attrs['onUpdate:modelValue'](e.target.value)
+                },
+                type: 'color',
+                style: {
+                  padding: '0',
+                  width: '50px'
+                }
+              })
+          }
+        )
+    }
+  },
+  TextAreaWidget: modelValueComponent('a-textarea'),
+  InputNumberWidget: modelValueComponent('a-input-number'),
+  AutoCompleteWidget: modelValueComponent('a-auto-complete'),
+  SliderWidget: modelValueComponent('a-slider'),
+  RateWidget: modelValueComponent('a-rate'),
+  SwitchWidget: modelValueComponent('a-switch', {
+    model: 'checked'
+  })
+}
+
+export default widgetComponents

+ 182 - 0
src/components/form/src/index.js

@@ -0,0 +1,182 @@
+/**
+ * Created by Liu.Jun on 2019/11/29 11:25.
+ */
+
+import { h, ref, onMounted, defineComponent } from 'vue'
+import createVue3Core, { fieldProps, SchemaField } from '@lljj/vue3-form-core'
+
+import i18n from '@lljj/vjsf-utils/i18n'
+import * as vueUtils from '@lljj/vjsf-utils/vue3Utils'
+import * as formUtils from '@lljj/vjsf-utils/formUtils'
+import * as schemaValidate from '@lljj/vjsf-utils/schema/validate'
+import getDefaultFormState from '@lljj/vjsf-utils/schema/getDefaultFormState'
+import WIDGET_MAP from './config/widgets/WIDGET_MAP'
+
+import { modelValueComponent } from './config/utils'
+
+import './style.css'
+
+const globalOptions = {
+  WIDGET_MAP,
+  COMPONENT_MAP: {
+    form: defineComponent({
+      inheritAttrs: false,
+      setup(_props, { attrs, slots }) {
+        // 处理 labelPosition 参数和layout之间转换
+
+        const labelPositionMap = {
+          top: {
+            layout: 'vertical'
+          },
+          left: {
+            layout: 'horizontal',
+            labelAlign: 'left'
+          },
+          right: {
+            layout: 'horizontal',
+            labelAlign: 'right'
+          }
+        }
+
+        // 返回当前的 form ref
+        const formRef = ref(null)
+        if (attrs.setFormRef) {
+          onMounted(() => {
+            // form组件实例上附加一个 validate 方法
+            formRef.value.$$validate = callBack => {
+              formRef.value
+                .validate()
+                .then(res => {
+                  callBack(true, res)
+                })
+                .catch(err => {
+                  callBack(false, err.errorFields)
+                })
+            }
+            attrs.setFormRef(formRef.value)
+          })
+        }
+
+        return () => {
+          const {
+            // eslint-disable-next-line no-unused-vars
+            setFormRef,
+            labelPosition,
+            labelWidth,
+            model,
+            ...otherAttrs
+          } = attrs
+
+          if (otherAttrs.inline) {
+            Object.assign(otherAttrs, {
+              layout: 'inline'
+              // labelCol: undefined,
+              // wrapperCol: undefined
+            })
+          }
+
+          return h(
+            vueUtils.resolveComponent('a-form'),
+            {
+              ref: formRef,
+              model: model.value,
+              ...labelPositionMap[labelPosition || 'top'],
+              ...otherAttrs,
+              colon: false
+            },
+            slots
+          )
+        }
+      }
+    }),
+    formItem: defineComponent({
+      inheritAttrs: false,
+      setup(props, { attrs, slots }) {
+        const formItemRef = ref(null)
+        return () => {
+          const { prop, rules, ...originAttrs } = attrs
+
+          return h(
+            vueUtils.resolveComponent('a-form-item'),
+            {
+              ...originAttrs,
+              ref: formItemRef,
+
+              // 去掉callback 使用promise 模式
+              rules: (rules || []).map(validateRule => ({
+                ...validateRule,
+                validator(rule, value) {
+                  return validateRule.validator.apply(this, [rule, value])
+                }
+              })),
+              name: prop ? prop.split('.') : prop
+            },
+            {
+              ...slots,
+              default: function proxySlotDefault() {
+                // 解决 a-form-item 只对第一个子元素进行劫持,并监听 blur 和 change 事件,如果存在第一个元素description无法校验
+                // @blur="() => {$refs.name.onFieldBlur()}"
+                // @change="() => {$refs.name.onFieldChange()}"
+                return slots.default.call(this, {
+                  onBlur: () => {
+                    if (
+                      formItemRef.value.$el.querySelector('.genFromWidget_des')
+                    ) {
+                      // 存在 description,需要手动触发校验事件
+                      formItemRef.value.onFieldBlur()
+                    }
+                  },
+                  onChange: () => {
+                    if (
+                      formItemRef.value.$el.querySelector('.genFromWidget_des')
+                    ) {
+                      // 存在 description,需要手动触发校验事件
+                      formItemRef.value.onFieldChange()
+                    }
+                  }
+                })
+              }
+            }
+          )
+        }
+      }
+    }),
+    button: 'a-button',
+    popover: defineComponent({
+      setup(props, { attrs, slots }) {
+        return () =>
+          h(vueUtils.resolveComponent('a-popover'), attrs, {
+            default: slots.reference,
+            content: slots.default
+          })
+      }
+    })
+  },
+  HELPERS: {
+    // 是否mini显示 description
+    isMiniDes(formProps) {
+      return (
+        formProps &&
+        (['left', 'right'].includes(formProps.labelPosition) ||
+          formProps.layout === 'horizontal' ||
+          formProps.inline === true)
+      )
+    }
+  }
+}
+
+const JsonSchemaForm = createVue3Core(globalOptions)
+
+export default JsonSchemaForm
+
+export {
+  globalOptions,
+  SchemaField,
+  getDefaultFormState,
+  fieldProps,
+  vueUtils,
+  formUtils,
+  schemaValidate,
+  i18n,
+  modelValueComponent
+}

+ 35 - 0
src/components/form/src/style.css

@@ -0,0 +1,35 @@
+/* element plus 重置样式*/
+.genFromComponent {
+    &.ant-form-vertical {
+        .ant-form-item-label {
+            line-height: 26px;
+            padding-bottom: 6px;
+            font-size: 14px;
+        }
+    }
+    .ant-form-item {
+        margin-bottom: 22px;
+        &.ant-form-item-with-help {
+            margin-bottom: 2px;
+        }
+    }
+    .ant-form-explain {
+        padding-top: 2px;
+        display: -webkit-box!important;
+        text-overflow: ellipsis;
+        overflow: hidden;
+        -webkit-box-orient: vertical;
+        -webkit-line-clamp: 2;
+        white-space: normal;
+        text-align: left;
+        line-height: 1.2;
+        font-size: 12px;
+    }
+    .validateWidget .ant-form-explain {
+        padding: 5px 0;
+        position: relative;
+    }
+    .ant-form-item-label > label.ant-form-item-no-colon::after {
+        display: none;
+    }
+}

+ 31 - 0
src/components/form/types/fieldProps.d.ts

@@ -0,0 +1,31 @@
+declare namespace fieldProps {
+  /** 当前节点schema */
+  export const schema: object
+
+  /** 当前节点 uiSchema */
+
+  export const uiSchema: object
+
+  /** 当前节点 errorSchema */
+  export const errorSchema: object
+
+  /** 自定义校验规则 */
+  export const customFormats: object
+
+  /** 根节点 schema */
+  export const rootSchema: object
+
+  /** 根节点 formData */
+  export const rootFormData: object
+
+  /** 当前节点 路径 */
+  export const curNodePath: string
+
+  /** 是否为必填 */
+  export const required: boolean
+
+  /** 是否需要校验数据组 */
+  export const needValidFieldGroup: boolean
+}
+
+export default fieldProps

+ 45 - 0
src/components/form/types/formUtils.d.ts

@@ -0,0 +1,45 @@
+interface Options {
+  schema: object
+  uiSchema: object
+}
+
+declare namespace formUtils {
+  /** 解析当前节点 ui field */
+  function getUiField(schemaOption: Options): object | null
+
+  /** 解析用户配置的 uiSchema options */
+  function getUserUiOptions(schemaOption: Options): object
+
+  /** 解析当前节点的ui options参数 */
+  function getUiOptions(schemaOption: Options): object
+
+  /** 获取当前节点的ui 配置 (options + widget) */
+  function getWidgetConfig(schemaOption: Options): object
+
+  /** 获取当前节点的ui 配置 (options + widget) */
+  function getUserErrOptions(schemaOption: Options): object
+
+  /** ui:order object-> properties 排序 */
+  function orderProperties(properties: object, order): object
+
+  /** 当前schema 值是否为常量 */
+  function isConstant(schema: object): boolean
+
+  function toConstant(schema: object): object | null
+
+  /** 是否为选择列表 * */
+  function isSelect(_schema: object, rootSchema: object): boolean
+
+  /** type array items 都为一个对象 * */
+  function isFixedItems(schema: object): boolean
+
+  /** 是否为多选 * */
+  function isMultiSelect(schema: object, rootSchema: object): boolean
+
+  function allowAdditionalItems(schemaOption: Options): boolean
+
+  /** 下拉选项 * */
+  function optionsList(schemaOption: Options)
+}
+
+export default formUtils

+ 8 - 0
src/components/form/types/getDefaultFormState.d.ts

@@ -0,0 +1,8 @@
+declare function getDefaultFormState(
+  schema: object,
+  formData: object,
+  rootSchema: object,
+  includeUndefinedValues?: boolean
+): any
+
+export default getDefaultFormState

+ 16 - 0
src/components/form/types/globalOptions.d.ts

@@ -0,0 +1,16 @@
+interface HELPERS1 {
+  isMiniDes: (formProps: object) => boolean
+}
+
+declare namespace globalOptions {
+  /** WIDGET_MAP 配置 */
+  export const WIDGET_MAP: object
+
+  /** COMPONENT_MAP 配置 */
+  export const COMPONENT_MAP: object
+
+  /** HELPERS 配置 */
+  export const HELPERS: HELPERS1
+}
+
+export default globalOptions

+ 7 - 0
src/components/form/types/i18n.d.ts

@@ -0,0 +1,7 @@
+declare namespace i18n {
+  function getCurrentLocalize(): object
+
+  function useLocal(fn): object
+}
+
+export default i18n

+ 24 - 0
src/components/form/types/index.d.ts

@@ -0,0 +1,24 @@
+import JsonSchemaForm from './vueForm'
+import getDefaultFormState from './getDefaultFormState'
+import modelValueComponent from './modelValueComponent'
+import fieldProps from './fieldProps'
+import vueUtils from './vueUtils'
+import formUtils from './formUtils'
+import schemaValidate from './schemaValidate'
+import i18n from './i18n'
+import globalOptions from './globalOptions'
+
+export default JsonSchemaForm
+
+export {
+  globalOptions,
+  getDefaultFormState,
+  fieldProps,
+  vueUtils,
+  formUtils,
+  schemaValidate,
+  i18n,
+  modelValueComponent
+}
+
+export class SchemaField {}

+ 9 - 0
src/components/form/types/modelValueComponent.d.ts

@@ -0,0 +1,9 @@
+declare function modelValueComponent(
+  // eslint-disable-next-line @typescript-eslint/ban-types
+  baseComponent: object | string | Function,
+  options?: {
+    model?: string
+  }
+): any
+
+export default modelValueComponent

+ 22 - 0
src/components/form/types/schemaValidate.d.ts

@@ -0,0 +1,22 @@
+declare namespace schemaValidate {
+  /** schema通过ajv校验formData并返回错误信息 */
+  function ajvValidateFormData(options: object): object
+
+  /** 校验formData 并转换错误信息 */
+  function validateFormDataAndTransformMsg(options: object): object
+
+  /** schema 是否通过校验 */
+  function isValid(schema: object, data: any): boolean
+
+  /** ajv validate 方法 */
+  function ajvValid(schema: object, data: any): boolean
+
+  /** oneOf anyOf 通过formData的值来找到当前匹配项索引 */
+  function getMatchingOption(
+    formData: object,
+    options: object,
+    rootSchema: object
+  ): boolean
+}
+
+export default schemaValidate

+ 29 - 0
src/components/form/types/vueForm.d.ts

@@ -0,0 +1,29 @@
+import Vue from 'vue'
+
+declare class VueForm extends Vue {
+  /** formFooter 配置 */
+  formFooter: object
+
+  /** value / v-model */
+  value: object
+
+  /** 传递给form的props */
+  formProps: object
+
+  /** schema 配置 */
+  schema: object
+
+  /** uiSchema 配置 */
+  uiSchema: object
+
+  /** 重置自定义错误 */
+  errorSchema: object
+
+  /** 自定义校验规则 */
+  customFormats: object
+
+  /** 自定义校验规则 */
+  customRule: null
+}
+
+export default VueForm

+ 24 - 0
src/components/form/types/vueUtils.d.ts

@@ -0,0 +1,24 @@
+declare namespace vueUtils {
+  /** nodePath 转css类名 */
+  function nodePath2ClassName(path: string): string
+
+  /** 是否为根节点 */
+  function isRootNodePath(path: string): boolean
+
+  /** 计算当前节点path */
+  function computedCurPath(prePath: string, curKey: string): string
+
+  /** 计算当前节点name */
+  function deletePathVal(vueData: object, name: string): void
+
+  /** 设置当前path值 */
+  function setPathVal(vueData: object, path: string, value: any): void
+
+  /** 设置当前path值 */
+  function getPathVal(vueData: object, path: string): object
+
+  /** 设置当前path值 */
+  function path2prop(path: string): string
+}
+
+export default vueUtils

+ 79 - 0
src/components/global-setting/block.vue

@@ -0,0 +1,79 @@
+<template>
+  <div class="block">
+    <h5 class="title">{{ title }}</h5>
+    <!-- <div v-for="option in options" :key="option.name" class="switch-wrapper">
+      <span>{{ $t(option.name) }}</span>
+      <form-wrapper
+        :type="option.type || 'switch'"
+        :name="option.key"
+        :default-value="option.defaultVal"
+        @input-change="handleChange"
+      />
+    </div> -->
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { PropType } from 'vue'
+// import { useAppStore } from '@/store'
+// import FormWrapper from './form-wrapper.vue'
+
+interface OptionsProps {
+  name: string
+  key: string
+  type?: string
+  defaultVal?: boolean | string | number
+}
+defineProps({
+  title: {
+    type: String,
+    default: ''
+  },
+  options: {
+    type: Array as PropType<OptionsProps[]>,
+    default() {
+      return []
+    }
+  }
+})
+// const appStore = useAppStore()
+// const handleChange = async ({
+//   key,
+//   value
+// }: {
+//   key: string
+//   value: unknown
+// }) => {
+//   if (key === 'colorWeak') {
+//     document.body.style.filter = value ? 'invert(80%)' : 'none'
+//   }
+//   if (key === 'menuFromServer' && value) {
+//     await appStore.fetchServerMenuConfig()
+//   }
+//   if (key === 'topMenu') {
+//     appStore.updateSettings({
+//       menuCollapse: false
+//     })
+//   }
+//   appStore.updateSettings({ [key]: value })
+// }
+</script>
+
+<style scoped lang="less">
+.block {
+  margin-bottom: 24px;
+}
+
+.title {
+  margin: 10px 0;
+  padding: 0;
+  font-size: 14px;
+}
+
+.switch-wrapper {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  height: 32px;
+}
+</style>

+ 39 - 0
src/components/global-setting/form-wrapper.vue

@@ -0,0 +1,39 @@
+<template>
+  <a-input-number
+    v-if="type === 'number'"
+    :style="{ width: '80px' }"
+    size="small"
+    :default-value="(defaultValue as number)"
+    @change="handleChange"
+  />
+  <a-switch
+    v-else
+    :default-checked="(defaultValue as boolean)"
+    size="small"
+    @change="handleChange"
+  />
+</template>
+
+<script lang="ts" setup>
+const props = defineProps({
+  type: {
+    type: String,
+    default: ''
+  },
+  name: {
+    type: String,
+    default: ''
+  },
+  defaultValue: {
+    type: [String, Boolean, Number],
+    default: ''
+  }
+})
+const emit = defineEmits(['inputChange'])
+const handleChange = (value: unknown) => {
+  emit('inputChange', {
+    value,
+    key: props.name
+  })
+}
+</script>

+ 77 - 0
src/components/global-setting/index.vue

@@ -0,0 +1,77 @@
+<template>
+  <div v-if="!appStore.navbar" class="fixed-settings" @click="setVisible">
+    <a-button type="primary">
+      <template #icon>
+        <icon-settings />
+      </template>
+    </a-button>
+  </div>
+  <a-drawer
+    :width="300"
+    unmount-on-close
+    :visible="visible"
+    ok-text="确认"
+    :hide-cancel="true"
+    @ok="cancel"
+    @cancel="cancel"
+  >
+    <template #title> {{ $t('settings.title') }} </template>
+    <Block :options="contentOpts" :title="$t('settings.content')" />
+    <Block :options="othersOpts" :title="$t('settings.otherSettings')" />
+  </a-drawer>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue'
+import { useAppStore } from '@/store'
+import Block from './block.vue'
+
+const emit = defineEmits(['cancel'])
+
+const appStore = useAppStore()
+const visible = computed(() => appStore.globalSettings)
+const contentOpts = computed(() => [
+  { name: 'settings.navbar', key: 'navbar', defaultVal: appStore.navbar },
+  {
+    name: 'settings.menu',
+    key: 'menu',
+    defaultVal: appStore.menu
+  },
+  { name: 'settings.footer', key: 'footer', defaultVal: appStore.footer },
+  // { name: 'settings.tabBar', key: 'tabBar', defaultVal: appStore.tabBar },
+  {
+    name: 'settings.menuWidth',
+    key: 'menuWidth',
+    defaultVal: appStore.menuWidth,
+    type: 'number'
+  }
+])
+const othersOpts = computed(() => [
+  {
+    name: 'settings.colorWeak',
+    key: 'colorWeak',
+    defaultVal: appStore.colorWeak
+  }
+])
+
+const cancel = () => {
+  appStore.updateSettings({ globalSettings: false })
+  emit('cancel')
+}
+const setVisible = () => {
+  appStore.updateSettings({ globalSettings: true })
+}
+</script>
+
+<style scoped lang="less">
+.fixed-settings {
+  position: fixed;
+  top: 280px;
+  right: 0;
+
+  svg {
+    font-size: 18px;
+    vertical-align: -4px;
+  }
+}
+</style>

+ 336 - 0
src/components/hover-editor-detail/index.vue

@@ -0,0 +1,336 @@
+<template>
+  <div
+    class="hover-editor-container"
+    @mouseenter="mouseenterHandler()"
+    @mouseleave="mouseleaveHandler()"
+  >
+    <!-- <a-tooltip :content="modelValue"> -->
+    <div
+      class="hover-editor-content"
+      :style="{
+        flex: isModify ? '1 1 auto' : ''
+      }"
+    >
+      <span v-if="!isModify" class="hover-editor-text">
+        <div
+          v-if="props.componentsType === ComponentsType.TEXTAREA"
+          v-html="visibleName"
+        ></div>
+        <span v-else> {{ visibleName }}{{ props.inputSuffix }}</span>
+      </span>
+      <span
+        v-else
+        v-focus="true"
+        :style="{
+          width: [
+            ComponentsType.STRINGINPUT,
+            ComponentsType.NUMBERINPUT,
+            ComponentsType.TEXTAREA,
+            ComponentsType.SELECT
+          ].includes(props.componentsType)
+            ? '100%'
+            : ''
+        }"
+      >
+        <a-input
+          v-if="props.componentsType === ComponentsType.STRINGINPUT"
+          ref="inputRef"
+          v-model="editingValue"
+          :placeholder="placeholder"
+          :allow-clear="!notAllowNull"
+          @blur="blur"
+          @change="update"
+        />
+        <a-input-number
+          v-if="props.componentsType === ComponentsType.NUMBERINPUT"
+          ref="inputRef"
+          v-model="editingValue"
+          style="width: 100"
+          :precision="precision"
+          :hide-button="!showNumberButton"
+          :placeholder="placeholder"
+          :allow-clear="!notAllowNull"
+          @blur="blur"
+          @change="update"
+        >
+          <template #suffix>
+            {{ props.inputSuffix }}
+          </template>
+        </a-input-number>
+        <a-date-picker
+          v-if="props.componentsType === ComponentsType.DATEPICKER"
+          ref="inputRef"
+          v-model="editingValue"
+          :default-popup-visible="true"
+          :allow-clear="!notAllowNull"
+          :placeholder="placeholder"
+          @clear="clear"
+          @popup-visible-change="update"
+        ></a-date-picker>
+        <a-select
+          v-if="props.componentsType === ComponentsType.SELECT"
+          ref="inputRef"
+          v-model="editingValue"
+          :default-popup-visible="true"
+          :placeholder="placeholder"
+          allow-search
+          :multiple="multiple"
+          :allow-clear="!notAllowNull"
+          @clear="clear"
+          @popup-visible-change="update"
+        >
+          <a-option
+            v-for="item in options"
+            :key="item.id"
+            :value="item.id"
+            :label="item.name"
+          ></a-option>
+        </a-select>
+        <a-cascader
+          v-if="props.componentsType === ComponentsType.CASCADER"
+          ref="inputRef"
+          v-model="editingValue"
+          :default-popup-visible="true"
+          :options="props.options"
+          :field-names="{
+            value: 'id',
+            label: 'name'
+          }"
+          :allow-search="true"
+          :allow-clear="!notAllowNull"
+          :expand-child="visibleCascader"
+          :placeholder="placeholder"
+          :check-strictly="!unCheckStrictly"
+          @clear="clear"
+          @popup-visible-change="update()"
+        />
+        <a-textarea
+          v-if="props.componentsType === ComponentsType.TEXTAREA"
+          ref="inputRef"
+          v-model="editingValue"
+          :auto-size="false"
+          :allow-clear="!notAllowNull"
+          style="width: 100%"
+          :placeholder="placeholder"
+          @blur="blur"
+          @change="update"
+        />
+
+        <!-- @blur="blur"
+          @change="update" -->
+      </span>
+    </div>
+    <div
+      v-show="
+        !isModify && (showEditor || ['', null, undefined].includes(visibleName))
+      "
+      :style="{
+        marginLeft: ['', null, undefined].includes(visibleName) ? '5px' : '5px'
+      }"
+      class="editor-btn"
+    >
+      <icon-edit :size="14" @click="showEditorComponents()" />
+    </div>
+    <!-- </a-tooltip> -->
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ComponentsType } from '@/enums/api'
+import { Message } from '@arco-design/web-vue'
+import { watch, computed, ref, shallowRef } from 'vue'
+
+interface DataItem {
+  id: string | number
+  name: string
+  children?: DataItem[]
+}
+
+const props = defineProps<{
+  modelValue?: any
+  oldValue?: any
+  disabled?: boolean
+  placeholder?: string
+  componentsType: ComponentsType
+  isInputNumber?: boolean
+  inputSuffix?: string
+  options?: DataItem[]
+  multiple?: boolean
+  showNumberButton?: boolean
+  unCheckStrictly?: boolean
+  notAllowNull?: boolean
+  precision?: number
+}>()
+
+const emits = defineEmits(['update:modelValue', 'modify'])
+const editingValue = ref(
+  props.componentsType === ComponentsType.TEXTAREA && props.modelValue
+    ? props.modelValue.replace(/<br\/>/g, '\n')
+    : props.modelValue
+)
+
+const isModify = ref(false)
+const showEditor = ref(false)
+const inputRef = shallowRef()
+const visibleCascader = ref(false)
+
+/**
+ * 根据id查找name值,递归则获取路径拼接name
+ * @param nodes
+ * @param id
+ * @param path
+ */
+function findPathById(
+  nodes: DataItem[],
+  id: string,
+  path: string[] = []
+): string | null {
+  const result = nodes
+    .map(node => {
+      if (node.id === id) {
+        return [...path, node.name].join(' / ')
+      }
+      if (node.children) {
+        return findPathById(node.children, id, [...path, node.name])
+      }
+      return null
+    })
+    .find(p => p !== null)
+
+  return result || null
+}
+
+const visibleName = computed(() => {
+  if (
+    props.componentsType === ComponentsType.STRINGINPUT ||
+    props.componentsType === ComponentsType.NUMBERINPUT
+  ) {
+    return props.modelValue
+  }
+
+  if (
+    props.componentsType === ComponentsType.SELECT ||
+    props.componentsType === ComponentsType.CASCADER
+  ) {
+    const ids = props.modelValue
+    if (props.multiple) {
+      const arrName: string[] = []
+      ids.map((item: string | number) => {
+        const isFind = props.options?.find(p => p.id === item)
+        if (isFind) {
+          arrName.push(isFind.name)
+        }
+        return item
+      })
+      return arrName.join(',')
+    }
+    return findPathById(props.options!, ids)
+  }
+  return props.modelValue
+})
+
+// const foucs = () => {
+//   if (!visibleCascader.value) {
+//     visibleCascader.value = true
+//   }
+// }
+
+const blur = () => {
+  console.log(editingValue.value)
+  isModify.value = false
+}
+
+// const popupVisibleChange = (visible: boolean) => {
+//   if (!visible) {
+//     isModify.value = false
+//   }
+// }
+
+// 返显数据
+const update = () => {
+  const v =
+    props.componentsType === ComponentsType.TEXTAREA
+      ? editingValue.value.replace(/\n/g, '<br/>')
+      : editingValue.value
+  if (props.notAllowNull && ['', null, undefined].includes(v)) {
+    Message.error('不允许为空')
+    return
+  }
+  // const fValue =
+  //   [ComponentsType.CASCADER, ComponentsType.SELECT].includes(
+  //     props.componentsType
+  //   ) && v === ''
+  //     ? null
+  //     : v
+  if (props.modelValue !== v) {
+    emits('update:modelValue', v)
+    emits('modify', props.oldValue)
+  }
+  isModify.value = false
+}
+
+// 返显数据
+const clear = () => {
+  editingValue.value = null
+}
+
+watch(
+  () => props.modelValue,
+  () => {
+    const v =
+      props.componentsType === ComponentsType.TEXTAREA
+        ? props.modelValue.replace(/<br\/>/g, '\n')
+        : props.modelValue
+    editingValue.value = v
+  }
+)
+
+const showEditorComponents = () => {
+  isModify.value = true
+  showEditor.value = false
+}
+
+// 鼠标悬浮时显示编辑图标
+const mouseenterHandler = () => {
+  if (isModify.value) {
+    return
+  }
+  showEditor.value = true
+}
+
+// 鼠标离开时隐藏编辑图标
+const mouseleaveHandler = () => {
+  showEditor.value = false
+}
+</script>
+
+<style lang="less" scoped>
+.hover-editor-container {
+  width: 100%;
+  display: flex;
+  justify-content: start;
+  align-items: center;
+  position: relative;
+  // height: 30px;
+  .hover-editor-content {
+    display: flex;
+    align-items: center;
+    position: relative;
+    line-clamp: 1;
+    max-width: calc(100% - 15px);
+    // height: 30px;
+    .hover-editor-text {
+      position: relative;
+      width: 100%;
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+  }
+}
+.editor-btn {
+  display: flex;
+  align-items: center;
+  color: rgb(var(--primary-6));
+  cursor: pointer;
+}
+</style>

+ 125 - 0
src/components/hover-editor-detail/textarea.vue

@@ -0,0 +1,125 @@
+<template>
+  <div
+    class="hover-editor-container"
+    @mouseenter="mouseenterHandler()"
+    @mouseleave="mouseleaveHandler()"
+  >
+    <div class="hover-editor-content">
+      <span v-if="!isModify" class="hover-editor-text">
+        {{ editingValue }}
+      </span>
+      <span v-else v-focus="true">
+        <a-textarea
+          v-model="editingValue"
+          :placeholder="placeholder"
+          class="editor-textarea"
+          auto-size
+          @blur="blur"
+          @update="update"
+        />
+      </span>
+    </div>
+    <div
+      v-show="showEditor"
+      :style="{
+        marginLeft: ['', null, undefined].includes(visibleName) ? '5px' : '5px'
+      }"
+      class="editor-btn"
+    >
+      <icon-edit :size="14" @click="showEditorComponents()" />
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { watch, computed, ref } from 'vue'
+
+const props = defineProps<{
+  modelValue?: any
+  placeholder?: string
+}>()
+
+const emits = defineEmits(['update:modelValue', 'modify'])
+const editingValue = ref(props.modelValue)
+const isModify = ref(false)
+const showEditor = ref(false)
+
+const visibleName = computed(() => {
+  return props.modelValue
+})
+
+const blur = () => {
+  isModify.value = false
+}
+
+// 返显数据
+const update = () => {
+  const v = editingValue.value
+  if (props.modelValue !== v) {
+    emits('update:modelValue', v)
+    emits('modify')
+  }
+  isModify.value = false
+}
+
+watch(
+  () => props.modelValue,
+  () => {
+    editingValue.value = props.modelValue
+  }
+)
+
+const showEditorComponents = () => {
+  isModify.value = true
+  showEditor.value = false
+}
+
+// 鼠标悬浮时显示编辑图标
+const mouseenterHandler = () => {
+  if (isModify.value) {
+    return
+  }
+  showEditor.value = true
+}
+
+// 鼠标离开时隐藏编辑图标
+const mouseleaveHandler = () => {
+  showEditor.value = false
+}
+</script>
+
+<style lang="less" scoped>
+.hover-editor-container {
+  width: 100%;
+  display: flex;
+  justify-content: start;
+  align-items: center;
+  //   position: absolute;
+  top: 0;
+  left: 0;
+  height: auto;
+  overflow-x: hidden;
+  line-clamp: 3;
+  text-overflow: ellipsis;
+  line-clamp: 1;
+  .hover-editor-content {
+    overflow: hidden;
+
+    padding: 5px;
+    text-overflow: ellipsis;
+    .hover-editor-text {
+      height: 100%;
+      width: 100%;
+      padding-left: 10px;
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+  }
+}
+.editor-btn {
+  display: flex;
+  align-items: center;
+  color: rgb(var(--primary-6));
+  cursor: pointer;
+}
+</style>

+ 35 - 0
src/components/index.ts

@@ -0,0 +1,35 @@
+import { App } from 'vue'
+import { use } from 'echarts/core'
+import { CanvasRenderer } from 'echarts/renderers'
+import { BarChart, LineChart, PieChart, RadarChart } from 'echarts/charts'
+import {
+  GridComponent,
+  TooltipComponent,
+  LegendComponent,
+  DataZoomComponent,
+  GraphicComponent
+} from 'echarts/components'
+import Chart from './chart/index.vue'
+import Breadcrumb from './breadcrumb/index.vue'
+
+// Manually introduce ECharts modules to reduce packing size
+
+use([
+  CanvasRenderer,
+  BarChart,
+  LineChart,
+  PieChart,
+  RadarChart,
+  GridComponent,
+  TooltipComponent,
+  LegendComponent,
+  DataZoomComponent,
+  GraphicComponent
+])
+
+export default {
+  install(Vue: App) {
+    Vue.component('Chart', Chart)
+    Vue.component('Breadcrumb', Breadcrumb)
+  }
+}

+ 220 - 0
src/components/menu/index.vue

@@ -0,0 +1,220 @@
+<script lang="tsx">
+import { defineComponent, ref, h, compile, computed } from 'vue'
+import { useRoute, useRouter, RouteRecordRaw } from 'vue-router'
+import type { RouteMeta } from 'vue-router'
+import { useAppStore } from '@/store'
+import { openWindow, regexUrl } from '@/utils'
+import { listenerRouteChange } from '@/logics/mitt/routeChange'
+import { useUndoneCount } from '@/hooks/useUndoneCount'
+import { useToggle } from '@vueuse/core'
+import useMenuTree from './use-menu-tree'
+
+export default defineComponent({
+  emit: ['collapse'],
+  setup() {
+    const appStore = useAppStore()
+    const [subShowBadge, toggle] = useToggle()
+    const { undoneCount } = useUndoneCount()
+    const router = useRouter()
+    const route = useRoute()
+    const { menuTree } = useMenuTree()
+    const collapsed = computed({
+      get() {
+        if (appStore.device === 'desktop') return appStore.menuCollapse
+        return false
+      },
+      set(value: boolean) {
+        appStore.updateSettings({ menuCollapse: value })
+      }
+    })
+
+    const openKeys = ref<string[]>([])
+    const selectedKey = ref<string[]>([])
+
+    const goto = (item: RouteRecordRaw, e: PointerEvent) => {
+      e.stopPropagation()
+      // Open external link
+      if (regexUrl.test(item.path)) {
+        openWindow(item.path)
+        selectedKey.value = [item.name as string]
+        return
+      }
+      // Eliminate external link side effects
+      const { hideInMenu, activeMenu } = item.meta as RouteMeta
+      if (route.name === item.name && !hideInMenu && !activeMenu) {
+        selectedKey.value = [item.name as string]
+        return
+      }
+      // Trigger router change
+      router.push({
+        name: item.name
+      })
+    }
+
+    const expandSubMenu = (item: RouteRecordRaw) => {
+      console.log(selectedKey.value)
+      const { showBadge } = item.meta as RouteMeta
+      if (showBadge) {
+        toggle()
+      }
+    }
+    const findMenuOpenKeys = (name: string) => {
+      const result: string[] = []
+      let isFind = false
+      const backtrack = (
+        item: RouteRecordRaw,
+        keys: string[],
+        target: string
+      ) => {
+        if (item.name === target) {
+          isFind = true
+          result.push(...keys, item.name as string)
+          return
+        }
+        if (item.children?.length) {
+          item.children.forEach(el => {
+            backtrack(el, [...keys], target)
+          })
+        }
+      }
+      menuTree.value.forEach((el: RouteRecordRaw) => {
+        if (isFind) return // Performance optimization
+        backtrack(el, [el.name as string], name)
+      })
+      return result
+    }
+    const setCollapse = (val?: boolean) => {
+      if (appStore.device === 'desktop')
+        appStore.updateSettings({ menuCollapse: !!val })
+    }
+    listenerRouteChange(newRoute => {
+      const { requiresAuth, activeMenu, hideInMenu, menuCollapse } =
+        newRoute.meta
+      setCollapse(menuCollapse)
+      if (requiresAuth && (!hideInMenu || activeMenu)) {
+        const menuOpenKeys = findMenuOpenKeys(
+          (activeMenu || newRoute.name) as string
+        )
+        const keySet = new Set([...menuOpenKeys, ...openKeys.value])
+        openKeys.value = [...keySet]
+        const activeKey =
+          (activeMenu as string) || menuOpenKeys[menuOpenKeys.length - 1]
+        selectedKey.value = [activeKey]
+        if (selectedKey.value && newRoute.name === 'Dashboard') {
+          selectedKey.value = ['Home']
+        }
+
+        if (
+          ['AllWork', 'CompletedWork', 'IncompleteWork'].includes(
+            selectedKey.value[0]
+          )
+        ) {
+          subShowBadge.value = true
+        }
+        // newRoute.meta.showBadge = false
+      }
+    }, true)
+    // <icon-font type="icon-map" :size="16" />
+    const renderSubMenu = () => {
+      function travel(_route: RouteRecordRaw[], nodes = []) {
+        if (_route) {
+          _route.forEach(element => {
+            const icon = element?.meta?.icon
+              ? () => h(compile(`<${element?.meta?.icon}/>`))
+              : null
+            const node =
+              element?.children && element?.children.length !== 0 ? (
+                <a-sub-menu
+                  key={element?.name}
+                  selectable={true}
+                  class={element?.meta?.showBadge ? 'badge-sub-menu' : '2'}
+                  onClick={() => expandSubMenu(element)}
+                  v-slots={{
+                    icon,
+                    title: () =>
+                      element?.meta?.showBadge && !subShowBadge.value
+                        ? h(
+                            compile(`<a-badge
+                            :count="${Number(undoneCount.value) || 0}"
+                            :offset="[12, -2]"
+                            >
+                              ${element?.meta?.title}
+                            </a-badge>`)
+                          )
+                        : h(compile(element?.meta?.title as string))
+                  }}
+                >
+                  {travel(element?.children)}
+                </a-sub-menu>
+              ) : (
+                <a-menu-item
+                  key={element?.name}
+                  v-slots={{
+                    icon
+                  }}
+                  onClick={(e: any) => goto(element, e)}
+                >
+                  {element?.meta?.showBadge ? (
+                    <a-badge
+                      count={Number(undoneCount.value) || 0}
+                      offset={[12, -2]}
+                    >
+                      {element?.meta?.title}
+                    </a-badge>
+                  ) : (
+                    `${element?.meta?.title}`
+                  )}
+                </a-menu-item>
+              )
+            nodes.push(node as never)
+          })
+        }
+        return nodes
+      }
+      return travel(menuTree.value)
+    }
+
+    return () => (
+      <a-menu
+        v-model:collapsed={collapsed.value}
+        v-model:open-keys={openKeys.value}
+        show-collapse-button={appStore.device !== 'mobile'}
+        auto-open={false}
+        selected-keys={selectedKey.value}
+        auto-open-selected={true}
+        level-indent={34}
+        style="height: 100%"
+        onCollapse={setCollapse}
+      >
+        {renderSubMenu()}
+      </a-menu>
+    )
+  }
+})
+</script>
+
+<style lang="less" scoped>
+:deep(.arco-menu-inner) {
+  .arco-menu-inline-header {
+    display: flex;
+    align-items: center;
+  }
+
+  .arco-menu-icon {
+    margin-right: 5px;
+  }
+
+  .arco-icon {
+    &:not(.arco-icon-down) {
+      font-size: 18px;
+    }
+  }
+}
+:deep(.badge-sub-menu) {
+  .arco-menu-inline-header {
+    .arco-menu-title {
+      overflow: initial !important;
+    }
+  }
+}
+</style>

+ 66 - 0
src/components/menu/use-menu-tree.ts

@@ -0,0 +1,66 @@
+import { computed } from 'vue'
+import { RouteRecordRaw, RouteRecordNormalized } from 'vue-router'
+import { useAppStore, useUserStore } from '@/store'
+import appClientMenus from '@/router/app-menus'
+
+export default function useMenuTree() {
+  const appStore = useAppStore()
+  const userStore = useUserStore()
+  const { permissionsList } = userStore
+  const appRoute = computed(() => {
+    if (appStore.menuFromServer) {
+      return appStore.appAsyncMenus
+    }
+    return appClientMenus
+  })
+  const menuTree = computed(() => {
+    const copyRouter = JSON.parse(JSON.stringify(appRoute.value))
+    copyRouter.sort((a: RouteRecordNormalized, b: RouteRecordNormalized) => {
+      return (a.meta.order || 0) - (b.meta.order || 0)
+    })
+    function travel(_routes: RouteRecordRaw[], layer: number) {
+      if (!_routes) return null
+
+      const collector: any = _routes.map(element => {
+        if (element.meta?.hideChildrenInMenu || !element.children) {
+          element.children = []
+          return element
+        }
+
+        // route filter hideInMenu true
+        element.children = element.children.filter(x => {
+          const duplicatedValues = permissionsList?.filter(item =>
+            x.meta?.permissions?.includes(`api/${item}`)
+          )
+          const condition = duplicatedValues?.length !== 0
+          return x.meta?.hideInMenu !== true && condition
+        })
+        // Associated child node
+        const subItem = travel(element.children, layer + 1)
+
+        if (subItem.length) {
+          element.children = subItem
+          return element
+        }
+
+        // the else logic
+        if (layer > 1) {
+          element.children = subItem
+          return element
+        }
+
+        if (element.meta?.hideInMenu === false) {
+          return element
+        }
+
+        return null
+      })
+      return collector.filter(Boolean)
+    }
+    return travel(copyRouter, 0)
+  })
+
+  return {
+    menuTree
+  }
+}

+ 125 - 0
src/components/message-box/index.vue

@@ -0,0 +1,125 @@
+<template>
+  <a-spin style="display: block" :loading="loading">
+    <a-tabs v-model:activeKey="messageType" type="rounded" destroy-on-hide>
+      <a-tab-pane v-for="item in tabList" :key="item.key">
+        <template #title>
+          <span> {{ item.title }}{{ formatUnreadLength(item.key) }} </span>
+        </template>
+        <a-result v-if="!renderList.length" status="404">
+          <template #subtitle> 内容 </template>
+        </a-result>
+        <List :render-list="renderList" />
+      </a-tab-pane>
+      <template #extra>
+        <a-button type="text" @click="refresh"> 刷新 </a-button>
+      </template>
+    </a-tabs>
+  </a-spin>
+</template>
+
+<script lang="ts" setup>
+import { ref, reactive, toRefs, computed } from 'vue'
+import useLoading from '@/hooks/loading'
+import { Work } from '@/types/work'
+import { getWorkList } from '@/api/project/work'
+import List from './list.vue'
+
+interface TabItem {
+  key: number
+  title: string
+  avatar?: string
+}
+
+const props = defineProps({
+  prjId: {
+    type: String,
+    required: true
+  }
+})
+const { loading, setLoading } = useLoading(true)
+const messageType = ref(0)
+const messageData = reactive<{
+  renderList: Work[]
+  messageList: Work[]
+  taskList: Work[]
+}>({
+  renderList: [],
+  messageList: [],
+  taskList: []
+})
+toRefs(messageData)
+const tabList: TabItem[] = [
+  {
+    key: 0,
+    title: '进行中'
+  },
+  {
+    key: 1,
+    title: '已过期'
+  },
+  {
+    key: 2,
+    title: '已完成'
+  }
+]
+
+async function fetchSourceData() {
+  setLoading(true)
+  try {
+    const { list } = await getWorkList({
+      page_no: 1,
+      page_size: 9999,
+      prj_id: props.prjId
+    })
+    messageData.messageList = list
+  } catch (err) {
+    // you can report use errorHandler or other
+  } finally {
+    setLoading(false)
+  }
+}
+
+const renderList = computed(() => {
+  return messageData.messageList.filter(
+    item => messageType.value === item.status
+  )
+})
+
+const getUnreadList = (type: number) => {
+  const list = messageData.messageList.filter(
+    item => item.type === type && !item.status
+  )
+  return list
+}
+const formatUnreadLength = (type: number) => {
+  const list = getUnreadList(type)
+  return list.length ? `(${list.length})` : ``
+}
+
+const refresh = () => {
+  messageData.messageList = []
+  fetchSourceData()
+}
+
+fetchSourceData()
+</script>
+
+<style scoped lang="less">
+:deep(.arco-popover-popup-content) {
+  padding: 0;
+}
+
+:deep(.arco-list-item-meta) {
+  align-items: flex-start;
+}
+:deep(.arco-tabs-nav) {
+  padding-bottom: 16px;
+  border-bottom: 1px solid var(--color-neutral-3);
+}
+:deep(.arco-tabs-content) {
+  padding-top: 0;
+  .arco-result-subtitle {
+    color: rgb(var(--gray-6));
+  }
+}
+</style>

+ 170 - 0
src/components/message-box/list.vue

@@ -0,0 +1,170 @@
+<template>
+  <a-list :bordered="false" :max-height="480" :scrollbar="false">
+    <a-list-item
+      v-for="item in renderList"
+      :key="item.id"
+      :style="{
+        padding: '8px 15px'
+      }"
+    >
+      <a-list-item-meta>
+        <template #avatar>
+          <icon-font
+            v-if="item.process_type === 1"
+            type="my-to-be-done"
+            :size="60"
+          />
+          <icon-font v-else type="my-to-be-read" :size="60" />
+        </template>
+        <template #title>
+          <a-space :size="4">
+            <a-typography-text style="font-weight: bold">{{
+              item.name
+            }}</a-typography-text>
+            <a-typography-text type="secondary"> </a-typography-text>
+          </a-space>
+        </template>
+        <template #description>
+          <div>
+            <a-typography-paragraph
+              :ellipsis="{
+                rows: 1
+              }"
+              >{{ item.prj_name }}</a-typography-paragraph
+            >
+            <a-typography-text class="time-text">
+              {{ item.started_at }}
+            </a-typography-text>
+          </div>
+        </template>
+      </a-list-item-meta>
+      <template #actions>
+        <a-button
+          type="text"
+          :disabled="item.status === 2 && item.process_type === 1"
+          @click="processWorkInfo(item)"
+        >
+          {{ item.process_type === 1 ? '办理' : '阅读' }}
+        </a-button>
+      </template>
+    </a-list-item>
+    <div
+      v-if="renderList.length && renderList.length < 3"
+      :style="{ height: (showMax - renderList.length) * 86 + 'px' }"
+    ></div>
+  </a-list>
+  <!-- <a-list :bordered="false">
+    <a-list-item
+      v-for="item in renderList"
+      :key="item.id"
+      action-layout="vertical"
+      :style="{
+        opacity: item.status ? 0.5 : 1,
+        padding: '8px 10px'
+      }"
+    >
+      <template #actions>
+        <a-button type="text"> 刷新 </a-button>
+      </template>
+      <a-list-item-meta>
+        <template #avatar>
+          <icon-font
+            v-if="item.process_type === 1"
+            type="my-to-be-done"
+            :size="60"
+          />
+          <icon-font v-else type="my-to-be-read" :size="60" />
+        </template>
+        <template #title>
+          <a-space :size="4">
+            <a-typography-text style="font-weight: bold">{{
+              item.name
+            }}</a-typography-text>
+            <a-typography-text type="secondary"> </a-typography-text>
+          </a-space>
+        </template>
+        <template #description>
+          <div>
+            <a-typography-paragraph
+              :ellipsis="{
+                rows: 1
+              }"
+              >{{ item.prj_name }}</a-typography-paragraph
+            >
+            <a-typography-text class="time-text">
+              {{ item.started_at }}
+            </a-typography-text>
+          </div>
+        </template>
+      </a-list-item-meta>
+    </a-list-item>
+    <div
+      v-if="renderList.length && renderList.length < 3"
+      :style="{ height: (showMax - renderList.length) * 86 + 'px' }"
+    ></div>
+  </a-list> -->
+</template>
+
+<script lang="ts" setup>
+import { IconFontUrl, ProjectDetailTabTitleList } from '@/enums/api'
+import { useEmitt } from '@/logics/mitt/useEmitt'
+import { Work } from '@/types/work'
+import { Icon } from '@arco-design/web-vue'
+import { PropType } from 'vue'
+import { useRouter } from 'vue-router'
+
+defineProps({
+  renderList: {
+    type: Array as PropType<Work[]>,
+    required: true
+  }
+})
+
+const router = useRouter()
+const IconFont = Icon.addFromIconFontCn({
+  src: IconFontUrl.URL
+})
+
+const { emitter } = useEmitt()
+
+// 跳转到详情页面
+const processWorkInfo = (work: Work) => {
+  if (!work.target_type) {
+    emitter.emit('show_work_modal', work.id)
+    return
+  }
+  router.push({
+    name: ProjectDetailTabTitleList.find(item =>
+      item.redirectId?.includes(work.target_type)
+    )?.routerName,
+    params: { prjId: work.prj_id },
+    query: { ...work.params }
+  })
+  // emitter.emit('show_work_modal', work.id)
+}
+const showMax = 3
+</script>
+
+<style scoped lang="less">
+:deep(.arco-list) {
+  .arco-list-item {
+    min-height: 86px;
+    border-bottom: 1px solid rgb(var(--gray-3));
+  }
+
+  .item-wrap {
+    cursor: pointer;
+  }
+  .time-text {
+    font-size: 12px;
+    color: rgb(var(--gray-6));
+  }
+  .arco-empty {
+    display: none;
+  }
+
+  .arco-typography {
+    margin-bottom: 0;
+  }
+}
+</style>

+ 127 - 0
src/components/modal/index.vue

@@ -0,0 +1,127 @@
+<template>
+  <div>
+    <a-modal
+      v-bind="getBindValue"
+      :fullscreen="isFullscreen"
+      :title-align="'start'"
+      :unmount-on-close="true"
+      :draggable="true"
+      :width="finalyWidth"
+      :ok-text="okText"
+      :footer="showFooter"
+      :simple="simple"
+      :cancel-text="cancelText"
+      :esc-to-close="true"
+      :render-to-body="renderToBody"
+      :on-before-ok="handleBeforeOk"
+      @before-close="handleCancel"
+    >
+      <template #title>
+        <div class="modal-header">
+          <span class="modal-title">{{ title }}</span>
+          <span class="nav-btn" @click="toggleFullScreen">
+            <icon-fullscreen-exit v-if="isFullscreen" />
+            <icon-fullscreen v-else />
+          </span>
+        </div>
+      </template>
+      <!-- <a-scrollbar style="max-height: 800px; overflow: auto"> -->
+      <slot></slot>
+      <!-- </a-scrollbar> -->
+      <template v-if="slots.footer" #footer>
+        <slot name="footer"></slot>
+      </template>
+    </a-modal>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, useSlots, unref, computed, useAttrs } from 'vue'
+
+const slots = useSlots()
+const props = defineProps({
+  title: {
+    type: String,
+    default: ''
+  },
+  visible: {
+    type: Boolean,
+    default: false
+  },
+  width: {
+    type: [Number, String]
+  },
+  okText: {
+    type: String,
+    default: '确定'
+  },
+  cancelText: {
+    type: String,
+    default: '取消'
+  },
+  showFooter: {
+    type: Boolean,
+    default: true
+  },
+  simple: {
+    type: Boolean,
+    default: false
+  },
+  renderToBody: {
+    type: Boolean,
+    default: true
+  }
+})
+const emits = defineEmits<{
+  (e: 'modalHandleOk', val: any): void
+  (e: 'modalHandleCancel'): void
+}>()
+const isFullscreen = ref(false)
+const finalyWidth = computed(() => {
+  if (isFullscreen.value) {
+    return undefined
+  }
+  return props.width
+})
+const toggleFullScreen = () => {
+  isFullscreen.value = !unref(isFullscreen)
+}
+
+const getBindValue = computed(() => {
+  const attrs = useAttrs()
+  const obj = { ...attrs, ...props }
+  return obj
+})
+
+// 监听确认事件
+const handleBeforeOk = (done: (closed: boolean) => void) => {
+  emits('modalHandleOk', done)
+}
+
+// 取消
+const handleCancel = () => {
+  isFullscreen.value = false
+  emits('modalHandleCancel')
+}
+</script>
+
+<style scoped lang="less">
+.modal-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  width: 100%;
+  .modal-title {
+    line-height: 24px;
+    font-size: 18px;
+    // color: #303133;
+  }
+  .nav-btn {
+    cursor: pointer;
+    border-color: rgb(var(--gray-2));
+    color: rgb(var(--gray-8));
+    font-size: 14px;
+    margin-right: 20px;
+  }
+}
+</style>

+ 227 - 0
src/components/navbar/index.vue

@@ -0,0 +1,227 @@
+<template>
+  <div class="navbar">
+    <div class="left-side">
+      <a-space>
+        <div class="logo">
+          <!-- <img alt="logo" :src="logo" width="30" height="30" /> -->
+          <div class="logo-container">
+            <div class="logo-text">
+              <!-- <img alt="logo" :src="bannerImage" width="30" height="30" /> -->
+              <span>Logo</span>
+            </div>
+          </div>
+        </div>
+        <a-typography-title
+          :style="{ margin: 0, fontSize: '20px' }"
+          :heading="5"
+        >
+          项目管理系统
+        </a-typography-title>
+        <icon-menu-fold
+          v-if="appStore.device === 'mobile'"
+          style="font-size: 22px; cursor: pointer"
+          @click="toggleDrawerMenu"
+        />
+      </a-space>
+    </div>
+    <ul class="right-side">
+      <li>
+        <a-tooltip :content="theme === 'light' ? '暗黑模式' : '明亮模式'">
+          <a-button
+            class="nav-btn"
+            type="outline"
+            :shape="'circle'"
+            @click="handleToggleTheme"
+          >
+            <template #icon>
+              <icon-moon-fill v-if="theme === 'dark'" />
+              <icon-sun-fill v-else />
+            </template>
+          </a-button>
+        </a-tooltip>
+      </li>
+      <li>
+        <a-tooltip :content="isFullscreen ? '退出全屏' : '全屏'">
+          <a-button
+            class="nav-btn"
+            type="outline"
+            :shape="'circle'"
+            @click="toggleFullScreen"
+          >
+            <template #icon>
+              <icon-fullscreen-exit v-if="isFullscreen" />
+              <icon-fullscreen v-else />
+            </template>
+          </a-button>
+        </a-tooltip>
+      </li>
+      <!-- <li>
+        <a-tooltip content="设置">
+          <a-button
+            class="nav-btn"
+            type="outline"
+            :shape="'circle'"
+            @click="setVisible"
+          >
+            <template #icon>
+              <icon-settings />
+            </template>
+          </a-button>
+        </a-tooltip>
+      </li> -->
+      <li>
+        <a-dropdown trigger="hover" position="bl">
+          <a-avatar
+            :size="32"
+            :style="{
+              cursor: 'pointer',
+              backgroundColor: 'rgb(var(--primary-6))'
+            }"
+          >
+            <IconUser />
+          </a-avatar>
+          <template #content>
+            <a-doption>
+              <a-space @click="$router.push({ name: 'Self' })">
+                <icon-user />
+                <span> 个人中心 </span>
+              </a-space>
+            </a-doption>
+            <a-doption>
+              <a-space @click="handleLogout">
+                <icon-export />
+                <span> 退出 </span>
+              </a-space>
+            </a-doption>
+          </template>
+        </a-dropdown>
+      </li>
+    </ul>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed, inject } from 'vue'
+import { useDark, useToggle, useFullscreen } from '@vueuse/core'
+import { useAppStore, useUserStore } from '@/store'
+import useUser from '@/hooks/user'
+import { IconUser } from '@arco-design/web-vue/es/icon'
+// import logo from '@/assets/images/logo.png'
+// import bannerImage from '@/assets/images/logo.png'
+
+const userStore = useUserStore()
+const { getRouter } = userStore
+
+const appStore = useAppStore()
+const { logout } = useUser(getRouter)
+const { isFullscreen, toggle: toggleFullScreen } = useFullscreen()
+const theme = computed(() => {
+  return appStore.theme
+})
+const isDark = useDark({
+  selector: 'body',
+  attribute: 'arco-theme',
+  valueDark: 'dark',
+  valueLight: 'light',
+  storageKey: 'arco-theme',
+  onChanged(dark: boolean) {
+    // overridden default behavior
+    appStore.toggleTheme(dark)
+  }
+})
+const toggleTheme = useToggle(isDark)
+const handleToggleTheme = () => {
+  toggleTheme()
+}
+// const setVisible = () => {
+//   appStore.updateSettings({ globalSettings: true })
+// }
+const handleLogout = () => {
+  logout()
+}
+const toggleDrawerMenu: any = inject('toggleDrawerMenu')
+</script>
+
+<style scoped lang="less">
+.navbar {
+  display: flex;
+  justify-content: space-between;
+  height: 100%;
+  background-color: var(--color-bg-2);
+  border-bottom: 1px solid var(--color-border);
+}
+
+.left-side {
+  display: flex;
+  align-items: center;
+  padding-left: 15px;
+  .logo {
+    width: 40px;
+    height: 40px;
+    border-radius: 5px;
+    .logo-container {
+      width: 100%;
+      height: 100%;
+      .logo-text {
+        display: flex;
+        border-radius: 6px;
+        align-items: center;
+        justify-content: center;
+        box-sizing: border-box;
+        width: 100%;
+        height: 100%;
+        overflow: hidden;
+        text-align: center;
+        white-space: pre-wrap;
+        background-color: #adddf3;
+        span {
+          font-size: 14px;
+          color: #fff;
+          font-weight: 700;
+          line-height: 16px;
+          padding: 2px;
+        }
+      }
+    }
+    // border: 1px solid #ebeef5;
+    // background: #fff;
+  }
+}
+
+.right-side {
+  display: flex;
+  padding-right: 20px;
+  list-style: none;
+
+  :deep(.locale-select) {
+    border-radius: 20px;
+  }
+
+  li {
+    display: flex;
+    align-items: center;
+    padding: 0 10px;
+  }
+
+  a {
+    color: var(--color-text-1);
+    text-decoration: none;
+  }
+
+  .nav-btn {
+    border-color: rgb(var(--gray-2));
+    color: rgb(var(--gray-8));
+    font-size: 16px;
+  }
+
+  .trigger-btn,
+  .ref-btn {
+    position: absolute;
+    bottom: 14px;
+  }
+
+  .trigger-btn {
+    margin-left: 14px;
+  }
+}
+</style>

+ 21 - 0
src/components/page/index.ts

@@ -0,0 +1,21 @@
+import { reactive } from 'vue'
+
+export interface IPage {
+  pageIndex: number
+  pageSize: number
+  total: number
+  sizes: number[]
+}
+
+export default () => {
+  const page: IPage = reactive({
+    pageIndex: 1,
+    pageSize: 10,
+    total: 0,
+    sizes: [10, 20, 50, 100, 200]
+  })
+
+  return {
+    page
+  }
+}

+ 62 - 0
src/components/page/index.vue

@@ -0,0 +1,62 @@
+<template>
+  <a-pagination
+    style="margin-top: 20px; justify-content: end"
+    background
+    show-total
+    show-jumper
+    :show-page-size="!simple"
+    :simple="simple"
+    :current="page.pageIndex"
+    :page-size-options="page.sizes"
+    :page-size="page.pageSize"
+    :total="page.total"
+    @change="currentChangeHandle"
+    @page-size-change="sizeChangeHandle"
+  />
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue'
+
+export default defineComponent({
+  props: {
+    page: {
+      type: Object,
+      required: true
+    },
+    simple: {
+      type: Boolean,
+      required: false
+    }
+  },
+  emits: ['change'],
+  setup(props, { emit }) {
+    /**
+     * 当前页变化事件
+     * @param pageIndex
+     */
+    const currentChangeHandle = (pageIndex: number): void => {
+      emit('change', { pageIndex, pageSize: props.page.pageSize })
+    }
+
+    /**
+     * 当前页数发送变化事件
+     * @param pageSize 页数
+     */
+    const sizeChangeHandle = (pageSize: number): void => {
+      emit('change', { pageIndex: props.page.pageIndex, pageSize })
+    }
+
+    return {
+      currentChangeHandle,
+      sizeChangeHandle
+    }
+  }
+})
+</script>
+
+<style lang="less" scoped>
+.page {
+  text-align: center;
+}
+</style>

+ 172 - 0
src/components/tab-bar/index.vue

@@ -0,0 +1,172 @@
+<template>
+  <div class="tab-bar-container">
+    <a-affix ref="affixRef" :offset-top="offsetTop">
+      <div class="tab-bar-box">
+        <div class="tab-bar-scroll">
+          <div class="tags-wrap">
+            <a-tabs
+              :active-key="activeTabKey"
+              type="card-gutter"
+              :justify="true"
+              :auto-switch="true"
+              :hide-content="true"
+              size="medium"
+              :editable="true"
+              @tab-click="goto($event as string)"
+              @delete="tagClose($event as string)"
+            >
+              <a-tab-pane
+                v-for="(tag, index) in tagList"
+                :key="tag.fullPath"
+                :closable="
+                  tag.fullPath === $route.fullPath &&
+                  tag.name !== DEFAULT_ROUTE_NAME
+                "
+              >
+                <template #title>
+                  <tab-item
+                    :key="tag.fullPath"
+                    :index="index"
+                    :item-data="tag"
+                  />
+                </template>
+              </a-tab-pane>
+            </a-tabs>
+          </div>
+        </div>
+      </div>
+    </a-affix>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed, watch } from 'vue'
+import type { RouteLocationNormalized } from 'vue-router'
+import { useAppStore, useTabBarStore } from '@/store'
+import { DEFAULT_ROUTE_NAME } from '@/router/routes/index'
+import { listenerRouteChange } from '@/logics/mitt/routeChange'
+import type { TagProps } from '@/store/modules/tab-bar/types'
+import { onBeforeRouteUpdate, useRouter, useRoute } from 'vue-router'
+import tabItem from './tab-item.vue'
+
+const appStore = useAppStore()
+const router = useRouter()
+const route = useRoute()
+const tabBarStore = useTabBarStore()
+const activeTabKey = ref('')
+
+const affixRef = ref()
+const tagList = computed(() => {
+  return tabBarStore.getTabList
+})
+const offsetTop = computed(() => {
+  return appStore.navbar ? 60 : 0
+})
+
+/**
+ * 根据tabs点击事件跳转到相对于的路由
+ */
+const goto = (key: string) => {
+  const tag: TagProps = tagList.value.find(item => item.fullPath === key)!
+  router.push({ ...tag })
+}
+
+/**
+ * 点击tabs删除按钮时
+ */
+const tagClose = (key: string) => {
+  const idx: number = tagList.value.findIndex(item => item.fullPath === key)
+  const tag: TagProps = tagList.value[idx]
+  tabBarStore.deleteTag(idx, tag)
+  if (tag.fullPath === route.fullPath) {
+    const parent = tagList.value.find(
+      item => item.fullPath === tag.parent.fullPath
+    )
+    if (parent) {
+      router.push({
+        name: tag.parent!.name,
+        params: tag.parent!.params,
+        query: tag.parent!.query
+      })
+    } else {
+      const latest = tagList.value[idx - 1] // 获取队列的前一个tab
+      router.push({
+        name: latest.name,
+        params: latest.params,
+        query: latest.query
+      })
+    }
+  }
+}
+
+watch(
+  () => appStore.navbar,
+  () => {
+    affixRef.value.updatePosition()
+  }
+)
+listenerRouteChange(async (r: RouteLocationNormalized) => {
+  if (
+    !route.meta.noAffix &&
+    !tagList.value.some(tag => tag.fullPath === r.fullPath)
+  ) {
+    tabBarStore.updateTabList(r)
+  }
+  setTimeout(() => {
+    activeTabKey.value = r.fullPath
+  }, 200) // 修复tabs内容宽度超出容器宽度时,未滚至最右或最左的bug
+}, true)
+onBeforeRouteUpdate((to, from) => {
+  if (to.meta.reset && from.fullPath !== '/') {
+    const tab = tabBarStore.getTabList.find(item => {
+      return item.fullPath === to.fullPath
+    })
+    if (tab && tab.parent.fullPath === undefined) {
+      if (to.name === from.name) {
+        tab.parent = from.meta.parent
+        return
+      }
+      tab.parent = from
+      to.meta.parent = from
+    }
+  }
+})
+</script>
+
+<style scoped lang="less">
+.tab-bar-container {
+  position: relative;
+  background-color: var(--color-bg-2);
+  .tab-bar-box {
+    display: flex;
+    padding: 5px 20px 0 20px;
+    color: var(--color-text-1);
+    background-color: var(--color-bg-2);
+    border-bottom: 1px solid var(--color-border);
+    .tab-bar-scroll {
+      height: 48px;
+      // width: 100%;
+      flex: 1;
+      overflow: hidden;
+      .tags-wrap {
+        padding: 4px 0;
+        height: 48px;
+        white-space: nowrap;
+        overflow-x: auto;
+
+        :deep(.arco-tag) {
+          display: inline-flex;
+          align-items: center;
+          padding: 0;
+          cursor: pointer;
+        }
+        :deep(.arco-icon-hover) {
+          margin-top: 1px;
+          margin-left: 4px;
+          font-size: 12px;
+        }
+      }
+    }
+  }
+}
+</style>

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů