commit 1c30c4a1e25c56833eb0f2af834be333bcb5ea23
Author: riskcn <riskcn@163.com>
Date:   Tue Nov 5 09:59:02 2024 +0800

    first commit

diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..3454886
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,14 @@
+# https://editorconfig.org
+root = true
+
+[*]
+charset = utf-8
+indent_style = space
+indent_size = 2
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.md]
+insert_final_newline = false
+trim_trailing_whitespace = false
diff --git a/.env b/.env
new file mode 100644
index 0000000..5399ead
--- /dev/null
+++ b/.env
@@ -0,0 +1,11 @@
+#VITE_APP_NAME=珞璜临港产业城
+#VITE_API_URL=http://apil.cqtlcm.com/api
+#VITE_HOME_URL=http://lhyq.cqtlcm.com
+
+#VITE_APP_NAME=滨江新城
+#VITE_API_URL=http://apib.cqtlcm.com/api
+#VITE_HOME_URL=http://bjxc.cqtlcm.com
+
+VITE_APP_NAME=科学城江津管委会
+VITE_API_URL=http://apik.cqtlcm.com/api
+VITE_HOME_URL=http://kxc.cqtlcm.com
\ No newline at end of file
diff --git a/.env.development b/.env.development
new file mode 100644
index 0000000..5399ead
--- /dev/null
+++ b/.env.development
@@ -0,0 +1,11 @@
+#VITE_APP_NAME=珞璜临港产业城
+#VITE_API_URL=http://apil.cqtlcm.com/api
+#VITE_HOME_URL=http://lhyq.cqtlcm.com
+
+#VITE_APP_NAME=滨江新城
+#VITE_API_URL=http://apib.cqtlcm.com/api
+#VITE_HOME_URL=http://bjxc.cqtlcm.com
+
+VITE_APP_NAME=科学城江津管委会
+VITE_API_URL=http://apik.cqtlcm.com/api
+VITE_HOME_URL=http://kxc.cqtlcm.com
\ No newline at end of file
diff --git a/.env.production b/.env.production
new file mode 100644
index 0000000..5399ead
--- /dev/null
+++ b/.env.production
@@ -0,0 +1,11 @@
+#VITE_APP_NAME=珞璜临港产业城
+#VITE_API_URL=http://apil.cqtlcm.com/api
+#VITE_HOME_URL=http://lhyq.cqtlcm.com
+
+#VITE_APP_NAME=滨江新城
+#VITE_API_URL=http://apib.cqtlcm.com/api
+#VITE_HOME_URL=http://bjxc.cqtlcm.com
+
+VITE_APP_NAME=科学城江津管委会
+VITE_API_URL=http://apik.cqtlcm.com/api
+VITE_HOME_URL=http://kxc.cqtlcm.com
\ No newline at end of file
diff --git a/.eslintignore b/.eslintignore
new file mode 100644
index 0000000..46b1426
--- /dev/null
+++ b/.eslintignore
@@ -0,0 +1,4 @@
+public
+src/assets
+dist
+node_modules
diff --git a/.eslintrc.js b/.eslintrc.js
new file mode 100644
index 0000000..3f64efc
--- /dev/null
+++ b/.eslintrc.js
@@ -0,0 +1,78 @@
+// @ts-check
+const { defineConfig } = require('eslint-define-config');
+
+module.exports = defineConfig({
+  root: true,
+  env: {
+    browser: true,
+    node: true,
+    es6: true
+  },
+  parser: 'vue-eslint-parser',
+  extends: [
+    'plugin:vue/vue3-recommended',
+    'plugin:@typescript-eslint/recommended',
+    'plugin:prettier/recommended'
+  ],
+  parserOptions: {
+    parser: '@typescript-eslint/parser',
+    ecmaVersion: 2020,
+    sourceType: 'module',
+    jsxPragma: 'React',
+    ecmaFeatures: {
+      jsx: true
+    }
+  },
+  rules: {
+    'vue/script-setup-uses-vars': 'error',
+    '@typescript-eslint/ban-ts-ignore': 'off',
+    '@typescript-eslint/explicit-function-return-type': 'off',
+    '@typescript-eslint/no-explicit-any': 'off',
+    '@typescript-eslint/no-var-requires': 'off',
+    '@typescript-eslint/no-empty-function': 'off',
+    'vue/custom-event-name-casing': 'off',
+    'no-use-before-define': 'off',
+    '@typescript-eslint/no-use-before-define': 'off',
+    '@typescript-eslint/ban-ts-comment': 'off',
+    '@typescript-eslint/ban-types': 'off',
+    '@typescript-eslint/no-non-null-assertion': 'off',
+    '@typescript-eslint/explicit-module-boundary-types': 'off',
+    '@typescript-eslint/no-unused-vars': [
+      'error',
+      {
+        argsIgnorePattern: '^_',
+        varsIgnorePattern: '^_'
+      }
+    ],
+    'no-unused-vars': [
+      'error',
+      {
+        argsIgnorePattern: '^_',
+        varsIgnorePattern: '^_'
+      }
+    ],
+    'space-before-function-paren': 'off',
+    'vue/attributes-order': 'off',
+    'vue/one-component-per-file': 'off',
+    'vue/html-closing-bracket-newline': 'off',
+    'vue/max-attributes-per-line': 'off',
+    'vue/multiline-html-element-content-newline': 'off',
+    'vue/singleline-html-element-content-newline': 'off',
+    'vue/attribute-hyphenation': 'off',
+    'vue/require-default-prop': 'off',
+    'vue/html-self-closing': [
+      'error',
+      {
+        html: {
+          void: 'always',
+          normal: 'never',
+          component: 'always'
+        },
+        svg: 'always',
+        math: 'always'
+      }
+    ],
+    'vue/v-on-event-hyphenation': 'off',
+    'vue/multi-word-component-names': 'off'
+  }
+});
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..eca40d3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,25 @@
+.DS_Store
+node_modules
+/dist
+/dist-ssr
+
+# local env files
+#.env.local
+#.env.*.local
+
+# Log files
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+.pnpm-debug.log
+.eslintcache
+
+# Editor directories and files
+.idea
+.vscode
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..f7e39e6
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,9 @@
+/dist/*
+.local
+.output.js
+/node_modules/**
+
+**/*.svg
+**/*.sh
+
+/public/*
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..a797a27
--- /dev/null
+++ b/README.md
@@ -0,0 +1,27 @@
+# Vue 3 + Typescript + Vite
+
+This template should help get you started developing with Vue 3 and Typescript in Vite.
+
+## Recommended IDE Setup
+
+[VSCode](https://code.visualstudio.com/) + [Vetur](https://marketplace.visualstudio.com/items?itemName=octref.vetur). Make sure to enable `vetur.experimental.templateInterpolationService` in settings!
+
+### If Using `<script setup>`
+
+[`<script setup>`](https://github.com/vuejs/rfcs/pull/227) is a feature that is currently in RFC stage. To get proper IDE support for the syntax, use [Volar](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.volar) instead of Vetur (and disable Vetur).
+
+## Type Support For `.vue` Imports in TS
+
+Since TypeScript cannot handle type information for `.vue` imports, they are shimmed to be a generic Vue component type by default. In most cases this is fine if you don't really care about component prop types outside of templates. However, if you wish to get actual prop types in `.vue` imports (for example to get props validation when using manual `h(...)` calls), you can use the following:
+
+### If Using Volar
+
+Run `Volar: Switch TS Plugin on/off` from VSCode command palette.
+
+### If Using Vetur
+
+1. Install and add `@vuedx/typescript-plugin-vue` to the [plugins section](https://www.typescriptlang.org/tsconfig#plugins) in `tsconfig.json`
+2. Delete `src/shims-vue.d.ts` as it is no longer needed to provide module info to Typescript
+3. Open `src/main.ts` in VSCode
+4. Open the VSCode command palette
+5. Search and run "Select TypeScript version" -> "Use workspace version"
diff --git a/components.d.ts b/components.d.ts
new file mode 100644
index 0000000..6429be2
--- /dev/null
+++ b/components.d.ts
@@ -0,0 +1,2 @@
+import 'ant-design-vue/typings/global';
+import 'ele-admin-pro/typings/global';
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..f8aba46
--- /dev/null
+++ b/index.html
@@ -0,0 +1,64 @@
+<!DOCTYPE html>
+<html lang="zh">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" href="/favicon.ico" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>EleAdminPro</title>
+    <style>
+      .ele-admin-loading {
+        width: 36px;
+        font-size: 0;
+        display: inline-block;
+        transform: rotate(45deg);
+        animation: loadingRotate 1.2s infinite linear;
+        position: relative;
+        top: calc(50% - 18px);
+        left: calc(50% - 18px);
+      }
+
+      .ele-admin-loading span {
+        width: 10px;
+        height: 10px;
+        margin: 4px;
+        border-radius: 50%;
+        background: #1890ff;
+        display: inline-block;
+        opacity: 0.9;
+      }
+
+      .ele-admin-loading span:nth-child(2) {
+        opacity: 0.7;
+      }
+
+      .ele-admin-loading span:nth-child(3) {
+        opacity: 0.5;
+      }
+
+      .ele-admin-loading span:nth-child(4) {
+        opacity: 0.3;
+      }
+
+      @keyframes loadingRotate {
+        to {
+          transform: rotate(405deg);
+        }
+      }
+
+      #app > .ele-admin-loading {
+        position: fixed;
+      }
+    </style>
+  </head>
+  <body>
+    <div id="app">
+      <div class="ele-admin-loading">
+        <span></span>
+        <span></span>
+        <span></span>
+        <span></span>
+      </div>
+    </div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..87253d8
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,8353 @@
+{
+  "name": "ele-admin-pro-template",
+  "version": "1.11.1",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "ele-admin-pro-template",
+      "version": "1.11.1",
+      "dependencies": {
+        "@amap/amap-jsapi-loader": "^1.0.1",
+        "@ant-design/colors": "^7.0.0",
+        "@ant-design/icons-vue": "^6.1.0",
+        "@bytemd/plugin-gfm": "^1.21.0",
+        "ant-design-vue": "^3.2.17",
+        "axios": "^1.3.5",
+        "bytemd": "^1.21.0",
+        "countup.js": "^2.6.0",
+        "cropperjs": "^1.5.13",
+        "dayjs": "^1.11.7",
+        "echarts": "^5.4.2",
+        "echarts-wordcloud": "^2.1.0",
+        "ele-admin-pro": "^1.11.1",
+        "github-markdown-css": "^5.2.0",
+        "jsbarcode": "^3.11.5",
+        "lodash-es": "^4.17.21",
+        "nprogress": "^0.2.0",
+        "pinia": "^2.0.34",
+        "sortablejs": "^1.15.0",
+        "tinymce": "^5.10.7",
+        "vue": "^3.2.47",
+        "vue-echarts": "^6.5.4",
+        "vue-i18n": "^9.2.2",
+        "vue-router": "^4.1.6",
+        "vuedraggable": "^4.1.0",
+        "xgplayer": "^3.0.1",
+        "xlsx": "^0.18.5"
+      },
+      "devDependencies": {
+        "@types/lodash-es": "^4.17.7",
+        "@types/node": "^18.15.11",
+        "@types/nprogress": "^0.2.0",
+        "@types/sortablejs": "^1.15.1",
+        "@typescript-eslint/eslint-plugin": "^5.58.0",
+        "@typescript-eslint/parser": "^5.58.0",
+        "@vitejs/plugin-legacy": "^4.0.2",
+        "@vitejs/plugin-vue": "^4.1.0",
+        "@vue/compiler-sfc": "^3.2.47",
+        "eslint": "^8.38.0",
+        "eslint-config-prettier": "^8.8.0",
+        "eslint-define-config": "^1.18.0",
+        "eslint-plugin-prettier": "^4.2.1",
+        "eslint-plugin-vue": "^9.11.0",
+        "less": "^4.1.3",
+        "postcss": "^8.4.22",
+        "prettier": "^2.8.7",
+        "rimraf": "^5.0.0",
+        "terser": "^5.16.9",
+        "typescript": "^5.0.4",
+        "unplugin-vue-components": "^0.24.1",
+        "vite": "^4.2.1",
+        "vite-plugin-compression": "^0.5.1",
+        "vue-eslint-parser": "^9.1.1",
+        "vue-tsc": "^1.2.0"
+      }
+    },
+    "node_modules/@aashutoshrathi/word-wrap": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz",
+      "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/@amap/amap-jsapi-loader": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@amap/amap-jsapi-loader/-/amap-jsapi-loader-1.0.1.tgz",
+      "integrity": "sha512-nPyLKt7Ow/ThHLkSvn2etQlUzqxmTVgK7bIgwdBRTg2HK5668oN7xVxkaiRe3YZEzGzfV2XgH5Jmu2T73ljejw=="
+    },
+    "node_modules/@ampproject/remapping": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz",
+      "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==",
+      "dev": true,
+      "dependencies": {
+        "@jridgewell/gen-mapping": "^0.3.0",
+        "@jridgewell/trace-mapping": "^0.3.9"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@ant-design/colors": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.0.0.tgz",
+      "integrity": "sha512-iVm/9PfGCbC0dSMBrz7oiEXZaaGH7ceU40OJEfKmyuzR9R5CRimJYPlRiFtMQGQcbNMea/ePcoIebi4ASGYXtg==",
+      "dependencies": {
+        "@ctrl/tinycolor": "^3.4.0"
+      }
+    },
+    "node_modules/@ant-design/icons-svg": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.3.0.tgz",
+      "integrity": "sha512-WOgvdH/1Wl8Z7VXigRbCa5djO14zxrNTzvrAQzhWiBQtEKT0uTc8K1ltjKZ8U1gPn/wXhMA8/jE39SJl0WNxSg=="
+    },
+    "node_modules/@ant-design/icons-vue": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/@ant-design/icons-vue/-/icons-vue-6.1.0.tgz",
+      "integrity": "sha512-EX6bYm56V+ZrKN7+3MT/ubDkvJ5rK/O2t380WFRflDcVFgsvl3NLH7Wxeau6R8DbrO5jWR6DSTC3B6gYFp77AA==",
+      "dependencies": {
+        "@ant-design/colors": "^6.0.0",
+        "@ant-design/icons-svg": "^4.2.1"
+      },
+      "peerDependencies": {
+        "vue": ">=3.0.3"
+      }
+    },
+    "node_modules/@ant-design/icons-vue/node_modules/@ant-design/colors": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-6.0.0.tgz",
+      "integrity": "sha512-qAZRvPzfdWHtfameEGP2Qvuf838NhergR35o+EuVyB5XvSA98xod5r4utvi4TJ3ywmevm290g9nsCG5MryrdWQ==",
+      "dependencies": {
+        "@ctrl/tinycolor": "^3.4.0"
+      }
+    },
+    "node_modules/@antfu/utils": {
+      "version": "0.7.5",
+      "resolved": "https://registry.npmjs.org/@antfu/utils/-/utils-0.7.5.tgz",
+      "integrity": "sha512-dlR6LdS+0SzOAPx/TPRhnoi7hE251OVeT2Snw0RguNbBSbjUHdWr0l3vcUUDg26rEysT89kCbtw1lVorBXLLCg==",
+      "dev": true,
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/@babel/code-frame": {
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.10.tgz",
+      "integrity": "sha512-/KKIMG4UEL35WmI9OlvMhurwtytjvXoFcGNrOvyG9zIzA8YmPjVtIZUf7b05+TPO7G7/GEmLHDaoCgACHl9hhA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/highlight": "^7.22.10",
+        "chalk": "^2.4.2"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/compat-data": {
+      "version": "7.22.9",
+      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.22.9.tgz",
+      "integrity": "sha512-5UamI7xkUcJ3i9qVDS+KFDEK8/7oJ55/sJMB1Ge7IEapr7KfdfV/HErR+koZwOfd+SgtFKOKRhRakdg++DcJpQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/core": {
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.22.10.tgz",
+      "integrity": "sha512-fTmqbbUBAwCcre6zPzNngvsI0aNrPZe77AeqvDxWM9Nm+04RrJ3CAmGHA9f7lJQY6ZMhRztNemy4uslDxTX4Qw==",
+      "dev": true,
+      "dependencies": {
+        "@ampproject/remapping": "^2.2.0",
+        "@babel/code-frame": "^7.22.10",
+        "@babel/generator": "^7.22.10",
+        "@babel/helper-compilation-targets": "^7.22.10",
+        "@babel/helper-module-transforms": "^7.22.9",
+        "@babel/helpers": "^7.22.10",
+        "@babel/parser": "^7.22.10",
+        "@babel/template": "^7.22.5",
+        "@babel/traverse": "^7.22.10",
+        "@babel/types": "^7.22.10",
+        "convert-source-map": "^1.7.0",
+        "debug": "^4.1.0",
+        "gensync": "^1.0.0-beta.2",
+        "json5": "^2.2.2",
+        "semver": "^6.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/babel"
+      }
+    },
+    "node_modules/@babel/core/node_modules/semver": {
+      "version": "6.3.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+      "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+      "dev": true,
+      "bin": {
+        "semver": "bin/semver.js"
+      }
+    },
+    "node_modules/@babel/generator": {
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.22.10.tgz",
+      "integrity": "sha512-79KIf7YiWjjdZ81JnLujDRApWtl7BxTqWD88+FFdQEIOG8LJ0etDOM7CXuIgGJa55sGOwZVwuEsaLEm0PJ5/+A==",
+      "dev": true,
+      "dependencies": {
+        "@babel/types": "^7.22.10",
+        "@jridgewell/gen-mapping": "^0.3.2",
+        "@jridgewell/trace-mapping": "^0.3.17",
+        "jsesc": "^2.5.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-annotate-as-pure": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz",
+      "integrity": "sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==",
+      "dev": true,
+      "dependencies": {
+        "@babel/types": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-builder-binary-assignment-operator-visitor": {
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.22.10.tgz",
+      "integrity": "sha512-Av0qubwDQxC56DoUReVDeLfMEjYYSN1nZrTUrWkXd7hpU73ymRANkbuDm3yni9npkn+RXy9nNbEJZEzXr7xrfQ==",
+      "dev": true,
+      "dependencies": {
+        "@babel/types": "^7.22.10"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-compilation-targets": {
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.10.tgz",
+      "integrity": "sha512-JMSwHD4J7SLod0idLq5PKgI+6g/hLD/iuWBq08ZX49xE14VpVEojJ5rHWptpirV2j020MvypRLAXAO50igCJ5Q==",
+      "dev": true,
+      "dependencies": {
+        "@babel/compat-data": "^7.22.9",
+        "@babel/helper-validator-option": "^7.22.5",
+        "browserslist": "^4.21.9",
+        "lru-cache": "^5.1.1",
+        "semver": "^6.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-compilation-targets/node_modules/semver": {
+      "version": "6.3.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+      "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+      "dev": true,
+      "bin": {
+        "semver": "bin/semver.js"
+      }
+    },
+    "node_modules/@babel/helper-create-class-features-plugin": {
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.22.10.tgz",
+      "integrity": "sha512-5IBb77txKYQPpOEdUdIhBx8VrZyDCQ+H82H0+5dX1TmuscP5vJKEE3cKurjtIw/vFwzbVH48VweE78kVDBrqjA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-annotate-as-pure": "^7.22.5",
+        "@babel/helper-environment-visitor": "^7.22.5",
+        "@babel/helper-function-name": "^7.22.5",
+        "@babel/helper-member-expression-to-functions": "^7.22.5",
+        "@babel/helper-optimise-call-expression": "^7.22.5",
+        "@babel/helper-replace-supers": "^7.22.9",
+        "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5",
+        "@babel/helper-split-export-declaration": "^7.22.6",
+        "semver": "^6.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": {
+      "version": "6.3.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+      "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+      "dev": true,
+      "bin": {
+        "semver": "bin/semver.js"
+      }
+    },
+    "node_modules/@babel/helper-create-regexp-features-plugin": {
+      "version": "7.22.9",
+      "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.22.9.tgz",
+      "integrity": "sha512-+svjVa/tFwsNSG4NEy1h85+HQ5imbT92Q5/bgtS7P0GTQlP8WuFdqsiABmQouhiFGyV66oGxZFpeYHza1rNsKw==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-annotate-as-pure": "^7.22.5",
+        "regexpu-core": "^5.3.1",
+        "semver": "^6.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": {
+      "version": "6.3.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+      "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+      "dev": true,
+      "bin": {
+        "semver": "bin/semver.js"
+      }
+    },
+    "node_modules/@babel/helper-define-polyfill-provider": {
+      "version": "0.4.2",
+      "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.4.2.tgz",
+      "integrity": "sha512-k0qnnOqHn5dK9pZpfD5XXZ9SojAITdCKRn2Lp6rnDGzIbaP0rHyMPk/4wsSxVBVz4RfN0q6VpXWP2pDGIoQ7hw==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-compilation-targets": "^7.22.6",
+        "@babel/helper-plugin-utils": "^7.22.5",
+        "debug": "^4.1.1",
+        "lodash.debounce": "^4.0.8",
+        "resolve": "^1.14.2"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
+      }
+    },
+    "node_modules/@babel/helper-environment-visitor": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.5.tgz",
+      "integrity": "sha512-XGmhECfVA/5sAt+H+xpSg0mfrHq6FzNr9Oxh7PSEBBRUb/mL7Kz3NICXb194rCqAEdxkhPT1a88teizAFyvk8Q==",
+      "dev": true,
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-function-name": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.22.5.tgz",
+      "integrity": "sha512-wtHSq6jMRE3uF2otvfuD3DIvVhOsSNshQl0Qrd7qC9oQJzHvOL4qQXlQn2916+CXGywIjpGuIkoyZRRxHPiNQQ==",
+      "dev": true,
+      "dependencies": {
+        "@babel/template": "^7.22.5",
+        "@babel/types": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-hoist-variables": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz",
+      "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==",
+      "dev": true,
+      "dependencies": {
+        "@babel/types": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-member-expression-to-functions": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.22.5.tgz",
+      "integrity": "sha512-aBiH1NKMG0H2cGZqspNvsaBe6wNGjbJjuLy29aU+eDZjSbbN53BaxlpB02xm9v34pLTZ1nIQPFYn2qMZoa5BQQ==",
+      "dev": true,
+      "dependencies": {
+        "@babel/types": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-module-imports": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.5.tgz",
+      "integrity": "sha512-8Dl6+HD/cKifutF5qGd/8ZJi84QeAKh+CEe1sBzz8UayBBGg1dAIJrdHOcOM5b2MpzWL2yuotJTtGjETq0qjXg==",
+      "dev": true,
+      "dependencies": {
+        "@babel/types": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-module-transforms": {
+      "version": "7.22.9",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.22.9.tgz",
+      "integrity": "sha512-t+WA2Xn5K+rTeGtC8jCsdAH52bjggG5TKRuRrAGNM/mjIbO4GxvlLMFOEz9wXY5I2XQ60PMFsAG2WIcG82dQMQ==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-environment-visitor": "^7.22.5",
+        "@babel/helper-module-imports": "^7.22.5",
+        "@babel/helper-simple-access": "^7.22.5",
+        "@babel/helper-split-export-declaration": "^7.22.6",
+        "@babel/helper-validator-identifier": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/helper-optimise-call-expression": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.22.5.tgz",
+      "integrity": "sha512-HBwaojN0xFRx4yIvpwGqxiV2tUfl7401jlok564NgB9EHS1y6QT17FmKWm4ztqjeVdXLuC4fSvHc5ePpQjoTbw==",
+      "dev": true,
+      "dependencies": {
+        "@babel/types": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-plugin-utils": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz",
+      "integrity": "sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==",
+      "dev": true,
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-remap-async-to-generator": {
+      "version": "7.22.9",
+      "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.22.9.tgz",
+      "integrity": "sha512-8WWC4oR4Px+tr+Fp0X3RHDVfINGpF3ad1HIbrc8A77epiR6eMMc6jsgozkzT2uDiOOdoS9cLIQ+XD2XvI2WSmQ==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-annotate-as-pure": "^7.22.5",
+        "@babel/helper-environment-visitor": "^7.22.5",
+        "@babel/helper-wrap-function": "^7.22.9"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/helper-replace-supers": {
+      "version": "7.22.9",
+      "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.22.9.tgz",
+      "integrity": "sha512-LJIKvvpgPOPUThdYqcX6IXRuIcTkcAub0IaDRGCZH0p5GPUp7PhRU9QVgFcDDd51BaPkk77ZjqFwh6DZTAEmGg==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-environment-visitor": "^7.22.5",
+        "@babel/helper-member-expression-to-functions": "^7.22.5",
+        "@babel/helper-optimise-call-expression": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/helper-simple-access": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz",
+      "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==",
+      "dev": true,
+      "dependencies": {
+        "@babel/types": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-skip-transparent-expression-wrappers": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.22.5.tgz",
+      "integrity": "sha512-tK14r66JZKiC43p8Ki33yLBVJKlQDFoA8GYN67lWCDCqoL6EMMSuM9b+Iff2jHaM/RRFYl7K+iiru7hbRqNx8Q==",
+      "dev": true,
+      "dependencies": {
+        "@babel/types": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-split-export-declaration": {
+      "version": "7.22.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz",
+      "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==",
+      "dev": true,
+      "dependencies": {
+        "@babel/types": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-string-parser": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz",
+      "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==",
+      "dev": true,
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-identifier": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.5.tgz",
+      "integrity": "sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-option": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.5.tgz",
+      "integrity": "sha512-R3oB6xlIVKUnxNUxbmgq7pKjxpru24zlimpE8WK47fACIlM0II/Hm1RS8IaOI7NgCr6LNS+jl5l75m20npAziw==",
+      "dev": true,
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-wrap-function": {
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.22.10.tgz",
+      "integrity": "sha512-OnMhjWjuGYtdoO3FmsEFWvBStBAe2QOgwOLsLNDjN+aaiMD8InJk1/O3HSD8lkqTjCgg5YI34Tz15KNNA3p+nQ==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-function-name": "^7.22.5",
+        "@babel/template": "^7.22.5",
+        "@babel/types": "^7.22.10"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helpers": {
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.22.10.tgz",
+      "integrity": "sha512-a41J4NW8HyZa1I1vAndrraTlPZ/eZoga2ZgS7fEr0tZJGVU4xqdE80CEm0CcNjha5EZ8fTBYLKHF0kqDUuAwQw==",
+      "dev": true,
+      "dependencies": {
+        "@babel/template": "^7.22.5",
+        "@babel/traverse": "^7.22.10",
+        "@babel/types": "^7.22.10"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/highlight": {
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.10.tgz",
+      "integrity": "sha512-78aUtVcT7MUscr0K5mIEnkwxPE0MaxkR5RxRwuHaQ+JuU5AmTPhY+do2mdzVTnIJJpyBglql2pehuBIWHug+WQ==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-validator-identifier": "^7.22.5",
+        "chalk": "^2.4.2",
+        "js-tokens": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/parser": {
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.10.tgz",
+      "integrity": "sha512-lNbdGsQb9ekfsnjFGhEiF4hfFqGgfOP3H3d27re3n+CGhNuTSUEQdfWk556sTLNTloczcdM5TYF2LhzmDQKyvQ==",
+      "bin": {
+        "parser": "bin/babel-parser.js"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.22.5.tgz",
+      "integrity": "sha512-NP1M5Rf+u2Gw9qfSO4ihjcTGW5zXTi36ITLd4/EoAcEhIZ0yjMqmftDNl3QC19CX7olhrjpyU454g/2W7X0jvQ==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.22.5.tgz",
+      "integrity": "sha512-31Bb65aZaUwqCbWMnZPduIZxCBngHFlzyN6Dq6KAJjtx+lx6ohKHubc61OomYi7XwVD4Ol0XCVz4h+pYFR048g==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.22.5",
+        "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5",
+        "@babel/plugin-transform-optional-chaining": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.13.0"
+      }
+    },
+    "node_modules/@babel/plugin-proposal-private-property-in-object": {
+      "version": "7.21.0-placeholder-for-preset-env.2",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz",
+      "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==",
+      "dev": true,
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-async-generators": {
+      "version": "7.8.4",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz",
+      "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.8.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-class-properties": {
+      "version": "7.12.13",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz",
+      "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.12.13"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-class-static-block": {
+      "version": "7.14.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz",
+      "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.14.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-dynamic-import": {
+      "version": "7.8.3",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz",
+      "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.8.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-export-namespace-from": {
+      "version": "7.8.3",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz",
+      "integrity": "sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.8.3"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-import-assertions": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.22.5.tgz",
+      "integrity": "sha512-rdV97N7KqsRzeNGoWUOK6yUsWarLjE5Su/Snk9IYPU9CwkWHs4t+rTGOvffTR8XGkJMTAdLfO0xVnXm8wugIJg==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-import-attributes": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.22.5.tgz",
+      "integrity": "sha512-KwvoWDeNKPETmozyFE0P2rOLqh39EoQHNjqizrI5B8Vt0ZNS7M56s7dAiAqbYfiAYOuIzIh96z3iR2ktgu3tEg==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-import-meta": {
+      "version": "7.10.4",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz",
+      "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.10.4"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-json-strings": {
+      "version": "7.8.3",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz",
+      "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.8.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-logical-assignment-operators": {
+      "version": "7.10.4",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz",
+      "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.10.4"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": {
+      "version": "7.8.3",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz",
+      "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.8.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-numeric-separator": {
+      "version": "7.10.4",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz",
+      "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.10.4"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-object-rest-spread": {
+      "version": "7.8.3",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz",
+      "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.8.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-optional-catch-binding": {
+      "version": "7.8.3",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz",
+      "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.8.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-optional-chaining": {
+      "version": "7.8.3",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz",
+      "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.8.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-private-property-in-object": {
+      "version": "7.14.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz",
+      "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.14.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-top-level-await": {
+      "version": "7.14.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz",
+      "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.14.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-unicode-sets-regex": {
+      "version": "7.18.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz",
+      "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-create-regexp-features-plugin": "^7.18.6",
+        "@babel/helper-plugin-utils": "^7.18.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-arrow-functions": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.22.5.tgz",
+      "integrity": "sha512-26lTNXoVRdAnsaDXPpvCNUq+OVWEVC6bx7Vvz9rC53F2bagUWW4u4ii2+h8Fejfh7RYqPxn+libeFBBck9muEw==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-async-generator-functions": {
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.22.10.tgz",
+      "integrity": "sha512-eueE8lvKVzq5wIObKK/7dvoeKJ+xc6TvRn6aysIjS6pSCeLy7S/eVi7pEQknZqyqvzaNKdDtem8nUNTBgDVR2g==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-environment-visitor": "^7.22.5",
+        "@babel/helper-plugin-utils": "^7.22.5",
+        "@babel/helper-remap-async-to-generator": "^7.22.9",
+        "@babel/plugin-syntax-async-generators": "^7.8.4"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-async-to-generator": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.22.5.tgz",
+      "integrity": "sha512-b1A8D8ZzE/VhNDoV1MSJTnpKkCG5bJo+19R4o4oy03zM7ws8yEMK755j61Dc3EyvdysbqH5BOOTquJ7ZX9C6vQ==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-module-imports": "^7.22.5",
+        "@babel/helper-plugin-utils": "^7.22.5",
+        "@babel/helper-remap-async-to-generator": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-block-scoped-functions": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.22.5.tgz",
+      "integrity": "sha512-tdXZ2UdknEKQWKJP1KMNmuF5Lx3MymtMN/pvA+p/VEkhK8jVcQ1fzSy8KM9qRYhAf2/lV33hoMPKI/xaI9sADA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-block-scoping": {
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.22.10.tgz",
+      "integrity": "sha512-1+kVpGAOOI1Albt6Vse7c8pHzcZQdQKW+wJH+g8mCaszOdDVwRXa/slHPqIw+oJAJANTKDMuM2cBdV0Dg618Vg==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-class-properties": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.22.5.tgz",
+      "integrity": "sha512-nDkQ0NfkOhPTq8YCLiWNxp1+f9fCobEjCb0n8WdbNUBc4IB5V7P1QnX9IjpSoquKrXF5SKojHleVNs2vGeHCHQ==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-create-class-features-plugin": "^7.22.5",
+        "@babel/helper-plugin-utils": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-class-static-block": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.22.5.tgz",
+      "integrity": "sha512-SPToJ5eYZLxlnp1UzdARpOGeC2GbHvr9d/UV0EukuVx8atktg194oe+C5BqQ8jRTkgLRVOPYeXRSBg1IlMoVRA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-create-class-features-plugin": "^7.22.5",
+        "@babel/helper-plugin-utils": "^7.22.5",
+        "@babel/plugin-syntax-class-static-block": "^7.14.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.12.0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-classes": {
+      "version": "7.22.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.22.6.tgz",
+      "integrity": "sha512-58EgM6nuPNG6Py4Z3zSuu0xWu2VfodiMi72Jt5Kj2FECmaYk1RrTXA45z6KBFsu9tRgwQDwIiY4FXTt+YsSFAQ==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-annotate-as-pure": "^7.22.5",
+        "@babel/helper-compilation-targets": "^7.22.6",
+        "@babel/helper-environment-visitor": "^7.22.5",
+        "@babel/helper-function-name": "^7.22.5",
+        "@babel/helper-optimise-call-expression": "^7.22.5",
+        "@babel/helper-plugin-utils": "^7.22.5",
+        "@babel/helper-replace-supers": "^7.22.5",
+        "@babel/helper-split-export-declaration": "^7.22.6",
+        "globals": "^11.1.0"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-computed-properties": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.22.5.tgz",
+      "integrity": "sha512-4GHWBgRf0krxPX+AaPtgBAlTgTeZmqDynokHOX7aqqAB4tHs3U2Y02zH6ETFdLZGcg9UQSD1WCmkVrE9ErHeOg==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.22.5",
+        "@babel/template": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-destructuring": {
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.22.10.tgz",
+      "integrity": "sha512-dPJrL0VOyxqLM9sritNbMSGx/teueHF/htMKrPT7DNxccXxRDPYqlgPFFdr8u+F+qUZOkZoXue/6rL5O5GduEw==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-dotall-regex": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.22.5.tgz",
+      "integrity": "sha512-5/Yk9QxCQCl+sOIB1WelKnVRxTJDSAIxtJLL2/pqL14ZVlbH0fUQUZa/T5/UnQtBNgghR7mfB8ERBKyKPCi7Vw==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-create-regexp-features-plugin": "^7.22.5",
+        "@babel/helper-plugin-utils": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-duplicate-keys": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.22.5.tgz",
+      "integrity": "sha512-dEnYD+9BBgld5VBXHnF/DbYGp3fqGMsyxKbtD1mDyIA7AkTSpKXFhCVuj/oQVOoALfBs77DudA0BE4d5mcpmqw==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-dynamic-import": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.22.5.tgz",
+      "integrity": "sha512-0MC3ppTB1AMxd8fXjSrbPa7LT9hrImt+/fcj+Pg5YMD7UQyWp/02+JWpdnCymmsXwIx5Z+sYn1bwCn4ZJNvhqQ==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.22.5",
+        "@babel/plugin-syntax-dynamic-import": "^7.8.3"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-exponentiation-operator": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.22.5.tgz",
+      "integrity": "sha512-vIpJFNM/FjZ4rh1myqIya9jXwrwwgFRHPjT3DkUA9ZLHuzox8jiXkOLvwm1H+PQIP3CqfC++WPKeuDi0Sjdj1g==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-builder-binary-assignment-operator-visitor": "^7.22.5",
+        "@babel/helper-plugin-utils": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-export-namespace-from": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.22.5.tgz",
+      "integrity": "sha512-X4hhm7FRnPgd4nDA4b/5V280xCx6oL7Oob5+9qVS5C13Zq4bh1qq7LU0GgRU6b5dBWBvhGaXYVB4AcN6+ol6vg==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.22.5",
+        "@babel/plugin-syntax-export-namespace-from": "^7.8.3"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-for-of": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.22.5.tgz",
+      "integrity": "sha512-3kxQjX1dU9uudwSshyLeEipvrLjBCVthCgeTp6CzE/9JYrlAIaeekVxRpCWsDDfYTfRZRoCeZatCQvwo+wvK8A==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-function-name": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.22.5.tgz",
+      "integrity": "sha512-UIzQNMS0p0HHiQm3oelztj+ECwFnj+ZRV4KnguvlsD2of1whUeM6o7wGNj6oLwcDoAXQ8gEqfgC24D+VdIcevg==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-compilation-targets": "^7.22.5",
+        "@babel/helper-function-name": "^7.22.5",
+        "@babel/helper-plugin-utils": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-json-strings": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.22.5.tgz",
+      "integrity": "sha512-DuCRB7fu8MyTLbEQd1ew3R85nx/88yMoqo2uPSjevMj3yoN7CDM8jkgrY0wmVxfJZyJ/B9fE1iq7EQppWQmR5A==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.22.5",
+        "@babel/plugin-syntax-json-strings": "^7.8.3"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-literals": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.22.5.tgz",
+      "integrity": "sha512-fTLj4D79M+mepcw3dgFBTIDYpbcB9Sm0bpm4ppXPaO+U+PKFFyV9MGRvS0gvGw62sd10kT5lRMKXAADb9pWy8g==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-logical-assignment-operators": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.22.5.tgz",
+      "integrity": "sha512-MQQOUW1KL8X0cDWfbwYP+TbVbZm16QmQXJQ+vndPtH/BoO0lOKpVoEDMI7+PskYxH+IiE0tS8xZye0qr1lGzSA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.22.5",
+        "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-member-expression-literals": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.22.5.tgz",
+      "integrity": "sha512-RZEdkNtzzYCFl9SE9ATaUMTj2hqMb4StarOJLrZRbqqU4HSBE7UlBw9WBWQiDzrJZJdUWiMTVDI6Gv/8DPvfew==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-modules-amd": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.22.5.tgz",
+      "integrity": "sha512-R+PTfLTcYEmb1+kK7FNkhQ1gP4KgjpSO6HfH9+f8/yfp2Nt3ggBjiVpRwmwTlfqZLafYKJACy36yDXlEmI9HjQ==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-module-transforms": "^7.22.5",
+        "@babel/helper-plugin-utils": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-modules-commonjs": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.22.5.tgz",
+      "integrity": "sha512-B4pzOXj+ONRmuaQTg05b3y/4DuFz3WcCNAXPLb2Q0GT0TrGKGxNKV4jwsXts+StaM0LQczZbOpj8o1DLPDJIiA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-module-transforms": "^7.22.5",
+        "@babel/helper-plugin-utils": "^7.22.5",
+        "@babel/helper-simple-access": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-modules-systemjs": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.22.5.tgz",
+      "integrity": "sha512-emtEpoaTMsOs6Tzz+nbmcePl6AKVtS1yC4YNAeMun9U8YCsgadPNxnOPQ8GhHFB2qdx+LZu9LgoC0Lthuu05DQ==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-hoist-variables": "^7.22.5",
+        "@babel/helper-module-transforms": "^7.22.5",
+        "@babel/helper-plugin-utils": "^7.22.5",
+        "@babel/helper-validator-identifier": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-modules-umd": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.22.5.tgz",
+      "integrity": "sha512-+S6kzefN/E1vkSsKx8kmQuqeQsvCKCd1fraCM7zXm4SFoggI099Tr4G8U81+5gtMdUeMQ4ipdQffbKLX0/7dBQ==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-module-transforms": "^7.22.5",
+        "@babel/helper-plugin-utils": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-named-capturing-groups-regex": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.22.5.tgz",
+      "integrity": "sha512-YgLLKmS3aUBhHaxp5hi1WJTgOUb/NCuDHzGT9z9WTt3YG+CPRhJs6nprbStx6DnWM4dh6gt7SU3sZodbZ08adQ==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-create-regexp-features-plugin": "^7.22.5",
+        "@babel/helper-plugin-utils": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-new-target": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.22.5.tgz",
+      "integrity": "sha512-AsF7K0Fx/cNKVyk3a+DW0JLo+Ua598/NxMRvxDnkpCIGFh43+h/v2xyhRUYf6oD8gE4QtL83C7zZVghMjHd+iw==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-nullish-coalescing-operator": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.22.5.tgz",
+      "integrity": "sha512-6CF8g6z1dNYZ/VXok5uYkkBBICHZPiGEl7oDnAx2Mt1hlHVHOSIKWJaXHjQJA5VB43KZnXZDIexMchY4y2PGdA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.22.5",
+        "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-numeric-separator": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.22.5.tgz",
+      "integrity": "sha512-NbslED1/6M+sXiwwtcAB/nieypGw02Ejf4KtDeMkCEpP6gWFMX1wI9WKYua+4oBneCCEmulOkRpwywypVZzs/g==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.22.5",
+        "@babel/plugin-syntax-numeric-separator": "^7.10.4"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-object-rest-spread": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.22.5.tgz",
+      "integrity": "sha512-Kk3lyDmEslH9DnvCDA1s1kkd3YWQITiBOHngOtDL9Pt6BZjzqb6hiOlb8VfjiiQJ2unmegBqZu0rx5RxJb5vmQ==",
+      "dev": true,
+      "dependencies": {
+        "@babel/compat-data": "^7.22.5",
+        "@babel/helper-compilation-targets": "^7.22.5",
+        "@babel/helper-plugin-utils": "^7.22.5",
+        "@babel/plugin-syntax-object-rest-spread": "^7.8.3",
+        "@babel/plugin-transform-parameters": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-object-super": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.22.5.tgz",
+      "integrity": "sha512-klXqyaT9trSjIUrcsYIfETAzmOEZL3cBYqOYLJxBHfMFFggmXOv+NYSX/Jbs9mzMVESw/WycLFPRx8ba/b2Ipw==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.22.5",
+        "@babel/helper-replace-supers": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-optional-catch-binding": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.22.5.tgz",
+      "integrity": "sha512-pH8orJahy+hzZje5b8e2QIlBWQvGpelS76C63Z+jhZKsmzfNaPQ+LaW6dcJ9bxTpo1mtXbgHwy765Ro3jftmUg==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.22.5",
+        "@babel/plugin-syntax-optional-catch-binding": "^7.8.3"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-optional-chaining": {
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.22.10.tgz",
+      "integrity": "sha512-MMkQqZAZ+MGj+jGTG3OTuhKeBpNcO+0oCEbrGNEaOmiEn+1MzRyQlYsruGiU8RTK3zV6XwrVJTmwiDOyYK6J9g==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.22.5",
+        "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5",
+        "@babel/plugin-syntax-optional-chaining": "^7.8.3"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-parameters": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.22.5.tgz",
+      "integrity": "sha512-AVkFUBurORBREOmHRKo06FjHYgjrabpdqRSwq6+C7R5iTCZOsM4QbcB27St0a4U6fffyAOqh3s/qEfybAhfivg==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-private-methods": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.22.5.tgz",
+      "integrity": "sha512-PPjh4gyrQnGe97JTalgRGMuU4icsZFnWkzicB/fUtzlKUqvsWBKEpPPfr5a2JiyirZkHxnAqkQMO5Z5B2kK3fA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-create-class-features-plugin": "^7.22.5",
+        "@babel/helper-plugin-utils": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-private-property-in-object": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.22.5.tgz",
+      "integrity": "sha512-/9xnaTTJcVoBtSSmrVyhtSvO3kbqS2ODoh2juEU72c3aYonNF0OMGiaz2gjukyKM2wBBYJP38S4JiE0Wfb5VMQ==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-annotate-as-pure": "^7.22.5",
+        "@babel/helper-create-class-features-plugin": "^7.22.5",
+        "@babel/helper-plugin-utils": "^7.22.5",
+        "@babel/plugin-syntax-private-property-in-object": "^7.14.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-property-literals": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.22.5.tgz",
+      "integrity": "sha512-TiOArgddK3mK/x1Qwf5hay2pxI6wCZnvQqrFSqbtg1GLl2JcNMitVH/YnqjP+M31pLUeTfzY1HAXFDnUBV30rQ==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-regenerator": {
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.22.10.tgz",
+      "integrity": "sha512-F28b1mDt8KcT5bUyJc/U9nwzw6cV+UmTeRlXYIl2TNqMMJif0Jeey9/RQ3C4NOd2zp0/TRsDns9ttj2L523rsw==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.22.5",
+        "regenerator-transform": "^0.15.2"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-reserved-words": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.22.5.tgz",
+      "integrity": "sha512-DTtGKFRQUDm8svigJzZHzb/2xatPc6TzNvAIJ5GqOKDsGFYgAskjRulbR/vGsPKq3OPqtexnz327qYpP57RFyA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-shorthand-properties": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.22.5.tgz",
+      "integrity": "sha512-vM4fq9IXHscXVKzDv5itkO1X52SmdFBFcMIBZ2FRn2nqVYqw6dBexUgMvAjHW+KXpPPViD/Yo3GrDEBaRC0QYA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-spread": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.22.5.tgz",
+      "integrity": "sha512-5ZzDQIGyvN4w8+dMmpohL6MBo+l2G7tfC/O2Dg7/hjpgeWvUx8FzfeOKxGog9IimPa4YekaQ9PlDqTLOljkcxg==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.22.5",
+        "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-sticky-regex": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.22.5.tgz",
+      "integrity": "sha512-zf7LuNpHG0iEeiyCNwX4j3gDg1jgt1k3ZdXBKbZSoA3BbGQGvMiSvfbZRR3Dr3aeJe3ooWFZxOOG3IRStYp2Bw==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-template-literals": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.22.5.tgz",
+      "integrity": "sha512-5ciOehRNf+EyUeewo8NkbQiUs4d6ZxiHo6BcBcnFlgiJfu16q0bQUw9Jvo0b0gBKFG1SMhDSjeKXSYuJLeFSMA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-typeof-symbol": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.22.5.tgz",
+      "integrity": "sha512-bYkI5lMzL4kPii4HHEEChkD0rkc+nvnlR6+o/qdqR6zrm0Sv/nodmyLhlq2DO0YKLUNd2VePmPRjJXSBh9OIdA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-unicode-escapes": {
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.22.10.tgz",
+      "integrity": "sha512-lRfaRKGZCBqDlRU3UIFovdp9c9mEvlylmpod0/OatICsSfuQ9YFthRo1tpTkGsklEefZdqlEFdY4A2dwTb6ohg==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-unicode-property-regex": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.22.5.tgz",
+      "integrity": "sha512-HCCIb+CbJIAE6sXn5CjFQXMwkCClcOfPCzTlilJ8cUatfzwHlWQkbtV0zD338u9dZskwvuOYTuuaMaA8J5EI5A==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-create-regexp-features-plugin": "^7.22.5",
+        "@babel/helper-plugin-utils": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-unicode-regex": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.22.5.tgz",
+      "integrity": "sha512-028laaOKptN5vHJf9/Arr/HiJekMd41hOEZYvNsrsXqJ7YPYuX2bQxh31fkZzGmq3YqHRJzYFFAVYvKfMPKqyg==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-create-regexp-features-plugin": "^7.22.5",
+        "@babel/helper-plugin-utils": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-unicode-sets-regex": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.22.5.tgz",
+      "integrity": "sha512-lhMfi4FC15j13eKrh3DnYHjpGj6UKQHtNKTbtc1igvAhRy4+kLhV07OpLcsN0VgDEw/MjAvJO4BdMJsHwMhzCg==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-create-regexp-features-plugin": "^7.22.5",
+        "@babel/helper-plugin-utils": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/preset-env": {
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.22.10.tgz",
+      "integrity": "sha512-riHpLb1drNkpLlocmSyEg4oYJIQFeXAK/d7rI6mbD0XsvoTOOweXDmQPG/ErxsEhWk3rl3Q/3F6RFQlVFS8m0A==",
+      "dev": true,
+      "dependencies": {
+        "@babel/compat-data": "^7.22.9",
+        "@babel/helper-compilation-targets": "^7.22.10",
+        "@babel/helper-plugin-utils": "^7.22.5",
+        "@babel/helper-validator-option": "^7.22.5",
+        "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.22.5",
+        "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.22.5",
+        "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2",
+        "@babel/plugin-syntax-async-generators": "^7.8.4",
+        "@babel/plugin-syntax-class-properties": "^7.12.13",
+        "@babel/plugin-syntax-class-static-block": "^7.14.5",
+        "@babel/plugin-syntax-dynamic-import": "^7.8.3",
+        "@babel/plugin-syntax-export-namespace-from": "^7.8.3",
+        "@babel/plugin-syntax-import-assertions": "^7.22.5",
+        "@babel/plugin-syntax-import-attributes": "^7.22.5",
+        "@babel/plugin-syntax-import-meta": "^7.10.4",
+        "@babel/plugin-syntax-json-strings": "^7.8.3",
+        "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4",
+        "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3",
+        "@babel/plugin-syntax-numeric-separator": "^7.10.4",
+        "@babel/plugin-syntax-object-rest-spread": "^7.8.3",
+        "@babel/plugin-syntax-optional-catch-binding": "^7.8.3",
+        "@babel/plugin-syntax-optional-chaining": "^7.8.3",
+        "@babel/plugin-syntax-private-property-in-object": "^7.14.5",
+        "@babel/plugin-syntax-top-level-await": "^7.14.5",
+        "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6",
+        "@babel/plugin-transform-arrow-functions": "^7.22.5",
+        "@babel/plugin-transform-async-generator-functions": "^7.22.10",
+        "@babel/plugin-transform-async-to-generator": "^7.22.5",
+        "@babel/plugin-transform-block-scoped-functions": "^7.22.5",
+        "@babel/plugin-transform-block-scoping": "^7.22.10",
+        "@babel/plugin-transform-class-properties": "^7.22.5",
+        "@babel/plugin-transform-class-static-block": "^7.22.5",
+        "@babel/plugin-transform-classes": "^7.22.6",
+        "@babel/plugin-transform-computed-properties": "^7.22.5",
+        "@babel/plugin-transform-destructuring": "^7.22.10",
+        "@babel/plugin-transform-dotall-regex": "^7.22.5",
+        "@babel/plugin-transform-duplicate-keys": "^7.22.5",
+        "@babel/plugin-transform-dynamic-import": "^7.22.5",
+        "@babel/plugin-transform-exponentiation-operator": "^7.22.5",
+        "@babel/plugin-transform-export-namespace-from": "^7.22.5",
+        "@babel/plugin-transform-for-of": "^7.22.5",
+        "@babel/plugin-transform-function-name": "^7.22.5",
+        "@babel/plugin-transform-json-strings": "^7.22.5",
+        "@babel/plugin-transform-literals": "^7.22.5",
+        "@babel/plugin-transform-logical-assignment-operators": "^7.22.5",
+        "@babel/plugin-transform-member-expression-literals": "^7.22.5",
+        "@babel/plugin-transform-modules-amd": "^7.22.5",
+        "@babel/plugin-transform-modules-commonjs": "^7.22.5",
+        "@babel/plugin-transform-modules-systemjs": "^7.22.5",
+        "@babel/plugin-transform-modules-umd": "^7.22.5",
+        "@babel/plugin-transform-named-capturing-groups-regex": "^7.22.5",
+        "@babel/plugin-transform-new-target": "^7.22.5",
+        "@babel/plugin-transform-nullish-coalescing-operator": "^7.22.5",
+        "@babel/plugin-transform-numeric-separator": "^7.22.5",
+        "@babel/plugin-transform-object-rest-spread": "^7.22.5",
+        "@babel/plugin-transform-object-super": "^7.22.5",
+        "@babel/plugin-transform-optional-catch-binding": "^7.22.5",
+        "@babel/plugin-transform-optional-chaining": "^7.22.10",
+        "@babel/plugin-transform-parameters": "^7.22.5",
+        "@babel/plugin-transform-private-methods": "^7.22.5",
+        "@babel/plugin-transform-private-property-in-object": "^7.22.5",
+        "@babel/plugin-transform-property-literals": "^7.22.5",
+        "@babel/plugin-transform-regenerator": "^7.22.10",
+        "@babel/plugin-transform-reserved-words": "^7.22.5",
+        "@babel/plugin-transform-shorthand-properties": "^7.22.5",
+        "@babel/plugin-transform-spread": "^7.22.5",
+        "@babel/plugin-transform-sticky-regex": "^7.22.5",
+        "@babel/plugin-transform-template-literals": "^7.22.5",
+        "@babel/plugin-transform-typeof-symbol": "^7.22.5",
+        "@babel/plugin-transform-unicode-escapes": "^7.22.10",
+        "@babel/plugin-transform-unicode-property-regex": "^7.22.5",
+        "@babel/plugin-transform-unicode-regex": "^7.22.5",
+        "@babel/plugin-transform-unicode-sets-regex": "^7.22.5",
+        "@babel/preset-modules": "0.1.6-no-external-plugins",
+        "@babel/types": "^7.22.10",
+        "babel-plugin-polyfill-corejs2": "^0.4.5",
+        "babel-plugin-polyfill-corejs3": "^0.8.3",
+        "babel-plugin-polyfill-regenerator": "^0.5.2",
+        "core-js-compat": "^3.31.0",
+        "semver": "^6.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/preset-env/node_modules/semver": {
+      "version": "6.3.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+      "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+      "dev": true,
+      "bin": {
+        "semver": "bin/semver.js"
+      }
+    },
+    "node_modules/@babel/preset-modules": {
+      "version": "0.1.6-no-external-plugins",
+      "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz",
+      "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.0.0",
+        "@babel/types": "^7.4.4",
+        "esutils": "^2.0.2"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0"
+      }
+    },
+    "node_modules/@babel/regjsgen": {
+      "version": "0.8.0",
+      "resolved": "https://registry.npmjs.org/@babel/regjsgen/-/regjsgen-0.8.0.tgz",
+      "integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==",
+      "dev": true
+    },
+    "node_modules/@babel/runtime": {
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.10.tgz",
+      "integrity": "sha512-21t/fkKLMZI4pqP2wlmsQAWnYW1PDyKyyUV4vCi+B25ydmdaYTKXPwCj0BzSUnZf4seIiYvSA3jcZ3gdsMFkLQ==",
+      "dependencies": {
+        "regenerator-runtime": "^0.14.0"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/runtime/node_modules/regenerator-runtime": {
+      "version": "0.14.0",
+      "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz",
+      "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA=="
+    },
+    "node_modules/@babel/template": {
+      "version": "7.22.5",
+      "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.5.tgz",
+      "integrity": "sha512-X7yV7eiwAxdj9k94NEylvbVHLiVG1nvzCV2EAowhxLTwODV1jl9UzZ48leOC0sH7OnuHrIkllaBgneUykIcZaw==",
+      "dev": true,
+      "dependencies": {
+        "@babel/code-frame": "^7.22.5",
+        "@babel/parser": "^7.22.5",
+        "@babel/types": "^7.22.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/traverse": {
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.22.10.tgz",
+      "integrity": "sha512-Q/urqV4pRByiNNpb/f5OSv28ZlGJiFiiTh+GAHktbIrkPhPbl90+uW6SmpoLyZqutrg9AEaEf3Q/ZBRHBXgxig==",
+      "dev": true,
+      "dependencies": {
+        "@babel/code-frame": "^7.22.10",
+        "@babel/generator": "^7.22.10",
+        "@babel/helper-environment-visitor": "^7.22.5",
+        "@babel/helper-function-name": "^7.22.5",
+        "@babel/helper-hoist-variables": "^7.22.5",
+        "@babel/helper-split-export-declaration": "^7.22.6",
+        "@babel/parser": "^7.22.10",
+        "@babel/types": "^7.22.10",
+        "debug": "^4.1.0",
+        "globals": "^11.1.0"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/types": {
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.22.10.tgz",
+      "integrity": "sha512-obaoigiLrlDZ7TUQln/8m4mSqIW2QFeOrCQc9r+xsaHGNoplVNYlRVpsfE8Vj35GEm2ZH4ZhrNYogs/3fj85kg==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-string-parser": "^7.22.5",
+        "@babel/helper-validator-identifier": "^7.22.5",
+        "to-fast-properties": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@bytemd/plugin-gfm": {
+      "version": "1.21.0",
+      "resolved": "https://registry.npmjs.org/@bytemd/plugin-gfm/-/plugin-gfm-1.21.0.tgz",
+      "integrity": "sha512-ZlrLa+Nl80gUDeC1hTnyRDfgJU3DGQVjQvX9rIIitUCler+KsAiagEnng6S/W2SZNpv+f8eWpVNL8KA8X3d7Tg==",
+      "dependencies": {
+        "remark-gfm": "^3.0.1"
+      },
+      "peerDependencies": {
+        "bytemd": "^1.5.0"
+      }
+    },
+    "node_modules/@ctrl/tinycolor": {
+      "version": "3.6.0",
+      "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.0.tgz",
+      "integrity": "sha512-/Z3l6pXthq0JvMYdUFyX9j0MaCltlIn6mfh9jLyQwg5aPKxkyNa0PTHtU1AlFXLNk55ZuAeJRcpvq+tmLfKmaQ==",
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.18.20",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz",
+      "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.18.20",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz",
+      "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.18.20",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz",
+      "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.18.20",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz",
+      "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.18.20",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz",
+      "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.18.20",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz",
+      "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.18.20",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz",
+      "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.18.20",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz",
+      "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.18.20",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz",
+      "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.18.20",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz",
+      "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.18.20",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz",
+      "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.18.20",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz",
+      "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.18.20",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz",
+      "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.18.20",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz",
+      "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.18.20",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz",
+      "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.18.20",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz",
+      "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.18.20",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz",
+      "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.18.20",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz",
+      "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.18.20",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz",
+      "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.18.20",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz",
+      "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.18.20",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz",
+      "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.18.20",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz",
+      "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@eslint-community/eslint-utils": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
+      "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==",
+      "dev": true,
+      "dependencies": {
+        "eslint-visitor-keys": "^3.3.0"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "peerDependencies": {
+        "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+      }
+    },
+    "node_modules/@eslint-community/regexpp": {
+      "version": "4.6.2",
+      "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.6.2.tgz",
+      "integrity": "sha512-pPTNuaAG3QMH+buKyBIGJs3g/S5y0caxw0ygM3YyE6yJFySwiGGSzA+mM3KJ8QQvzeLh3blwgSonkFjgQdxzMw==",
+      "dev": true,
+      "engines": {
+        "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+      }
+    },
+    "node_modules/@eslint/eslintrc": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.2.tgz",
+      "integrity": "sha512-+wvgpDsrB1YqAMdEUCcnTlpfVBH7Vqn6A/NT3D8WVXFIaKMlErPIZT3oCIAVCOtarRpMtelZLqJeU3t7WY6X6g==",
+      "dev": true,
+      "dependencies": {
+        "ajv": "^6.12.4",
+        "debug": "^4.3.2",
+        "espree": "^9.6.0",
+        "globals": "^13.19.0",
+        "ignore": "^5.2.0",
+        "import-fresh": "^3.2.1",
+        "js-yaml": "^4.1.0",
+        "minimatch": "^3.1.2",
+        "strip-json-comments": "^3.1.1"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/@eslint/eslintrc/node_modules/globals": {
+      "version": "13.21.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-13.21.0.tgz",
+      "integrity": "sha512-ybyme3s4yy/t/3s35bewwXKOf7cvzfreG2lH0lZl0JB7I4GxRP2ghxOK/Nb9EkRXdbBXZLfq/p/0W2JUONB/Gg==",
+      "dev": true,
+      "dependencies": {
+        "type-fest": "^0.20.2"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/@eslint/js": {
+      "version": "8.47.0",
+      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.47.0.tgz",
+      "integrity": "sha512-P6omY1zv5MItm93kLM8s2vr1HICJH8v0dvddDhysbIuZ+vcjOHg5Zbkf1mTkcmi2JA9oBG2anOkRnW8WJTS8Og==",
+      "dev": true,
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      }
+    },
+    "node_modules/@humanwhocodes/config-array": {
+      "version": "0.11.10",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.10.tgz",
+      "integrity": "sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==",
+      "dev": true,
+      "dependencies": {
+        "@humanwhocodes/object-schema": "^1.2.1",
+        "debug": "^4.1.1",
+        "minimatch": "^3.0.5"
+      },
+      "engines": {
+        "node": ">=10.10.0"
+      }
+    },
+    "node_modules/@humanwhocodes/module-importer": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+      "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+      "dev": true,
+      "engines": {
+        "node": ">=12.22"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/nzakas"
+      }
+    },
+    "node_modules/@humanwhocodes/object-schema": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz",
+      "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==",
+      "dev": true
+    },
+    "node_modules/@intlify/core-base": {
+      "version": "9.2.2",
+      "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.2.2.tgz",
+      "integrity": "sha512-JjUpQtNfn+joMbrXvpR4hTF8iJQ2sEFzzK3KIESOx+f+uwIjgw20igOyaIdhfsVVBCds8ZM64MoeNSx+PHQMkA==",
+      "dependencies": {
+        "@intlify/devtools-if": "9.2.2",
+        "@intlify/message-compiler": "9.2.2",
+        "@intlify/shared": "9.2.2",
+        "@intlify/vue-devtools": "9.2.2"
+      },
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/@intlify/devtools-if": {
+      "version": "9.2.2",
+      "resolved": "https://registry.npmjs.org/@intlify/devtools-if/-/devtools-if-9.2.2.tgz",
+      "integrity": "sha512-4ttr/FNO29w+kBbU7HZ/U0Lzuh2cRDhP8UlWOtV9ERcjHzuyXVZmjyleESK6eVP60tGC9QtQW9yZE+JeRhDHkg==",
+      "dependencies": {
+        "@intlify/shared": "9.2.2"
+      },
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/@intlify/message-compiler": {
+      "version": "9.2.2",
+      "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.2.2.tgz",
+      "integrity": "sha512-IUrQW7byAKN2fMBe8z6sK6riG1pue95e5jfokn8hA5Q3Bqy4MBJ5lJAofUsawQJYHeoPJ7svMDyBaVJ4d0GTtA==",
+      "dependencies": {
+        "@intlify/shared": "9.2.2",
+        "source-map": "0.6.1"
+      },
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/@intlify/shared": {
+      "version": "9.2.2",
+      "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.2.2.tgz",
+      "integrity": "sha512-wRwTpsslgZS5HNyM7uDQYZtxnbI12aGiBZURX3BTR9RFIKKRWpllTsgzHWvj3HKm3Y2Sh5LPC1r0PDCKEhVn9Q==",
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/@intlify/vue-devtools": {
+      "version": "9.2.2",
+      "resolved": "https://registry.npmjs.org/@intlify/vue-devtools/-/vue-devtools-9.2.2.tgz",
+      "integrity": "sha512-+dUyqyCHWHb/UcvY1MlIpO87munedm3Gn6E9WWYdWrMuYLcoIoOEVDWSS8xSwtlPU+kA+MEQTP6Q1iI/ocusJg==",
+      "dependencies": {
+        "@intlify/core-base": "9.2.2",
+        "@intlify/shared": "9.2.2"
+      },
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/@isaacs/cliui": {
+      "version": "8.0.2",
+      "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
+      "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
+      "dev": true,
+      "dependencies": {
+        "string-width": "^5.1.2",
+        "string-width-cjs": "npm:string-width@^4.2.0",
+        "strip-ansi": "^7.0.1",
+        "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
+        "wrap-ansi": "^8.1.0",
+        "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@isaacs/cliui/node_modules/ansi-regex": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
+      "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
+      "dev": true,
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+      }
+    },
+    "node_modules/@isaacs/cliui/node_modules/strip-ansi": {
+      "version": "7.1.0",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
+      "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+      "dev": true,
+      "dependencies": {
+        "ansi-regex": "^6.0.1"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+      }
+    },
+    "node_modules/@jridgewell/gen-mapping": {
+      "version": "0.3.3",
+      "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz",
+      "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==",
+      "dev": true,
+      "dependencies": {
+        "@jridgewell/set-array": "^1.0.1",
+        "@jridgewell/sourcemap-codec": "^1.4.10",
+        "@jridgewell/trace-mapping": "^0.3.9"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@jridgewell/resolve-uri": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz",
+      "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==",
+      "dev": true,
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@jridgewell/set-array": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz",
+      "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==",
+      "dev": true,
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@jridgewell/source-map": {
+      "version": "0.3.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz",
+      "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==",
+      "dev": true,
+      "dependencies": {
+        "@jridgewell/gen-mapping": "^0.3.0",
+        "@jridgewell/trace-mapping": "^0.3.9"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.4.15",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
+      "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg=="
+    },
+    "node_modules/@jridgewell/trace-mapping": {
+      "version": "0.3.19",
+      "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.19.tgz",
+      "integrity": "sha512-kf37QtfW+Hwx/buWGMPcR60iF9ziHa6r/CZJIHbmcm4+0qrXiVdxegAH0F6yddEVQ7zdkjcGCgCzUu+BcbhQxw==",
+      "dev": true,
+      "dependencies": {
+        "@jridgewell/resolve-uri": "^3.1.0",
+        "@jridgewell/sourcemap-codec": "^1.4.14"
+      }
+    },
+    "node_modules/@nodelib/fs.scandir": {
+      "version": "2.1.5",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+      "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+      "dev": true,
+      "dependencies": {
+        "@nodelib/fs.stat": "2.0.5",
+        "run-parallel": "^1.1.9"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@nodelib/fs.stat": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+      "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+      "dev": true,
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@nodelib/fs.walk": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+      "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+      "dev": true,
+      "dependencies": {
+        "@nodelib/fs.scandir": "2.1.5",
+        "fastq": "^1.6.0"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@pkgjs/parseargs": {
+      "version": "0.11.0",
+      "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
+      "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
+      "dev": true,
+      "optional": true,
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/@popperjs/core": {
+      "version": "2.11.8",
+      "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
+      "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/popperjs"
+      }
+    },
+    "node_modules/@rollup/pluginutils": {
+      "version": "5.0.3",
+      "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.0.3.tgz",
+      "integrity": "sha512-hfllNN4a80rwNQ9QCxhxuHCGHMAvabXqxNdaChUSSadMre7t4iEUI6fFAhBOn/eIYTgYVhBv7vCLsAJ4u3lf3g==",
+      "dev": true,
+      "dependencies": {
+        "@types/estree": "^1.0.0",
+        "estree-walker": "^2.0.2",
+        "picomatch": "^2.3.1"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      },
+      "peerDependencies": {
+        "rollup": "^1.20.0||^2.0.0||^3.0.0"
+      },
+      "peerDependenciesMeta": {
+        "rollup": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@simonwep/pickr": {
+      "version": "1.8.2",
+      "resolved": "https://registry.npmjs.org/@simonwep/pickr/-/pickr-1.8.2.tgz",
+      "integrity": "sha512-/l5w8BIkrpP6n1xsetx9MWPWlU6OblN5YgZZphxan0Tq4BByTCETL6lyIeY8lagalS2Nbt4F2W034KHLIiunKA==",
+      "dependencies": {
+        "core-js": "^3.15.1",
+        "nanopop": "^2.1.0"
+      }
+    },
+    "node_modules/@types/codemirror": {
+      "version": "5.60.8",
+      "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz",
+      "integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==",
+      "dependencies": {
+        "@types/tern": "*"
+      }
+    },
+    "node_modules/@types/debug": {
+      "version": "4.1.8",
+      "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.8.tgz",
+      "integrity": "sha512-/vPO1EPOs306Cvhwv7KfVfYvOJqA/S/AXjaHQiJboCZzcNDb+TIJFN9/2C9DZ//ijSKWioNyUxD792QmDJ+HKQ==",
+      "dependencies": {
+        "@types/ms": "*"
+      }
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.1.tgz",
+      "integrity": "sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA=="
+    },
+    "node_modules/@types/hast": {
+      "version": "2.3.5",
+      "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.5.tgz",
+      "integrity": "sha512-SvQi0L/lNpThgPoleH53cdjB3y9zpLlVjRbqB3rH8hx1jiRSBGAhyjV3H+URFjNVRqt2EdYNrbZE5IsGlNfpRg==",
+      "dependencies": {
+        "@types/unist": "^2"
+      }
+    },
+    "node_modules/@types/json-schema": {
+      "version": "7.0.12",
+      "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.12.tgz",
+      "integrity": "sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==",
+      "dev": true
+    },
+    "node_modules/@types/lodash": {
+      "version": "4.14.197",
+      "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.197.tgz",
+      "integrity": "sha512-BMVOiWs0uNxHVlHBgzTIqJYmj+PgCo4euloGF+5m4okL3rEYzM2EEv78mw8zWSMM57dM7kVIgJ2QDvwHSoCI5g=="
+    },
+    "node_modules/@types/lodash-es": {
+      "version": "4.17.8",
+      "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.8.tgz",
+      "integrity": "sha512-euY3XQcZmIzSy7YH5+Unb3b2X12Wtk54YWINBvvGQ5SmMvwb11JQskGsfkH/5HXK77Kr8GF0wkVDIxzAisWtog==",
+      "dependencies": {
+        "@types/lodash": "*"
+      }
+    },
+    "node_modules/@types/mdast": {
+      "version": "3.0.12",
+      "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.12.tgz",
+      "integrity": "sha512-DT+iNIRNX884cx0/Q1ja7NyUPpZuv0KPyL5rGNxm1WC1OtHstl7n4Jb7nk+xacNShQMbczJjt8uFzznpp6kYBg==",
+      "dependencies": {
+        "@types/unist": "^2"
+      }
+    },
+    "node_modules/@types/ms": {
+      "version": "0.7.31",
+      "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz",
+      "integrity": "sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA=="
+    },
+    "node_modules/@types/node": {
+      "version": "18.17.5",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-18.17.5.tgz",
+      "integrity": "sha512-xNbS75FxH6P4UXTPUJp/zNPq6/xsfdJKussCWNOnz4aULWIRwMgP1LgaB5RiBnMX1DPCYenuqGZfnIAx5mbFLA==",
+      "dev": true
+    },
+    "node_modules/@types/nprogress": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/@types/nprogress/-/nprogress-0.2.0.tgz",
+      "integrity": "sha512-1cYJrqq9GezNFPsWTZpFut/d4CjpZqA0vhqDUPFWYKF1oIyBz5qnoYMzR+0C/T96t3ebLAC1SSnwrVOm5/j74A==",
+      "dev": true
+    },
+    "node_modules/@types/parse5": {
+      "version": "6.0.3",
+      "resolved": "https://registry.npmjs.org/@types/parse5/-/parse5-6.0.3.tgz",
+      "integrity": "sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g=="
+    },
+    "node_modules/@types/semver": {
+      "version": "7.5.0",
+      "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.0.tgz",
+      "integrity": "sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==",
+      "dev": true
+    },
+    "node_modules/@types/sortablejs": {
+      "version": "1.15.1",
+      "resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.1.tgz",
+      "integrity": "sha512-g/JwBNToh6oCTAwNS8UGVmjO7NLDKsejVhvE4x1eWiPTC3uCuNsa/TD4ssvX3du+MLiM+SHPNDuijp8y76JzLQ==",
+      "dev": true
+    },
+    "node_modules/@types/tern": {
+      "version": "0.23.4",
+      "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.4.tgz",
+      "integrity": "sha512-JAUw1iXGO1qaWwEOzxTKJZ/5JxVeON9kvGZ/osgZaJImBnyjyn0cjovPsf6FNLmyGY8Vw9DoXZCMlfMkMwHRWg==",
+      "dependencies": {
+        "@types/estree": "*"
+      }
+    },
+    "node_modules/@types/unist": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.7.tgz",
+      "integrity": "sha512-cputDpIbFgLUaGQn6Vqg3/YsJwxUwHLO13v3i5ouxT4lat0khip9AEWxtERujXV9wxIB1EyF97BSJFt6vpdI8g=="
+    },
+    "node_modules/@typescript-eslint/eslint-plugin": {
+      "version": "5.62.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz",
+      "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==",
+      "dev": true,
+      "dependencies": {
+        "@eslint-community/regexpp": "^4.4.0",
+        "@typescript-eslint/scope-manager": "5.62.0",
+        "@typescript-eslint/type-utils": "5.62.0",
+        "@typescript-eslint/utils": "5.62.0",
+        "debug": "^4.3.4",
+        "graphemer": "^1.4.0",
+        "ignore": "^5.2.0",
+        "natural-compare-lite": "^1.4.0",
+        "semver": "^7.3.7",
+        "tsutils": "^3.21.0"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "@typescript-eslint/parser": "^5.0.0",
+        "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@typescript-eslint/parser": {
+      "version": "5.62.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz",
+      "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/scope-manager": "5.62.0",
+        "@typescript-eslint/types": "5.62.0",
+        "@typescript-eslint/typescript-estree": "5.62.0",
+        "debug": "^4.3.4"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@typescript-eslint/scope-manager": {
+      "version": "5.62.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz",
+      "integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/types": "5.62.0",
+        "@typescript-eslint/visitor-keys": "5.62.0"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      }
+    },
+    "node_modules/@typescript-eslint/type-utils": {
+      "version": "5.62.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz",
+      "integrity": "sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/typescript-estree": "5.62.0",
+        "@typescript-eslint/utils": "5.62.0",
+        "debug": "^4.3.4",
+        "tsutils": "^3.21.0"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "eslint": "*"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@typescript-eslint/types": {
+      "version": "5.62.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz",
+      "integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==",
+      "dev": true,
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      }
+    },
+    "node_modules/@typescript-eslint/typescript-estree": {
+      "version": "5.62.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz",
+      "integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/types": "5.62.0",
+        "@typescript-eslint/visitor-keys": "5.62.0",
+        "debug": "^4.3.4",
+        "globby": "^11.1.0",
+        "is-glob": "^4.0.3",
+        "semver": "^7.3.7",
+        "tsutils": "^3.21.0"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@typescript-eslint/utils": {
+      "version": "5.62.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz",
+      "integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==",
+      "dev": true,
+      "dependencies": {
+        "@eslint-community/eslint-utils": "^4.2.0",
+        "@types/json-schema": "^7.0.9",
+        "@types/semver": "^7.3.12",
+        "@typescript-eslint/scope-manager": "5.62.0",
+        "@typescript-eslint/types": "5.62.0",
+        "@typescript-eslint/typescript-estree": "5.62.0",
+        "eslint-scope": "^5.1.1",
+        "semver": "^7.3.7"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/visitor-keys": {
+      "version": "5.62.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz",
+      "integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/types": "5.62.0",
+        "eslint-visitor-keys": "^3.3.0"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      }
+    },
+    "node_modules/@vitejs/plugin-legacy": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/@vitejs/plugin-legacy/-/plugin-legacy-4.1.1.tgz",
+      "integrity": "sha512-um3gbVouD2Q/g19C0qpDfHwveXDCAHzs8OC3e9g6aXpKoD1H14himgs7wkMnhAynBJy7QqUoZNAXDuqN8zLR2g==",
+      "dev": true,
+      "dependencies": {
+        "@babel/core": "^7.22.9",
+        "@babel/preset-env": "^7.22.9",
+        "browserslist": "^4.21.9",
+        "core-js": "^3.31.1",
+        "magic-string": "^0.30.1",
+        "regenerator-runtime": "^0.13.11",
+        "systemjs": "^6.14.1"
+      },
+      "engines": {
+        "node": "^14.18.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "peerDependencies": {
+        "terser": "^5.4.0",
+        "vite": "^4.0.0"
+      }
+    },
+    "node_modules/@vitejs/plugin-vue": {
+      "version": "4.2.3",
+      "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.2.3.tgz",
+      "integrity": "sha512-R6JDUfiZbJA9cMiguQ7jxALsgiprjBeHL5ikpXfJCH62pPHtI+JdJ5xWj6Ev73yXSlYl86+blXn1kZHQ7uElxw==",
+      "dev": true,
+      "engines": {
+        "node": "^14.18.0 || >=16.0.0"
+      },
+      "peerDependencies": {
+        "vite": "^4.0.0",
+        "vue": "^3.2.25"
+      }
+    },
+    "node_modules/@volar/language-core": {
+      "version": "1.10.0",
+      "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-1.10.0.tgz",
+      "integrity": "sha512-ddyWwSYqcbEZNFHm+Z3NZd6M7Ihjcwl/9B5cZd8kECdimVXUFdFi60XHWD27nrWtUQIsUYIG7Ca1WBwV2u2LSQ==",
+      "dev": true,
+      "dependencies": {
+        "@volar/source-map": "1.10.0"
+      }
+    },
+    "node_modules/@volar/source-map": {
+      "version": "1.10.0",
+      "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-1.10.0.tgz",
+      "integrity": "sha512-/ibWdcOzDGiq/GM1JU2eX8fH1bvAhl66hfe8yEgLEzg9txgr6qb5sQ/DEz5PcDL75tF5H5sCRRwn8Eu8ezi9mw==",
+      "dev": true,
+      "dependencies": {
+        "muggle-string": "^0.3.1"
+      }
+    },
+    "node_modules/@volar/typescript": {
+      "version": "1.10.0",
+      "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-1.10.0.tgz",
+      "integrity": "sha512-OtqGtFbUKYC0pLNIk3mHQp5xWnvL1CJIUc9VE39VdZ/oqpoBh5jKfb9uJ45Y4/oP/WYTrif/Uxl1k8VTPz66Gg==",
+      "dev": true,
+      "dependencies": {
+        "@volar/language-core": "1.10.0"
+      }
+    },
+    "node_modules/@vue/compiler-core": {
+      "version": "3.3.4",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.3.4.tgz",
+      "integrity": "sha512-cquyDNvZ6jTbf/+x+AgM2Arrp6G4Dzbb0R64jiG804HRMfRiFXWI6kqUVqZ6ZR0bQhIoQjB4+2bhNtVwndW15g==",
+      "dependencies": {
+        "@babel/parser": "^7.21.3",
+        "@vue/shared": "3.3.4",
+        "estree-walker": "^2.0.2",
+        "source-map-js": "^1.0.2"
+      }
+    },
+    "node_modules/@vue/compiler-dom": {
+      "version": "3.3.4",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.3.4.tgz",
+      "integrity": "sha512-wyM+OjOVpuUukIq6p5+nwHYtj9cFroz9cwkfmP9O1nzH68BenTTv0u7/ndggT8cIQlnBeOo6sUT/gvHcIkLA5w==",
+      "dependencies": {
+        "@vue/compiler-core": "3.3.4",
+        "@vue/shared": "3.3.4"
+      }
+    },
+    "node_modules/@vue/compiler-sfc": {
+      "version": "3.3.4",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.3.4.tgz",
+      "integrity": "sha512-6y/d8uw+5TkCuzBkgLS0v3lSM3hJDntFEiUORM11pQ/hKvkhSKZrXW6i69UyXlJQisJxuUEJKAWEqWbWsLeNKQ==",
+      "dependencies": {
+        "@babel/parser": "^7.20.15",
+        "@vue/compiler-core": "3.3.4",
+        "@vue/compiler-dom": "3.3.4",
+        "@vue/compiler-ssr": "3.3.4",
+        "@vue/reactivity-transform": "3.3.4",
+        "@vue/shared": "3.3.4",
+        "estree-walker": "^2.0.2",
+        "magic-string": "^0.30.0",
+        "postcss": "^8.1.10",
+        "source-map-js": "^1.0.2"
+      }
+    },
+    "node_modules/@vue/compiler-ssr": {
+      "version": "3.3.4",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.3.4.tgz",
+      "integrity": "sha512-m0v6oKpup2nMSehwA6Uuu+j+wEwcy7QmwMkVNVfrV9P2qE5KshC6RwOCq8fjGS/Eak/uNb8AaWekfiXxbBB6gQ==",
+      "dependencies": {
+        "@vue/compiler-dom": "3.3.4",
+        "@vue/shared": "3.3.4"
+      }
+    },
+    "node_modules/@vue/devtools-api": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.5.0.tgz",
+      "integrity": "sha512-o9KfBeaBmCKl10usN4crU53fYtC1r7jJwdGKjPT24t348rHxgfpZ0xL3Xm/gLUYnc0oTp8LAmrxOeLyu6tbk2Q=="
+    },
+    "node_modules/@vue/language-core": {
+      "version": "1.8.8",
+      "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-1.8.8.tgz",
+      "integrity": "sha512-i4KMTuPazf48yMdYoebTkgSOJdFraE4pQf0B+FTOFkbB+6hAfjrSou/UmYWRsWyZV6r4Rc6DDZdI39CJwL0rWw==",
+      "dev": true,
+      "dependencies": {
+        "@volar/language-core": "~1.10.0",
+        "@volar/source-map": "~1.10.0",
+        "@vue/compiler-dom": "^3.3.0",
+        "@vue/reactivity": "^3.3.0",
+        "@vue/shared": "^3.3.0",
+        "minimatch": "^9.0.0",
+        "muggle-string": "^0.3.1",
+        "vue-template-compiler": "^2.7.14"
+      },
+      "peerDependencies": {
+        "typescript": "*"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@vue/language-core/node_modules/brace-expansion": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+      "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+      "dev": true,
+      "dependencies": {
+        "balanced-match": "^1.0.0"
+      }
+    },
+    "node_modules/@vue/language-core/node_modules/minimatch": {
+      "version": "9.0.3",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
+      "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+      "dev": true,
+      "dependencies": {
+        "brace-expansion": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=16 || 14 >=14.17"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/@vue/reactivity": {
+      "version": "3.3.4",
+      "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.3.4.tgz",
+      "integrity": "sha512-kLTDLwd0B1jG08NBF3R5rqULtv/f8x3rOFByTDz4J53ttIQEDmALqKqXY0J+XQeN0aV2FBxY8nJDf88yvOPAqQ==",
+      "dependencies": {
+        "@vue/shared": "3.3.4"
+      }
+    },
+    "node_modules/@vue/reactivity-transform": {
+      "version": "3.3.4",
+      "resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.3.4.tgz",
+      "integrity": "sha512-MXgwjako4nu5WFLAjpBnCj/ieqcjE2aJBINUNQzkZQfzIZA4xn+0fV1tIYBJvvva3N3OvKGofRLvQIwEQPpaXw==",
+      "dependencies": {
+        "@babel/parser": "^7.20.15",
+        "@vue/compiler-core": "3.3.4",
+        "@vue/shared": "3.3.4",
+        "estree-walker": "^2.0.2",
+        "magic-string": "^0.30.0"
+      }
+    },
+    "node_modules/@vue/runtime-core": {
+      "version": "3.3.4",
+      "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.3.4.tgz",
+      "integrity": "sha512-R+bqxMN6pWO7zGI4OMlmvePOdP2c93GsHFM/siJI7O2nxFRzj55pLwkpCedEY+bTMgp5miZ8CxfIZo3S+gFqvA==",
+      "dependencies": {
+        "@vue/reactivity": "3.3.4",
+        "@vue/shared": "3.3.4"
+      }
+    },
+    "node_modules/@vue/runtime-dom": {
+      "version": "3.3.4",
+      "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.3.4.tgz",
+      "integrity": "sha512-Aj5bTJ3u5sFsUckRghsNjVTtxZQ1OyMWCr5dZRAPijF/0Vy4xEoRCwLyHXcj4D0UFbJ4lbx3gPTgg06K/GnPnQ==",
+      "dependencies": {
+        "@vue/runtime-core": "3.3.4",
+        "@vue/shared": "3.3.4",
+        "csstype": "^3.1.1"
+      }
+    },
+    "node_modules/@vue/server-renderer": {
+      "version": "3.3.4",
+      "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.3.4.tgz",
+      "integrity": "sha512-Q6jDDzR23ViIb67v+vM1Dqntu+HUexQcsWKhhQa4ARVzxOY2HbC7QRW/ggkDBd5BU+uM1sV6XOAP0b216o34JQ==",
+      "dependencies": {
+        "@vue/compiler-ssr": "3.3.4",
+        "@vue/shared": "3.3.4"
+      },
+      "peerDependencies": {
+        "vue": "3.3.4"
+      }
+    },
+    "node_modules/@vue/shared": {
+      "version": "3.3.4",
+      "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.3.4.tgz",
+      "integrity": "sha512-7OjdcV8vQ74eiz1TZLzZP4JwqM5fA94K6yntPS5Z25r9HDuGNzaGdgvwKYq6S+MxwF0TFRwe50fIR/MYnakdkQ=="
+    },
+    "node_modules/@vue/typescript": {
+      "version": "1.8.8",
+      "resolved": "https://registry.npmjs.org/@vue/typescript/-/typescript-1.8.8.tgz",
+      "integrity": "sha512-jUnmMB6egu5wl342eaUH236v8tdcEPXXkPgj+eI/F6JwW/lb+yAU6U07ZbQ3MVabZRlupIlPESB7ajgAGixhow==",
+      "dev": true,
+      "dependencies": {
+        "@volar/typescript": "~1.10.0",
+        "@vue/language-core": "1.8.8"
+      }
+    },
+    "node_modules/acorn": {
+      "version": "8.10.0",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz",
+      "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==",
+      "dev": true,
+      "bin": {
+        "acorn": "bin/acorn"
+      },
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/acorn-jsx": {
+      "version": "5.3.2",
+      "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+      "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+      "dev": true,
+      "peerDependencies": {
+        "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+      }
+    },
+    "node_modules/adler-32": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
+      "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/ajv": {
+      "version": "6.12.6",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+      "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+      "dev": true,
+      "dependencies": {
+        "fast-deep-equal": "^3.1.1",
+        "fast-json-stable-stringify": "^2.0.0",
+        "json-schema-traverse": "^0.4.1",
+        "uri-js": "^4.2.2"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/epoberezkin"
+      }
+    },
+    "node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/ansi-styles": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+      "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+      "dev": true,
+      "dependencies": {
+        "color-convert": "^1.9.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/ant-design-vue": {
+      "version": "3.2.20",
+      "resolved": "https://registry.npmjs.org/ant-design-vue/-/ant-design-vue-3.2.20.tgz",
+      "integrity": "sha512-YWpMfGaGoRastIXEYfCoJiaRiDHk4chqtYhlKQM5GqPt6NfvrM1Vg2e60yHtjxlZjed91wCMm0rAmyUr7Hwzdg==",
+      "dependencies": {
+        "@ant-design/colors": "^6.0.0",
+        "@ant-design/icons-vue": "^6.1.0",
+        "@babel/runtime": "^7.10.5",
+        "@ctrl/tinycolor": "^3.4.0",
+        "@simonwep/pickr": "~1.8.0",
+        "array-tree-filter": "^2.1.0",
+        "async-validator": "^4.0.0",
+        "dayjs": "^1.10.5",
+        "dom-align": "^1.12.1",
+        "dom-scroll-into-view": "^2.0.0",
+        "lodash": "^4.17.21",
+        "lodash-es": "^4.17.15",
+        "resize-observer-polyfill": "^1.5.1",
+        "scroll-into-view-if-needed": "^2.2.25",
+        "shallow-equal": "^1.0.0",
+        "vue-types": "^3.0.0",
+        "warning": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=12.22.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/ant-design-vue"
+      },
+      "peerDependencies": {
+        "vue": ">=3.2.0"
+      }
+    },
+    "node_modules/ant-design-vue/node_modules/@ant-design/colors": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-6.0.0.tgz",
+      "integrity": "sha512-qAZRvPzfdWHtfameEGP2Qvuf838NhergR35o+EuVyB5XvSA98xod5r4utvi4TJ3ywmevm290g9nsCG5MryrdWQ==",
+      "dependencies": {
+        "@ctrl/tinycolor": "^3.4.0"
+      }
+    },
+    "node_modules/anymatch": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+      "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+      "dev": true,
+      "dependencies": {
+        "normalize-path": "^3.0.0",
+        "picomatch": "^2.0.4"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/argparse": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+      "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+      "dev": true
+    },
+    "node_modules/array-tree-filter": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/array-tree-filter/-/array-tree-filter-2.1.0.tgz",
+      "integrity": "sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw=="
+    },
+    "node_modules/array-union": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
+      "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/async-validator": {
+      "version": "4.2.5",
+      "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
+      "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg=="
+    },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
+    },
+    "node_modules/axios": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz",
+      "integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==",
+      "dependencies": {
+        "follow-redirects": "^1.15.0",
+        "form-data": "^4.0.0",
+        "proxy-from-env": "^1.1.0"
+      }
+    },
+    "node_modules/babel-plugin-polyfill-corejs2": {
+      "version": "0.4.5",
+      "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.5.tgz",
+      "integrity": "sha512-19hwUH5FKl49JEsvyTcoHakh6BE0wgXLLptIyKZ3PijHc/Ci521wygORCUCCred+E/twuqRyAkE02BAWPmsHOg==",
+      "dev": true,
+      "dependencies": {
+        "@babel/compat-data": "^7.22.6",
+        "@babel/helper-define-polyfill-provider": "^0.4.2",
+        "semver": "^6.3.1"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
+      }
+    },
+    "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": {
+      "version": "6.3.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+      "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+      "dev": true,
+      "bin": {
+        "semver": "bin/semver.js"
+      }
+    },
+    "node_modules/babel-plugin-polyfill-corejs3": {
+      "version": "0.8.3",
+      "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.8.3.tgz",
+      "integrity": "sha512-z41XaniZL26WLrvjy7soabMXrfPWARN25PZoriDEiLMxAp50AUW3t35BGQUMg5xK3UrpVTtagIDklxYa+MhiNA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-define-polyfill-provider": "^0.4.2",
+        "core-js-compat": "^3.31.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
+      }
+    },
+    "node_modules/babel-plugin-polyfill-regenerator": {
+      "version": "0.5.2",
+      "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.5.2.tgz",
+      "integrity": "sha512-tAlOptU0Xj34V1Y2PNTL4Y0FOJMDB6bZmoW39FeCQIhigGLkqu3Fj6uiXpxIf6Ij274ENdYx64y6Au+ZKlb1IA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-define-polyfill-provider": "^0.4.2"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
+      }
+    },
+    "node_modules/bail": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
+      "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/balanced-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+      "dev": true
+    },
+    "node_modules/binary-extensions": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
+      "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/boolbase": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
+      "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
+      "dev": true
+    },
+    "node_modules/brace-expansion": {
+      "version": "1.1.11",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+      "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+      "dev": true,
+      "dependencies": {
+        "balanced-match": "^1.0.0",
+        "concat-map": "0.0.1"
+      }
+    },
+    "node_modules/braces": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
+      "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
+      "dev": true,
+      "dependencies": {
+        "fill-range": "^7.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/browserslist": {
+      "version": "4.21.10",
+      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.10.tgz",
+      "integrity": "sha512-bipEBdZfVH5/pwrvqc+Ub0kUPVfGUhlKxbvfD+z1BDnPEO/X98ruXGA1WP5ASpAFKan7Qr6j736IacbZQuAlKQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/browserslist"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "dependencies": {
+        "caniuse-lite": "^1.0.30001517",
+        "electron-to-chromium": "^1.4.477",
+        "node-releases": "^2.0.13",
+        "update-browserslist-db": "^1.0.11"
+      },
+      "bin": {
+        "browserslist": "cli.js"
+      },
+      "engines": {
+        "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+      }
+    },
+    "node_modules/buffer-from": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+      "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+      "dev": true
+    },
+    "node_modules/bytemd": {
+      "version": "1.21.0",
+      "resolved": "https://registry.npmjs.org/bytemd/-/bytemd-1.21.0.tgz",
+      "integrity": "sha512-6nc658omwzcLdc/lT24w8G2x5pptZXiMyrQPbFuHwhYbmrLnsmKLm+9klsOx2/Lg2cYHYb2WzVh7zKZ9MZCVdg==",
+      "dependencies": {
+        "@popperjs/core": "^2.11.7",
+        "@types/codemirror": "^5.60.7",
+        "@types/hast": "^2.3.4",
+        "@types/lodash-es": "^4.17.7",
+        "@types/mdast": "^3.0.11",
+        "codemirror-ssr": "^0.65.0",
+        "hast-util-sanitize": "^4.1.0",
+        "lodash-es": "^4.17.21",
+        "rehype-raw": "^6.1.1",
+        "rehype-sanitize": "^5.0.1",
+        "rehype-stringify": "^9.0.3",
+        "remark-parse": "^10.0.1",
+        "remark-rehype": "^10.1.0",
+        "select-files": "^1.0.1",
+        "tippy.js": "^6.3.7",
+        "unified": "^10.1.2",
+        "unist-util-visit": "^4.1.2",
+        "vfile": "^5.3.7",
+        "word-count": "^0.2.2"
+      }
+    },
+    "node_modules/callsites": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+      "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/caniuse-lite": {
+      "version": "1.0.30001520",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001520.tgz",
+      "integrity": "sha512-tahF5O9EiiTzwTUqAeFjIZbn4Dnqxzz7ktrgGlMYNLH43Ul26IgTMH/zvL3DG0lZxBYnlT04axvInszUsZULdA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ]
+    },
+    "node_modules/ccount": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
+      "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/cfb": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
+      "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
+      "dependencies": {
+        "adler-32": "~1.3.0",
+        "crc-32": "~1.2.0"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/chalk": {
+      "version": "2.4.2",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+      "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+      "dev": true,
+      "dependencies": {
+        "ansi-styles": "^3.2.1",
+        "escape-string-regexp": "^1.0.5",
+        "supports-color": "^5.3.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/character-entities": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
+      "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/character-entities-html4": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
+      "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/character-entities-legacy": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
+      "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/chokidar": {
+      "version": "3.5.3",
+      "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
+      "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://paulmillr.com/funding/"
+        }
+      ],
+      "dependencies": {
+        "anymatch": "~3.1.2",
+        "braces": "~3.0.2",
+        "glob-parent": "~5.1.2",
+        "is-binary-path": "~2.1.0",
+        "is-glob": "~4.0.1",
+        "normalize-path": "~3.0.0",
+        "readdirp": "~3.6.0"
+      },
+      "engines": {
+        "node": ">= 8.10.0"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/chokidar/node_modules/glob-parent": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+      "dev": true,
+      "dependencies": {
+        "is-glob": "^4.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/codemirror-ssr": {
+      "version": "0.65.0",
+      "resolved": "https://registry.npmjs.org/codemirror-ssr/-/codemirror-ssr-0.65.0.tgz",
+      "integrity": "sha512-ofTAfPkQV56SYFfymNMYJ1ELo3+Jnkw3mOLgnIiQjhonwNmNzX1OFvnihAnYRXL0PWl2kT7s0gKrLc2ExshK4g==",
+      "peerDependencies": {
+        "@types/codemirror": "^5.0.0"
+      }
+    },
+    "node_modules/codepage": {
+      "version": "1.15.0",
+      "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
+      "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/color-convert": {
+      "version": "1.9.3",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+      "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+      "dev": true,
+      "dependencies": {
+        "color-name": "1.1.3"
+      }
+    },
+    "node_modules/color-name": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+      "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
+      "dev": true
+    },
+    "node_modules/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/comma-separated-tokens": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
+      "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/commander": {
+      "version": "2.20.3",
+      "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+      "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+      "dev": true
+    },
+    "node_modules/compute-scroll-into-view": {
+      "version": "1.0.20",
+      "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz",
+      "integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg=="
+    },
+    "node_modules/concat-map": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+      "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+      "dev": true
+    },
+    "node_modules/convert-source-map": {
+      "version": "1.9.0",
+      "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
+      "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
+      "dev": true
+    },
+    "node_modules/copy-anything": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-2.0.6.tgz",
+      "integrity": "sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==",
+      "dev": true,
+      "dependencies": {
+        "is-what": "^3.14.1"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/mesqueeb"
+      }
+    },
+    "node_modules/core-js": {
+      "version": "3.32.0",
+      "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.32.0.tgz",
+      "integrity": "sha512-rd4rYZNlF3WuoYuRIDEmbR/ga9CeuWX9U05umAvgrrZoHY4Z++cp/xwPQMvUpBB4Ag6J8KfD80G0zwCyaSxDww==",
+      "hasInstallScript": true,
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/core-js"
+      }
+    },
+    "node_modules/core-js-compat": {
+      "version": "3.32.0",
+      "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.32.0.tgz",
+      "integrity": "sha512-7a9a3D1k4UCVKnLhrgALyFcP7YCsLOQIxPd0dKjf/6GuPcgyiGP70ewWdCGrSK7evyhymi0qO4EqCmSJofDeYw==",
+      "dev": true,
+      "dependencies": {
+        "browserslist": "^4.21.9"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/core-js"
+      }
+    },
+    "node_modules/countup.js": {
+      "version": "2.7.0",
+      "resolved": "https://registry.npmjs.org/countup.js/-/countup.js-2.7.0.tgz",
+      "integrity": "sha512-IP9nYLGgW//0If73eXQdFlReGhpFGHaStqB1v82FknxnUWueF6HFuuOXySW4sEDMc88PsZL1EOn/NPkfTZalmQ=="
+    },
+    "node_modules/crc-32": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
+      "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
+      "bin": {
+        "crc32": "bin/crc32.njs"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/cropperjs": {
+      "version": "1.5.13",
+      "resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.5.13.tgz",
+      "integrity": "sha512-by7jKAo73y5/Do0K6sxdTKHgndY0NMjG2bEdgeJxycbcmHuCiMXqw8sxy5C5Y5WTOTcDGmbT7Sr5CgKOXR06OA=="
+    },
+    "node_modules/cross-spawn": {
+      "version": "7.0.3",
+      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
+      "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
+      "dev": true,
+      "dependencies": {
+        "path-key": "^3.1.0",
+        "shebang-command": "^2.0.0",
+        "which": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/cssesc": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+      "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+      "dev": true,
+      "bin": {
+        "cssesc": "bin/cssesc"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/csstype": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz",
+      "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ=="
+    },
+    "node_modules/d": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz",
+      "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==",
+      "dependencies": {
+        "es5-ext": "^0.10.50",
+        "type": "^1.0.1"
+      }
+    },
+    "node_modules/danmu.js": {
+      "version": "1.1.11",
+      "resolved": "https://registry.npmjs.org/danmu.js/-/danmu.js-1.1.11.tgz",
+      "integrity": "sha512-beL1hDnjvzvvXs2CuearTybkB+VzdyrMsBgRxIRO9FD6r5TakJ7O5D72sZz7Q7rfV1jwDUO1zlFwxrS0SNZTBQ==",
+      "dependencies": {
+        "event-emitter": "^0.3.5"
+      }
+    },
+    "node_modules/dayjs": {
+      "version": "1.11.9",
+      "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.9.tgz",
+      "integrity": "sha512-QvzAURSbQ0pKdIye2txOzNaHmxtUBXerpY0FJsFXUMKbIZeFm5ht1LS/jFsrncjnmtv8HsG0W2g6c0zUjZWmpA=="
+    },
+    "node_modules/de-indent": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
+      "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
+      "dev": true
+    },
+    "node_modules/debug": {
+      "version": "4.3.4",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+      "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+      "dependencies": {
+        "ms": "2.1.2"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/decode-named-character-reference": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz",
+      "integrity": "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==",
+      "dependencies": {
+        "character-entities": "^2.0.0"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/deep-is": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+      "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+      "dev": true
+    },
+    "node_modules/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/delegate": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz",
+      "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw=="
+    },
+    "node_modules/dequal": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+      "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/diff": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz",
+      "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==",
+      "engines": {
+        "node": ">=0.3.1"
+      }
+    },
+    "node_modules/dir-glob": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
+      "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
+      "dev": true,
+      "dependencies": {
+        "path-type": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/doctrine": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
+      "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+      "dev": true,
+      "dependencies": {
+        "esutils": "^2.0.2"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/dom-align": {
+      "version": "1.12.4",
+      "resolved": "https://registry.npmjs.org/dom-align/-/dom-align-1.12.4.tgz",
+      "integrity": "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw=="
+    },
+    "node_modules/dom-scroll-into-view": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/dom-scroll-into-view/-/dom-scroll-into-view-2.0.1.tgz",
+      "integrity": "sha512-bvVTQe1lfaUr1oFzZX80ce9KLDlZ3iU+XGNE/bz9HnGdklTieqsbmsLHe+rT2XWqopvL0PckkYqN7ksmm5pe3w=="
+    },
+    "node_modules/downloadjs": {
+      "version": "1.4.7",
+      "resolved": "https://registry.npmjs.org/downloadjs/-/downloadjs-1.4.7.tgz",
+      "integrity": "sha512-LN1gO7+u9xjU5oEScGFKvXhYf7Y/empUIIEAGBs1LzUq/rg5duiDrkuH5A2lQGd5jfMOb9X9usDa2oVXwJ0U/Q=="
+    },
+    "node_modules/eastasianwidth": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
+      "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
+      "dev": true
+    },
+    "node_modules/echarts": {
+      "version": "5.4.3",
+      "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.4.3.tgz",
+      "integrity": "sha512-mYKxLxhzy6zyTi/FaEbJMOZU1ULGEQHaeIeuMR5L+JnJTpz+YR03mnnpBhbR4+UYJAgiXgpyTVLffPAjOTLkZA==",
+      "dependencies": {
+        "tslib": "2.3.0",
+        "zrender": "5.4.4"
+      }
+    },
+    "node_modules/echarts-wordcloud": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/echarts-wordcloud/-/echarts-wordcloud-2.1.0.tgz",
+      "integrity": "sha512-Kt1JmbcROgb+3IMI48KZECK2AP5lG6bSsOEs+AsuwaWJxQom31RTNd6NFYI01E/YaI1PFZeueaupjlmzSQasjQ==",
+      "peerDependencies": {
+        "echarts": "^5.0.1"
+      }
+    },
+    "node_modules/ele-admin-pro": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/ele-admin-pro/-/ele-admin-pro-1.11.1.tgz",
+      "integrity": "sha512-3gTCTrQ6XCnfMP8nNZZFXaoRcNpOMLpK2mEsu+21f3w5ID/BFhyOxWSngJL/LMPelwSpK+PwRclp0r3Wu16J7Q==",
+      "peerDependencies": {
+        "ant-design-vue": ">=3.1.0",
+        "vue": ">=3.1.0"
+      }
+    },
+    "node_modules/electron-to-chromium": {
+      "version": "1.4.490",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.490.tgz",
+      "integrity": "sha512-6s7NVJz+sATdYnIwhdshx/N/9O6rvMxmhVoDSDFdj6iA45gHR8EQje70+RYsF4GeB+k0IeNSBnP7yG9ZXJFr7A==",
+      "dev": true
+    },
+    "node_modules/emoji-regex": {
+      "version": "9.2.2",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+      "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+      "dev": true
+    },
+    "node_modules/errno": {
+      "version": "0.1.8",
+      "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz",
+      "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==",
+      "dev": true,
+      "optional": true,
+      "dependencies": {
+        "prr": "~1.0.1"
+      },
+      "bin": {
+        "errno": "cli.js"
+      }
+    },
+    "node_modules/es5-ext": {
+      "version": "0.10.62",
+      "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.62.tgz",
+      "integrity": "sha512-BHLqn0klhEpnOKSrzn/Xsz2UIW8j+cGmo9JLzr8BiUapV8hPL9+FliFqjwr9ngW7jWdnxv6eO+/LqyhJVqgrjA==",
+      "hasInstallScript": true,
+      "dependencies": {
+        "es6-iterator": "^2.0.3",
+        "es6-symbol": "^3.1.3",
+        "next-tick": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
+    "node_modules/es6-iterator": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz",
+      "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==",
+      "dependencies": {
+        "d": "1",
+        "es5-ext": "^0.10.35",
+        "es6-symbol": "^3.1.1"
+      }
+    },
+    "node_modules/es6-symbol": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz",
+      "integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==",
+      "dependencies": {
+        "d": "^1.0.1",
+        "ext": "^1.1.2"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.18.20",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz",
+      "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==",
+      "dev": true,
+      "hasInstallScript": true,
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "optionalDependencies": {
+        "@esbuild/android-arm": "0.18.20",
+        "@esbuild/android-arm64": "0.18.20",
+        "@esbuild/android-x64": "0.18.20",
+        "@esbuild/darwin-arm64": "0.18.20",
+        "@esbuild/darwin-x64": "0.18.20",
+        "@esbuild/freebsd-arm64": "0.18.20",
+        "@esbuild/freebsd-x64": "0.18.20",
+        "@esbuild/linux-arm": "0.18.20",
+        "@esbuild/linux-arm64": "0.18.20",
+        "@esbuild/linux-ia32": "0.18.20",
+        "@esbuild/linux-loong64": "0.18.20",
+        "@esbuild/linux-mips64el": "0.18.20",
+        "@esbuild/linux-ppc64": "0.18.20",
+        "@esbuild/linux-riscv64": "0.18.20",
+        "@esbuild/linux-s390x": "0.18.20",
+        "@esbuild/linux-x64": "0.18.20",
+        "@esbuild/netbsd-x64": "0.18.20",
+        "@esbuild/openbsd-x64": "0.18.20",
+        "@esbuild/sunos-x64": "0.18.20",
+        "@esbuild/win32-arm64": "0.18.20",
+        "@esbuild/win32-ia32": "0.18.20",
+        "@esbuild/win32-x64": "0.18.20"
+      }
+    },
+    "node_modules/escalade": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
+      "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/escape-string-regexp": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+      "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.8.0"
+      }
+    },
+    "node_modules/eslint": {
+      "version": "8.47.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.47.0.tgz",
+      "integrity": "sha512-spUQWrdPt+pRVP1TTJLmfRNJJHHZryFmptzcafwSvHsceV81djHOdnEeDmkdotZyLNjDhrOasNK8nikkoG1O8Q==",
+      "dev": true,
+      "dependencies": {
+        "@eslint-community/eslint-utils": "^4.2.0",
+        "@eslint-community/regexpp": "^4.6.1",
+        "@eslint/eslintrc": "^2.1.2",
+        "@eslint/js": "^8.47.0",
+        "@humanwhocodes/config-array": "^0.11.10",
+        "@humanwhocodes/module-importer": "^1.0.1",
+        "@nodelib/fs.walk": "^1.2.8",
+        "ajv": "^6.12.4",
+        "chalk": "^4.0.0",
+        "cross-spawn": "^7.0.2",
+        "debug": "^4.3.2",
+        "doctrine": "^3.0.0",
+        "escape-string-regexp": "^4.0.0",
+        "eslint-scope": "^7.2.2",
+        "eslint-visitor-keys": "^3.4.3",
+        "espree": "^9.6.1",
+        "esquery": "^1.4.2",
+        "esutils": "^2.0.2",
+        "fast-deep-equal": "^3.1.3",
+        "file-entry-cache": "^6.0.1",
+        "find-up": "^5.0.0",
+        "glob-parent": "^6.0.2",
+        "globals": "^13.19.0",
+        "graphemer": "^1.4.0",
+        "ignore": "^5.2.0",
+        "imurmurhash": "^0.1.4",
+        "is-glob": "^4.0.0",
+        "is-path-inside": "^3.0.3",
+        "js-yaml": "^4.1.0",
+        "json-stable-stringify-without-jsonify": "^1.0.1",
+        "levn": "^0.4.1",
+        "lodash.merge": "^4.6.2",
+        "minimatch": "^3.1.2",
+        "natural-compare": "^1.4.0",
+        "optionator": "^0.9.3",
+        "strip-ansi": "^6.0.1",
+        "text-table": "^0.2.0"
+      },
+      "bin": {
+        "eslint": "bin/eslint.js"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/eslint-config-prettier": {
+      "version": "8.10.0",
+      "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.10.0.tgz",
+      "integrity": "sha512-SM8AMJdeQqRYT9O9zguiruQZaN7+z+E4eAP9oiLNGKMtomwaB1E9dcgUD6ZAn/eQAb52USbvezbiljfZUhbJcg==",
+      "dev": true,
+      "bin": {
+        "eslint-config-prettier": "bin/cli.js"
+      },
+      "peerDependencies": {
+        "eslint": ">=7.0.0"
+      }
+    },
+    "node_modules/eslint-define-config": {
+      "version": "1.23.0",
+      "resolved": "https://registry.npmjs.org/eslint-define-config/-/eslint-define-config-1.23.0.tgz",
+      "integrity": "sha512-4mMyu0JuBkQHsCtR+42irIQdFLmLIW+pMAVcyOV/gZRL4O1R8iuH0eMG3oL3Cbi1eo9fDAfT5CIHVHgdyxcf6w==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/Shinigami92"
+        },
+        {
+          "type": "paypal",
+          "url": "https://www.paypal.com/donate/?hosted_button_id=L7GY729FBKTZY"
+        }
+      ],
+      "engines": {
+        "node": "^16.13.0 || >=18.0.0",
+        "npm": ">=7.0.0",
+        "pnpm": ">= 8.6.0"
+      }
+    },
+    "node_modules/eslint-plugin-prettier": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz",
+      "integrity": "sha512-f/0rXLXUt0oFYs8ra4w49wYZBG5GKZpAYsJSm6rnYL5uVDjd+zowwMwVZHnAjf4edNrKpCDYfXDgmRE/Ak7QyQ==",
+      "dev": true,
+      "dependencies": {
+        "prettier-linter-helpers": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "peerDependencies": {
+        "eslint": ">=7.28.0",
+        "prettier": ">=2.0.0"
+      },
+      "peerDependenciesMeta": {
+        "eslint-config-prettier": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/eslint-plugin-vue": {
+      "version": "9.17.0",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-9.17.0.tgz",
+      "integrity": "sha512-r7Bp79pxQk9I5XDP0k2dpUC7Ots3OSWgvGZNu3BxmKK6Zg7NgVtcOB6OCna5Kb9oQwJPl5hq183WD0SY5tZtIQ==",
+      "dev": true,
+      "dependencies": {
+        "@eslint-community/eslint-utils": "^4.4.0",
+        "natural-compare": "^1.4.0",
+        "nth-check": "^2.1.1",
+        "postcss-selector-parser": "^6.0.13",
+        "semver": "^7.5.4",
+        "vue-eslint-parser": "^9.3.1",
+        "xml-name-validator": "^4.0.0"
+      },
+      "engines": {
+        "node": "^14.17.0 || >=16.0.0"
+      },
+      "peerDependencies": {
+        "eslint": "^6.2.0 || ^7.0.0 || ^8.0.0"
+      }
+    },
+    "node_modules/eslint-scope": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
+      "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
+      "dev": true,
+      "dependencies": {
+        "esrecurse": "^4.3.0",
+        "estraverse": "^4.1.1"
+      },
+      "engines": {
+        "node": ">=8.0.0"
+      }
+    },
+    "node_modules/eslint-visitor-keys": {
+      "version": "3.4.3",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+      "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+      "dev": true,
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/eslint/node_modules/ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "dev": true,
+      "dependencies": {
+        "color-convert": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/eslint/node_modules/chalk": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+      "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+      "dev": true,
+      "dependencies": {
+        "ansi-styles": "^4.1.0",
+        "supports-color": "^7.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/chalk?sponsor=1"
+      }
+    },
+    "node_modules/eslint/node_modules/color-convert": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "dev": true,
+      "dependencies": {
+        "color-name": "~1.1.4"
+      },
+      "engines": {
+        "node": ">=7.0.0"
+      }
+    },
+    "node_modules/eslint/node_modules/color-name": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+      "dev": true
+    },
+    "node_modules/eslint/node_modules/escape-string-regexp": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+      "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/eslint/node_modules/eslint-scope": {
+      "version": "7.2.2",
+      "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
+      "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
+      "dev": true,
+      "dependencies": {
+        "esrecurse": "^4.3.0",
+        "estraverse": "^5.2.0"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/eslint/node_modules/estraverse": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+      "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+      "dev": true,
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/eslint/node_modules/globals": {
+      "version": "13.21.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-13.21.0.tgz",
+      "integrity": "sha512-ybyme3s4yy/t/3s35bewwXKOf7cvzfreG2lH0lZl0JB7I4GxRP2ghxOK/Nb9EkRXdbBXZLfq/p/0W2JUONB/Gg==",
+      "dev": true,
+      "dependencies": {
+        "type-fest": "^0.20.2"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/eslint/node_modules/has-flag": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/eslint/node_modules/supports-color": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+      "dev": true,
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/espree": {
+      "version": "9.6.1",
+      "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
+      "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
+      "dev": true,
+      "dependencies": {
+        "acorn": "^8.9.0",
+        "acorn-jsx": "^5.3.2",
+        "eslint-visitor-keys": "^3.4.1"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/esquery": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz",
+      "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==",
+      "dev": true,
+      "dependencies": {
+        "estraverse": "^5.1.0"
+      },
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
+    "node_modules/esquery/node_modules/estraverse": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+      "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+      "dev": true,
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/esrecurse": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+      "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+      "dev": true,
+      "dependencies": {
+        "estraverse": "^5.2.0"
+      },
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/esrecurse/node_modules/estraverse": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+      "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+      "dev": true,
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/estraverse": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
+      "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
+      "dev": true,
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/estree-walker": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+      "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
+    },
+    "node_modules/esutils": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+      "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/event-emitter": {
+      "version": "0.3.5",
+      "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz",
+      "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==",
+      "dependencies": {
+        "d": "1",
+        "es5-ext": "~0.10.14"
+      }
+    },
+    "node_modules/eventemitter3": {
+      "version": "4.0.7",
+      "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
+      "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
+    },
+    "node_modules/ext": {
+      "version": "1.7.0",
+      "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz",
+      "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==",
+      "dependencies": {
+        "type": "^2.7.2"
+      }
+    },
+    "node_modules/ext/node_modules/type": {
+      "version": "2.7.2",
+      "resolved": "https://registry.npmjs.org/type/-/type-2.7.2.tgz",
+      "integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw=="
+    },
+    "node_modules/extend": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+      "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
+    },
+    "node_modules/fast-deep-equal": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+      "dev": true
+    },
+    "node_modules/fast-diff": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
+      "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
+      "dev": true
+    },
+    "node_modules/fast-glob": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
+      "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==",
+      "dev": true,
+      "dependencies": {
+        "@nodelib/fs.stat": "^2.0.2",
+        "@nodelib/fs.walk": "^1.2.3",
+        "glob-parent": "^5.1.2",
+        "merge2": "^1.3.0",
+        "micromatch": "^4.0.4"
+      },
+      "engines": {
+        "node": ">=8.6.0"
+      }
+    },
+    "node_modules/fast-glob/node_modules/glob-parent": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+      "dev": true,
+      "dependencies": {
+        "is-glob": "^4.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/fast-json-stable-stringify": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+      "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+      "dev": true
+    },
+    "node_modules/fast-levenshtein": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+      "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+      "dev": true
+    },
+    "node_modules/fastq": {
+      "version": "1.15.0",
+      "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz",
+      "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==",
+      "dev": true,
+      "dependencies": {
+        "reusify": "^1.0.4"
+      }
+    },
+    "node_modules/file-entry-cache": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
+      "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
+      "dev": true,
+      "dependencies": {
+        "flat-cache": "^3.0.4"
+      },
+      "engines": {
+        "node": "^10.12.0 || >=12.0.0"
+      }
+    },
+    "node_modules/fill-range": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
+      "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
+      "dev": true,
+      "dependencies": {
+        "to-regex-range": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/find-up": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+      "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+      "dev": true,
+      "dependencies": {
+        "locate-path": "^6.0.0",
+        "path-exists": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/flat-cache": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz",
+      "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==",
+      "dev": true,
+      "dependencies": {
+        "flatted": "^3.1.0",
+        "rimraf": "^3.0.2"
+      },
+      "engines": {
+        "node": "^10.12.0 || >=12.0.0"
+      }
+    },
+    "node_modules/flat-cache/node_modules/glob": {
+      "version": "7.2.3",
+      "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+      "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+      "dev": true,
+      "dependencies": {
+        "fs.realpath": "^1.0.0",
+        "inflight": "^1.0.4",
+        "inherits": "2",
+        "minimatch": "^3.1.1",
+        "once": "^1.3.0",
+        "path-is-absolute": "^1.0.0"
+      },
+      "engines": {
+        "node": "*"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/flat-cache/node_modules/rimraf": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+      "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+      "dev": true,
+      "dependencies": {
+        "glob": "^7.1.3"
+      },
+      "bin": {
+        "rimraf": "bin.js"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/flatted": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz",
+      "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
+      "dev": true
+    },
+    "node_modules/follow-redirects": {
+      "version": "1.15.2",
+      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
+      "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/RubenVerborgh"
+        }
+      ],
+      "engines": {
+        "node": ">=4.0"
+      },
+      "peerDependenciesMeta": {
+        "debug": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/foreground-child": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz",
+      "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==",
+      "dev": true,
+      "dependencies": {
+        "cross-spawn": "^7.0.0",
+        "signal-exit": "^4.0.1"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/form-data": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
+      "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "mime-types": "^2.1.12"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/frac": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
+      "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/fs-extra": {
+      "version": "10.1.0",
+      "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
+      "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
+      "dev": true,
+      "dependencies": {
+        "graceful-fs": "^4.2.0",
+        "jsonfile": "^6.0.1",
+        "universalify": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/fs.realpath": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+      "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+      "dev": true
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+      "dev": true,
+      "hasInstallScript": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
+      "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
+      "dev": true
+    },
+    "node_modules/gensync": {
+      "version": "1.0.0-beta.2",
+      "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+      "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+      "dev": true,
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/github-markdown-css": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/github-markdown-css/-/github-markdown-css-5.2.0.tgz",
+      "integrity": "sha512-hq5RaCInSUZ48bImOZpkppW2/MT44StRgsbsZ8YA4vJFwLKB/Vo3k7R2t+pUGqO+ThG0QDMi96TewV/B3vyItg==",
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/glob": {
+      "version": "10.3.3",
+      "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.3.tgz",
+      "integrity": "sha512-92vPiMb/iqpmEgsOoIDvTjc50wf9CCCvMzsi6W0JLPeUKE8TWP1a73PgqSrqy7iAZxaSD1YdzU7QZR5LF51MJw==",
+      "dev": true,
+      "dependencies": {
+        "foreground-child": "^3.1.0",
+        "jackspeak": "^2.0.3",
+        "minimatch": "^9.0.1",
+        "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
+        "path-scurry": "^1.10.1"
+      },
+      "bin": {
+        "glob": "dist/cjs/src/bin.js"
+      },
+      "engines": {
+        "node": ">=16 || 14 >=14.17"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/glob-parent": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+      "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+      "dev": true,
+      "dependencies": {
+        "is-glob": "^4.0.3"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/glob/node_modules/brace-expansion": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+      "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+      "dev": true,
+      "dependencies": {
+        "balanced-match": "^1.0.0"
+      }
+    },
+    "node_modules/glob/node_modules/minimatch": {
+      "version": "9.0.3",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
+      "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+      "dev": true,
+      "dependencies": {
+        "brace-expansion": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=16 || 14 >=14.17"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/globals": {
+      "version": "11.12.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
+      "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/globby": {
+      "version": "11.1.0",
+      "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
+      "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
+      "dev": true,
+      "dependencies": {
+        "array-union": "^2.1.0",
+        "dir-glob": "^3.0.1",
+        "fast-glob": "^3.2.9",
+        "ignore": "^5.2.0",
+        "merge2": "^1.4.1",
+        "slash": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/graceful-fs": {
+      "version": "4.2.11",
+      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+      "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+      "dev": true
+    },
+    "node_modules/graphemer": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
+      "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
+      "dev": true
+    },
+    "node_modules/has": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
+      "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
+      "dev": true,
+      "dependencies": {
+        "function-bind": "^1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.4.0"
+      }
+    },
+    "node_modules/has-flag": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+      "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/hast-util-from-parse5": {
+      "version": "7.1.2",
+      "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-7.1.2.tgz",
+      "integrity": "sha512-Nz7FfPBuljzsN3tCQ4kCBKqdNhQE2l0Tn+X1ubgKBPRoiDIu1mL08Cfw4k7q71+Duyaw7DXDN+VTAp4Vh3oCOw==",
+      "dependencies": {
+        "@types/hast": "^2.0.0",
+        "@types/unist": "^2.0.0",
+        "hastscript": "^7.0.0",
+        "property-information": "^6.0.0",
+        "vfile": "^5.0.0",
+        "vfile-location": "^4.0.0",
+        "web-namespaces": "^2.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/hast-util-parse-selector": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-3.1.1.tgz",
+      "integrity": "sha512-jdlwBjEexy1oGz0aJ2f4GKMaVKkA9jwjr4MjAAI22E5fM/TXVZHuS5OpONtdeIkRKqAaryQ2E9xNQxijoThSZA==",
+      "dependencies": {
+        "@types/hast": "^2.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/hast-util-raw": {
+      "version": "7.2.3",
+      "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-7.2.3.tgz",
+      "integrity": "sha512-RujVQfVsOrxzPOPSzZFiwofMArbQke6DJjnFfceiEbFh7S05CbPt0cYN+A5YeD3pso0JQk6O1aHBnx9+Pm2uqg==",
+      "dependencies": {
+        "@types/hast": "^2.0.0",
+        "@types/parse5": "^6.0.0",
+        "hast-util-from-parse5": "^7.0.0",
+        "hast-util-to-parse5": "^7.0.0",
+        "html-void-elements": "^2.0.0",
+        "parse5": "^6.0.0",
+        "unist-util-position": "^4.0.0",
+        "unist-util-visit": "^4.0.0",
+        "vfile": "^5.0.0",
+        "web-namespaces": "^2.0.0",
+        "zwitch": "^2.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/hast-util-sanitize": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-4.1.0.tgz",
+      "integrity": "sha512-Hd9tU0ltknMGRDv+d6Ro/4XKzBqQnP/EZrpiTbpFYfXv/uOhWeKc+2uajcbEvAEH98VZd7eII2PiXm13RihnLw==",
+      "dependencies": {
+        "@types/hast": "^2.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/hast-util-to-html": {
+      "version": "8.0.4",
+      "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-8.0.4.tgz",
+      "integrity": "sha512-4tpQTUOr9BMjtYyNlt0P50mH7xj0Ks2xpo8M943Vykljf99HW6EzulIoJP1N3eKOSScEHzyzi9dm7/cn0RfGwA==",
+      "dependencies": {
+        "@types/hast": "^2.0.0",
+        "@types/unist": "^2.0.0",
+        "ccount": "^2.0.0",
+        "comma-separated-tokens": "^2.0.0",
+        "hast-util-raw": "^7.0.0",
+        "hast-util-whitespace": "^2.0.0",
+        "html-void-elements": "^2.0.0",
+        "property-information": "^6.0.0",
+        "space-separated-tokens": "^2.0.0",
+        "stringify-entities": "^4.0.0",
+        "zwitch": "^2.0.4"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/hast-util-to-parse5": {
+      "version": "7.1.0",
+      "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-7.1.0.tgz",
+      "integrity": "sha512-YNRgAJkH2Jky5ySkIqFXTQiaqcAtJyVE+D5lkN6CdtOqrnkLfGYYrEcKuHOJZlp+MwjSwuD3fZuawI+sic/RBw==",
+      "dependencies": {
+        "@types/hast": "^2.0.0",
+        "comma-separated-tokens": "^2.0.0",
+        "property-information": "^6.0.0",
+        "space-separated-tokens": "^2.0.0",
+        "web-namespaces": "^2.0.0",
+        "zwitch": "^2.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/hast-util-whitespace": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-2.0.1.tgz",
+      "integrity": "sha512-nAxA0v8+vXSBDt3AnRUNjyRIQ0rD+ntpbAp4LnPkumc5M9yUbSMa4XDU9Q6etY4f1Wp4bNgvc1yjiZtsTTrSng==",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/hastscript": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-7.2.0.tgz",
+      "integrity": "sha512-TtYPq24IldU8iKoJQqvZOuhi5CyCQRAbvDOX0x1eW6rsHSxa/1i2CCiptNTotGHJ3VoHRGmqiv6/D3q113ikkw==",
+      "dependencies": {
+        "@types/hast": "^2.0.0",
+        "comma-separated-tokens": "^2.0.0",
+        "hast-util-parse-selector": "^3.0.0",
+        "property-information": "^6.0.0",
+        "space-separated-tokens": "^2.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/he": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+      "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+      "dev": true,
+      "bin": {
+        "he": "bin/he"
+      }
+    },
+    "node_modules/html-void-elements": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-2.0.1.tgz",
+      "integrity": "sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+      "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+      "dev": true,
+      "optional": true,
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/ignore": {
+      "version": "5.2.4",
+      "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz",
+      "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==",
+      "dev": true,
+      "engines": {
+        "node": ">= 4"
+      }
+    },
+    "node_modules/image-size": {
+      "version": "0.5.5",
+      "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz",
+      "integrity": "sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==",
+      "dev": true,
+      "optional": true,
+      "bin": {
+        "image-size": "bin/image-size.js"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/import-fresh": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
+      "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+      "dev": true,
+      "dependencies": {
+        "parent-module": "^1.0.0",
+        "resolve-from": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/imurmurhash": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+      "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.8.19"
+      }
+    },
+    "node_modules/inflight": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+      "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+      "dev": true,
+      "dependencies": {
+        "once": "^1.3.0",
+        "wrappy": "1"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "dev": true
+    },
+    "node_modules/is-binary-path": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+      "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+      "dev": true,
+      "dependencies": {
+        "binary-extensions": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-buffer": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz",
+      "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/is-core-module": {
+      "version": "2.13.0",
+      "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.0.tgz",
+      "integrity": "sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==",
+      "dev": true,
+      "dependencies": {
+        "has": "^1.0.3"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-extglob": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+      "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-fullwidth-code-point": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+      "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-glob": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+      "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+      "dev": true,
+      "dependencies": {
+        "is-extglob": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-number": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+      "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.12.0"
+      }
+    },
+    "node_modules/is-path-inside": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
+      "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-plain-obj": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
+      "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/is-plain-object": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-3.0.1.tgz",
+      "integrity": "sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-what": {
+      "version": "3.14.1",
+      "resolved": "https://registry.npmjs.org/is-what/-/is-what-3.14.1.tgz",
+      "integrity": "sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==",
+      "dev": true
+    },
+    "node_modules/isexe": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+      "dev": true
+    },
+    "node_modules/jackspeak": {
+      "version": "2.2.3",
+      "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.2.3.tgz",
+      "integrity": "sha512-pF0kfjmg8DJLxDrizHoCZGUFz4P4czQ3HyfW4BU0ffebYkzAVlBywp5zaxW/TM+r0sGbmrQdi8EQQVTJFxnGsQ==",
+      "dev": true,
+      "dependencies": {
+        "@isaacs/cliui": "^8.0.2"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      },
+      "optionalDependencies": {
+        "@pkgjs/parseargs": "^0.11.0"
+      }
+    },
+    "node_modules/js-tokens": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
+    },
+    "node_modules/js-yaml": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+      "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+      "dev": true,
+      "dependencies": {
+        "argparse": "^2.0.1"
+      },
+      "bin": {
+        "js-yaml": "bin/js-yaml.js"
+      }
+    },
+    "node_modules/jsbarcode": {
+      "version": "3.11.5",
+      "resolved": "https://registry.npmjs.org/jsbarcode/-/jsbarcode-3.11.5.tgz",
+      "integrity": "sha512-zv3KsH51zD00I/LrFzFSM6dst7rDn0vIMzaiZFL7qusTjPZiPtxg3zxetp0RR7obmjTw4f6NyGgbdkBCgZUIrA==",
+      "bin": {
+        "auto.js": "bin/barcodes/CODE128/auto.js",
+        "Barcode.js": "bin/barcodes/Barcode.js",
+        "barcodes": "bin/barcodes",
+        "canvas.js": "bin/renderers/canvas.js",
+        "checksums.js": "bin/barcodes/MSI/checksums.js",
+        "codabar": "bin/barcodes/codabar",
+        "CODE128": "bin/barcodes/CODE128",
+        "CODE128_AUTO.js": "bin/barcodes/CODE128/CODE128_AUTO.js",
+        "CODE128.js": "bin/barcodes/CODE128/CODE128.js",
+        "CODE128A.js": "bin/barcodes/CODE128/CODE128A.js",
+        "CODE128B.js": "bin/barcodes/CODE128/CODE128B.js",
+        "CODE128C.js": "bin/barcodes/CODE128/CODE128C.js",
+        "CODE39": "bin/barcodes/CODE39",
+        "constants.js": "bin/barcodes/ITF/constants.js",
+        "defaults.js": "bin/options/defaults.js",
+        "EAN_UPC": "bin/barcodes/EAN_UPC",
+        "EAN.js": "bin/barcodes/EAN_UPC/EAN.js",
+        "EAN13.js": "bin/barcodes/EAN_UPC/EAN13.js",
+        "EAN2.js": "bin/barcodes/EAN_UPC/EAN2.js",
+        "EAN5.js": "bin/barcodes/EAN_UPC/EAN5.js",
+        "EAN8.js": "bin/barcodes/EAN_UPC/EAN8.js",
+        "encoder.js": "bin/barcodes/EAN_UPC/encoder.js",
+        "ErrorHandler.js": "bin/exceptions/ErrorHandler.js",
+        "exceptions": "bin/exceptions",
+        "exceptions.js": "bin/exceptions/exceptions.js",
+        "fixOptions.js": "bin/help/fixOptions.js",
+        "GenericBarcode": "bin/barcodes/GenericBarcode",
+        "getOptionsFromElement.js": "bin/help/getOptionsFromElement.js",
+        "getRenderProperties.js": "bin/help/getRenderProperties.js",
+        "help": "bin/help",
+        "index.js": "bin/renderers/index.js",
+        "index.tmp.js": "bin/barcodes/index.tmp.js",
+        "ITF": "bin/barcodes/ITF",
+        "ITF.js": "bin/barcodes/ITF/ITF.js",
+        "ITF14.js": "bin/barcodes/ITF/ITF14.js",
+        "JsBarcode.js": "bin/JsBarcode.js",
+        "linearizeEncodings.js": "bin/help/linearizeEncodings.js",
+        "merge.js": "bin/help/merge.js",
+        "MSI": "bin/barcodes/MSI",
+        "MSI.js": "bin/barcodes/MSI/MSI.js",
+        "MSI10.js": "bin/barcodes/MSI/MSI10.js",
+        "MSI1010.js": "bin/barcodes/MSI/MSI1010.js",
+        "MSI11.js": "bin/barcodes/MSI/MSI11.js",
+        "MSI1110.js": "bin/barcodes/MSI/MSI1110.js",
+        "object.js": "bin/renderers/object.js",
+        "options": "bin/options",
+        "optionsFromStrings.js": "bin/help/optionsFromStrings.js",
+        "pharmacode": "bin/barcodes/pharmacode",
+        "renderers": "bin/renderers",
+        "shared.js": "bin/renderers/shared.js",
+        "svg.js": "bin/renderers/svg.js",
+        "UPC.js": "bin/barcodes/EAN_UPC/UPC.js",
+        "UPCE.js": "bin/barcodes/EAN_UPC/UPCE.js"
+      }
+    },
+    "node_modules/jsesc": {
+      "version": "2.5.2",
+      "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
+      "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==",
+      "dev": true,
+      "bin": {
+        "jsesc": "bin/jsesc"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/json-schema-traverse": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+      "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+      "dev": true
+    },
+    "node_modules/json-stable-stringify-without-jsonify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+      "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+      "dev": true
+    },
+    "node_modules/json5": {
+      "version": "2.2.3",
+      "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+      "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+      "dev": true,
+      "bin": {
+        "json5": "lib/cli.js"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/jsonfile": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
+      "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
+      "dev": true,
+      "dependencies": {
+        "universalify": "^2.0.0"
+      },
+      "optionalDependencies": {
+        "graceful-fs": "^4.1.6"
+      }
+    },
+    "node_modules/kleur": {
+      "version": "4.1.5",
+      "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
+      "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/less": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/less/-/less-4.2.0.tgz",
+      "integrity": "sha512-P3b3HJDBtSzsXUl0im2L7gTO5Ubg8mEN6G8qoTS77iXxXX4Hvu4Qj540PZDvQ8V6DmX6iXo98k7Md0Cm1PrLaA==",
+      "dev": true,
+      "dependencies": {
+        "copy-anything": "^2.0.1",
+        "parse-node-version": "^1.0.1",
+        "tslib": "^2.3.0"
+      },
+      "bin": {
+        "lessc": "bin/lessc"
+      },
+      "engines": {
+        "node": ">=6"
+      },
+      "optionalDependencies": {
+        "errno": "^0.1.1",
+        "graceful-fs": "^4.1.2",
+        "image-size": "~0.5.0",
+        "make-dir": "^2.1.0",
+        "mime": "^1.4.1",
+        "needle": "^3.1.0",
+        "source-map": "~0.6.0"
+      }
+    },
+    "node_modules/levn": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+      "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+      "dev": true,
+      "dependencies": {
+        "prelude-ls": "^1.2.1",
+        "type-check": "~0.4.0"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/local-pkg": {
+      "version": "0.4.3",
+      "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.4.3.tgz",
+      "integrity": "sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==",
+      "dev": true,
+      "engines": {
+        "node": ">=14"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/locate-path": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+      "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+      "dev": true,
+      "dependencies": {
+        "p-locate": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/lodash": {
+      "version": "4.17.21",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
+    },
+    "node_modules/lodash-es": {
+      "version": "4.17.21",
+      "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
+      "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="
+    },
+    "node_modules/lodash.debounce": {
+      "version": "4.0.8",
+      "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
+      "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
+      "dev": true
+    },
+    "node_modules/lodash.merge": {
+      "version": "4.6.2",
+      "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+      "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+      "dev": true
+    },
+    "node_modules/longest-streak": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
+      "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/loose-envify": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+      "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+      "dependencies": {
+        "js-tokens": "^3.0.0 || ^4.0.0"
+      },
+      "bin": {
+        "loose-envify": "cli.js"
+      }
+    },
+    "node_modules/lru-cache": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+      "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+      "dev": true,
+      "dependencies": {
+        "yallist": "^3.0.2"
+      }
+    },
+    "node_modules/magic-string": {
+      "version": "0.30.2",
+      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.2.tgz",
+      "integrity": "sha512-lNZdu7pewtq/ZvWUp9Wpf/x7WzMTsR26TWV03BRZrXFsv+BI6dy8RAiKgm1uM/kyR0rCfUcqvOlXKG66KhIGug==",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.4.15"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/make-dir": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz",
+      "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==",
+      "dev": true,
+      "optional": true,
+      "dependencies": {
+        "pify": "^4.0.1",
+        "semver": "^5.6.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/make-dir/node_modules/semver": {
+      "version": "5.7.2",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
+      "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
+      "dev": true,
+      "optional": true,
+      "bin": {
+        "semver": "bin/semver"
+      }
+    },
+    "node_modules/markdown-table": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.3.tgz",
+      "integrity": "sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw==",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/mdast-util-definitions": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-5.1.2.tgz",
+      "integrity": "sha512-8SVPMuHqlPME/z3gqVwWY4zVXn8lqKv/pAhC57FuJ40ImXyBpmO5ukh98zB2v7Blql2FiHjHv9LVztSIqjY+MA==",
+      "dependencies": {
+        "@types/mdast": "^3.0.0",
+        "@types/unist": "^2.0.0",
+        "unist-util-visit": "^4.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/mdast-util-find-and-replace": {
+      "version": "2.2.2",
+      "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-2.2.2.tgz",
+      "integrity": "sha512-MTtdFRz/eMDHXzeK6W3dO7mXUlF82Gom4y0oOgvHhh/HXZAGvIQDUvQ0SuUx+j2tv44b8xTHOm8K/9OoRFnXKw==",
+      "dependencies": {
+        "@types/mdast": "^3.0.0",
+        "escape-string-regexp": "^5.0.0",
+        "unist-util-is": "^5.0.0",
+        "unist-util-visit-parents": "^5.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
+      "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/mdast-util-from-markdown": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-1.3.1.tgz",
+      "integrity": "sha512-4xTO/M8c82qBcnQc1tgpNtubGUW/Y1tBQ1B0i5CtSoelOLKFYlElIr3bvgREYYO5iRqbMY1YuqZng0GVOI8Qww==",
+      "dependencies": {
+        "@types/mdast": "^3.0.0",
+        "@types/unist": "^2.0.0",
+        "decode-named-character-reference": "^1.0.0",
+        "mdast-util-to-string": "^3.1.0",
+        "micromark": "^3.0.0",
+        "micromark-util-decode-numeric-character-reference": "^1.0.0",
+        "micromark-util-decode-string": "^1.0.0",
+        "micromark-util-normalize-identifier": "^1.0.0",
+        "micromark-util-symbol": "^1.0.0",
+        "micromark-util-types": "^1.0.0",
+        "unist-util-stringify-position": "^3.0.0",
+        "uvu": "^0.5.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/mdast-util-gfm": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-2.0.2.tgz",
+      "integrity": "sha512-qvZ608nBppZ4icQlhQQIAdc6S3Ffj9RGmzwUKUWuEICFnd1LVkN3EktF7ZHAgfcEdvZB5owU9tQgt99e2TlLjg==",
+      "dependencies": {
+        "mdast-util-from-markdown": "^1.0.0",
+        "mdast-util-gfm-autolink-literal": "^1.0.0",
+        "mdast-util-gfm-footnote": "^1.0.0",
+        "mdast-util-gfm-strikethrough": "^1.0.0",
+        "mdast-util-gfm-table": "^1.0.0",
+        "mdast-util-gfm-task-list-item": "^1.0.0",
+        "mdast-util-to-markdown": "^1.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/mdast-util-gfm-autolink-literal": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-1.0.3.tgz",
+      "integrity": "sha512-My8KJ57FYEy2W2LyNom4n3E7hKTuQk/0SES0u16tjA9Z3oFkF4RrC/hPAPgjlSpezsOvI8ObcXcElo92wn5IGA==",
+      "dependencies": {
+        "@types/mdast": "^3.0.0",
+        "ccount": "^2.0.0",
+        "mdast-util-find-and-replace": "^2.0.0",
+        "micromark-util-character": "^1.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/mdast-util-gfm-footnote": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-1.0.2.tgz",
+      "integrity": "sha512-56D19KOGbE00uKVj3sgIykpwKL179QsVFwx/DCW0u/0+URsryacI4MAdNJl0dh+u2PSsD9FtxPFbHCzJ78qJFQ==",
+      "dependencies": {
+        "@types/mdast": "^3.0.0",
+        "mdast-util-to-markdown": "^1.3.0",
+        "micromark-util-normalize-identifier": "^1.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/mdast-util-gfm-strikethrough": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-1.0.3.tgz",
+      "integrity": "sha512-DAPhYzTYrRcXdMjUtUjKvW9z/FNAMTdU0ORyMcbmkwYNbKocDpdk+PX1L1dQgOID/+vVs1uBQ7ElrBQfZ0cuiQ==",
+      "dependencies": {
+        "@types/mdast": "^3.0.0",
+        "mdast-util-to-markdown": "^1.3.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/mdast-util-gfm-table": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-1.0.7.tgz",
+      "integrity": "sha512-jjcpmNnQvrmN5Vx7y7lEc2iIOEytYv7rTvu+MeyAsSHTASGCCRA79Igg2uKssgOs1i1po8s3plW0sTu1wkkLGg==",
+      "dependencies": {
+        "@types/mdast": "^3.0.0",
+        "markdown-table": "^3.0.0",
+        "mdast-util-from-markdown": "^1.0.0",
+        "mdast-util-to-markdown": "^1.3.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/mdast-util-gfm-task-list-item": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-1.0.2.tgz",
+      "integrity": "sha512-PFTA1gzfp1B1UaiJVyhJZA1rm0+Tzn690frc/L8vNX1Jop4STZgOE6bxUhnzdVSB+vm2GU1tIsuQcA9bxTQpMQ==",
+      "dependencies": {
+        "@types/mdast": "^3.0.0",
+        "mdast-util-to-markdown": "^1.3.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/mdast-util-phrasing": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-3.0.1.tgz",
+      "integrity": "sha512-WmI1gTXUBJo4/ZmSk79Wcb2HcjPJBzM1nlI/OUWA8yk2X9ik3ffNbBGsU+09BFmXaL1IBb9fiuvq6/KMiNycSg==",
+      "dependencies": {
+        "@types/mdast": "^3.0.0",
+        "unist-util-is": "^5.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/mdast-util-to-hast": {
+      "version": "12.3.0",
+      "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-12.3.0.tgz",
+      "integrity": "sha512-pits93r8PhnIoU4Vy9bjW39M2jJ6/tdHyja9rrot9uujkN7UTU9SDnE6WNJz/IGyQk3XHX6yNNtrBH6cQzm8Hw==",
+      "dependencies": {
+        "@types/hast": "^2.0.0",
+        "@types/mdast": "^3.0.0",
+        "mdast-util-definitions": "^5.0.0",
+        "micromark-util-sanitize-uri": "^1.1.0",
+        "trim-lines": "^3.0.0",
+        "unist-util-generated": "^2.0.0",
+        "unist-util-position": "^4.0.0",
+        "unist-util-visit": "^4.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/mdast-util-to-markdown": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-1.5.0.tgz",
+      "integrity": "sha512-bbv7TPv/WC49thZPg3jXuqzuvI45IL2EVAr/KxF0BSdHsU0ceFHOmwQn6evxAh1GaoK/6GQ1wp4R4oW2+LFL/A==",
+      "dependencies": {
+        "@types/mdast": "^3.0.0",
+        "@types/unist": "^2.0.0",
+        "longest-streak": "^3.0.0",
+        "mdast-util-phrasing": "^3.0.0",
+        "mdast-util-to-string": "^3.0.0",
+        "micromark-util-decode-string": "^1.0.0",
+        "unist-util-visit": "^4.0.0",
+        "zwitch": "^2.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/mdast-util-to-string": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-3.2.0.tgz",
+      "integrity": "sha512-V4Zn/ncyN1QNSqSBxTrMOLpjr+IKdHl2v3KVLoWmDPscP4r9GcCi71gjgvUV1SFSKh92AjAG4peFuBl2/YgCJg==",
+      "dependencies": {
+        "@types/mdast": "^3.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/merge2": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+      "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+      "dev": true,
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/micromark": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/micromark/-/micromark-3.2.0.tgz",
+      "integrity": "sha512-uD66tJj54JLYq0De10AhWycZWGQNUvDI55xPgk2sQM5kn1JYlhbCMTtEeT27+vAhW2FBQxLlOmS3pmA7/2z4aA==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "dependencies": {
+        "@types/debug": "^4.0.0",
+        "debug": "^4.0.0",
+        "decode-named-character-reference": "^1.0.0",
+        "micromark-core-commonmark": "^1.0.1",
+        "micromark-factory-space": "^1.0.0",
+        "micromark-util-character": "^1.0.0",
+        "micromark-util-chunked": "^1.0.0",
+        "micromark-util-combine-extensions": "^1.0.0",
+        "micromark-util-decode-numeric-character-reference": "^1.0.0",
+        "micromark-util-encode": "^1.0.0",
+        "micromark-util-normalize-identifier": "^1.0.0",
+        "micromark-util-resolve-all": "^1.0.0",
+        "micromark-util-sanitize-uri": "^1.0.0",
+        "micromark-util-subtokenize": "^1.0.0",
+        "micromark-util-symbol": "^1.0.0",
+        "micromark-util-types": "^1.0.1",
+        "uvu": "^0.5.0"
+      }
+    },
+    "node_modules/micromark-core-commonmark": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-1.1.0.tgz",
+      "integrity": "sha512-BgHO1aRbolh2hcrzL2d1La37V0Aoz73ymF8rAcKnohLy93titmv62E0gP8Hrx9PKcKrqCZ1BbLGbP3bEhoXYlw==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "dependencies": {
+        "decode-named-character-reference": "^1.0.0",
+        "micromark-factory-destination": "^1.0.0",
+        "micromark-factory-label": "^1.0.0",
+        "micromark-factory-space": "^1.0.0",
+        "micromark-factory-title": "^1.0.0",
+        "micromark-factory-whitespace": "^1.0.0",
+        "micromark-util-character": "^1.0.0",
+        "micromark-util-chunked": "^1.0.0",
+        "micromark-util-classify-character": "^1.0.0",
+        "micromark-util-html-tag-name": "^1.0.0",
+        "micromark-util-normalize-identifier": "^1.0.0",
+        "micromark-util-resolve-all": "^1.0.0",
+        "micromark-util-subtokenize": "^1.0.0",
+        "micromark-util-symbol": "^1.0.0",
+        "micromark-util-types": "^1.0.1",
+        "uvu": "^0.5.0"
+      }
+    },
+    "node_modules/micromark-extension-gfm": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-2.0.3.tgz",
+      "integrity": "sha512-vb9OoHqrhCmbRidQv/2+Bc6pkP0FrtlhurxZofvOEy5o8RtuuvTq+RQ1Vw5ZDNrVraQZu3HixESqbG+0iKk/MQ==",
+      "dependencies": {
+        "micromark-extension-gfm-autolink-literal": "^1.0.0",
+        "micromark-extension-gfm-footnote": "^1.0.0",
+        "micromark-extension-gfm-strikethrough": "^1.0.0",
+        "micromark-extension-gfm-table": "^1.0.0",
+        "micromark-extension-gfm-tagfilter": "^1.0.0",
+        "micromark-extension-gfm-task-list-item": "^1.0.0",
+        "micromark-util-combine-extensions": "^1.0.0",
+        "micromark-util-types": "^1.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/micromark-extension-gfm-autolink-literal": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-1.0.5.tgz",
+      "integrity": "sha512-z3wJSLrDf8kRDOh2qBtoTRD53vJ+CWIyo7uyZuxf/JAbNJjiHsOpG1y5wxk8drtv3ETAHutCu6N3thkOOgueWg==",
+      "dependencies": {
+        "micromark-util-character": "^1.0.0",
+        "micromark-util-sanitize-uri": "^1.0.0",
+        "micromark-util-symbol": "^1.0.0",
+        "micromark-util-types": "^1.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/micromark-extension-gfm-footnote": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-1.1.2.tgz",
+      "integrity": "sha512-Yxn7z7SxgyGWRNa4wzf8AhYYWNrwl5q1Z8ii+CSTTIqVkmGZF1CElX2JI8g5yGoM3GAman9/PVCUFUSJ0kB/8Q==",
+      "dependencies": {
+        "micromark-core-commonmark": "^1.0.0",
+        "micromark-factory-space": "^1.0.0",
+        "micromark-util-character": "^1.0.0",
+        "micromark-util-normalize-identifier": "^1.0.0",
+        "micromark-util-sanitize-uri": "^1.0.0",
+        "micromark-util-symbol": "^1.0.0",
+        "micromark-util-types": "^1.0.0",
+        "uvu": "^0.5.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/micromark-extension-gfm-strikethrough": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-1.0.7.tgz",
+      "integrity": "sha512-sX0FawVE1o3abGk3vRjOH50L5TTLr3b5XMqnP9YDRb34M0v5OoZhG+OHFz1OffZ9dlwgpTBKaT4XW/AsUVnSDw==",
+      "dependencies": {
+        "micromark-util-chunked": "^1.0.0",
+        "micromark-util-classify-character": "^1.0.0",
+        "micromark-util-resolve-all": "^1.0.0",
+        "micromark-util-symbol": "^1.0.0",
+        "micromark-util-types": "^1.0.0",
+        "uvu": "^0.5.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/micromark-extension-gfm-table": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-1.0.7.tgz",
+      "integrity": "sha512-3ZORTHtcSnMQEKtAOsBQ9/oHp9096pI/UvdPtN7ehKvrmZZ2+bbWhi0ln+I9drmwXMt5boocn6OlwQzNXeVeqw==",
+      "dependencies": {
+        "micromark-factory-space": "^1.0.0",
+        "micromark-util-character": "^1.0.0",
+        "micromark-util-symbol": "^1.0.0",
+        "micromark-util-types": "^1.0.0",
+        "uvu": "^0.5.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/micromark-extension-gfm-tagfilter": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-1.0.2.tgz",
+      "integrity": "sha512-5XWB9GbAUSHTn8VPU8/1DBXMuKYT5uOgEjJb8gN3mW0PNW5OPHpSdojoqf+iq1xo7vWzw/P8bAHY0n6ijpXF7g==",
+      "dependencies": {
+        "micromark-util-types": "^1.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/micromark-extension-gfm-task-list-item": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-1.0.5.tgz",
+      "integrity": "sha512-RMFXl2uQ0pNQy6Lun2YBYT9g9INXtWJULgbt01D/x8/6yJ2qpKyzdZD3pi6UIkzF++Da49xAelVKUeUMqd5eIQ==",
+      "dependencies": {
+        "micromark-factory-space": "^1.0.0",
+        "micromark-util-character": "^1.0.0",
+        "micromark-util-symbol": "^1.0.0",
+        "micromark-util-types": "^1.0.0",
+        "uvu": "^0.5.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/micromark-factory-destination": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-1.1.0.tgz",
+      "integrity": "sha512-XaNDROBgx9SgSChd69pjiGKbV+nfHGDPVYFs5dOoDd7ZnMAE+Cuu91BCpsY8RT2NP9vo/B8pds2VQNCLiu0zhg==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "dependencies": {
+        "micromark-util-character": "^1.0.0",
+        "micromark-util-symbol": "^1.0.0",
+        "micromark-util-types": "^1.0.0"
+      }
+    },
+    "node_modules/micromark-factory-label": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-1.1.0.tgz",
+      "integrity": "sha512-OLtyez4vZo/1NjxGhcpDSbHQ+m0IIGnT8BoPamh+7jVlzLJBH98zzuCoUeMxvM6WsNeh8wx8cKvqLiPHEACn0w==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "dependencies": {
+        "micromark-util-character": "^1.0.0",
+        "micromark-util-symbol": "^1.0.0",
+        "micromark-util-types": "^1.0.0",
+        "uvu": "^0.5.0"
+      }
+    },
+    "node_modules/micromark-factory-space": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-1.1.0.tgz",
+      "integrity": "sha512-cRzEj7c0OL4Mw2v6nwzttyOZe8XY/Z8G0rzmWQZTBi/jjwyw/U4uqKtUORXQrR5bAZZnbTI/feRV/R7hc4jQYQ==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "dependencies": {
+        "micromark-util-character": "^1.0.0",
+        "micromark-util-types": "^1.0.0"
+      }
+    },
+    "node_modules/micromark-factory-title": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-1.1.0.tgz",
+      "integrity": "sha512-J7n9R3vMmgjDOCY8NPw55jiyaQnH5kBdV2/UXCtZIpnHH3P6nHUKaH7XXEYuWwx/xUJcawa8plLBEjMPU24HzQ==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "dependencies": {
+        "micromark-factory-space": "^1.0.0",
+        "micromark-util-character": "^1.0.0",
+        "micromark-util-symbol": "^1.0.0",
+        "micromark-util-types": "^1.0.0"
+      }
+    },
+    "node_modules/micromark-factory-whitespace": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-1.1.0.tgz",
+      "integrity": "sha512-v2WlmiymVSp5oMg+1Q0N1Lxmt6pMhIHD457whWM7/GUlEks1hI9xj5w3zbc4uuMKXGisksZk8DzP2UyGbGqNsQ==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "dependencies": {
+        "micromark-factory-space": "^1.0.0",
+        "micromark-util-character": "^1.0.0",
+        "micromark-util-symbol": "^1.0.0",
+        "micromark-util-types": "^1.0.0"
+      }
+    },
+    "node_modules/micromark-util-character": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-1.2.0.tgz",
+      "integrity": "sha512-lXraTwcX3yH/vMDaFWCQJP1uIszLVebzUa3ZHdrgxr7KEU/9mL4mVgCpGbyhvNLNlauROiNUq7WN5u7ndbY6xg==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "dependencies": {
+        "micromark-util-symbol": "^1.0.0",
+        "micromark-util-types": "^1.0.0"
+      }
+    },
+    "node_modules/micromark-util-chunked": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-1.1.0.tgz",
+      "integrity": "sha512-Ye01HXpkZPNcV6FiyoW2fGZDUw4Yc7vT0E9Sad83+bEDiCJ1uXu0S3mr8WLpsz3HaG3x2q0HM6CTuPdcZcluFQ==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "dependencies": {
+        "micromark-util-symbol": "^1.0.0"
+      }
+    },
+    "node_modules/micromark-util-classify-character": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-1.1.0.tgz",
+      "integrity": "sha512-SL0wLxtKSnklKSUplok1WQFoGhUdWYKggKUiqhX+Swala+BtptGCu5iPRc+xvzJ4PXE/hwM3FNXsfEVgoZsWbw==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "dependencies": {
+        "micromark-util-character": "^1.0.0",
+        "micromark-util-symbol": "^1.0.0",
+        "micromark-util-types": "^1.0.0"
+      }
+    },
+    "node_modules/micromark-util-combine-extensions": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-1.1.0.tgz",
+      "integrity": "sha512-Q20sp4mfNf9yEqDL50WwuWZHUrCO4fEyeDCnMGmG5Pr0Cz15Uo7KBs6jq+dq0EgX4DPwwrh9m0X+zPV1ypFvUA==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "dependencies": {
+        "micromark-util-chunked": "^1.0.0",
+        "micromark-util-types": "^1.0.0"
+      }
+    },
+    "node_modules/micromark-util-decode-numeric-character-reference": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-1.1.0.tgz",
+      "integrity": "sha512-m9V0ExGv0jB1OT21mrWcuf4QhP46pH1KkfWy9ZEezqHKAxkj4mPCy3nIH1rkbdMlChLHX531eOrymlwyZIf2iw==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "dependencies": {
+        "micromark-util-symbol": "^1.0.0"
+      }
+    },
+    "node_modules/micromark-util-decode-string": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-1.1.0.tgz",
+      "integrity": "sha512-YphLGCK8gM1tG1bd54azwyrQRjCFcmgj2S2GoJDNnh4vYtnL38JS8M4gpxzOPNyHdNEpheyWXCTnnTDY3N+NVQ==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "dependencies": {
+        "decode-named-character-reference": "^1.0.0",
+        "micromark-util-character": "^1.0.0",
+        "micromark-util-decode-numeric-character-reference": "^1.0.0",
+        "micromark-util-symbol": "^1.0.0"
+      }
+    },
+    "node_modules/micromark-util-encode": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-1.1.0.tgz",
+      "integrity": "sha512-EuEzTWSTAj9PA5GOAs992GzNh2dGQO52UvAbtSOMvXTxv3Criqb6IOzJUBCmEqrrXSblJIJBbFFv6zPxpreiJw==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ]
+    },
+    "node_modules/micromark-util-html-tag-name": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-1.2.0.tgz",
+      "integrity": "sha512-VTQzcuQgFUD7yYztuQFKXT49KghjtETQ+Wv/zUjGSGBioZnkA4P1XXZPT1FHeJA6RwRXSF47yvJ1tsJdoxwO+Q==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ]
+    },
+    "node_modules/micromark-util-normalize-identifier": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-1.1.0.tgz",
+      "integrity": "sha512-N+w5vhqrBihhjdpM8+5Xsxy71QWqGn7HYNUvch71iV2PM7+E3uWGox1Qp90loa1ephtCxG2ftRV/Conitc6P2Q==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "dependencies": {
+        "micromark-util-symbol": "^1.0.0"
+      }
+    },
+    "node_modules/micromark-util-resolve-all": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-1.1.0.tgz",
+      "integrity": "sha512-b/G6BTMSg+bX+xVCshPTPyAu2tmA0E4X98NSR7eIbeC6ycCqCeE7wjfDIgzEbkzdEVJXRtOG4FbEm/uGbCRouA==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "dependencies": {
+        "micromark-util-types": "^1.0.0"
+      }
+    },
+    "node_modules/micromark-util-sanitize-uri": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-1.2.0.tgz",
+      "integrity": "sha512-QO4GXv0XZfWey4pYFndLUKEAktKkG5kZTdUNaTAkzbuJxn2tNBOr+QtxR2XpWaMhbImT2dPzyLrPXLlPhph34A==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "dependencies": {
+        "micromark-util-character": "^1.0.0",
+        "micromark-util-encode": "^1.0.0",
+        "micromark-util-symbol": "^1.0.0"
+      }
+    },
+    "node_modules/micromark-util-subtokenize": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-1.1.0.tgz",
+      "integrity": "sha512-kUQHyzRoxvZO2PuLzMt2P/dwVsTiivCK8icYTeR+3WgbuPqfHgPPy7nFKbeqRivBvn/3N3GBiNC+JRTMSxEC7A==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "dependencies": {
+        "micromark-util-chunked": "^1.0.0",
+        "micromark-util-symbol": "^1.0.0",
+        "micromark-util-types": "^1.0.0",
+        "uvu": "^0.5.0"
+      }
+    },
+    "node_modules/micromark-util-symbol": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-1.1.0.tgz",
+      "integrity": "sha512-uEjpEYY6KMs1g7QfJ2eX1SQEV+ZT4rUD3UcF6l57acZvLNK7PBZL+ty82Z1qhK1/yXIY4bdx04FKMgR0g4IAag==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ]
+    },
+    "node_modules/micromark-util-types": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-1.1.0.tgz",
+      "integrity": "sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ]
+    },
+    "node_modules/micromatch": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz",
+      "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==",
+      "dev": true,
+      "dependencies": {
+        "braces": "^3.0.2",
+        "picomatch": "^2.3.1"
+      },
+      "engines": {
+        "node": ">=8.6"
+      }
+    },
+    "node_modules/mime": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+      "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+      "dev": true,
+      "optional": true,
+      "bin": {
+        "mime": "cli.js"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/minimatch": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+      "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+      "dev": true,
+      "dependencies": {
+        "brace-expansion": "^1.1.7"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/minipass": {
+      "version": "7.0.3",
+      "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.3.tgz",
+      "integrity": "sha512-LhbbwCfz3vsb12j/WkWQPZfKTsgqIe1Nf/ti1pKjYESGLHIVjWU96G9/ljLH4F9mWNVhlQOm0VySdAWzf05dpg==",
+      "dev": true,
+      "engines": {
+        "node": ">=16 || 14 >=14.17"
+      }
+    },
+    "node_modules/mri": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
+      "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+      "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+    },
+    "node_modules/muggle-string": {
+      "version": "0.3.1",
+      "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.3.1.tgz",
+      "integrity": "sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==",
+      "dev": true
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.6",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
+      "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/nanopop": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/nanopop/-/nanopop-2.3.0.tgz",
+      "integrity": "sha512-fzN+T2K7/Ah25XU02MJkPZ5q4Tj5FpjmIYq4rvoHX4yb16HzFdCO6JxFFn5Y/oBhQ8no8fUZavnyIv9/+xkBBw=="
+    },
+    "node_modules/natural-compare": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+      "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+      "dev": true
+    },
+    "node_modules/natural-compare-lite": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz",
+      "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==",
+      "dev": true
+    },
+    "node_modules/needle": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/needle/-/needle-3.2.0.tgz",
+      "integrity": "sha512-oUvzXnyLiVyVGoianLijF9O/RecZUf7TkBfimjGrLM4eQhXyeJwM6GeAWccwfQ9aa4gMCZKqhAOuLaMIcQxajQ==",
+      "dev": true,
+      "optional": true,
+      "dependencies": {
+        "debug": "^3.2.6",
+        "iconv-lite": "^0.6.3",
+        "sax": "^1.2.4"
+      },
+      "bin": {
+        "needle": "bin/needle"
+      },
+      "engines": {
+        "node": ">= 4.4.x"
+      }
+    },
+    "node_modules/needle/node_modules/debug": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+      "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+      "dev": true,
+      "optional": true,
+      "dependencies": {
+        "ms": "^2.1.1"
+      }
+    },
+    "node_modules/next-tick": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz",
+      "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ=="
+    },
+    "node_modules/node-releases": {
+      "version": "2.0.13",
+      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz",
+      "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==",
+      "dev": true
+    },
+    "node_modules/normalize-path": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+      "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/nprogress": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/nprogress/-/nprogress-0.2.0.tgz",
+      "integrity": "sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA=="
+    },
+    "node_modules/nth-check": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
+      "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
+      "dev": true,
+      "dependencies": {
+        "boolbase": "^1.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/nth-check?sponsor=1"
+      }
+    },
+    "node_modules/once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+      "dev": true,
+      "dependencies": {
+        "wrappy": "1"
+      }
+    },
+    "node_modules/optionator": {
+      "version": "0.9.3",
+      "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz",
+      "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==",
+      "dev": true,
+      "dependencies": {
+        "@aashutoshrathi/word-wrap": "^1.2.3",
+        "deep-is": "^0.1.3",
+        "fast-levenshtein": "^2.0.6",
+        "levn": "^0.4.1",
+        "prelude-ls": "^1.2.1",
+        "type-check": "^0.4.0"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/p-limit": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+      "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+      "dev": true,
+      "dependencies": {
+        "yocto-queue": "^0.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/p-locate": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+      "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+      "dev": true,
+      "dependencies": {
+        "p-limit": "^3.0.2"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/parent-module": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+      "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+      "dev": true,
+      "dependencies": {
+        "callsites": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/parse-node-version": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz",
+      "integrity": "sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/parse5": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz",
+      "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="
+    },
+    "node_modules/path-exists": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+      "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/path-is-absolute": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+      "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/path-key": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/path-parse": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+      "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+      "dev": true
+    },
+    "node_modules/path-scurry": {
+      "version": "1.10.1",
+      "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz",
+      "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==",
+      "dev": true,
+      "dependencies": {
+        "lru-cache": "^9.1.1 || ^10.0.0",
+        "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
+      },
+      "engines": {
+        "node": ">=16 || 14 >=14.17"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/path-scurry/node_modules/lru-cache": {
+      "version": "10.0.1",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.0.1.tgz",
+      "integrity": "sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1rV1O0sJFszx75g==",
+      "dev": true,
+      "engines": {
+        "node": "14 || >=16.14"
+      }
+    },
+    "node_modules/path-type": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
+      "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/picocolors": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
+      "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ=="
+    },
+    "node_modules/picomatch": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+      "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+      "dev": true,
+      "engines": {
+        "node": ">=8.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/pify": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
+      "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==",
+      "dev": true,
+      "optional": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/pinia": {
+      "version": "2.1.6",
+      "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.1.6.tgz",
+      "integrity": "sha512-bIU6QuE5qZviMmct5XwCesXelb5VavdOWKWaB17ggk++NUwQWWbP5YnsONTk3b752QkW9sACiR81rorpeOMSvQ==",
+      "dependencies": {
+        "@vue/devtools-api": "^6.5.0",
+        "vue-demi": ">=0.14.5"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/posva"
+      },
+      "peerDependencies": {
+        "@vue/composition-api": "^1.4.0",
+        "typescript": ">=4.4.4",
+        "vue": "^2.6.14 || ^3.3.0"
+      },
+      "peerDependenciesMeta": {
+        "@vue/composition-api": {
+          "optional": true
+        },
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/pinia/node_modules/vue-demi": {
+      "version": "0.14.5",
+      "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.5.tgz",
+      "integrity": "sha512-o9NUVpl/YlsGJ7t+xuqJKx8EBGf1quRhCiT6D/J0pfwmk9zUwYkC7yrF4SZCe6fETvSM3UNL2edcbYrSyc4QHA==",
+      "hasInstallScript": true,
+      "bin": {
+        "vue-demi-fix": "bin/vue-demi-fix.js",
+        "vue-demi-switch": "bin/vue-demi-switch.js"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      },
+      "peerDependencies": {
+        "@vue/composition-api": "^1.0.0-rc.1",
+        "vue": "^3.0.0-0 || ^2.6.0"
+      },
+      "peerDependenciesMeta": {
+        "@vue/composition-api": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.4.27",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.27.tgz",
+      "integrity": "sha512-gY/ACJtJPSmUFPDCHtX78+01fHa64FaU4zaaWfuh1MhGJISufJAH4cun6k/8fwsHYeK4UQmENQK+tRLCFJE8JQ==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "dependencies": {
+        "nanoid": "^3.3.6",
+        "picocolors": "^1.0.0",
+        "source-map-js": "^1.0.2"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/postcss-selector-parser": {
+      "version": "6.0.13",
+      "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz",
+      "integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==",
+      "dev": true,
+      "dependencies": {
+        "cssesc": "^3.0.0",
+        "util-deprecate": "^1.0.2"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/prelude-ls": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+      "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/prettier": {
+      "version": "2.8.8",
+      "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz",
+      "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==",
+      "dev": true,
+      "bin": {
+        "prettier": "bin-prettier.js"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      },
+      "funding": {
+        "url": "https://github.com/prettier/prettier?sponsor=1"
+      }
+    },
+    "node_modules/prettier-linter-helpers": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz",
+      "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==",
+      "dev": true,
+      "dependencies": {
+        "fast-diff": "^1.1.2"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/property-information": {
+      "version": "6.2.0",
+      "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.2.0.tgz",
+      "integrity": "sha512-kma4U7AFCTwpqq5twzC1YVIDXSqg6qQK6JN0smOw8fgRy1OkMi0CYSzFmsy6dnqSenamAtj0CyXMUJ1Mf6oROg==",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/proxy-from-env": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+      "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
+    },
+    "node_modules/prr": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",
+      "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==",
+      "dev": true,
+      "optional": true
+    },
+    "node_modules/punycode": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz",
+      "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/queue-microtask": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+      "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ]
+    },
+    "node_modules/readdirp": {
+      "version": "3.6.0",
+      "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+      "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+      "dev": true,
+      "dependencies": {
+        "picomatch": "^2.2.1"
+      },
+      "engines": {
+        "node": ">=8.10.0"
+      }
+    },
+    "node_modules/regenerate": {
+      "version": "1.4.2",
+      "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
+      "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==",
+      "dev": true
+    },
+    "node_modules/regenerate-unicode-properties": {
+      "version": "10.1.0",
+      "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.0.tgz",
+      "integrity": "sha512-d1VudCLoIGitcU/hEg2QqvyGZQmdC0Lf8BqdOMXGFSvJP4bNV1+XqbPQeHHLD51Jh4QJJ225dlIFvY4Ly6MXmQ==",
+      "dev": true,
+      "dependencies": {
+        "regenerate": "^1.4.2"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/regenerator-runtime": {
+      "version": "0.13.11",
+      "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
+      "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
+      "dev": true
+    },
+    "node_modules/regenerator-transform": {
+      "version": "0.15.2",
+      "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz",
+      "integrity": "sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==",
+      "dev": true,
+      "dependencies": {
+        "@babel/runtime": "^7.8.4"
+      }
+    },
+    "node_modules/regexpu-core": {
+      "version": "5.3.2",
+      "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.3.2.tgz",
+      "integrity": "sha512-RAM5FlZz+Lhmo7db9L298p2vHP5ZywrVXmVXpmAD9GuL5MPH6t9ROw1iA/wfHkQ76Qe7AaPF0nGuim96/IrQMQ==",
+      "dev": true,
+      "dependencies": {
+        "@babel/regjsgen": "^0.8.0",
+        "regenerate": "^1.4.2",
+        "regenerate-unicode-properties": "^10.1.0",
+        "regjsparser": "^0.9.1",
+        "unicode-match-property-ecmascript": "^2.0.0",
+        "unicode-match-property-value-ecmascript": "^2.1.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/regjsparser": {
+      "version": "0.9.1",
+      "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.9.1.tgz",
+      "integrity": "sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==",
+      "dev": true,
+      "dependencies": {
+        "jsesc": "~0.5.0"
+      },
+      "bin": {
+        "regjsparser": "bin/parser"
+      }
+    },
+    "node_modules/regjsparser/node_modules/jsesc": {
+      "version": "0.5.0",
+      "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz",
+      "integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==",
+      "dev": true,
+      "bin": {
+        "jsesc": "bin/jsesc"
+      }
+    },
+    "node_modules/rehype-raw": {
+      "version": "6.1.1",
+      "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-6.1.1.tgz",
+      "integrity": "sha512-d6AKtisSRtDRX4aSPsJGTfnzrX2ZkHQLE5kiUuGOeEoLpbEulFF4hj0mLPbsa+7vmguDKOVVEQdHKDSwoaIDsQ==",
+      "dependencies": {
+        "@types/hast": "^2.0.0",
+        "hast-util-raw": "^7.2.0",
+        "unified": "^10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/rehype-sanitize": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-5.0.1.tgz",
+      "integrity": "sha512-da/jIOjq8eYt/1r9GN6GwxIR3gde7OZ+WV8pheu1tL8K0D9KxM2AyMh+UEfke+FfdM3PvGHeYJU0Td5OWa7L5A==",
+      "dependencies": {
+        "@types/hast": "^2.0.0",
+        "hast-util-sanitize": "^4.0.0",
+        "unified": "^10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/rehype-stringify": {
+      "version": "9.0.3",
+      "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-9.0.3.tgz",
+      "integrity": "sha512-kWiZ1bgyWlgOxpqD5HnxShKAdXtb2IUljn3hQAhySeak6IOQPPt6DeGnsIh4ixm7yKJWzm8TXFuC/lPfcWHJqw==",
+      "dependencies": {
+        "@types/hast": "^2.0.0",
+        "hast-util-to-html": "^8.0.0",
+        "unified": "^10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/remark-gfm": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-3.0.1.tgz",
+      "integrity": "sha512-lEFDoi2PICJyNrACFOfDD3JlLkuSbOa5Wd8EPt06HUdptv8Gn0bxYTdbU/XXQ3swAPkEaGxxPN9cbnMHvVu1Ig==",
+      "dependencies": {
+        "@types/mdast": "^3.0.0",
+        "mdast-util-gfm": "^2.0.0",
+        "micromark-extension-gfm": "^2.0.0",
+        "unified": "^10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/remark-parse": {
+      "version": "10.0.2",
+      "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-10.0.2.tgz",
+      "integrity": "sha512-3ydxgHa/ZQzG8LvC7jTXccARYDcRld3VfcgIIFs7bI6vbRSxJJmzgLEIIoYKyrfhaY+ujuWaf/PJiMZXoiCXgw==",
+      "dependencies": {
+        "@types/mdast": "^3.0.0",
+        "mdast-util-from-markdown": "^1.0.0",
+        "unified": "^10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/remark-rehype": {
+      "version": "10.1.0",
+      "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-10.1.0.tgz",
+      "integrity": "sha512-EFmR5zppdBp0WQeDVZ/b66CWJipB2q2VLNFMabzDSGR66Z2fQii83G5gTBbgGEnEEA0QRussvrFHxk1HWGJskw==",
+      "dependencies": {
+        "@types/hast": "^2.0.0",
+        "@types/mdast": "^3.0.0",
+        "mdast-util-to-hast": "^12.1.0",
+        "unified": "^10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/resize-detector": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/resize-detector/-/resize-detector-0.3.0.tgz",
+      "integrity": "sha512-R/tCuvuOHQ8o2boRP6vgx8hXCCy87H1eY9V5imBYeVNyNVpuL9ciReSccLj2gDcax9+2weXy3bc8Vv+NRXeEvQ=="
+    },
+    "node_modules/resize-observer-polyfill": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
+      "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
+    },
+    "node_modules/resolve": {
+      "version": "1.22.4",
+      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.4.tgz",
+      "integrity": "sha512-PXNdCiPqDqeUou+w1C2eTQbNfxKSuMxqTCuvlmmMsk1NWHL5fRrhY6Pl0qEYYc6+QqGClco1Qj8XnjPego4wfg==",
+      "dev": true,
+      "dependencies": {
+        "is-core-module": "^2.13.0",
+        "path-parse": "^1.0.7",
+        "supports-preserve-symlinks-flag": "^1.0.0"
+      },
+      "bin": {
+        "resolve": "bin/resolve"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/resolve-from": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+      "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/reusify": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
+      "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
+      "dev": true,
+      "engines": {
+        "iojs": ">=1.0.0",
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/rimraf": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.1.tgz",
+      "integrity": "sha512-OfFZdwtd3lZ+XZzYP/6gTACubwFcHdLRqS9UX3UwpU2dnGQYkPFISRwvM3w9IiB2w7bW5qGo/uAwE4SmXXSKvg==",
+      "dev": true,
+      "dependencies": {
+        "glob": "^10.2.5"
+      },
+      "bin": {
+        "rimraf": "dist/cjs/src/bin.js"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/rollup": {
+      "version": "3.28.0",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.28.0.tgz",
+      "integrity": "sha512-d7zhvo1OUY2SXSM6pfNjgD5+d0Nz87CUp4mt8l/GgVP3oBsPwzNvSzyu1me6BSG9JIgWNTVcafIXBIyM8yQ3yw==",
+      "dev": true,
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=14.18.0",
+        "npm": ">=8.0.0"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/run-parallel": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+      "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "dependencies": {
+        "queue-microtask": "^1.2.2"
+      }
+    },
+    "node_modules/sade": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
+      "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
+      "dependencies": {
+        "mri": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "dev": true,
+      "optional": true
+    },
+    "node_modules/sax": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
+      "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==",
+      "dev": true,
+      "optional": true
+    },
+    "node_modules/scroll-into-view-if-needed": {
+      "version": "2.2.31",
+      "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz",
+      "integrity": "sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==",
+      "dependencies": {
+        "compute-scroll-into-view": "^1.0.20"
+      }
+    },
+    "node_modules/select-files": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/select-files/-/select-files-1.0.1.tgz",
+      "integrity": "sha512-8h4DSpjfFa0hyMP3z3ye4SxyhdaE5RgaXeScRpH7xl4YblnZSHwexmLdLNdSKwTO8H9ccDKj7Votz0io+18+BQ=="
+    },
+    "node_modules/semver": {
+      "version": "7.5.4",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
+      "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
+      "dev": true,
+      "dependencies": {
+        "lru-cache": "^6.0.0"
+      },
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/semver/node_modules/lru-cache": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+      "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+      "dev": true,
+      "dependencies": {
+        "yallist": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/semver/node_modules/yallist": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+      "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+      "dev": true
+    },
+    "node_modules/shallow-equal": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-1.2.1.tgz",
+      "integrity": "sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA=="
+    },
+    "node_modules/shebang-command": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+      "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+      "dev": true,
+      "dependencies": {
+        "shebang-regex": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/shebang-regex": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/signal-exit": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+      "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+      "dev": true,
+      "engines": {
+        "node": ">=14"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/slash": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
+      "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/sortablejs": {
+      "version": "1.15.0",
+      "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.0.tgz",
+      "integrity": "sha512-bv9qgVMjUMf89wAvM6AxVvS/4MX3sPeN0+agqShejLU5z5GX4C75ow1O2e5k4L6XItUyAK3gH6AxSbXrOM5e8w=="
+    },
+    "node_modules/source-map": {
+      "version": "0.6.1",
+      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+      "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
+      "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/source-map-support": {
+      "version": "0.5.21",
+      "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
+      "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
+      "dev": true,
+      "dependencies": {
+        "buffer-from": "^1.0.0",
+        "source-map": "^0.6.0"
+      }
+    },
+    "node_modules/space-separated-tokens": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
+      "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/ssf": {
+      "version": "0.11.2",
+      "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
+      "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
+      "dependencies": {
+        "frac": "~1.1.2"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/string-width": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+      "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+      "dev": true,
+      "dependencies": {
+        "eastasianwidth": "^0.2.0",
+        "emoji-regex": "^9.2.2",
+        "strip-ansi": "^7.0.1"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/string-width-cjs": {
+      "name": "string-width",
+      "version": "4.2.3",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+      "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+      "dev": true,
+      "dependencies": {
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/string-width-cjs/node_modules/emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+      "dev": true
+    },
+    "node_modules/string-width/node_modules/ansi-regex": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
+      "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
+      "dev": true,
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+      }
+    },
+    "node_modules/string-width/node_modules/strip-ansi": {
+      "version": "7.1.0",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
+      "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+      "dev": true,
+      "dependencies": {
+        "ansi-regex": "^6.0.1"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+      }
+    },
+    "node_modules/stringify-entities": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.3.tgz",
+      "integrity": "sha512-BP9nNHMhhfcMbiuQKCqMjhDP5yBCAxsPu4pHFFzJ6Alo9dZgY4VLDPutXqIjpRiMoKdp7Av85Gr73Q5uH9k7+g==",
+      "dependencies": {
+        "character-entities-html4": "^2.0.0",
+        "character-entities-legacy": "^3.0.0"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/strip-ansi": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "dev": true,
+      "dependencies": {
+        "ansi-regex": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/strip-ansi-cjs": {
+      "name": "strip-ansi",
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "dev": true,
+      "dependencies": {
+        "ansi-regex": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/strip-json-comments": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+      "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/supports-color": {
+      "version": "5.5.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+      "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+      "dev": true,
+      "dependencies": {
+        "has-flag": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/supports-preserve-symlinks-flag": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+      "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/systemjs": {
+      "version": "6.14.1",
+      "resolved": "https://registry.npmjs.org/systemjs/-/systemjs-6.14.1.tgz",
+      "integrity": "sha512-8ftwWd+XnQtZ/aGbatrN4QFNGrKJzmbtixW+ODpci7pyoTajg4sonPP8aFLESAcuVxaC1FyDESt+SpfFCH9rZQ==",
+      "dev": true
+    },
+    "node_modules/terser": {
+      "version": "5.19.2",
+      "resolved": "https://registry.npmjs.org/terser/-/terser-5.19.2.tgz",
+      "integrity": "sha512-qC5+dmecKJA4cpYxRa5aVkKehYsQKc+AHeKl0Oe62aYjBL8ZA33tTljktDHJSaxxMnbI5ZYw+o/S2DxxLu8OfA==",
+      "dev": true,
+      "dependencies": {
+        "@jridgewell/source-map": "^0.3.3",
+        "acorn": "^8.8.2",
+        "commander": "^2.20.0",
+        "source-map-support": "~0.5.20"
+      },
+      "bin": {
+        "terser": "bin/terser"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/text-table": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
+      "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
+      "dev": true
+    },
+    "node_modules/tinymce": {
+      "version": "5.10.7",
+      "resolved": "https://registry.npmjs.org/tinymce/-/tinymce-5.10.7.tgz",
+      "integrity": "sha512-9UUjaO0R7FxcFo0oxnd1lMs7H+D0Eh+dDVo5hKbVe1a+VB0nit97vOqlinj+YwgoBDt6/DSCUoWqAYlLI8BLYA=="
+    },
+    "node_modules/tippy.js": {
+      "version": "6.3.7",
+      "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz",
+      "integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==",
+      "dependencies": {
+        "@popperjs/core": "^2.9.0"
+      }
+    },
+    "node_modules/to-fast-properties": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
+      "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/to-regex-range": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+      "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+      "dev": true,
+      "dependencies": {
+        "is-number": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=8.0"
+      }
+    },
+    "node_modules/trim-lines": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
+      "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/trough": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/trough/-/trough-2.1.0.tgz",
+      "integrity": "sha512-AqTiAOLcj85xS7vQ8QkAV41hPDIJ71XJB4RCUrzo/1GM2CQwhkJGaf9Hgr7BOugMRpgGUrqRg/DrBDl4H40+8g==",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/tslib": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
+      "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
+    },
+    "node_modules/tsutils": {
+      "version": "3.21.0",
+      "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz",
+      "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==",
+      "dev": true,
+      "dependencies": {
+        "tslib": "^1.8.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      },
+      "peerDependencies": {
+        "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta"
+      }
+    },
+    "node_modules/tsutils/node_modules/tslib": {
+      "version": "1.14.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
+      "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
+      "dev": true
+    },
+    "node_modules/type": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz",
+      "integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg=="
+    },
+    "node_modules/type-check": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+      "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+      "dev": true,
+      "dependencies": {
+        "prelude-ls": "^1.2.1"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/type-fest": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+      "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/typescript": {
+      "version": "5.1.6",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz",
+      "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==",
+      "devOptional": true,
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/unicode-canonical-property-names-ecmascript": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz",
+      "integrity": "sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/unicode-match-property-ecmascript": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz",
+      "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==",
+      "dev": true,
+      "dependencies": {
+        "unicode-canonical-property-names-ecmascript": "^2.0.0",
+        "unicode-property-aliases-ecmascript": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/unicode-match-property-value-ecmascript": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.1.0.tgz",
+      "integrity": "sha512-qxkjQt6qjg/mYscYMC0XKRn3Rh0wFPlfxB0xkt9CfyTvpX1Ra0+rAmdX2QyAobptSEvuy4RtpPRui6XkV+8wjA==",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/unicode-property-aliases-ecmascript": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz",
+      "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/unified": {
+      "version": "10.1.2",
+      "resolved": "https://registry.npmjs.org/unified/-/unified-10.1.2.tgz",
+      "integrity": "sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==",
+      "dependencies": {
+        "@types/unist": "^2.0.0",
+        "bail": "^2.0.0",
+        "extend": "^3.0.0",
+        "is-buffer": "^2.0.0",
+        "is-plain-obj": "^4.0.0",
+        "trough": "^2.0.0",
+        "vfile": "^5.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/unist-util-generated": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/unist-util-generated/-/unist-util-generated-2.0.1.tgz",
+      "integrity": "sha512-qF72kLmPxAw0oN2fwpWIqbXAVyEqUzDHMsbtPvOudIlUzXYFIeQIuxXQCRCFh22B7cixvU0MG7m3MW8FTq/S+A==",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/unist-util-is": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-5.2.1.tgz",
+      "integrity": "sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw==",
+      "dependencies": {
+        "@types/unist": "^2.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/unist-util-position": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-4.0.4.tgz",
+      "integrity": "sha512-kUBE91efOWfIVBo8xzh/uZQ7p9ffYRtUbMRZBNFYwf0RK8koUMx6dGUfwylLOKmaT2cs4wSW96QoYUSXAyEtpg==",
+      "dependencies": {
+        "@types/unist": "^2.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/unist-util-stringify-position": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-3.0.3.tgz",
+      "integrity": "sha512-k5GzIBZ/QatR8N5X2y+drfpWG8IDBzdnVj6OInRNWm1oXrzydiaAT2OQiA8DPRRZyAKb9b6I2a6PxYklZD0gKg==",
+      "dependencies": {
+        "@types/unist": "^2.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/unist-util-visit": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-4.1.2.tgz",
+      "integrity": "sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg==",
+      "dependencies": {
+        "@types/unist": "^2.0.0",
+        "unist-util-is": "^5.0.0",
+        "unist-util-visit-parents": "^5.1.1"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/unist-util-visit-parents": {
+      "version": "5.1.3",
+      "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-5.1.3.tgz",
+      "integrity": "sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg==",
+      "dependencies": {
+        "@types/unist": "^2.0.0",
+        "unist-util-is": "^5.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/universalify": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
+      "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==",
+      "dev": true,
+      "engines": {
+        "node": ">= 10.0.0"
+      }
+    },
+    "node_modules/unplugin": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.4.0.tgz",
+      "integrity": "sha512-5x4eIEL6WgbzqGtF9UV8VEC/ehKptPXDS6L2b0mv4FRMkJxRtjaJfOWDd6a8+kYbqsjklix7yWP0N3SUepjXcg==",
+      "dev": true,
+      "dependencies": {
+        "acorn": "^8.9.0",
+        "chokidar": "^3.5.3",
+        "webpack-sources": "^3.2.3",
+        "webpack-virtual-modules": "^0.5.0"
+      }
+    },
+    "node_modules/unplugin-vue-components": {
+      "version": "0.24.1",
+      "resolved": "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-0.24.1.tgz",
+      "integrity": "sha512-T3A8HkZoIE1Cja95xNqolwza0yD5IVlgZZ1PVAGvVCx8xthmjsv38xWRCtHtwl+rvZyL9uif42SRkDGw9aCfMA==",
+      "dev": true,
+      "dependencies": {
+        "@antfu/utils": "^0.7.2",
+        "@rollup/pluginutils": "^5.0.2",
+        "chokidar": "^3.5.3",
+        "debug": "^4.3.4",
+        "fast-glob": "^3.2.12",
+        "local-pkg": "^0.4.3",
+        "magic-string": "^0.30.0",
+        "minimatch": "^7.4.2",
+        "resolve": "^1.22.1",
+        "unplugin": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      },
+      "peerDependencies": {
+        "@babel/parser": "^7.15.8",
+        "@nuxt/kit": "^3.2.2",
+        "vue": "2 || 3"
+      },
+      "peerDependenciesMeta": {
+        "@babel/parser": {
+          "optional": true
+        },
+        "@nuxt/kit": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/unplugin-vue-components/node_modules/brace-expansion": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+      "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+      "dev": true,
+      "dependencies": {
+        "balanced-match": "^1.0.0"
+      }
+    },
+    "node_modules/unplugin-vue-components/node_modules/minimatch": {
+      "version": "7.4.6",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-7.4.6.tgz",
+      "integrity": "sha512-sBz8G/YjVniEz6lKPNpKxXwazJe4c19fEfV2GDMX6AjFz+MX9uDWIZW8XreVhkFW3fkIdTv/gxWr/Kks5FFAVw==",
+      "dev": true,
+      "dependencies": {
+        "brace-expansion": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/update-browserslist-db": {
+      "version": "1.0.11",
+      "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz",
+      "integrity": "sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/browserslist"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "dependencies": {
+        "escalade": "^3.1.1",
+        "picocolors": "^1.0.0"
+      },
+      "bin": {
+        "update-browserslist-db": "cli.js"
+      },
+      "peerDependencies": {
+        "browserslist": ">= 4.21.0"
+      }
+    },
+    "node_modules/uri-js": {
+      "version": "4.4.1",
+      "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+      "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+      "dev": true,
+      "dependencies": {
+        "punycode": "^2.1.0"
+      }
+    },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+      "dev": true
+    },
+    "node_modules/uvu": {
+      "version": "0.5.6",
+      "resolved": "https://registry.npmjs.org/uvu/-/uvu-0.5.6.tgz",
+      "integrity": "sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==",
+      "dependencies": {
+        "dequal": "^2.0.0",
+        "diff": "^5.0.0",
+        "kleur": "^4.0.3",
+        "sade": "^1.7.3"
+      },
+      "bin": {
+        "uvu": "bin.js"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/vfile": {
+      "version": "5.3.7",
+      "resolved": "https://registry.npmjs.org/vfile/-/vfile-5.3.7.tgz",
+      "integrity": "sha512-r7qlzkgErKjobAmyNIkkSpizsFPYiUPuJb5pNW1RB4JcYVZhs4lIbVqk8XPk033CV/1z8ss5pkax8SuhGpcG8g==",
+      "dependencies": {
+        "@types/unist": "^2.0.0",
+        "is-buffer": "^2.0.0",
+        "unist-util-stringify-position": "^3.0.0",
+        "vfile-message": "^3.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/vfile-location": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-4.1.0.tgz",
+      "integrity": "sha512-YF23YMyASIIJXpktBa4vIGLJ5Gs88UB/XePgqPmTa7cDA+JeO3yclbpheQYCHjVHBn/yePzrXuygIL+xbvRYHw==",
+      "dependencies": {
+        "@types/unist": "^2.0.0",
+        "vfile": "^5.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/vfile-message": {
+      "version": "3.1.4",
+      "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-3.1.4.tgz",
+      "integrity": "sha512-fa0Z6P8HUrQN4BZaX05SIVXic+7kE3b05PWAtPuYP9QLHsLKYR7/AlLW3NtOrpXRLeawpDLMsVkmk5DG0NXgWw==",
+      "dependencies": {
+        "@types/unist": "^2.0.0",
+        "unist-util-stringify-position": "^3.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/vite": {
+      "version": "4.4.9",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-4.4.9.tgz",
+      "integrity": "sha512-2mbUn2LlUmNASWwSCNSJ/EG2HuSRTnVNaydp6vMCm5VIqJsjMfbIWtbH2kDuwUVW5mMUKKZvGPX/rqeqVvv1XA==",
+      "dev": true,
+      "dependencies": {
+        "esbuild": "^0.18.10",
+        "postcss": "^8.4.27",
+        "rollup": "^3.27.1"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^14.18.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.2"
+      },
+      "peerDependencies": {
+        "@types/node": ">= 14",
+        "less": "*",
+        "lightningcss": "^1.21.0",
+        "sass": "*",
+        "stylus": "*",
+        "sugarss": "*",
+        "terser": "^5.4.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "lightningcss": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vite-plugin-compression": {
+      "version": "0.5.1",
+      "resolved": "https://registry.npmjs.org/vite-plugin-compression/-/vite-plugin-compression-0.5.1.tgz",
+      "integrity": "sha512-5QJKBDc+gNYVqL/skgFAP81Yuzo9R+EAf19d+EtsMF/i8kFUpNi3J/H01QD3Oo8zBQn+NzoCIFkpPLynoOzaJg==",
+      "dev": true,
+      "dependencies": {
+        "chalk": "^4.1.2",
+        "debug": "^4.3.3",
+        "fs-extra": "^10.0.0"
+      },
+      "peerDependencies": {
+        "vite": ">=2.0.0"
+      }
+    },
+    "node_modules/vite-plugin-compression/node_modules/ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "dev": true,
+      "dependencies": {
+        "color-convert": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/vite-plugin-compression/node_modules/chalk": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+      "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+      "dev": true,
+      "dependencies": {
+        "ansi-styles": "^4.1.0",
+        "supports-color": "^7.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/chalk?sponsor=1"
+      }
+    },
+    "node_modules/vite-plugin-compression/node_modules/color-convert": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "dev": true,
+      "dependencies": {
+        "color-name": "~1.1.4"
+      },
+      "engines": {
+        "node": ">=7.0.0"
+      }
+    },
+    "node_modules/vite-plugin-compression/node_modules/color-name": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+      "dev": true
+    },
+    "node_modules/vite-plugin-compression/node_modules/has-flag": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/vite-plugin-compression/node_modules/supports-color": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+      "dev": true,
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/vue": {
+      "version": "3.3.4",
+      "resolved": "https://registry.npmjs.org/vue/-/vue-3.3.4.tgz",
+      "integrity": "sha512-VTyEYn3yvIeY1Py0WaYGZsXnz3y5UnGi62GjVEqvEGPl6nxbOrCXbVOTQWBEJUqAyTUk2uJ5JLVnYJ6ZzGbrSw==",
+      "dependencies": {
+        "@vue/compiler-dom": "3.3.4",
+        "@vue/compiler-sfc": "3.3.4",
+        "@vue/runtime-dom": "3.3.4",
+        "@vue/server-renderer": "3.3.4",
+        "@vue/shared": "3.3.4"
+      }
+    },
+    "node_modules/vue-echarts": {
+      "version": "6.6.1",
+      "resolved": "https://registry.npmjs.org/vue-echarts/-/vue-echarts-6.6.1.tgz",
+      "integrity": "sha512-EpreTzlNeJ+eaUn0AhXEmKJk98xJGecgTqAdyZovoXWnhTxnlW2HuBM0ei3y8rLw1JCUabf8/sYvxjlr8SzBKQ==",
+      "hasInstallScript": true,
+      "dependencies": {
+        "resize-detector": "^0.3.0",
+        "vue-demi": "^0.13.11"
+      },
+      "peerDependencies": {
+        "@vue/composition-api": "^1.0.5",
+        "echarts": "^5.4.1",
+        "vue": "^2.6.12 || ^3.1.1"
+      },
+      "peerDependenciesMeta": {
+        "@vue/composition-api": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue-echarts/node_modules/vue-demi": {
+      "version": "0.13.11",
+      "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz",
+      "integrity": "sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==",
+      "hasInstallScript": true,
+      "bin": {
+        "vue-demi-fix": "bin/vue-demi-fix.js",
+        "vue-demi-switch": "bin/vue-demi-switch.js"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      },
+      "peerDependencies": {
+        "@vue/composition-api": "^1.0.0-rc.1",
+        "vue": "^3.0.0-0 || ^2.6.0"
+      },
+      "peerDependenciesMeta": {
+        "@vue/composition-api": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue-eslint-parser": {
+      "version": "9.3.1",
+      "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.3.1.tgz",
+      "integrity": "sha512-Clr85iD2XFZ3lJ52/ppmUDG/spxQu6+MAeHXjjyI4I1NUYZ9xmenQp4N0oaHJhrA8OOxltCVxMRfANGa70vU0g==",
+      "dev": true,
+      "dependencies": {
+        "debug": "^4.3.4",
+        "eslint-scope": "^7.1.1",
+        "eslint-visitor-keys": "^3.3.0",
+        "espree": "^9.3.1",
+        "esquery": "^1.4.0",
+        "lodash": "^4.17.21",
+        "semver": "^7.3.6"
+      },
+      "engines": {
+        "node": "^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/mysticatea"
+      },
+      "peerDependencies": {
+        "eslint": ">=6.0.0"
+      }
+    },
+    "node_modules/vue-eslint-parser/node_modules/eslint-scope": {
+      "version": "7.2.2",
+      "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
+      "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
+      "dev": true,
+      "dependencies": {
+        "esrecurse": "^4.3.0",
+        "estraverse": "^5.2.0"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/vue-eslint-parser/node_modules/estraverse": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+      "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+      "dev": true,
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/vue-i18n": {
+      "version": "9.2.2",
+      "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.2.2.tgz",
+      "integrity": "sha512-yswpwtj89rTBhegUAv9Mu37LNznyu3NpyLQmozF3i1hYOhwpG8RjcjIFIIfnu+2MDZJGSZPXaKWvnQA71Yv9TQ==",
+      "dependencies": {
+        "@intlify/core-base": "9.2.2",
+        "@intlify/shared": "9.2.2",
+        "@intlify/vue-devtools": "9.2.2",
+        "@vue/devtools-api": "^6.2.1"
+      },
+      "engines": {
+        "node": ">= 14"
+      },
+      "peerDependencies": {
+        "vue": "^3.0.0"
+      }
+    },
+    "node_modules/vue-router": {
+      "version": "4.2.4",
+      "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.2.4.tgz",
+      "integrity": "sha512-9PISkmaCO02OzPVOMq2w82ilty6+xJmQrarYZDkjZBfl4RvYAlt4PKnEX21oW4KTtWfa9OuO/b3qk1Od3AEdCQ==",
+      "dependencies": {
+        "@vue/devtools-api": "^6.5.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/posva"
+      },
+      "peerDependencies": {
+        "vue": "^3.2.0"
+      }
+    },
+    "node_modules/vue-template-compiler": {
+      "version": "2.7.14",
+      "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.14.tgz",
+      "integrity": "sha512-zyA5Y3ArvVG0NacJDkkzJuPQDF8RFeRlzV2vLeSnhSpieO6LK2OVbdLPi5MPPs09Ii+gMO8nY4S3iKQxBxDmWQ==",
+      "dev": true,
+      "dependencies": {
+        "de-indent": "^1.0.2",
+        "he": "^1.2.0"
+      }
+    },
+    "node_modules/vue-tsc": {
+      "version": "1.8.8",
+      "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-1.8.8.tgz",
+      "integrity": "sha512-bSydNFQsF7AMvwWsRXD7cBIXaNs/KSjvzWLymq/UtKE36697sboX4EccSHFVxvgdBlI1frYPc/VMKJNB7DFeDQ==",
+      "dev": true,
+      "dependencies": {
+        "@vue/language-core": "1.8.8",
+        "@vue/typescript": "1.8.8",
+        "semver": "^7.3.8"
+      },
+      "bin": {
+        "vue-tsc": "bin/vue-tsc.js"
+      },
+      "peerDependencies": {
+        "typescript": "*"
+      }
+    },
+    "node_modules/vue-types": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/vue-types/-/vue-types-3.0.2.tgz",
+      "integrity": "sha512-IwUC0Aq2zwaXqy74h4WCvFCUtoV0iSWr0snWnE9TnU18S66GAQyqQbRf2qfJtUuiFsBf6qp0MEwdonlwznlcrw==",
+      "dependencies": {
+        "is-plain-object": "3.0.1"
+      },
+      "engines": {
+        "node": ">=10.15.0"
+      },
+      "peerDependencies": {
+        "vue": "^3.0.0"
+      }
+    },
+    "node_modules/vuedraggable": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz",
+      "integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==",
+      "dependencies": {
+        "sortablejs": "1.14.0"
+      },
+      "peerDependencies": {
+        "vue": "^3.0.1"
+      }
+    },
+    "node_modules/vuedraggable/node_modules/sortablejs": {
+      "version": "1.14.0",
+      "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz",
+      "integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w=="
+    },
+    "node_modules/warning": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
+      "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
+      "dependencies": {
+        "loose-envify": "^1.0.0"
+      }
+    },
+    "node_modules/web-namespaces": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
+      "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/webpack-sources": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz",
+      "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==",
+      "dev": true,
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/webpack-virtual-modules": {
+      "version": "0.5.0",
+      "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.5.0.tgz",
+      "integrity": "sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw==",
+      "dev": true
+    },
+    "node_modules/which": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+      "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+      "dev": true,
+      "dependencies": {
+        "isexe": "^2.0.0"
+      },
+      "bin": {
+        "node-which": "bin/node-which"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/wmf": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
+      "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/word": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
+      "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/word-count": {
+      "version": "0.2.2",
+      "resolved": "https://registry.npmjs.org/word-count/-/word-count-0.2.2.tgz",
+      "integrity": "sha512-tPRTbQ+nTCPY3F0z1f/y0PX22ScE6l/4/8j9KqA3h77JhlZ/w6cbVS8LIO5Pq/aV96SWBOoiE2IEgzxF0Cn+kA=="
+    },
+    "node_modules/wrap-ansi": {
+      "version": "8.1.0",
+      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+      "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
+      "dev": true,
+      "dependencies": {
+        "ansi-styles": "^6.1.0",
+        "string-width": "^5.0.1",
+        "strip-ansi": "^7.0.1"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+      }
+    },
+    "node_modules/wrap-ansi-cjs": {
+      "name": "wrap-ansi",
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+      "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+      "dev": true,
+      "dependencies": {
+        "ansi-styles": "^4.0.0",
+        "string-width": "^4.1.0",
+        "strip-ansi": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+      }
+    },
+    "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "dev": true,
+      "dependencies": {
+        "color-convert": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/wrap-ansi-cjs/node_modules/color-convert": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "dev": true,
+      "dependencies": {
+        "color-name": "~1.1.4"
+      },
+      "engines": {
+        "node": ">=7.0.0"
+      }
+    },
+    "node_modules/wrap-ansi-cjs/node_modules/color-name": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+      "dev": true
+    },
+    "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+      "dev": true
+    },
+    "node_modules/wrap-ansi-cjs/node_modules/string-width": {
+      "version": "4.2.3",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+      "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+      "dev": true,
+      "dependencies": {
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/wrap-ansi/node_modules/ansi-regex": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
+      "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
+      "dev": true,
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+      }
+    },
+    "node_modules/wrap-ansi/node_modules/ansi-styles": {
+      "version": "6.2.1",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
+      "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
+      "dev": true,
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/wrap-ansi/node_modules/strip-ansi": {
+      "version": "7.1.0",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
+      "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+      "dev": true,
+      "dependencies": {
+        "ansi-regex": "^6.0.1"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+      }
+    },
+    "node_modules/wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+      "dev": true
+    },
+    "node_modules/xgplayer": {
+      "version": "3.0.7",
+      "resolved": "https://registry.npmjs.org/xgplayer/-/xgplayer-3.0.7.tgz",
+      "integrity": "sha512-lGkcwsxtD4hLXRfXx4pqDKrz74qnpcYXb0g1v9uMiAYawYV8jppf9lr8mFlahGOJTSf+WpcsYiquuO90PWxFsg==",
+      "dependencies": {
+        "danmu.js": ">=1.1.6",
+        "delegate": "^3.2.0",
+        "downloadjs": "1.4.7",
+        "eventemitter3": "^4.0.7",
+        "xgplayer-subtitles": "3.0.7"
+      },
+      "peerDependencies": {
+        "core-js": ">=3.12.1"
+      }
+    },
+    "node_modules/xgplayer-subtitles": {
+      "version": "3.0.7",
+      "resolved": "https://registry.npmjs.org/xgplayer-subtitles/-/xgplayer-subtitles-3.0.7.tgz",
+      "integrity": "sha512-28q33G8JsYQREvQwRNyuCmprZD7+Y30BhV0RpciHTH4dBAoA78Xtj4gp47UeNjmZCb6P1QdnRtk4ZPDkF1Yxog==",
+      "dependencies": {
+        "eventemitter3": "^4.0.7"
+      },
+      "peerDependencies": {
+        "core-js": ">=3.12.1"
+      }
+    },
+    "node_modules/xlsx": {
+      "version": "0.18.5",
+      "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
+      "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
+      "dependencies": {
+        "adler-32": "~1.3.0",
+        "cfb": "~1.2.1",
+        "codepage": "~1.15.0",
+        "crc-32": "~1.2.1",
+        "ssf": "~0.11.2",
+        "wmf": "~1.0.1",
+        "word": "~0.3.0"
+      },
+      "bin": {
+        "xlsx": "bin/xlsx.njs"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/xml-name-validator": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
+      "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==",
+      "dev": true,
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/yallist": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+      "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+      "dev": true
+    },
+    "node_modules/yocto-queue": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+      "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/zrender": {
+      "version": "5.4.4",
+      "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.4.4.tgz",
+      "integrity": "sha512-0VxCNJ7AGOMCWeHVyTrGzUgrK4asT4ml9PEkeGirAkKNYXYzoPJCLvmyfdoOXcjTHPs10OZVMfD1Rwg16AZyYw==",
+      "dependencies": {
+        "tslib": "2.3.0"
+      }
+    },
+    "node_modules/zwitch": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
+      "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..a0e0285
--- /dev/null
+++ b/package.json
@@ -0,0 +1,69 @@
+{
+  "name": "ele-admin-pro-template",
+  "version": "1.11.1",
+  "private": true,
+  "scripts": {
+    "dev": "vite",
+    "serve": "vite build && vite preview",
+    "build": "vite build",
+    "lint:eslint": "eslint --cache --max-warnings 0  \"src/**/*.{vue,ts}\" --fix",
+    "clean:cache": "rimraf node_modules/.cache/ && rimraf node_modules/.vite/",
+    "clean:lib": "rimraf node_modules"
+  },
+  "dependencies": {
+    "@amap/amap-jsapi-loader": "^1.0.1",
+    "@ant-design/colors": "^7.0.0",
+    "@ant-design/icons-vue": "^6.1.0",
+    "@bytemd/plugin-gfm": "^1.21.0",
+    "ant-design-vue": "^3.2.17",
+    "axios": "^1.3.5",
+    "bytemd": "^1.21.0",
+    "countup.js": "^2.6.0",
+    "cropperjs": "^1.5.13",
+    "dayjs": "^1.11.7",
+    "echarts": "^5.4.2",
+    "echarts-wordcloud": "^2.1.0",
+    "ele-admin-pro": "^1.11.1",
+    "github-markdown-css": "^5.2.0",
+    "jsbarcode": "^3.11.5",
+    "lodash-es": "^4.17.21",
+    "nprogress": "^0.2.0",
+    "pinia": "^2.0.34",
+    "sortablejs": "^1.15.0",
+    "tinymce": "^5.10.7",
+    "vue": "^3.2.47",
+    "vue-echarts": "^6.5.4",
+    "vue-i18n": "^9.2.2",
+    "vue-router": "^4.1.6",
+    "vuedraggable": "^4.1.0",
+    "xgplayer": "^3.0.1",
+    "xlsx": "^0.18.5"
+  },
+  "devDependencies": {
+    "@types/lodash-es": "^4.17.7",
+    "@types/node": "^18.15.11",
+    "@types/nprogress": "^0.2.0",
+    "@types/sortablejs": "^1.15.1",
+    "@typescript-eslint/eslint-plugin": "^5.58.0",
+    "@typescript-eslint/parser": "^5.58.0",
+    "@vitejs/plugin-legacy": "^4.0.2",
+    "@vitejs/plugin-vue": "^4.1.0",
+    "@vue/compiler-sfc": "^3.2.47",
+    "eslint": "^8.38.0",
+    "eslint-config-prettier": "^8.8.0",
+    "eslint-define-config": "^1.18.0",
+    "eslint-plugin-prettier": "^4.2.1",
+    "eslint-plugin-vue": "^9.11.0",
+    "less": "^4.1.3",
+    "postcss": "^8.4.22",
+    "prettier": "^2.8.7",
+    "rimraf": "^5.0.0",
+    "terser": "^5.16.9",
+    "typescript": "^5.0.4",
+    "unplugin-vue-components": "^0.24.1",
+    "vite": "^4.2.1",
+    "vite-plugin-compression": "^0.5.1",
+    "vue-eslint-parser": "^9.1.1",
+    "vue-tsc": "^1.2.0"
+  }
+}
diff --git a/postcss.config.js b/postcss.config.js
new file mode 100644
index 0000000..fa4aabc
--- /dev/null
+++ b/postcss.config.js
@@ -0,0 +1,3 @@
+module.exports = {
+  plugins: {}
+};
diff --git a/prettier.config.js b/prettier.config.js
new file mode 100644
index 0000000..33ad45b
--- /dev/null
+++ b/prettier.config.js
@@ -0,0 +1,19 @@
+module.exports = {
+  printWidth: 80,
+  tabWidth: 2,
+  useTabs: false,
+  semi: true,
+  singleQuote: true,
+  quoteProps: 'as-needed',
+  jsxSingleQuote: false,
+  trailingComma: 'none',
+  bracketSpacing: true,
+  bracketSameLine: false,
+  arrowParens: 'always',
+  requirePragma: false,
+  insertPragma: false,
+  proseWrap: 'never',
+  htmlWhitespaceSensitivity: 'strict',
+  vueIndentScriptAndStyle: true,
+  endOfLine: 'lf'
+};
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 0000000..bac3b38
Binary files /dev/null and b/public/favicon.ico differ
diff --git a/public/json/china-provinces.geo.json b/public/json/china-provinces.geo.json
new file mode 100644
index 0000000..be70ca7
--- /dev/null
+++ b/public/json/china-provinces.geo.json
@@ -0,0 +1 @@
+{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"id":"65","size":"550","name":"新疆","cp":[84.9023,42.148],"childNum":18},"geometry":{"type":"Polygon","coordinates":[[[96.416,42.7588],[96.416,42.7148],[95.9766,42.4951],[96.0645,42.3193],[96.2402,42.2314],[95.9766,41.9238],[95.2734,41.6162],[95.1855,41.792],[94.5703,41.4844],[94.043,41.0889],[93.8672,40.6934],[93.0762,40.6494],[92.6367,39.6387],[92.373,39.3311],[92.373,39.1113],[92.373,39.0234],[90.1758,38.4961],[90.3516,38.2324],[90.6152,38.3203],[90.5273,37.8369],[91.0547,37.4414],[91.3184,37.0898],[90.7031,36.7822],[90.791,36.6064],[91.0547,36.5186],[91.0547,36.0791],[90.8789,36.0352],[90,36.2549],[89.9121,36.0791],[89.7363,36.0791],[89.209,36.2988],[88.7695,36.3428],[88.5938,36.4746],[87.3633,36.4307],[86.2207,36.167],[86.1328,35.8594],[85.6055,35.6836],[85.0781,35.7275],[84.1992,35.376],[83.1445,35.4199],[82.8809,35.6836],[82.4414,35.7275],[82.002,35.332],[81.6504,35.2441],[80.4199,35.4199],[80.2441,35.2881],[80.332,35.1563],[80.2441,35.2002],[79.8926,34.8047],[79.8047,34.4971],[79.1016,34.4531],[79.0137,34.3213],[78.2227,34.7168],[78.0469,35.2441],[78.0469,35.5078],[77.4316,35.4639],[76.8164,35.6396],[76.5527,35.8594],[76.2012,35.8154],[75.9375,36.0352],[76.0254,36.4746],[75.8496,36.6943],[75.498,36.7383],[75.4102,36.958],[75.0586,37.002],[74.8828,36.9141],[74.7949,37.0459],[74.5313,37.0898],[74.5313,37.2217],[74.8828,37.2217],[75.1465,37.4414],[74.8828,37.5732],[74.9707,37.749],[74.8828,38.4521],[74.3555,38.6719],[74.1797,38.6719],[74.0918,38.54],[73.8281,38.584],[73.7402,38.8477],[73.8281,38.9795],[73.4766,39.375],[73.916,39.5068],[73.916,39.6826],[73.8281,39.7705],[74.0039,40.0342],[74.8828,40.3418],[74.7949,40.5176],[75.2344,40.4297],[75.5859,40.6494],[75.7617,40.2979],[76.377,40.3857],[76.9043,41.001],[77.6074,41.001],[78.1348,41.2207],[78.1348,41.3965],[80.1563,42.0557],[80.2441,42.2754],[80.1563,42.627],[80.2441,42.8467],[80.5078,42.8906],[80.4199,43.0664],[80.7715,43.1982],[80.4199,44.165],[80.4199,44.6045],[79.9805,44.8242],[79.9805,44.9561],[81.7383,45.3955],[82.0898,45.2197],[82.5293,45.2197],[82.2656,45.6592],[83.0566,47.2412],[83.6719,47.0215],[84.7266,47.0215],[84.9023,46.8896],[85.5176,47.0654],[85.6934,47.2852],[85.5176,48.1201],[85.7813,48.4277],[86.5723,48.5596],[86.8359,48.8232],[86.748,48.9551],[86.8359,49.1309],[87.8027,49.1748],[87.8906,48.999],[87.7148,48.9111],[88.0664,48.7354],[87.9785,48.6035],[88.5059,48.3838],[88.6816,48.1641],[89.1211,47.9883],[89.5605,48.0322],[89.7363,47.8564],[90.0879,47.8564],[90.3516,47.6807],[90.5273,47.2412],[90.8789,46.9775],[91.0547,46.582],[90.8789,46.3184],[91.0547,46.0107],[90.7031,45.7471],[90.7031,45.5273],[90.8789,45.2197],[91.582,45.0879],[93.5156,44.9561],[94.7461,44.3408],[95.3613,44.2969],[95.3613,44.0332],[95.5371,43.9014],[95.8887,43.2422],[96.3281,42.9346],[96.416,42.7588]]]}},{"type":"Feature","properties":{"id":"54","size":"550","name":"西藏","cp":[87.8695,31.6846],"childNum":7},"geometry":{"type":"Polygon","coordinates":[[[79.0137,34.3213],[79.1016,34.4531],[79.8047,34.4971],[79.8926,34.8047],[80.2441,35.2002],[80.332,35.1563],[80.2441,35.2881],[80.4199,35.4199],[81.6504,35.2441],[82.002,35.332],[82.4414,35.7275],[82.8809,35.6836],[83.1445,35.4199],[84.1992,35.376],[85.0781,35.7275],[85.6055,35.6836],[86.1328,35.8594],[86.2207,36.167],[87.3633,36.4307],[88.5938,36.4746],[88.7695,36.3428],[89.209,36.2988],[89.7363,36.0791],[89.3848,36.0352],[89.4727,35.9033],[89.7363,35.7715],[89.7363,35.4199],[89.4727,35.376],[89.4727,35.2441],[89.5605,34.8926],[89.8242,34.8486],[89.7363,34.6729],[89.8242,34.3652],[89.6484,34.0137],[90.0879,33.4863],[90.7031,33.1348],[91.4063,33.1348],[91.9336,32.8271],[92.1973,32.8271],[92.2852,32.7393],[92.9883,32.7393],[93.5156,32.4756],[93.7793,32.5635],[94.1309,32.4316],[94.6582,32.6074],[95.1855,32.4316],[95.0098,32.2998],[95.1855,32.3438],[95.2734,32.2119],[95.3613,32.168],[95.3613,31.9922],[95.4492,31.8164],[95.8008,31.6846],[95.9766,31.8164],[96.1523,31.5967],[96.2402,31.9482],[96.5039,31.7285],[96.8555,31.6846],[96.7676,31.9922],[97.2949,32.0801],[97.3828,32.5635],[97.7344,32.5195],[98.1738,32.3438],[98.4375,31.8604],[98.877,31.4209],[98.6133,31.2012],[98.9648,30.7617],[99.1406,29.2676],[98.9648,29.1357],[98.9648,28.8281],[98.7891,28.8721],[98.7891,29.0039],[98.7012,28.916],[98.6133,28.5205],[98.7891,28.3447],[98.7012,28.2129],[98.3496,28.125],[98.2617,28.3887],[98.1738,28.125],[97.5586,28.5205],[97.2949,28.0811],[97.3828,27.9053],[97.0313,27.7295],[96.5039,28.125],[95.7129,28.2568],[95.3613,28.125],[95.2734,27.9492],[94.2188,27.5537],[93.8672,27.0264],[93.6035,26.9385],[92.1094,26.8506],[92.0215,27.4658],[91.582,27.5537],[91.582,27.9053],[91.4063,28.0371],[91.0547,27.8613],[90.7031,28.0811],[89.8242,28.2129],[89.6484,28.1689],[89.1211,27.5977],[89.1211,27.334],[89.0332,27.2021],[88.7695,27.4219],[88.8574,27.9932],[88.6816,28.125],[88.1543,27.9053],[87.8906,27.9492],[87.7148,27.8174],[87.0996,27.8174],[86.748,28.125],[86.5723,28.125],[86.4844,27.9053],[86.1328,28.125],[86.0449,27.9053],[85.6934,28.3447],[85.6055,28.2568],[85.166,28.3447],[85.166,28.6523],[84.9023,28.5645],[84.4629,28.7402],[84.2871,28.8721],[84.1992,29.2236],[84.1113,29.2676],[83.584,29.1797],[83.2324,29.5752],[82.1777,30.0586],[82.0898,30.3223],[81.3867,30.3662],[81.2109,30.0146],[81.0352,30.2344],[80.0684,30.5859],[79.7168,30.9375],[79.0137,31.0693],[78.75,31.333],[78.8379,31.5967],[78.6621,31.8164],[78.75,31.9043],[78.4863,32.124],[78.3984,32.5195],[78.75,32.6953],[78.9258,32.3438],[79.2773,32.5635],[79.1016,33.1787],[78.6621,33.6621],[78.6621,34.1016],[78.9258,34.1455],[79.0137,34.3213]]]}},{"type":"Feature","properties":{"id":"15","size":"450","name":"内蒙古","cp":[111.670801,41.818311],"childNum":12},"geometry":{"type":"Polygon","coordinates":[[[97.207,42.8027],[99.4922,42.583],[100.8105,42.6709],[101.7773,42.4951],[102.041,42.2314],[102.7441,42.1436],[103.3594,41.8799],[103.8867,41.792],[104.502,41.8799],[104.502,41.6602],[105.0293,41.5723],[105.7324,41.9238],[107.4023,42.4512],[109.4238,42.4512],[110.3906,42.7588],[111.0059,43.3301],[111.9727,43.6816],[111.9727,43.8135],[111.4453,44.3848],[111.7969,45],[111.9727,45.0879],[113.6426,44.7363],[114.1699,44.9561],[114.5215,45.3955],[115.6641,45.4395],[116.1914,45.7031],[116.2793,45.9668],[116.543,46.2744],[117.334,46.3623],[117.4219,46.582],[117.7734,46.5381],[118.3008,46.7578],[118.7402,46.7139],[118.916,46.7578],[119.0918,46.6699],[119.707,46.626],[119.9707,46.7139],[119.707,47.1973],[118.4766,47.9883],[117.8613,48.0322],[117.334,47.6807],[116.8066,47.9004],[116.1914,47.8564],[115.9277,47.6807],[115.5762,47.9004],[115.4883,48.1641],[115.8398,48.252],[115.8398,48.5596],[116.7188,49.834],[117.7734,49.5264],[118.5645,49.9219],[119.2676,50.0977],[119.3555,50.3174],[119.1797,50.3613],[119.5313,50.7568],[119.5313,50.8887],[119.707,51.0645],[120.1465,51.6797],[120.6738,51.9434],[120.7617,52.1191],[120.7617,52.251],[120.5859,52.3389],[120.6738,52.5146],[120.4102,52.6465],[120.0586,52.6025],[120.0586,52.7344],[120.8496,53.2617],[121.4648,53.3496],[121.8164,53.042],[121.2012,52.5586],[121.6406,52.4268],[121.7285,52.2949],[121.9922,52.2949],[122.168,52.5146],[122.6953,52.251],[122.6074,52.0752],[122.959,51.3281],[123.3105,51.2402],[123.6621,51.3721],[124.3652,51.2842],[124.541,51.3721],[124.8926,51.3721],[125.0684,51.6357],[125.332,51.6357],[126.0352,51.0205],[125.7715,50.7568],[125.7715,50.5371],[125.332,50.1416],[125.1563,49.834],[125.2441,49.1748],[124.8047,49.1309],[124.4531,48.1201],[124.2773,48.5156],[122.4316,47.373],[123.0469,46.7139],[123.3984,46.8896],[123.3984,46.9775],[123.4863,46.9775],[123.5742,46.8457],[123.5742,46.8896],[123.5742,46.6699],[123.0469,46.582],[123.2227,46.2305],[122.7832,46.0107],[122.6953,45.7031],[122.4316,45.8789],[122.2559,45.791],[121.8164,46.0107],[121.7285,45.7471],[121.9043,45.7031],[122.2559,45.2637],[122.0801,44.8682],[122.3438,44.2529],[123.1348,44.4727],[123.4863,43.7256],[123.3105,43.5059],[123.6621,43.374],[123.5742,43.0225],[123.3105,42.9785],[123.1348,42.8027],[122.7832,42.7148],[122.3438,42.8467],[122.3438,42.6709],[121.9922,42.7148],[121.7285,42.4512],[121.4648,42.4951],[120.498,42.0996],[120.1465,41.7041],[119.8828,42.1875],[119.5313,42.3633],[119.3555,42.2754],[119.2676,41.7041],[119.4434,41.6162],[119.2676,41.3086],[118.3887,41.3086],[118.125,41.748],[118.3008,41.792],[118.3008,42.0996],[118.125,42.0557],[117.9492,42.2314],[118.0371,42.4072],[117.7734,42.627],[117.5098,42.583],[117.334,42.4512],[116.8945,42.4072],[116.8066,42.0117],[116.2793,42.0117],[116.0156,41.792],[115.9277,41.9238],[115.2246,41.5723],[114.9609,41.6162],[114.873,42.0996],[114.5215,42.1436],[114.1699,41.792],[114.2578,41.5723],[113.9063,41.4404],[113.9941,41.2207],[113.9063,41.1328],[114.082,40.7373],[114.082,40.5176],[113.8184,40.5176],[113.5547,40.3418],[113.2031,40.3857],[112.7637,40.166],[112.3242,40.2539],[111.9727,39.5947],[111.4453,39.6387],[111.3574,39.4189],[111.0938,39.375],[111.0938,39.5947],[110.6543,39.2871],[110.127,39.4629],[110.2148,39.2871],[109.8633,39.2432],[109.9512,39.1553],[108.9844,38.3203],[109.0723,38.0127],[108.8965,37.9688],[108.8086,38.0127],[108.7207,37.7051],[108.1934,37.6172],[107.666,37.8809],[107.3145,38.1006],[106.7871,38.1885],[106.5234,38.3203],[106.9629,38.9795],[106.7871,39.375],[106.3477,39.2871],[105.9082,38.7158],[105.8203,37.793],[104.3262,37.4414],[103.4473,37.8369],[103.3594,38.0127],[103.5352,38.1445],[103.4473,38.3643],[104.2383,38.9795],[104.0625,39.4189],[103.3594,39.3311],[103.0078,39.1113],[102.4805,39.2432],[101.8652,39.1113],[102.041,38.8916],[101.7773,38.6719],[101.3379,38.7598],[101.25,39.0234],[100.9863,38.9355],[100.8105,39.4189],[100.5469,39.4189],[100.0195,39.7705],[99.4922,39.8584],[100.1074,40.2539],[100.1953,40.6494],[99.9316,41.001],[99.2285,40.8691],[99.0527,40.6934],[98.9648,40.7813],[98.7891,40.6055],[98.5254,40.7373],[98.6133,40.6494],[98.3496,40.5615],[98.3496,40.9131],[97.4707,41.4844],[97.8223,41.6162],[97.8223,41.748],[97.207,42.8027]]]}},{"type":"Feature","properties":{"id":"63","size":"800","name":"青海","cp":[95.2402,35.4199],"childNum":8},"geometry":{"type":"Polygon","coordinates":[[[89.7363,36.0791],[89.9121,36.0791],[90,36.2549],[90.8789,36.0352],[91.0547,36.0791],[91.0547,36.5186],[90.791,36.6064],[90.7031,36.7822],[91.3184,37.0898],[91.0547,37.4414],[90.5273,37.8369],[90.6152,38.3203],[90.3516,38.2324],[90.1758,38.4961],[92.373,39.0234],[92.373,39.1113],[93.1641,39.1992],[93.1641,38.9795],[93.6914,38.9355],[93.8672,38.7158],[94.3066,38.7598],[94.5703,38.3643],[95.0098,38.4082],[95.4492,38.2764],[95.7129,38.3643],[96.2402,38.1006],[96.416,38.2324],[96.6797,38.1885],[96.6797,38.4521],[97.1191,38.584],[97.0313,39.1992],[98.1738,38.8037],[98.3496,39.0234],[98.6133,38.9355],[98.7891,39.0674],[99.1406,38.9355],[99.8438,38.3643],[100.1953,38.2764],[100.0195,38.4521],[100.1074,38.4961],[100.459,38.2764],[100.7227,38.2324],[101.1621,37.8369],[101.5137,37.8809],[101.7773,37.6172],[101.9531,37.7051],[102.1289,37.4414],[102.5684,37.1777],[102.4805,36.958],[102.6563,36.8262],[102.5684,36.7383],[102.832,36.3428],[103.0078,36.2549],[102.9199,36.0791],[102.9199,35.9033],[102.6563,35.7715],[102.832,35.5957],[102.4805,35.5957],[102.3047,35.4199],[102.3926,35.2002],[101.9531,34.8486],[101.9531,34.6289],[102.2168,34.4092],[102.1289,34.2773],[101.6895,34.1016],[100.9863,34.3652],[100.8105,34.2773],[101.25,33.6621],[101.5137,33.7061],[101.6016,33.5303],[101.7773,33.5303],[101.6895,33.3105],[101.7773,33.2227],[101.6016,33.1348],[101.1621,33.2227],[101.25,32.6953],[100.7227,32.6514],[100.7227,32.5195],[100.3711,32.7393],[100.1074,32.6514],[100.1074,32.8711],[99.8438,33.0029],[99.7559,32.7393],[99.2285,32.915],[99.2285,33.0469],[98.877,33.1787],[98.4375,34.0576],[97.8223,34.1895],[97.6465,34.1016],[97.7344,33.9258],[97.3828,33.8818],[97.4707,33.5742],[97.7344,33.3984],[97.3828,32.8711],[97.4707,32.6953],[97.7344,32.5195],[97.3828,32.5635],[97.2949,32.0801],[96.7676,31.9922],[96.8555,31.6846],[96.5039,31.7285],[96.2402,31.9482],[96.1523,31.5967],[95.9766,31.8164],[95.8008,31.6846],[95.4492,31.8164],[95.3613,31.9922],[95.3613,32.168],[95.2734,32.2119],[95.1855,32.3438],[95.0098,32.2998],[95.1855,32.4316],[94.6582,32.6074],[94.1309,32.4316],[93.7793,32.5635],[93.5156,32.4756],[92.9883,32.7393],[92.2852,32.7393],[92.1973,32.8271],[91.9336,32.8271],[91.4063,33.1348],[90.7031,33.1348],[90.0879,33.4863],[89.6484,34.0137],[89.8242,34.3652],[89.7363,34.6729],[89.8242,34.8486],[89.5605,34.8926],[89.4727,35.2441],[89.4727,35.376],[89.7363,35.4199],[89.7363,35.7715],[89.4727,35.9033],[89.3848,36.0352],[89.7363,36.0791]]]}},{"type":"Feature","properties":{"id":"51","size":"900","name":"四川","cp":[101.9199,30.1904],"childNum":21},"geometry":{"type":"Polygon","coordinates":[[[101.7773,33.5303],[101.8652,33.5742],[101.9531,33.4424],[101.8652,33.0908],[102.4805,33.4424],[102.2168,33.9258],[102.9199,34.3213],[103.0957,34.1895],[103.1836,33.7939],[104.1504,33.6182],[104.2383,33.3984],[104.4141,33.3105],[104.3262,33.2227],[104.4141,33.0469],[104.3262,32.8711],[104.4141,32.7393],[105.2051,32.6074],[105.3809,32.7393],[105.3809,32.8711],[105.4688,32.915],[105.5566,32.7393],[106.084,32.8711],[106.084,32.7393],[106.3477,32.6514],[107.0508,32.6953],[107.1387,32.4756],[107.2266,32.4316],[107.4023,32.5195],[108.0176,32.168],[108.2813,32.2559],[108.5449,32.2119],[108.3691,32.168],[108.2813,31.9043],[108.5449,31.6846],[108.1934,31.5088],[107.9297,30.8496],[107.4902,30.8496],[107.4023,30.7617],[107.4902,30.6299],[107.0508,30.0146],[106.7871,30.0146],[106.6113,30.3223],[106.2598,30.1904],[105.8203,30.4541],[105.6445,30.2783],[105.5566,30.1025],[105.7324,29.8828],[105.293,29.5313],[105.4688,29.3115],[105.7324,29.2676],[105.8203,28.96],[106.2598,28.8721],[106.3477,28.5205],[105.9961,28.7402],[105.6445,28.4326],[105.9082,28.125],[106.1719,28.125],[106.3477,27.8174],[105.6445,27.6416],[105.5566,27.7734],[105.293,27.7295],[105.2051,27.9932],[105.0293,28.0811],[104.8535,27.9053],[104.4141,27.9492],[104.3262,28.0371],[104.4141,28.125],[104.4141,28.2568],[104.2383,28.4326],[104.4141,28.6084],[103.8867,28.6523],[103.7988,28.3008],[103.4473,28.125],[103.4473,27.7734],[102.9199,27.29],[103.0078,26.3672],[102.6563,26.1914],[102.5684,26.3672],[102.1289,26.1035],[101.8652,26.0596],[101.6016,26.2354],[101.6895,26.3672],[101.4258,26.5869],[101.4258,26.8066],[101.4258,26.7188],[101.1621,27.0264],[101.1621,27.1582],[100.7227,27.8613],[100.3711,27.8174],[100.2832,27.7295],[100.0195,28.125],[100.1953,28.3447],[99.668,28.8281],[99.4043,28.5205],[99.4043,28.1689],[99.2285,28.3008],[99.1406,29.2676],[98.9648,30.7617],[98.6133,31.2012],[98.877,31.4209],[98.4375,31.8604],[98.1738,32.3438],[97.7344,32.5195],[97.4707,32.6953],[97.3828,32.8711],[97.7344,33.3984],[97.4707,33.5742],[97.3828,33.8818],[97.7344,33.9258],[97.6465,34.1016],[97.8223,34.1895],[98.4375,34.0576],[98.877,33.1787],[99.2285,33.0469],[99.2285,32.915],[99.7559,32.7393],[99.8438,33.0029],[100.1074,32.8711],[100.1074,32.6514],[100.3711,32.7393],[100.7227,32.5195],[100.7227,32.6514],[101.25,32.6953],[101.1621,33.2227],[101.6016,33.1348],[101.7773,33.2227],[101.6895,33.3105],[101.7773,33.5303]]]}},{"type":"Feature","properties":{"id":"23","size":"700","name":"黑龙江","cp":[128.642464,46.756967],"childNum":13},"geometry":{"type":"Polygon","coordinates":[[[121.4648,53.3496],[123.6621,53.5693],[124.8926,53.0859],[125.0684,53.2178],[125.5957,53.0859],[125.6836,52.9102],[126.123,52.7783],[126.0352,52.6025],[126.2109,52.5146],[126.3867,52.2949],[126.3867,52.207],[126.5625,52.1631],[126.4746,51.9434],[126.9141,51.3721],[126.8262,51.2842],[127.002,51.3281],[126.9141,51.1084],[127.2656,50.7568],[127.3535,50.2734],[127.6172,50.2295],[127.5293,49.8779],[127.793,49.6143],[128.7598,49.5703],[129.1113,49.3506],[129.4629,49.4385],[130.2539,48.8672],[130.6934,48.8672],[130.5176,48.6475],[130.8691,48.2959],[130.6934,48.1201],[131.0449,47.6807],[132.5391,47.7246],[132.627,47.9443],[133.0664,48.1201],[133.5059,48.1201],[134.209,48.3838],[135.0879,48.4277],[134.7363,48.252],[134.5605,47.9883],[134.7363,47.6807],[134.5605,47.4609],[134.3848,47.4609],[134.209,47.2852],[134.209,47.1533],[133.8574,46.5381],[133.9453,46.2744],[133.5059,45.835],[133.418,45.5713],[133.2422,45.5273],[133.0664,45.1318],[132.8906,45.0439],[131.9238,45.3516],[131.5723,45.0439],[131.0449,44.8682],[131.3086,44.0771],[131.2207,43.7256],[131.3086,43.4619],[130.8691,43.418],[130.5176,43.6377],[130.3418,43.9893],[129.9902,43.8574],[129.9023,44.0332],[129.8145,43.9014],[129.2871,43.8135],[129.1992,43.5938],[128.8477,43.5498],[128.4961,44.165],[128.4082,44.4727],[128.0566,44.3408],[128.0566,44.1211],[127.7051,44.1211],[127.5293,44.6045],[127.0898,44.6045],[127.002,44.7803],[127.0898,45],[126.9141,45.1318],[126.5625,45.2637],[126.0352,45.1758],[125.7715,45.3076],[125.6836,45.5273],[125.0684,45.3955],[124.8926,45.5273],[124.3652,45.4395],[124.0137,45.7471],[123.9258,46.2305],[123.2227,46.2305],[123.0469,46.582],[123.5742,46.6699],[123.5742,46.8896],[123.5742,46.8457],[123.4863,46.9775],[123.3984,46.9775],[123.3984,46.8896],[123.0469,46.7139],[122.4316,47.373],[124.2773,48.5156],[124.4531,48.1201],[124.8047,49.1309],[125.2441,49.1748],[125.1563,49.834],[125.332,50.1416],[125.7715,50.5371],[125.7715,50.7568],[126.0352,51.0205],[125.332,51.6357],[125.0684,51.6357],[124.8926,51.3721],[124.541,51.3721],[124.3652,51.2842],[123.6621,51.3721],[123.3105,51.2402],[122.959,51.3281],[122.6074,52.0752],[122.6953,52.251],[122.168,52.5146],[121.9922,52.2949],[121.7285,52.2949],[121.6406,52.4268],[121.2012,52.5586],[121.8164,53.042],[121.4648,53.3496]]]}},{"type":"Feature","properties":{"id":"62","size":"690","name":"甘肃","cp":[103.823557,36.058039],"childNum":14},"geometry":{"type":"Polygon","coordinates":[[[96.416,42.7148],[97.207,42.8027],[97.8223,41.748],[97.8223,41.6162],[97.4707,41.4844],[98.3496,40.9131],[98.3496,40.5615],[98.6133,40.6494],[98.5254,40.7373],[98.7891,40.6055],[98.9648,40.7813],[99.0527,40.6934],[99.2285,40.8691],[99.9316,41.001],[100.1953,40.6494],[100.1074,40.2539],[99.4922,39.8584],[100.0195,39.7705],[100.5469,39.4189],[100.8105,39.4189],[100.9863,38.9355],[101.25,39.0234],[101.3379,38.7598],[101.7773,38.6719],[102.041,38.8916],[101.8652,39.1113],[102.4805,39.2432],[103.0078,39.1113],[103.3594,39.3311],[104.0625,39.4189],[104.2383,38.9795],[103.4473,38.3643],[103.5352,38.1445],[103.3594,38.0127],[103.4473,37.8369],[104.3262,37.4414],[104.5898,37.4414],[104.5898,37.2217],[104.8535,37.2217],[105.293,36.8262],[105.2051,36.6943],[105.4688,36.123],[105.293,35.9912],[105.3809,35.7715],[105.7324,35.7275],[105.8203,35.5518],[105.9961,35.4639],[105.9082,35.4199],[105.9961,35.4199],[106.084,35.376],[106.2598,35.4199],[106.3477,35.2441],[106.5234,35.332],[106.4355,35.6836],[106.6992,35.6836],[106.9629,35.8154],[106.875,36.123],[106.5234,36.2549],[106.5234,36.4746],[106.4355,36.5625],[106.6113,36.7822],[106.6113,37.0898],[107.3145,37.0898],[107.3145,36.9141],[108.7207,36.3428],[108.6328,35.9912],[108.5449,35.8594],[108.6328,35.5518],[108.5449,35.2881],[107.7539,35.2881],[107.7539,35.1123],[107.8418,35.0244],[107.666,34.9365],[107.2266,34.8926],[106.9629,35.0684],[106.6113,35.0684],[106.5234,34.7607],[106.3477,34.585],[106.6992,34.3213],[106.5234,34.2773],[106.6113,34.1455],[106.4355,33.9258],[106.5234,33.5303],[105.9961,33.6182],[105.7324,33.3984],[105.9961,33.1787],[105.9082,33.0029],[105.4688,32.915],[105.3809,32.8711],[105.3809,32.7393],[105.2051,32.6074],[104.4141,32.7393],[104.3262,32.8711],[104.4141,33.0469],[104.3262,33.2227],[104.4141,33.3105],[104.2383,33.3984],[104.1504,33.6182],[103.1836,33.7939],[103.0957,34.1895],[102.9199,34.3213],[102.2168,33.9258],[102.4805,33.4424],[101.8652,33.0908],[101.9531,33.4424],[101.8652,33.5742],[101.7773,33.5303],[101.6016,33.5303],[101.5137,33.7061],[101.25,33.6621],[100.8105,34.2773],[100.9863,34.3652],[101.6895,34.1016],[102.1289,34.2773],[102.2168,34.4092],[101.9531,34.6289],[101.9531,34.8486],[102.3926,35.2002],[102.3047,35.4199],[102.4805,35.5957],[102.832,35.5957],[102.6563,35.7715],[102.9199,35.9033],[102.9199,36.0791],[103.0078,36.2549],[102.832,36.3428],[102.5684,36.7383],[102.6563,36.8262],[102.4805,36.958],[102.5684,37.1777],[102.1289,37.4414],[101.9531,37.7051],[101.7773,37.6172],[101.5137,37.8809],[101.1621,37.8369],[100.7227,38.2324],[100.459,38.2764],[100.1074,38.4961],[100.0195,38.4521],[100.1953,38.2764],[99.8438,38.3643],[99.1406,38.9355],[98.7891,39.0674],[98.6133,38.9355],[98.3496,39.0234],[98.1738,38.8037],[97.0313,39.1992],[97.1191,38.584],[96.6797,38.4521],[96.6797,38.1885],[96.416,38.2324],[96.2402,38.1006],[95.7129,38.3643],[95.4492,38.2764],[95.0098,38.4082],[94.5703,38.3643],[94.3066,38.7598],[93.8672,38.7158],[93.6914,38.9355],[93.1641,38.9795],[93.1641,39.1992],[92.373,39.1113],[92.373,39.3311],[92.6367,39.6387],[93.0762,40.6494],[93.8672,40.6934],[94.043,41.0889],[94.5703,41.4844],[95.1855,41.792],[95.2734,41.6162],[95.9766,41.9238],[96.2402,42.2314],[96.0645,42.3193],[95.9766,42.4951],[96.416,42.7148]]]}},{"type":"Feature","properties":{"id":"53","size":"1200","name":"云南","cp":[101.512251,24.740609],"childNum":16},"geometry":{"type":"Polygon","coordinates":[[[98.1738,28.125],[98.2617,28.3887],[98.3496,28.125],[98.7012,28.2129],[98.7891,28.3447],[98.6133,28.5205],[98.7012,28.916],[98.7891,29.0039],[98.7891,28.8721],[98.9648,28.8281],[98.9648,29.1357],[99.1406,29.2676],[99.2285,28.3008],[99.4043,28.1689],[99.4043,28.5205],[99.668,28.8281],[100.1953,28.3447],[100.0195,28.125],[100.2832,27.7295],[100.3711,27.8174],[100.7227,27.8613],[101.1621,27.1582],[101.1621,27.0264],[101.4258,26.7188],[101.4258,26.8066],[101.4258,26.5869],[101.6895,26.3672],[101.6016,26.2354],[101.8652,26.0596],[102.1289,26.1035],[102.5684,26.3672],[102.6563,26.1914],[103.0078,26.3672],[102.9199,27.29],[103.4473,27.7734],[103.4473,28.125],[103.7988,28.3008],[103.8867,28.6523],[104.4141,28.6084],[104.2383,28.4326],[104.4141,28.2568],[104.4141,28.125],[104.3262,28.0371],[104.4141,27.9492],[104.8535,27.9053],[105.0293,28.0811],[105.2051,27.9932],[105.293,27.7295],[105.2051,27.3779],[104.5898,27.334],[104.4141,27.4658],[104.1504,27.2461],[103.8867,27.4219],[103.623,27.0264],[103.7109,26.9824],[103.7109,26.7627],[103.8867,26.543],[104.4141,26.6748],[104.6777,26.4111],[104.3262,25.708],[104.8535,25.2246],[104.5898,25.0488],[104.6777,24.9609],[104.502,24.7412],[104.6777,24.3457],[104.7656,24.4775],[105.0293,24.4336],[105.2051,24.082],[105.4688,24.0381],[105.5566,24.126],[105.9961,24.126],[106.1719,23.8184],[106.1719,23.5547],[105.6445,23.4229],[105.5566,23.2031],[105.293,23.3789],[104.8535,23.1592],[104.7656,22.8516],[104.3262,22.6758],[104.1504,22.8076],[103.9746,22.5439],[103.623,22.7637],[103.5352,22.5879],[103.3594,22.8076],[103.0957,22.4561],[102.4805,22.7637],[102.3047,22.4121],[101.8652,22.3682],[101.7773,22.5],[101.6016,22.1924],[101.8652,21.6211],[101.7773,21.1377],[101.6016,21.2256],[101.25,21.1816],[101.1621,21.7529],[100.6348,21.4453],[100.1074,21.4893],[99.9316,22.0605],[99.2285,22.1484],[99.4043,22.5879],[99.3164,22.7197],[99.4922,23.0713],[98.877,23.2031],[98.7012,23.9502],[98.877,24.126],[98.1738,24.082],[97.7344,23.8623],[97.5586,23.9063],[97.7344,24.126],[97.6465,24.4336],[97.5586,24.4336],[97.5586,24.7412],[97.7344,24.8291],[97.8223,25.2686],[98.1738,25.4004],[98.1738,25.6201],[98.3496,25.5762],[98.5254,25.8398],[98.7012,25.8838],[98.6133,26.0596],[98.7012,26.1475],[98.7891,26.5869],[98.7012,27.5098],[98.5254,27.6416],[98.3496,27.5098],[98.1738,28.125]]]}},{"type":"Feature","properties":{"id":"45","size":"1450","name":"广西","cp":[107.7813,23.6426],"childNum":14},"geometry":{"type":"Polygon","coordinates":[[[104.502,24.7412],[104.6777,24.6094],[105.2051,24.9609],[105.9961,24.6533],[106.1719,24.7852],[106.1719,24.9609],[106.875,25.1807],[107.0508,25.2686],[106.9629,25.4883],[107.2266,25.6201],[107.4902,25.2246],[107.7539,25.2246],[107.8418,25.1367],[108.1055,25.2246],[108.1934,25.4443],[108.3691,25.5322],[108.6328,25.3125],[108.6328,25.5762],[109.0723,25.5322],[108.9844,25.752],[109.3359,25.708],[109.5117,26.0156],[109.7754,25.8838],[109.9512,26.1914],[110.2148,25.9717],[110.5664,26.3232],[111.1816,26.3232],[111.2695,26.2354],[111.2695,25.8838],[111.4453,25.8398],[111.0059,25.0049],[111.0938,24.9609],[111.3574,25.1367],[111.5332,24.6533],[111.709,24.7852],[112.0605,24.7412],[111.8848,24.6533],[112.0605,24.3457],[111.8848,24.2139],[111.8848,23.9941],[111.7969,23.8184],[111.6211,23.8184],[111.6211,23.6865],[111.3574,23.4668],[111.4453,23.0273],[111.2695,22.8076],[110.7422,22.5439],[110.7422,22.2803],[110.6543,22.1484],[110.3027,22.1484],[110.3027,21.8848],[109.9512,21.8408],[109.8633,21.665],[109.7754,21.6211],[109.7754,21.4014],[109.5996,21.4453],[109.1602,21.3574],[109.248,20.874],[109.0723,20.9619],[109.0723,21.5332],[108.7207,21.5332],[108.6328,21.665],[108.2813,21.4893],[107.8418,21.6211],[107.4023,21.6211],[107.0508,21.7969],[107.0508,21.9287],[106.6992,22.0166],[106.6113,22.4121],[106.7871,22.7637],[106.6992,22.8955],[105.9082,22.9395],[105.5566,23.0713],[105.5566,23.2031],[105.6445,23.4229],[106.1719,23.5547],[106.1719,23.8184],[105.9961,24.126],[105.5566,24.126],[105.4688,24.0381],[105.2051,24.082],[105.0293,24.4336],[104.7656,24.4775],[104.6777,24.3457],[104.502,24.7412]]]}},{"type":"Feature","properties":{"id":"43","size":"1700","name":"湖南","cp":[111.782279,28.09409],"childNum":14},"geometry":{"type":"Polygon","coordinates":[[[109.248,28.4766],[109.248,29.1357],[109.5117,29.6191],[109.6875,29.6191],[109.7754,29.751],[110.4785,29.6631],[110.6543,29.751],[110.4785,30.0146],[110.8301,30.1465],[111.7969,29.9268],[112.2363,29.5313],[112.5,29.6191],[112.6758,29.5752],[112.9395,29.7949],[113.0273,29.751],[112.9395,29.4873],[113.0273,29.4434],[113.5547,29.8389],[113.5547,29.707],[113.7305,29.5752],[113.6426,29.3115],[113.7305,29.0918],[113.9063,29.0479],[114.1699,28.8281],[114.082,28.5645],[114.2578,28.3447],[113.7305,27.9492],[113.6426,27.5977],[113.6426,27.3779],[113.8184,27.29],[113.7305,27.1143],[113.9063,26.9385],[113.9063,26.6309],[114.082,26.5869],[113.9941,26.1914],[114.2578,26.1475],[113.9941,26.0596],[113.9063,25.4443],[113.6426,25.3125],[113.2031,25.5322],[112.8516,25.3564],[113.0273,25.2246],[113.0273,24.9609],[112.8516,24.917],[112.5879,25.1367],[112.2363,25.1807],[112.1484,24.873],[112.0605,24.7412],[111.709,24.7852],[111.5332,24.6533],[111.3574,25.1367],[111.0938,24.9609],[111.0059,25.0049],[111.4453,25.8398],[111.2695,25.8838],[111.2695,26.2354],[111.1816,26.3232],[110.5664,26.3232],[110.2148,25.9717],[109.9512,26.1914],[109.7754,25.8838],[109.5117,26.0156],[109.4238,26.2793],[109.248,26.3232],[109.4238,26.5869],[109.3359,26.7188],[109.5117,26.8066],[109.5117,27.0264],[109.3359,27.1582],[108.8965,27.0264],[108.8086,27.1143],[109.4238,27.5977],[109.3359,27.9053],[109.3359,28.2568],[109.248,28.4766]]]}},{"type":"Feature","properties":{"id":"61","size":"1150","name":"陕西","cp":[108.948024,34.263161],"childNum":10},"geometry":{"type":"Polygon","coordinates":[[[105.4688,32.915],[105.9082,33.0029],[105.9961,33.1787],[105.7324,33.3984],[105.9961,33.6182],[106.5234,33.5303],[106.4355,33.9258],[106.6113,34.1455],[106.5234,34.2773],[106.6992,34.3213],[106.3477,34.585],[106.5234,34.7607],[106.6113,35.0684],[106.9629,35.0684],[107.2266,34.8926],[107.666,34.9365],[107.8418,35.0244],[107.7539,35.1123],[107.7539,35.2881],[108.5449,35.2881],[108.6328,35.5518],[108.5449,35.8594],[108.6328,35.9912],[108.7207,36.3428],[107.3145,36.9141],[107.3145,37.0898],[107.3145,37.6172],[107.666,37.8809],[108.1934,37.6172],[108.7207,37.7051],[108.8086,38.0127],[108.8965,37.9688],[109.0723,38.0127],[108.9844,38.3203],[109.9512,39.1553],[109.8633,39.2432],[110.2148,39.2871],[110.127,39.4629],[110.6543,39.2871],[111.0938,39.5947],[111.0938,39.375],[111.1816,39.2432],[110.918,38.7158],[110.8301,38.4961],[110.4785,38.1885],[110.4785,37.9688],[110.8301,37.6611],[110.3906,37.002],[110.4785,36.123],[110.5664,35.6396],[110.2148,34.8926],[110.2148,34.6729],[110.3906,34.585],[110.4785,34.2334],[110.6543,34.1455],[110.6543,33.8379],[111.0059,33.5303],[111.0059,33.2666],[110.7422,33.1348],[110.5664,33.2666],[110.3027,33.1787],[109.5996,33.2666],[109.4238,33.1348],[109.7754,33.0469],[109.7754,32.915],[110.127,32.7393],[110.127,32.6074],[109.6875,32.6074],[109.5117,32.4316],[109.5996,31.7285],[109.248,31.7285],[109.0723,31.9482],[108.5449,32.2119],[108.2813,32.2559],[108.0176,32.168],[107.4023,32.5195],[107.2266,32.4316],[107.1387,32.4756],[107.0508,32.6953],[106.3477,32.6514],[106.084,32.7393],[106.084,32.8711],[105.5566,32.7393],[105.4688,32.915]]]}},{"type":"Feature","properties":{"id":"44","size":"1600","name":"广东","cp":[113.280637,23.125178],"childNum":21},"geometry":{"type":"Polygon","coordinates":[[[109.7754,21.4014],[109.7754,21.6211],[109.8633,21.665],[109.9512,21.8408],[110.3027,21.8848],[110.3027,22.1484],[110.6543,22.1484],[110.7422,22.2803],[110.7422,22.5439],[111.2695,22.8076],[111.4453,23.0273],[111.3574,23.4668],[111.6211,23.6865],[111.6211,23.8184],[111.7969,23.8184],[111.8848,23.9941],[111.8848,24.2139],[112.0605,24.3457],[111.8848,24.6533],[112.0605,24.7412],[112.1484,24.873],[112.2363,25.1807],[112.5879,25.1367],[112.8516,24.917],[113.0273,24.9609],[113.0273,25.2246],[112.8516,25.3564],[113.2031,25.5322],[113.6426,25.3125],[113.9063,25.4443],[113.9941,25.2686],[114.6094,25.4004],[114.7852,25.2686],[114.6973,25.1367],[114.4336,24.9609],[114.1699,24.6973],[114.4336,24.5215],[115.4004,24.7852],[115.8398,24.5654],[115.752,24.7852],[115.9277,24.917],[116.2793,24.7852],[116.3672,24.873],[116.543,24.6094],[116.7188,24.6533],[116.9824,24.1699],[116.9824,23.9063],[117.1582,23.5547],[117.334,23.2471],[116.8945,23.3789],[116.6309,23.1152],[116.543,22.8516],[115.9277,22.7197],[115.6641,22.7637],[115.5762,22.6318],[115.0488,22.6758],[114.6094,22.3682],[114.3457,22.5439],[113.9941,22.5],[113.8184,22.1924],[114.3457,22.1484],[114.4336,22.0166],[114.082,21.9287],[113.9941,21.7969],[113.5547,22.0166],[113.1152,21.8408],[112.9395,21.5771],[112.4121,21.4453],[112.2363,21.5332],[111.5332,21.4893],[111.2695,21.3574],[110.7422,21.3574],[110.6543,21.2256],[110.7422,20.918],[110.4785,20.874],[110.6543,20.2588],[110.5664,20.2588],[110.3906,20.127],[110.0391,20.127],[109.8633,20.127],[109.8633,20.3027],[109.5996,20.918],[109.7754,21.4014],[109.7754,21.4014]],[[113.5986,22.1649],[113.6096,22.1265],[113.5547,22.11],[113.5437,22.2034],[113.5767,22.2034],[113.5986,22.1649]]]}},{"type":"Feature","properties":{"id":"22","size":"1120","name":"吉林","cp":[125.7746,43.5938],"childNum":9},"geometry":{"type":"Polygon","coordinates":[[[123.2227,46.2305],[123.9258,46.2305],[124.0137,45.7471],[124.3652,45.4395],[124.8926,45.5273],[125.0684,45.3955],[125.6836,45.5273],[125.7715,45.3076],[126.0352,45.1758],[126.5625,45.2637],[126.9141,45.1318],[127.0898,45],[127.002,44.7803],[127.0898,44.6045],[127.5293,44.6045],[127.7051,44.1211],[128.0566,44.1211],[128.0566,44.3408],[128.4082,44.4727],[128.4961,44.165],[128.8477,43.5498],[129.1992,43.5938],[129.2871,43.8135],[129.8145,43.9014],[129.9023,44.0332],[129.9902,43.8574],[130.3418,43.9893],[130.5176,43.6377],[130.8691,43.418],[131.3086,43.4619],[131.3086,43.3301],[131.1328,42.9346],[130.4297,42.7148],[130.6055,42.6709],[130.6055,42.4512],[130.2539,42.7588],[130.2539,42.8906],[130.166,42.9785],[129.9023,43.0225],[129.7266,42.4951],[129.375,42.4512],[128.9355,42.0117],[128.0566,42.0117],[128.3203,41.5723],[128.1445,41.3525],[127.0898,41.5283],[127.1777,41.5723],[126.9141,41.792],[126.6504,41.6602],[126.4746,41.3965],[126.123,40.957],[125.6836,40.8691],[125.5957,40.9131],[125.7715,41.2207],[125.332,41.6602],[125.332,41.9678],[125.4199,42.0996],[125.332,42.1436],[124.8926,42.8027],[124.8926,43.0664],[124.7168,43.0664],[124.4531,42.8467],[124.2773,43.2422],[123.8379,43.4619],[123.6621,43.374],[123.3105,43.5059],[123.4863,43.7256],[123.1348,44.4727],[122.3438,44.2529],[122.0801,44.8682],[122.2559,45.2637],[121.9043,45.7031],[121.7285,45.7471],[121.8164,46.0107],[122.2559,45.791],[122.4316,45.8789],[122.6953,45.7031],[122.7832,46.0107],[123.2227,46.2305]]]}},{"type":"Feature","properties":{"id":"13","size":"1300","name":"河北","cp":[114.502461,38.045474],"childNum":11},"geometry":{"type":"MultiPolygon","coordinates":[[[[114.5215,39.5068],[114.3457,39.8584],[113.9941,39.9902],[114.5215,40.3418],[114.3457,40.3857],[114.2578,40.6055],[114.082,40.7373],[113.9063,41.1328],[113.9941,41.2207],[113.9063,41.4404],[114.2578,41.5723],[114.1699,41.792],[114.5215,42.1436],[114.873,42.0996],[114.9609,41.6162],[115.2246,41.5723],[115.9277,41.9238],[116.0156,41.792],[116.2793,42.0117],[116.8066,42.0117],[116.8945,42.4072],[117.334,42.4512],[117.5098,42.583],[117.7734,42.627],[118.0371,42.4072],[117.9492,42.2314],[118.125,42.0557],[118.3008,42.0996],[118.3008,41.792],[118.125,41.748],[118.3887,41.3086],[119.2676,41.3086],[118.8281,40.8252],[119.2676,40.5176],[119.5313,40.5615],[119.707,40.1221],[119.8828,39.9463],[119.5313,39.6826],[119.4434,39.4189],[118.916,39.0674],[118.4766,38.9355],[118.125,39.0234],[118.0371,39.1992],[118.0371,39.2432],[117.8613,39.4189],[117.9492,39.5947],[117.6855,39.5947],[117.5098,39.7705],[117.5098,39.9902],[117.6855,39.9902],[117.6855,40.0781],[117.4219,40.21],[117.2461,40.5176],[117.4219,40.6494],[116.9824,40.6934],[116.6309,41.0449],[116.3672,40.9131],[116.4551,40.7813],[116.1914,40.7813],[116.1035,40.6055],[115.752,40.5615],[115.9277,40.2539],[115.4004,39.9463],[115.4883,39.6387],[115.752,39.5068],[116.1914,39.5947],[116.3672,39.4629],[116.543,39.5947],[116.8066,39.5947],[116.8945,39.1113],[116.7188,38.9355],[116.7188,38.8037],[117.2461,38.54],[117.5977,38.6279],[117.9492,38.3203],[117.4219,37.8369],[116.8066,37.8369],[116.4551,37.4854],[116.2793,37.5732],[116.2793,37.3535],[116.0156,37.3535],[115.752,36.9141],[115.3125,36.5186],[115.4883,36.167],[115.3125,36.0791],[115.1367,36.2109],[114.9609,36.0791],[114.873,36.123],[113.7305,36.3428],[113.4668,36.6504],[113.7305,36.8701],[113.7305,37.1338],[114.1699,37.6611],[113.9941,37.7051],[113.8184,38.1445],[113.5547,38.2764],[113.5547,38.54],[113.8184,38.8037],[113.8184,38.9355],[113.9063,39.0234],[114.3457,39.0674],[114.5215,39.5068]]],[[[117.2461,40.0781],[117.1582,39.8145],[117.1582,39.6387],[116.8945,39.6826],[116.8945,39.8145],[116.8066,39.9902],[117.2461,40.0781]]]]}},{"type":"Feature","properties":{"id":"42","size":"1500","name":"湖北","cp":[113.298572,30.684355],"childNum":17},"geometry":{"type":"Polygon","coordinates":[[[110.2148,31.1572],[110.127,31.377],[109.6875,31.5527],[109.7754,31.6846],[109.5996,31.7285],[109.5117,32.4316],[109.6875,32.6074],[110.127,32.6074],[110.127,32.7393],[109.7754,32.915],[109.7754,33.0469],[109.4238,33.1348],[109.5996,33.2666],[110.3027,33.1787],[110.5664,33.2666],[110.7422,33.1348],[111.0059,33.2666],[111.5332,32.6074],[112.3242,32.3438],[113.2031,32.4316],[113.4668,32.2998],[113.7305,32.4316],[113.8184,31.8604],[113.9941,31.7725],[114.1699,31.8604],[114.5215,31.7725],[114.6094,31.5527],[114.7852,31.4648],[115.1367,31.5967],[115.2246,31.4209],[115.4004,31.4209],[115.5762,31.2012],[116.0156,31.0254],[115.752,30.6738],[116.1035,30.1904],[116.1035,29.8389],[115.9277,29.707],[115.4883,29.7949],[114.873,29.3994],[114.2578,29.3555],[113.9063,29.0479],[113.7305,29.0918],[113.6426,29.3115],[113.7305,29.5752],[113.5547,29.707],[113.5547,29.8389],[113.0273,29.4434],[112.9395,29.4873],[113.0273,29.751],[112.9395,29.7949],[112.6758,29.5752],[112.5,29.6191],[112.2363,29.5313],[111.7969,29.9268],[110.8301,30.1465],[110.4785,30.0146],[110.6543,29.751],[110.4785,29.6631],[109.7754,29.751],[109.6875,29.6191],[109.5117,29.6191],[109.248,29.1357],[109.0723,29.3555],[108.9844,29.3115],[108.6328,29.8389],[108.457,29.7949],[108.5449,30.2344],[108.457,30.4102],[108.6328,30.5859],[108.8086,30.498],[109.0723,30.6299],[109.1602,30.542],[109.248,30.6299],[109.4238,30.542],[109.8633,30.8936],[110.0391,30.8057],[110.2148,31.1572]]]}},{"type":"Feature","properties":{"id":"52","size":"2000","name":"贵州","cp":[106.6113,26.9385],"childNum":9},"geometry":{"type":"Polygon","coordinates":[[[104.1504,27.2461],[104.4141,27.4658],[104.5898,27.334],[105.2051,27.3779],[105.293,27.7295],[105.5566,27.7734],[105.6445,27.6416],[106.3477,27.8174],[106.1719,28.125],[105.9082,28.125],[105.6445,28.4326],[105.9961,28.7402],[106.3477,28.5205],[106.5234,28.5645],[106.4355,28.7842],[106.5234,28.7842],[106.6113,28.6523],[106.6113,28.5205],[106.6992,28.4766],[106.875,28.7842],[107.4023,28.8721],[107.4023,29.1797],[107.5781,29.2236],[107.8418,29.1357],[107.8418,29.0039],[108.2813,29.0918],[108.3691,28.6523],[108.5449,28.6523],[108.5449,28.3887],[108.7207,28.4766],[108.7207,28.2129],[109.0723,28.2129],[109.248,28.4766],[109.3359,28.2568],[109.3359,27.9053],[109.4238,27.5977],[108.8086,27.1143],[108.8965,27.0264],[109.3359,27.1582],[109.5117,27.0264],[109.5117,26.8066],[109.3359,26.7188],[109.4238,26.5869],[109.248,26.3232],[109.4238,26.2793],[109.5117,26.0156],[109.3359,25.708],[108.9844,25.752],[109.0723,25.5322],[108.6328,25.5762],[108.6328,25.3125],[108.3691,25.5322],[108.1934,25.4443],[108.1055,25.2246],[107.8418,25.1367],[107.7539,25.2246],[107.4902,25.2246],[107.2266,25.6201],[106.9629,25.4883],[107.0508,25.2686],[106.875,25.1807],[106.1719,24.9609],[106.1719,24.7852],[105.9961,24.6533],[105.2051,24.9609],[104.6777,24.6094],[104.502,24.7412],[104.6777,24.9609],[104.5898,25.0488],[104.8535,25.2246],[104.3262,25.708],[104.6777,26.4111],[104.4141,26.6748],[103.8867,26.543],[103.7109,26.7627],[103.7109,26.9824],[103.623,27.0264],[103.8867,27.4219],[104.1504,27.2461]]]}},{"type":"Feature","properties":{"id":"37","size":"1500","name":"山东","cp":[118.7402,36.4307],"childNum":17},"geometry":{"type":"Polygon","coordinates":[[[115.4883,36.167],[115.3125,36.5186],[115.752,36.9141],[116.0156,37.3535],[116.2793,37.3535],[116.2793,37.5732],[116.4551,37.4854],[116.8066,37.8369],[117.4219,37.8369],[117.9492,38.3203],[118.125,38.1445],[118.916,38.1445],[119.3555,37.6611],[119.0039,37.5293],[119.0039,37.3535],[119.3555,37.1338],[119.707,37.1338],[119.8828,37.3975],[120.498,37.8369],[120.5859,38.1445],[120.9375,38.4521],[121.0254,37.8369],[121.2012,37.6611],[121.9043,37.4854],[122.168,37.6172],[122.2559,37.4854],[122.6074,37.4854],[122.6953,37.3535],[122.6074,36.9141],[122.4316,36.7822],[121.8164,36.8701],[121.7285,36.6943],[121.1133,36.6064],[121.1133,36.4307],[121.377,36.2549],[120.7617,36.167],[120.9375,35.8594],[120.6738,36.0352],[119.707,35.4639],[119.9707,34.9805],[119.3555,35.0244],[119.2676,35.1123],[118.916,35.0244],[118.7402,34.7168],[118.4766,34.6729],[118.3887,34.4092],[118.2129,34.4092],[118.125,34.6289],[117.9492,34.6729],[117.5977,34.4531],[117.334,34.585],[117.2461,34.4531],[116.8066,34.9365],[116.4551,34.8926],[116.3672,34.6289],[116.1914,34.585],[115.5762,34.585],[115.4004,34.8486],[114.7852,35.0684],[115.0488,35.376],[115.2246,35.4199],[115.4883,35.7275],[116.1035,36.0791],[115.3125,35.8154],[115.4883,36.167]]]}},{"type":"Feature","properties":{"id":"36","size":"1700","name":"江西","cp":[115.592151,27.676493],"childNum":11},"geometry":{"type":"Polygon","coordinates":[[[114.2578,28.3447],[114.082,28.5645],[114.1699,28.8281],[113.9063,29.0479],[114.2578,29.3555],[114.873,29.3994],[115.4883,29.7949],[115.9277,29.707],[116.1035,29.8389],[116.2793,29.7949],[116.7188,30.0586],[116.8945,29.9268],[116.7188,29.751],[116.7188,29.6191],[117.1582,29.707],[117.0703,29.8389],[117.1582,29.9268],[117.5098,29.6191],[118.0371,29.5752],[118.2129,29.3994],[118.0371,29.1797],[118.0371,29.0479],[118.3887,28.7842],[118.4766,28.3447],[118.4766,28.3008],[118.3008,28.0811],[117.7734,27.8174],[117.5098,27.9932],[116.9824,27.6416],[117.1582,27.29],[117.0703,27.1143],[116.543,26.8066],[116.6309,26.4551],[116.3672,26.2354],[116.4551,26.1035],[116.1914,25.8838],[116.0156,25.2686],[115.8398,25.2246],[115.9277,24.917],[115.752,24.7852],[115.8398,24.5654],[115.4004,24.7852],[114.4336,24.5215],[114.1699,24.6973],[114.4336,24.9609],[114.6973,25.1367],[114.7852,25.2686],[114.6094,25.4004],[113.9941,25.2686],[113.9063,25.4443],[113.9941,26.0596],[114.2578,26.1475],[113.9941,26.1914],[114.082,26.5869],[113.9063,26.6309],[113.9063,26.9385],[113.7305,27.1143],[113.8184,27.29],[113.6426,27.3779],[113.6426,27.5977],[113.7305,27.9492],[114.2578,28.3447]]]}},{"type":"Feature","properties":{"id":"41","size":"1700","name":"河南","cp":[113.0668,33.8818],"childNum":17},"geometry":{"type":"Polygon","coordinates":[[[110.3906,34.585],[110.8301,34.6289],[111.1816,34.8047],[111.5332,34.8486],[111.7969,35.0684],[112.0605,35.0684],[112.0605,35.2881],[112.7637,35.2002],[113.1152,35.332],[113.6426,35.6836],[113.7305,36.3428],[114.873,36.123],[114.9609,36.0791],[115.1367,36.2109],[115.3125,36.0791],[115.4883,36.167],[115.3125,35.8154],[116.1035,36.0791],[115.4883,35.7275],[115.2246,35.4199],[115.0488,35.376],[114.7852,35.0684],[115.4004,34.8486],[115.5762,34.585],[116.1914,34.585],[116.1914,34.4092],[116.543,34.2773],[116.6309,33.9258],[116.1914,33.7061],[116.0156,33.9697],[115.6641,34.0576],[115.5762,33.9258],[115.5762,33.6621],[115.4004,33.5303],[115.3125,33.1787],[114.873,33.1348],[114.873,33.0029],[115.1367,32.8711],[115.2246,32.6074],[115.5762,32.4316],[115.8398,32.5195],[115.9277,31.7725],[115.4883,31.6846],[115.4004,31.4209],[115.2246,31.4209],[115.1367,31.5967],[114.7852,31.4648],[114.6094,31.5527],[114.5215,31.7725],[114.1699,31.8604],[113.9941,31.7725],[113.8184,31.8604],[113.7305,32.4316],[113.4668,32.2998],[113.2031,32.4316],[112.3242,32.3438],[111.5332,32.6074],[111.0059,33.2666],[111.0059,33.5303],[110.6543,33.8379],[110.6543,34.1455],[110.4785,34.2334],[110.3906,34.585]]]}},{"type":"Feature","properties":{"id":"21","size":"1500","name":"辽宁","cp":[122.0438,41.0889],"childNum":14},"geometry":{"type":"Polygon","coordinates":[[[119.2676,41.3086],[119.4434,41.6162],[119.2676,41.7041],[119.3555,42.2754],[119.5313,42.3633],[119.8828,42.1875],[120.1465,41.7041],[120.498,42.0996],[121.4648,42.4951],[121.7285,42.4512],[121.9922,42.7148],[122.3438,42.6709],[122.3438,42.8467],[122.7832,42.7148],[123.1348,42.8027],[123.3105,42.9785],[123.5742,43.0225],[123.6621,43.374],[123.8379,43.4619],[124.2773,43.2422],[124.4531,42.8467],[124.7168,43.0664],[124.8926,43.0664],[124.8926,42.8027],[125.332,42.1436],[125.4199,42.0996],[125.332,41.9678],[125.332,41.6602],[125.7715,41.2207],[125.5957,40.9131],[125.6836,40.8691],[124.541,40.21],[124.1016,39.6826],[123.3984,39.6826],[123.1348,39.4189],[123.1348,39.0234],[122.0801,39.0234],[121.5527,38.7158],[121.1133,38.6719],[120.9375,38.9795],[121.377,39.1992],[121.2012,39.5508],[122.0801,40.3857],[121.9922,40.6934],[121.7285,40.8252],[121.2012,40.8252],[120.5859,40.21],[119.8828,39.9463],[119.707,40.1221],[119.5313,40.5615],[119.2676,40.5176],[118.8281,40.8252],[119.2676,41.3086]]]}},{"type":"Feature","properties":{"id":"14","size":"1450","name":"山西","cp":[111.849248,36.857014],"childNum":11},"geometry":{"type":"Polygon","coordinates":[[[110.918,38.7158],[111.1816,39.2432],[111.0938,39.375],[111.3574,39.4189],[111.4453,39.6387],[111.9727,39.5947],[112.3242,40.2539],[112.7637,40.166],[113.2031,40.3857],[113.5547,40.3418],[113.8184,40.5176],[114.082,40.5176],[114.082,40.7373],[114.2578,40.6055],[114.3457,40.3857],[114.5215,40.3418],[113.9941,39.9902],[114.3457,39.8584],[114.5215,39.5068],[114.3457,39.0674],[113.9063,39.0234],[113.8184,38.9355],[113.8184,38.8037],[113.5547,38.54],[113.5547,38.2764],[113.8184,38.1445],[113.9941,37.7051],[114.1699,37.6611],[113.7305,37.1338],[113.7305,36.8701],[113.4668,36.6504],[113.7305,36.3428],[113.6426,35.6836],[113.1152,35.332],[112.7637,35.2002],[112.0605,35.2881],[112.0605,35.0684],[111.7969,35.0684],[111.5332,34.8486],[111.1816,34.8047],[110.8301,34.6289],[110.3906,34.585],[110.2148,34.6729],[110.2148,34.8926],[110.5664,35.6396],[110.4785,36.123],[110.3906,37.002],[110.8301,37.6611],[110.4785,37.9688],[110.4785,38.1885],[110.8301,38.4961],[110.918,38.7158]]]}},{"type":"Feature","properties":{"id":"34","size":"1700","name":"安徽","cp":[117.283042,31.26119],"childNum":17},"geometry":{"type":"Polygon","coordinates":[[[116.6309,33.9258],[116.543,34.2773],[116.1914,34.4092],[116.1914,34.585],[116.3672,34.6289],[116.8945,34.4092],[117.1582,34.0576],[117.5977,34.0137],[117.7734,33.7061],[118.125,33.75],[117.9492,33.2227],[118.0371,33.1348],[118.2129,33.2227],[118.3008,32.7832],[118.7402,32.7393],[118.916,32.959],[119.1797,32.8271],[119.1797,32.4756],[118.5645,32.5635],[118.6523,32.2119],[118.4766,32.168],[118.3887,31.9482],[118.916,31.5527],[118.7402,31.377],[118.8281,31.2451],[119.3555,31.2891],[119.4434,31.1572],[119.6191,31.1133],[119.6191,31.0693],[119.4434,30.6738],[119.2676,30.6299],[119.3555,30.4102],[118.916,30.3223],[118.916,29.9707],[118.7402,29.707],[118.2129,29.3994],[118.0371,29.5752],[117.5098,29.6191],[117.1582,29.9268],[117.0703,29.8389],[117.1582,29.707],[116.7188,29.6191],[116.7188,29.751],[116.8945,29.9268],[116.7188,30.0586],[116.2793,29.7949],[116.1035,29.8389],[116.1035,30.1904],[115.752,30.6738],[116.0156,31.0254],[115.5762,31.2012],[115.4004,31.4209],[115.4883,31.6846],[115.9277,31.7725],[115.8398,32.5195],[115.5762,32.4316],[115.2246,32.6074],[115.1367,32.8711],[114.873,33.0029],[114.873,33.1348],[115.3125,33.1787],[115.4004,33.5303],[115.5762,33.6621],[115.5762,33.9258],[115.6641,34.0576],[116.0156,33.9697],[116.1914,33.7061],[116.6309,33.9258]]]}},{"type":"Feature","properties":{"id":"35","size":"2000","name":"福建","cp":[118.306239,26.075302],"childNum":9},"geometry":{"type":"Polygon","coordinates":[[[118.4766,28.3008],[118.8281,28.2568],[118.7402,28.0371],[118.916,27.4658],[119.2676,27.4219],[119.6191,27.6855],[119.7949,27.29],[120.2344,27.4219],[120.4102,27.1582],[120.7617,27.0264],[120.6738,26.8945],[120.2344,26.8506],[120.2344,26.7188],[120.4102,26.6748],[120.498,26.3672],[120.2344,26.2793],[120.4102,26.1475],[120.0586,26.1914],[119.9707,25.9277],[119.7949,25.9277],[119.9707,25.4004],[119.7949,25.2686],[119.5313,25.1367],[119.4434,25.0049],[119.2676,25.0928],[118.916,24.8291],[118.6523,24.5215],[118.4766,24.5215],[118.4766,24.4336],[118.2129,24.3457],[118.2129,24.1699],[117.8613,23.9941],[117.7734,23.7744],[117.5098,23.5986],[117.1582,23.5547],[116.9824,23.9063],[116.9824,24.1699],[116.7188,24.6533],[116.543,24.6094],[116.3672,24.873],[116.2793,24.7852],[115.9277,24.917],[115.8398,25.2246],[116.0156,25.2686],[116.1914,25.8838],[116.4551,26.1035],[116.3672,26.2354],[116.6309,26.4551],[116.543,26.8066],[117.0703,27.1143],[117.1582,27.29],[116.9824,27.6416],[117.5098,27.9932],[117.7734,27.8174],[118.3008,28.0811],[118.4766,28.3008]]]}},{"type":"Feature","properties":{"id":"33","size":"2100","name":"浙江","cp":[120.498,29.0918],"childNum":11},"geometry":{"type":"Polygon","coordinates":[[[118.2129,29.3994],[118.7402,29.707],[118.916,29.9707],[118.916,30.3223],[119.3555,30.4102],[119.2676,30.6299],[119.4434,30.6738],[119.6191,31.0693],[119.6191,31.1133],[119.9707,31.1572],[120.498,30.8057],[120.9375,31.0254],[121.2891,30.6738],[121.9922,30.8057],[122.6953,30.8936],[122.8711,30.7178],[122.959,30.1465],[122.6074,30.1025],[122.6074,29.9268],[122.168,29.5313],[122.3438,28.8721],[121.9922,28.8721],[121.9922,28.4326],[121.7285,28.3447],[121.7285,28.2129],[121.4648,28.2129],[121.5527,28.0371],[121.2891,27.9492],[121.1133,27.4219],[120.6738,27.334],[120.6738,27.1582],[120.9375,27.0264],[120.7617,27.0264],[120.4102,27.1582],[120.2344,27.4219],[119.7949,27.29],[119.6191,27.6855],[119.2676,27.4219],[118.916,27.4658],[118.7402,28.0371],[118.8281,28.2568],[118.4766,28.3008],[118.4766,28.3447],[118.3887,28.7842],[118.0371,29.0479],[118.0371,29.1797],[118.2129,29.3994]]]}},{"type":"Feature","properties":{"id":"32","size":"1950","name":"江苏","cp":[119.767413,33.041544],"childNum":13},"geometry":{"type":"Polygon","coordinates":[[[116.3672,34.6289],[116.4551,34.8926],[116.8066,34.9365],[117.2461,34.4531],[117.334,34.585],[117.5977,34.4531],[117.9492,34.6729],[118.125,34.6289],[118.2129,34.4092],[118.3887,34.4092],[118.4766,34.6729],[118.7402,34.7168],[118.916,35.0244],[119.2676,35.1123],[119.3555,35.0244],[119.3555,34.8486],[119.707,34.585],[120.3223,34.3652],[120.9375,33.0469],[121.0254,32.6514],[121.377,32.4756],[121.4648,32.168],[121.9043,31.9922],[121.9922,31.6846],[121.9922,31.5967],[121.2012,31.8604],[121.1133,31.7285],[121.377,31.5088],[121.2012,31.4648],[120.9375,31.0254],[120.498,30.8057],[119.9707,31.1572],[119.6191,31.1133],[119.4434,31.1572],[119.3555,31.2891],[118.8281,31.2451],[118.7402,31.377],[118.916,31.5527],[118.3887,31.9482],[118.4766,32.168],[118.6523,32.2119],[118.5645,32.5635],[119.1797,32.4756],[119.1797,32.8271],[118.916,32.959],[118.7402,32.7393],[118.3008,32.7832],[118.2129,33.2227],[118.0371,33.1348],[117.9492,33.2227],[118.125,33.75],[117.7734,33.7061],[117.5977,34.0137],[117.1582,34.0576],[116.8945,34.4092],[116.3672,34.6289]]]}},{"type":"Feature","properties":{"id":"50","size":"2380","name":"重庆","cp":[107.304962,29.533155],"childNum":40},"geometry":{"type":"Polygon","coordinates":[[[108.5449,31.6846],[108.2813,31.9043],[108.3691,32.168],[108.5449,32.2119],[109.0723,31.9482],[109.248,31.7285],[109.5996,31.7285],[109.7754,31.6846],[109.6875,31.5527],[110.127,31.377],[110.2148,31.1572],[110.0391,30.8057],[109.8633,30.8936],[109.4238,30.542],[109.248,30.6299],[109.1602,30.542],[109.0723,30.6299],[108.8086,30.498],[108.6328,30.5859],[108.457,30.4102],[108.5449,30.2344],[108.457,29.7949],[108.6328,29.8389],[108.9844,29.3115],[109.0723,29.3555],[109.248,29.1357],[109.248,28.4766],[109.0723,28.2129],[108.7207,28.2129],[108.7207,28.4766],[108.5449,28.3887],[108.5449,28.6523],[108.3691,28.6523],[108.2813,29.0918],[107.8418,29.0039],[107.8418,29.1357],[107.5781,29.2236],[107.4023,29.1797],[107.4023,28.8721],[106.875,28.7842],[106.6992,28.4766],[106.6113,28.5205],[106.6113,28.6523],[106.5234,28.7842],[106.4355,28.7842],[106.5234,28.5645],[106.3477,28.5205],[106.2598,28.8721],[105.8203,28.96],[105.7324,29.2676],[105.4688,29.3115],[105.293,29.5313],[105.7324,29.8828],[105.5566,30.1025],[105.6445,30.2783],[105.8203,30.4541],[106.2598,30.1904],[106.6113,30.3223],[106.7871,30.0146],[107.0508,30.0146],[107.4902,30.6299],[107.4023,30.7617],[107.4902,30.8496],[107.9297,30.8496],[108.1934,31.5088],[108.5449,31.6846]]]}},{"type":"Feature","properties":{"id":"64","size":"2100","name":"宁夏","cp":[105.9961,37.3096],"childNum":5},"geometry":{"type":"Polygon","coordinates":[[[104.3262,37.4414],[105.8203,37.793],[105.9082,38.7158],[106.3477,39.2871],[106.7871,39.375],[106.9629,38.9795],[106.5234,38.3203],[106.7871,38.1885],[107.3145,38.1006],[107.666,37.8809],[107.3145,37.6172],[107.3145,37.0898],[106.6113,37.0898],[106.6113,36.7822],[106.4355,36.5625],[106.5234,36.4746],[106.5234,36.2549],[106.875,36.123],[106.9629,35.8154],[106.6992,35.6836],[106.4355,35.6836],[106.5234,35.332],[106.3477,35.2441],[106.2598,35.4199],[106.084,35.376],[105.9961,35.4199],[106.084,35.4639],[105.9961,35.4639],[105.8203,35.5518],[105.7324,35.7275],[105.3809,35.7715],[105.293,35.9912],[105.4688,36.123],[105.2051,36.6943],[105.293,36.8262],[104.8535,37.2217],[104.5898,37.2217],[104.5898,37.4414],[104.3262,37.4414]]]}},{"type":"Feature","properties":{"id":"46","size":"4500","name":"海南","cp":[109.9512,19.2041],"childNum":18},"geometry":{"type":"Polygon","coordinates":[[[108.6328,19.3799],[109.0723,19.6436],[109.248,19.9512],[109.5996,20.0391],[110.0391,20.127],[110.3906,20.127],[110.5664,20.2588],[110.6543,20.2588],[111.0938,19.9512],[111.2695,19.9951],[110.6543,19.1602],[110.5664,18.6768],[110.2148,18.5889],[110.0391,18.3691],[109.8633,18.3691],[109.6875,18.1055],[108.9844,18.2813],[108.6328,18.457],[108.6328,19.3799]]]}},{"type":"Feature","properties":{"id":"71","size":"3000","name":"台湾","cp":[120.0254,23.5986],"childNum":1},"geometry":{"type":"Polygon","coordinates":[[[121.9043,25.0488],[121.9922,25.0049],[121.8164,24.7412],[121.9043,24.5654],[121.6406,24.0381],[121.377,23.1152],[121.0254,22.6758],[120.8496,22.0605],[120.7617,21.9287],[120.6738,22.3242],[120.2344,22.5879],[120.0586,23.0713],[120.1465,23.6865],[121.0254,25.0488],[121.5527,25.3125],[121.9043,25.0488]]]}},{"type":"Feature","properties":{"id":"11","size":"5000","name":"北京","cp":[116.4551,40.2539],"childNum":19},"geometry":{"type":"Polygon","coordinates":[[[117.4219,40.21],[117.334,40.1221],[117.2461,40.0781],[116.8066,39.9902],[116.8945,39.8145],[116.8945,39.6826],[116.8066,39.5947],[116.543,39.5947],[116.3672,39.4629],[116.1914,39.5947],[115.752,39.5068],[115.4883,39.6387],[115.4004,39.9463],[115.9277,40.2539],[115.752,40.5615],[116.1035,40.6055],[116.1914,40.7813],[116.4551,40.7813],[116.3672,40.9131],[116.6309,41.0449],[116.9824,40.6934],[117.4219,40.6494],[117.2461,40.5176],[117.4219,40.21]]]}},{"type":"Feature","properties":{"id":"12","size":"5000","name":"天津","cp":[117.4219,39.4189],"childNum":18},"geometry":{"type":"Polygon","coordinates":[[[116.8066,39.5947],[116.8945,39.6826],[117.1582,39.6387],[117.1582,39.8145],[117.2461,40.0781],[117.334,40.1221],[117.4219,40.21],[117.6855,40.0781],[117.6855,39.9902],[117.5098,39.9902],[117.5098,39.7705],[117.6855,39.5947],[117.9492,39.5947],[117.8613,39.4189],[118.0371,39.2432],[118.0371,39.1992],[117.8613,39.1113],[117.5977,38.6279],[117.2461,38.54],[116.7188,38.8037],[116.7188,38.9355],[116.8945,39.1113],[116.8066,39.5947]]]}},{"type":"Feature","properties":{"id":"31","size":"7500","name":"上海","cp":[121.4648,31.2891],"childNum":19},"geometry":{"type":"Polygon","coordinates":[[[120.9375,31.0254],[121.2012,31.4648],[121.377,31.5088],[121.1133,31.7285],[121.2012,31.8604],[121.9922,31.5967],[121.9043,31.1572],[121.9922,30.8057],[121.2891,30.6738],[120.9375,31.0254]]]}},{"type":"Feature","properties":{"id":"81","size":"18000","name":"香港","cp":[114.1178,22.3242],"childNum":1},"geometry":{"type":"Polygon","coordinates":[[[114.6094,22.4121],[114.5215,22.1484],[114.3457,22.1484],[113.9063,22.1484],[113.8184,22.1924],[113.9063,22.4121],[114.1699,22.5439],[114.3457,22.5439],[114.4336,22.5439],[114.4336,22.4121],[114.6094,22.4121]]]}},{"type":"Feature","properties":{"id":"82","size":"27","name":"澳门","cp":[111.5547,22.1484],"childNum":1},"geometry":{"type":"Polygon","coordinates":[[[113.5986,22.1649],[113.6096,22.1265],[113.5547,22.11],[113.5437,22.2034],[113.5767,22.2034],[113.5986,22.1649]]]}}]}
diff --git a/public/json/regions-data.json b/public/json/regions-data.json
new file mode 100644
index 0000000..4cbd130
--- /dev/null
+++ b/public/json/regions-data.json
@@ -0,0 +1 @@
+[{"label":"北京","value":"110000","children":[{"value":"110100","label":"北京市","children":[{"value":"110101","label":"东城区"},{"value":"110102","label":"西城区"},{"value":"110103","label":"崇文区"},{"value":"110104","label":"宣武区"},{"value":"110105","label":"朝阳区"},{"value":"110106","label":"丰台区"},{"value":"110107","label":"石景山区"},{"value":"110108","label":"海淀区"},{"value":"110109","label":"门头沟区"},{"value":"110111","label":"房山区"},{"value":"110112","label":"通州区"},{"value":"110113","label":"顺义区"},{"value":"110114","label":"昌平区"},{"value":"110115","label":"大兴区"},{"value":"110116","label":"怀柔区"},{"value":"110117","label":"平谷区"},{"value":"110228","label":"密云县"},{"value":"110229","label":"延庆县"}]}]},{"label":"天津","value":"120000","children":[{"value":"120100","label":"天津市","children":[{"value":"120101","label":"和平区"},{"value":"120102","label":"河东区"},{"value":"120103","label":"河西区"},{"value":"120104","label":"南开区"},{"value":"120105","label":"河北区"},{"value":"120106","label":"红桥区"},{"value":"120107","label":"塘沽区"},{"value":"120108","label":"汉沽区"},{"value":"120109","label":"大港区"},{"value":"120110","label":"东丽区"},{"value":"120111","label":"西青区"},{"value":"120112","label":"津南区"},{"value":"120113","label":"北辰区"},{"value":"120114","label":"武清区"},{"value":"120115","label":"宝坻区"},{"value":"120116","label":"滨海新区"},{"value":"120221","label":"宁河县"},{"value":"120223","label":"静海县"},{"value":"120225","label":"蓟县"}]}]},{"label":"河北省","value":"130000","children":[{"value":"130100","label":"石家庄市","children":[{"value":"130102","label":"长安区"},{"value":"130103","label":"桥东区"},{"value":"130104","label":"桥西区"},{"value":"130105","label":"新华区"},{"value":"130107","label":"井陉矿区"},{"value":"130108","label":"裕华区"},{"value":"130121","label":"井陉县"},{"value":"130123","label":"正定县"},{"value":"130124","label":"栾城县"},{"value":"130125","label":"行唐县"},{"value":"130126","label":"灵寿县"},{"value":"130127","label":"高邑县"},{"value":"130128","label":"深泽县"},{"value":"130129","label":"赞皇县"},{"value":"130130","label":"无极县"},{"value":"130131","label":"平山县"},{"value":"130132","label":"元氏县"},{"value":"130133","label":"赵县"},{"value":"130181","label":"辛集市"},{"value":"130182","label":"藁城市"},{"value":"130183","label":"晋州市"},{"value":"130184","label":"新乐市"},{"value":"130185","label":"鹿泉市"}]},{"value":"130200","label":"唐山市","children":[{"value":"130202","label":"路南区"},{"value":"130203","label":"路北区"},{"value":"130204","label":"古冶区"},{"value":"130205","label":"开平区"},{"value":"130207","label":"丰南区"},{"value":"130208","label":"丰润区"},{"value":"130223","label":"滦县"},{"value":"130224","label":"滦南县"},{"value":"130225","label":"乐亭县"},{"value":"130227","label":"迁西县"},{"value":"130229","label":"玉田县"},{"value":"130230","label":"唐海县"},{"value":"130281","label":"遵化市"},{"value":"130283","label":"迁安市"}]},{"value":"130300","label":"秦皇岛市","children":[{"value":"130302","label":"海港区"},{"value":"130303","label":"山海关区"},{"value":"130304","label":"北戴河区"},{"value":"130321","label":"青龙满族自治县"},{"value":"130322","label":"昌黎县"},{"value":"130323","label":"抚宁县"},{"value":"130324","label":"卢龙县"},{"value":"130399","label":"经济技术开发区"}]},{"value":"130400","label":"邯郸市","children":[{"value":"130402","label":"邯山区"},{"value":"130403","label":"丛台区"},{"value":"130404","label":"复兴区"},{"value":"130406","label":"峰峰矿区"},{"value":"130421","label":"邯郸县"},{"value":"130423","label":"临漳县"},{"value":"130424","label":"成安县"},{"value":"130425","label":"大名县"},{"value":"130426","label":"涉县"},{"value":"130427","label":"磁县"},{"value":"130428","label":"肥乡县"},{"value":"130429","label":"永年县"},{"value":"130430","label":"邱县"},{"value":"130431","label":"鸡泽县"},{"value":"130432","label":"广平县"},{"value":"130433","label":"馆陶县"},{"value":"130434","label":"魏县"},{"value":"130435","label":"曲周县"},{"value":"130481","label":"武安市"}]},{"value":"130500","label":"邢台市","children":[{"value":"130502","label":"桥东区"},{"value":"130503","label":"桥西区"},{"value":"130521","label":"邢台县"},{"value":"130522","label":"临城县"},{"value":"130523","label":"内丘县"},{"value":"130524","label":"柏乡县"},{"value":"130525","label":"隆尧县"},{"value":"130526","label":"任县"},{"value":"130527","label":"南和县"},{"value":"130528","label":"宁晋县"},{"value":"130529","label":"巨鹿县"},{"value":"130530","label":"新河县"},{"value":"130531","label":"广宗县"},{"value":"130532","label":"平乡县"},{"value":"130533","label":"威县"},{"value":"130534","label":"清河县"},{"value":"130535","label":"临西县"},{"value":"130581","label":"南宫市"},{"value":"130582","label":"沙河市"}]},{"value":"130600","label":"保定市","children":[{"value":"130602","label":"新市区"},{"value":"130603","label":"北市区"},{"value":"130604","label":"南市区"},{"value":"130621","label":"满城县"},{"value":"130622","label":"清苑县"},{"value":"130623","label":"涞水县"},{"value":"130624","label":"阜平县"},{"value":"130625","label":"徐水县"},{"value":"130626","label":"定兴县"},{"value":"130627","label":"唐县"},{"value":"130628","label":"高阳县"},{"value":"130629","label":"容城县"},{"value":"130630","label":"涞源县"},{"value":"130631","label":"望都县"},{"value":"130632","label":"安新县"},{"value":"130633","label":"易县"},{"value":"130634","label":"曲阳县"},{"value":"130635","label":"蠡县"},{"value":"130636","label":"顺平县"},{"value":"130637","label":"博野县"},{"value":"130638","label":"雄县"},{"value":"130681","label":"涿州市"},{"value":"130682","label":"定州市"},{"value":"130683","label":"安国市"},{"value":"130684","label":"高碑店市"},{"value":"130698","label":"高开区"}]},{"value":"130700","label":"张家口市","children":[{"value":"130702","label":"桥东区"},{"value":"130703","label":"桥西区"},{"value":"130705","label":"宣化区"},{"value":"130706","label":"下花园区"},{"value":"130721","label":"宣化县"},{"value":"130722","label":"张北县"},{"value":"130723","label":"康保县"},{"value":"130724","label":"沽源县"},{"value":"130725","label":"尚义县"},{"value":"130726","label":"蔚县"},{"value":"130727","label":"阳原县"},{"value":"130728","label":"怀安县"},{"value":"130729","label":"万全县"},{"value":"130730","label":"怀来县"},{"value":"130731","label":"涿鹿县"},{"value":"130732","label":"赤城县"},{"value":"130733","label":"崇礼县"}]},{"value":"130800","label":"承德市","children":[{"value":"130802","label":"双桥区"},{"value":"130803","label":"双滦区"},{"value":"130804","label":"鹰手营子矿区"},{"value":"130821","label":"承德县"},{"value":"130822","label":"兴隆县"},{"value":"130823","label":"平泉县"},{"value":"130824","label":"滦平县"},{"value":"130825","label":"隆化县"},{"value":"130826","label":"丰宁满族自治县"},{"value":"130827","label":"宽城满族自治县"},{"value":"130828","label":"围场满族蒙古族自治县"}]},{"value":"130900","label":"沧州市","children":[{"value":"130902","label":"新华区"},{"value":"130903","label":"运河区"},{"value":"130921","label":"沧县"},{"value":"130922","label":"青县"},{"value":"130923","label":"东光县"},{"value":"130924","label":"海兴县"},{"value":"130925","label":"盐山县"},{"value":"130926","label":"肃宁县"},{"value":"130927","label":"南皮县"},{"value":"130928","label":"吴桥县"},{"value":"130929","label":"献县"},{"value":"130930","label":"孟村回族自治县"},{"value":"130981","label":"泊头市"},{"value":"130982","label":"任丘市"},{"value":"130983","label":"黄骅市"},{"value":"130984","label":"河间市"}]},{"value":"131000","label":"廊坊市","children":[{"value":"131002","label":"安次区"},{"value":"131003","label":"广阳区"},{"value":"131022","label":"固安县"},{"value":"131023","label":"永清县"},{"value":"131024","label":"香河县"},{"value":"131025","label":"大城县"},{"value":"131026","label":"文安县"},{"value":"131028","label":"大厂回族自治县"},{"value":"131051","label":"开发区"},{"value":"131052","label":"燕郊经济技术开发区"},{"value":"131081","label":"霸州市"},{"value":"131082","label":"三河市"}]},{"value":"131100","label":"衡水市","children":[{"value":"131102","label":"桃城区"},{"value":"131121","label":"枣强县"},{"value":"131122","label":"武邑县"},{"value":"131123","label":"武强县"},{"value":"131124","label":"饶阳县"},{"value":"131125","label":"安平县"},{"value":"131126","label":"故城县"},{"value":"131127","label":"景县"},{"value":"131128","label":"阜城县"},{"value":"131181","label":"冀州市"},{"value":"131182","label":"深州市"}]}]},{"label":"山西省","value":"140000","children":[{"value":"140100","label":"太原市","children":[{"value":"140105","label":"小店区"},{"value":"140106","label":"迎泽区"},{"value":"140107","label":"杏花岭区"},{"value":"140108","label":"尖草坪区"},{"value":"140109","label":"万柏林区"},{"value":"140110","label":"晋源区"},{"value":"140121","label":"清徐县"},{"value":"140122","label":"阳曲县"},{"value":"140123","label":"娄烦县"},{"value":"140181","label":"古交市"}]},{"value":"140200","label":"大同市","children":[{"value":"140202","label":"城区"},{"value":"140203","label":"矿区"},{"value":"140211","label":"南郊区"},{"value":"140212","label":"新荣区"},{"value":"140221","label":"阳高县"},{"value":"140222","label":"天镇县"},{"value":"140223","label":"广灵县"},{"value":"140224","label":"灵丘县"},{"value":"140225","label":"浑源县"},{"value":"140226","label":"左云县"},{"value":"140227","label":"大同县"}]},{"value":"140300","label":"阳泉市","children":[{"value":"140302","label":"城区"},{"value":"140303","label":"矿区"},{"value":"140311","label":"郊区"},{"value":"140321","label":"平定县"},{"value":"140322","label":"盂县"}]},{"value":"140400","label":"长治市","children":[{"value":"140421","label":"长治县"},{"value":"140423","label":"襄垣县"},{"value":"140424","label":"屯留县"},{"value":"140425","label":"平顺县"},{"value":"140426","label":"黎城县"},{"value":"140427","label":"壶关县"},{"value":"140428","label":"长子县"},{"value":"140429","label":"武乡县"},{"value":"140430","label":"沁县"},{"value":"140431","label":"沁源县"},{"value":"140481","label":"潞城市"},{"value":"140482","label":"城区"},{"value":"140483","label":"郊区"},{"value":"140484","label":"高新区"}]},{"value":"140500","label":"晋城市","children":[{"value":"140502","label":"城区"},{"value":"140521","label":"沁水县"},{"value":"140522","label":"阳城县"},{"value":"140524","label":"陵川县"},{"value":"140525","label":"泽州县"},{"value":"140581","label":"高平市"}]},{"value":"140600","label":"朔州市","children":[{"value":"140602","label":"朔城区"},{"value":"140603","label":"平鲁区"},{"value":"140621","label":"山阴县"},{"value":"140622","label":"应县"},{"value":"140623","label":"右玉县"},{"value":"140624","label":"怀仁县"}]},{"value":"140700","label":"晋中市","children":[{"value":"140702","label":"榆次区"},{"value":"140721","label":"榆社县"},{"value":"140722","label":"左权县"},{"value":"140723","label":"和顺县"},{"value":"140724","label":"昔阳县"},{"value":"140725","label":"寿阳县"},{"value":"140726","label":"太谷县"},{"value":"140727","label":"祁县"},{"value":"140728","label":"平遥县"},{"value":"140729","label":"灵石县"},{"value":"140781","label":"介休市"}]},{"value":"140800","label":"运城市","children":[{"value":"140802","label":"盐湖区"},{"value":"140821","label":"临猗县"},{"value":"140822","label":"万荣县"},{"value":"140823","label":"闻喜县"},{"value":"140824","label":"稷山县"},{"value":"140825","label":"新绛县"},{"value":"140826","label":"绛县"},{"value":"140827","label":"垣曲县"},{"value":"140828","label":"夏县"},{"value":"140829","label":"平陆县"},{"value":"140830","label":"芮城县"},{"value":"140881","label":"永济市"},{"value":"140882","label":"河津市"}]},{"value":"140900","label":"忻州市","children":[{"value":"140902","label":"忻府区"},{"value":"140921","label":"定襄县"},{"value":"140922","label":"五台县"},{"value":"140923","label":"代县"},{"value":"140924","label":"繁峙县"},{"value":"140925","label":"宁武县"},{"value":"140926","label":"静乐县"},{"value":"140927","label":"神池县"},{"value":"140928","label":"五寨县"},{"value":"140929","label":"岢岚县"},{"value":"140930","label":"河曲县"},{"value":"140931","label":"保德县"},{"value":"140932","label":"偏关县"},{"value":"140981","label":"原平市"}]},{"value":"141000","label":"临汾市","children":[{"value":"141002","label":"尧都区"},{"value":"141021","label":"曲沃县"},{"value":"141022","label":"翼城县"},{"value":"141023","label":"襄汾县"},{"value":"141024","label":"洪洞县"},{"value":"141025","label":"古县"},{"value":"141026","label":"安泽县"},{"value":"141027","label":"浮山县"},{"value":"141028","label":"吉县"},{"value":"141029","label":"乡宁县"},{"value":"141030","label":"大宁县"},{"value":"141031","label":"隰县"},{"value":"141032","label":"永和县"},{"value":"141033","label":"蒲县"},{"value":"141034","label":"汾西县"},{"value":"141081","label":"侯马市"},{"value":"141082","label":"霍州市"}]},{"value":"141100","label":"吕梁市","children":[{"value":"141102","label":"离石区"},{"value":"141121","label":"文水县"},{"value":"141122","label":"交城县"},{"value":"141123","label":"兴县"},{"value":"141124","label":"临县"},{"value":"141125","label":"柳林县"},{"value":"141126","label":"石楼县"},{"value":"141127","label":"岚县"},{"value":"141128","label":"方山县"},{"value":"141129","label":"中阳县"},{"value":"141130","label":"交口县"},{"value":"141181","label":"孝义市"},{"value":"141182","label":"汾阳市"}]}]},{"label":"内蒙古自治区","value":"150000","children":[{"value":"150100","label":"呼和浩特市","children":[{"value":"150102","label":"新城区"},{"value":"150103","label":"回民区"},{"value":"150104","label":"玉泉区"},{"value":"150105","label":"赛罕区"},{"value":"150121","label":"土默特左旗"},{"value":"150122","label":"托克托县"},{"value":"150123","label":"和林格尔县"},{"value":"150124","label":"清水河县"},{"value":"150125","label":"武川县"}]},{"value":"150200","label":"包头市","children":[{"value":"150202","label":"东河区"},{"value":"150203","label":"昆都仑区"},{"value":"150204","label":"青山区"},{"value":"150205","label":"石拐区"},{"value":"150206","label":"白云矿区"},{"value":"150207","label":"九原区"},{"value":"150221","label":"土默特右旗"},{"value":"150222","label":"固阳县"},{"value":"150223","label":"达尔罕茂明安联合旗"}]},{"value":"150300","label":"乌海市","children":[{"value":"150302","label":"海勃湾区"},{"value":"150303","label":"海南区"},{"value":"150304","label":"乌达区"}]},{"value":"150400","label":"赤峰市","children":[{"value":"150402","label":"红山区"},{"value":"150403","label":"元宝山区"},{"value":"150404","label":"松山区"},{"value":"150421","label":"阿鲁科尔沁旗"},{"value":"150422","label":"巴林左旗"},{"value":"150423","label":"巴林右旗"},{"value":"150424","label":"林西县"},{"value":"150425","label":"克什克腾旗"},{"value":"150426","label":"翁牛特旗"},{"value":"150428","label":"喀喇沁旗"},{"value":"150429","label":"宁城县"},{"value":"150430","label":"敖汉旗"}]},{"value":"150500","label":"通辽市","children":[{"value":"150502","label":"科尔沁区"},{"value":"150521","label":"科尔沁左翼中旗"},{"value":"150522","label":"科尔沁左翼后旗"},{"value":"150523","label":"开鲁县"},{"value":"150524","label":"库伦旗"},{"value":"150525","label":"奈曼旗"},{"value":"150526","label":"扎鲁特旗"},{"value":"150581","label":"霍林郭勒市"}]},{"value":"150600","label":"鄂尔多斯市","children":[{"value":"150602","label":"东胜区"},{"value":"150621","label":"达拉特旗"},{"value":"150622","label":"准格尔旗"},{"value":"150623","label":"鄂托克前旗"},{"value":"150624","label":"鄂托克旗"},{"value":"150625","label":"杭锦旗"},{"value":"150626","label":"乌审旗"},{"value":"150627","label":"伊金霍洛旗"}]},{"value":"150700","label":"呼伦贝尔市","children":[{"value":"150702","label":"海拉尔区"},{"value":"150721","label":"阿荣旗"},{"value":"150722","label":"莫力达瓦达斡尔族自治旗"},{"value":"150723","label":"鄂伦春自治旗"},{"value":"150724","label":"鄂温克族自治旗"},{"value":"150725","label":"陈巴尔虎旗"},{"value":"150726","label":"新巴尔虎左旗"},{"value":"150727","label":"新巴尔虎右旗"},{"value":"150781","label":"满洲里市"},{"value":"150782","label":"牙克石市"},{"value":"150783","label":"扎兰屯市"},{"value":"150784","label":"额尔古纳市"},{"value":"150785","label":"根河市"}]},{"value":"150800","label":"巴彦淖尔市","children":[{"value":"150802","label":"临河区"},{"value":"150821","label":"五原县"},{"value":"150822","label":"磴口县"},{"value":"150823","label":"乌拉特前旗"},{"value":"150824","label":"乌拉特中旗"},{"value":"150825","label":"乌拉特后旗"},{"value":"150826","label":"杭锦后旗"}]},{"value":"150900","label":"乌兰察布市","children":[{"value":"150902","label":"集宁区"},{"value":"150921","label":"卓资县"},{"value":"150922","label":"化德县"},{"value":"150923","label":"商都县"},{"value":"150924","label":"兴和县"},{"value":"150925","label":"凉城县"},{"value":"150926","label":"察哈尔右翼前旗"},{"value":"150927","label":"察哈尔右翼中旗"},{"value":"150928","label":"察哈尔右翼后旗"},{"value":"150929","label":"四子王旗"},{"value":"150981","label":"丰镇市"}]},{"value":"152200","label":"兴安盟","children":[{"value":"152201","label":"乌兰浩特市"},{"value":"152202","label":"阿尔山市"},{"value":"152221","label":"科尔沁右翼前旗"},{"value":"152222","label":"科尔沁右翼中旗"},{"value":"152223","label":"扎赉特旗"},{"value":"152224","label":"突泉县"}]},{"value":"152500","label":"锡林郭勒盟","children":[{"value":"152501","label":"二连浩特市"},{"value":"152502","label":"锡林浩特市"},{"value":"152522","label":"阿巴嘎旗"},{"value":"152523","label":"苏尼特左旗"},{"value":"152524","label":"苏尼特右旗"},{"value":"152525","label":"东乌珠穆沁旗"},{"value":"152526","label":"西乌珠穆沁旗"},{"value":"152527","label":"太仆寺旗"},{"value":"152528","label":"镶黄旗"},{"value":"152529","label":"正镶白旗"},{"value":"152530","label":"正蓝旗"},{"value":"152531","label":"多伦县"}]},{"value":"152900","label":"阿拉善盟","children":[{"value":"152921","label":"阿拉善左旗"},{"value":"152922","label":"阿拉善右旗"},{"value":"152923","label":"额济纳旗"}]}]},{"label":"辽宁省","value":"210000","children":[{"value":"210100","label":"沈阳市","children":[{"value":"210102","label":"和平区"},{"value":"210103","label":"沈河区"},{"value":"210104","label":"大东区"},{"value":"210105","label":"皇姑区"},{"value":"210106","label":"铁西区"},{"value":"210111","label":"苏家屯区"},{"value":"210112","label":"东陵区"},{"value":"210113","label":"新城子区"},{"value":"210114","label":"于洪区"},{"value":"210122","label":"辽中县"},{"value":"210123","label":"康平县"},{"value":"210124","label":"法库县"},{"value":"210181","label":"新民市"},{"value":"210182","label":"浑南新区"},{"value":"210183","label":"张士开发区"},{"value":"210184","label":"沈北新区"}]},{"value":"210200","label":"大连市","children":[{"value":"210202","label":"中山区"},{"value":"210203","label":"西岗区"},{"value":"210204","label":"沙河口区"},{"value":"210211","label":"甘井子区"},{"value":"210212","label":"旅顺口区"},{"value":"210213","label":"金州区"},{"value":"210224","label":"长海县"},{"value":"210251","label":"开发区"},{"value":"210281","label":"瓦房店市"},{"value":"210282","label":"普兰店市"},{"value":"210283","label":"庄河市"},{"value":"210297","label":"岭前区"}]},{"value":"210300","label":"鞍山市","children":[{"value":"210302","label":"铁东区"},{"value":"210303","label":"铁西区"},{"value":"210304","label":"立山区"},{"value":"210311","label":"千山区"},{"value":"210321","label":"台安县"},{"value":"210323","label":"岫岩满族自治县"},{"value":"210351","label":"高新区"},{"value":"210381","label":"海城市"}]},{"value":"210400","label":"抚顺市","children":[{"value":"210402","label":"新抚区"},{"value":"210403","label":"东洲区"},{"value":"210404","label":"望花区"},{"value":"210411","label":"顺城区"},{"value":"210421","label":"抚顺县"},{"value":"210422","label":"新宾满族自治县"},{"value":"210423","label":"清原满族自治县"}]},{"value":"210500","label":"本溪市","children":[{"value":"210502","label":"平山区"},{"value":"210503","label":"溪湖区"},{"value":"210504","label":"明山区"},{"value":"210505","label":"南芬区"},{"value":"210521","label":"本溪满族自治县"},{"value":"210522","label":"桓仁满族自治县"}]},{"value":"210600","label":"丹东市","children":[{"value":"210602","label":"元宝区"},{"value":"210603","label":"振兴区"},{"value":"210604","label":"振安区"},{"value":"210624","label":"宽甸满族自治县"},{"value":"210681","label":"东港市"},{"value":"210682","label":"凤城市"}]},{"value":"210700","label":"锦州市","children":[{"value":"210702","label":"古塔区"},{"value":"210703","label":"凌河区"},{"value":"210711","label":"太和区"},{"value":"210726","label":"黑山县"},{"value":"210727","label":"义县"},{"value":"210781","label":"凌海市"},{"value":"210782","label":"北镇市"}]},{"value":"210800","label":"营口市","children":[{"value":"210802","label":"站前区"},{"value":"210803","label":"西市区"},{"value":"210804","label":"鲅鱼圈区"},{"value":"210811","label":"老边区"},{"value":"210881","label":"盖州市"},{"value":"210882","label":"大石桥市"}]},{"value":"210900","label":"阜新市","children":[{"value":"210902","label":"海州区"},{"value":"210903","label":"新邱区"},{"value":"210904","label":"太平区"},{"value":"210905","label":"清河门区"},{"value":"210911","label":"细河区"},{"value":"210921","label":"阜新蒙古族自治县"},{"value":"210922","label":"彰武县"}]},{"value":"211000","label":"辽阳市","children":[{"value":"211002","label":"白塔区"},{"value":"211003","label":"文圣区"},{"value":"211004","label":"宏伟区"},{"value":"211005","label":"弓长岭区"},{"value":"211011","label":"太子河区"},{"value":"211021","label":"辽阳县"},{"value":"211081","label":"灯塔市"}]},{"value":"211100","label":"盘锦市","children":[{"value":"211102","label":"双台子区"},{"value":"211103","label":"兴隆台区"},{"value":"211121","label":"大洼县"},{"value":"211122","label":"盘山县"}]},{"value":"211200","label":"铁岭市","children":[{"value":"211202","label":"银州区"},{"value":"211204","label":"清河区"},{"value":"211221","label":"铁岭县"},{"value":"211223","label":"西丰县"},{"value":"211224","label":"昌图县"},{"value":"211281","label":"调兵山市"},{"value":"211282","label":"开原市"}]},{"value":"211300","label":"朝阳市","children":[{"value":"211302","label":"双塔区"},{"value":"211303","label":"龙城区"},{"value":"211321","label":"朝阳县"},{"value":"211322","label":"建平县"},{"value":"211324","label":"喀喇沁左翼蒙古族自治县"},{"value":"211381","label":"北票市"},{"value":"211382","label":"凌源市"}]},{"value":"211400","label":"葫芦岛市","children":[{"value":"211402","label":"连山区"},{"value":"211403","label":"龙港区"},{"value":"211404","label":"南票区"},{"value":"211421","label":"绥中县"},{"value":"211422","label":"建昌县"},{"value":"211481","label":"兴城市"}]}]},{"label":"吉林省","value":"220000","children":[{"value":"220100","label":"长春市","children":[{"value":"220102","label":"南关区"},{"value":"220103","label":"宽城区"},{"value":"220104","label":"朝阳区"},{"value":"220105","label":"二道区"},{"value":"220106","label":"绿园区"},{"value":"220112","label":"双阳区"},{"value":"220122","label":"农安县"},{"value":"220181","label":"九台市"},{"value":"220182","label":"榆树市"},{"value":"220183","label":"德惠市"},{"value":"220184","label":"高新技术产业开发区"},{"value":"220185","label":"汽车产业开发区"},{"value":"220186","label":"经济技术开发区"},{"value":"220187","label":"净月旅游开发区"}]},{"value":"220200","label":"吉林市","children":[{"value":"220202","label":"昌邑区"},{"value":"220203","label":"龙潭区"},{"value":"220204","label":"船营区"},{"value":"220211","label":"丰满区"},{"value":"220221","label":"永吉县"},{"value":"220281","label":"蛟河市"},{"value":"220282","label":"桦甸市"},{"value":"220283","label":"舒兰市"},{"value":"220284","label":"磐石市"}]},{"value":"220300","label":"四平市","children":[{"value":"220302","label":"铁西区"},{"value":"220303","label":"铁东区"},{"value":"220322","label":"梨树县"},{"value":"220323","label":"伊通满族自治县"},{"value":"220381","label":"公主岭市"},{"value":"220382","label":"双辽市"}]},{"value":"220400","label":"辽源市","children":[{"value":"220402","label":"龙山区"},{"value":"220403","label":"西安区"},{"value":"220421","label":"东丰县"},{"value":"220422","label":"东辽县"}]},{"value":"220500","label":"通化市","children":[{"value":"220502","label":"东昌区"},{"value":"220503","label":"二道江区"},{"value":"220521","label":"通化县"},{"value":"220523","label":"辉南县"},{"value":"220524","label":"柳河县"},{"value":"220581","label":"梅河口市"},{"value":"220582","label":"集安市"}]},{"value":"220600","label":"白山市","children":[{"value":"220602","label":"八道江区"},{"value":"220621","label":"抚松县"},{"value":"220622","label":"靖宇县"},{"value":"220623","label":"长白朝鲜族自治县"},{"value":"220625","label":"江源市"},{"value":"220681","label":"临江市"}]},{"value":"220700","label":"松原市","children":[{"value":"220702","label":"宁江区"},{"value":"220721","label":"前郭尔罗斯蒙古族自治县"},{"value":"220722","label":"长岭县"},{"value":"220723","label":"乾安县"},{"value":"220724","label":"扶余县"}]},{"value":"220800","label":"白城市","children":[{"value":"220802","label":"洮北区"},{"value":"220821","label":"镇赉县"},{"value":"220822","label":"通榆县"},{"value":"220881","label":"洮南市"},{"value":"220882","label":"大安市"}]},{"value":"222400","label":"延边朝鲜族自治州","children":[{"value":"222401","label":"延吉市"},{"value":"222402","label":"图们市"},{"value":"222403","label":"敦化市"},{"value":"222404","label":"珲春市"},{"value":"222405","label":"龙井市"},{"value":"222406","label":"和龙市"},{"value":"222424","label":"汪清县"},{"value":"222426","label":"安图县"}]}]},{"label":"黑龙江省","value":"230000","children":[{"value":"230100","label":"哈尔滨市","children":[{"value":"230102","label":"道里区"},{"value":"230103","label":"南岗区"},{"value":"230104","label":"道外区"},{"value":"230106","label":"香坊区"},{"value":"230107","label":"动力区"},{"value":"230108","label":"平房区"},{"value":"230109","label":"松北区"},{"value":"230111","label":"呼兰区"},{"value":"230123","label":"依兰县"},{"value":"230124","label":"方正县"},{"value":"230125","label":"宾县"},{"value":"230126","label":"巴彦县"},{"value":"230127","label":"木兰县"},{"value":"230128","label":"通河县"},{"value":"230129","label":"延寿县"},{"value":"230181","label":"阿城市"},{"value":"230182","label":"双城市"},{"value":"230183","label":"尚志市"},{"value":"230184","label":"五常市"},{"value":"230185","label":"阿城市"}]},{"value":"230200","label":"齐齐哈尔市","children":[{"value":"230202","label":"龙沙区"},{"value":"230203","label":"建华区"},{"value":"230204","label":"铁锋区"},{"value":"230205","label":"昂昂溪区"},{"value":"230206","label":"富拉尔基区"},{"value":"230207","label":"碾子山区"},{"value":"230208","label":"梅里斯达斡尔族区"},{"value":"230221","label":"龙江县"},{"value":"230223","label":"依安县"},{"value":"230224","label":"泰来县"},{"value":"230225","label":"甘南县"},{"value":"230227","label":"富裕县"},{"value":"230229","label":"克山县"},{"value":"230230","label":"克东县"},{"value":"230231","label":"拜泉县"},{"value":"230281","label":"讷河市"}]},{"value":"230300","label":"鸡西市","children":[{"value":"230302","label":"鸡冠区"},{"value":"230303","label":"恒山区"},{"value":"230304","label":"滴道区"},{"value":"230305","label":"梨树区"},{"value":"230306","label":"城子河区"},{"value":"230307","label":"麻山区"},{"value":"230321","label":"鸡东县"},{"value":"230381","label":"虎林市"},{"value":"230382","label":"密山市"}]},{"value":"230400","label":"鹤岗市","children":[{"value":"230402","label":"向阳区"},{"value":"230403","label":"工农区"},{"value":"230404","label":"南山区"},{"value":"230405","label":"兴安区"},{"value":"230406","label":"东山区"},{"value":"230407","label":"兴山区"},{"value":"230421","label":"萝北县"},{"value":"230422","label":"绥滨县"}]},{"value":"230500","label":"双鸭山市","children":[{"value":"230502","label":"尖山区"},{"value":"230503","label":"岭东区"},{"value":"230505","label":"四方台区"},{"value":"230506","label":"宝山区"},{"value":"230521","label":"集贤县"},{"value":"230522","label":"友谊县"},{"value":"230523","label":"宝清县"},{"value":"230524","label":"饶河县"}]},{"value":"230600","label":"大庆市","children":[{"value":"230602","label":"萨尔图区"},{"value":"230603","label":"龙凤区"},{"value":"230604","label":"让胡路区"},{"value":"230605","label":"红岗区"},{"value":"230606","label":"大同区"},{"value":"230621","label":"肇州县"},{"value":"230622","label":"肇源县"},{"value":"230623","label":"林甸县"},{"value":"230624","label":"杜尔伯特蒙古族自治县"}]},{"value":"230700","label":"伊春市","children":[{"value":"230702","label":"伊春区"},{"value":"230703","label":"南岔区"},{"value":"230704","label":"友好区"},{"value":"230705","label":"西林区"},{"value":"230706","label":"翠峦区"},{"value":"230707","label":"新青区"},{"value":"230708","label":"美溪区"},{"value":"230709","label":"金山屯区"},{"value":"230710","label":"五营区"},{"value":"230711","label":"乌马河区"},{"value":"230712","label":"汤旺河区"},{"value":"230713","label":"带岭区"},{"value":"230714","label":"乌伊岭区"},{"value":"230715","label":"红星区"},{"value":"230716","label":"上甘岭区"},{"value":"230722","label":"嘉荫县"},{"value":"230781","label":"铁力市"}]},{"value":"230800","label":"佳木斯市","children":[{"value":"230802","label":"永红区"},{"value":"230803","label":"向阳区"},{"value":"230804","label":"前进区"},{"value":"230805","label":"东风区"},{"value":"230811","label":"郊区"},{"value":"230822","label":"桦南县"},{"value":"230826","label":"桦川县"},{"value":"230828","label":"汤原县"},{"value":"230833","label":"抚远县"},{"value":"230881","label":"同江市"},{"value":"230882","label":"富锦市"}]},{"value":"230900","label":"七台河市","children":[{"value":"230902","label":"新兴区"},{"value":"230903","label":"桃山区"},{"value":"230904","label":"茄子河区"},{"value":"230921","label":"勃利县"}]},{"value":"231000","label":"牡丹江市","children":[{"value":"231002","label":"东安区"},{"value":"231003","label":"阳明区"},{"value":"231004","label":"爱民区"},{"value":"231005","label":"西安区"},{"value":"231024","label":"东宁县"},{"value":"231025","label":"林口县"},{"value":"231081","label":"绥芬河市"},{"value":"231083","label":"海林市"},{"value":"231084","label":"宁安市"},{"value":"231085","label":"穆棱市"}]},{"value":"231100","label":"黑河市","children":[{"value":"231102","label":"爱辉区"},{"value":"231121","label":"嫩江县"},{"value":"231123","label":"逊克县"},{"value":"231124","label":"孙吴县"},{"value":"231181","label":"北安市"},{"value":"231182","label":"五大连池市"}]},{"value":"231200","label":"绥化市","children":[{"value":"231202","label":"北林区"},{"value":"231221","label":"望奎县"},{"value":"231222","label":"兰西县"},{"value":"231223","label":"青冈县"},{"value":"231224","label":"庆安县"},{"value":"231225","label":"明水县"},{"value":"231226","label":"绥棱县"},{"value":"231281","label":"安达市"},{"value":"231282","label":"肇东市"},{"value":"231283","label":"海伦市"}]},{"value":"232700","label":"大兴安岭地区","children":[{"value":"232721","label":"呼玛县"},{"value":"232722","label":"塔河县"},{"value":"232723","label":"漠河县"},{"value":"232724","label":"加格达奇区"}]}]},{"label":"上海","value":"310000","children":[{"value":"310100","label":"上海市","children":[{"value":"310101","label":"黄浦区"},{"value":"310103","label":"卢湾区"},{"value":"310104","label":"徐汇区"},{"value":"310105","label":"长宁区"},{"value":"310106","label":"静安区"},{"value":"310107","label":"普陀区"},{"value":"310108","label":"闸北区"},{"value":"310109","label":"虹口区"},{"value":"310110","label":"杨浦区"},{"value":"310112","label":"闵行区"},{"value":"310113","label":"宝山区"},{"value":"310114","label":"嘉定区"},{"value":"310115","label":"浦东新区"},{"value":"310116","label":"金山区"},{"value":"310117","label":"松江区"},{"value":"310118","label":"青浦区"},{"value":"310119","label":"南汇区"},{"value":"310120","label":"奉贤区"},{"value":"310152","label":"川沙区"},{"value":"310230","label":"崇明县"}]}]},{"label":"江苏省","value":"320000","children":[{"value":"320100","label":"南京市","children":[{"value":"320102","label":"玄武区"},{"value":"320103","label":"白下区"},{"value":"320104","label":"秦淮区"},{"value":"320105","label":"建邺区"},{"value":"320106","label":"鼓楼区"},{"value":"320107","label":"下关区"},{"value":"320111","label":"浦口区"},{"value":"320113","label":"栖霞区"},{"value":"320114","label":"雨花台区"},{"value":"320115","label":"江宁区"},{"value":"320116","label":"六合区"},{"value":"320124","label":"溧水县"},{"value":"320125","label":"高淳县"}]},{"value":"320200","label":"无锡市","children":[{"value":"320202","label":"崇安区"},{"value":"320203","label":"南长区"},{"value":"320204","label":"北塘区"},{"value":"320205","label":"锡山区"},{"value":"320206","label":"惠山区"},{"value":"320211","label":"滨湖区"},{"value":"320281","label":"江阴市"},{"value":"320282","label":"宜兴市"},{"value":"320296","label":"新区"}]},{"value":"320300","label":"徐州市","children":[{"value":"320302","label":"鼓楼区"},{"value":"320303","label":"云龙区"},{"value":"320304","label":"九里区"},{"value":"320305","label":"贾汪区"},{"value":"320311","label":"泉山区"},{"value":"320321","label":"丰县"},{"value":"320322","label":"沛县"},{"value":"320323","label":"铜山县"},{"value":"320324","label":"睢宁县"},{"value":"320381","label":"新沂市"},{"value":"320382","label":"邳州市"}]},{"value":"320400","label":"常州市","children":[{"value":"320402","label":"天宁区"},{"value":"320404","label":"钟楼区"},{"value":"320405","label":"戚墅堰区"},{"value":"320411","label":"新北区"},{"value":"320412","label":"武进区"},{"value":"320481","label":"溧阳市"},{"value":"320482","label":"金坛市"}]},{"value":"320500","label":"苏州市","children":[{"value":"320502","label":"沧浪区"},{"value":"320503","label":"平江区"},{"value":"320504","label":"金阊区"},{"value":"320505","label":"虎丘区"},{"value":"320506","label":"吴中区"},{"value":"320507","label":"相城区"},{"value":"320581","label":"常熟市"},{"value":"320582","label":"张家港市"},{"value":"320583","label":"昆山市"},{"value":"320584","label":"吴江市"},{"value":"320585","label":"太仓市"},{"value":"320594","label":"新区"},{"value":"320595","label":"园区"}]},{"value":"320600","label":"南通市","children":[{"value":"320602","label":"崇川区"},{"value":"320611","label":"港闸区"},{"value":"320612","label":"通州区"},{"value":"320621","label":"海安县"},{"value":"320623","label":"如东县"},{"value":"320681","label":"启东市"},{"value":"320682","label":"如皋市"},{"value":"320683","label":"通州市"},{"value":"320684","label":"海门市"},{"value":"320693","label":"开发区"}]},{"value":"320700","label":"连云港市","children":[{"value":"320703","label":"连云区"},{"value":"320705","label":"新浦区"},{"value":"320706","label":"海州区"},{"value":"320721","label":"赣榆县"},{"value":"320722","label":"东海县"},{"value":"320723","label":"灌云县"},{"value":"320724","label":"灌南县"}]},{"value":"320800","label":"淮安市","children":[{"value":"320802","label":"清河区"},{"value":"320803","label":"楚州区"},{"value":"320804","label":"淮阴区"},{"value":"320811","label":"清浦区"},{"value":"320826","label":"涟水县"},{"value":"320829","label":"洪泽县"},{"value":"320830","label":"盱眙县"},{"value":"320831","label":"金湖县"}]},{"value":"320900","label":"盐城市","children":[{"value":"320902","label":"亭湖区"},{"value":"320903","label":"盐都区"},{"value":"320921","label":"响水县"},{"value":"320922","label":"滨海县"},{"value":"320923","label":"阜宁县"},{"value":"320924","label":"射阳县"},{"value":"320925","label":"建湖县"},{"value":"320981","label":"东台市"},{"value":"320982","label":"大丰市"}]},{"value":"321000","label":"扬州市","children":[{"value":"321002","label":"广陵区"},{"value":"321003","label":"邗江区"},{"value":"321011","label":"维扬区"},{"value":"321023","label":"宝应县"},{"value":"321081","label":"仪征市"},{"value":"321084","label":"高邮市"},{"value":"321088","label":"江都市"},{"value":"321092","label":"经济开发区"}]},{"value":"321100","label":"镇江市","children":[{"value":"321102","label":"京口区"},{"value":"321111","label":"润州区"},{"value":"321112","label":"丹徒区"},{"value":"321181","label":"丹阳市"},{"value":"321182","label":"扬中市"},{"value":"321183","label":"句容市"}]},{"value":"321200","label":"泰州市","children":[{"value":"321202","label":"海陵区"},{"value":"321203","label":"高港区"},{"value":"321281","label":"兴化市"},{"value":"321282","label":"靖江市"},{"value":"321283","label":"泰兴市"},{"value":"321284","label":"姜堰市"}]},{"value":"321300","label":"宿迁市","children":[{"value":"321302","label":"宿城区"},{"value":"321311","label":"宿豫区"},{"value":"321322","label":"沭阳县"},{"value":"321323","label":"泗阳县"},{"value":"321324","label":"泗洪县"}]}]},{"label":"浙江省","value":"330000","children":[{"value":"330100","label":"杭州市","children":[{"value":"330102","label":"上城区"},{"value":"330103","label":"下城区"},{"value":"330104","label":"江干区"},{"value":"330105","label":"拱墅区"},{"value":"330106","label":"西湖区"},{"value":"330108","label":"滨江区"},{"value":"330109","label":"萧山区"},{"value":"330110","label":"余杭区"},{"value":"330122","label":"桐庐县"},{"value":"330127","label":"淳安县"},{"value":"330182","label":"建德市"},{"value":"330183","label":"富阳市"},{"value":"330185","label":"临安市"}]},{"value":"330200","label":"宁波市","children":[{"value":"330203","label":"海曙区"},{"value":"330204","label":"江东区"},{"value":"330205","label":"江北区"},{"value":"330206","label":"北仑区"},{"value":"330211","label":"镇海区"},{"value":"330212","label":"鄞州区"},{"value":"330225","label":"象山县"},{"value":"330226","label":"宁海县"},{"value":"330281","label":"余姚市"},{"value":"330282","label":"慈溪市"},{"value":"330283","label":"奉化市"}]},{"value":"330300","label":"温州市","children":[{"value":"330302","label":"鹿城区"},{"value":"330303","label":"龙湾区"},{"value":"330304","label":"瓯海区"},{"value":"330322","label":"洞头县"},{"value":"330324","label":"永嘉县"},{"value":"330326","label":"平阳县"},{"value":"330327","label":"苍南县"},{"value":"330328","label":"文成县"},{"value":"330329","label":"泰顺县"},{"value":"330381","label":"瑞安市"},{"value":"330382","label":"乐清市"}]},{"value":"330400","label":"嘉兴市","children":[{"value":"330402","label":"南湖区"},{"value":"330411","label":"秀洲区"},{"value":"330421","label":"嘉善县"},{"value":"330424","label":"海盐县"},{"value":"330481","label":"海宁市"},{"value":"330482","label":"平湖市"},{"value":"330483","label":"桐乡市"}]},{"value":"330500","label":"湖州市","children":[{"value":"330502","label":"吴兴区"},{"value":"330503","label":"南浔区"},{"value":"330521","label":"德清县"},{"value":"330522","label":"长兴县"},{"value":"330523","label":"安吉县"}]},{"value":"330600","label":"绍兴市","children":[{"value":"330602","label":"越城区"},{"value":"330621","label":"绍兴县"},{"value":"330624","label":"新昌县"},{"value":"330681","label":"诸暨市"},{"value":"330682","label":"上虞市"},{"value":"330683","label":"嵊州市"}]},{"value":"330700","label":"金华市","children":[{"value":"330702","label":"婺城区"},{"value":"330703","label":"金东区"},{"value":"330723","label":"武义县"},{"value":"330726","label":"浦江县"},{"value":"330727","label":"磐安县"},{"value":"330781","label":"兰溪市"},{"value":"330782","label":"义乌市"},{"value":"330783","label":"东阳市"},{"value":"330784","label":"永康市"}]},{"value":"330800","label":"衢州市","children":[{"value":"330802","label":"柯城区"},{"value":"330803","label":"衢江区"},{"value":"330822","label":"常山县"},{"value":"330824","label":"开化县"},{"value":"330825","label":"龙游县"},{"value":"330881","label":"江山市"}]},{"value":"330900","label":"舟山市","children":[{"value":"330902","label":"定海区"},{"value":"330903","label":"普陀区"},{"value":"330921","label":"岱山县"},{"value":"330922","label":"嵊泗县"}]},{"value":"331000","label":"台州市","children":[{"value":"331002","label":"椒江区"},{"value":"331003","label":"黄岩区"},{"value":"331004","label":"路桥区"},{"value":"331021","label":"玉环县"},{"value":"331022","label":"三门县"},{"value":"331023","label":"天台县"},{"value":"331024","label":"仙居县"},{"value":"331081","label":"温岭市"},{"value":"331082","label":"临海市"}]},{"value":"331100","label":"丽水市","children":[{"value":"331102","label":"莲都区"},{"value":"331121","label":"青田县"},{"value":"331122","label":"缙云县"},{"value":"331123","label":"遂昌县"},{"value":"331124","label":"松阳县"},{"value":"331125","label":"云和县"},{"value":"331126","label":"庆元县"},{"value":"331127","label":"景宁畲族自治县"},{"value":"331181","label":"龙泉市"}]}]},{"label":"安徽省","value":"340000","children":[{"value":"340100","label":"合肥市","children":[{"value":"340102","label":"瑶海区"},{"value":"340103","label":"庐阳区"},{"value":"340104","label":"蜀山区"},{"value":"340111","label":"包河区"},{"value":"340121","label":"长丰县"},{"value":"340122","label":"肥东县"},{"value":"340123","label":"肥西县"},{"value":"340151","label":"高新区"},{"value":"340191","label":"中区"},{"value":"341400","label":"巢湖市"},{"value":"341402","label":"居巢区"},{"value":"341421","label":"庐江县"}]},{"value":"340200","label":"芜湖市","children":[{"value":"340202","label":"镜湖区"},{"value":"340203","label":"弋江区"},{"value":"340207","label":"鸠江区"},{"value":"340208","label":"三山区"},{"value":"340221","label":"芜湖县"},{"value":"340222","label":"繁昌县"},{"value":"340223","label":"南陵县"},{"value":"341422","label":"无为县"}]},{"value":"340300","label":"蚌埠市","children":[{"value":"340302","label":"龙子湖区"},{"value":"340303","label":"蚌山区"},{"value":"340304","label":"禹会区"},{"value":"340311","label":"淮上区"},{"value":"340321","label":"怀远县"},{"value":"340322","label":"五河县"},{"value":"340323","label":"固镇县"}]},{"value":"340400","label":"淮南市","children":[{"value":"340402","label":"大通区"},{"value":"340403","label":"田家庵区"},{"value":"340404","label":"谢家集区"},{"value":"340405","label":"八公山区"},{"value":"340406","label":"潘集区"},{"value":"340421","label":"凤台县"}]},{"value":"340500","label":"马鞍山市","children":[{"value":"340502","label":"金家庄区"},{"value":"340503","label":"花山区"},{"value":"340504","label":"雨山区"},{"value":"340521","label":"当涂县"},{"value":"341423","label":"含山县"},{"value":"341424","label":"和县"}]},{"value":"340600","label":"淮北市","children":[{"value":"340602","label":"杜集区"},{"value":"340603","label":"相山区"},{"value":"340604","label":"烈山区"},{"value":"340621","label":"濉溪县"}]},{"value":"340700","label":"铜陵市","children":[{"value":"340702","label":"铜官山区"},{"value":"340703","label":"狮子山区"},{"value":"340711","label":"郊区"},{"value":"340721","label":"铜陵县"}]},{"value":"340800","label":"安庆市","children":[{"value":"340802","label":"迎江区"},{"value":"340803","label":"大观区"},{"value":"340811","label":"宜秀区"},{"value":"340822","label":"怀宁县"},{"value":"340823","label":"枞阳县"},{"value":"340824","label":"潜山县"},{"value":"340825","label":"太湖县"},{"value":"340826","label":"宿松县"},{"value":"340827","label":"望江县"},{"value":"340828","label":"岳西县"},{"value":"340881","label":"桐城市"}]},{"value":"341000","label":"黄山市","children":[{"value":"341002","label":"屯溪区"},{"value":"341003","label":"黄山区"},{"value":"341004","label":"徽州区"},{"value":"341021","label":"歙县"},{"value":"341022","label":"休宁县"},{"value":"341023","label":"黟县"},{"value":"341024","label":"祁门县"}]},{"value":"341100","label":"滁州市","children":[{"value":"341102","label":"琅琊区"},{"value":"341103","label":"南谯区"},{"value":"341122","label":"来安县"},{"value":"341124","label":"全椒县"},{"value":"341125","label":"定远县"},{"value":"341126","label":"凤阳县"},{"value":"341181","label":"天长市"},{"value":"341182","label":"明光市"}]},{"value":"341200","label":"阜阳市","children":[{"value":"341202","label":"颍州区"},{"value":"341203","label":"颍东区"},{"value":"341204","label":"颍泉区"},{"value":"341221","label":"临泉县"},{"value":"341222","label":"太和县"},{"value":"341225","label":"阜南县"},{"value":"341226","label":"颍上县"},{"value":"341282","label":"界首市"}]},{"value":"341300","label":"宿州市","children":[{"value":"341302","label":"埇桥区"},{"value":"341321","label":"砀山县"},{"value":"341322","label":"萧县"},{"value":"341323","label":"灵璧县"},{"value":"341324","label":"泗县"}]},{"value":"341500","label":"六安市","children":[{"value":"341502","label":"金安区"},{"value":"341503","label":"裕安区"},{"value":"341521","label":"寿县"},{"value":"341522","label":"霍邱县"},{"value":"341523","label":"舒城县"},{"value":"341524","label":"金寨县"},{"value":"341525","label":"霍山县"}]},{"value":"341600","label":"亳州市","children":[{"value":"341602","label":"谯城区"},{"value":"341621","label":"涡阳县"},{"value":"341622","label":"蒙城县"},{"value":"341623","label":"利辛县"}]},{"value":"341700","label":"池州市","children":[{"value":"341702","label":"贵池区"},{"value":"341721","label":"东至县"},{"value":"341722","label":"石台县"},{"value":"341723","label":"青阳县"}]},{"value":"341800","label":"宣城市","children":[{"value":"341802","label":"宣州区"},{"value":"341821","label":"郎溪县"},{"value":"341822","label":"广德县"},{"value":"341823","label":"泾县"},{"value":"341824","label":"绩溪县"},{"value":"341825","label":"旌德县"},{"value":"341881","label":"宁国市"}]}]},{"label":"福建省","value":"350000","children":[{"value":"350100","label":"福州市","children":[{"value":"350102","label":"鼓楼区"},{"value":"350103","label":"台江区"},{"value":"350104","label":"仓山区"},{"value":"350105","label":"马尾区"},{"value":"350111","label":"晋安区"},{"value":"350121","label":"闽侯县"},{"value":"350122","label":"连江县"},{"value":"350123","label":"罗源县"},{"value":"350124","label":"闽清县"},{"value":"350125","label":"永泰县"},{"value":"350128","label":"平潭县"},{"value":"350181","label":"福清市"},{"value":"350182","label":"长乐市"}]},{"value":"350200","label":"厦门市","children":[{"value":"350203","label":"思明区"},{"value":"350205","label":"海沧区"},{"value":"350206","label":"湖里区"},{"value":"350211","label":"集美区"},{"value":"350212","label":"同安区"},{"value":"350213","label":"翔安区"}]},{"value":"350300","label":"莆田市","children":[{"value":"350302","label":"城厢区"},{"value":"350303","label":"涵江区"},{"value":"350304","label":"荔城区"},{"value":"350305","label":"秀屿区"},{"value":"350322","label":"仙游县"}]},{"value":"350400","label":"三明市","children":[{"value":"350402","label":"梅列区"},{"value":"350403","label":"三元区"},{"value":"350421","label":"明溪县"},{"value":"350423","label":"清流县"},{"value":"350424","label":"宁化县"},{"value":"350425","label":"大田县"},{"value":"350426","label":"尤溪县"},{"value":"350427","label":"沙县"},{"value":"350428","label":"将乐县"},{"value":"350429","label":"泰宁县"},{"value":"350430","label":"建宁县"},{"value":"350481","label":"永安市"}]},{"value":"350500","label":"泉州市","children":[{"value":"350502","label":"鲤城区"},{"value":"350503","label":"丰泽区"},{"value":"350504","label":"洛江区"},{"value":"350505","label":"泉港区"},{"value":"350521","label":"惠安县"},{"value":"350524","label":"安溪县"},{"value":"350525","label":"永春县"},{"value":"350526","label":"德化县"},{"value":"350527","label":"金门县"},{"value":"350581","label":"石狮市"},{"value":"350582","label":"晋江市"},{"value":"350583","label":"南安市"}]},{"value":"350600","label":"漳州市","children":[{"value":"350602","label":"芗城区"},{"value":"350603","label":"龙文区"},{"value":"350622","label":"云霄县"},{"value":"350623","label":"漳浦县"},{"value":"350624","label":"诏安县"},{"value":"350625","label":"长泰县"},{"value":"350626","label":"东山县"},{"value":"350627","label":"南靖县"},{"value":"350628","label":"平和县"},{"value":"350629","label":"华安县"},{"value":"350681","label":"龙海市"}]},{"value":"350700","label":"南平市","children":[{"value":"350702","label":"延平区"},{"value":"350721","label":"顺昌县"},{"value":"350722","label":"浦城县"},{"value":"350723","label":"光泽县"},{"value":"350724","label":"松溪县"},{"value":"350725","label":"政和县"},{"value":"350781","label":"邵武市"},{"value":"350782","label":"武夷山市"},{"value":"350783","label":"建瓯市"},{"value":"350784","label":"建阳市"}]},{"value":"350800","label":"龙岩市","children":[{"value":"350802","label":"新罗区"},{"value":"350821","label":"长汀县"},{"value":"350822","label":"永定县"},{"value":"350823","label":"上杭县"},{"value":"350824","label":"武平县"},{"value":"350825","label":"连城县"},{"value":"350881","label":"漳平市"}]},{"value":"350900","label":"宁德市","children":[{"value":"350902","label":"蕉城区"},{"value":"350921","label":"霞浦县"},{"value":"350922","label":"古田县"},{"value":"350923","label":"屏南县"},{"value":"350924","label":"寿宁县"},{"value":"350925","label":"周宁县"},{"value":"350926","label":"柘荣县"},{"value":"350981","label":"福安市"},{"value":"350982","label":"福鼎市"}]}]},{"label":"江西省","value":"360000","children":[{"value":"360100","label":"南昌市","children":[{"value":"360102","label":"东湖区"},{"value":"360103","label":"西湖区"},{"value":"360104","label":"青云谱区"},{"value":"360105","label":"湾里区"},{"value":"360111","label":"青山湖区"},{"value":"360121","label":"南昌县"},{"value":"360122","label":"新建县"},{"value":"360123","label":"安义县"},{"value":"360124","label":"进贤县"},{"value":"360125","label":"红谷滩新区"},{"value":"360126","label":"经济技术开发区"},{"value":"360127","label":"昌北区"}]},{"value":"360200","label":"景德镇市","children":[{"value":"360202","label":"昌江区"},{"value":"360203","label":"珠山区"},{"value":"360222","label":"浮梁县"},{"value":"360281","label":"乐平市"}]},{"value":"360300","label":"萍乡市","children":[{"value":"360302","label":"安源区"},{"value":"360313","label":"湘东区"},{"value":"360321","label":"莲花县"},{"value":"360322","label":"上栗县"},{"value":"360323","label":"芦溪县"}]},{"value":"360400","label":"九江市","children":[{"value":"360402","label":"庐山区"},{"value":"360403","label":"浔阳区"},{"value":"360421","label":"九江县"},{"value":"360423","label":"武宁县"},{"value":"360424","label":"修水县"},{"value":"360425","label":"永修县"},{"value":"360426","label":"德安县"},{"value":"360427","label":"星子县"},{"value":"360428","label":"都昌县"},{"value":"360429","label":"湖口县"},{"value":"360430","label":"彭泽县"},{"value":"360481","label":"瑞昌市"}]},{"value":"360500","label":"新余市","children":[{"value":"360502","label":"渝水区"},{"value":"360521","label":"分宜县"}]},{"value":"360600","label":"鹰潭市","children":[{"value":"360602","label":"月湖区"},{"value":"360622","label":"余江县"},{"value":"360681","label":"贵溪市"}]},{"value":"360700","label":"赣州市","children":[{"value":"360702","label":"章贡区"},{"value":"360721","label":"赣县"},{"value":"360722","label":"信丰县"},{"value":"360723","label":"大余县"},{"value":"360724","label":"上犹县"},{"value":"360725","label":"崇义县"},{"value":"360726","label":"安远县"},{"value":"360727","label":"龙南县"},{"value":"360728","label":"定南县"},{"value":"360729","label":"全南县"},{"value":"360730","label":"宁都县"},{"value":"360731","label":"于都县"},{"value":"360732","label":"兴国县"},{"value":"360733","label":"会昌县"},{"value":"360734","label":"寻乌县"},{"value":"360735","label":"石城县"},{"value":"360751","label":"黄金区"},{"value":"360781","label":"瑞金市"},{"value":"360782","label":"南康市"}]},{"value":"360800","label":"吉安市","children":[{"value":"360802","label":"吉州区"},{"value":"360803","label":"青原区"},{"value":"360821","label":"吉安县"},{"value":"360822","label":"吉水县"},{"value":"360823","label":"峡江县"},{"value":"360824","label":"新干县"},{"value":"360825","label":"永丰县"},{"value":"360826","label":"泰和县"},{"value":"360827","label":"遂川县"},{"value":"360828","label":"万安县"},{"value":"360829","label":"安福县"},{"value":"360830","label":"永新县"},{"value":"360881","label":"井冈山市"}]},{"value":"360900","label":"宜春市","children":[{"value":"360902","label":"袁州区"},{"value":"360921","label":"奉新县"},{"value":"360922","label":"万载县"},{"value":"360923","label":"上高县"},{"value":"360924","label":"宜丰县"},{"value":"360925","label":"靖安县"},{"value":"360926","label":"铜鼓县"},{"value":"360981","label":"丰城市"},{"value":"360982","label":"樟树市"},{"value":"360983","label":"高安市"}]},{"value":"361000","label":"抚州市","children":[{"value":"361002","label":"临川区"},{"value":"361021","label":"南城县"},{"value":"361022","label":"黎川县"},{"value":"361023","label":"南丰县"},{"value":"361024","label":"崇仁县"},{"value":"361025","label":"乐安县"},{"value":"361026","label":"宜黄县"},{"value":"361027","label":"金溪县"},{"value":"361028","label":"资溪县"},{"value":"361029","label":"东乡县"},{"value":"361030","label":"广昌县"}]},{"value":"361100","label":"上饶市","children":[{"value":"361102","label":"信州区"},{"value":"361121","label":"上饶县"},{"value":"361122","label":"广丰县"},{"value":"361123","label":"玉山县"},{"value":"361124","label":"铅山县"},{"value":"361125","label":"横峰县"},{"value":"361126","label":"弋阳县"},{"value":"361127","label":"余干县"},{"value":"361128","label":"鄱阳县"},{"value":"361129","label":"万年县"},{"value":"361130","label":"婺源县"},{"value":"361181","label":"德兴市"}]}]},{"label":"山东省","value":"370000","children":[{"value":"370100","label":"济南市","children":[{"value":"370102","label":"历下区"},{"value":"370103","label":"市中区"},{"value":"370104","label":"槐荫区"},{"value":"370105","label":"天桥区"},{"value":"370112","label":"历城区"},{"value":"370113","label":"长清区"},{"value":"370124","label":"平阴县"},{"value":"370125","label":"济阳县"},{"value":"370126","label":"商河县"},{"value":"370181","label":"章丘市"}]},{"value":"370200","label":"青岛市","children":[{"value":"370202","label":"市南区"},{"value":"370203","label":"市北区"},{"value":"370205","label":"四方区"},{"value":"370211","label":"黄岛区"},{"value":"370212","label":"崂山区"},{"value":"370213","label":"李沧区"},{"value":"370214","label":"城阳区"},{"value":"370251","label":"开发区"},{"value":"370281","label":"胶州市"},{"value":"370282","label":"即墨市"},{"value":"370283","label":"平度市"},{"value":"370284","label":"胶南市"},{"value":"370285","label":"莱西市"}]},{"value":"370300","label":"淄博市","children":[{"value":"370302","label":"淄川区"},{"value":"370303","label":"张店区"},{"value":"370304","label":"博山区"},{"value":"370305","label":"临淄区"},{"value":"370306","label":"周村区"},{"value":"370321","label":"桓台县"},{"value":"370322","label":"高青县"},{"value":"370323","label":"沂源县"}]},{"value":"370400","label":"枣庄市","children":[{"value":"370402","label":"市中区"},{"value":"370403","label":"薛城区"},{"value":"370404","label":"峄城区"},{"value":"370405","label":"台儿庄区"},{"value":"370406","label":"山亭区"},{"value":"370481","label":"滕州市"}]},{"value":"370500","label":"东营市","children":[{"value":"370502","label":"东营区"},{"value":"370503","label":"河口区"},{"value":"370521","label":"垦利县"},{"value":"370522","label":"利津县"},{"value":"370523","label":"广饶县"},{"value":"370589","label":"西城区"},{"value":"370590","label":"东城区"}]},{"value":"370600","label":"烟台市","children":[{"value":"370602","label":"芝罘区"},{"value":"370611","label":"福山区"},{"value":"370612","label":"牟平区"},{"value":"370613","label":"莱山区"},{"value":"370634","label":"长岛县"},{"value":"370681","label":"龙口市"},{"value":"370682","label":"莱阳市"},{"value":"370683","label":"莱州市"},{"value":"370684","label":"蓬莱市"},{"value":"370685","label":"招远市"},{"value":"370686","label":"栖霞市"},{"value":"370687","label":"海阳市"}]},{"value":"370700","label":"潍坊市","children":[{"value":"370702","label":"潍城区"},{"value":"370703","label":"寒亭区"},{"value":"370704","label":"坊子区"},{"value":"370705","label":"奎文区"},{"value":"370724","label":"临朐县"},{"value":"370725","label":"昌乐县"},{"value":"370751","label":"开发区"},{"value":"370781","label":"青州市"},{"value":"370782","label":"诸城市"},{"value":"370783","label":"寿光市"},{"value":"370784","label":"安丘市"},{"value":"370785","label":"高密市"},{"value":"370786","label":"昌邑市"}]},{"value":"370800","label":"济宁市","children":[{"value":"370802","label":"市中区"},{"value":"370811","label":"任城区"},{"value":"370826","label":"微山县"},{"value":"370827","label":"鱼台县"},{"value":"370828","label":"金乡县"},{"value":"370829","label":"嘉祥县"},{"value":"370830","label":"汶上县"},{"value":"370831","label":"泗水县"},{"value":"370832","label":"梁山县"},{"value":"370881","label":"曲阜市"},{"value":"370882","label":"兖州市"},{"value":"370883","label":"邹城市"}]},{"value":"370900","label":"泰安市","children":[{"value":"370902","label":"泰山区"},{"value":"370903","label":"岱岳区"},{"value":"370921","label":"宁阳县"},{"value":"370923","label":"东平县"},{"value":"370982","label":"新泰市"},{"value":"370983","label":"肥城市"}]},{"value":"371000","label":"威海市","children":[{"value":"371002","label":"环翠区"},{"value":"371081","label":"文登市"},{"value":"371082","label":"荣成市"},{"value":"371083","label":"乳山市"}]},{"value":"371100","label":"日照市","children":[{"value":"371102","label":"东港区"},{"value":"371103","label":"岚山区"},{"value":"371121","label":"五莲县"},{"value":"371122","label":"莒县"}]},{"value":"371200","label":"莱芜市","children":[{"value":"371202","label":"莱城区"},{"value":"371203","label":"钢城区"}]},{"value":"371300","label":"临沂市","children":[{"value":"371302","label":"兰山区"},{"value":"371311","label":"罗庄区"},{"value":"371312","label":"河东区"},{"value":"371321","label":"沂南县"},{"value":"371322","label":"郯城县"},{"value":"371323","label":"沂水县"},{"value":"371324","label":"苍山县"},{"value":"371325","label":"费县"},{"value":"371326","label":"平邑县"},{"value":"371327","label":"莒南县"},{"value":"371328","label":"蒙阴县"},{"value":"371329","label":"临沭县"}]},{"value":"371400","label":"德州市","children":[{"value":"371402","label":"德城区"},{"value":"371421","label":"陵县"},{"value":"371422","label":"宁津县"},{"value":"371423","label":"庆云县"},{"value":"371424","label":"临邑县"},{"value":"371425","label":"齐河县"},{"value":"371426","label":"平原县"},{"value":"371427","label":"夏津县"},{"value":"371428","label":"武城县"},{"value":"371451","label":"开发区"},{"value":"371481","label":"乐陵市"},{"value":"371482","label":"禹城市"}]},{"value":"371500","label":"聊城市","children":[{"value":"371502","label":"东昌府区"},{"value":"371521","label":"阳谷县"},{"value":"371522","label":"莘县"},{"value":"371523","label":"茌平县"},{"value":"371524","label":"东阿县"},{"value":"371525","label":"冠县"},{"value":"371526","label":"高唐县"},{"value":"371581","label":"临清市"}]},{"value":"371600","label":"滨州市","children":[{"value":"371602","label":"滨城区"},{"value":"371621","label":"惠民县"},{"value":"371622","label":"阳信县"},{"value":"371623","label":"无棣县"},{"value":"371624","label":"沾化县"},{"value":"371625","label":"博兴县"},{"value":"371626","label":"邹平县"}]},{"value":"371700","label":"菏泽市","children":[{"value":"371702","label":"牡丹区"},{"value":"371721","label":"曹县"},{"value":"371722","label":"单县"},{"value":"371723","label":"成武县"},{"value":"371724","label":"巨野县"},{"value":"371725","label":"郓城县"},{"value":"371726","label":"鄄城县"},{"value":"371727","label":"定陶县"},{"value":"371728","label":"东明县"}]}]},{"label":"河南省","value":"410000","children":[{"value":"410100","label":"郑州市","children":[{"value":"410102","label":"中原区"},{"value":"410103","label":"二七区"},{"value":"410104","label":"管城回族区"},{"value":"410105","label":"金水区"},{"value":"410106","label":"上街区"},{"value":"410108","label":"惠济区"},{"value":"410122","label":"中牟县"},{"value":"410181","label":"巩义市"},{"value":"410182","label":"荥阳市"},{"value":"410183","label":"新密市"},{"value":"410184","label":"新郑市"},{"value":"410185","label":"登封市"},{"value":"410186","label":"郑东新区"},{"value":"410187","label":"高新区"}]},{"value":"410200","label":"开封市","children":[{"value":"410202","label":"龙亭区"},{"value":"410203","label":"顺河回族区"},{"value":"410204","label":"鼓楼区"},{"value":"410205","label":"禹王台区"},{"value":"410211","label":"金明区"},{"value":"410221","label":"杞县"},{"value":"410222","label":"通许县"},{"value":"410223","label":"尉氏县"},{"value":"410224","label":"开封县"},{"value":"410225","label":"兰考县"}]},{"value":"410300","label":"洛阳市","children":[{"value":"410302","label":"老城区"},{"value":"410303","label":"西工区"},{"value":"410304","label":"廛河回族区"},{"value":"410305","label":"涧西区"},{"value":"410306","label":"吉利区"},{"value":"410307","label":"洛龙区"},{"value":"410322","label":"孟津县"},{"value":"410323","label":"新安县"},{"value":"410324","label":"栾川县"},{"value":"410325","label":"嵩县"},{"value":"410326","label":"汝阳县"},{"value":"410327","label":"宜阳县"},{"value":"410328","label":"洛宁县"},{"value":"410329","label":"伊川县"},{"value":"410381","label":"偃师市"},{"value":"471004","label":"高新区"}]},{"value":"410400","label":"平顶山市","children":[{"value":"410402","label":"新华区"},{"value":"410403","label":"卫东区"},{"value":"410404","label":"石龙区"},{"value":"410411","label":"湛河区"},{"value":"410421","label":"宝丰县"},{"value":"410422","label":"叶县"},{"value":"410423","label":"鲁山县"},{"value":"410425","label":"郏县"},{"value":"410481","label":"舞钢市"},{"value":"410482","label":"汝州市"}]},{"value":"410500","label":"安阳市","children":[{"value":"410502","label":"文峰区"},{"value":"410503","label":"北关区"},{"value":"410505","label":"殷都区"},{"value":"410506","label":"龙安区"},{"value":"410522","label":"安阳县"},{"value":"410523","label":"汤阴县"},{"value":"410526","label":"滑县"},{"value":"410527","label":"内黄县"},{"value":"410581","label":"林州市"}]},{"value":"410600","label":"鹤壁市","children":[{"value":"410602","label":"鹤山区"},{"value":"410603","label":"山城区"},{"value":"410611","label":"淇滨区"},{"value":"410621","label":"浚县"},{"value":"410622","label":"淇县"}]},{"value":"410700","label":"新乡市","children":[{"value":"410702","label":"红旗区"},{"value":"410703","label":"卫滨区"},{"value":"410704","label":"凤泉区"},{"value":"410711","label":"牧野区"},{"value":"410721","label":"新乡县"},{"value":"410724","label":"获嘉县"},{"value":"410725","label":"原阳县"},{"value":"410726","label":"延津县"},{"value":"410727","label":"封丘县"},{"value":"410728","label":"长垣县"},{"value":"410781","label":"卫辉市"},{"value":"410782","label":"辉县市"}]},{"value":"410800","label":"焦作市","children":[{"value":"410802","label":"解放区"},{"value":"410803","label":"中站区"},{"value":"410804","label":"马村区"},{"value":"410811","label":"山阳区"},{"value":"410821","label":"修武县"},{"value":"410822","label":"博爱县"},{"value":"410823","label":"武陟县"},{"value":"410825","label":"温县"},{"value":"410882","label":"沁阳市"},{"value":"410883","label":"孟州市"}]},{"value":"410881","label":"济源市"},{"value":"410900","label":"濮阳市","children":[{"value":"410902","label":"华龙区"},{"value":"410922","label":"清丰县"},{"value":"410923","label":"南乐县"},{"value":"410926","label":"范县"},{"value":"410927","label":"台前县"},{"value":"410928","label":"濮阳县"}]},{"value":"411000","label":"许昌市","children":[{"value":"411002","label":"魏都区"},{"value":"411023","label":"许昌县"},{"value":"411024","label":"鄢陵县"},{"value":"411025","label":"襄城县"},{"value":"411081","label":"禹州市"},{"value":"411082","label":"长葛市"}]},{"value":"411100","label":"漯河市","children":[{"value":"411102","label":"源汇区"},{"value":"411103","label":"郾城区"},{"value":"411104","label":"召陵区"},{"value":"411121","label":"舞阳县"},{"value":"411122","label":"临颍县"}]},{"value":"411200","label":"三门峡市","children":[{"value":"411202","label":"湖滨区"},{"value":"411221","label":"渑池县"},{"value":"411222","label":"陕县"},{"value":"411224","label":"卢氏县"},{"value":"411281","label":"义马市"},{"value":"411282","label":"灵宝市"}]},{"value":"411300","label":"南阳市","children":[{"value":"411302","label":"宛城区"},{"value":"411303","label":"卧龙区"},{"value":"411321","label":"南召县"},{"value":"411322","label":"方城县"},{"value":"411323","label":"西峡县"},{"value":"411324","label":"镇平县"},{"value":"411325","label":"内乡县"},{"value":"411326","label":"淅川县"},{"value":"411327","label":"社旗县"},{"value":"411328","label":"唐河县"},{"value":"411329","label":"新野县"},{"value":"411330","label":"桐柏县"},{"value":"411381","label":"邓州市"}]},{"value":"411400","label":"商丘市","children":[{"value":"411402","label":"梁园区"},{"value":"411403","label":"睢阳区"},{"value":"411421","label":"民权县"},{"value":"411422","label":"睢县"},{"value":"411423","label":"宁陵县"},{"value":"411424","label":"柘城县"},{"value":"411425","label":"虞城县"},{"value":"411426","label":"夏邑县"},{"value":"411481","label":"永城市"}]},{"value":"411500","label":"信阳市","children":[{"value":"411502","label":"浉河区"},{"value":"411503","label":"平桥区"},{"value":"411521","label":"罗山县"},{"value":"411522","label":"光山县"},{"value":"411523","label":"新县"},{"value":"411524","label":"商城县"},{"value":"411525","label":"固始县"},{"value":"411526","label":"潢川县"},{"value":"411527","label":"淮滨县"},{"value":"411528","label":"息县"}]},{"value":"411600","label":"周口市","children":[{"value":"411602","label":"川汇区"},{"value":"411621","label":"扶沟县"},{"value":"411622","label":"西华县"},{"value":"411623","label":"商水县"},{"value":"411624","label":"沈丘县"},{"value":"411625","label":"郸城县"},{"value":"411626","label":"淮阳县"},{"value":"411627","label":"太康县"},{"value":"411628","label":"鹿邑县"},{"value":"411681","label":"项城市"}]},{"value":"411700","label":"驻马店市","children":[{"value":"411702","label":"驿城区"},{"value":"411721","label":"西平县"},{"value":"411722","label":"上蔡县"},{"value":"411723","label":"平舆县"},{"value":"411724","label":"正阳县"},{"value":"411725","label":"确山县"},{"value":"411726","label":"泌阳县"},{"value":"411727","label":"汝南县"},{"value":"411728","label":"遂平县"},{"value":"411729","label":"新蔡县"}]}]},{"label":"湖北省","value":"420000","children":[{"value":"420100","label":"武汉市","children":[{"value":"420102","label":"江岸区"},{"value":"420103","label":"江汉区"},{"value":"420104","label":"硚口区"},{"value":"420105","label":"汉阳区"},{"value":"420106","label":"武昌区"},{"value":"420107","label":"青山区"},{"value":"420111","label":"洪山区"},{"value":"420112","label":"东西湖区"},{"value":"420113","label":"汉南区"},{"value":"420114","label":"蔡甸区"},{"value":"420115","label":"江夏区"},{"value":"420116","label":"黄陂区"},{"value":"420117","label":"新洲区"}]},{"value":"420200","label":"黄石市","children":[{"value":"420202","label":"黄石港区"},{"value":"420203","label":"西塞山区"},{"value":"420204","label":"下陆区"},{"value":"420205","label":"铁山区"},{"value":"420222","label":"阳新县"},{"value":"420281","label":"大冶市"}]},{"value":"420300","label":"十堰市","children":[{"value":"420302","label":"茅箭区"},{"value":"420303","label":"张湾区"},{"value":"420321","label":"郧县"},{"value":"420322","label":"郧西县"},{"value":"420323","label":"竹山县"},{"value":"420324","label":"竹溪县"},{"value":"420325","label":"房县"},{"value":"420381","label":"丹江口市"},{"value":"420382","label":"城区"}]},{"value":"420500","label":"宜昌市","children":[{"value":"420502","label":"西陵区"},{"value":"420503","label":"伍家岗区"},{"value":"420504","label":"点军区"},{"value":"420505","label":"猇亭区"},{"value":"420506","label":"夷陵区"},{"value":"420525","label":"远安县"},{"value":"420526","label":"兴山县"},{"value":"420527","label":"秭归县"},{"value":"420528","label":"长阳土家族自治县"},{"value":"420529","label":"五峰土家族自治县"},{"value":"420551","label":"葛洲坝区"},{"value":"420552","label":"开发区"},{"value":"420581","label":"宜都市"},{"value":"420582","label":"当阳市"},{"value":"420583","label":"枝江市"}]},{"value":"420600","label":"襄阳市","children":[{"value":"420602","label":"襄城区"},{"value":"420606","label":"樊城区"},{"value":"420607","label":"襄州区"},{"value":"420624","label":"南漳县"},{"value":"420625","label":"谷城县"},{"value":"420626","label":"保康县"},{"value":"420682","label":"老河口市"},{"value":"420683","label":"枣阳市"},{"value":"420684","label":"宜城市"}]},{"value":"420700","label":"鄂州市","children":[{"value":"420702","label":"梁子湖区"},{"value":"420703","label":"华容区"},{"value":"420704","label":"鄂城区"}]},{"value":"420800","label":"荆门市","children":[{"value":"420802","label":"东宝区"},{"value":"420804","label":"掇刀区"},{"value":"420821","label":"京山县"},{"value":"420822","label":"沙洋县"},{"value":"420881","label":"钟祥市"}]},{"value":"420900","label":"孝感市","children":[{"value":"420902","label":"孝南区"},{"value":"420921","label":"孝昌县"},{"value":"420922","label":"大悟县"},{"value":"420923","label":"云梦县"},{"value":"420981","label":"应城市"},{"value":"420982","label":"安陆市"},{"value":"420984","label":"汉川市"}]},{"value":"421000","label":"荆州市","children":[{"value":"421002","label":"沙市区"},{"value":"421003","label":"荆州区"},{"value":"421022","label":"公安县"},{"value":"421023","label":"监利县"},{"value":"421024","label":"江陵县"},{"value":"421081","label":"石首市"},{"value":"421083","label":"洪湖市"},{"value":"421087","label":"松滋市"}]},{"value":"421100","label":"黄冈市","children":[{"value":"421102","label":"黄州区"},{"value":"421121","label":"团风县"},{"value":"421122","label":"红安县"},{"value":"421123","label":"罗田县"},{"value":"421124","label":"英山县"},{"value":"421125","label":"浠水县"},{"value":"421126","label":"蕲春县"},{"value":"421127","label":"黄梅县"},{"value":"421181","label":"麻城市"},{"value":"421182","label":"武穴市"}]},{"value":"421200","label":"咸宁市","children":[{"value":"421202","label":"咸安区"},{"value":"421221","label":"嘉鱼县"},{"value":"421222","label":"通城县"},{"value":"421223","label":"崇阳县"},{"value":"421224","label":"通山县"},{"value":"421281","label":"赤壁市"},{"value":"421282","label":"温泉城区"}]},{"value":"421300","label":"随州市","children":[{"value":"421302","label":"曾都区"},{"value":"421321","label":"随县"},{"value":"421381","label":"广水市"}]},{"value":"422800","label":"恩施土家族苗族自治州","children":[{"value":"422801","label":"恩施市"},{"value":"422802","label":"利川市"},{"value":"422822","label":"建始县"},{"value":"422823","label":"巴东县"},{"value":"422825","label":"宣恩县"},{"value":"422826","label":"咸丰县"},{"value":"422827","label":"来凤县"},{"value":"422828","label":"鹤峰县"}]},{"value":"429004","label":"仙桃市"},{"value":"429005","label":"潜江市"},{"value":"429006","label":"天门市"},{"value":"429021","label":"神农架林区"}]},{"label":"湖南省","value":"430000","children":[{"value":"430100","label":"长沙市","children":[{"value":"430102","label":"芙蓉区"},{"value":"430103","label":"天心区"},{"value":"430104","label":"岳麓区"},{"value":"430105","label":"开福区"},{"value":"430111","label":"雨花区"},{"value":"430121","label":"长沙县"},{"value":"430122","label":"望城县"},{"value":"430124","label":"宁乡县"},{"value":"430181","label":"浏阳市"}]},{"value":"430200","label":"株洲市","children":[{"value":"430202","label":"荷塘区"},{"value":"430203","label":"芦淞区"},{"value":"430204","label":"石峰区"},{"value":"430211","label":"天元区"},{"value":"430221","label":"株洲县"},{"value":"430223","label":"攸县"},{"value":"430224","label":"茶陵县"},{"value":"430225","label":"炎陵县"},{"value":"430281","label":"醴陵市"}]},{"value":"430300","label":"湘潭市","children":[{"value":"430302","label":"雨湖区"},{"value":"430304","label":"岳塘区"},{"value":"430321","label":"湘潭县"},{"value":"430381","label":"湘乡市"},{"value":"430382","label":"韶山市"}]},{"value":"430400","label":"衡阳市","children":[{"value":"430405","label":"珠晖区"},{"value":"430406","label":"雁峰区"},{"value":"430407","label":"石鼓区"},{"value":"430408","label":"蒸湘区"},{"value":"430412","label":"南岳区"},{"value":"430421","label":"衡阳县"},{"value":"430422","label":"衡南县"},{"value":"430423","label":"衡山县"},{"value":"430424","label":"衡东县"},{"value":"430426","label":"祁东县"},{"value":"430481","label":"耒阳市"},{"value":"430482","label":"常宁市"}]},{"value":"430500","label":"邵阳市","children":[{"value":"430502","label":"双清区"},{"value":"430503","label":"大祥区"},{"value":"430511","label":"北塔区"},{"value":"430521","label":"邵东县"},{"value":"430522","label":"新邵县"},{"value":"430523","label":"邵阳县"},{"value":"430524","label":"隆回县"},{"value":"430525","label":"洞口县"},{"value":"430527","label":"绥宁县"},{"value":"430528","label":"新宁县"},{"value":"430529","label":"城步苗族自治县"},{"value":"430581","label":"武冈市"}]},{"value":"430600","label":"岳阳市","children":[{"value":"430602","label":"岳阳楼区"},{"value":"430603","label":"云溪区"},{"value":"430611","label":"君山区"},{"value":"430621","label":"岳阳县"},{"value":"430623","label":"华容县"},{"value":"430624","label":"湘阴县"},{"value":"430626","label":"平江县"},{"value":"430681","label":"汨罗市"},{"value":"430682","label":"临湘市"}]},{"value":"430700","label":"常德市","children":[{"value":"430702","label":"武陵区"},{"value":"430703","label":"鼎城区"},{"value":"430721","label":"安乡县"},{"value":"430722","label":"汉寿县"},{"value":"430723","label":"澧县"},{"value":"430724","label":"临澧县"},{"value":"430725","label":"桃源县"},{"value":"430726","label":"石门县"},{"value":"430781","label":"津市市"}]},{"value":"430800","label":"张家界市","children":[{"value":"430802","label":"永定区"},{"value":"430811","label":"武陵源区"},{"value":"430821","label":"慈利县"},{"value":"430822","label":"桑植县"}]},{"value":"430900","label":"益阳市","children":[{"value":"430902","label":"资阳区"},{"value":"430903","label":"赫山区"},{"value":"430921","label":"南县"},{"value":"430922","label":"桃江县"},{"value":"430923","label":"安化县"},{"value":"430981","label":"沅江市"}]},{"value":"431000","label":"郴州市","children":[{"value":"431002","label":"北湖区"},{"value":"431003","label":"苏仙区"},{"value":"431021","label":"桂阳县"},{"value":"431022","label":"宜章县"},{"value":"431023","label":"永兴县"},{"value":"431024","label":"嘉禾县"},{"value":"431025","label":"临武县"},{"value":"431026","label":"汝城县"},{"value":"431027","label":"桂东县"},{"value":"431028","label":"安仁县"},{"value":"431081","label":"资兴市"}]},{"value":"431100","label":"永州市","children":[{"value":"431102","label":"零陵区"},{"value":"431103","label":"冷水滩区"},{"value":"431121","label":"祁阳县"},{"value":"431122","label":"东安县"},{"value":"431123","label":"双牌县"},{"value":"431124","label":"道县"},{"value":"431125","label":"江永县"},{"value":"431126","label":"宁远县"},{"value":"431127","label":"蓝山县"},{"value":"431128","label":"新田县"},{"value":"431129","label":"江华瑶族自治县"}]},{"value":"431200","label":"怀化市","children":[{"value":"431202","label":"鹤城区"},{"value":"431221","label":"中方县"},{"value":"431222","label":"沅陵县"},{"value":"431223","label":"辰溪县"},{"value":"431224","label":"溆浦县"},{"value":"431225","label":"会同县"},{"value":"431226","label":"麻阳苗族自治县"},{"value":"431227","label":"新晃侗族自治县"},{"value":"431228","label":"芷江侗族自治县"},{"value":"431229","label":"靖州苗族侗族自治县"},{"value":"431230","label":"通道侗族自治县"},{"value":"431281","label":"洪江市"}]},{"value":"431300","label":"娄底市","children":[{"value":"431302","label":"娄星区"},{"value":"431321","label":"双峰县"},{"value":"431322","label":"新化县"},{"value":"431381","label":"冷水江市"},{"value":"431382","label":"涟源市"}]},{"value":"433100","label":"湘西土家族苗族自治州","children":[{"value":"433101","label":"吉首市"},{"value":"433122","label":"泸溪县"},{"value":"433123","label":"凤凰县"},{"value":"433124","label":"花垣县"},{"value":"433125","label":"保靖县"},{"value":"433126","label":"古丈县"},{"value":"433127","label":"永顺县"},{"value":"433130","label":"龙山县"}]}]},{"label":"广东省","value":"440000","children":[{"value":"440100","label":"广州市","children":[{"value":"440103","label":"荔湾区"},{"value":"440104","label":"越秀区"},{"value":"440105","label":"海珠区"},{"value":"440106","label":"天河区"},{"value":"440111","label":"白云区"},{"value":"440112","label":"黄埔区"},{"value":"440113","label":"番禺区"},{"value":"440114","label":"花都区"},{"value":"440115","label":"南沙区"},{"value":"440116","label":"萝岗区"},{"value":"440183","label":"增城市"},{"value":"440184","label":"从化市"},{"value":"440188","label":"东山区"}]},{"value":"440200","label":"韶关市","children":[{"value":"440203","label":"武江区"},{"value":"440204","label":"浈江区"},{"value":"440205","label":"曲江区"},{"value":"440222","label":"始兴县"},{"value":"440224","label":"仁化县"},{"value":"440229","label":"翁源县"},{"value":"440232","label":"乳源瑶族自治县"},{"value":"440233","label":"新丰县"},{"value":"440281","label":"乐昌市"},{"value":"440282","label":"南雄市"}]},{"value":"440300","label":"深圳市","children":[{"value":"440303","label":"罗湖区"},{"value":"440304","label":"福田区"},{"value":"440305","label":"南山区"},{"value":"440306","label":"宝安区"},{"value":"440307","label":"龙岗区"},{"value":"440308","label":"盐田区"},{"value":"1032697","label":"光明新区"},{"value":"1032698","label":"坪山新区"},{"value":"1032699","label":"大鹏新区"},{"value":"1032700","label":"龙华新区"}]},{"value":"440400","label":"珠海市","children":[{"value":"440402","label":"香洲区"},{"value":"440403","label":"斗门区"},{"value":"440404","label":"金湾区"},{"value":"440486","label":"金唐区"},{"value":"440487","label":"南湾区"}]},{"value":"440500","label":"汕头市","children":[{"value":"440507","label":"龙湖区"},{"value":"440511","label":"金平区"},{"value":"440512","label":"濠江区"},{"value":"440513","label":"潮阳区"},{"value":"440514","label":"潮南区"},{"value":"440515","label":"澄海区"},{"value":"440523","label":"南澳县"}]},{"value":"440600","label":"佛山市","children":[{"value":"440604","label":"禅城区"},{"value":"440605","label":"南海区"},{"value":"440606","label":"顺德区"},{"value":"440607","label":"三水区"},{"value":"440608","label":"高明区"}]},{"value":"440700","label":"江门市","children":[{"value":"440703","label":"蓬江区"},{"value":"440704","label":"江海区"},{"value":"440705","label":"新会区"},{"value":"440781","label":"台山市"},{"value":"440783","label":"开平市"},{"value":"440784","label":"鹤山市"},{"value":"440785","label":"恩平市"}]},{"value":"440800","label":"湛江市","children":[{"value":"440802","label":"赤坎区"},{"value":"440803","label":"霞山区"},{"value":"440804","label":"坡头区"},{"value":"440811","label":"麻章区"},{"value":"440823","label":"遂溪县"},{"value":"440825","label":"徐闻县"},{"value":"440881","label":"廉江市"},{"value":"440882","label":"雷州市"},{"value":"440883","label":"吴川市"}]},{"value":"440900","label":"茂名市","children":[{"value":"440902","label":"茂南区"},{"value":"440903","label":"茂港区"},{"value":"440923","label":"电白县"},{"value":"440981","label":"高州市"},{"value":"440982","label":"化州市"},{"value":"440983","label":"信宜市"}]},{"value":"441200","label":"肇庆市","children":[{"value":"441202","label":"端州区"},{"value":"441203","label":"鼎湖区"},{"value":"441223","label":"广宁县"},{"value":"441224","label":"怀集县"},{"value":"441225","label":"封开县"},{"value":"441226","label":"德庆县"},{"value":"441283","label":"高要市"},{"value":"441284","label":"四会市"}]},{"value":"441300","label":"惠州市","children":[{"value":"441302","label":"惠城区"},{"value":"441303","label":"惠阳区"},{"value":"441322","label":"博罗县"},{"value":"441323","label":"惠东县"},{"value":"441324","label":"龙门县"}]},{"value":"441400","label":"梅州市","children":[{"value":"441402","label":"梅江区"},{"value":"441421","label":"梅县"},{"value":"441422","label":"大埔县"},{"value":"441423","label":"丰顺县"},{"value":"441424","label":"五华县"},{"value":"441426","label":"平远县"},{"value":"441427","label":"蕉岭县"},{"value":"441481","label":"兴宁市"}]},{"value":"441500","label":"汕尾市","children":[{"value":"441502","label":"城区"},{"value":"441521","label":"海丰县"},{"value":"441523","label":"陆河县"},{"value":"441581","label":"陆丰市"}]},{"value":"441600","label":"河源市","children":[{"value":"441602","label":"源城区"},{"value":"441621","label":"紫金县"},{"value":"441622","label":"龙川县"},{"value":"441623","label":"连平县"},{"value":"441624","label":"和平县"},{"value":"441625","label":"东源县"}]},{"value":"441700","label":"阳江市","children":[{"value":"441702","label":"江城区"},{"value":"441721","label":"阳西县"},{"value":"441723","label":"阳东县"},{"value":"441781","label":"阳春市"}]},{"value":"441800","label":"清远市","children":[{"value":"441802","label":"清城区"},{"value":"441821","label":"佛冈县"},{"value":"441823","label":"阳山县"},{"value":"441825","label":"连山壮族瑶族自治县"},{"value":"441826","label":"连南瑶族自治县"},{"value":"441827","label":"清新县"},{"value":"441881","label":"英德市"},{"value":"441882","label":"连州市"}]},{"value":"441900","label":"东莞市"},{"value":"442000","label":"中山市"},{"value":"445100","label":"潮州市","children":[{"value":"445102","label":"湘桥区"},{"value":"445121","label":"潮安县"},{"value":"445122","label":"饶平县"},{"value":"445185","label":"枫溪区"}]},{"value":"445200","label":"揭阳市","children":[{"value":"445202","label":"榕城区"},{"value":"445221","label":"揭东县"},{"value":"445222","label":"揭西县"},{"value":"445224","label":"惠来县"},{"value":"445281","label":"普宁市"},{"value":"445284","label":"东山区"}]},{"value":"445300","label":"云浮市","children":[{"value":"445302","label":"云城区"},{"value":"445321","label":"新兴县"},{"value":"445322","label":"郁南县"},{"value":"445323","label":"云安县"},{"value":"445381","label":"罗定市"}]}]},{"label":"广西壮族自治区","value":"450000","children":[{"value":"450100","label":"南宁市","children":[{"value":"450102","label":"兴宁区"},{"value":"450103","label":"青秀区"},{"value":"450105","label":"江南区"},{"value":"450107","label":"西乡塘区"},{"value":"450108","label":"良庆区"},{"value":"450109","label":"邕宁区"},{"value":"450122","label":"武鸣县"},{"value":"450123","label":"隆安县"},{"value":"450124","label":"马山县"},{"value":"450125","label":"上林县"},{"value":"450126","label":"宾阳县"},{"value":"450127","label":"横县"}]},{"value":"450200","label":"柳州市","children":[{"value":"450202","label":"城中区"},{"value":"450203","label":"鱼峰区"},{"value":"450204","label":"柳南区"},{"value":"450205","label":"柳北区"},{"value":"450221","label":"柳江县"},{"value":"450222","label":"柳城县"},{"value":"450223","label":"鹿寨县"},{"value":"450224","label":"融安县"},{"value":"450225","label":"融水苗族自治县"},{"value":"450226","label":"三江侗族自治县"}]},{"value":"450300","label":"桂林市","children":[{"value":"450302","label":"秀峰区"},{"value":"450303","label":"叠彩区"},{"value":"450304","label":"象山区"},{"value":"450305","label":"七星区"},{"value":"450311","label":"雁山区"},{"value":"450321","label":"阳朔县"},{"value":"450322","label":"临桂县"},{"value":"450323","label":"灵川县"},{"value":"450324","label":"全州县"},{"value":"450325","label":"兴安县"},{"value":"450326","label":"永福县"},{"value":"450327","label":"灌阳县"},{"value":"450328","label":"龙胜各族自治县"},{"value":"450329","label":"资源县"},{"value":"450330","label":"平乐县"},{"value":"450331","label":"荔浦县"},{"value":"450332","label":"恭城瑶族自治县"}]},{"value":"450400","label":"梧州市","children":[{"value":"450403","label":"万秀区"},{"value":"450404","label":"蝶山区"},{"value":"450405","label":"长洲区"},{"value":"450421","label":"苍梧县"},{"value":"450422","label":"藤县"},{"value":"450423","label":"蒙山县"},{"value":"450481","label":"岑溪市"}]},{"value":"450500","label":"北海市","children":[{"value":"450502","label":"海城区"},{"value":"450503","label":"银海区"},{"value":"450512","label":"铁山港区"},{"value":"450521","label":"合浦县"}]},{"value":"450600","label":"防城港市","children":[{"value":"450602","label":"港口区"},{"value":"450603","label":"防城区"},{"value":"450621","label":"上思县"},{"value":"450681","label":"东兴市"}]},{"value":"450700","label":"钦州市","children":[{"value":"450702","label":"钦南区"},{"value":"450703","label":"钦北区"},{"value":"450721","label":"灵山县"},{"value":"450722","label":"浦北县"}]},{"value":"450800","label":"贵港市","children":[{"value":"450802","label":"港北区"},{"value":"450803","label":"港南区"},{"value":"450804","label":"覃塘区"},{"value":"450821","label":"平南县"},{"value":"450881","label":"桂平市"}]},{"value":"450900","label":"玉林市","children":[{"value":"450902","label":"玉州区"},{"value":"450921","label":"容县"},{"value":"450922","label":"陆川县"},{"value":"450923","label":"博白县"},{"value":"450924","label":"兴业县"},{"value":"450981","label":"北流市"}]},{"value":"451000","label":"百色市","children":[{"value":"451002","label":"右江区"},{"value":"451021","label":"田阳县"},{"value":"451022","label":"田东县"},{"value":"451023","label":"平果县"},{"value":"451024","label":"德保县"},{"value":"451025","label":"靖西县"},{"value":"451026","label":"那坡县"},{"value":"451027","label":"凌云县"},{"value":"451028","label":"乐业县"},{"value":"451029","label":"田林县"},{"value":"451030","label":"西林县"},{"value":"451031","label":"隆林各族自治县"}]},{"value":"451100","label":"贺州市","children":[{"value":"451102","label":"八步区"},{"value":"451121","label":"昭平县"},{"value":"451122","label":"钟山县"},{"value":"451123","label":"富川瑶族自治县"}]},{"value":"451200","label":"河池市","children":[{"value":"451202","label":"金城江区"},{"value":"451221","label":"南丹县"},{"value":"451222","label":"天峨县"},{"value":"451223","label":"凤山县"},{"value":"451224","label":"东兰县"},{"value":"451225","label":"罗城仫佬族自治县"},{"value":"451226","label":"环江毛南族自治县"},{"value":"451227","label":"巴马瑶族自治县"},{"value":"451228","label":"都安瑶族自治县"},{"value":"451229","label":"大化瑶族自治县"},{"value":"451281","label":"宜州市"}]},{"value":"451300","label":"来宾市","children":[{"value":"451302","label":"兴宾区"},{"value":"451321","label":"忻城县"},{"value":"451322","label":"象州县"},{"value":"451323","label":"武宣县"},{"value":"451324","label":"金秀瑶族自治县"},{"value":"451381","label":"合山市"}]},{"value":"451400","label":"崇左市","children":[{"value":"451402","label":"江洲区"},{"value":"451421","label":"扶绥县"},{"value":"451422","label":"宁明县"},{"value":"451423","label":"龙州县"},{"value":"451424","label":"大新县"},{"value":"451425","label":"天等县"},{"value":"451481","label":"凭祥市"}]}]},{"label":"海南省","value":"460000","children":[{"value":"460100","label":"海口市","children":[{"value":"460105","label":"秀英区"},{"value":"460106","label":"龙华区"},{"value":"460107","label":"琼山区"},{"value":"460108","label":"美兰区"}]},{"value":"460200","label":"三亚市"},{"value":"469001","label":"五指山市"},{"value":"469002","label":"琼海市"},{"value":"469003","label":"儋州市"},{"value":"469005","label":"文昌市"},{"value":"469006","label":"万宁市"},{"value":"469007","label":"东方市"},{"value":"469025","label":"定安县"},{"value":"469026","label":"屯昌县"},{"value":"469027","label":"澄迈县"},{"value":"469028","label":"临高县"},{"value":"469030","label":"白沙黎族自治县"},{"value":"469031","label":"昌江黎族自治县"},{"value":"469033","label":"乐东黎族自治县"},{"value":"469034","label":"陵水黎族自治县"},{"value":"469035","label":"保亭黎族苗族自治县"},{"value":"469036","label":"琼中黎族苗族自治县"},{"value":"469037","label":"西沙群岛"},{"value":"469038","label":"南沙群岛"},{"value":"469039","label":"中沙群岛的岛礁及其海域"}]},{"label":"重庆","value":"500000","children":[{"value":"500100","label":"重庆市","children":[{"value":"500101","label":"万州区"},{"value":"500102","label":"涪陵区"},{"value":"500103","label":"渝中区"},{"value":"500104","label":"大渡口区"},{"value":"500105","label":"江北区"},{"value":"500106","label":"沙坪坝区"},{"value":"500107","label":"九龙坡区"},{"value":"500108","label":"南岸区"},{"value":"500109","label":"北碚区"},{"value":"500110","label":"万盛区"},{"value":"500111","label":"双桥区"},{"value":"500112","label":"渝北区"},{"value":"500113","label":"巴南区"},{"value":"500114","label":"黔江区"},{"value":"500115","label":"长寿区"},{"value":"500222","label":"綦江县"},{"value":"500223","label":"潼南县"},{"value":"500224","label":"铜梁县"},{"value":"500225","label":"大足县"},{"value":"500226","label":"荣昌县"},{"value":"500227","label":"璧山县"},{"value":"500228","label":"梁平县"},{"value":"500229","label":"城口县"},{"value":"500230","label":"丰都县"},{"value":"500231","label":"垫江县"},{"value":"500232","label":"武隆县"},{"value":"500233","label":"忠县"},{"value":"500234","label":"开县"},{"value":"500235","label":"云阳县"},{"value":"500236","label":"奉节县"},{"value":"500237","label":"巫山县"},{"value":"500238","label":"巫溪县"},{"value":"500240","label":"石柱土家族自治县"},{"value":"500241","label":"秀山土家族苗族自治县"},{"value":"500242","label":"酉阳土家族苗族自治县"},{"value":"500243","label":"彭水苗族土家族自治县"},{"value":"500381","label":"江津区"},{"value":"500382","label":"合川区"},{"value":"500383","label":"永川区"},{"value":"500384","label":"南川区"}]}]},{"label":"四川省","value":"510000","children":[{"value":"510100","label":"成都市","children":[{"value":"510104","label":"锦江区"},{"value":"510105","label":"青羊区"},{"value":"510106","label":"金牛区"},{"value":"510107","label":"武侯区"},{"value":"510108","label":"成华区"},{"value":"510112","label":"龙泉驿区"},{"value":"510113","label":"青白江区"},{"value":"510114","label":"新都区"},{"value":"510115","label":"温江区"},{"value":"510121","label":"金堂县"},{"value":"510122","label":"双流县"},{"value":"510124","label":"郫县"},{"value":"510129","label":"大邑县"},{"value":"510131","label":"蒲江县"},{"value":"510132","label":"新津县"},{"value":"510181","label":"都江堰市"},{"value":"510182","label":"彭州市"},{"value":"510183","label":"邛崃市"},{"value":"510184","label":"崇州市"}]},{"value":"510300","label":"自贡市","children":[{"value":"510302","label":"自流井区"},{"value":"510303","label":"贡井区"},{"value":"510304","label":"大安区"},{"value":"510311","label":"沿滩区"},{"value":"510321","label":"荣县"},{"value":"510322","label":"富顺县"}]},{"value":"510400","label":"攀枝花市","children":[{"value":"510402","label":"东区"},{"value":"510403","label":"西区"},{"value":"510411","label":"仁和区"},{"value":"510421","label":"米易县"},{"value":"510422","label":"盐边县"}]},{"value":"510500","label":"泸州市","children":[{"value":"510502","label":"江阳区"},{"value":"510503","label":"纳溪区"},{"value":"510504","label":"龙马潭区"},{"value":"510521","label":"泸县"},{"value":"510522","label":"合江县"},{"value":"510524","label":"叙永县"},{"value":"510525","label":"古蔺县"}]},{"value":"510600","label":"德阳市","children":[{"value":"510603","label":"旌阳区"},{"value":"510623","label":"中江县"},{"value":"510626","label":"罗江县"},{"value":"510681","label":"广汉市"},{"value":"510682","label":"什邡市"},{"value":"510683","label":"绵竹市"}]},{"value":"510700","label":"绵阳市","children":[{"value":"510703","label":"涪城区"},{"value":"510704","label":"游仙区"},{"value":"510722","label":"三台县"},{"value":"510723","label":"盐亭县"},{"value":"510724","label":"安县"},{"value":"510725","label":"梓潼县"},{"value":"510726","label":"北川羌族自治县"},{"value":"510727","label":"平武县"},{"value":"510751","label":"高新区"},{"value":"510781","label":"江油市"}]},{"value":"510800","label":"广元市","children":[{"value":"510802","label":"利州区"},{"value":"510811","label":"元坝区"},{"value":"510812","label":"朝天区"},{"value":"510821","label":"旺苍县"},{"value":"510822","label":"青川县"},{"value":"510823","label":"剑阁县"},{"value":"510824","label":"苍溪县"}]},{"value":"510900","label":"遂宁市","children":[{"value":"510903","label":"船山区"},{"value":"510904","label":"安居区"},{"value":"510921","label":"蓬溪县"},{"value":"510922","label":"射洪县"},{"value":"510923","label":"大英县"}]},{"value":"511000","label":"内江市","children":[{"value":"511002","label":"市中区"},{"value":"511011","label":"东兴区"},{"value":"511024","label":"威远县"},{"value":"511025","label":"资中县"},{"value":"511028","label":"隆昌县"}]},{"value":"511100","label":"乐山市","children":[{"value":"511102","label":"市中区"},{"value":"511111","label":"沙湾区"},{"value":"511112","label":"五通桥区"},{"value":"511113","label":"金口河区"},{"value":"511123","label":"犍为县"},{"value":"511124","label":"井研县"},{"value":"511126","label":"夹江县"},{"value":"511129","label":"沐川县"},{"value":"511132","label":"峨边彝族自治县"},{"value":"511133","label":"马边彝族自治县"},{"value":"511181","label":"峨眉山市"}]},{"value":"511300","label":"南充市","children":[{"value":"511302","label":"顺庆区"},{"value":"511303","label":"高坪区"},{"value":"511304","label":"嘉陵区"},{"value":"511321","label":"南部县"},{"value":"511322","label":"营山县"},{"value":"511323","label":"蓬安县"},{"value":"511324","label":"仪陇县"},{"value":"511325","label":"西充县"},{"value":"511381","label":"阆中市"}]},{"value":"511400","label":"眉山市","children":[{"value":"511402","label":"东坡区"},{"value":"511421","label":"仁寿县"},{"value":"511422","label":"彭山县"},{"value":"511423","label":"洪雅县"},{"value":"511424","label":"丹棱县"},{"value":"511425","label":"青神县"}]},{"value":"511500","label":"宜宾市","children":[{"value":"511502","label":"翠屏区"},{"value":"511521","label":"宜宾县"},{"value":"511522","label":"南溪县"},{"value":"511523","label":"江安县"},{"value":"511524","label":"长宁县"},{"value":"511525","label":"高县"},{"value":"511526","label":"珙县"},{"value":"511527","label":"筠连县"},{"value":"511528","label":"兴文县"},{"value":"511529","label":"屏山县"}]},{"value":"511600","label":"广安市","children":[{"value":"511602","label":"广安区"},{"value":"511621","label":"岳池县"},{"value":"511622","label":"武胜县"},{"value":"511623","label":"邻水县"},{"value":"511681","label":"华蓥市"},{"value":"511682","label":"市辖区"}]},{"value":"511700","label":"达州市","children":[{"value":"511702","label":"通川区"},{"value":"511721","label":"达县"},{"value":"511722","label":"宣汉县"},{"value":"511723","label":"开江县"},{"value":"511724","label":"大竹县"},{"value":"511725","label":"渠县"},{"value":"511781","label":"万源市"}]},{"value":"511800","label":"雅安市","children":[{"value":"511802","label":"雨城区"},{"value":"511821","label":"名山县"},{"value":"511822","label":"荥经县"},{"value":"511823","label":"汉源县"},{"value":"511824","label":"石棉县"},{"value":"511825","label":"天全县"},{"value":"511826","label":"芦山县"},{"value":"511827","label":"宝兴县"}]},{"value":"511900","label":"巴中市","children":[{"value":"511902","label":"巴州区"},{"value":"511921","label":"通江县"},{"value":"511922","label":"南江县"},{"value":"511923","label":"平昌县"}]},{"value":"512000","label":"资阳市","children":[{"value":"512002","label":"雁江区"},{"value":"512021","label":"安岳县"},{"value":"512022","label":"乐至县"},{"value":"512081","label":"简阳市"}]},{"value":"513200","label":"阿坝藏族羌族自治州","children":[{"value":"513221","label":"汶川县"},{"value":"513222","label":"理县"},{"value":"513223","label":"茂县"},{"value":"513224","label":"松潘县"},{"value":"513225","label":"九寨沟县"},{"value":"513226","label":"金川县"},{"value":"513227","label":"小金县"},{"value":"513228","label":"黑水县"},{"value":"513229","label":"马尔康县"},{"value":"513230","label":"壤塘县"},{"value":"513231","label":"阿坝县"},{"value":"513232","label":"若尔盖县"},{"value":"513233","label":"红原县"}]},{"value":"513300","label":"甘孜藏族自治州","children":[{"value":"513321","label":"康定县"},{"value":"513322","label":"泸定县"},{"value":"513323","label":"丹巴县"},{"value":"513324","label":"九龙县"},{"value":"513325","label":"雅江县"},{"value":"513326","label":"道孚县"},{"value":"513327","label":"炉霍县"},{"value":"513328","label":"甘孜县"},{"value":"513329","label":"新龙县"},{"value":"513330","label":"德格县"},{"value":"513331","label":"白玉县"},{"value":"513332","label":"石渠县"},{"value":"513333","label":"色达县"},{"value":"513334","label":"理塘县"},{"value":"513335","label":"巴塘县"},{"value":"513336","label":"乡城县"},{"value":"513337","label":"稻城县"},{"value":"513338","label":"得荣县"}]},{"value":"513400","label":"凉山彝族自治州","children":[{"value":"513401","label":"西昌市"},{"value":"513422","label":"木里藏族自治县"},{"value":"513423","label":"盐源县"},{"value":"513424","label":"德昌县"},{"value":"513425","label":"会理县"},{"value":"513426","label":"会东县"},{"value":"513427","label":"宁南县"},{"value":"513428","label":"普格县"},{"value":"513429","label":"布拖县"},{"value":"513430","label":"金阳县"},{"value":"513431","label":"昭觉县"},{"value":"513432","label":"喜德县"},{"value":"513433","label":"冕宁县"},{"value":"513434","label":"越西县"},{"value":"513435","label":"甘洛县"},{"value":"513436","label":"美姑县"},{"value":"513437","label":"雷波县"}]}]},{"label":"贵州省","value":"520000","children":[{"value":"520100","label":"贵阳市","children":[{"value":"520102","label":"南明区"},{"value":"520103","label":"云岩区"},{"value":"520111","label":"花溪区"},{"value":"520112","label":"乌当区"},{"value":"520113","label":"白云区"},{"value":"520114","label":"小河区"},{"value":"520121","label":"开阳县"},{"value":"520122","label":"息烽县"},{"value":"520123","label":"修文县"},{"value":"520151","label":"金阳开发区"},{"value":"520181","label":"清镇市"}]},{"value":"520200","label":"六盘水市","children":[{"value":"520201","label":"钟山区"},{"value":"520203","label":"六枝特区"},{"value":"520221","label":"水城县"},{"value":"520222","label":"盘县"}]},{"value":"520300","label":"遵义市","children":[{"value":"520302","label":"红花岗区"},{"value":"520303","label":"汇川区"},{"value":"520321","label":"遵义县"},{"value":"520322","label":"桐梓县"},{"value":"520323","label":"绥阳县"},{"value":"520324","label":"正安县"},{"value":"520325","label":"道真仡佬族苗族自治县"},{"value":"520326","label":"务川仡佬族苗族自治县"},{"value":"520327","label":"凤冈县"},{"value":"520328","label":"湄潭县"},{"value":"520329","label":"余庆县"},{"value":"520330","label":"习水县"},{"value":"520381","label":"赤水市"},{"value":"520382","label":"仁怀市"}]},{"value":"520400","label":"安顺市","children":[{"value":"520402","label":"西秀区"},{"value":"520421","label":"平坝县"},{"value":"520422","label":"普定县"},{"value":"520423","label":"镇宁布依族苗族自治县"},{"value":"520424","label":"关岭布依族苗族自治县"},{"value":"520425","label":"紫云苗族布依族自治县"}]},{"value":"522200","label":"铜仁地区","children":[{"value":"522201","label":"铜仁市"},{"value":"522222","label":"江口县"},{"value":"522223","label":"玉屏侗族自治县"},{"value":"522224","label":"石阡县"},{"value":"522225","label":"思南县"},{"value":"522226","label":"印江土家族苗族自治县"},{"value":"522227","label":"德江县"},{"value":"522228","label":"沿河土家族自治县"},{"value":"522229","label":"松桃苗族自治县"},{"value":"522230","label":"万山特区"}]},{"value":"522300","label":"黔西南布依族苗族自治州","children":[{"value":"522301","label":"兴义市"},{"value":"522322","label":"兴仁县"},{"value":"522323","label":"普安县"},{"value":"522324","label":"晴隆县"},{"value":"522325","label":"贞丰县"},{"value":"522326","label":"望谟县"},{"value":"522327","label":"册亨县"},{"value":"522328","label":"安龙县"}]},{"value":"522400","label":"毕节地区","children":[{"value":"522401","label":"毕节市"},{"value":"522422","label":"大方县"},{"value":"522423","label":"黔西县"},{"value":"522424","label":"金沙县"},{"value":"522425","label":"织金县"},{"value":"522426","label":"纳雍县"},{"value":"522427","label":"威宁彝族回族苗族自治县"},{"value":"522428","label":"赫章县"}]},{"value":"522600","label":"黔东南苗族侗族自治州","children":[{"value":"522601","label":"凯里市"},{"value":"522622","label":"黄平县"},{"value":"522623","label":"施秉县"},{"value":"522624","label":"三穗县"},{"value":"522625","label":"镇远县"},{"value":"522626","label":"岑巩县"},{"value":"522627","label":"天柱县"},{"value":"522628","label":"锦屏县"},{"value":"522629","label":"剑河县"},{"value":"522630","label":"台江县"},{"value":"522631","label":"黎平县"},{"value":"522632","label":"榕江县"},{"value":"522633","label":"从江县"},{"value":"522634","label":"雷山县"},{"value":"522635","label":"麻江县"},{"value":"522636","label":"丹寨县"}]},{"value":"522700","label":"黔南布依族苗族自治州","children":[{"value":"522701","label":"都匀市"},{"value":"522702","label":"福泉市"},{"value":"522722","label":"荔波县"},{"value":"522723","label":"贵定县"},{"value":"522725","label":"瓮安县"},{"value":"522726","label":"独山县"},{"value":"522727","label":"平塘县"},{"value":"522728","label":"罗甸县"},{"value":"522729","label":"长顺县"},{"value":"522730","label":"龙里县"},{"value":"522731","label":"惠水县"},{"value":"522732","label":"三都水族自治县"}]}]},{"label":"云南省","value":"530000","children":[{"value":"530100","label":"昆明市","children":[{"value":"530102","label":"五华区"},{"value":"530103","label":"盘龙区"},{"value":"530111","label":"官渡区"},{"value":"530112","label":"西山区"},{"value":"530113","label":"东川区"},{"value":"530121","label":"呈贡县"},{"value":"530122","label":"晋宁县"},{"value":"530124","label":"富民县"},{"value":"530125","label":"宜良县"},{"value":"530126","label":"石林彝族自治县"},{"value":"530127","label":"嵩明县"},{"value":"530128","label":"禄劝彝族苗族自治县"},{"value":"530129","label":"寻甸回族彝族自治县"},{"value":"530181","label":"安宁市"}]},{"value":"530300","label":"曲靖市","children":[{"value":"530302","label":"麒麟区"},{"value":"530321","label":"马龙县"},{"value":"530322","label":"陆良县"},{"value":"530323","label":"师宗县"},{"value":"530324","label":"罗平县"},{"value":"530325","label":"富源县"},{"value":"530326","label":"会泽县"},{"value":"530328","label":"沾益县"},{"value":"530381","label":"宣威市"}]},{"value":"530400","label":"玉溪市","children":[{"value":"530402","label":"红塔区"},{"value":"530421","label":"江川县"},{"value":"530422","label":"澄江县"},{"value":"530423","label":"通海县"},{"value":"530424","label":"华宁县"},{"value":"530425","label":"易门县"},{"value":"530426","label":"峨山彝族自治县"},{"value":"530427","label":"新平彝族傣族自治县"},{"value":"530428","label":"元江哈尼族彝族傣族自治县"}]},{"value":"530500","label":"保山市","children":[{"value":"530502","label":"隆阳区"},{"value":"530521","label":"施甸县"},{"value":"530522","label":"腾冲县"},{"value":"530523","label":"龙陵县"},{"value":"530524","label":"昌宁县"}]},{"value":"530600","label":"昭通市","children":[{"value":"530602","label":"昭阳区"},{"value":"530621","label":"鲁甸县"},{"value":"530622","label":"巧家县"},{"value":"530623","label":"盐津县"},{"value":"530624","label":"大关县"},{"value":"530625","label":"永善县"},{"value":"530626","label":"绥江县"},{"value":"530627","label":"镇雄县"},{"value":"530628","label":"彝良县"},{"value":"530629","label":"威信县"},{"value":"530630","label":"水富县"}]},{"value":"530700","label":"丽江市","children":[{"value":"530702","label":"古城区"},{"value":"530721","label":"玉龙纳西族自治县"},{"value":"530722","label":"永胜县"},{"value":"530723","label":"华坪县"},{"value":"530724","label":"宁蒗彝族自治县"}]},{"value":"530800","label":"普洱市","children":[{"value":"530802","label":"思茅区"},{"value":"530821","label":"宁洱哈尼族彝族自治县"},{"value":"530822","label":"墨江哈尼族自治县"},{"value":"530823","label":"景东彝族自治县"},{"value":"530824","label":"景谷傣族彝族自治县"},{"value":"530825","label":"镇沅彝族哈尼族拉祜族自治县"},{"value":"530826","label":"江城哈尼族彝族自治县"},{"value":"530827","label":"孟连傣族拉祜族佤族自治县"},{"value":"530828","label":"澜沧拉祜族自治县"},{"value":"530829","label":"西盟佤族自治县"}]},{"value":"530900","label":"临沧市","children":[{"value":"530902","label":"临翔区"},{"value":"530921","label":"凤庆县"},{"value":"530922","label":"云县"},{"value":"530923","label":"永德县"},{"value":"530924","label":"镇康县"},{"value":"530925","label":"双江拉祜族佤族布朗族傣族自治县"},{"value":"530926","label":"耿马傣族佤族自治县"},{"value":"530927","label":"沧源佤族自治县"}]},{"value":"532300","label":"楚雄彝族自治州","children":[{"value":"532301","label":"楚雄市"},{"value":"532322","label":"双柏县"},{"value":"532323","label":"牟定县"},{"value":"532324","label":"南华县"},{"value":"532325","label":"姚安县"},{"value":"532326","label":"大姚县"},{"value":"532327","label":"永仁县"},{"value":"532328","label":"元谋县"},{"value":"532329","label":"武定县"},{"value":"532331","label":"禄丰县"}]},{"value":"532500","label":"红河哈尼族彝族自治州","children":[{"value":"532501","label":"个旧市"},{"value":"532502","label":"开远市"},{"value":"532522","label":"蒙自县"},{"value":"532523","label":"屏边苗族自治县"},{"value":"532524","label":"建水县"},{"value":"532525","label":"石屏县"},{"value":"532526","label":"弥勒县"},{"value":"532527","label":"泸西县"},{"value":"532528","label":"元阳县"},{"value":"532529","label":"红河县"},{"value":"532530","label":"金平苗族瑶族傣族自治县"},{"value":"532531","label":"绿春县"},{"value":"532532","label":"河口瑶族自治县"}]},{"value":"532600","label":"文山壮族苗族自治州","children":[{"value":"532621","label":"文山县"},{"value":"532622","label":"砚山县"},{"value":"532623","label":"西畴县"},{"value":"532624","label":"麻栗坡县"},{"value":"532625","label":"马关县"},{"value":"532626","label":"丘北县"},{"value":"532627","label":"广南县"},{"value":"532628","label":"富宁县"}]},{"value":"532800","label":"西双版纳傣族自治州","children":[{"value":"532801","label":"景洪市"},{"value":"532822","label":"勐海县"},{"value":"532823","label":"勐腊县"}]},{"value":"532900","label":"大理白族自治州","children":[{"value":"532901","label":"大理市"},{"value":"532922","label":"漾濞彝族自治县"},{"value":"532923","label":"祥云县"},{"value":"532924","label":"宾川县"},{"value":"532925","label":"弥渡县"},{"value":"532926","label":"南涧彝族自治县"},{"value":"532927","label":"巍山彝族回族自治县"},{"value":"532928","label":"永平县"},{"value":"532929","label":"云龙县"},{"value":"532930","label":"洱源县"},{"value":"532931","label":"剑川县"},{"value":"532932","label":"鹤庆县"}]},{"value":"533100","label":"德宏傣族景颇族自治州","children":[{"value":"533102","label":"瑞丽市"},{"value":"533103","label":"潞西市"},{"value":"533122","label":"梁河县"},{"value":"533123","label":"盈江县"},{"value":"533124","label":"陇川县"}]},{"value":"533300","label":"怒江傈僳族自治州","children":[{"value":"533321","label":"泸水县"},{"value":"533323","label":"福贡县"},{"value":"533324","label":"贡山独龙族怒族自治县"},{"value":"533325","label":"兰坪白族普米族自治县"}]},{"value":"533400","label":"迪庆藏族自治州","children":[{"value":"533421","label":"香格里拉县"},{"value":"533422","label":"德钦县"},{"value":"533423","label":"维西傈僳族自治县"}]}]},{"label":"西藏自治区","value":"540000","children":[{"value":"540100","label":"拉萨市","children":[{"value":"540102","label":"城关区"},{"value":"540121","label":"林周县"},{"value":"540122","label":"当雄县"},{"value":"540123","label":"尼木县"},{"value":"540124","label":"曲水县"},{"value":"540125","label":"堆龙德庆县"},{"value":"540126","label":"达孜县"},{"value":"540127","label":"墨竹工卡县"}]},{"value":"542100","label":"昌都地区","children":[{"value":"542121","label":"昌都县"},{"value":"542122","label":"江达县"},{"value":"542123","label":"贡觉县"},{"value":"542124","label":"类乌齐县"},{"value":"542125","label":"丁青县"},{"value":"542126","label":"察雅县"},{"value":"542127","label":"八宿县"},{"value":"542128","label":"左贡县"},{"value":"542129","label":"芒康县"},{"value":"542132","label":"洛隆县"},{"value":"542133","label":"边坝县"}]},{"value":"542200","label":"山南地区","children":[{"value":"542221","label":"乃东县"},{"value":"542222","label":"扎囊县"},{"value":"542223","label":"贡嘎县"},{"value":"542224","label":"桑日县"},{"value":"542225","label":"琼结县"},{"value":"542226","label":"曲松县"},{"value":"542227","label":"措美县"},{"value":"542228","label":"洛扎县"},{"value":"542229","label":"加查县"},{"value":"542231","label":"隆子县"},{"value":"542232","label":"错那县"},{"value":"542233","label":"浪卡子县"}]},{"value":"542300","label":"日喀则地区","children":[{"value":"542301","label":"日喀则市"},{"value":"542322","label":"南木林县"},{"value":"542323","label":"江孜县"},{"value":"542324","label":"定日县"},{"value":"542325","label":"萨迦县"},{"value":"542326","label":"拉孜县"},{"value":"542327","label":"昂仁县"},{"value":"542328","label":"谢通门县"},{"value":"542329","label":"白朗县"},{"value":"542330","label":"仁布县"},{"value":"542331","label":"康马县"},{"value":"542332","label":"定结县"},{"value":"542333","label":"仲巴县"},{"value":"542334","label":"亚东县"},{"value":"542335","label":"吉隆县"},{"value":"542336","label":"聂拉木县"},{"value":"542337","label":"萨嘎县"},{"value":"542338","label":"岗巴县"}]},{"value":"542400","label":"那曲地区","children":[{"value":"542421","label":"那曲县"},{"value":"542422","label":"嘉黎县"},{"value":"542423","label":"比如县"},{"value":"542424","label":"聂荣县"},{"value":"542425","label":"安多县"},{"value":"542426","label":"申扎县"},{"value":"542427","label":"索县"},{"value":"542428","label":"班戈县"},{"value":"542429","label":"巴青县"},{"value":"542430","label":"尼玛县"}]},{"value":"542500","label":"阿里地区","children":[{"value":"542521","label":"普兰县"},{"value":"542522","label":"札达县"},{"value":"542523","label":"噶尔县"},{"value":"542524","label":"日土县"},{"value":"542525","label":"革吉县"},{"value":"542526","label":"改则县"},{"value":"542527","label":"措勤县"}]},{"value":"542600","label":"林芝地区","children":[{"value":"542621","label":"林芝县"},{"value":"542622","label":"工布江达县"},{"value":"542623","label":"米林县"},{"value":"542624","label":"墨脱县"},{"value":"542625","label":"波密县"},{"value":"542626","label":"察隅县"},{"value":"542627","label":"朗县"}]}]},{"label":"陕西省","value":"610000","children":[{"value":"610100","label":"西安市","children":[{"value":"610102","label":"新城区"},{"value":"610103","label":"碑林区"},{"value":"610104","label":"莲湖区"},{"value":"610111","label":"灞桥区"},{"value":"610112","label":"未央区"},{"value":"610113","label":"雁塔区"},{"value":"610114","label":"阎良区"},{"value":"610115","label":"临潼区"},{"value":"610116","label":"长安区"},{"value":"610122","label":"蓝田县"},{"value":"610124","label":"周至县"},{"value":"610125","label":"户县"},{"value":"610126","label":"高陵县"}]},{"value":"610200","label":"铜川市","children":[{"value":"610202","label":"王益区"},{"value":"610203","label":"印台区"},{"value":"610204","label":"耀州区"},{"value":"610222","label":"宜君县"}]},{"value":"610300","label":"宝鸡市","children":[{"value":"610302","label":"渭滨区"},{"value":"610303","label":"金台区"},{"value":"610304","label":"陈仓区"},{"value":"610322","label":"凤翔县"},{"value":"610323","label":"岐山县"},{"value":"610324","label":"扶风县"},{"value":"610326","label":"眉县"},{"value":"610327","label":"陇县"},{"value":"610328","label":"千阳县"},{"value":"610329","label":"麟游县"},{"value":"610330","label":"凤县"},{"value":"610331","label":"太白县"}]},{"value":"610400","label":"咸阳市","children":[{"value":"610402","label":"秦都区"},{"value":"610403","label":"杨陵区"},{"value":"610404","label":"渭城区"},{"value":"610422","label":"三原县"},{"value":"610423","label":"泾阳县"},{"value":"610424","label":"乾县"},{"value":"610425","label":"礼泉县"},{"value":"610426","label":"永寿县"},{"value":"610427","label":"彬县"},{"value":"610428","label":"长武县"},{"value":"610429","label":"旬邑县"},{"value":"610430","label":"淳化县"},{"value":"610431","label":"武功县"},{"value":"610481","label":"兴平市"}]},{"value":"610500","label":"渭南市","children":[{"value":"610502","label":"临渭区"},{"value":"610521","label":"华县"},{"value":"610522","label":"潼关县"},{"value":"610523","label":"大荔县"},{"value":"610524","label":"合阳县"},{"value":"610525","label":"澄城县"},{"value":"610526","label":"蒲城县"},{"value":"610527","label":"白水县"},{"value":"610528","label":"富平县"},{"value":"610581","label":"韩城市"},{"value":"610582","label":"华阴市"}]},{"value":"610600","label":"延安市","children":[{"value":"610602","label":"宝塔区"},{"value":"610621","label":"延长县"},{"value":"610622","label":"延川县"},{"value":"610623","label":"子长县"},{"value":"610624","label":"安塞县"},{"value":"610625","label":"志丹县"},{"value":"610626","label":"吴起县"},{"value":"610627","label":"甘泉县"},{"value":"610628","label":"富县"},{"value":"610629","label":"洛川县"},{"value":"610630","label":"宜川县"},{"value":"610631","label":"黄龙县"},{"value":"610632","label":"黄陵县"}]},{"value":"610700","label":"汉中市","children":[{"value":"610702","label":"汉台区"},{"value":"610721","label":"南郑县"},{"value":"610722","label":"城固县"},{"value":"610723","label":"洋县"},{"value":"610724","label":"西乡县"},{"value":"610725","label":"勉县"},{"value":"610726","label":"宁强县"},{"value":"610727","label":"略阳县"},{"value":"610728","label":"镇巴县"},{"value":"610729","label":"留坝县"},{"value":"610730","label":"佛坪县"}]},{"value":"610800","label":"榆林市","children":[{"value":"610802","label":"榆阳区"},{"value":"610821","label":"神木县"},{"value":"610822","label":"府谷县"},{"value":"610823","label":"横山县"},{"value":"610824","label":"靖边县"},{"value":"610825","label":"定边县"},{"value":"610826","label":"绥德县"},{"value":"610827","label":"米脂县"},{"value":"610828","label":"佳县"},{"value":"610829","label":"吴堡县"},{"value":"610830","label":"清涧县"},{"value":"610831","label":"子洲县"}]},{"value":"610900","label":"安康市","children":[{"value":"610902","label":"汉滨区"},{"value":"610921","label":"汉阴县"},{"value":"610922","label":"石泉县"},{"value":"610923","label":"宁陕县"},{"value":"610924","label":"紫阳县"},{"value":"610925","label":"岚皋县"},{"value":"610926","label":"平利县"},{"value":"610927","label":"镇坪县"},{"value":"610928","label":"旬阳县"},{"value":"610929","label":"白河县"}]},{"value":"611000","label":"商洛市","children":[{"value":"611002","label":"商州区"},{"value":"611021","label":"洛南县"},{"value":"611022","label":"丹凤县"},{"value":"611023","label":"商南县"},{"value":"611024","label":"山阳县"},{"value":"611025","label":"镇安县"},{"value":"611026","label":"柞水县"}]}]},{"label":"甘肃省","value":"620000","children":[{"value":"620100","label":"兰州市","children":[{"value":"620102","label":"城关区"},{"value":"620103","label":"七里河区"},{"value":"620104","label":"西固区"},{"value":"620105","label":"安宁区"},{"value":"620111","label":"红古区"},{"value":"620121","label":"永登县"},{"value":"620122","label":"皋兰县"},{"value":"620123","label":"榆中县"}]},{"value":"620200","label":"嘉峪关市"},{"value":"620300","label":"金昌市","children":[{"value":"620302","label":"金川区"},{"value":"620321","label":"永昌县"}]},{"value":"620400","label":"白银市","children":[{"value":"620402","label":"白银区"},{"value":"620403","label":"平川区"},{"value":"620421","label":"靖远县"},{"value":"620422","label":"会宁县"},{"value":"620423","label":"景泰县"}]},{"value":"620500","label":"天水市","children":[{"value":"620502","label":"秦州区"},{"value":"620503","label":"麦积区"},{"value":"620521","label":"清水县"},{"value":"620522","label":"秦安县"},{"value":"620523","label":"甘谷县"},{"value":"620524","label":"武山县"},{"value":"620525","label":"张家川回族自治县"}]},{"value":"620600","label":"武威市","children":[{"value":"620602","label":"凉州区"},{"value":"620621","label":"民勤县"},{"value":"620622","label":"古浪县"},{"value":"620623","label":"天祝藏族自治县"}]},{"value":"620700","label":"张掖市","children":[{"value":"620702","label":"甘州区"},{"value":"620721","label":"肃南裕固族自治县"},{"value":"620722","label":"民乐县"},{"value":"620723","label":"临泽县"},{"value":"620724","label":"高台县"},{"value":"620725","label":"山丹县"}]},{"value":"620800","label":"平凉市","children":[{"value":"620802","label":"崆峒区"},{"value":"620821","label":"泾川县"},{"value":"620822","label":"灵台县"},{"value":"620823","label":"崇信县"},{"value":"620824","label":"华亭县"},{"value":"620825","label":"庄浪县"},{"value":"620826","label":"静宁县"}]},{"value":"620900","label":"酒泉市","children":[{"value":"620902","label":"肃州区"},{"value":"620921","label":"金塔县"},{"value":"620922","label":"安西县"},{"value":"620923","label":"肃北蒙古族自治县"},{"value":"620924","label":"阿克塞哈萨克族自治县"},{"value":"620981","label":"玉门市"},{"value":"620982","label":"敦煌市"}]},{"value":"621000","label":"庆阳市","children":[{"value":"621002","label":"西峰区"},{"value":"621021","label":"庆城县"},{"value":"621022","label":"环县"},{"value":"621023","label":"华池县"},{"value":"621024","label":"合水县"},{"value":"621025","label":"正宁县"},{"value":"621026","label":"宁县"},{"value":"621027","label":"镇原县"}]},{"value":"621100","label":"定西市","children":[{"value":"621102","label":"安定区"},{"value":"621121","label":"通渭县"},{"value":"621122","label":"陇西县"},{"value":"621123","label":"渭源县"},{"value":"621124","label":"临洮县"},{"value":"621125","label":"漳县"},{"value":"621126","label":"岷县"}]},{"value":"621200","label":"陇南市","children":[{"value":"621202","label":"武都区"},{"value":"621221","label":"成县"},{"value":"621222","label":"文县"},{"value":"621223","label":"宕昌县"},{"value":"621224","label":"康县"},{"value":"621225","label":"西和县"},{"value":"621226","label":"礼县"},{"value":"621227","label":"徽县"},{"value":"621228","label":"两当县"}]},{"value":"622900","label":"临夏回族自治州","children":[{"value":"622901","label":"临夏市"},{"value":"622921","label":"临夏县"},{"value":"622922","label":"康乐县"},{"value":"622923","label":"永靖县"},{"value":"622924","label":"广河县"},{"value":"622925","label":"和政县"},{"value":"622926","label":"东乡族自治县"},{"value":"622927","label":"积石山保安族东乡族撒拉族自治县"}]},{"value":"623000","label":"甘南藏族自治州","children":[{"value":"623001","label":"合作市"},{"value":"623021","label":"临潭县"},{"value":"623022","label":"卓尼县"},{"value":"623023","label":"舟曲县"},{"value":"623024","label":"迭部县"},{"value":"623025","label":"玛曲县"},{"value":"623026","label":"碌曲县"},{"value":"623027","label":"夏河县"}]}]},{"label":"青海省","value":"630000","children":[{"value":"630100","label":"西宁市","children":[{"value":"630102","label":"城东区"},{"value":"630103","label":"城中区"},{"value":"630104","label":"城西区"},{"value":"630105","label":"城北区"},{"value":"630121","label":"大通回族土族自治县"},{"value":"630122","label":"湟中县"},{"value":"630123","label":"湟源县"}]},{"value":"632100","label":"海东地区","children":[{"value":"632121","label":"平安县"},{"value":"632122","label":"民和回族土族自治县"},{"value":"632123","label":"乐都县"},{"value":"632126","label":"互助土族自治县"},{"value":"632127","label":"化隆回族自治县"},{"value":"632128","label":"循化撒拉族自治县"}]},{"value":"632200","label":"海北藏族自治州","children":[{"value":"632221","label":"门源回族自治县"},{"value":"632222","label":"祁连县"},{"value":"632223","label":"海晏县"},{"value":"632224","label":"刚察县"}]},{"value":"632300","label":"黄南藏族自治州","children":[{"value":"632321","label":"同仁县"},{"value":"632322","label":"尖扎县"},{"value":"632323","label":"泽库县"},{"value":"632324","label":"河南蒙古族自治县"}]},{"value":"632500","label":"海南藏族自治州","children":[{"value":"632521","label":"共和县"},{"value":"632522","label":"同德县"},{"value":"632523","label":"贵德县"},{"value":"632524","label":"兴海县"},{"value":"632525","label":"贵南县"}]},{"value":"632600","label":"果洛藏族自治州","children":[{"value":"632621","label":"玛沁县"},{"value":"632622","label":"班玛县"},{"value":"632623","label":"甘德县"},{"value":"632624","label":"达日县"},{"value":"632625","label":"久治县"},{"value":"632626","label":"玛多县"}]},{"value":"632700","label":"玉树藏族自治州","children":[{"value":"632721","label":"玉树县"},{"value":"632722","label":"杂多县"},{"value":"632723","label":"称多县"},{"value":"632724","label":"治多县"},{"value":"632725","label":"囊谦县"},{"value":"632726","label":"曲麻莱县"}]},{"value":"632800","label":"海西蒙古族藏族自治州","children":[{"value":"632801","label":"格尔木市"},{"value":"632802","label":"德令哈市"},{"value":"632821","label":"乌兰县"},{"value":"632822","label":"都兰县"},{"value":"632823","label":"天峻县"}]}]},{"label":"宁夏回族自治区","value":"640000","children":[{"value":"640100","label":"银川市","children":[{"value":"640104","label":"兴庆区"},{"value":"640105","label":"西夏区"},{"value":"640106","label":"金凤区"},{"value":"640121","label":"永宁县"},{"value":"640122","label":"贺兰县"},{"value":"640181","label":"灵武市"}]},{"value":"640200","label":"石嘴山市","children":[{"value":"640202","label":"大武口区"},{"value":"640205","label":"惠农区"},{"value":"640221","label":"平罗县"}]},{"value":"640300","label":"吴忠市","children":[{"value":"640302","label":"利通区"},{"value":"640303","label":"红寺堡区"},{"value":"640323","label":"盐池县"},{"value":"640324","label":"同心县"},{"value":"640381","label":"青铜峡市"}]},{"value":"640400","label":"固原市","children":[{"value":"640402","label":"原州区"},{"value":"640422","label":"西吉县"},{"value":"640423","label":"隆德县"},{"value":"640424","label":"泾源县"},{"value":"640425","label":"彭阳县"}]},{"value":"640500","label":"中卫市","children":[{"value":"640502","label":"沙坡头区"},{"value":"640521","label":"中宁县"},{"value":"640522","label":"海原县"}]}]},{"label":"新疆维吾尔自治区","value":"650000","children":[{"value":"650100","label":"乌鲁木齐市","children":[{"value":"650102","label":"天山区"},{"value":"650103","label":"沙依巴克区"},{"value":"650104","label":"新市区"},{"value":"650105","label":"水磨沟区"},{"value":"650106","label":"头屯河区"},{"value":"650107","label":"达坂城区"},{"value":"650108","label":"东山区"},{"value":"650109","label":"米东区"},{"value":"650121","label":"乌鲁木齐县"}]},{"value":"650200","label":"克拉玛依市","children":[{"value":"650202","label":"独山子区"},{"value":"650203","label":"克拉玛依区"},{"value":"650204","label":"白碱滩区"},{"value":"650205","label":"乌尔禾区"}]},{"value":"652100","label":"吐鲁番地区","children":[{"value":"652101","label":"吐鲁番市"},{"value":"652122","label":"鄯善县"},{"value":"652123","label":"托克逊县"}]},{"value":"652200","label":"哈密地区","children":[{"value":"652201","label":"哈密市"},{"value":"652222","label":"巴里坤哈萨克自治县"},{"value":"652223","label":"伊吾县"}]},{"value":"652300","label":"昌吉回族自治州","children":[{"value":"652301","label":"昌吉市"},{"value":"652302","label":"阜康市"},{"value":"652303","label":"米泉市"},{"value":"652323","label":"呼图壁县"},{"value":"652324","label":"玛纳斯县"},{"value":"652325","label":"奇台县"},{"value":"652327","label":"吉木萨尔县"},{"value":"652328","label":"木垒哈萨克自治县"}]},{"value":"652700","label":"博尔塔拉蒙古自治州","children":[{"value":"652701","label":"博乐市"},{"value":"652722","label":"精河县"},{"value":"652723","label":"温泉县"}]},{"value":"652800","label":"巴音郭楞蒙古自治州","children":[{"value":"652801","label":"库尔勒市"},{"value":"652822","label":"轮台县"},{"value":"652823","label":"尉犁县"},{"value":"652824","label":"若羌县"},{"value":"652825","label":"且末县"},{"value":"652826","label":"焉耆回族自治县"},{"value":"652827","label":"和静县"},{"value":"652828","label":"和硕县"},{"value":"652829","label":"博湖县"}]},{"value":"652900","label":"阿克苏地区","children":[{"value":"652901","label":"阿克苏市"},{"value":"652922","label":"温宿县"},{"value":"652923","label":"库车县"},{"value":"652924","label":"沙雅县"},{"value":"652925","label":"新和县"},{"value":"652926","label":"拜城县"},{"value":"652927","label":"乌什县"},{"value":"652928","label":"阿瓦提县"},{"value":"652929","label":"柯坪县"}]},{"value":"653000","label":"克孜勒苏柯尔克孜自治州","children":[{"value":"653001","label":"阿图什市"},{"value":"653022","label":"阿克陶县"},{"value":"653023","label":"阿合奇县"},{"value":"653024","label":"乌恰县"}]},{"value":"653100","label":"喀什地区","children":[{"value":"653101","label":"喀什市"},{"value":"653121","label":"疏附县"},{"value":"653122","label":"疏勒县"},{"value":"653123","label":"英吉沙县"},{"value":"653124","label":"泽普县"},{"value":"653125","label":"莎车县"},{"value":"653126","label":"叶城县"},{"value":"653127","label":"麦盖提县"},{"value":"653128","label":"岳普湖县"},{"value":"653129","label":"伽师县"},{"value":"653130","label":"巴楚县"},{"value":"653131","label":"塔什库尔干塔吉克自治县"}]},{"value":"653200","label":"和田地区","children":[{"value":"653201","label":"和田市"},{"value":"653221","label":"和田县"},{"value":"653222","label":"墨玉县"},{"value":"653223","label":"皮山县"},{"value":"653224","label":"洛浦县"},{"value":"653225","label":"策勒县"},{"value":"653226","label":"于田县"},{"value":"653227","label":"民丰县"}]},{"value":"654000","label":"伊犁哈萨克自治州","children":[{"value":"654002","label":"伊宁市"},{"value":"654003","label":"奎屯市"},{"value":"654021","label":"伊宁县"},{"value":"654022","label":"察布查尔锡伯自治县"},{"value":"654023","label":"霍城县"},{"value":"654024","label":"巩留县"},{"value":"654025","label":"新源县"},{"value":"654026","label":"昭苏县"},{"value":"654027","label":"特克斯县"},{"value":"654028","label":"尼勒克县"}]},{"value":"654200","label":"塔城地区","children":[{"value":"654201","label":"塔城市"},{"value":"654202","label":"乌苏市"},{"value":"654221","label":"额敏县"},{"value":"654223","label":"沙湾县"},{"value":"654224","label":"托里县"},{"value":"654225","label":"裕民县"},{"value":"654226","label":"和布克赛尔蒙古自治县"}]},{"value":"654300","label":"阿勒泰地区","children":[{"value":"654301","label":"阿勒泰市"},{"value":"654321","label":"布尔津县"},{"value":"654322","label":"富蕴县"},{"value":"654323","label":"福海县"},{"value":"654324","label":"哈巴河县"},{"value":"654325","label":"青河县"},{"value":"654326","label":"吉木乃县"}]},{"value":"659001","label":"石河子市"},{"value":"659002","label":"阿拉尔市"},{"value":"659003","label":"图木舒克市"},{"value":"659004","label":"五家渠市"}]},{"label":"台湾省","value":"710000","children":[{"value":"710100","label":"台北市","children":[{"value":"710101","label":"中正区"},{"value":"710102","label":"大同区"},{"value":"710103","label":"中山区"},{"value":"710104","label":"松山区"},{"value":"710105","label":"大安区"},{"value":"710106","label":"万华区"},{"value":"710107","label":"信义区"},{"value":"710108","label":"士林区"},{"value":"710109","label":"北投区"},{"value":"710110","label":"内湖区"},{"value":"710111","label":"南港区"},{"value":"710112","label":"文山区"}]},{"value":"710200","label":"高雄市","children":[{"value":"710201","label":"新兴区"},{"value":"710202","label":"前金区"},{"value":"710203","label":"芩雅区"},{"value":"710204","label":"盐埕区"},{"value":"710205","label":"鼓山区"},{"value":"710206","label":"旗津区"},{"value":"710207","label":"前镇区"},{"value":"710208","label":"三民区"},{"value":"710209","label":"左营区"},{"value":"710210","label":"楠梓区"},{"value":"710211","label":"小港区"}]},{"value":"710300","label":"台南市","children":[{"value":"710301","label":"中西区"},{"value":"710302","label":"东区"},{"value":"710303","label":"南区"},{"value":"710304","label":"北区"},{"value":"710305","label":"安平区"},{"value":"710306","label":"安南区"}]},{"value":"710400","label":"台中市","children":[{"value":"710401","label":"中区"},{"value":"710402","label":"东区"},{"value":"710403","label":"南区"},{"value":"710404","label":"西区"},{"value":"710405","label":"北区"},{"value":"710406","label":"北屯区"},{"value":"710407","label":"西屯区"},{"value":"710408","label":"南屯区"}]},{"value":"710500","label":"金门县"},{"value":"710600","label":"南投县"},{"value":"710700","label":"基隆市","children":[{"value":"710701","label":"仁爱区"},{"value":"710702","label":"信义区"},{"value":"710703","label":"中正区"},{"value":"710704","label":"中山区"},{"value":"710705","label":"安乐区"},{"value":"710706","label":"暖暖区"},{"value":"710707","label":"七堵区"}]},{"value":"710800","label":"新竹市","children":[{"value":"710801","label":"东区"},{"value":"710802","label":"北区"},{"value":"710803","label":"香山区"}]},{"value":"710900","label":"嘉义市","children":[{"value":"710901","label":"东区"},{"value":"710902","label":"西区"}]},{"value":"711100","label":"新北市"},{"value":"711200","label":"宜兰县"},{"value":"711300","label":"新竹县"},{"value":"711400","label":"桃园县"},{"value":"711500","label":"苗栗县"},{"value":"711700","label":"彰化县"},{"value":"711900","label":"嘉义县"},{"value":"712100","label":"云林县"},{"value":"712400","label":"屏东县"},{"value":"712500","label":"台东县"},{"value":"712600","label":"花莲县"},{"value":"712700","label":"澎湖县"}]},{"label":"香港特别行政区","value":"810000","children":[{"value":"810100","label":"香港岛","children":[{"value":"810101","label":"中西区"},{"value":"810102","label":"湾仔"},{"value":"810103","label":"东区"},{"value":"810104","label":"南区"}]},{"value":"810200","label":"九龙","children":[{"value":"810201","label":"九龙城区"},{"value":"810202","label":"油尖旺区"},{"value":"810203","label":"深水埗区"},{"value":"810204","label":"黄大仙区"},{"value":"810205","label":"观塘区"}]},{"value":"810300","label":"新界","children":[{"value":"810301","label":"北区"},{"value":"810302","label":"大埔区"},{"value":"810303","label":"沙田区"},{"value":"810304","label":"西贡区"},{"value":"810305","label":"元朗区"},{"value":"810306","label":"屯门区"},{"value":"810307","label":"荃湾区"},{"value":"810308","label":"葵青区"},{"value":"810309","label":"离岛区"}]}]},{"label":"澳门特别行政区","value":"820000","children":[{"value":"820100","label":"澳门半岛"},{"value":"820200","label":"离岛"}]},{"label":"海外","value":"990000","children":[{"value":"990100","label":"海外"}]}]
diff --git a/public/tinymce/langs/zh_CN.js b/public/tinymce/langs/zh_CN.js
new file mode 100644
index 0000000..2a784f5
--- /dev/null
+++ b/public/tinymce/langs/zh_CN.js
@@ -0,0 +1,462 @@
+tinymce.addI18n('zh_CN',{
+"Redo": "\u91cd\u505a",
+"Undo": "\u64a4\u9500",
+"Cut": "\u526a\u5207",
+"Copy": "\u590d\u5236",
+"Paste": "\u7c98\u8d34",
+"Select all": "\u5168\u9009",
+"New document": "\u65b0\u6587\u4ef6",
+"Ok": "\u786e\u5b9a",
+"Cancel": "\u53d6\u6d88",
+"Visual aids": "\u7f51\u683c\u7ebf",
+"Bold": "\u7c97\u4f53",
+"Italic": "\u659c\u4f53",
+"Underline": "\u4e0b\u5212\u7ebf",
+"Strikethrough": "\u5220\u9664\u7ebf",
+"Superscript": "\u4e0a\u6807",
+"Subscript": "\u4e0b\u6807",
+"Clear formatting": "\u6e05\u9664\u683c\u5f0f",
+"Align left": "\u5de6\u8fb9\u5bf9\u9f50",
+"Align center": "\u4e2d\u95f4\u5bf9\u9f50",
+"Align right": "\u53f3\u8fb9\u5bf9\u9f50",
+"Justify": "\u4e24\u7aef\u5bf9\u9f50",
+"Bullet list": "\u9879\u76ee\u7b26\u53f7",
+"Numbered list": "\u7f16\u53f7\u5217\u8868",
+"Decrease indent": "\u51cf\u5c11\u7f29\u8fdb",
+"Increase indent": "\u589e\u52a0\u7f29\u8fdb",
+"Close": "\u5173\u95ed",
+"Formats": "\u683c\u5f0f",
+"Your browser doesn't support direct access to the clipboard. Please use the Ctrl+X\/C\/V keyboard shortcuts instead.": "\u4f60\u7684\u6d4f\u89c8\u5668\u4e0d\u652f\u6301\u6253\u5f00\u526a\u8d34\u677f\uff0c\u8bf7\u4f7f\u7528Ctrl+X\/C\/V\u7b49\u5feb\u6377\u952e\u3002",
+"Headers": "\u6807\u9898",
+"Header 1": "\u6807\u98981",
+"Header 2": "\u6807\u98982",
+"Header 3": "\u6807\u98983",
+"Header 4": "\u6807\u98984",
+"Header 5": "\u6807\u98985",
+"Header 6": "\u6807\u98986",
+"Headings": "\u6807\u9898",
+"Heading 1": "\u6807\u98981",
+"Heading 2": "\u6807\u98982",
+"Heading 3": "\u6807\u98983",
+"Heading 4": "\u6807\u98984",
+"Heading 5": "\u6807\u98985",
+"Heading 6": "\u6807\u98986",
+"Preformatted": "\u9884\u5148\u683c\u5f0f\u5316\u7684",
+"Div": "Div",
+"Pre": "Pre",
+"Code": "\u4ee3\u7801",
+"Paragraph": "\u6bb5\u843d",
+"Blockquote": "\u5f15\u6587\u533a\u5757",
+"Inline": "\u6587\u672c",
+"Blocks": "\u57fa\u5757",
+"Paste is now in plain text mode. Contents will now be pasted as plain text until you toggle this option off.": "\u5f53\u524d\u4e3a\u7eaf\u6587\u672c\u7c98\u8d34\u6a21\u5f0f\uff0c\u518d\u6b21\u70b9\u51fb\u53ef\u4ee5\u56de\u5230\u666e\u901a\u7c98\u8d34\u6a21\u5f0f\u3002",
+"Fonts": "\u5b57\u4f53",
+"Font Sizes": "\u5b57\u53f7",
+"Class": "\u7c7b\u578b",
+"Browse for an image": "\u6d4f\u89c8\u56fe\u50cf",
+"OR": "\u6216",
+"Drop an image here": "\u62d6\u653e\u4e00\u5f20\u56fe\u50cf\u81f3\u6b64",
+"Upload": "\u4e0a\u4f20",
+"Block": "\u5757",
+"Align": "\u5bf9\u9f50",
+"Default": "\u9ed8\u8ba4",
+"Circle": "\u7a7a\u5fc3\u5706",
+"Disc": "\u5b9e\u5fc3\u5706",
+"Square": "\u65b9\u5757",
+"Lower Alpha": "\u5c0f\u5199\u82f1\u6587\u5b57\u6bcd",
+"Lower Greek": "\u5c0f\u5199\u5e0c\u814a\u5b57\u6bcd",
+"Lower Roman": "\u5c0f\u5199\u7f57\u9a6c\u5b57\u6bcd",
+"Upper Alpha": "\u5927\u5199\u82f1\u6587\u5b57\u6bcd",
+"Upper Roman": "\u5927\u5199\u7f57\u9a6c\u5b57\u6bcd",
+"Anchor...": "\u951a\u70b9...",
+"Name": "\u540d\u79f0",
+"Id": "\u6807\u8bc6\u7b26",
+"Id should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores.": "\u6807\u8bc6\u7b26\u5e94\u8be5\u4ee5\u5b57\u6bcd\u5f00\u5934\uff0c\u540e\u8ddf\u5b57\u6bcd\u3001\u6570\u5b57\u3001\u7834\u6298\u53f7\u3001\u70b9\u3001\u5192\u53f7\u6216\u4e0b\u5212\u7ebf\u3002",
+"You have unsaved changes are you sure you want to navigate away?": "\u4f60\u8fd8\u6709\u6587\u6863\u5c1a\u672a\u4fdd\u5b58\uff0c\u786e\u5b9a\u8981\u79bb\u5f00\uff1f",
+"Restore last draft": "\u6062\u590d\u4e0a\u6b21\u7684\u8349\u7a3f",
+"Special character...": "\u7279\u6b8a\u5b57\u7b26...",
+"Source code": "\u6e90\u4ee3\u7801",
+"Insert\/Edit code sample": "\u63d2\u5165\/\u7f16\u8f91\u4ee3\u7801\u793a\u4f8b",
+"Language": "\u8bed\u8a00",
+"Code sample...": "\u793a\u4f8b\u4ee3\u7801...",
+"Color Picker": "\u9009\u8272\u5668",
+"R": "R",
+"G": "G",
+"B": "B",
+"Left to right": "\u4ece\u5de6\u5230\u53f3",
+"Right to left": "\u4ece\u53f3\u5230\u5de6",
+"Emoticons": "\u8868\u60c5",
+"Emoticons...": "\u8868\u60c5\u7b26\u53f7...",
+"Metadata and Document Properties": "\u5143\u6570\u636e\u548c\u6587\u6863\u5c5e\u6027",
+"Title": "\u6807\u9898",
+"Keywords": "\u5173\u952e\u8bcd",
+"Description": "\u63cf\u8ff0",
+"Robots": "\u673a\u5668\u4eba",
+"Author": "\u4f5c\u8005",
+"Encoding": "\u7f16\u7801",
+"Fullscreen": "\u5168\u5c4f",
+"Action": "\u64cd\u4f5c",
+"Shortcut": "\u5feb\u6377\u952e",
+"Help": "\u5e2e\u52a9",
+"Address": "\u5730\u5740",
+"Focus to menubar": "\u79fb\u52a8\u7126\u70b9\u5230\u83dc\u5355\u680f",
+"Focus to toolbar": "\u79fb\u52a8\u7126\u70b9\u5230\u5de5\u5177\u680f",
+"Focus to element path": "\u79fb\u52a8\u7126\u70b9\u5230\u5143\u7d20\u8def\u5f84",
+"Focus to contextual toolbar": "\u79fb\u52a8\u7126\u70b9\u5230\u4e0a\u4e0b\u6587\u83dc\u5355",
+"Insert link (if link plugin activated)": "\u63d2\u5165\u94fe\u63a5 (\u5982\u679c\u94fe\u63a5\u63d2\u4ef6\u5df2\u6fc0\u6d3b)",
+"Save (if save plugin activated)": "\u4fdd\u5b58(\u5982\u679c\u4fdd\u5b58\u63d2\u4ef6\u5df2\u6fc0\u6d3b)",
+"Find (if searchreplace plugin activated)": "\u67e5\u627e(\u5982\u679c\u67e5\u627e\u66ff\u6362\u63d2\u4ef6\u5df2\u6fc0\u6d3b)",
+"Plugins installed ({0}):": "\u5df2\u5b89\u88c5\u63d2\u4ef6 ({0}):",
+"Premium plugins:": "\u4f18\u79c0\u63d2\u4ef6\uff1a",
+"Learn more...": "\u4e86\u89e3\u66f4\u591a...",
+"You are using {0}": "\u4f60\u6b63\u5728\u4f7f\u7528 {0}",
+"Plugins": "\u63d2\u4ef6",
+"Handy Shortcuts": "\u5feb\u6377\u952e",
+"Horizontal line": "\u6c34\u5e73\u5206\u5272\u7ebf",
+"Insert\/edit image": "\u63d2\u5165\/\u7f16\u8f91\u56fe\u7247",
+"Alternative description": "\u66ff\u4ee3\u63cf\u8ff0",
+"Accessibility": "\u8f85\u52a9\u529f\u80fd",
+"Image is decorative": "\u56fe\u50cf\u662f\u88c5\u9970\u6027\u7684",
+"Source": "\u5730\u5740",
+"Dimensions": "\u5927\u5c0f",
+"Constrain proportions": "\u4fdd\u6301\u7eb5\u6a2a\u6bd4",
+"General": "\u666e\u901a",
+"Advanced": "\u9ad8\u7ea7",
+"Style": "\u6837\u5f0f",
+"Vertical space": "\u5782\u76f4\u8fb9\u8ddd",
+"Horizontal space": "\u6c34\u5e73\u8fb9\u8ddd",
+"Border": "\u8fb9\u6846",
+"Insert image": "\u63d2\u5165\u56fe\u7247",
+"Image...": "\u56fe\u7247...",
+"Image list": "\u56fe\u7247\u5217\u8868",
+"Rotate counterclockwise": "\u9006\u65f6\u9488\u65cb\u8f6c",
+"Rotate clockwise": "\u987a\u65f6\u9488\u65cb\u8f6c",
+"Flip vertically": "\u5782\u76f4\u7ffb\u8f6c",
+"Flip horizontally": "\u6c34\u5e73\u7ffb\u8f6c",
+"Edit image": "\u7f16\u8f91\u56fe\u7247",
+"Image options": "\u56fe\u7247\u9009\u9879",
+"Zoom in": "\u653e\u5927",
+"Zoom out": "\u7f29\u5c0f",
+"Crop": "\u88c1\u526a",
+"Resize": "\u8c03\u6574\u5927\u5c0f",
+"Orientation": "\u65b9\u5411",
+"Brightness": "\u4eae\u5ea6",
+"Sharpen": "\u9510\u5316",
+"Contrast": "\u5bf9\u6bd4\u5ea6",
+"Color levels": "\u989c\u8272\u5c42\u6b21",
+"Gamma": "\u4f3d\u9a6c\u503c",
+"Invert": "\u53cd\u8f6c",
+"Apply": "\u5e94\u7528",
+"Back": "\u540e\u9000",
+"Insert date\/time": "\u63d2\u5165\u65e5\u671f\/\u65f6\u95f4",
+"Date\/time": "\u65e5\u671f\/\u65f6\u95f4",
+"Insert\/edit link": "\u63d2\u5165\/\u7f16\u8f91\u94fe\u63a5",
+"Text to display": "\u663e\u793a\u6587\u5b57",
+"Url": "\u5730\u5740",
+"Open link in...": "\u94fe\u63a5\u6253\u5f00\u4f4d\u7f6e...",
+"Current window": "\u5f53\u524d\u7a97\u53e3",
+"None": "\u65e0",
+"New window": "\u5728\u65b0\u7a97\u53e3\u6253\u5f00",
+"Open link": "\u6253\u5f00\u94fe\u63a5",
+"Remove link": "\u5220\u9664\u94fe\u63a5",
+"Anchors": "\u951a\u70b9",
+"Link...": "\u94fe\u63a5...",
+"Paste or type a link": "\u7c98\u8d34\u6216\u8f93\u5165\u94fe\u63a5",
+"The URL you entered seems to be an email address. Do you want to add the required mailto: prefix?": "\u4f60\u6240\u586b\u5199\u7684URL\u5730\u5740\u4e3a\u90ae\u4ef6\u5730\u5740\uff0c\u9700\u8981\u52a0\u4e0amailto:\u524d\u7f00\u5417\uff1f",
+"The URL you entered seems to be an external link. Do you want to add the required http:\/\/ prefix?": "\u4f60\u6240\u586b\u5199\u7684URL\u5730\u5740\u5c5e\u4e8e\u5916\u90e8\u94fe\u63a5\uff0c\u9700\u8981\u52a0\u4e0ahttp:\/\/:\u524d\u7f00\u5417\uff1f",
+"The URL you entered seems to be an external link. Do you want to add the required https:\/\/ prefix?": "\u60a8\u8f93\u5165\u7684 URL \u4f3c\u4e4e\u662f\u4e00\u4e2a\u5916\u90e8\u94fe\u63a5\u3002\u60a8\u60f3\u6dfb\u52a0\u6240\u9700\u7684 https:\/\/ \u524d\u7f00\u5417\uff1f",
+"Link list": "\u94fe\u63a5\u5217\u8868",
+"Insert video": "\u63d2\u5165\u89c6\u9891",
+"Insert\/edit video": "\u63d2\u5165\/\u7f16\u8f91\u89c6\u9891",
+"Insert\/edit media": "\u63d2\u5165\/\u7f16\u8f91\u5a92\u4f53",
+"Alternative source": "\u955c\u50cf",
+"Alternative source URL": "\u66ff\u4ee3\u6765\u6e90\u7f51\u5740",
+"Media poster (Image URL)": "\u5c01\u9762(\u56fe\u7247\u5730\u5740)",
+"Paste your embed code below:": "\u5c06\u5185\u5d4c\u4ee3\u7801\u7c98\u8d34\u5728\u4e0b\u9762:",
+"Embed": "\u5185\u5d4c",
+"Media...": "\u591a\u5a92\u4f53...",
+"Nonbreaking space": "\u4e0d\u95f4\u65ad\u7a7a\u683c",
+"Page break": "\u5206\u9875\u7b26",
+"Paste as text": "\u7c98\u8d34\u4e3a\u6587\u672c",
+"Preview": "\u9884\u89c8",
+"Print...": "\u6253\u5370...",
+"Save": "\u4fdd\u5b58",
+"Find": "\u67e5\u627e",
+"Replace with": "\u66ff\u6362\u4e3a",
+"Replace": "\u66ff\u6362",
+"Replace all": "\u5168\u90e8\u66ff\u6362",
+"Previous": "\u4e0a\u4e00\u4e2a",
+"Next": "\u4e0b\u4e00\u4e2a",
+"Find and Replace": "\u67e5\u627e\u548c\u66ff\u6362",
+"Find and replace...": "\u67e5\u627e\u5e76\u66ff\u6362...",
+"Could not find the specified string.": "\u672a\u627e\u5230\u641c\u7d22\u5185\u5bb9.",
+"Match case": "\u533a\u5206\u5927\u5c0f\u5199",
+"Find whole words only": "\u5168\u5b57\u5339\u914d",
+"Find in selection": "\u5728\u9009\u533a\u4e2d\u67e5\u627e",
+"Spellcheck": "\u62fc\u5199\u68c0\u67e5",
+"Spellcheck Language": "\u62fc\u5199\u68c0\u67e5\u8bed\u8a00",
+"No misspellings found.": "\u6ca1\u6709\u53d1\u73b0\u62fc\u5199\u9519\u8bef",
+"Ignore": "\u5ffd\u7565",
+"Ignore all": "\u5168\u90e8\u5ffd\u7565",
+"Finish": "\u5b8c\u6210",
+"Add to Dictionary": "\u6dfb\u52a0\u5230\u5b57\u5178",
+"Insert table": "\u63d2\u5165\u8868\u683c",
+"Table properties": "\u8868\u683c\u5c5e\u6027",
+"Delete table": "\u5220\u9664\u8868\u683c",
+"Cell": "\u5355\u5143\u683c",
+"Row": "\u884c",
+"Column": "\u5217",
+"Cell properties": "\u5355\u5143\u683c\u5c5e\u6027",
+"Merge cells": "\u5408\u5e76\u5355\u5143\u683c",
+"Split cell": "\u62c6\u5206\u5355\u5143\u683c",
+"Insert row before": "\u5728\u4e0a\u65b9\u63d2\u5165",
+"Insert row after": "\u5728\u4e0b\u65b9\u63d2\u5165",
+"Delete row": "\u5220\u9664\u884c",
+"Row properties": "\u884c\u5c5e\u6027",
+"Cut row": "\u526a\u5207\u884c",
+"Copy row": "\u590d\u5236\u884c",
+"Paste row before": "\u7c98\u8d34\u5230\u4e0a\u65b9",
+"Paste row after": "\u7c98\u8d34\u5230\u4e0b\u65b9",
+"Insert column before": "\u5728\u5de6\u4fa7\u63d2\u5165",
+"Insert column after": "\u5728\u53f3\u4fa7\u63d2\u5165",
+"Delete column": "\u5220\u9664\u5217",
+"Cols": "\u5217",
+"Rows": "\u884c",
+"Width": "\u5bbd",
+"Height": "\u9ad8",
+"Cell spacing": "\u5355\u5143\u683c\u5916\u95f4\u8ddd",
+"Cell padding": "\u5355\u5143\u683c\u5185\u8fb9\u8ddd",
+"Caption": "\u6807\u9898",
+"Show caption": "\u663e\u793a\u6807\u9898",
+"Left": "\u5de6\u5bf9\u9f50",
+"Center": "\u5c45\u4e2d",
+"Right": "\u53f3\u5bf9\u9f50",
+"Cell type": "\u5355\u5143\u683c\u7c7b\u578b",
+"Scope": "\u8303\u56f4",
+"Alignment": "\u5bf9\u9f50\u65b9\u5f0f",
+"H Align": "\u6c34\u5e73\u5bf9\u9f50",
+"V Align": "\u5782\u76f4\u5bf9\u9f50",
+"Top": "\u9876\u90e8\u5bf9\u9f50",
+"Middle": "\u5782\u76f4\u5c45\u4e2d",
+"Bottom": "\u5e95\u90e8\u5bf9\u9f50",
+"Header cell": "\u8868\u5934\u5355\u5143\u683c",
+"Row group": "\u884c\u7ec4",
+"Column group": "\u5217\u7ec4",
+"Row type": "\u884c\u7c7b\u578b",
+"Header": "\u8868\u5934",
+"Body": "\u8868\u4f53",
+"Footer": "\u8868\u5c3e",
+"Border color": "\u8fb9\u6846\u989c\u8272",
+"Insert template...": "\u63d2\u5165\u6a21\u677f...",
+"Templates": "\u6a21\u677f",
+"Template": "\u6a21\u677f",
+"Text color": "\u6587\u5b57\u989c\u8272",
+"Background color": "\u80cc\u666f\u8272",
+"Custom...": "\u81ea\u5b9a\u4e49...",
+"Custom color": "\u81ea\u5b9a\u4e49\u989c\u8272",
+"No color": "\u65e0",
+"Remove color": "\u79fb\u9664\u989c\u8272",
+"Table of Contents": "\u5185\u5bb9\u5217\u8868",
+"Show blocks": "\u663e\u793a\u533a\u5757\u8fb9\u6846",
+"Show invisible characters": "\u663e\u793a\u4e0d\u53ef\u89c1\u5b57\u7b26",
+"Word count": "\u5b57\u6570",
+"Count": "\u8ba1\u6570",
+"Document": "\u6587\u6863",
+"Selection": "\u9009\u62e9",
+"Words": "\u5355\u8bcd",
+"Words: {0}": "\u5b57\u6570\uff1a{0}",
+"{0} words": "{0} \u5b57",
+"File": "\u6587\u4ef6",
+"Edit": "\u7f16\u8f91",
+"Insert": "\u63d2\u5165",
+"View": "\u89c6\u56fe",
+"Format": "\u683c\u5f0f",
+"Table": "\u8868\u683c",
+"Tools": "\u5de5\u5177",
+"Powered by {0}": "\u7531{0}\u9a71\u52a8",
+"Rich Text Area. Press ALT-F9 for menu. Press ALT-F10 for toolbar. Press ALT-0 for help": "\u5728\u7f16\u8f91\u533a\u6309ALT-F9\u6253\u5f00\u83dc\u5355\uff0c\u6309ALT-F10\u6253\u5f00\u5de5\u5177\u680f\uff0c\u6309ALT-0\u67e5\u770b\u5e2e\u52a9",
+"Image title": "\u56fe\u7247\u6807\u9898",
+"Border width": "\u8fb9\u6846\u5bbd\u5ea6",
+"Border style": "\u8fb9\u6846\u6837\u5f0f",
+"Error": "\u9519\u8bef",
+"Warn": "\u8b66\u544a",
+"Valid": "\u6709\u6548",
+"To open the popup, press Shift+Enter": "\u6309Shitf+Enter\u952e\u6253\u5f00\u5bf9\u8bdd\u6846",
+"Rich Text Area. Press ALT-0 for help.": "\u7f16\u8f91\u533a\u3002\u6309Alt+0\u952e\u6253\u5f00\u5e2e\u52a9\u3002",
+"System Font": "\u7cfb\u7edf\u5b57\u4f53",
+"Failed to upload image: {0}": "\u56fe\u7247\u4e0a\u4f20\u5931\u8d25: {0}",
+"Failed to load plugin: {0} from url {1}": "\u63d2\u4ef6\u52a0\u8f7d\u5931\u8d25: {0} \u6765\u81ea\u94fe\u63a5 {1}",
+"Failed to load plugin url: {0}": "\u63d2\u4ef6\u52a0\u8f7d\u5931\u8d25 \u94fe\u63a5: {0}",
+"Failed to initialize plugin: {0}": "\u63d2\u4ef6\u521d\u59cb\u5316\u5931\u8d25: {0}",
+"example": "\u793a\u4f8b",
+"Search": "\u641c\u7d22",
+"All": "\u5168\u90e8",
+"Currency": "\u8d27\u5e01",
+"Text": "\u6587\u5b57",
+"Quotations": "\u5f15\u7528",
+"Mathematical": "\u6570\u5b66",
+"Extended Latin": "\u62c9\u4e01\u8bed\u6269\u5145",
+"Symbols": "\u7b26\u53f7",
+"Arrows": "\u7bad\u5934",
+"User Defined": "\u81ea\u5b9a\u4e49",
+"dollar sign": "\u7f8e\u5143\u7b26\u53f7",
+"currency sign": "\u8d27\u5e01\u7b26\u53f7",
+"euro-currency sign": "\u6b27\u5143\u7b26\u53f7",
+"colon sign": "\u5192\u53f7",
+"cruzeiro sign": "\u514b\u9c81\u8d5b\u7f57\u5e01\u7b26\u53f7",
+"french franc sign": "\u6cd5\u90ce\u7b26\u53f7",
+"lira sign": "\u91cc\u62c9\u7b26\u53f7",
+"mill sign": "\u5bc6\u5c14\u7b26\u53f7",
+"naira sign": "\u5948\u62c9\u7b26\u53f7",
+"peseta sign": "\u6bd4\u585e\u5854\u7b26\u53f7",
+"rupee sign": "\u5362\u6bd4\u7b26\u53f7",
+"won sign": "\u97e9\u5143\u7b26\u53f7",
+"new sheqel sign": "\u65b0\u8c22\u514b\u5c14\u7b26\u53f7",
+"dong sign": "\u8d8a\u5357\u76fe\u7b26\u53f7",
+"kip sign": "\u8001\u631d\u57fa\u666e\u7b26\u53f7",
+"tugrik sign": "\u56fe\u683c\u91cc\u514b\u7b26\u53f7",
+"drachma sign": "\u5fb7\u62c9\u514b\u9a6c\u7b26\u53f7",
+"german penny symbol": "\u5fb7\u56fd\u4fbf\u58eb\u7b26\u53f7",
+"peso sign": "\u6bd4\u7d22\u7b26\u53f7",
+"guarani sign": "\u74dc\u62c9\u5c3c\u7b26\u53f7",
+"austral sign": "\u6fb3\u5143\u7b26\u53f7",
+"hryvnia sign": "\u683c\u91cc\u592b\u5c3c\u4e9a\u7b26\u53f7",
+"cedi sign": "\u585e\u5730\u7b26\u53f7",
+"livre tournois sign": "\u91cc\u5f17\u5f17\u5c14\u7b26\u53f7",
+"spesmilo sign": "spesmilo\u7b26\u53f7",
+"tenge sign": "\u575a\u6208\u7b26\u53f7",
+"indian rupee sign": "\u5370\u5ea6\u5362\u6bd4",
+"turkish lira sign": "\u571f\u8033\u5176\u91cc\u62c9",
+"nordic mark sign": "\u5317\u6b27\u9a6c\u514b",
+"manat sign": "\u9a6c\u7eb3\u7279\u7b26\u53f7",
+"ruble sign": "\u5362\u5e03\u7b26\u53f7",
+"yen character": "\u65e5\u5143\u5b57\u6837",
+"yuan character": "\u4eba\u6c11\u5e01\u5143\u5b57\u6837",
+"yuan character, in hong kong and taiwan": "\u5143\u5b57\u6837\uff08\u6e2f\u53f0\u5730\u533a\uff09",
+"yen\/yuan character variant one": "\u5143\u5b57\u6837\uff08\u5927\u5199\uff09",
+"Loading emoticons...": "\u52a0\u8f7d\u8868\u60c5\u7b26\u53f7...",
+"Could not load emoticons": "\u4e0d\u80fd\u52a0\u8f7d\u8868\u60c5\u7b26\u53f7",
+"People": "\u4eba\u7c7b",
+"Animals and Nature": "\u52a8\u7269\u548c\u81ea\u7136",
+"Food and Drink": "\u98df\u7269\u548c\u996e\u54c1",
+"Activity": "\u6d3b\u52a8",
+"Travel and Places": "\u65c5\u6e38\u548c\u5730\u70b9",
+"Objects": "\u7269\u4ef6",
+"Flags": "\u65d7\u5e1c",
+"Characters": "\u5b57\u7b26",
+"Characters (no spaces)": "\u5b57\u7b26(\u65e0\u7a7a\u683c)",
+"{0} characters": "{0} \u4e2a\u5b57\u7b26",
+"Error: Form submit field collision.": "\u9519\u8bef: \u8868\u5355\u63d0\u4ea4\u5b57\u6bb5\u51b2\u7a81\u3002",
+"Error: No form element found.": "\u9519\u8bef: \u6ca1\u6709\u8868\u5355\u63a7\u4ef6\u3002",
+"Update": "\u66f4\u65b0",
+"Color swatch": "\u989c\u8272\u6837\u672c",
+"Turquoise": "\u9752\u7eff\u8272",
+"Green": "\u7eff\u8272",
+"Blue": "\u84dd\u8272",
+"Purple": "\u7d2b\u8272",
+"Navy Blue": "\u6d77\u519b\u84dd",
+"Dark Turquoise": "\u6df1\u84dd\u7eff\u8272",
+"Dark Green": "\u6df1\u7eff\u8272",
+"Medium Blue": "\u4e2d\u84dd\u8272",
+"Medium Purple": "\u4e2d\u7d2b\u8272",
+"Midnight Blue": "\u6df1\u84dd\u8272",
+"Yellow": "\u9ec4\u8272",
+"Orange": "\u6a59\u8272",
+"Red": "\u7ea2\u8272",
+"Light Gray": "\u6d45\u7070\u8272",
+"Gray": "\u7070\u8272",
+"Dark Yellow": "\u6697\u9ec4\u8272",
+"Dark Orange": "\u6df1\u6a59\u8272",
+"Dark Red": "\u6df1\u7ea2\u8272",
+"Medium Gray": "\u4e2d\u7070\u8272",
+"Dark Gray": "\u6df1\u7070\u8272",
+"Light Green": "\u6d45\u7eff\u8272",
+"Light Yellow": "\u6d45\u9ec4\u8272",
+"Light Red": "\u6d45\u7ea2\u8272",
+"Light Purple": "\u6d45\u7d2b\u8272",
+"Light Blue": "\u6d45\u84dd\u8272",
+"Dark Purple": "\u6df1\u7d2b\u8272",
+"Dark Blue": "\u6df1\u84dd\u8272",
+"Black": "\u9ed1\u8272",
+"White": "\u767d\u8272",
+"Switch to or from fullscreen mode": "\u5207\u6362\u5168\u5c4f\u6a21\u5f0f",
+"Open help dialog": "\u6253\u5f00\u5e2e\u52a9\u5bf9\u8bdd\u6846",
+"history": "\u5386\u53f2",
+"styles": "\u6837\u5f0f",
+"formatting": "\u683c\u5f0f\u5316",
+"alignment": "\u5bf9\u9f50",
+"indentation": "\u7f29\u8fdb",
+"Font": "\u5b57\u4f53",
+"Size": "\u5b57\u53f7",
+"More...": "\u66f4\u591a...",
+"Select...": "\u9009\u62e9...",
+"Preferences": "\u9996\u9009\u9879",
+"Yes": "\u662f",
+"No": "\u5426",
+"Keyboard Navigation": "\u952e\u76d8\u6307\u5f15",
+"Version": "\u7248\u672c",
+"Code view": "\u4ee3\u7801\u89c6\u56fe",
+"Open popup menu for split buttons": "\u6253\u5f00\u5f39\u51fa\u5f0f\u83dc\u5355\uff0c\u7528\u4e8e\u62c6\u5206\u6309\u94ae",
+"List Properties": "\u5217\u8868\u5c5e\u6027",
+"List properties...": "\u6807\u9898\u5b57\u4f53\u5c5e\u6027",
+"Start list at number": "\u4ee5\u6570\u5b57\u5f00\u59cb\u5217\u8868",
+"Line height": "\u884c\u9ad8",
+"comments": "\u5907\u6ce8",
+"Format Painter": "\u683c\u5f0f\u5237",
+"Insert\/edit iframe": "\u63d2\u5165\/\u7f16\u8f91\u6846\u67b6",
+"Capitalization": "\u5927\u5199",
+"lowercase": "\u5c0f\u5199",
+"UPPERCASE": "\u5927\u5199",
+"Title Case": "\u9996\u5b57\u6bcd\u5927\u5199",
+"permanent pen": "\u8bb0\u53f7\u7b14",
+"Permanent Pen Properties": "\u6c38\u4e45\u7b14\u5c5e\u6027",
+"Permanent pen properties...": "\u6c38\u4e45\u7b14\u5c5e\u6027...",
+"case change": "\u6848\u4f8b\u66f4\u6539",
+"page embed": "\u9875\u9762\u5d4c\u5165",
+"Advanced sort...": "\u9ad8\u7ea7\u6392\u5e8f...",
+"Advanced Sort": "\u9ad8\u7ea7\u6392\u5e8f",
+"Sort table by column ascending": "\u6309\u5217\u5347\u5e8f\u8868",
+"Sort table by column descending": "\u6309\u5217\u964d\u5e8f\u8868",
+"Sort": "\u6392\u5e8f",
+"Order": "\u6392\u5e8f",
+"Sort by": "\u6392\u5e8f\u65b9\u5f0f",
+"Ascending": "\u5347\u5e8f",
+"Descending": "\u964d\u5e8f",
+"Column {0}": "\u5217{0}",
+"Row {0}": "\u884c{0}",
+"Spellcheck...": "\u62fc\u5199\u68c0\u67e5...",
+"Misspelled word": "\u62fc\u5199\u9519\u8bef\u7684\u5355\u8bcd",
+"Suggestions": "\u5efa\u8bae",
+"Change": "\u66f4\u6539",
+"Finding word suggestions": "\u67e5\u627e\u5355\u8bcd\u5efa\u8bae",
+"Success": "\u6210\u529f",
+"Repair": "\u4fee\u590d",
+"Issue {0} of {1}": "\u5171\u8ba1{1}\u95ee\u9898{0}",
+"Images must be marked as decorative or have an alternative text description": "\u56fe\u50cf\u5fc5\u987b\u6807\u8bb0\u4e3a\u88c5\u9970\u6027\u6216\u5177\u6709\u66ff\u4ee3\u6587\u672c\u63cf\u8ff0",
+"Images must have an alternative text description. Decorative images are not allowed.": "\u56fe\u50cf\u5fc5\u987b\u5177\u6709\u66ff\u4ee3\u6587\u672c\u63cf\u8ff0\u3002\u4e0d\u5141\u8bb8\u4f7f\u7528\u88c5\u9970\u56fe\u50cf\u3002",
+"Or provide alternative text:": "\u6216\u63d0\u4f9b\u5907\u9009\u6587\u672c\uff1a",
+"Make image decorative:": "\u4f7f\u56fe\u50cf\u88c5\u9970\uff1a",
+"ID attribute must be unique": "ID \u5c5e\u6027\u5fc5\u987b\u662f\u552f\u4e00\u7684",
+"Make ID unique": "\u4f7f ID \u72ec\u4e00\u65e0\u4e8c",
+"Keep this ID and remove all others": "\u4fdd\u7559\u6b64 ID \u5e76\u5220\u9664\u6240\u6709\u5176\u4ed6",
+"Remove this ID": "\u5220\u9664\u6b64 ID",
+"Remove all IDs": "\u6e05\u9664\u5168\u90e8IDs",
+"Checklist": "\u6e05\u5355",
+"Anchor": "\u951a\u70b9",
+"Special character": "\u7279\u6b8a\u7b26\u53f7",
+"Code sample": "\u4ee3\u7801\u793a\u4f8b",
+"Color": "\u989c\u8272",
+"Document properties": "\u6587\u6863\u5c5e\u6027",
+"Image description": "\u56fe\u7247\u63cf\u8ff0",
+"Image": "\u56fe\u7247",
+"Insert link": "\u63d2\u5165\u94fe\u63a5",
+"Target": "\u6253\u5f00\u65b9\u5f0f",
+"Link": "\u94fe\u63a5",
+"Poster": "\u5c01\u9762",
+"Media": "\u5a92\u4f53",
+"Print": "\u6253\u5370",
+"Prev": "\u4e0a\u4e00\u4e2a",
+"Find and replace": "\u67e5\u627e\u548c\u66ff\u6362",
+"Whole words": "\u5168\u5b57\u5339\u914d",
+"Insert template": "\u63d2\u5165\u6a21\u677f"
+});
\ No newline at end of file
diff --git a/public/tinymce/langs/zh_TW.js b/public/tinymce/langs/zh_TW.js
new file mode 100644
index 0000000..1987486
--- /dev/null
+++ b/public/tinymce/langs/zh_TW.js
@@ -0,0 +1,419 @@
+tinymce.addI18n('zh_TW',{
+"Redo": "\u91cd\u505a",
+"Undo": "\u64a4\u92b7",
+"Cut": "\u526a\u4e0b",
+"Copy": "\u8907\u88fd",
+"Paste": "\u8cbc\u4e0a",
+"Select all": "\u5168\u9078",
+"New document": "\u65b0\u6587\u4ef6",
+"Ok": "\u78ba\u5b9a",
+"Cancel": "\u53d6\u6d88",
+"Visual aids": "\u5c0f\u5e6b\u624b",
+"Bold": "\u7c97\u9ad4",
+"Italic": "\u659c\u9ad4",
+"Underline": "\u4e0b\u5283\u7dda",
+"Strikethrough": "\u522a\u9664\u7dda",
+"Superscript": "\u4e0a\u6a19",
+"Subscript": "\u4e0b\u6a19",
+"Clear formatting": "\u6e05\u9664\u683c\u5f0f",
+"Align left": "\u5de6\u908a\u5c0d\u9f4a",
+"Align center": "\u4e2d\u9593\u5c0d\u9f4a",
+"Align right": "\u53f3\u908a\u5c0d\u9f4a",
+"Justify": "\u5de6\u53f3\u5c0d\u9f4a",
+"Bullet list": "\u9805\u76ee\u6e05\u55ae",
+"Numbered list": "\u6578\u5b57\u6e05\u55ae",
+"Decrease indent": "\u6e1b\u5c11\u7e2e\u6392",
+"Increase indent": "\u589e\u52a0\u7e2e\u6392",
+"Close": "\u95dc\u9589",
+"Formats": "\u683c\u5f0f",
+"Your browser doesn't support direct access to the clipboard. Please use the Ctrl+X\/C\/V keyboard shortcuts instead.": "\u60a8\u7684\u700f\u89bd\u5668\u4e0d\u652f\u63f4\u5b58\u53d6\u526a\u8cbc\u7c3f\uff0c\u53ef\u4ee5\u4f7f\u7528\u5feb\u901f\u9375 Ctrl + X\/C\/V \u4ee3\u66ff\u526a\u4e0b\u3001\u8907\u88fd\u8207\u8cbc\u4e0a\u3002",
+"Headers": "\u6a19\u984c",
+"Header 1": "\u6a19\u984c 1",
+"Header 2": "\u6a19\u984c 2",
+"Header 3": "\u6a19\u984c 3",
+"Header 4": "\u6a19\u984c 4",
+"Header 5": "\u6a19\u984c 5",
+"Header 6": "\u6a19\u984c 6",
+"Headings": "\u6a19\u984c",
+"Heading 1": "\u6a19\u984c1",
+"Heading 2": "\u6a19\u984c2",
+"Heading 3": "\u6a19\u984c3",
+"Heading 4": "\u6a19\u984c4",
+"Heading 5": "\u6a19\u984c5",
+"Heading 6": "\u6a19\u984c6",
+"Preformatted": "\u9810\u5148\u683c\u5f0f\u5316\u7684",
+"Div": "Div",
+"Pre": "Pre",
+"Code": "\u4ee3\u78bc",
+"Paragraph": "\u6bb5\u843d",
+"Blockquote": "\u5f15\u6587\u5340\u584a",
+"Inline": "\u5167\u806f",
+"Blocks": "\u57fa\u584a",
+"Paste is now in plain text mode. Contents will now be pasted as plain text until you toggle this option off.": "\u76ee\u524d\u5c07\u4ee5\u7d14\u6587\u5b57\u7684\u6a21\u5f0f\u8cbc\u4e0a\uff0c\u60a8\u53ef\u4ee5\u518d\u9ede\u9078\u4e00\u6b21\u53d6\u6d88\u3002",
+"Fonts": "\u5b57\u578b",
+"Font Sizes": "\u5b57\u578b\u5927\u5c0f",
+"Class": "\u985e\u578b",
+"Browse for an image": "\u5f9e\u5716\u7247\u4e2d\u700f\u89bd",
+"OR": "\u6216",
+"Drop an image here": "\u62d6\u66f3\u5716\u7247\u81f3\u6b64",
+"Upload": "\u4e0a\u50b3",
+"Block": "\u5340\u584a",
+"Align": "\u5c0d\u9f4a",
+"Default": "\u9810\u8a2d",
+"Circle": "\u7a7a\u5fc3\u5713",
+"Disc": "\u5be6\u5fc3\u5713",
+"Square": "\u6b63\u65b9\u5f62",
+"Lower Alpha": "\u5c0f\u5beb\u82f1\u6587\u5b57\u6bcd",
+"Lower Greek": "\u5e0c\u81d8\u5b57\u6bcd",
+"Lower Roman": "\u5c0f\u5beb\u7f85\u99ac\u6578\u5b57",
+"Upper Alpha": "\u5927\u5beb\u82f1\u6587\u5b57\u6bcd",
+"Upper Roman": "\u5927\u5beb\u7f85\u99ac\u6578\u5b57",
+"Anchor...": "\u9328\u9ede...",
+"Name": "\u540d\u7a31",
+"Id": "Id",
+"Id should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores.": "Id\u61c9\u4ee5\u5b57\u6bcd\u958b\u982d\uff0c\u5f8c\u9762\u63a5\u8457\u5b57\u6bcd\uff0c\u6578\u5b57\uff0c\u7834\u6298\u865f\uff0c\u9ede\u6578\uff0c\u5192\u865f\u6216\u4e0b\u5283\u7dda\u3002",
+"You have unsaved changes are you sure you want to navigate away?": "\u7de8\u8f2f\u5c1a\u672a\u88ab\u5132\u5b58\uff0c\u4f60\u78ba\u5b9a\u8981\u96e2\u958b\uff1f",
+"Restore last draft": "\u8f09\u5165\u4e0a\u4e00\u6b21\u7de8\u8f2f\u7684\u8349\u7a3f",
+"Special character...": "\u7279\u6b8a\u5b57\u5143......",
+"Source code": "\u539f\u59cb\u78bc",
+"Insert\/Edit code sample": "\u63d2\u5165\/\u7de8\u8f2f \u7a0b\u5f0f\u78bc\u7bc4\u4f8b",
+"Language": "\u8a9e\u8a00",
+"Code sample...": "\u7a0b\u5f0f\u78bc\u7bc4\u4f8b...",
+"Color Picker": "\u9078\u8272\u5668",
+"R": "\u7d05",
+"G": "\u7da0",
+"B": "\u85cd",
+"Left to right": "\u5f9e\u5de6\u5230\u53f3",
+"Right to left": "\u5f9e\u53f3\u5230\u5de6",
+"Emoticons...": "\u8868\u60c5\u7b26\u865f\u2026",
+"Metadata and Document Properties": "\u5f8c\u8a2d\u8cc7\u6599\u8207\u6587\u4ef6\u5c6c\u6027",
+"Title": "\u6a19\u984c",
+"Keywords": "\u95dc\u9375\u5b57",
+"Description": "\u63cf\u8ff0",
+"Robots": "\u6a5f\u5668\u4eba",
+"Author": "\u4f5c\u8005",
+"Encoding": "\u7de8\u78bc",
+"Fullscreen": "\u5168\u87a2\u5e55",
+"Action": "\u52d5\u4f5c",
+"Shortcut": "\u5feb\u901f\u9375",
+"Help": "\u5e6b\u52a9",
+"Address": "\u5730\u5740",
+"Focus to menubar": "\u8df3\u81f3\u9078\u55ae\u5217",
+"Focus to toolbar": "\u8df3\u81f3\u5de5\u5177\u5217",
+"Focus to element path": "\u8df3\u81f3HTML\u5143\u7d20\u5217",
+"Focus to contextual toolbar": "\u8df3\u81f3\u5feb\u6377\u9078\u55ae",
+"Insert link (if link plugin activated)": "\u65b0\u589e\u6377\u5f91 (\u6377\u5f91\u5916\u639b\u555f\u7528\u6642)",
+"Save (if save plugin activated)": "\u5132\u5b58 (\u5132\u5b58\u5916\u639b\u555f\u7528\u6642)",
+"Find (if searchreplace plugin activated)": "\u5c0b\u627e (\u5c0b\u627e\u53d6\u4ee3\u5916\u639b\u555f\u7528\u6642)",
+"Plugins installed ({0}):": "({0}) \u500b\u5916\u639b\u5df2\u5b89\u88dd\uff1a",
+"Premium plugins:": "\u52a0\u503c\u5916\u639b\uff1a",
+"Learn more...": "\u4e86\u89e3\u66f4\u591a...",
+"You are using {0}": "\u60a8\u6b63\u5728\u4f7f\u7528 {0}",
+"Plugins": "\u5916\u639b",
+"Handy Shortcuts": "\u5feb\u901f\u9375",
+"Horizontal line": "\u6c34\u5e73\u7dda",
+"Insert\/edit image": "\u63d2\u5165\/\u7de8\u8f2f \u5716\u7247",
+"Image description": "\u5716\u7247\u63cf\u8ff0",
+"Source": "\u5716\u7247\u7db2\u5740",
+"Dimensions": "\u5c3a\u5bf8",
+"Constrain proportions": "\u7b49\u6bd4\u4f8b\u7e2e\u653e",
+"General": "\u4e00\u822c",
+"Advanced": "\u9032\u968e",
+"Style": "\u6a23\u5f0f",
+"Vertical space": "\u9ad8\u5ea6",
+"Horizontal space": "\u5bec\u5ea6",
+"Border": "\u908a\u6846",
+"Insert image": "\u63d2\u5165\u5716\u7247",
+"Image...": "\u5716\u7247......",
+"Image list": "\u5716\u7247\u6e05\u55ae",
+"Rotate counterclockwise": "\u9006\u6642\u91dd\u65cb\u8f49",
+"Rotate clockwise": "\u9806\u6642\u91dd\u65cb\u8f49",
+"Flip vertically": "\u5782\u76f4\u7ffb\u8f49",
+"Flip horizontally": "\u6c34\u5e73\u7ffb\u8f49",
+"Edit image": "\u7de8\u8f2f\u5716\u7247",
+"Image options": "\u5716\u7247\u9078\u9805",
+"Zoom in": "\u653e\u5927",
+"Zoom out": "\u7e2e\u5c0f",
+"Crop": "\u88c1\u526a",
+"Resize": "\u8abf\u6574\u5927\u5c0f",
+"Orientation": "\u65b9\u5411",
+"Brightness": "\u4eae\u5ea6",
+"Sharpen": "\u92b3\u5316",
+"Contrast": "\u5c0d\u6bd4",
+"Color levels": "\u984f\u8272\u5c64\u6b21",
+"Gamma": "\u4f3d\u99ac\u503c",
+"Invert": "\u53cd\u8f49",
+"Apply": "\u61c9\u7528",
+"Back": "\u5f8c\u9000",
+"Insert date\/time": "\u63d2\u5165 \u65e5\u671f\/\u6642\u9593",
+"Date\/time": "\u65e5\u671f\/\u6642\u9593",
+"Insert\/Edit Link": "\u63d2\u5165\/\u7de8\u8f2f\u9023\u7d50",
+"Insert\/edit link": "\u63d2\u5165\/\u7de8\u8f2f\u9023\u7d50",
+"Text to display": "\u986f\u793a\u6587\u5b57",
+"Url": "\u7db2\u5740",
+"Open link in...": "\u958b\u555f\u9023\u7d50\u65bc...",
+"Current window": "\u76ee\u524d\u8996\u7a97",
+"None": "\u7121",
+"New window": "\u53e6\u958b\u8996\u7a97",
+"Remove link": "\u79fb\u9664\u9023\u7d50",
+"Anchors": "\u52a0\u5165\u9328\u9ede",
+"Link...": "\u9023\u7d50...",
+"Paste or type a link": "\u8cbc\u4e0a\u6216\u8f38\u5165\u9023\u7d50",
+"The URL you entered seems to be an email address. Do you want to add the required mailto: prefix?": "\u4f60\u6240\u586b\u5beb\u7684URL\u70ba\u96fb\u5b50\u90f5\u4ef6\uff0c\u9700\u8981\u52a0\u4e0amailto:\u524d\u7db4\u55ce\uff1f",
+"The URL you entered seems to be an external link. Do you want to add the required http:\/\/ prefix?": "\u4f60\u6240\u586b\u5beb\u7684URL\u5c6c\u65bc\u5916\u90e8\u93c8\u63a5\uff0c\u9700\u8981\u52a0\u4e0ahttp:\/\/:\u524d\u7db4\u55ce\uff1f",
+"Link list": "\u9023\u7d50\u6e05\u55ae",
+"Insert video": "\u63d2\u5165\u5f71\u97f3",
+"Insert\/edit video": "\u63d2\u4ef6\/\u7de8\u8f2f \u5f71\u97f3",
+"Insert\/edit media": "\u63d2\u5165\/\u7de8\u8f2f \u5a92\u9ad4",
+"Alternative source": "\u66ff\u4ee3\u5f71\u97f3",
+"Alternative source URL": "\u66ff\u4ee3\u4f86\u6e90URL",
+"Media poster (Image URL)": "\u5a92\u9ad4\u6d77\u5831\uff08\u5f71\u50cfImage URL\uff09",
+"Paste your embed code below:": "\u8acb\u5c07\u60a8\u7684\u5d4c\u5165\u5f0f\u7a0b\u5f0f\u78bc\u8cbc\u5728\u4e0b\u9762:",
+"Embed": "\u5d4c\u5165\u78bc",
+"Media...": "\u5a92\u9ad4...",
+"Nonbreaking space": "\u4e0d\u5206\u884c\u7684\u7a7a\u683c",
+"Page break": "\u5206\u9801",
+"Paste as text": "\u4ee5\u7d14\u6587\u5b57\u8cbc\u4e0a",
+"Preview": "\u9810\u89bd",
+"Print...": "\u5217\u5370...",
+"Save": "\u5132\u5b58",
+"Find": "\u641c\u5c0b",
+"Replace with": "\u66f4\u63db",
+"Replace": "\u66ff\u63db",
+"Replace all": "\u66ff\u63db\u5168\u90e8",
+"Previous": "\u4e0a\u4e00\u500b",
+"Next": "\u4e0b\u4e00\u500b",
+"Find and replace...": "\u5c0b\u627e\u53ca\u53d6\u4ee3...",
+"Could not find the specified string.": "\u7121\u6cd5\u67e5\u8a62\u5230\u6b64\u7279\u5b9a\u5b57\u4e32",
+"Match case": "\u76f8\u5339\u914d\u6848\u4ef6",
+"Find whole words only": "\u50c5\u627e\u51fa\u5b8c\u6574\u5b57\u532f",
+"Spell check": "\u62fc\u5beb\u6aa2\u67e5",
+"Ignore": "\u5ffd\u7565",
+"Ignore all": "\u5ffd\u7565\u6240\u6709",
+"Finish": "\u5b8c\u6210",
+"Add to Dictionary": "\u52a0\u5165\u5b57\u5178\u4e2d",
+"Insert table": "\u63d2\u5165\u8868\u683c",
+"Table properties": "\u8868\u683c\u5c6c\u6027",
+"Delete table": "\u522a\u9664\u8868\u683c",
+"Cell": "\u5132\u5b58\u683c",
+"Row": "\u5217",
+"Column": "\u884c",
+"Cell properties": "\u5132\u5b58\u683c\u5c6c\u6027",
+"Merge cells": "\u5408\u4f75\u5132\u5b58\u683c",
+"Split cell": "\u5206\u5272\u5132\u5b58\u683c",
+"Insert row before": "\u63d2\u5165\u5217\u5728...\u4e4b\u524d",
+"Insert row after": "\u63d2\u5165\u5217\u5728...\u4e4b\u5f8c",
+"Delete row": "\u522a\u9664\u5217",
+"Row properties": "\u5217\u5c6c\u6027",
+"Cut row": "\u526a\u4e0b\u5217",
+"Copy row": "\u8907\u88fd\u5217",
+"Paste row before": "\u8cbc\u4e0a\u5217\u5728...\u4e4b\u524d",
+"Paste row after": "\u8cbc\u4e0a\u5217\u5728...\u4e4b\u5f8c",
+"Insert column before": "\u63d2\u5165\u6b04\u4f4d\u5728...\u4e4b\u524d",
+"Insert column after": "\u63d2\u5165\u6b04\u4f4d\u5728...\u4e4b\u5f8c",
+"Delete column": "\u522a\u9664\u884c",
+"Cols": "\u6b04\u4f4d\u6bb5",
+"Rows": "\u5217",
+"Width": "\u5bec\u5ea6",
+"Height": "\u9ad8\u5ea6",
+"Cell spacing": "\u5132\u5b58\u683c\u5f97\u9593\u8ddd",
+"Cell padding": "\u5132\u5b58\u683c\u7684\u908a\u8ddd",
+"Show caption": "\u986f\u793a\u6a19\u984c",
+"Left": "\u5de6\u908a",
+"Center": "\u4e2d\u9593",
+"Right": "\u53f3\u908a",
+"Cell type": "\u5132\u5b58\u683c\u7684\u985e\u578b",
+"Scope": "\u7bc4\u570d",
+"Alignment": "\u5c0d\u9f4a",
+"H Align": "\u6c34\u5e73\u4f4d\u7f6e",
+"V Align": "\u5782\u76f4\u4f4d\u7f6e",
+"Top": "\u7f6e\u9802",
+"Middle": "\u7f6e\u4e2d",
+"Bottom": "\u7f6e\u5e95",
+"Header cell": "\u6a19\u982d\u5132\u5b58\u683c",
+"Row group": "\u5217\u7fa4\u7d44",
+"Column group": "\u6b04\u4f4d\u7fa4\u7d44",
+"Row type": "\u884c\u7684\u985e\u578b",
+"Header": "\u6a19\u982d",
+"Body": "\u4e3b\u9ad4",
+"Footer": "\u9801\u5c3e",
+"Border color": "\u908a\u6846\u984f\u8272",
+"Insert template...": "\u63d2\u5165\u6a23\u7248...",
+"Templates": "\u6a23\u7248",
+"Template": "\u6a23\u677f",
+"Text color": "\u6587\u5b57\u984f\u8272",
+"Background color": "\u80cc\u666f\u984f\u8272",
+"Custom...": "\u81ea\u8a02",
+"Custom color": "\u81ea\u8a02\u984f\u8272",
+"No color": "No color",
+"Remove color": "\u79fb\u9664\u984f\u8272",
+"Table of Contents": "\u76ee\u9304",
+"Show blocks": "\u986f\u793a\u5340\u584a\u8cc7\u8a0a",
+"Show invisible characters": "\u986f\u793a\u96b1\u85cf\u5b57\u5143",
+"Word count": "\u8a08\u7b97\u5b57\u6578",
+"Count": "\u8a08\u7b97",
+"Document": "\u6587\u4ef6",
+"Selection": "\u9078\u9805",
+"Words": "\u5b57\u6578",
+"Words: {0}": "\u5b57\u6578\uff1a{0}",
+"{0} words": "{0} \u5b57\u5143",
+"File": "\u6a94\u6848",
+"Edit": "\u7de8\u8f2f",
+"Insert": "\u63d2\u5165",
+"View": "\u6aa2\u8996",
+"Format": "\u683c\u5f0f",
+"Table": "\u8868\u683c",
+"Tools": "\u5de5\u5177",
+"Powered by {0}": "\u7531 {0} \u63d0\u4f9b",
+"Rich Text Area. Press ALT-F9 for menu. Press ALT-F10 for toolbar. Press ALT-0 for help": "\u8c50\u5bcc\u7684\u6587\u672c\u5340\u57df\u3002\u6309ALT-F9\u524d\u5f80\u4e3b\u9078\u55ae\u3002\u6309ALT-F10\u547c\u53eb\u5de5\u5177\u6b04\u3002\u6309ALT-0\u5c0b\u6c42\u5e6b\u52a9",
+"Image title": "\u5716\u7247\u6a19\u984c",
+"Border width": "\u6846\u7dda\u5bec\u5ea6",
+"Border style": "\u6846\u7dda\u6a23\u5f0f",
+"Error": "\u932f\u8aa4",
+"Warn": "\u8b66\u544a",
+"Valid": "\u6709\u6548",
+"To open the popup, press Shift+Enter": "\u8981\u958b\u555f\u5f48\u51fa\u8996\u7a97\uff0c\u8acb\u6309Shift+Enter",
+"Rich Text Area. Press ALT-0 for help.": "\u5bcc\u6587\u672c\u5340\u57df\u3002\u8acb\u6309ALT-0\u5c0b\u6c42\u5354\u52a9\u3002",
+"System Font": "\u7cfb\u7d71\u5b57\u578b",
+"Failed to upload image: {0}": "\u7121\u6cd5\u4e0a\u50b3\u5f71\u50cf\uff1a{0}",
+"Failed to load plugin: {0} from url {1}": "\u7121\u6cd5\u4e0a\u50b3\u63d2\u4ef6\uff1a{0}\u81eaurl{1}",
+"Failed to load plugin url: {0}": "\u7121\u6cd5\u4e0a\u50b3\u63d2\u4ef6\uff1a{0}",
+"Failed to initialize plugin: {0}": "\u7121\u6cd5\u555f\u52d5\u63d2\u4ef6\uff1a{0}",
+"example": "\u7bc4\u4f8b",
+"Search": "\u641c\u7d22",
+"All": "\u5168\u90e8",
+"Currency": "\u8ca8\u5e63",
+"Text": "\u6587\u672c",
+"Quotations": "\u5f15\u7528",
+"Mathematical": "\u6578\u5b78",
+"Extended Latin": "\u62c9\u4e01\u5b57\u6bcd\u64f4\u5145",
+"Symbols": "\u7b26\u865f",
+"Arrows": "\u7bad\u982d",
+"User Defined": "\u4f7f\u7528\u8005\u5df2\u5b9a\u7fa9",
+"dollar sign": "\u7f8e\u5143\u7b26\u865f",
+"currency sign": "\u8ca8\u5e63\u7b26\u865f",
+"euro-currency sign": "\u6b50\u5143\u7b26\u865f",
+"colon sign": "\u79d1\u6717\u7b26\u865f",
+"cruzeiro sign": "\u514b\u9b6f\u8cfd\u7f85\u7b26\u865f",
+"french franc sign": "\u6cd5\u6717\u7b26\u865f",
+"lira sign": "\u91cc\u62c9\u7b26\u865f",
+"mill sign": "\u6587\u7b26\u865f",
+"naira sign": "\u5948\u62c9\u7b26\u865f",
+"peseta sign": "\u6bd4\u585e\u5854\u7b26\u865f",
+"rupee sign": "\u76e7\u6bd4\u7b26\u865f",
+"won sign": "\u97d3\u571c\u7b26\u865f",
+"new sheqel sign": "\u65b0\u8b1d\u514b\u723e\u7b26\u865f",
+"dong sign": "\u8d8a\u5357\u76fe\u7b26\u865f",
+"kip sign": "\u8001\u64be\u5e63\u7b26\u865f",
+"tugrik sign": "\u8499\u53e4\u5e63\u7b26\u865f",
+"drachma sign": "\u5fb7\u514b\u62c9\u99ac\u7b26\u865f",
+"german penny symbol": "\u5fb7\u570b\u5206\u7b26\u865f",
+"peso sign": "\u62ab\u7d22\u7b26\u865f",
+"guarani sign": "\u5df4\u62c9\u572d\u5e63\u7b26\u865f",
+"austral sign": "\u963f\u6839\u5ef7\u5e63\u7b26\u865f",
+"hryvnia sign": "\u70cf\u514b\u862d\u5e63\u7b26\u865f",
+"cedi sign": "\u8fe6\u7d0d\u5e63\u7b26\u865f",
+"livre tournois sign": "\u91cc\u5f17\u723e\u7b26\u865f",
+"spesmilo sign": "\u570b\u969b\u5e63\u7b26\u865f",
+"tenge sign": "\u54c8\u85a9\u514b\u5e63\u7b26\u865f",
+"indian rupee sign": "\u5370\u5ea6\u76e7\u6bd4\u7b26\u865f",
+"turkish lira sign": "\u571f\u8033\u5176\u91cc\u62c9\u7b26\u865f",
+"nordic mark sign": "\u5317\u6b50\u99ac\u514b\u7b26\u865f",
+"manat sign": "\u4e9e\u585e\u62dc\u7136\u5e63\u7b26\u865f",
+"ruble sign": "\u76e7\u5e03\u7b26\u865f",
+"yen character": "\u65e5\u5713\u7b26\u865f",
+"yuan character": "\u4eba\u6c11\u5e63\u7b26\u865f",
+"yuan character, in hong kong and taiwan": "\u6e2f\u5143\u8207\u53f0\u5e63\u7b26\u865f",
+"yen\/yuan character variant one": "\u65e5\u5713\/\u4eba\u6c11\u5e63\u7b26\u865f\u8b8a\u5316\u578b",
+"Loading emoticons...": "\u8f09\u5165\u8868\u60c5\u7b26\u865f\u2026",
+"Could not load emoticons": "\u7121\u6cd5\u8f09\u5165\u8868\u60c5\u7b26\u865f",
+"People": "\u4eba",
+"Animals and Nature": "\u52d5\u7269\u8207\u81ea\u7136",
+"Food and Drink": "\u98f2\u98df",
+"Activity": "\u6d3b\u52d5",
+"Travel and Places": "\u65c5\u884c\u8207\u5730\u9ede",
+"Objects": "\u7269\u4ef6",
+"Flags": "\u65d7\u6a19",
+"Characters": "\u5b57\u5143",
+"Characters (no spaces)": "\u5b57\u5143\uff08\u7121\u7a7a\u683c\uff09",
+"{0} characters": "{0}\u5b57\u5143",
+"Error: Form submit field collision.": "\u932f\u8aa4\uff1a\u8868\u683c\u905e\u4ea4\u6b04\u4f4d\u885d\u7a81\u3002",
+"Error: No form element found.": "\u932f\u8aa4\uff1a\u627e\u4e0d\u5230\u8868\u683c\u5143\u7d20\u3002",
+"Update": "\u66f4\u65b0",
+"Color swatch": "\u8272\u5f69\u6a23\u672c",
+"Turquoise": "\u571f\u8033\u5176\u85cd",
+"Green": "\u7da0\u8272",
+"Blue": "\u85cd\u8272",
+"Purple": "\u7d2b\u8272",
+"Navy Blue": "\u6df1\u85cd\u8272",
+"Dark Turquoise": "\u6df1\u571f\u8033\u5176\u85cd",
+"Dark Green": "\u6df1\u7da0\u8272",
+"Medium Blue": "\u4e2d\u85cd\u8272",
+"Medium Purple": "\u4e2d\u7d2b\u8272",
+"Midnight Blue": "\u9ed1\u85cd\u8272",
+"Yellow": "\u9ec3\u8272",
+"Orange": "\u6a59\u8272",
+"Red": "\u7d05\u8272",
+"Light Gray": "\u6dfa\u7070\u8272",
+"Gray": "\u7070\u8272",
+"Dark Yellow": "\u6df1\u9ec3\u8272",
+"Dark Orange": "\u6df1\u6a59\u8272",
+"Dark Red": "\u6697\u7d05\u8272",
+"Medium Gray": "\u4e2d\u7070\u8272",
+"Dark Gray": "\u6df1\u7070\u8272",
+"Light Green": "\u6de1\u7da0\u8272",
+"Light Yellow": "\u6dfa\u9ec3\u8272",
+"Light Red": "\u6dfa\u7d05\u8272",
+"Light Purple": "\u6dfa\u7d2b\u8272",
+"Light Blue": "\u6dfa\u85cd\u8272",
+"Dark Purple": "\u6df1\u7d2b\u8272",
+"Dark Blue": "\u6df1\u85cd\u8272",
+"Black": "\u9ed1\u8272",
+"White": "\u767d\u8272",
+"Switch to or from fullscreen mode": "\u8f49\u63db\u81ea\/\u81f3\u5168\u87a2\u5e55\u6a21\u5f0f",
+"Open help dialog": "\u958b\u555f\u5354\u52a9\u5c0d\u8a71",
+"history": "\u6b77\u53f2",
+"styles": "\u6a23\u5f0f",
+"formatting": "\u683c\u5f0f",
+"alignment": "\u5c0d\u9f4a",
+"indentation": "\u7e2e\u6392",
+"permanent pen": "\u6c38\u4e45\u6027\u7b46",
+"comments": "\u8a3b\u89e3",
+"Format Painter": "\u8907\u88fd\u683c\u5f0f",
+"Insert\/edit iframe": "\u63d2\u5165\/\u7de8\u8f2fiframe",
+"Capitalization": "\u5927\u5beb",
+"lowercase": "\u5c0f\u5beb",
+"UPPERCASE": "\u5927\u5beb",
+"Title Case": "\u5b57\u9996\u5927\u5beb",
+"Permanent Pen Properties": "\u6c38\u4e45\u6a19\u8a18\u5c6c\u6027",
+"Permanent pen properties...": "\u6c38\u4e45\u6a19\u8a18\u5c6c\u6027......",
+"Font": "\u5b57\u578b",
+"Size": "\u5b57\u5f62\u5927\u5c0f",
+"More...": "\u66f4\u591a\u8cc7\u8a0a......",
+"Spellcheck Language": "\u62fc\u5beb\u8a9e\u8a00",
+"Select...": "\u9078\u64c7......",
+"Preferences": "\u9996\u9078\u9805",
+"Yes": "\u662f",
+"No": "\u5426",
+"Keyboard Navigation": "\u9375\u76e4\u5c0e\u822a",
+"Version": "\u7248\u672c",
+"Anchor": "\u52a0\u5165\u9328\u9ede",
+"Special character": "\u7279\u6b8a\u5b57\u5143",
+"Code sample": "\u7a0b\u5f0f\u78bc\u7bc4\u4f8b",
+"Color": "\u984f\u8272",
+"Emoticons": "\u8868\u60c5",
+"Document properties": "\u6587\u4ef6\u7684\u5c6c\u6027",
+"Image": "\u5716\u7247",
+"Insert link": "\u63d2\u5165\u9023\u7d50",
+"Target": "\u958b\u555f\u65b9\u5f0f",
+"Link": "\u9023\u7d50",
+"Poster": "\u9810\u89bd\u5716\u7247",
+"Media": "\u5a92\u9ad4",
+"Print": "\u5217\u5370",
+"Prev": "\u4e0a\u4e00\u500b",
+"Find and replace": "\u5c0b\u627e\u53ca\u53d6\u4ee3",
+"Whole words": "\u6574\u500b\u55ae\u5b57",
+"Spellcheck": "\u62fc\u5b57\u6aa2\u67e5",
+"Caption": "\u8868\u683c\u6a19\u984c",
+"Insert template": "\u63d2\u5165\u6a23\u7248"
+});
\ No newline at end of file
diff --git a/public/tinymce/skins/content/dark/content.css b/public/tinymce/skins/content/dark/content.css
new file mode 100644
index 0000000..bae7923
--- /dev/null
+++ b/public/tinymce/skins/content/dark/content.css
@@ -0,0 +1,72 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+body {
+  background-color: #2f3742;
+  color: #dfe0e4;
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
+  line-height: 1.4;
+  margin: 1rem;
+}
+a {
+  color: #4099ff;
+}
+table {
+  border-collapse: collapse;
+}
+/* Apply a default padding if legacy cellpadding attribute is missing */
+table:not([cellpadding]) th,
+table:not([cellpadding]) td {
+  padding: 0.4rem;
+}
+/* Set default table styles if a table has a positive border attribute
+   and no inline css */
+table[border]:not([border="0"]):not([style*="border-width"]) th,
+table[border]:not([border="0"]):not([style*="border-width"]) td {
+  border-width: 1px;
+}
+/* Set default table styles if a table has a positive border attribute
+   and no inline css */
+table[border]:not([border="0"]):not([style*="border-style"]) th,
+table[border]:not([border="0"]):not([style*="border-style"]) td {
+  border-style: solid;
+}
+/* Set default table styles if a table has a positive border attribute
+   and no inline css */
+table[border]:not([border="0"]):not([style*="border-color"]) th,
+table[border]:not([border="0"]):not([style*="border-color"]) td {
+  border-color: #6d737b;
+}
+figure {
+  display: table;
+  margin: 1rem auto;
+}
+figure figcaption {
+  color: #8a8f97;
+  display: block;
+  margin-top: 0.25rem;
+  text-align: center;
+}
+hr {
+  border-color: #6d737b;
+  border-style: solid;
+  border-width: 1px 0 0 0;
+}
+code {
+  background-color: #6d737b;
+  border-radius: 3px;
+  padding: 0.1rem 0.2rem;
+}
+.mce-content-body:not([dir=rtl]) blockquote {
+  border-left: 2px solid #6d737b;
+  margin-left: 1.5rem;
+  padding-left: 1rem;
+}
+.mce-content-body[dir=rtl] blockquote {
+  border-right: 2px solid #6d737b;
+  margin-right: 1.5rem;
+  padding-right: 1rem;
+}
diff --git a/public/tinymce/skins/content/dark/content.min.css b/public/tinymce/skins/content/dark/content.min.css
new file mode 100644
index 0000000..07d40c2
--- /dev/null
+++ b/public/tinymce/skins/content/dark/content.min.css
@@ -0,0 +1,7 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+body{background-color:#2f3742;color:#dfe0e4;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif;line-height:1.4;margin:1rem}a{color:#4099ff}table{border-collapse:collapse}table:not([cellpadding]) td,table:not([cellpadding]) th{padding:.4rem}table[border]:not([border="0"]):not([style*=border-width]) td,table[border]:not([border="0"]):not([style*=border-width]) th{border-width:1px}table[border]:not([border="0"]):not([style*=border-style]) td,table[border]:not([border="0"]):not([style*=border-style]) th{border-style:solid}table[border]:not([border="0"]):not([style*=border-color]) td,table[border]:not([border="0"]):not([style*=border-color]) th{border-color:#6d737b}figure{display:table;margin:1rem auto}figure figcaption{color:#8a8f97;display:block;margin-top:.25rem;text-align:center}hr{border-color:#6d737b;border-style:solid;border-width:1px 0 0 0}code{background-color:#6d737b;border-radius:3px;padding:.1rem .2rem}.mce-content-body:not([dir=rtl]) blockquote{border-left:2px solid #6d737b;margin-left:1.5rem;padding-left:1rem}.mce-content-body[dir=rtl] blockquote{border-right:2px solid #6d737b;margin-right:1.5rem;padding-right:1rem}
diff --git a/public/tinymce/skins/content/default/content.css b/public/tinymce/skins/content/default/content.css
new file mode 100644
index 0000000..dd6a5c1
--- /dev/null
+++ b/public/tinymce/skins/content/default/content.css
@@ -0,0 +1,67 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+body {
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
+  line-height: 1.4;
+  margin: 1rem;
+}
+table {
+  border-collapse: collapse;
+}
+/* Apply a default padding if legacy cellpadding attribute is missing */
+table:not([cellpadding]) th,
+table:not([cellpadding]) td {
+  padding: 0.4rem;
+}
+/* Set default table styles if a table has a positive border attribute
+   and no inline css */
+table[border]:not([border="0"]):not([style*="border-width"]) th,
+table[border]:not([border="0"]):not([style*="border-width"]) td {
+  border-width: 1px;
+}
+/* Set default table styles if a table has a positive border attribute
+   and no inline css */
+table[border]:not([border="0"]):not([style*="border-style"]) th,
+table[border]:not([border="0"]):not([style*="border-style"]) td {
+  border-style: solid;
+}
+/* Set default table styles if a table has a positive border attribute
+   and no inline css */
+table[border]:not([border="0"]):not([style*="border-color"]) th,
+table[border]:not([border="0"]):not([style*="border-color"]) td {
+  border-color: #ccc;
+}
+figure {
+  display: table;
+  margin: 1rem auto;
+}
+figure figcaption {
+  color: #999;
+  display: block;
+  margin-top: 0.25rem;
+  text-align: center;
+}
+hr {
+  border-color: #ccc;
+  border-style: solid;
+  border-width: 1px 0 0 0;
+}
+code {
+  background-color: #e8e8e8;
+  border-radius: 3px;
+  padding: 0.1rem 0.2rem;
+}
+.mce-content-body:not([dir=rtl]) blockquote {
+  border-left: 2px solid #ccc;
+  margin-left: 1.5rem;
+  padding-left: 1rem;
+}
+.mce-content-body[dir=rtl] blockquote {
+  border-right: 2px solid #ccc;
+  margin-right: 1.5rem;
+  padding-right: 1rem;
+}
diff --git a/public/tinymce/skins/content/default/content.min.css b/public/tinymce/skins/content/default/content.min.css
new file mode 100644
index 0000000..29cd987
--- /dev/null
+++ b/public/tinymce/skins/content/default/content.min.css
@@ -0,0 +1,7 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif;line-height:1.4;margin:1rem}table{border-collapse:collapse}table:not([cellpadding]) td,table:not([cellpadding]) th{padding:.4rem}table[border]:not([border="0"]):not([style*=border-width]) td,table[border]:not([border="0"]):not([style*=border-width]) th{border-width:1px}table[border]:not([border="0"]):not([style*=border-style]) td,table[border]:not([border="0"]):not([style*=border-style]) th{border-style:solid}table[border]:not([border="0"]):not([style*=border-color]) td,table[border]:not([border="0"]):not([style*=border-color]) th{border-color:#ccc}figure{display:table;margin:1rem auto}figure figcaption{color:#999;display:block;margin-top:.25rem;text-align:center}hr{border-color:#ccc;border-style:solid;border-width:1px 0 0 0}code{background-color:#e8e8e8;border-radius:3px;padding:.1rem .2rem}.mce-content-body:not([dir=rtl]) blockquote{border-left:2px solid #ccc;margin-left:1.5rem;padding-left:1rem}.mce-content-body[dir=rtl] blockquote{border-right:2px solid #ccc;margin-right:1.5rem;padding-right:1rem}
diff --git a/public/tinymce/skins/content/document/content.css b/public/tinymce/skins/content/document/content.css
new file mode 100644
index 0000000..75f637a
--- /dev/null
+++ b/public/tinymce/skins/content/document/content.css
@@ -0,0 +1,72 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+@media screen {
+  html {
+    background: #f4f4f4;
+    min-height: 100%;
+  }
+}
+body {
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
+}
+@media screen {
+  body {
+    background-color: #fff;
+    box-shadow: 0 0 4px rgba(0, 0, 0, 0.15);
+    box-sizing: border-box;
+    margin: 1rem auto 0;
+    max-width: 820px;
+    min-height: calc(100vh - 1rem);
+    padding: 4rem 6rem 6rem 6rem;
+  }
+}
+table {
+  border-collapse: collapse;
+}
+/* Apply a default padding if legacy cellpadding attribute is missing */
+table:not([cellpadding]) th,
+table:not([cellpadding]) td {
+  padding: 0.4rem;
+}
+/* Set default table styles if a table has a positive border attribute
+   and no inline css */
+table[border]:not([border="0"]):not([style*="border-width"]) th,
+table[border]:not([border="0"]):not([style*="border-width"]) td {
+  border-width: 1px;
+}
+/* Set default table styles if a table has a positive border attribute
+   and no inline css */
+table[border]:not([border="0"]):not([style*="border-style"]) th,
+table[border]:not([border="0"]):not([style*="border-style"]) td {
+  border-style: solid;
+}
+/* Set default table styles if a table has a positive border attribute
+   and no inline css */
+table[border]:not([border="0"]):not([style*="border-color"]) th,
+table[border]:not([border="0"]):not([style*="border-color"]) td {
+  border-color: #ccc;
+}
+figure figcaption {
+  color: #999;
+  margin-top: 0.25rem;
+  text-align: center;
+}
+hr {
+  border-color: #ccc;
+  border-style: solid;
+  border-width: 1px 0 0 0;
+}
+.mce-content-body:not([dir=rtl]) blockquote {
+  border-left: 2px solid #ccc;
+  margin-left: 1.5rem;
+  padding-left: 1rem;
+}
+.mce-content-body[dir=rtl] blockquote {
+  border-right: 2px solid #ccc;
+  margin-right: 1.5rem;
+  padding-right: 1rem;
+}
diff --git a/public/tinymce/skins/content/document/content.min.css b/public/tinymce/skins/content/document/content.min.css
new file mode 100644
index 0000000..a1feef4
--- /dev/null
+++ b/public/tinymce/skins/content/document/content.min.css
@@ -0,0 +1,7 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+@media screen{html{background:#f4f4f4;min-height:100%}}body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif}@media screen{body{background-color:#fff;box-shadow:0 0 4px rgba(0,0,0,.15);box-sizing:border-box;margin:1rem auto 0;max-width:820px;min-height:calc(100vh - 1rem);padding:4rem 6rem 6rem 6rem}}table{border-collapse:collapse}table:not([cellpadding]) td,table:not([cellpadding]) th{padding:.4rem}table[border]:not([border="0"]):not([style*=border-width]) td,table[border]:not([border="0"]):not([style*=border-width]) th{border-width:1px}table[border]:not([border="0"]):not([style*=border-style]) td,table[border]:not([border="0"]):not([style*=border-style]) th{border-style:solid}table[border]:not([border="0"]):not([style*=border-color]) td,table[border]:not([border="0"]):not([style*=border-color]) th{border-color:#ccc}figure figcaption{color:#999;margin-top:.25rem;text-align:center}hr{border-color:#ccc;border-style:solid;border-width:1px 0 0 0}.mce-content-body:not([dir=rtl]) blockquote{border-left:2px solid #ccc;margin-left:1.5rem;padding-left:1rem}.mce-content-body[dir=rtl] blockquote{border-right:2px solid #ccc;margin-right:1.5rem;padding-right:1rem}
diff --git a/public/tinymce/skins/content/writer/content.css b/public/tinymce/skins/content/writer/content.css
new file mode 100644
index 0000000..ceee359
--- /dev/null
+++ b/public/tinymce/skins/content/writer/content.css
@@ -0,0 +1,68 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+body {
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
+  line-height: 1.4;
+  margin: 1rem auto;
+  max-width: 900px;
+}
+table {
+  border-collapse: collapse;
+}
+/* Apply a default padding if legacy cellpadding attribute is missing */
+table:not([cellpadding]) th,
+table:not([cellpadding]) td {
+  padding: 0.4rem;
+}
+/* Set default table styles if a table has a positive border attribute
+   and no inline css */
+table[border]:not([border="0"]):not([style*="border-width"]) th,
+table[border]:not([border="0"]):not([style*="border-width"]) td {
+  border-width: 1px;
+}
+/* Set default table styles if a table has a positive border attribute
+   and no inline css */
+table[border]:not([border="0"]):not([style*="border-style"]) th,
+table[border]:not([border="0"]):not([style*="border-style"]) td {
+  border-style: solid;
+}
+/* Set default table styles if a table has a positive border attribute
+   and no inline css */
+table[border]:not([border="0"]):not([style*="border-color"]) th,
+table[border]:not([border="0"]):not([style*="border-color"]) td {
+  border-color: #ccc;
+}
+figure {
+  display: table;
+  margin: 1rem auto;
+}
+figure figcaption {
+  color: #999;
+  display: block;
+  margin-top: 0.25rem;
+  text-align: center;
+}
+hr {
+  border-color: #ccc;
+  border-style: solid;
+  border-width: 1px 0 0 0;
+}
+code {
+  background-color: #e8e8e8;
+  border-radius: 3px;
+  padding: 0.1rem 0.2rem;
+}
+.mce-content-body:not([dir=rtl]) blockquote {
+  border-left: 2px solid #ccc;
+  margin-left: 1.5rem;
+  padding-left: 1rem;
+}
+.mce-content-body[dir=rtl] blockquote {
+  border-right: 2px solid #ccc;
+  margin-right: 1.5rem;
+  padding-right: 1rem;
+}
diff --git a/public/tinymce/skins/content/writer/content.min.css b/public/tinymce/skins/content/writer/content.min.css
new file mode 100644
index 0000000..0d8f5d3
--- /dev/null
+++ b/public/tinymce/skins/content/writer/content.min.css
@@ -0,0 +1,7 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif;line-height:1.4;margin:1rem auto;max-width:900px}table{border-collapse:collapse}table:not([cellpadding]) td,table:not([cellpadding]) th{padding:.4rem}table[border]:not([border="0"]):not([style*=border-width]) td,table[border]:not([border="0"]):not([style*=border-width]) th{border-width:1px}table[border]:not([border="0"]):not([style*=border-style]) td,table[border]:not([border="0"]):not([style*=border-style]) th{border-style:solid}table[border]:not([border="0"]):not([style*=border-color]) td,table[border]:not([border="0"]):not([style*=border-color]) th{border-color:#ccc}figure{display:table;margin:1rem auto}figure figcaption{color:#999;display:block;margin-top:.25rem;text-align:center}hr{border-color:#ccc;border-style:solid;border-width:1px 0 0 0}code{background-color:#e8e8e8;border-radius:3px;padding:.1rem .2rem}.mce-content-body:not([dir=rtl]) blockquote{border-left:2px solid #ccc;margin-left:1.5rem;padding-left:1rem}.mce-content-body[dir=rtl] blockquote{border-right:2px solid #ccc;margin-right:1.5rem;padding-right:1rem}
diff --git a/public/tinymce/skins/ui/oxide-dark/content.css b/public/tinymce/skins/ui/oxide-dark/content.css
new file mode 100644
index 0000000..9c0e3a8
--- /dev/null
+++ b/public/tinymce/skins/ui/oxide-dark/content.css
@@ -0,0 +1,714 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+.mce-content-body .mce-item-anchor {
+  background: transparent url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'8'%20height%3D'12'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20d%3D'M0%200L8%200%208%2012%204.09117821%209%200%2012z'%20fill%3D%22%23cccccc%22%2F%3E%3C%2Fsvg%3E%0A") no-repeat center;
+  cursor: default;
+  display: inline-block;
+  height: 12px !important;
+  padding: 0 2px;
+  -webkit-user-modify: read-only;
+  -moz-user-modify: read-only;
+  -webkit-user-select: all;
+  -moz-user-select: all;
+  -ms-user-select: all;
+      user-select: all;
+  width: 8px !important;
+}
+.mce-content-body .mce-item-anchor[data-mce-selected] {
+  outline-offset: 1px;
+}
+.tox-comments-visible .tox-comment {
+  background-color: #fff0b7;
+}
+.tox-comments-visible .tox-comment--active {
+  background-color: #ffe168;
+}
+.tox-checklist > li:not(.tox-checklist--hidden) {
+  list-style: none;
+  margin: 0.25em 0;
+}
+.tox-checklist > li:not(.tox-checklist--hidden)::before {
+  content: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cg%20id%3D%22checklist-unchecked%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Crect%20id%3D%22Rectangle%22%20width%3D%2215%22%20height%3D%2215%22%20x%3D%22.5%22%20y%3D%22.5%22%20fill-rule%3D%22nonzero%22%20stroke%3D%22%236d737b%22%20rx%3D%222%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E%0A");
+  cursor: pointer;
+  height: 1em;
+  margin-left: -1.5em;
+  margin-top: 0.125em;
+  position: absolute;
+  width: 1em;
+}
+.tox-checklist li:not(.tox-checklist--hidden).tox-checklist--checked::before {
+  content: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cg%20id%3D%22checklist-checked%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Crect%20id%3D%22Rectangle%22%20width%3D%2216%22%20height%3D%2216%22%20fill%3D%22%234099FF%22%20fill-rule%3D%22nonzero%22%20rx%3D%222%22%2F%3E%3Cpath%20id%3D%22Path%22%20fill%3D%22%23FFF%22%20fill-rule%3D%22nonzero%22%20d%3D%22M11.5703186%2C3.14417309%20C11.8516238%2C2.73724603%2012.4164781%2C2.62829933%2012.83558%2C2.89774797%20C13.260121%2C3.17069355%2013.3759736%2C3.72932262%2013.0909105%2C4.14168582%20L7.7580587%2C11.8560195%20C7.43776896%2C12.3193404%206.76483983%2C12.3852142%206.35607322%2C11.9948725%20L3.02491697%2C8.8138662%20C2.66090143%2C8.46625845%202.65798871%2C7.89594698%203.01850234%2C7.54483354%20C3.373942%2C7.19866177%203.94940006%2C7.19592841%204.30829608%2C7.5386474%20L6.85276923%2C9.9684299%20L11.5703186%2C3.14417309%20Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E%0A");
+}
+[dir=rtl] .tox-checklist > li:not(.tox-checklist--hidden)::before {
+  margin-left: 0;
+  margin-right: -1.5em;
+}
+/* stylelint-disable */
+/* http://prismjs.com/ */
+/**
+ * Dracula Theme originally by Zeno Rocha [@zenorocha]
+ * https://draculatheme.com/
+ *
+ * Ported for PrismJS by Albert Vallverdu [@byverdu]
+ */
+code[class*="language-"],
+pre[class*="language-"] {
+  color: #f8f8f2;
+  background: none;
+  text-shadow: 0 1px rgba(0, 0, 0, 0.3);
+  font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
+  text-align: left;
+  white-space: pre;
+  word-spacing: normal;
+  word-break: normal;
+  word-wrap: normal;
+  line-height: 1.5;
+  -moz-tab-size: 4;
+  tab-size: 4;
+  -webkit-hyphens: none;
+  -ms-hyphens: none;
+  hyphens: none;
+}
+/* Code blocks */
+pre[class*="language-"] {
+  padding: 1em;
+  margin: 0.5em 0;
+  overflow: auto;
+  border-radius: 0.3em;
+}
+:not(pre) > code[class*="language-"],
+pre[class*="language-"] {
+  background: #282a36;
+}
+/* Inline code */
+:not(pre) > code[class*="language-"] {
+  padding: 0.1em;
+  border-radius: 0.3em;
+  white-space: normal;
+}
+.token.comment,
+.token.prolog,
+.token.doctype,
+.token.cdata {
+  color: #6272a4;
+}
+.token.punctuation {
+  color: #f8f8f2;
+}
+.namespace {
+  opacity: 0.7;
+}
+.token.property,
+.token.tag,
+.token.constant,
+.token.symbol,
+.token.deleted {
+  color: #ff79c6;
+}
+.token.boolean,
+.token.number {
+  color: #bd93f9;
+}
+.token.selector,
+.token.attr-name,
+.token.string,
+.token.char,
+.token.builtin,
+.token.inserted {
+  color: #50fa7b;
+}
+.token.operator,
+.token.entity,
+.token.url,
+.language-css .token.string,
+.style .token.string,
+.token.variable {
+  color: #f8f8f2;
+}
+.token.atrule,
+.token.attr-value,
+.token.function,
+.token.class-name {
+  color: #f1fa8c;
+}
+.token.keyword {
+  color: #8be9fd;
+}
+.token.regex,
+.token.important {
+  color: #ffb86c;
+}
+.token.important,
+.token.bold {
+  font-weight: bold;
+}
+.token.italic {
+  font-style: italic;
+}
+.token.entity {
+  cursor: help;
+}
+/* stylelint-enable */
+.mce-content-body {
+  overflow-wrap: break-word;
+  word-wrap: break-word;
+}
+.mce-content-body .mce-visual-caret {
+  background-color: black;
+  background-color: currentColor;
+  position: absolute;
+}
+.mce-content-body .mce-visual-caret-hidden {
+  display: none;
+}
+.mce-content-body *[data-mce-caret] {
+  left: -1000px;
+  margin: 0;
+  padding: 0;
+  position: absolute;
+  right: auto;
+  top: 0;
+}
+.mce-content-body .mce-offscreen-selection {
+  left: -2000000px;
+  max-width: 1000000px;
+  position: absolute;
+}
+.mce-content-body *[contentEditable=false] {
+  cursor: default;
+}
+.mce-content-body *[contentEditable=true] {
+  cursor: text;
+}
+.tox-cursor-format-painter {
+  cursor: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%3E%0A%20%20%3Cg%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%0A%20%20%20%20%3Cpath%20fill%3D%22%23000%22%20fill-rule%3D%22nonzero%22%20d%3D%22M15%2C6%20C15%2C5.45%2014.55%2C5%2014%2C5%20L6%2C5%20C5.45%2C5%205%2C5.45%205%2C6%20L5%2C10%20C5%2C10.55%205.45%2C11%206%2C11%20L14%2C11%20C14.55%2C11%2015%2C10.55%2015%2C10%20L15%2C9%20L16%2C9%20L16%2C12%20L9%2C12%20L9%2C19%20C9%2C19.55%209.45%2C20%2010%2C20%20L11%2C20%20C11.55%2C20%2012%2C19.55%2012%2C19%20L12%2C14%20L18%2C14%20L18%2C7%20L15%2C7%20L15%2C6%20Z%22%2F%3E%0A%20%20%20%20%3Cpath%20fill%3D%22%23000%22%20fill-rule%3D%22nonzero%22%20d%3D%22M1%2C1%20L8.25%2C1%20C8.66421356%2C1%209%2C1.33578644%209%2C1.75%20L9%2C1.75%20C9%2C2.16421356%208.66421356%2C2.5%208.25%2C2.5%20L2.5%2C2.5%20L2.5%2C8.25%20C2.5%2C8.66421356%202.16421356%2C9%201.75%2C9%20L1.75%2C9%20C1.33578644%2C9%201%2C8.66421356%201%2C8.25%20L1%2C1%20Z%22%2F%3E%0A%20%20%3C%2Fg%3E%0A%3C%2Fsvg%3E%0A"), default;
+}
+.mce-content-body figure.align-left {
+  float: left;
+}
+.mce-content-body figure.align-right {
+  float: right;
+}
+.mce-content-body figure.image.align-center {
+  display: table;
+  margin-left: auto;
+  margin-right: auto;
+}
+.mce-preview-object {
+  border: 1px solid gray;
+  display: inline-block;
+  line-height: 0;
+  margin: 0 2px 0 2px;
+  position: relative;
+}
+.mce-preview-object .mce-shim {
+  background: url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7);
+  height: 100%;
+  left: 0;
+  position: absolute;
+  top: 0;
+  width: 100%;
+}
+.mce-preview-object[data-mce-selected="2"] .mce-shim {
+  display: none;
+}
+.mce-object {
+  background: transparent url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M4%203h16a1%201%200%200%201%201%201v16a1%201%200%200%201-1%201H4a1%201%200%200%201-1-1V4a1%201%200%200%201%201-1zm1%202v14h14V5H5zm4.79%202.565l5.64%204.028a.5.5%200%200%201%200%20.814l-5.64%204.028a.5.5%200%200%201-.79-.407V7.972a.5.5%200%200%201%20.79-.407z%22%20fill%3D%22%23cccccc%22%2F%3E%3C%2Fsvg%3E%0A") no-repeat center;
+  border: 1px dashed #aaa;
+}
+.mce-pagebreak {
+  border: 1px dashed #aaa;
+  cursor: default;
+  display: block;
+  height: 5px;
+  margin-top: 15px;
+  page-break-before: always;
+  width: 100%;
+}
+@media print {
+  .mce-pagebreak {
+    border: 0;
+  }
+}
+.tiny-pageembed .mce-shim {
+  background: url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7);
+  height: 100%;
+  left: 0;
+  position: absolute;
+  top: 0;
+  width: 100%;
+}
+.tiny-pageembed[data-mce-selected="2"] .mce-shim {
+  display: none;
+}
+.tiny-pageembed {
+  display: inline-block;
+  position: relative;
+}
+.tiny-pageembed--21by9,
+.tiny-pageembed--16by9,
+.tiny-pageembed--4by3,
+.tiny-pageembed--1by1 {
+  display: block;
+  overflow: hidden;
+  padding: 0;
+  position: relative;
+  width: 100%;
+}
+.tiny-pageembed--21by9 {
+  padding-top: 42.857143%;
+}
+.tiny-pageembed--16by9 {
+  padding-top: 56.25%;
+}
+.tiny-pageembed--4by3 {
+  padding-top: 75%;
+}
+.tiny-pageembed--1by1 {
+  padding-top: 100%;
+}
+.tiny-pageembed--21by9 iframe,
+.tiny-pageembed--16by9 iframe,
+.tiny-pageembed--4by3 iframe,
+.tiny-pageembed--1by1 iframe {
+  border: 0;
+  height: 100%;
+  left: 0;
+  position: absolute;
+  top: 0;
+  width: 100%;
+}
+.mce-content-body[data-mce-placeholder] {
+  position: relative;
+}
+.mce-content-body[data-mce-placeholder]:not(.mce-visualblocks)::before {
+  color: rgba(34, 47, 62, 0.7);
+  content: attr(data-mce-placeholder);
+  position: absolute;
+}
+.mce-content-body:not([dir=rtl])[data-mce-placeholder]:not(.mce-visualblocks)::before {
+  left: 1px;
+}
+.mce-content-body[dir=rtl][data-mce-placeholder]:not(.mce-visualblocks)::before {
+  right: 1px;
+}
+.mce-content-body div.mce-resizehandle {
+  background-color: #4099ff;
+  border-color: #4099ff;
+  border-style: solid;
+  border-width: 1px;
+  box-sizing: border-box;
+  height: 10px;
+  position: absolute;
+  width: 10px;
+  z-index: 1298;
+}
+.mce-content-body div.mce-resizehandle:hover {
+  background-color: #4099ff;
+}
+.mce-content-body div.mce-resizehandle:nth-of-type(1) {
+  cursor: nwse-resize;
+}
+.mce-content-body div.mce-resizehandle:nth-of-type(2) {
+  cursor: nesw-resize;
+}
+.mce-content-body div.mce-resizehandle:nth-of-type(3) {
+  cursor: nwse-resize;
+}
+.mce-content-body div.mce-resizehandle:nth-of-type(4) {
+  cursor: nesw-resize;
+}
+.mce-content-body .mce-resize-backdrop {
+  z-index: 10000;
+}
+.mce-content-body .mce-clonedresizable {
+  cursor: default;
+  opacity: 0.5;
+  outline: 1px dashed black;
+  position: absolute;
+  z-index: 10001;
+}
+.mce-content-body .mce-clonedresizable.mce-resizetable-columns th,
+.mce-content-body .mce-clonedresizable.mce-resizetable-columns td {
+  border: 0;
+}
+.mce-content-body .mce-resize-helper {
+  background: #555;
+  background: rgba(0, 0, 0, 0.75);
+  border: 1px;
+  border-radius: 3px;
+  color: white;
+  display: none;
+  font-family: sans-serif;
+  font-size: 12px;
+  line-height: 14px;
+  margin: 5px 10px;
+  padding: 5px;
+  position: absolute;
+  white-space: nowrap;
+  z-index: 10002;
+}
+.tox-rtc-user-selection {
+  position: relative;
+}
+.tox-rtc-user-cursor {
+  bottom: 0;
+  cursor: default;
+  position: absolute;
+  top: 0;
+  width: 2px;
+}
+.tox-rtc-user-cursor::before {
+  background-color: inherit;
+  border-radius: 50%;
+  content: '';
+  display: block;
+  height: 8px;
+  position: absolute;
+  right: -3px;
+  top: -3px;
+  width: 8px;
+}
+.tox-rtc-user-cursor:hover::after {
+  background-color: inherit;
+  border-radius: 100px;
+  box-sizing: border-box;
+  color: #fff;
+  content: attr(data-user);
+  display: block;
+  font-size: 12px;
+  font-weight: bold;
+  left: -5px;
+  min-height: 8px;
+  min-width: 8px;
+  padding: 0 12px;
+  position: absolute;
+  top: -11px;
+  white-space: nowrap;
+  z-index: 1000;
+}
+.tox-rtc-user-selection--1 .tox-rtc-user-cursor {
+  background-color: #2dc26b;
+}
+.tox-rtc-user-selection--2 .tox-rtc-user-cursor {
+  background-color: #e03e2d;
+}
+.tox-rtc-user-selection--3 .tox-rtc-user-cursor {
+  background-color: #f1c40f;
+}
+.tox-rtc-user-selection--4 .tox-rtc-user-cursor {
+  background-color: #3598db;
+}
+.tox-rtc-user-selection--5 .tox-rtc-user-cursor {
+  background-color: #b96ad9;
+}
+.tox-rtc-user-selection--6 .tox-rtc-user-cursor {
+  background-color: #e67e23;
+}
+.tox-rtc-user-selection--7 .tox-rtc-user-cursor {
+  background-color: #aaa69d;
+}
+.tox-rtc-user-selection--8 .tox-rtc-user-cursor {
+  background-color: #f368e0;
+}
+.tox-rtc-remote-image {
+  background: #eaeaea url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2236%22%20height%3D%2212%22%20viewBox%3D%220%200%2036%2012%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Ccircle%20cx%3D%226%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%20%20%3Ccircle%20cx%3D%2218%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20begin%3D%22.33s%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%20%20%3Ccircle%20cx%3D%2230%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20begin%3D%22.66s%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%3C%2Fsvg%3E%0A") no-repeat center center;
+  border: 1px solid #ccc;
+  min-height: 240px;
+  min-width: 320px;
+}
+.mce-match-marker {
+  background: #aaa;
+  color: #fff;
+}
+.mce-match-marker-selected {
+  background: #39f;
+  color: #fff;
+}
+.mce-match-marker-selected::-moz-selection {
+  background: #39f;
+  color: #fff;
+}
+.mce-match-marker-selected::selection {
+  background: #39f;
+  color: #fff;
+}
+.mce-content-body img[data-mce-selected],
+.mce-content-body video[data-mce-selected],
+.mce-content-body audio[data-mce-selected],
+.mce-content-body object[data-mce-selected],
+.mce-content-body embed[data-mce-selected],
+.mce-content-body table[data-mce-selected] {
+  outline: 3px solid #4099ff;
+}
+.mce-content-body hr[data-mce-selected] {
+  outline: 3px solid #4099ff;
+  outline-offset: 1px;
+}
+.mce-content-body *[contentEditable=false] *[contentEditable=true]:focus {
+  outline: 3px solid #4099ff;
+}
+.mce-content-body *[contentEditable=false] *[contentEditable=true]:hover {
+  outline: 3px solid #4099ff;
+}
+.mce-content-body *[contentEditable=false][data-mce-selected] {
+  cursor: not-allowed;
+  outline: 3px solid #4099ff;
+}
+.mce-content-body.mce-content-readonly *[contentEditable=true]:focus,
+.mce-content-body.mce-content-readonly *[contentEditable=true]:hover {
+  outline: none;
+}
+.mce-content-body *[data-mce-selected="inline-boundary"] {
+  background-color: #4099ff;
+}
+.mce-content-body .mce-edit-focus {
+  outline: 3px solid #4099ff;
+}
+.mce-content-body td[data-mce-selected],
+.mce-content-body th[data-mce-selected] {
+  position: relative;
+}
+.mce-content-body td[data-mce-selected]::-moz-selection,
+.mce-content-body th[data-mce-selected]::-moz-selection {
+  background: none;
+}
+.mce-content-body td[data-mce-selected]::selection,
+.mce-content-body th[data-mce-selected]::selection {
+  background: none;
+}
+.mce-content-body td[data-mce-selected] *,
+.mce-content-body th[data-mce-selected] * {
+  outline: none;
+  -webkit-touch-callout: none;
+  -webkit-user-select: none;
+     -moz-user-select: none;
+      -ms-user-select: none;
+          user-select: none;
+}
+.mce-content-body td[data-mce-selected]::after,
+.mce-content-body th[data-mce-selected]::after {
+  background-color: rgba(180, 215, 255, 0.7);
+  border: 1px solid transparent;
+  bottom: -1px;
+  content: '';
+  left: -1px;
+  mix-blend-mode: lighten;
+  position: absolute;
+  right: -1px;
+  top: -1px;
+}
+@media screen and (-ms-high-contrast: active), (-ms-high-contrast: none) {
+  .mce-content-body td[data-mce-selected]::after,
+  .mce-content-body th[data-mce-selected]::after {
+    border-color: rgba(0, 84, 180, 0.7);
+  }
+}
+.mce-content-body img::-moz-selection {
+  background: none;
+}
+.mce-content-body img::selection {
+  background: none;
+}
+.ephox-snooker-resizer-bar {
+  background-color: #4099ff;
+  opacity: 0;
+  -webkit-user-select: none;
+  -moz-user-select: none;
+  -ms-user-select: none;
+  user-select: none;
+}
+.ephox-snooker-resizer-cols {
+  cursor: col-resize;
+}
+.ephox-snooker-resizer-rows {
+  cursor: row-resize;
+}
+.ephox-snooker-resizer-bar.ephox-snooker-resizer-bar-dragging {
+  opacity: 1;
+}
+.mce-spellchecker-word {
+  background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'4'%20height%3D'4'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20stroke%3D'%23ff0000'%20fill%3D'none'%20stroke-linecap%3D'round'%20stroke-opacity%3D'.75'%20d%3D'M0%203L2%201%204%203'%2F%3E%3C%2Fsvg%3E%0A");
+  background-position: 0 calc(100% + 1px);
+  background-repeat: repeat-x;
+  background-size: auto 6px;
+  cursor: default;
+  height: 2rem;
+}
+.mce-spellchecker-grammar {
+  background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'4'%20height%3D'4'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20stroke%3D'%2300A835'%20fill%3D'none'%20stroke-linecap%3D'round'%20d%3D'M0%203L2%201%204%203'%2F%3E%3C%2Fsvg%3E%0A");
+  background-position: 0 calc(100% + 1px);
+  background-repeat: repeat-x;
+  background-size: auto 6px;
+  cursor: default;
+}
+.mce-toc {
+  border: 1px solid gray;
+}
+.mce-toc h2 {
+  margin: 4px;
+}
+.mce-toc li {
+  list-style-type: none;
+}
+table[style*="border-width: 0px"],
+.mce-item-table:not([border]),
+.mce-item-table[border="0"],
+table[style*="border-width: 0px"] td,
+.mce-item-table:not([border]) td,
+.mce-item-table[border="0"] td,
+table[style*="border-width: 0px"] th,
+.mce-item-table:not([border]) th,
+.mce-item-table[border="0"] th,
+table[style*="border-width: 0px"] caption,
+.mce-item-table:not([border]) caption,
+.mce-item-table[border="0"] caption {
+  border: 1px dashed #bbb;
+}
+.mce-visualblocks p,
+.mce-visualblocks h1,
+.mce-visualblocks h2,
+.mce-visualblocks h3,
+.mce-visualblocks h4,
+.mce-visualblocks h5,
+.mce-visualblocks h6,
+.mce-visualblocks div:not([data-mce-bogus]),
+.mce-visualblocks section,
+.mce-visualblocks article,
+.mce-visualblocks blockquote,
+.mce-visualblocks address,
+.mce-visualblocks pre,
+.mce-visualblocks figure,
+.mce-visualblocks figcaption,
+.mce-visualblocks hgroup,
+.mce-visualblocks aside,
+.mce-visualblocks ul,
+.mce-visualblocks ol,
+.mce-visualblocks dl {
+  background-repeat: no-repeat;
+  border: 1px dashed #bbb;
+  margin-left: 3px;
+  padding-top: 10px;
+}
+.mce-visualblocks p {
+  background-image: url(data:image/gif;base64,R0lGODlhCQAJAJEAAAAAAP///7u7u////yH5BAEAAAMALAAAAAAJAAkAAAIQnG+CqCN/mlyvsRUpThG6AgA7);
+}
+.mce-visualblocks h1 {
+  background-image: url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybGu1JuxHoAfRNRW3TWXyF2YiRUAOw==);
+}
+.mce-visualblocks h2 {
+  background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8Hybbx4oOuqgTynJd6bGlWg3DkJzoaUAAAOw==);
+}
+.mce-visualblocks h3 {
+  background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIZjI8Hybbx4oOuqgTynJf2Ln2NOHpQpmhAAQA7);
+}
+.mce-visualblocks h4 {
+  background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxInR0zqeAdhtJlXwV1oCll2HaWgAAOw==);
+}
+.mce-visualblocks h5 {
+  background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxIoiuwjane4iq5GlW05GgIkIZUAAAOw==);
+}
+.mce-visualblocks h6 {
+  background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxIoiuwjan04jep1iZ1XRlAo5bVgAAOw==);
+}
+.mce-visualblocks div:not([data-mce-bogus]) {
+  background-image: url(data:image/gif;base64,R0lGODlhEgAKAIABALu7u////yH5BAEAAAEALAAAAAASAAoAAAIfjI9poI0cgDywrhuxfbrzDEbQM2Ei5aRjmoySW4pAAQA7);
+}
+.mce-visualblocks section {
+  background-image: url(data:image/gif;base64,R0lGODlhKAAKAIABALu7u////yH5BAEAAAEALAAAAAAoAAoAAAI5jI+pywcNY3sBWHdNrplytD2ellDeSVbp+GmWqaDqDMepc8t17Y4vBsK5hDyJMcI6KkuYU+jpjLoKADs=);
+}
+.mce-visualblocks article {
+  background-image: url(data:image/gif;base64,R0lGODlhKgAKAIABALu7u////yH5BAEAAAEALAAAAAAqAAoAAAI6jI+pywkNY3wG0GBvrsd2tXGYSGnfiF7ikpXemTpOiJScasYoDJJrjsG9gkCJ0ag6KhmaIe3pjDYBBQA7);
+}
+.mce-visualblocks blockquote {
+  background-image: url(data:image/gif;base64,R0lGODlhPgAKAIABALu7u////yH5BAEAAAEALAAAAAA+AAoAAAJPjI+py+0Knpz0xQDyuUhvfoGgIX5iSKZYgq5uNL5q69asZ8s5rrf0yZmpNkJZzFesBTu8TOlDVAabUyatguVhWduud3EyiUk45xhTTgMBBQA7);
+}
+.mce-visualblocks address {
+  background-image: url(data:image/gif;base64,R0lGODlhLQAKAIABALu7u////yH5BAEAAAEALAAAAAAtAAoAAAI/jI+pywwNozSP1gDyyZcjb3UaRpXkWaXmZW4OqKLhBmLs+K263DkJK7OJeifh7FicKD9A1/IpGdKkyFpNmCkAADs=);
+}
+.mce-visualblocks pre {
+  background-image: url(data:image/gif;base64,R0lGODlhFQAKAIABALu7uwAAACH5BAEAAAEALAAAAAAVAAoAAAIjjI+ZoN0cgDwSmnpz1NCueYERhnibZVKLNnbOq8IvKpJtVQAAOw==);
+}
+.mce-visualblocks figure {
+  background-image: url(data:image/gif;base64,R0lGODlhJAAKAIAAALu7u////yH5BAEAAAEALAAAAAAkAAoAAAI0jI+py+2fwAHUSFvD3RlvG4HIp4nX5JFSpnZUJ6LlrM52OE7uSWosBHScgkSZj7dDKnWAAgA7);
+}
+.mce-visualblocks figcaption {
+  border: 1px dashed #bbb;
+}
+.mce-visualblocks hgroup {
+  background-image: url(data:image/gif;base64,R0lGODlhJwAKAIABALu7uwAAACH5BAEAAAEALAAAAAAnAAoAAAI3jI+pywYNI3uB0gpsRtt5fFnfNZaVSYJil4Wo03Hv6Z62uOCgiXH1kZIIJ8NiIxRrAZNMZAtQAAA7);
+}
+.mce-visualblocks aside {
+  background-image: url(data:image/gif;base64,R0lGODlhHgAKAIABAKqqqv///yH5BAEAAAEALAAAAAAeAAoAAAItjI+pG8APjZOTzgtqy7I3f1yehmQcFY4WKZbqByutmW4aHUd6vfcVbgudgpYCADs=);
+}
+.mce-visualblocks ul {
+  background-image: url(data:image/gif;base64,R0lGODlhDQAKAIAAALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybGuYnqUVSjvw26DzzXiqIDlVwAAOw==);
+}
+.mce-visualblocks ol {
+  background-image: url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybH6HHt0qourxC6CvzXieHyeWQAAOw==);
+}
+.mce-visualblocks dl {
+  background-image: url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybEOnmOvUoWznTqeuEjNSCqeGRUAOw==);
+}
+.mce-visualblocks:not([dir=rtl]) p,
+.mce-visualblocks:not([dir=rtl]) h1,
+.mce-visualblocks:not([dir=rtl]) h2,
+.mce-visualblocks:not([dir=rtl]) h3,
+.mce-visualblocks:not([dir=rtl]) h4,
+.mce-visualblocks:not([dir=rtl]) h5,
+.mce-visualblocks:not([dir=rtl]) h6,
+.mce-visualblocks:not([dir=rtl]) div:not([data-mce-bogus]),
+.mce-visualblocks:not([dir=rtl]) section,
+.mce-visualblocks:not([dir=rtl]) article,
+.mce-visualblocks:not([dir=rtl]) blockquote,
+.mce-visualblocks:not([dir=rtl]) address,
+.mce-visualblocks:not([dir=rtl]) pre,
+.mce-visualblocks:not([dir=rtl]) figure,
+.mce-visualblocks:not([dir=rtl]) figcaption,
+.mce-visualblocks:not([dir=rtl]) hgroup,
+.mce-visualblocks:not([dir=rtl]) aside,
+.mce-visualblocks:not([dir=rtl]) ul,
+.mce-visualblocks:not([dir=rtl]) ol,
+.mce-visualblocks:not([dir=rtl]) dl {
+  margin-left: 3px;
+}
+.mce-visualblocks[dir=rtl] p,
+.mce-visualblocks[dir=rtl] h1,
+.mce-visualblocks[dir=rtl] h2,
+.mce-visualblocks[dir=rtl] h3,
+.mce-visualblocks[dir=rtl] h4,
+.mce-visualblocks[dir=rtl] h5,
+.mce-visualblocks[dir=rtl] h6,
+.mce-visualblocks[dir=rtl] div:not([data-mce-bogus]),
+.mce-visualblocks[dir=rtl] section,
+.mce-visualblocks[dir=rtl] article,
+.mce-visualblocks[dir=rtl] blockquote,
+.mce-visualblocks[dir=rtl] address,
+.mce-visualblocks[dir=rtl] pre,
+.mce-visualblocks[dir=rtl] figure,
+.mce-visualblocks[dir=rtl] figcaption,
+.mce-visualblocks[dir=rtl] hgroup,
+.mce-visualblocks[dir=rtl] aside,
+.mce-visualblocks[dir=rtl] ul,
+.mce-visualblocks[dir=rtl] ol,
+.mce-visualblocks[dir=rtl] dl {
+  background-position-x: right;
+  margin-right: 3px;
+}
+.mce-nbsp,
+.mce-shy {
+  background: #aaa;
+}
+.mce-shy::after {
+  content: '-';
+}
+body {
+  font-family: sans-serif;
+}
+table {
+  border-collapse: collapse;
+}
diff --git a/public/tinymce/skins/ui/oxide-dark/content.inline.css b/public/tinymce/skins/ui/oxide-dark/content.inline.css
new file mode 100644
index 0000000..8e7521d
--- /dev/null
+++ b/public/tinymce/skins/ui/oxide-dark/content.inline.css
@@ -0,0 +1,726 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+.mce-content-body .mce-item-anchor {
+  background: transparent url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'8'%20height%3D'12'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20d%3D'M0%200L8%200%208%2012%204.09117821%209%200%2012z'%2F%3E%3C%2Fsvg%3E%0A") no-repeat center;
+  cursor: default;
+  display: inline-block;
+  height: 12px !important;
+  padding: 0 2px;
+  -webkit-user-modify: read-only;
+  -moz-user-modify: read-only;
+  -webkit-user-select: all;
+  -moz-user-select: all;
+  -ms-user-select: all;
+      user-select: all;
+  width: 8px !important;
+}
+.mce-content-body .mce-item-anchor[data-mce-selected] {
+  outline-offset: 1px;
+}
+.tox-comments-visible .tox-comment {
+  background-color: #fff0b7;
+}
+.tox-comments-visible .tox-comment--active {
+  background-color: #ffe168;
+}
+.tox-checklist > li:not(.tox-checklist--hidden) {
+  list-style: none;
+  margin: 0.25em 0;
+}
+.tox-checklist > li:not(.tox-checklist--hidden)::before {
+  content: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cg%20id%3D%22checklist-unchecked%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Crect%20id%3D%22Rectangle%22%20width%3D%2215%22%20height%3D%2215%22%20x%3D%22.5%22%20y%3D%22.5%22%20fill-rule%3D%22nonzero%22%20stroke%3D%22%234C4C4C%22%20rx%3D%222%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E%0A");
+  cursor: pointer;
+  height: 1em;
+  margin-left: -1.5em;
+  margin-top: 0.125em;
+  position: absolute;
+  width: 1em;
+}
+.tox-checklist li:not(.tox-checklist--hidden).tox-checklist--checked::before {
+  content: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cg%20id%3D%22checklist-checked%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Crect%20id%3D%22Rectangle%22%20width%3D%2216%22%20height%3D%2216%22%20fill%3D%22%234099FF%22%20fill-rule%3D%22nonzero%22%20rx%3D%222%22%2F%3E%3Cpath%20id%3D%22Path%22%20fill%3D%22%23FFF%22%20fill-rule%3D%22nonzero%22%20d%3D%22M11.5703186%2C3.14417309%20C11.8516238%2C2.73724603%2012.4164781%2C2.62829933%2012.83558%2C2.89774797%20C13.260121%2C3.17069355%2013.3759736%2C3.72932262%2013.0909105%2C4.14168582%20L7.7580587%2C11.8560195%20C7.43776896%2C12.3193404%206.76483983%2C12.3852142%206.35607322%2C11.9948725%20L3.02491697%2C8.8138662%20C2.66090143%2C8.46625845%202.65798871%2C7.89594698%203.01850234%2C7.54483354%20C3.373942%2C7.19866177%203.94940006%2C7.19592841%204.30829608%2C7.5386474%20L6.85276923%2C9.9684299%20L11.5703186%2C3.14417309%20Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E%0A");
+}
+[dir=rtl] .tox-checklist > li:not(.tox-checklist--hidden)::before {
+  margin-left: 0;
+  margin-right: -1.5em;
+}
+/* stylelint-disable */
+/* http://prismjs.com/ */
+/**
+ * prism.js default theme for JavaScript, CSS and HTML
+ * Based on dabblet (http://dabblet.com)
+ * @author Lea Verou
+ */
+code[class*="language-"],
+pre[class*="language-"] {
+  color: black;
+  background: none;
+  text-shadow: 0 1px white;
+  font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
+  font-size: 1em;
+  text-align: left;
+  white-space: pre;
+  word-spacing: normal;
+  word-break: normal;
+  word-wrap: normal;
+  line-height: 1.5;
+  -moz-tab-size: 4;
+  tab-size: 4;
+  -webkit-hyphens: none;
+  -ms-hyphens: none;
+  hyphens: none;
+}
+pre[class*="language-"]::-moz-selection,
+pre[class*="language-"] ::-moz-selection,
+code[class*="language-"]::-moz-selection,
+code[class*="language-"] ::-moz-selection {
+  text-shadow: none;
+  background: #b3d4fc;
+}
+pre[class*="language-"]::selection,
+pre[class*="language-"] ::selection,
+code[class*="language-"]::selection,
+code[class*="language-"] ::selection {
+  text-shadow: none;
+  background: #b3d4fc;
+}
+@media print {
+  code[class*="language-"],
+  pre[class*="language-"] {
+    text-shadow: none;
+  }
+}
+/* Code blocks */
+pre[class*="language-"] {
+  padding: 1em;
+  margin: 0.5em 0;
+  overflow: auto;
+}
+:not(pre) > code[class*="language-"],
+pre[class*="language-"] {
+  background: #f5f2f0;
+}
+/* Inline code */
+:not(pre) > code[class*="language-"] {
+  padding: 0.1em;
+  border-radius: 0.3em;
+  white-space: normal;
+}
+.token.comment,
+.token.prolog,
+.token.doctype,
+.token.cdata {
+  color: slategray;
+}
+.token.punctuation {
+  color: #999;
+}
+.namespace {
+  opacity: 0.7;
+}
+.token.property,
+.token.tag,
+.token.boolean,
+.token.number,
+.token.constant,
+.token.symbol,
+.token.deleted {
+  color: #905;
+}
+.token.selector,
+.token.attr-name,
+.token.string,
+.token.char,
+.token.builtin,
+.token.inserted {
+  color: #690;
+}
+.token.operator,
+.token.entity,
+.token.url,
+.language-css .token.string,
+.style .token.string {
+  color: #9a6e3a;
+  background: hsla(0, 0%, 100%, 0.5);
+}
+.token.atrule,
+.token.attr-value,
+.token.keyword {
+  color: #07a;
+}
+.token.function,
+.token.class-name {
+  color: #DD4A68;
+}
+.token.regex,
+.token.important,
+.token.variable {
+  color: #e90;
+}
+.token.important,
+.token.bold {
+  font-weight: bold;
+}
+.token.italic {
+  font-style: italic;
+}
+.token.entity {
+  cursor: help;
+}
+/* stylelint-enable */
+.mce-content-body {
+  overflow-wrap: break-word;
+  word-wrap: break-word;
+}
+.mce-content-body .mce-visual-caret {
+  background-color: black;
+  background-color: currentColor;
+  position: absolute;
+}
+.mce-content-body .mce-visual-caret-hidden {
+  display: none;
+}
+.mce-content-body *[data-mce-caret] {
+  left: -1000px;
+  margin: 0;
+  padding: 0;
+  position: absolute;
+  right: auto;
+  top: 0;
+}
+.mce-content-body .mce-offscreen-selection {
+  left: -2000000px;
+  max-width: 1000000px;
+  position: absolute;
+}
+.mce-content-body *[contentEditable=false] {
+  cursor: default;
+}
+.mce-content-body *[contentEditable=true] {
+  cursor: text;
+}
+.tox-cursor-format-painter {
+  cursor: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%3E%0A%20%20%3Cg%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%0A%20%20%20%20%3Cpath%20fill%3D%22%23000%22%20fill-rule%3D%22nonzero%22%20d%3D%22M15%2C6%20C15%2C5.45%2014.55%2C5%2014%2C5%20L6%2C5%20C5.45%2C5%205%2C5.45%205%2C6%20L5%2C10%20C5%2C10.55%205.45%2C11%206%2C11%20L14%2C11%20C14.55%2C11%2015%2C10.55%2015%2C10%20L15%2C9%20L16%2C9%20L16%2C12%20L9%2C12%20L9%2C19%20C9%2C19.55%209.45%2C20%2010%2C20%20L11%2C20%20C11.55%2C20%2012%2C19.55%2012%2C19%20L12%2C14%20L18%2C14%20L18%2C7%20L15%2C7%20L15%2C6%20Z%22%2F%3E%0A%20%20%20%20%3Cpath%20fill%3D%22%23000%22%20fill-rule%3D%22nonzero%22%20d%3D%22M1%2C1%20L8.25%2C1%20C8.66421356%2C1%209%2C1.33578644%209%2C1.75%20L9%2C1.75%20C9%2C2.16421356%208.66421356%2C2.5%208.25%2C2.5%20L2.5%2C2.5%20L2.5%2C8.25%20C2.5%2C8.66421356%202.16421356%2C9%201.75%2C9%20L1.75%2C9%20C1.33578644%2C9%201%2C8.66421356%201%2C8.25%20L1%2C1%20Z%22%2F%3E%0A%20%20%3C%2Fg%3E%0A%3C%2Fsvg%3E%0A"), default;
+}
+.mce-content-body figure.align-left {
+  float: left;
+}
+.mce-content-body figure.align-right {
+  float: right;
+}
+.mce-content-body figure.image.align-center {
+  display: table;
+  margin-left: auto;
+  margin-right: auto;
+}
+.mce-preview-object {
+  border: 1px solid gray;
+  display: inline-block;
+  line-height: 0;
+  margin: 0 2px 0 2px;
+  position: relative;
+}
+.mce-preview-object .mce-shim {
+  background: url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7);
+  height: 100%;
+  left: 0;
+  position: absolute;
+  top: 0;
+  width: 100%;
+}
+.mce-preview-object[data-mce-selected="2"] .mce-shim {
+  display: none;
+}
+.mce-object {
+  background: transparent url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M4%203h16a1%201%200%200%201%201%201v16a1%201%200%200%201-1%201H4a1%201%200%200%201-1-1V4a1%201%200%200%201%201-1zm1%202v14h14V5H5zm4.79%202.565l5.64%204.028a.5.5%200%200%201%200%20.814l-5.64%204.028a.5.5%200%200%201-.79-.407V7.972a.5.5%200%200%201%20.79-.407z%22%2F%3E%3C%2Fsvg%3E%0A") no-repeat center;
+  border: 1px dashed #aaa;
+}
+.mce-pagebreak {
+  border: 1px dashed #aaa;
+  cursor: default;
+  display: block;
+  height: 5px;
+  margin-top: 15px;
+  page-break-before: always;
+  width: 100%;
+}
+@media print {
+  .mce-pagebreak {
+    border: 0;
+  }
+}
+.tiny-pageembed .mce-shim {
+  background: url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7);
+  height: 100%;
+  left: 0;
+  position: absolute;
+  top: 0;
+  width: 100%;
+}
+.tiny-pageembed[data-mce-selected="2"] .mce-shim {
+  display: none;
+}
+.tiny-pageembed {
+  display: inline-block;
+  position: relative;
+}
+.tiny-pageembed--21by9,
+.tiny-pageembed--16by9,
+.tiny-pageembed--4by3,
+.tiny-pageembed--1by1 {
+  display: block;
+  overflow: hidden;
+  padding: 0;
+  position: relative;
+  width: 100%;
+}
+.tiny-pageembed--21by9 {
+  padding-top: 42.857143%;
+}
+.tiny-pageembed--16by9 {
+  padding-top: 56.25%;
+}
+.tiny-pageembed--4by3 {
+  padding-top: 75%;
+}
+.tiny-pageembed--1by1 {
+  padding-top: 100%;
+}
+.tiny-pageembed--21by9 iframe,
+.tiny-pageembed--16by9 iframe,
+.tiny-pageembed--4by3 iframe,
+.tiny-pageembed--1by1 iframe {
+  border: 0;
+  height: 100%;
+  left: 0;
+  position: absolute;
+  top: 0;
+  width: 100%;
+}
+.mce-content-body[data-mce-placeholder] {
+  position: relative;
+}
+.mce-content-body[data-mce-placeholder]:not(.mce-visualblocks)::before {
+  color: rgba(34, 47, 62, 0.7);
+  content: attr(data-mce-placeholder);
+  position: absolute;
+}
+.mce-content-body:not([dir=rtl])[data-mce-placeholder]:not(.mce-visualblocks)::before {
+  left: 1px;
+}
+.mce-content-body[dir=rtl][data-mce-placeholder]:not(.mce-visualblocks)::before {
+  right: 1px;
+}
+.mce-content-body div.mce-resizehandle {
+  background-color: #4099ff;
+  border-color: #4099ff;
+  border-style: solid;
+  border-width: 1px;
+  box-sizing: border-box;
+  height: 10px;
+  position: absolute;
+  width: 10px;
+  z-index: 1298;
+}
+.mce-content-body div.mce-resizehandle:hover {
+  background-color: #4099ff;
+}
+.mce-content-body div.mce-resizehandle:nth-of-type(1) {
+  cursor: nwse-resize;
+}
+.mce-content-body div.mce-resizehandle:nth-of-type(2) {
+  cursor: nesw-resize;
+}
+.mce-content-body div.mce-resizehandle:nth-of-type(3) {
+  cursor: nwse-resize;
+}
+.mce-content-body div.mce-resizehandle:nth-of-type(4) {
+  cursor: nesw-resize;
+}
+.mce-content-body .mce-resize-backdrop {
+  z-index: 10000;
+}
+.mce-content-body .mce-clonedresizable {
+  cursor: default;
+  opacity: 0.5;
+  outline: 1px dashed black;
+  position: absolute;
+  z-index: 10001;
+}
+.mce-content-body .mce-clonedresizable.mce-resizetable-columns th,
+.mce-content-body .mce-clonedresizable.mce-resizetable-columns td {
+  border: 0;
+}
+.mce-content-body .mce-resize-helper {
+  background: #555;
+  background: rgba(0, 0, 0, 0.75);
+  border: 1px;
+  border-radius: 3px;
+  color: white;
+  display: none;
+  font-family: sans-serif;
+  font-size: 12px;
+  line-height: 14px;
+  margin: 5px 10px;
+  padding: 5px;
+  position: absolute;
+  white-space: nowrap;
+  z-index: 10002;
+}
+.tox-rtc-user-selection {
+  position: relative;
+}
+.tox-rtc-user-cursor {
+  bottom: 0;
+  cursor: default;
+  position: absolute;
+  top: 0;
+  width: 2px;
+}
+.tox-rtc-user-cursor::before {
+  background-color: inherit;
+  border-radius: 50%;
+  content: '';
+  display: block;
+  height: 8px;
+  position: absolute;
+  right: -3px;
+  top: -3px;
+  width: 8px;
+}
+.tox-rtc-user-cursor:hover::after {
+  background-color: inherit;
+  border-radius: 100px;
+  box-sizing: border-box;
+  color: #fff;
+  content: attr(data-user);
+  display: block;
+  font-size: 12px;
+  font-weight: bold;
+  left: -5px;
+  min-height: 8px;
+  min-width: 8px;
+  padding: 0 12px;
+  position: absolute;
+  top: -11px;
+  white-space: nowrap;
+  z-index: 1000;
+}
+.tox-rtc-user-selection--1 .tox-rtc-user-cursor {
+  background-color: #2dc26b;
+}
+.tox-rtc-user-selection--2 .tox-rtc-user-cursor {
+  background-color: #e03e2d;
+}
+.tox-rtc-user-selection--3 .tox-rtc-user-cursor {
+  background-color: #f1c40f;
+}
+.tox-rtc-user-selection--4 .tox-rtc-user-cursor {
+  background-color: #3598db;
+}
+.tox-rtc-user-selection--5 .tox-rtc-user-cursor {
+  background-color: #b96ad9;
+}
+.tox-rtc-user-selection--6 .tox-rtc-user-cursor {
+  background-color: #e67e23;
+}
+.tox-rtc-user-selection--7 .tox-rtc-user-cursor {
+  background-color: #aaa69d;
+}
+.tox-rtc-user-selection--8 .tox-rtc-user-cursor {
+  background-color: #f368e0;
+}
+.tox-rtc-remote-image {
+  background: #eaeaea url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2236%22%20height%3D%2212%22%20viewBox%3D%220%200%2036%2012%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Ccircle%20cx%3D%226%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%20%20%3Ccircle%20cx%3D%2218%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20begin%3D%22.33s%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%20%20%3Ccircle%20cx%3D%2230%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20begin%3D%22.66s%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%3C%2Fsvg%3E%0A") no-repeat center center;
+  border: 1px solid #ccc;
+  min-height: 240px;
+  min-width: 320px;
+}
+.mce-match-marker {
+  background: #aaa;
+  color: #fff;
+}
+.mce-match-marker-selected {
+  background: #39f;
+  color: #fff;
+}
+.mce-match-marker-selected::-moz-selection {
+  background: #39f;
+  color: #fff;
+}
+.mce-match-marker-selected::selection {
+  background: #39f;
+  color: #fff;
+}
+.mce-content-body img[data-mce-selected],
+.mce-content-body video[data-mce-selected],
+.mce-content-body audio[data-mce-selected],
+.mce-content-body object[data-mce-selected],
+.mce-content-body embed[data-mce-selected],
+.mce-content-body table[data-mce-selected] {
+  outline: 3px solid #b4d7ff;
+}
+.mce-content-body hr[data-mce-selected] {
+  outline: 3px solid #b4d7ff;
+  outline-offset: 1px;
+}
+.mce-content-body *[contentEditable=false] *[contentEditable=true]:focus {
+  outline: 3px solid #b4d7ff;
+}
+.mce-content-body *[contentEditable=false] *[contentEditable=true]:hover {
+  outline: 3px solid #b4d7ff;
+}
+.mce-content-body *[contentEditable=false][data-mce-selected] {
+  cursor: not-allowed;
+  outline: 3px solid #b4d7ff;
+}
+.mce-content-body.mce-content-readonly *[contentEditable=true]:focus,
+.mce-content-body.mce-content-readonly *[contentEditable=true]:hover {
+  outline: none;
+}
+.mce-content-body *[data-mce-selected="inline-boundary"] {
+  background-color: #b4d7ff;
+}
+.mce-content-body .mce-edit-focus {
+  outline: 3px solid #b4d7ff;
+}
+.mce-content-body td[data-mce-selected],
+.mce-content-body th[data-mce-selected] {
+  position: relative;
+}
+.mce-content-body td[data-mce-selected]::-moz-selection,
+.mce-content-body th[data-mce-selected]::-moz-selection {
+  background: none;
+}
+.mce-content-body td[data-mce-selected]::selection,
+.mce-content-body th[data-mce-selected]::selection {
+  background: none;
+}
+.mce-content-body td[data-mce-selected] *,
+.mce-content-body th[data-mce-selected] * {
+  outline: none;
+  -webkit-touch-callout: none;
+  -webkit-user-select: none;
+     -moz-user-select: none;
+      -ms-user-select: none;
+          user-select: none;
+}
+.mce-content-body td[data-mce-selected]::after,
+.mce-content-body th[data-mce-selected]::after {
+  background-color: rgba(180, 215, 255, 0.7);
+  border: 1px solid rgba(180, 215, 255, 0.7);
+  bottom: -1px;
+  content: '';
+  left: -1px;
+  mix-blend-mode: multiply;
+  position: absolute;
+  right: -1px;
+  top: -1px;
+}
+@media screen and (-ms-high-contrast: active), (-ms-high-contrast: none) {
+  .mce-content-body td[data-mce-selected]::after,
+  .mce-content-body th[data-mce-selected]::after {
+    border-color: rgba(0, 84, 180, 0.7);
+  }
+}
+.mce-content-body img::-moz-selection {
+  background: none;
+}
+.mce-content-body img::selection {
+  background: none;
+}
+.ephox-snooker-resizer-bar {
+  background-color: #b4d7ff;
+  opacity: 0;
+  -webkit-user-select: none;
+  -moz-user-select: none;
+  -ms-user-select: none;
+  user-select: none;
+}
+.ephox-snooker-resizer-cols {
+  cursor: col-resize;
+}
+.ephox-snooker-resizer-rows {
+  cursor: row-resize;
+}
+.ephox-snooker-resizer-bar.ephox-snooker-resizer-bar-dragging {
+  opacity: 1;
+}
+.mce-spellchecker-word {
+  background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'4'%20height%3D'4'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20stroke%3D'%23ff0000'%20fill%3D'none'%20stroke-linecap%3D'round'%20stroke-opacity%3D'.75'%20d%3D'M0%203L2%201%204%203'%2F%3E%3C%2Fsvg%3E%0A");
+  background-position: 0 calc(100% + 1px);
+  background-repeat: repeat-x;
+  background-size: auto 6px;
+  cursor: default;
+  height: 2rem;
+}
+.mce-spellchecker-grammar {
+  background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'4'%20height%3D'4'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20stroke%3D'%2300A835'%20fill%3D'none'%20stroke-linecap%3D'round'%20d%3D'M0%203L2%201%204%203'%2F%3E%3C%2Fsvg%3E%0A");
+  background-position: 0 calc(100% + 1px);
+  background-repeat: repeat-x;
+  background-size: auto 6px;
+  cursor: default;
+}
+.mce-toc {
+  border: 1px solid gray;
+}
+.mce-toc h2 {
+  margin: 4px;
+}
+.mce-toc li {
+  list-style-type: none;
+}
+table[style*="border-width: 0px"],
+.mce-item-table:not([border]),
+.mce-item-table[border="0"],
+table[style*="border-width: 0px"] td,
+.mce-item-table:not([border]) td,
+.mce-item-table[border="0"] td,
+table[style*="border-width: 0px"] th,
+.mce-item-table:not([border]) th,
+.mce-item-table[border="0"] th,
+table[style*="border-width: 0px"] caption,
+.mce-item-table:not([border]) caption,
+.mce-item-table[border="0"] caption {
+  border: 1px dashed #bbb;
+}
+.mce-visualblocks p,
+.mce-visualblocks h1,
+.mce-visualblocks h2,
+.mce-visualblocks h3,
+.mce-visualblocks h4,
+.mce-visualblocks h5,
+.mce-visualblocks h6,
+.mce-visualblocks div:not([data-mce-bogus]),
+.mce-visualblocks section,
+.mce-visualblocks article,
+.mce-visualblocks blockquote,
+.mce-visualblocks address,
+.mce-visualblocks pre,
+.mce-visualblocks figure,
+.mce-visualblocks figcaption,
+.mce-visualblocks hgroup,
+.mce-visualblocks aside,
+.mce-visualblocks ul,
+.mce-visualblocks ol,
+.mce-visualblocks dl {
+  background-repeat: no-repeat;
+  border: 1px dashed #bbb;
+  margin-left: 3px;
+  padding-top: 10px;
+}
+.mce-visualblocks p {
+  background-image: url(data:image/gif;base64,R0lGODlhCQAJAJEAAAAAAP///7u7u////yH5BAEAAAMALAAAAAAJAAkAAAIQnG+CqCN/mlyvsRUpThG6AgA7);
+}
+.mce-visualblocks h1 {
+  background-image: url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybGu1JuxHoAfRNRW3TWXyF2YiRUAOw==);
+}
+.mce-visualblocks h2 {
+  background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8Hybbx4oOuqgTynJd6bGlWg3DkJzoaUAAAOw==);
+}
+.mce-visualblocks h3 {
+  background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIZjI8Hybbx4oOuqgTynJf2Ln2NOHpQpmhAAQA7);
+}
+.mce-visualblocks h4 {
+  background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxInR0zqeAdhtJlXwV1oCll2HaWgAAOw==);
+}
+.mce-visualblocks h5 {
+  background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxIoiuwjane4iq5GlW05GgIkIZUAAAOw==);
+}
+.mce-visualblocks h6 {
+  background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxIoiuwjan04jep1iZ1XRlAo5bVgAAOw==);
+}
+.mce-visualblocks div:not([data-mce-bogus]) {
+  background-image: url(data:image/gif;base64,R0lGODlhEgAKAIABALu7u////yH5BAEAAAEALAAAAAASAAoAAAIfjI9poI0cgDywrhuxfbrzDEbQM2Ei5aRjmoySW4pAAQA7);
+}
+.mce-visualblocks section {
+  background-image: url(data:image/gif;base64,R0lGODlhKAAKAIABALu7u////yH5BAEAAAEALAAAAAAoAAoAAAI5jI+pywcNY3sBWHdNrplytD2ellDeSVbp+GmWqaDqDMepc8t17Y4vBsK5hDyJMcI6KkuYU+jpjLoKADs=);
+}
+.mce-visualblocks article {
+  background-image: url(data:image/gif;base64,R0lGODlhKgAKAIABALu7u////yH5BAEAAAEALAAAAAAqAAoAAAI6jI+pywkNY3wG0GBvrsd2tXGYSGnfiF7ikpXemTpOiJScasYoDJJrjsG9gkCJ0ag6KhmaIe3pjDYBBQA7);
+}
+.mce-visualblocks blockquote {
+  background-image: url(data:image/gif;base64,R0lGODlhPgAKAIABALu7u////yH5BAEAAAEALAAAAAA+AAoAAAJPjI+py+0Knpz0xQDyuUhvfoGgIX5iSKZYgq5uNL5q69asZ8s5rrf0yZmpNkJZzFesBTu8TOlDVAabUyatguVhWduud3EyiUk45xhTTgMBBQA7);
+}
+.mce-visualblocks address {
+  background-image: url(data:image/gif;base64,R0lGODlhLQAKAIABALu7u////yH5BAEAAAEALAAAAAAtAAoAAAI/jI+pywwNozSP1gDyyZcjb3UaRpXkWaXmZW4OqKLhBmLs+K263DkJK7OJeifh7FicKD9A1/IpGdKkyFpNmCkAADs=);
+}
+.mce-visualblocks pre {
+  background-image: url(data:image/gif;base64,R0lGODlhFQAKAIABALu7uwAAACH5BAEAAAEALAAAAAAVAAoAAAIjjI+ZoN0cgDwSmnpz1NCueYERhnibZVKLNnbOq8IvKpJtVQAAOw==);
+}
+.mce-visualblocks figure {
+  background-image: url(data:image/gif;base64,R0lGODlhJAAKAIAAALu7u////yH5BAEAAAEALAAAAAAkAAoAAAI0jI+py+2fwAHUSFvD3RlvG4HIp4nX5JFSpnZUJ6LlrM52OE7uSWosBHScgkSZj7dDKnWAAgA7);
+}
+.mce-visualblocks figcaption {
+  border: 1px dashed #bbb;
+}
+.mce-visualblocks hgroup {
+  background-image: url(data:image/gif;base64,R0lGODlhJwAKAIABALu7uwAAACH5BAEAAAEALAAAAAAnAAoAAAI3jI+pywYNI3uB0gpsRtt5fFnfNZaVSYJil4Wo03Hv6Z62uOCgiXH1kZIIJ8NiIxRrAZNMZAtQAAA7);
+}
+.mce-visualblocks aside {
+  background-image: url(data:image/gif;base64,R0lGODlhHgAKAIABAKqqqv///yH5BAEAAAEALAAAAAAeAAoAAAItjI+pG8APjZOTzgtqy7I3f1yehmQcFY4WKZbqByutmW4aHUd6vfcVbgudgpYCADs=);
+}
+.mce-visualblocks ul {
+  background-image: url(data:image/gif;base64,R0lGODlhDQAKAIAAALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybGuYnqUVSjvw26DzzXiqIDlVwAAOw==);
+}
+.mce-visualblocks ol {
+  background-image: url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybH6HHt0qourxC6CvzXieHyeWQAAOw==);
+}
+.mce-visualblocks dl {
+  background-image: url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybEOnmOvUoWznTqeuEjNSCqeGRUAOw==);
+}
+.mce-visualblocks:not([dir=rtl]) p,
+.mce-visualblocks:not([dir=rtl]) h1,
+.mce-visualblocks:not([dir=rtl]) h2,
+.mce-visualblocks:not([dir=rtl]) h3,
+.mce-visualblocks:not([dir=rtl]) h4,
+.mce-visualblocks:not([dir=rtl]) h5,
+.mce-visualblocks:not([dir=rtl]) h6,
+.mce-visualblocks:not([dir=rtl]) div:not([data-mce-bogus]),
+.mce-visualblocks:not([dir=rtl]) section,
+.mce-visualblocks:not([dir=rtl]) article,
+.mce-visualblocks:not([dir=rtl]) blockquote,
+.mce-visualblocks:not([dir=rtl]) address,
+.mce-visualblocks:not([dir=rtl]) pre,
+.mce-visualblocks:not([dir=rtl]) figure,
+.mce-visualblocks:not([dir=rtl]) figcaption,
+.mce-visualblocks:not([dir=rtl]) hgroup,
+.mce-visualblocks:not([dir=rtl]) aside,
+.mce-visualblocks:not([dir=rtl]) ul,
+.mce-visualblocks:not([dir=rtl]) ol,
+.mce-visualblocks:not([dir=rtl]) dl {
+  margin-left: 3px;
+}
+.mce-visualblocks[dir=rtl] p,
+.mce-visualblocks[dir=rtl] h1,
+.mce-visualblocks[dir=rtl] h2,
+.mce-visualblocks[dir=rtl] h3,
+.mce-visualblocks[dir=rtl] h4,
+.mce-visualblocks[dir=rtl] h5,
+.mce-visualblocks[dir=rtl] h6,
+.mce-visualblocks[dir=rtl] div:not([data-mce-bogus]),
+.mce-visualblocks[dir=rtl] section,
+.mce-visualblocks[dir=rtl] article,
+.mce-visualblocks[dir=rtl] blockquote,
+.mce-visualblocks[dir=rtl] address,
+.mce-visualblocks[dir=rtl] pre,
+.mce-visualblocks[dir=rtl] figure,
+.mce-visualblocks[dir=rtl] figcaption,
+.mce-visualblocks[dir=rtl] hgroup,
+.mce-visualblocks[dir=rtl] aside,
+.mce-visualblocks[dir=rtl] ul,
+.mce-visualblocks[dir=rtl] ol,
+.mce-visualblocks[dir=rtl] dl {
+  background-position-x: right;
+  margin-right: 3px;
+}
+.mce-nbsp,
+.mce-shy {
+  background: #aaa;
+}
+.mce-shy::after {
+  content: '-';
+}
diff --git a/public/tinymce/skins/ui/oxide-dark/content.inline.min.css b/public/tinymce/skins/ui/oxide-dark/content.inline.min.css
new file mode 100644
index 0000000..b4ab9a3
--- /dev/null
+++ b/public/tinymce/skins/ui/oxide-dark/content.inline.min.css
@@ -0,0 +1,7 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+.mce-content-body .mce-item-anchor{background:transparent url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'8'%20height%3D'12'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20d%3D'M0%200L8%200%208%2012%204.09117821%209%200%2012z'%2F%3E%3C%2Fsvg%3E%0A") no-repeat center;cursor:default;display:inline-block;height:12px!important;padding:0 2px;-webkit-user-modify:read-only;-moz-user-modify:read-only;-webkit-user-select:all;-moz-user-select:all;-ms-user-select:all;user-select:all;width:8px!important}.mce-content-body .mce-item-anchor[data-mce-selected]{outline-offset:1px}.tox-comments-visible .tox-comment{background-color:#fff0b7}.tox-comments-visible .tox-comment--active{background-color:#ffe168}.tox-checklist>li:not(.tox-checklist--hidden){list-style:none;margin:.25em 0}.tox-checklist>li:not(.tox-checklist--hidden)::before{content:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cg%20id%3D%22checklist-unchecked%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Crect%20id%3D%22Rectangle%22%20width%3D%2215%22%20height%3D%2215%22%20x%3D%22.5%22%20y%3D%22.5%22%20fill-rule%3D%22nonzero%22%20stroke%3D%22%234C4C4C%22%20rx%3D%222%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E%0A");cursor:pointer;height:1em;margin-left:-1.5em;margin-top:.125em;position:absolute;width:1em}.tox-checklist li:not(.tox-checklist--hidden).tox-checklist--checked::before{content:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cg%20id%3D%22checklist-checked%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Crect%20id%3D%22Rectangle%22%20width%3D%2216%22%20height%3D%2216%22%20fill%3D%22%234099FF%22%20fill-rule%3D%22nonzero%22%20rx%3D%222%22%2F%3E%3Cpath%20id%3D%22Path%22%20fill%3D%22%23FFF%22%20fill-rule%3D%22nonzero%22%20d%3D%22M11.5703186%2C3.14417309%20C11.8516238%2C2.73724603%2012.4164781%2C2.62829933%2012.83558%2C2.89774797%20C13.260121%2C3.17069355%2013.3759736%2C3.72932262%2013.0909105%2C4.14168582%20L7.7580587%2C11.8560195%20C7.43776896%2C12.3193404%206.76483983%2C12.3852142%206.35607322%2C11.9948725%20L3.02491697%2C8.8138662%20C2.66090143%2C8.46625845%202.65798871%2C7.89594698%203.01850234%2C7.54483354%20C3.373942%2C7.19866177%203.94940006%2C7.19592841%204.30829608%2C7.5386474%20L6.85276923%2C9.9684299%20L11.5703186%2C3.14417309%20Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E%0A")}[dir=rtl] .tox-checklist>li:not(.tox-checklist--hidden)::before{margin-left:0;margin-right:-1.5em}code[class*=language-],pre[class*=language-]{color:#000;background:0 0;text-shadow:0 1px #fff;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;tab-size:4;-webkit-hyphens:none;-ms-hyphens:none;hyphens:none}code[class*=language-] ::-moz-selection,code[class*=language-]::-moz-selection,pre[class*=language-] ::-moz-selection,pre[class*=language-]::-moz-selection{text-shadow:none;background:#b3d4fc}code[class*=language-] ::selection,code[class*=language-]::selection,pre[class*=language-] ::selection,pre[class*=language-]::selection{text-shadow:none;background:#b3d4fc}@media print{code[class*=language-],pre[class*=language-]{text-shadow:none}}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#f5f2f0}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#708090}.token.punctuation{color:#999}.namespace{opacity:.7}.token.boolean,.token.constant,.token.deleted,.token.number,.token.property,.token.symbol,.token.tag{color:#905}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string{color:#690}.language-css .token.string,.style .token.string,.token.entity,.token.operator,.token.url{color:#9a6e3a;background:hsla(0,0%,100%,.5)}.token.atrule,.token.attr-value,.token.keyword{color:#07a}.token.class-name,.token.function{color:#dd4a68}.token.important,.token.regex,.token.variable{color:#e90}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}.mce-content-body{overflow-wrap:break-word;word-wrap:break-word}.mce-content-body .mce-visual-caret{background-color:#000;background-color:currentColor;position:absolute}.mce-content-body .mce-visual-caret-hidden{display:none}.mce-content-body [data-mce-caret]{left:-1000px;margin:0;padding:0;position:absolute;right:auto;top:0}.mce-content-body .mce-offscreen-selection{left:-2000000px;max-width:1000000px;position:absolute}.mce-content-body [contentEditable=false]{cursor:default}.mce-content-body [contentEditable=true]{cursor:text}.tox-cursor-format-painter{cursor:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%3E%0A%20%20%3Cg%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%0A%20%20%20%20%3Cpath%20fill%3D%22%23000%22%20fill-rule%3D%22nonzero%22%20d%3D%22M15%2C6%20C15%2C5.45%2014.55%2C5%2014%2C5%20L6%2C5%20C5.45%2C5%205%2C5.45%205%2C6%20L5%2C10%20C5%2C10.55%205.45%2C11%206%2C11%20L14%2C11%20C14.55%2C11%2015%2C10.55%2015%2C10%20L15%2C9%20L16%2C9%20L16%2C12%20L9%2C12%20L9%2C19%20C9%2C19.55%209.45%2C20%2010%2C20%20L11%2C20%20C11.55%2C20%2012%2C19.55%2012%2C19%20L12%2C14%20L18%2C14%20L18%2C7%20L15%2C7%20L15%2C6%20Z%22%2F%3E%0A%20%20%20%20%3Cpath%20fill%3D%22%23000%22%20fill-rule%3D%22nonzero%22%20d%3D%22M1%2C1%20L8.25%2C1%20C8.66421356%2C1%209%2C1.33578644%209%2C1.75%20L9%2C1.75%20C9%2C2.16421356%208.66421356%2C2.5%208.25%2C2.5%20L2.5%2C2.5%20L2.5%2C8.25%20C2.5%2C8.66421356%202.16421356%2C9%201.75%2C9%20L1.75%2C9%20C1.33578644%2C9%201%2C8.66421356%201%2C8.25%20L1%2C1%20Z%22%2F%3E%0A%20%20%3C%2Fg%3E%0A%3C%2Fsvg%3E%0A"),default}.mce-content-body figure.align-left{float:left}.mce-content-body figure.align-right{float:right}.mce-content-body figure.image.align-center{display:table;margin-left:auto;margin-right:auto}.mce-preview-object{border:1px solid gray;display:inline-block;line-height:0;margin:0 2px 0 2px;position:relative}.mce-preview-object .mce-shim{background:url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7);height:100%;left:0;position:absolute;top:0;width:100%}.mce-preview-object[data-mce-selected="2"] .mce-shim{display:none}.mce-object{background:transparent url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M4%203h16a1%201%200%200%201%201%201v16a1%201%200%200%201-1%201H4a1%201%200%200%201-1-1V4a1%201%200%200%201%201-1zm1%202v14h14V5H5zm4.79%202.565l5.64%204.028a.5.5%200%200%201%200%20.814l-5.64%204.028a.5.5%200%200%201-.79-.407V7.972a.5.5%200%200%201%20.79-.407z%22%2F%3E%3C%2Fsvg%3E%0A") no-repeat center;border:1px dashed #aaa}.mce-pagebreak{border:1px dashed #aaa;cursor:default;display:block;height:5px;margin-top:15px;page-break-before:always;width:100%}@media print{.mce-pagebreak{border:0}}.tiny-pageembed .mce-shim{background:url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7);height:100%;left:0;position:absolute;top:0;width:100%}.tiny-pageembed[data-mce-selected="2"] .mce-shim{display:none}.tiny-pageembed{display:inline-block;position:relative}.tiny-pageembed--16by9,.tiny-pageembed--1by1,.tiny-pageembed--21by9,.tiny-pageembed--4by3{display:block;overflow:hidden;padding:0;position:relative;width:100%}.tiny-pageembed--21by9{padding-top:42.857143%}.tiny-pageembed--16by9{padding-top:56.25%}.tiny-pageembed--4by3{padding-top:75%}.tiny-pageembed--1by1{padding-top:100%}.tiny-pageembed--16by9 iframe,.tiny-pageembed--1by1 iframe,.tiny-pageembed--21by9 iframe,.tiny-pageembed--4by3 iframe{border:0;height:100%;left:0;position:absolute;top:0;width:100%}.mce-content-body[data-mce-placeholder]{position:relative}.mce-content-body[data-mce-placeholder]:not(.mce-visualblocks)::before{color:rgba(34,47,62,.7);content:attr(data-mce-placeholder);position:absolute}.mce-content-body:not([dir=rtl])[data-mce-placeholder]:not(.mce-visualblocks)::before{left:1px}.mce-content-body[dir=rtl][data-mce-placeholder]:not(.mce-visualblocks)::before{right:1px}.mce-content-body div.mce-resizehandle{background-color:#4099ff;border-color:#4099ff;border-style:solid;border-width:1px;box-sizing:border-box;height:10px;position:absolute;width:10px;z-index:1298}.mce-content-body div.mce-resizehandle:hover{background-color:#4099ff}.mce-content-body div.mce-resizehandle:nth-of-type(1){cursor:nwse-resize}.mce-content-body div.mce-resizehandle:nth-of-type(2){cursor:nesw-resize}.mce-content-body div.mce-resizehandle:nth-of-type(3){cursor:nwse-resize}.mce-content-body div.mce-resizehandle:nth-of-type(4){cursor:nesw-resize}.mce-content-body .mce-resize-backdrop{z-index:10000}.mce-content-body .mce-clonedresizable{cursor:default;opacity:.5;outline:1px dashed #000;position:absolute;z-index:10001}.mce-content-body .mce-clonedresizable.mce-resizetable-columns td,.mce-content-body .mce-clonedresizable.mce-resizetable-columns th{border:0}.mce-content-body .mce-resize-helper{background:#555;background:rgba(0,0,0,.75);border:1px;border-radius:3px;color:#fff;display:none;font-family:sans-serif;font-size:12px;line-height:14px;margin:5px 10px;padding:5px;position:absolute;white-space:nowrap;z-index:10002}.tox-rtc-user-selection{position:relative}.tox-rtc-user-cursor{bottom:0;cursor:default;position:absolute;top:0;width:2px}.tox-rtc-user-cursor::before{background-color:inherit;border-radius:50%;content:'';display:block;height:8px;position:absolute;right:-3px;top:-3px;width:8px}.tox-rtc-user-cursor:hover::after{background-color:inherit;border-radius:100px;box-sizing:border-box;color:#fff;content:attr(data-user);display:block;font-size:12px;font-weight:700;left:-5px;min-height:8px;min-width:8px;padding:0 12px;position:absolute;top:-11px;white-space:nowrap;z-index:1000}.tox-rtc-user-selection--1 .tox-rtc-user-cursor{background-color:#2dc26b}.tox-rtc-user-selection--2 .tox-rtc-user-cursor{background-color:#e03e2d}.tox-rtc-user-selection--3 .tox-rtc-user-cursor{background-color:#f1c40f}.tox-rtc-user-selection--4 .tox-rtc-user-cursor{background-color:#3598db}.tox-rtc-user-selection--5 .tox-rtc-user-cursor{background-color:#b96ad9}.tox-rtc-user-selection--6 .tox-rtc-user-cursor{background-color:#e67e23}.tox-rtc-user-selection--7 .tox-rtc-user-cursor{background-color:#aaa69d}.tox-rtc-user-selection--8 .tox-rtc-user-cursor{background-color:#f368e0}.tox-rtc-remote-image{background:#eaeaea url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2236%22%20height%3D%2212%22%20viewBox%3D%220%200%2036%2012%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Ccircle%20cx%3D%226%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%20%20%3Ccircle%20cx%3D%2218%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20begin%3D%22.33s%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%20%20%3Ccircle%20cx%3D%2230%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20begin%3D%22.66s%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%3C%2Fsvg%3E%0A") no-repeat center center;border:1px solid #ccc;min-height:240px;min-width:320px}.mce-match-marker{background:#aaa;color:#fff}.mce-match-marker-selected{background:#39f;color:#fff}.mce-match-marker-selected::-moz-selection{background:#39f;color:#fff}.mce-match-marker-selected::selection{background:#39f;color:#fff}.mce-content-body audio[data-mce-selected],.mce-content-body embed[data-mce-selected],.mce-content-body img[data-mce-selected],.mce-content-body object[data-mce-selected],.mce-content-body table[data-mce-selected],.mce-content-body video[data-mce-selected]{outline:3px solid #b4d7ff}.mce-content-body hr[data-mce-selected]{outline:3px solid #b4d7ff;outline-offset:1px}.mce-content-body [contentEditable=false] [contentEditable=true]:focus{outline:3px solid #b4d7ff}.mce-content-body [contentEditable=false] [contentEditable=true]:hover{outline:3px solid #b4d7ff}.mce-content-body [contentEditable=false][data-mce-selected]{cursor:not-allowed;outline:3px solid #b4d7ff}.mce-content-body.mce-content-readonly [contentEditable=true]:focus,.mce-content-body.mce-content-readonly [contentEditable=true]:hover{outline:0}.mce-content-body [data-mce-selected=inline-boundary]{background-color:#b4d7ff}.mce-content-body .mce-edit-focus{outline:3px solid #b4d7ff}.mce-content-body td[data-mce-selected],.mce-content-body th[data-mce-selected]{position:relative}.mce-content-body td[data-mce-selected]::-moz-selection,.mce-content-body th[data-mce-selected]::-moz-selection{background:0 0}.mce-content-body td[data-mce-selected]::selection,.mce-content-body th[data-mce-selected]::selection{background:0 0}.mce-content-body td[data-mce-selected] *,.mce-content-body th[data-mce-selected] *{outline:0;-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.mce-content-body td[data-mce-selected]::after,.mce-content-body th[data-mce-selected]::after{background-color:rgba(180,215,255,.7);border:1px solid rgba(180,215,255,.7);bottom:-1px;content:'';left:-1px;mix-blend-mode:multiply;position:absolute;right:-1px;top:-1px}@media screen and (-ms-high-contrast:active),(-ms-high-contrast:none){.mce-content-body td[data-mce-selected]::after,.mce-content-body th[data-mce-selected]::after{border-color:rgba(0,84,180,.7)}}.mce-content-body img::-moz-selection{background:0 0}.mce-content-body img::selection{background:0 0}.ephox-snooker-resizer-bar{background-color:#b4d7ff;opacity:0;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.ephox-snooker-resizer-cols{cursor:col-resize}.ephox-snooker-resizer-rows{cursor:row-resize}.ephox-snooker-resizer-bar.ephox-snooker-resizer-bar-dragging{opacity:1}.mce-spellchecker-word{background-image:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'4'%20height%3D'4'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20stroke%3D'%23ff0000'%20fill%3D'none'%20stroke-linecap%3D'round'%20stroke-opacity%3D'.75'%20d%3D'M0%203L2%201%204%203'%2F%3E%3C%2Fsvg%3E%0A");background-position:0 calc(100% + 1px);background-repeat:repeat-x;background-size:auto 6px;cursor:default;height:2rem}.mce-spellchecker-grammar{background-image:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'4'%20height%3D'4'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20stroke%3D'%2300A835'%20fill%3D'none'%20stroke-linecap%3D'round'%20d%3D'M0%203L2%201%204%203'%2F%3E%3C%2Fsvg%3E%0A");background-position:0 calc(100% + 1px);background-repeat:repeat-x;background-size:auto 6px;cursor:default}.mce-toc{border:1px solid gray}.mce-toc h2{margin:4px}.mce-toc li{list-style-type:none}.mce-item-table:not([border]),.mce-item-table:not([border]) caption,.mce-item-table:not([border]) td,.mce-item-table:not([border]) th,.mce-item-table[border="0"],.mce-item-table[border="0"] caption,.mce-item-table[border="0"] td,.mce-item-table[border="0"] th,table[style*="border-width: 0px"],table[style*="border-width: 0px"] caption,table[style*="border-width: 0px"] td,table[style*="border-width: 0px"] th{border:1px dashed #bbb}.mce-visualblocks address,.mce-visualblocks article,.mce-visualblocks aside,.mce-visualblocks blockquote,.mce-visualblocks div:not([data-mce-bogus]),.mce-visualblocks dl,.mce-visualblocks figcaption,.mce-visualblocks figure,.mce-visualblocks h1,.mce-visualblocks h2,.mce-visualblocks h3,.mce-visualblocks h4,.mce-visualblocks h5,.mce-visualblocks h6,.mce-visualblocks hgroup,.mce-visualblocks ol,.mce-visualblocks p,.mce-visualblocks pre,.mce-visualblocks section,.mce-visualblocks ul{background-repeat:no-repeat;border:1px dashed #bbb;margin-left:3px;padding-top:10px}.mce-visualblocks p{background-image:url(data:image/gif;base64,R0lGODlhCQAJAJEAAAAAAP///7u7u////yH5BAEAAAMALAAAAAAJAAkAAAIQnG+CqCN/mlyvsRUpThG6AgA7)}.mce-visualblocks h1{background-image:url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybGu1JuxHoAfRNRW3TWXyF2YiRUAOw==)}.mce-visualblocks h2{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8Hybbx4oOuqgTynJd6bGlWg3DkJzoaUAAAOw==)}.mce-visualblocks h3{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIZjI8Hybbx4oOuqgTynJf2Ln2NOHpQpmhAAQA7)}.mce-visualblocks h4{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxInR0zqeAdhtJlXwV1oCll2HaWgAAOw==)}.mce-visualblocks h5{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxIoiuwjane4iq5GlW05GgIkIZUAAAOw==)}.mce-visualblocks h6{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxIoiuwjan04jep1iZ1XRlAo5bVgAAOw==)}.mce-visualblocks div:not([data-mce-bogus]){background-image:url(data:image/gif;base64,R0lGODlhEgAKAIABALu7u////yH5BAEAAAEALAAAAAASAAoAAAIfjI9poI0cgDywrhuxfbrzDEbQM2Ei5aRjmoySW4pAAQA7)}.mce-visualblocks section{background-image:url(data:image/gif;base64,R0lGODlhKAAKAIABALu7u////yH5BAEAAAEALAAAAAAoAAoAAAI5jI+pywcNY3sBWHdNrplytD2ellDeSVbp+GmWqaDqDMepc8t17Y4vBsK5hDyJMcI6KkuYU+jpjLoKADs=)}.mce-visualblocks article{background-image:url(data:image/gif;base64,R0lGODlhKgAKAIABALu7u////yH5BAEAAAEALAAAAAAqAAoAAAI6jI+pywkNY3wG0GBvrsd2tXGYSGnfiF7ikpXemTpOiJScasYoDJJrjsG9gkCJ0ag6KhmaIe3pjDYBBQA7)}.mce-visualblocks blockquote{background-image:url(data:image/gif;base64,R0lGODlhPgAKAIABALu7u////yH5BAEAAAEALAAAAAA+AAoAAAJPjI+py+0Knpz0xQDyuUhvfoGgIX5iSKZYgq5uNL5q69asZ8s5rrf0yZmpNkJZzFesBTu8TOlDVAabUyatguVhWduud3EyiUk45xhTTgMBBQA7)}.mce-visualblocks address{background-image:url(data:image/gif;base64,R0lGODlhLQAKAIABALu7u////yH5BAEAAAEALAAAAAAtAAoAAAI/jI+pywwNozSP1gDyyZcjb3UaRpXkWaXmZW4OqKLhBmLs+K263DkJK7OJeifh7FicKD9A1/IpGdKkyFpNmCkAADs=)}.mce-visualblocks pre{background-image:url(data:image/gif;base64,R0lGODlhFQAKAIABALu7uwAAACH5BAEAAAEALAAAAAAVAAoAAAIjjI+ZoN0cgDwSmnpz1NCueYERhnibZVKLNnbOq8IvKpJtVQAAOw==)}.mce-visualblocks figure{background-image:url(data:image/gif;base64,R0lGODlhJAAKAIAAALu7u////yH5BAEAAAEALAAAAAAkAAoAAAI0jI+py+2fwAHUSFvD3RlvG4HIp4nX5JFSpnZUJ6LlrM52OE7uSWosBHScgkSZj7dDKnWAAgA7)}.mce-visualblocks figcaption{border:1px dashed #bbb}.mce-visualblocks hgroup{background-image:url(data:image/gif;base64,R0lGODlhJwAKAIABALu7uwAAACH5BAEAAAEALAAAAAAnAAoAAAI3jI+pywYNI3uB0gpsRtt5fFnfNZaVSYJil4Wo03Hv6Z62uOCgiXH1kZIIJ8NiIxRrAZNMZAtQAAA7)}.mce-visualblocks aside{background-image:url(data:image/gif;base64,R0lGODlhHgAKAIABAKqqqv///yH5BAEAAAEALAAAAAAeAAoAAAItjI+pG8APjZOTzgtqy7I3f1yehmQcFY4WKZbqByutmW4aHUd6vfcVbgudgpYCADs=)}.mce-visualblocks ul{background-image:url(data:image/gif;base64,R0lGODlhDQAKAIAAALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybGuYnqUVSjvw26DzzXiqIDlVwAAOw==)}.mce-visualblocks ol{background-image:url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybH6HHt0qourxC6CvzXieHyeWQAAOw==)}.mce-visualblocks dl{background-image:url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybEOnmOvUoWznTqeuEjNSCqeGRUAOw==)}.mce-visualblocks:not([dir=rtl]) address,.mce-visualblocks:not([dir=rtl]) article,.mce-visualblocks:not([dir=rtl]) aside,.mce-visualblocks:not([dir=rtl]) blockquote,.mce-visualblocks:not([dir=rtl]) div:not([data-mce-bogus]),.mce-visualblocks:not([dir=rtl]) dl,.mce-visualblocks:not([dir=rtl]) figcaption,.mce-visualblocks:not([dir=rtl]) figure,.mce-visualblocks:not([dir=rtl]) h1,.mce-visualblocks:not([dir=rtl]) h2,.mce-visualblocks:not([dir=rtl]) h3,.mce-visualblocks:not([dir=rtl]) h4,.mce-visualblocks:not([dir=rtl]) h5,.mce-visualblocks:not([dir=rtl]) h6,.mce-visualblocks:not([dir=rtl]) hgroup,.mce-visualblocks:not([dir=rtl]) ol,.mce-visualblocks:not([dir=rtl]) p,.mce-visualblocks:not([dir=rtl]) pre,.mce-visualblocks:not([dir=rtl]) section,.mce-visualblocks:not([dir=rtl]) ul{margin-left:3px}.mce-visualblocks[dir=rtl] address,.mce-visualblocks[dir=rtl] article,.mce-visualblocks[dir=rtl] aside,.mce-visualblocks[dir=rtl] blockquote,.mce-visualblocks[dir=rtl] div:not([data-mce-bogus]),.mce-visualblocks[dir=rtl] dl,.mce-visualblocks[dir=rtl] figcaption,.mce-visualblocks[dir=rtl] figure,.mce-visualblocks[dir=rtl] h1,.mce-visualblocks[dir=rtl] h2,.mce-visualblocks[dir=rtl] h3,.mce-visualblocks[dir=rtl] h4,.mce-visualblocks[dir=rtl] h5,.mce-visualblocks[dir=rtl] h6,.mce-visualblocks[dir=rtl] hgroup,.mce-visualblocks[dir=rtl] ol,.mce-visualblocks[dir=rtl] p,.mce-visualblocks[dir=rtl] pre,.mce-visualblocks[dir=rtl] section,.mce-visualblocks[dir=rtl] ul{background-position-x:right;margin-right:3px}.mce-nbsp,.mce-shy{background:#aaa}.mce-shy::after{content:'-'}
diff --git a/public/tinymce/skins/ui/oxide-dark/content.min.css b/public/tinymce/skins/ui/oxide-dark/content.min.css
new file mode 100644
index 0000000..e27b8a0
--- /dev/null
+++ b/public/tinymce/skins/ui/oxide-dark/content.min.css
@@ -0,0 +1,7 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+.mce-content-body .mce-item-anchor{background:transparent url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'8'%20height%3D'12'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20d%3D'M0%200L8%200%208%2012%204.09117821%209%200%2012z'%20fill%3D%22%23cccccc%22%2F%3E%3C%2Fsvg%3E%0A") no-repeat center;cursor:default;display:inline-block;height:12px!important;padding:0 2px;-webkit-user-modify:read-only;-moz-user-modify:read-only;-webkit-user-select:all;-moz-user-select:all;-ms-user-select:all;user-select:all;width:8px!important}.mce-content-body .mce-item-anchor[data-mce-selected]{outline-offset:1px}.tox-comments-visible .tox-comment{background-color:#fff0b7}.tox-comments-visible .tox-comment--active{background-color:#ffe168}.tox-checklist>li:not(.tox-checklist--hidden){list-style:none;margin:.25em 0}.tox-checklist>li:not(.tox-checklist--hidden)::before{content:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cg%20id%3D%22checklist-unchecked%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Crect%20id%3D%22Rectangle%22%20width%3D%2215%22%20height%3D%2215%22%20x%3D%22.5%22%20y%3D%22.5%22%20fill-rule%3D%22nonzero%22%20stroke%3D%22%236d737b%22%20rx%3D%222%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E%0A");cursor:pointer;height:1em;margin-left:-1.5em;margin-top:.125em;position:absolute;width:1em}.tox-checklist li:not(.tox-checklist--hidden).tox-checklist--checked::before{content:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cg%20id%3D%22checklist-checked%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Crect%20id%3D%22Rectangle%22%20width%3D%2216%22%20height%3D%2216%22%20fill%3D%22%234099FF%22%20fill-rule%3D%22nonzero%22%20rx%3D%222%22%2F%3E%3Cpath%20id%3D%22Path%22%20fill%3D%22%23FFF%22%20fill-rule%3D%22nonzero%22%20d%3D%22M11.5703186%2C3.14417309%20C11.8516238%2C2.73724603%2012.4164781%2C2.62829933%2012.83558%2C2.89774797%20C13.260121%2C3.17069355%2013.3759736%2C3.72932262%2013.0909105%2C4.14168582%20L7.7580587%2C11.8560195%20C7.43776896%2C12.3193404%206.76483983%2C12.3852142%206.35607322%2C11.9948725%20L3.02491697%2C8.8138662%20C2.66090143%2C8.46625845%202.65798871%2C7.89594698%203.01850234%2C7.54483354%20C3.373942%2C7.19866177%203.94940006%2C7.19592841%204.30829608%2C7.5386474%20L6.85276923%2C9.9684299%20L11.5703186%2C3.14417309%20Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E%0A")}[dir=rtl] .tox-checklist>li:not(.tox-checklist--hidden)::before{margin-left:0;margin-right:-1.5em}code[class*=language-],pre[class*=language-]{color:#f8f8f2;background:0 0;text-shadow:0 1px rgba(0,0,0,.3);font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;tab-size:4;-webkit-hyphens:none;-ms-hyphens:none;hyphens:none}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto;border-radius:.3em}:not(pre)>code[class*=language-],pre[class*=language-]{background:#282a36}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#6272a4}.token.punctuation{color:#f8f8f2}.namespace{opacity:.7}.token.constant,.token.deleted,.token.property,.token.symbol,.token.tag{color:#ff79c6}.token.boolean,.token.number{color:#bd93f9}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string{color:#50fa7b}.language-css .token.string,.style .token.string,.token.entity,.token.operator,.token.url,.token.variable{color:#f8f8f2}.token.atrule,.token.attr-value,.token.class-name,.token.function{color:#f1fa8c}.token.keyword{color:#8be9fd}.token.important,.token.regex{color:#ffb86c}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}.mce-content-body{overflow-wrap:break-word;word-wrap:break-word}.mce-content-body .mce-visual-caret{background-color:#000;background-color:currentColor;position:absolute}.mce-content-body .mce-visual-caret-hidden{display:none}.mce-content-body [data-mce-caret]{left:-1000px;margin:0;padding:0;position:absolute;right:auto;top:0}.mce-content-body .mce-offscreen-selection{left:-2000000px;max-width:1000000px;position:absolute}.mce-content-body [contentEditable=false]{cursor:default}.mce-content-body [contentEditable=true]{cursor:text}.tox-cursor-format-painter{cursor:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%3E%0A%20%20%3Cg%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%0A%20%20%20%20%3Cpath%20fill%3D%22%23000%22%20fill-rule%3D%22nonzero%22%20d%3D%22M15%2C6%20C15%2C5.45%2014.55%2C5%2014%2C5%20L6%2C5%20C5.45%2C5%205%2C5.45%205%2C6%20L5%2C10%20C5%2C10.55%205.45%2C11%206%2C11%20L14%2C11%20C14.55%2C11%2015%2C10.55%2015%2C10%20L15%2C9%20L16%2C9%20L16%2C12%20L9%2C12%20L9%2C19%20C9%2C19.55%209.45%2C20%2010%2C20%20L11%2C20%20C11.55%2C20%2012%2C19.55%2012%2C19%20L12%2C14%20L18%2C14%20L18%2C7%20L15%2C7%20L15%2C6%20Z%22%2F%3E%0A%20%20%20%20%3Cpath%20fill%3D%22%23000%22%20fill-rule%3D%22nonzero%22%20d%3D%22M1%2C1%20L8.25%2C1%20C8.66421356%2C1%209%2C1.33578644%209%2C1.75%20L9%2C1.75%20C9%2C2.16421356%208.66421356%2C2.5%208.25%2C2.5%20L2.5%2C2.5%20L2.5%2C8.25%20C2.5%2C8.66421356%202.16421356%2C9%201.75%2C9%20L1.75%2C9%20C1.33578644%2C9%201%2C8.66421356%201%2C8.25%20L1%2C1%20Z%22%2F%3E%0A%20%20%3C%2Fg%3E%0A%3C%2Fsvg%3E%0A"),default}.mce-content-body figure.align-left{float:left}.mce-content-body figure.align-right{float:right}.mce-content-body figure.image.align-center{display:table;margin-left:auto;margin-right:auto}.mce-preview-object{border:1px solid gray;display:inline-block;line-height:0;margin:0 2px 0 2px;position:relative}.mce-preview-object .mce-shim{background:url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7);height:100%;left:0;position:absolute;top:0;width:100%}.mce-preview-object[data-mce-selected="2"] .mce-shim{display:none}.mce-object{background:transparent url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M4%203h16a1%201%200%200%201%201%201v16a1%201%200%200%201-1%201H4a1%201%200%200%201-1-1V4a1%201%200%200%201%201-1zm1%202v14h14V5H5zm4.79%202.565l5.64%204.028a.5.5%200%200%201%200%20.814l-5.64%204.028a.5.5%200%200%201-.79-.407V7.972a.5.5%200%200%201%20.79-.407z%22%20fill%3D%22%23cccccc%22%2F%3E%3C%2Fsvg%3E%0A") no-repeat center;border:1px dashed #aaa}.mce-pagebreak{border:1px dashed #aaa;cursor:default;display:block;height:5px;margin-top:15px;page-break-before:always;width:100%}@media print{.mce-pagebreak{border:0}}.tiny-pageembed .mce-shim{background:url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7);height:100%;left:0;position:absolute;top:0;width:100%}.tiny-pageembed[data-mce-selected="2"] .mce-shim{display:none}.tiny-pageembed{display:inline-block;position:relative}.tiny-pageembed--16by9,.tiny-pageembed--1by1,.tiny-pageembed--21by9,.tiny-pageembed--4by3{display:block;overflow:hidden;padding:0;position:relative;width:100%}.tiny-pageembed--21by9{padding-top:42.857143%}.tiny-pageembed--16by9{padding-top:56.25%}.tiny-pageembed--4by3{padding-top:75%}.tiny-pageembed--1by1{padding-top:100%}.tiny-pageembed--16by9 iframe,.tiny-pageembed--1by1 iframe,.tiny-pageembed--21by9 iframe,.tiny-pageembed--4by3 iframe{border:0;height:100%;left:0;position:absolute;top:0;width:100%}.mce-content-body[data-mce-placeholder]{position:relative}.mce-content-body[data-mce-placeholder]:not(.mce-visualblocks)::before{color:rgba(34,47,62,.7);content:attr(data-mce-placeholder);position:absolute}.mce-content-body:not([dir=rtl])[data-mce-placeholder]:not(.mce-visualblocks)::before{left:1px}.mce-content-body[dir=rtl][data-mce-placeholder]:not(.mce-visualblocks)::before{right:1px}.mce-content-body div.mce-resizehandle{background-color:#4099ff;border-color:#4099ff;border-style:solid;border-width:1px;box-sizing:border-box;height:10px;position:absolute;width:10px;z-index:1298}.mce-content-body div.mce-resizehandle:hover{background-color:#4099ff}.mce-content-body div.mce-resizehandle:nth-of-type(1){cursor:nwse-resize}.mce-content-body div.mce-resizehandle:nth-of-type(2){cursor:nesw-resize}.mce-content-body div.mce-resizehandle:nth-of-type(3){cursor:nwse-resize}.mce-content-body div.mce-resizehandle:nth-of-type(4){cursor:nesw-resize}.mce-content-body .mce-resize-backdrop{z-index:10000}.mce-content-body .mce-clonedresizable{cursor:default;opacity:.5;outline:1px dashed #000;position:absolute;z-index:10001}.mce-content-body .mce-clonedresizable.mce-resizetable-columns td,.mce-content-body .mce-clonedresizable.mce-resizetable-columns th{border:0}.mce-content-body .mce-resize-helper{background:#555;background:rgba(0,0,0,.75);border:1px;border-radius:3px;color:#fff;display:none;font-family:sans-serif;font-size:12px;line-height:14px;margin:5px 10px;padding:5px;position:absolute;white-space:nowrap;z-index:10002}.tox-rtc-user-selection{position:relative}.tox-rtc-user-cursor{bottom:0;cursor:default;position:absolute;top:0;width:2px}.tox-rtc-user-cursor::before{background-color:inherit;border-radius:50%;content:'';display:block;height:8px;position:absolute;right:-3px;top:-3px;width:8px}.tox-rtc-user-cursor:hover::after{background-color:inherit;border-radius:100px;box-sizing:border-box;color:#fff;content:attr(data-user);display:block;font-size:12px;font-weight:700;left:-5px;min-height:8px;min-width:8px;padding:0 12px;position:absolute;top:-11px;white-space:nowrap;z-index:1000}.tox-rtc-user-selection--1 .tox-rtc-user-cursor{background-color:#2dc26b}.tox-rtc-user-selection--2 .tox-rtc-user-cursor{background-color:#e03e2d}.tox-rtc-user-selection--3 .tox-rtc-user-cursor{background-color:#f1c40f}.tox-rtc-user-selection--4 .tox-rtc-user-cursor{background-color:#3598db}.tox-rtc-user-selection--5 .tox-rtc-user-cursor{background-color:#b96ad9}.tox-rtc-user-selection--6 .tox-rtc-user-cursor{background-color:#e67e23}.tox-rtc-user-selection--7 .tox-rtc-user-cursor{background-color:#aaa69d}.tox-rtc-user-selection--8 .tox-rtc-user-cursor{background-color:#f368e0}.tox-rtc-remote-image{background:#eaeaea url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2236%22%20height%3D%2212%22%20viewBox%3D%220%200%2036%2012%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Ccircle%20cx%3D%226%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%20%20%3Ccircle%20cx%3D%2218%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20begin%3D%22.33s%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%20%20%3Ccircle%20cx%3D%2230%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20begin%3D%22.66s%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%3C%2Fsvg%3E%0A") no-repeat center center;border:1px solid #ccc;min-height:240px;min-width:320px}.mce-match-marker{background:#aaa;color:#fff}.mce-match-marker-selected{background:#39f;color:#fff}.mce-match-marker-selected::-moz-selection{background:#39f;color:#fff}.mce-match-marker-selected::selection{background:#39f;color:#fff}.mce-content-body audio[data-mce-selected],.mce-content-body embed[data-mce-selected],.mce-content-body img[data-mce-selected],.mce-content-body object[data-mce-selected],.mce-content-body table[data-mce-selected],.mce-content-body video[data-mce-selected]{outline:3px solid #4099ff}.mce-content-body hr[data-mce-selected]{outline:3px solid #4099ff;outline-offset:1px}.mce-content-body [contentEditable=false] [contentEditable=true]:focus{outline:3px solid #4099ff}.mce-content-body [contentEditable=false] [contentEditable=true]:hover{outline:3px solid #4099ff}.mce-content-body [contentEditable=false][data-mce-selected]{cursor:not-allowed;outline:3px solid #4099ff}.mce-content-body.mce-content-readonly [contentEditable=true]:focus,.mce-content-body.mce-content-readonly [contentEditable=true]:hover{outline:0}.mce-content-body [data-mce-selected=inline-boundary]{background-color:#4099ff}.mce-content-body .mce-edit-focus{outline:3px solid #4099ff}.mce-content-body td[data-mce-selected],.mce-content-body th[data-mce-selected]{position:relative}.mce-content-body td[data-mce-selected]::-moz-selection,.mce-content-body th[data-mce-selected]::-moz-selection{background:0 0}.mce-content-body td[data-mce-selected]::selection,.mce-content-body th[data-mce-selected]::selection{background:0 0}.mce-content-body td[data-mce-selected] *,.mce-content-body th[data-mce-selected] *{outline:0;-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.mce-content-body td[data-mce-selected]::after,.mce-content-body th[data-mce-selected]::after{background-color:rgba(180,215,255,.7);border:1px solid transparent;bottom:-1px;content:'';left:-1px;mix-blend-mode:lighten;position:absolute;right:-1px;top:-1px}@media screen and (-ms-high-contrast:active),(-ms-high-contrast:none){.mce-content-body td[data-mce-selected]::after,.mce-content-body th[data-mce-selected]::after{border-color:rgba(0,84,180,.7)}}.mce-content-body img::-moz-selection{background:0 0}.mce-content-body img::selection{background:0 0}.ephox-snooker-resizer-bar{background-color:#4099ff;opacity:0;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.ephox-snooker-resizer-cols{cursor:col-resize}.ephox-snooker-resizer-rows{cursor:row-resize}.ephox-snooker-resizer-bar.ephox-snooker-resizer-bar-dragging{opacity:1}.mce-spellchecker-word{background-image:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'4'%20height%3D'4'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20stroke%3D'%23ff0000'%20fill%3D'none'%20stroke-linecap%3D'round'%20stroke-opacity%3D'.75'%20d%3D'M0%203L2%201%204%203'%2F%3E%3C%2Fsvg%3E%0A");background-position:0 calc(100% + 1px);background-repeat:repeat-x;background-size:auto 6px;cursor:default;height:2rem}.mce-spellchecker-grammar{background-image:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'4'%20height%3D'4'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20stroke%3D'%2300A835'%20fill%3D'none'%20stroke-linecap%3D'round'%20d%3D'M0%203L2%201%204%203'%2F%3E%3C%2Fsvg%3E%0A");background-position:0 calc(100% + 1px);background-repeat:repeat-x;background-size:auto 6px;cursor:default}.mce-toc{border:1px solid gray}.mce-toc h2{margin:4px}.mce-toc li{list-style-type:none}.mce-item-table:not([border]),.mce-item-table:not([border]) caption,.mce-item-table:not([border]) td,.mce-item-table:not([border]) th,.mce-item-table[border="0"],.mce-item-table[border="0"] caption,.mce-item-table[border="0"] td,.mce-item-table[border="0"] th,table[style*="border-width: 0px"],table[style*="border-width: 0px"] caption,table[style*="border-width: 0px"] td,table[style*="border-width: 0px"] th{border:1px dashed #bbb}.mce-visualblocks address,.mce-visualblocks article,.mce-visualblocks aside,.mce-visualblocks blockquote,.mce-visualblocks div:not([data-mce-bogus]),.mce-visualblocks dl,.mce-visualblocks figcaption,.mce-visualblocks figure,.mce-visualblocks h1,.mce-visualblocks h2,.mce-visualblocks h3,.mce-visualblocks h4,.mce-visualblocks h5,.mce-visualblocks h6,.mce-visualblocks hgroup,.mce-visualblocks ol,.mce-visualblocks p,.mce-visualblocks pre,.mce-visualblocks section,.mce-visualblocks ul{background-repeat:no-repeat;border:1px dashed #bbb;margin-left:3px;padding-top:10px}.mce-visualblocks p{background-image:url(data:image/gif;base64,R0lGODlhCQAJAJEAAAAAAP///7u7u////yH5BAEAAAMALAAAAAAJAAkAAAIQnG+CqCN/mlyvsRUpThG6AgA7)}.mce-visualblocks h1{background-image:url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybGu1JuxHoAfRNRW3TWXyF2YiRUAOw==)}.mce-visualblocks h2{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8Hybbx4oOuqgTynJd6bGlWg3DkJzoaUAAAOw==)}.mce-visualblocks h3{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIZjI8Hybbx4oOuqgTynJf2Ln2NOHpQpmhAAQA7)}.mce-visualblocks h4{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxInR0zqeAdhtJlXwV1oCll2HaWgAAOw==)}.mce-visualblocks h5{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxIoiuwjane4iq5GlW05GgIkIZUAAAOw==)}.mce-visualblocks h6{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxIoiuwjan04jep1iZ1XRlAo5bVgAAOw==)}.mce-visualblocks div:not([data-mce-bogus]){background-image:url(data:image/gif;base64,R0lGODlhEgAKAIABALu7u////yH5BAEAAAEALAAAAAASAAoAAAIfjI9poI0cgDywrhuxfbrzDEbQM2Ei5aRjmoySW4pAAQA7)}.mce-visualblocks section{background-image:url(data:image/gif;base64,R0lGODlhKAAKAIABALu7u////yH5BAEAAAEALAAAAAAoAAoAAAI5jI+pywcNY3sBWHdNrplytD2ellDeSVbp+GmWqaDqDMepc8t17Y4vBsK5hDyJMcI6KkuYU+jpjLoKADs=)}.mce-visualblocks article{background-image:url(data:image/gif;base64,R0lGODlhKgAKAIABALu7u////yH5BAEAAAEALAAAAAAqAAoAAAI6jI+pywkNY3wG0GBvrsd2tXGYSGnfiF7ikpXemTpOiJScasYoDJJrjsG9gkCJ0ag6KhmaIe3pjDYBBQA7)}.mce-visualblocks blockquote{background-image:url(data:image/gif;base64,R0lGODlhPgAKAIABALu7u////yH5BAEAAAEALAAAAAA+AAoAAAJPjI+py+0Knpz0xQDyuUhvfoGgIX5iSKZYgq5uNL5q69asZ8s5rrf0yZmpNkJZzFesBTu8TOlDVAabUyatguVhWduud3EyiUk45xhTTgMBBQA7)}.mce-visualblocks address{background-image:url(data:image/gif;base64,R0lGODlhLQAKAIABALu7u////yH5BAEAAAEALAAAAAAtAAoAAAI/jI+pywwNozSP1gDyyZcjb3UaRpXkWaXmZW4OqKLhBmLs+K263DkJK7OJeifh7FicKD9A1/IpGdKkyFpNmCkAADs=)}.mce-visualblocks pre{background-image:url(data:image/gif;base64,R0lGODlhFQAKAIABALu7uwAAACH5BAEAAAEALAAAAAAVAAoAAAIjjI+ZoN0cgDwSmnpz1NCueYERhnibZVKLNnbOq8IvKpJtVQAAOw==)}.mce-visualblocks figure{background-image:url(data:image/gif;base64,R0lGODlhJAAKAIAAALu7u////yH5BAEAAAEALAAAAAAkAAoAAAI0jI+py+2fwAHUSFvD3RlvG4HIp4nX5JFSpnZUJ6LlrM52OE7uSWosBHScgkSZj7dDKnWAAgA7)}.mce-visualblocks figcaption{border:1px dashed #bbb}.mce-visualblocks hgroup{background-image:url(data:image/gif;base64,R0lGODlhJwAKAIABALu7uwAAACH5BAEAAAEALAAAAAAnAAoAAAI3jI+pywYNI3uB0gpsRtt5fFnfNZaVSYJil4Wo03Hv6Z62uOCgiXH1kZIIJ8NiIxRrAZNMZAtQAAA7)}.mce-visualblocks aside{background-image:url(data:image/gif;base64,R0lGODlhHgAKAIABAKqqqv///yH5BAEAAAEALAAAAAAeAAoAAAItjI+pG8APjZOTzgtqy7I3f1yehmQcFY4WKZbqByutmW4aHUd6vfcVbgudgpYCADs=)}.mce-visualblocks ul{background-image:url(data:image/gif;base64,R0lGODlhDQAKAIAAALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybGuYnqUVSjvw26DzzXiqIDlVwAAOw==)}.mce-visualblocks ol{background-image:url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybH6HHt0qourxC6CvzXieHyeWQAAOw==)}.mce-visualblocks dl{background-image:url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybEOnmOvUoWznTqeuEjNSCqeGRUAOw==)}.mce-visualblocks:not([dir=rtl]) address,.mce-visualblocks:not([dir=rtl]) article,.mce-visualblocks:not([dir=rtl]) aside,.mce-visualblocks:not([dir=rtl]) blockquote,.mce-visualblocks:not([dir=rtl]) div:not([data-mce-bogus]),.mce-visualblocks:not([dir=rtl]) dl,.mce-visualblocks:not([dir=rtl]) figcaption,.mce-visualblocks:not([dir=rtl]) figure,.mce-visualblocks:not([dir=rtl]) h1,.mce-visualblocks:not([dir=rtl]) h2,.mce-visualblocks:not([dir=rtl]) h3,.mce-visualblocks:not([dir=rtl]) h4,.mce-visualblocks:not([dir=rtl]) h5,.mce-visualblocks:not([dir=rtl]) h6,.mce-visualblocks:not([dir=rtl]) hgroup,.mce-visualblocks:not([dir=rtl]) ol,.mce-visualblocks:not([dir=rtl]) p,.mce-visualblocks:not([dir=rtl]) pre,.mce-visualblocks:not([dir=rtl]) section,.mce-visualblocks:not([dir=rtl]) ul{margin-left:3px}.mce-visualblocks[dir=rtl] address,.mce-visualblocks[dir=rtl] article,.mce-visualblocks[dir=rtl] aside,.mce-visualblocks[dir=rtl] blockquote,.mce-visualblocks[dir=rtl] div:not([data-mce-bogus]),.mce-visualblocks[dir=rtl] dl,.mce-visualblocks[dir=rtl] figcaption,.mce-visualblocks[dir=rtl] figure,.mce-visualblocks[dir=rtl] h1,.mce-visualblocks[dir=rtl] h2,.mce-visualblocks[dir=rtl] h3,.mce-visualblocks[dir=rtl] h4,.mce-visualblocks[dir=rtl] h5,.mce-visualblocks[dir=rtl] h6,.mce-visualblocks[dir=rtl] hgroup,.mce-visualblocks[dir=rtl] ol,.mce-visualblocks[dir=rtl] p,.mce-visualblocks[dir=rtl] pre,.mce-visualblocks[dir=rtl] section,.mce-visualblocks[dir=rtl] ul{background-position-x:right;margin-right:3px}.mce-nbsp,.mce-shy{background:#aaa}.mce-shy::after{content:'-'}body{font-family:sans-serif}table{border-collapse:collapse}
diff --git a/public/tinymce/skins/ui/oxide-dark/content.mobile.css b/public/tinymce/skins/ui/oxide-dark/content.mobile.css
new file mode 100644
index 0000000..4bdb8ba
--- /dev/null
+++ b/public/tinymce/skins/ui/oxide-dark/content.mobile.css
@@ -0,0 +1,29 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+.tinymce-mobile-unfocused-selections .tinymce-mobile-unfocused-selection {
+  /* Note: this file is used inside the content, so isn't part of theming */
+  background-color: green;
+  display: inline-block;
+  opacity: 0.5;
+  position: absolute;
+}
+body {
+  -webkit-text-size-adjust: none;
+}
+body img {
+  /* this is related to the content margin */
+  max-width: 96vw;
+}
+body table img {
+  max-width: 95%;
+}
+body {
+  font-family: sans-serif;
+}
+table {
+  border-collapse: collapse;
+}
diff --git a/public/tinymce/skins/ui/oxide-dark/content.mobile.min.css b/public/tinymce/skins/ui/oxide-dark/content.mobile.min.css
new file mode 100644
index 0000000..35f7dc0
--- /dev/null
+++ b/public/tinymce/skins/ui/oxide-dark/content.mobile.min.css
@@ -0,0 +1,7 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+.tinymce-mobile-unfocused-selections .tinymce-mobile-unfocused-selection{background-color:green;display:inline-block;opacity:.5;position:absolute}body{-webkit-text-size-adjust:none}body img{max-width:96vw}body table img{max-width:95%}body{font-family:sans-serif}table{border-collapse:collapse}
diff --git a/public/tinymce/skins/ui/oxide-dark/fonts/tinymce-mobile.woff b/public/tinymce/skins/ui/oxide-dark/fonts/tinymce-mobile.woff
new file mode 100644
index 0000000..1e3be03
Binary files /dev/null and b/public/tinymce/skins/ui/oxide-dark/fonts/tinymce-mobile.woff differ
diff --git a/public/tinymce/skins/ui/oxide-dark/skin.css b/public/tinymce/skins/ui/oxide-dark/skin.css
new file mode 100644
index 0000000..d34b9c1
--- /dev/null
+++ b/public/tinymce/skins/ui/oxide-dark/skin.css
@@ -0,0 +1,3047 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+.tox {
+  box-shadow: none;
+  box-sizing: content-box;
+  color: #2A3746;
+  cursor: auto;
+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
+  font-size: 16px;
+  font-style: normal;
+  font-weight: normal;
+  line-height: normal;
+  -webkit-tap-highlight-color: transparent;
+  text-decoration: none;
+  text-shadow: none;
+  text-transform: none;
+  vertical-align: initial;
+  white-space: normal;
+}
+.tox *:not(svg):not(rect) {
+  box-sizing: inherit;
+  color: inherit;
+  cursor: inherit;
+  direction: inherit;
+  font-family: inherit;
+  font-size: inherit;
+  font-style: inherit;
+  font-weight: inherit;
+  line-height: inherit;
+  -webkit-tap-highlight-color: inherit;
+  text-align: inherit;
+  text-decoration: inherit;
+  text-shadow: inherit;
+  text-transform: inherit;
+  vertical-align: inherit;
+  white-space: inherit;
+}
+.tox *:not(svg):not(rect) {
+  /* stylelint-disable-line no-duplicate-selectors */
+  background: transparent;
+  border: 0;
+  box-shadow: none;
+  float: none;
+  height: auto;
+  margin: 0;
+  max-width: none;
+  outline: 0;
+  padding: 0;
+  position: static;
+  width: auto;
+}
+.tox:not([dir=rtl]) {
+  direction: ltr;
+  text-align: left;
+}
+.tox[dir=rtl] {
+  direction: rtl;
+  text-align: right;
+}
+.tox-tinymce {
+  border: 1px solid #000000;
+  border-radius: 0;
+  box-shadow: none;
+  box-sizing: border-box;
+  display: flex;
+  flex-direction: column;
+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
+  overflow: hidden;
+  position: relative;
+  visibility: inherit !important;
+}
+.tox-tinymce-inline {
+  border: none;
+  box-shadow: none;
+}
+.tox-tinymce-inline .tox-editor-header {
+  background-color: transparent;
+  border: 1px solid #000000;
+  border-radius: 0;
+  box-shadow: none;
+}
+.tox-tinymce-aux {
+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
+  z-index: 1300;
+}
+.tox-tinymce *:focus,
+.tox-tinymce-aux *:focus {
+  outline: none;
+}
+button::-moz-focus-inner {
+  border: 0;
+}
+.tox[dir=rtl] .tox-icon--flip svg {
+  transform: rotateY(180deg);
+}
+.tox .accessibility-issue__header {
+  align-items: center;
+  display: flex;
+  margin-bottom: 4px;
+}
+.tox .accessibility-issue__description {
+  align-items: stretch;
+  border: 1px solid #000000;
+  border-radius: 3px;
+  display: flex;
+  justify-content: space-between;
+}
+.tox .accessibility-issue__description > div {
+  padding-bottom: 4px;
+}
+.tox .accessibility-issue__description > div > div {
+  align-items: center;
+  display: flex;
+  margin-bottom: 4px;
+}
+.tox .accessibility-issue__description > *:last-child:not(:only-child) {
+  border-color: #000000;
+  border-style: solid;
+}
+.tox .accessibility-issue__repair {
+  margin-top: 16px;
+}
+.tox .tox-dialog__body-content .accessibility-issue--info .accessibility-issue__description {
+  background-color: rgba(32, 122, 183, 0.5);
+  border-color: #207ab7;
+  color: #fff;
+}
+.tox .tox-dialog__body-content .accessibility-issue--info .accessibility-issue__description > *:last-child {
+  border-color: #207ab7;
+}
+.tox .tox-dialog__body-content .accessibility-issue--info .tox-form__group h2 {
+  color: #fff;
+}
+.tox .tox-dialog__body-content .accessibility-issue--info .tox-icon svg {
+  fill: #fff;
+}
+.tox .tox-dialog__body-content .accessibility-issue--info a .tox-icon {
+  color: #fff;
+}
+.tox .tox-dialog__body-content .accessibility-issue--warn .accessibility-issue__description {
+  background-color: rgba(255, 165, 0, 0.5);
+  border-color: rgba(255, 165, 0, 0.8);
+  color: #fff;
+}
+.tox .tox-dialog__body-content .accessibility-issue--warn .accessibility-issue__description > *:last-child {
+  border-color: rgba(255, 165, 0, 0.8);
+}
+.tox .tox-dialog__body-content .accessibility-issue--warn .tox-form__group h2 {
+  color: #fff;
+}
+.tox .tox-dialog__body-content .accessibility-issue--warn .tox-icon svg {
+  fill: #fff;
+}
+.tox .tox-dialog__body-content .accessibility-issue--warn a .tox-icon {
+  color: #fff;
+}
+.tox .tox-dialog__body-content .accessibility-issue--error .accessibility-issue__description {
+  background-color: rgba(204, 0, 0, 0.5);
+  border-color: rgba(204, 0, 0, 0.8);
+  color: #fff;
+}
+.tox .tox-dialog__body-content .accessibility-issue--error .accessibility-issue__description > *:last-child {
+  border-color: rgba(204, 0, 0, 0.8);
+}
+.tox .tox-dialog__body-content .accessibility-issue--error .tox-form__group h2 {
+  color: #fff;
+}
+.tox .tox-dialog__body-content .accessibility-issue--error .tox-icon svg {
+  fill: #fff;
+}
+.tox .tox-dialog__body-content .accessibility-issue--error a .tox-icon {
+  color: #fff;
+}
+.tox .tox-dialog__body-content .accessibility-issue--success .accessibility-issue__description {
+  background-color: rgba(120, 171, 70, 0.5);
+  border-color: rgba(120, 171, 70, 0.8);
+  color: #fff;
+}
+.tox .tox-dialog__body-content .accessibility-issue--success .accessibility-issue__description > *:last-child {
+  border-color: rgba(120, 171, 70, 0.8);
+}
+.tox .tox-dialog__body-content .accessibility-issue--success .tox-form__group h2 {
+  color: #fff;
+}
+.tox .tox-dialog__body-content .accessibility-issue--success .tox-icon svg {
+  fill: #fff;
+}
+.tox .tox-dialog__body-content .accessibility-issue--success a .tox-icon {
+  color: #fff;
+}
+.tox .tox-dialog__body-content .accessibility-issue__header h1,
+.tox .tox-dialog__body-content .tox-form__group .accessibility-issue__description h2 {
+  margin-top: 0;
+}
+.tox:not([dir=rtl]) .tox-dialog__body-content .accessibility-issue__header .tox-button {
+  margin-left: 4px;
+}
+.tox:not([dir=rtl]) .tox-dialog__body-content .accessibility-issue__header > *:nth-last-child(2) {
+  margin-left: auto;
+}
+.tox:not([dir=rtl]) .tox-dialog__body-content .accessibility-issue__description {
+  padding: 4px 4px 4px 8px;
+}
+.tox:not([dir=rtl]) .tox-dialog__body-content .accessibility-issue__description > *:last-child {
+  border-left-width: 1px;
+  padding-left: 4px;
+}
+.tox[dir=rtl] .tox-dialog__body-content .accessibility-issue__header .tox-button {
+  margin-right: 4px;
+}
+.tox[dir=rtl] .tox-dialog__body-content .accessibility-issue__header > *:nth-last-child(2) {
+  margin-right: auto;
+}
+.tox[dir=rtl] .tox-dialog__body-content .accessibility-issue__description {
+  padding: 4px 8px 4px 4px;
+}
+.tox[dir=rtl] .tox-dialog__body-content .accessibility-issue__description > *:last-child {
+  border-right-width: 1px;
+  padding-right: 4px;
+}
+.tox .tox-anchorbar {
+  display: flex;
+  flex: 0 0 auto;
+}
+.tox .tox-bar {
+  display: flex;
+  flex: 0 0 auto;
+}
+.tox .tox-button {
+  background-color: #207ab7;
+  background-image: none;
+  background-position: 0 0;
+  background-repeat: repeat;
+  border-color: #207ab7;
+  border-radius: 3px;
+  border-style: solid;
+  border-width: 1px;
+  box-shadow: none;
+  box-sizing: border-box;
+  color: #fff;
+  cursor: pointer;
+  display: inline-block;
+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
+  font-size: 14px;
+  font-style: normal;
+  font-weight: bold;
+  letter-spacing: normal;
+  line-height: 24px;
+  margin: 0;
+  outline: none;
+  padding: 4px 16px;
+  text-align: center;
+  text-decoration: none;
+  text-transform: none;
+  white-space: nowrap;
+}
+.tox .tox-button[disabled] {
+  background-color: #207ab7;
+  background-image: none;
+  border-color: #207ab7;
+  box-shadow: none;
+  color: rgba(255, 255, 255, 0.5);
+  cursor: not-allowed;
+}
+.tox .tox-button:focus:not(:disabled) {
+  background-color: #1c6ca1;
+  background-image: none;
+  border-color: #1c6ca1;
+  box-shadow: none;
+  color: #fff;
+}
+.tox .tox-button:hover:not(:disabled) {
+  background-color: #1c6ca1;
+  background-image: none;
+  border-color: #1c6ca1;
+  box-shadow: none;
+  color: #fff;
+}
+.tox .tox-button:active:not(:disabled) {
+  background-color: #185d8c;
+  background-image: none;
+  border-color: #185d8c;
+  box-shadow: none;
+  color: #fff;
+}
+.tox .tox-button--secondary {
+  background-color: #3d546f;
+  background-image: none;
+  background-position: 0 0;
+  background-repeat: repeat;
+  border-color: #3d546f;
+  border-radius: 3px;
+  border-style: solid;
+  border-width: 1px;
+  box-shadow: none;
+  color: #fff;
+  font-size: 14px;
+  font-style: normal;
+  font-weight: bold;
+  letter-spacing: normal;
+  outline: none;
+  padding: 4px 16px;
+  text-decoration: none;
+  text-transform: none;
+}
+.tox .tox-button--secondary[disabled] {
+  background-color: #3d546f;
+  background-image: none;
+  border-color: #3d546f;
+  box-shadow: none;
+  color: rgba(255, 255, 255, 0.5);
+}
+.tox .tox-button--secondary:focus:not(:disabled) {
+  background-color: #34485f;
+  background-image: none;
+  border-color: #34485f;
+  box-shadow: none;
+  color: #fff;
+}
+.tox .tox-button--secondary:hover:not(:disabled) {
+  background-color: #34485f;
+  background-image: none;
+  border-color: #34485f;
+  box-shadow: none;
+  color: #fff;
+}
+.tox .tox-button--secondary:active:not(:disabled) {
+  background-color: #2b3b4e;
+  background-image: none;
+  border-color: #2b3b4e;
+  box-shadow: none;
+  color: #fff;
+}
+.tox .tox-button--icon,
+.tox .tox-button.tox-button--icon,
+.tox .tox-button.tox-button--secondary.tox-button--icon {
+  padding: 4px;
+}
+.tox .tox-button--icon .tox-icon svg,
+.tox .tox-button.tox-button--icon .tox-icon svg,
+.tox .tox-button.tox-button--secondary.tox-button--icon .tox-icon svg {
+  display: block;
+  fill: currentColor;
+}
+.tox .tox-button-link {
+  background: 0;
+  border: none;
+  box-sizing: border-box;
+  cursor: pointer;
+  display: inline-block;
+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
+  font-size: 16px;
+  font-weight: normal;
+  line-height: 1.3;
+  margin: 0;
+  padding: 0;
+  white-space: nowrap;
+}
+.tox .tox-button-link--sm {
+  font-size: 14px;
+}
+.tox .tox-button--naked {
+  background-color: transparent;
+  border-color: transparent;
+  box-shadow: unset;
+  color: #fff;
+}
+.tox .tox-button--naked[disabled] {
+  background-color: #3d546f;
+  border-color: #3d546f;
+  box-shadow: none;
+  color: rgba(255, 255, 255, 0.5);
+}
+.tox .tox-button--naked:hover:not(:disabled) {
+  background-color: #34485f;
+  border-color: #34485f;
+  box-shadow: none;
+  color: #fff;
+}
+.tox .tox-button--naked:focus:not(:disabled) {
+  background-color: #34485f;
+  border-color: #34485f;
+  box-shadow: none;
+  color: #fff;
+}
+.tox .tox-button--naked:active:not(:disabled) {
+  background-color: #2b3b4e;
+  border-color: #2b3b4e;
+  box-shadow: none;
+  color: #fff;
+}
+.tox .tox-button--naked .tox-icon svg {
+  fill: currentColor;
+}
+.tox .tox-button--naked.tox-button--icon:hover:not(:disabled) {
+  color: #fff;
+}
+.tox .tox-checkbox {
+  align-items: center;
+  border-radius: 3px;
+  cursor: pointer;
+  display: flex;
+  height: 36px;
+  min-width: 36px;
+}
+.tox .tox-checkbox__input {
+  /* Hide from view but visible to screen readers */
+  height: 1px;
+  overflow: hidden;
+  position: absolute;
+  top: auto;
+  width: 1px;
+}
+.tox .tox-checkbox__icons {
+  align-items: center;
+  border-radius: 3px;
+  box-shadow: 0 0 0 2px transparent;
+  box-sizing: content-box;
+  display: flex;
+  height: 24px;
+  justify-content: center;
+  padding: calc(4px - 1px);
+  width: 24px;
+}
+.tox .tox-checkbox__icons .tox-checkbox-icon__unchecked svg {
+  display: block;
+  fill: rgba(255, 255, 255, 0.2);
+}
+.tox .tox-checkbox__icons .tox-checkbox-icon__indeterminate svg {
+  display: none;
+  fill: #207ab7;
+}
+.tox .tox-checkbox__icons .tox-checkbox-icon__checked svg {
+  display: none;
+  fill: #207ab7;
+}
+.tox .tox-checkbox--disabled {
+  color: rgba(255, 255, 255, 0.5);
+  cursor: not-allowed;
+}
+.tox .tox-checkbox--disabled .tox-checkbox__icons .tox-checkbox-icon__checked svg {
+  fill: rgba(255, 255, 255, 0.5);
+}
+.tox .tox-checkbox--disabled .tox-checkbox__icons .tox-checkbox-icon__unchecked svg {
+  fill: rgba(255, 255, 255, 0.5);
+}
+.tox .tox-checkbox--disabled .tox-checkbox__icons .tox-checkbox-icon__indeterminate svg {
+  fill: rgba(255, 255, 255, 0.5);
+}
+.tox input.tox-checkbox__input:checked + .tox-checkbox__icons .tox-checkbox-icon__unchecked svg {
+  display: none;
+}
+.tox input.tox-checkbox__input:checked + .tox-checkbox__icons .tox-checkbox-icon__checked svg {
+  display: block;
+}
+.tox input.tox-checkbox__input:indeterminate + .tox-checkbox__icons .tox-checkbox-icon__unchecked svg {
+  display: none;
+}
+.tox input.tox-checkbox__input:indeterminate + .tox-checkbox__icons .tox-checkbox-icon__indeterminate svg {
+  display: block;
+}
+.tox input.tox-checkbox__input:focus + .tox-checkbox__icons {
+  border-radius: 3px;
+  box-shadow: inset 0 0 0 1px #207ab7;
+  padding: calc(4px - 1px);
+}
+.tox:not([dir=rtl]) .tox-checkbox__label {
+  margin-left: 4px;
+}
+.tox:not([dir=rtl]) .tox-checkbox__input {
+  left: -10000px;
+}
+.tox:not([dir=rtl]) .tox-bar .tox-checkbox {
+  margin-left: 4px;
+}
+.tox[dir=rtl] .tox-checkbox__label {
+  margin-right: 4px;
+}
+.tox[dir=rtl] .tox-checkbox__input {
+  right: -10000px;
+}
+.tox[dir=rtl] .tox-bar .tox-checkbox {
+  margin-right: 4px;
+}
+.tox {
+  /* stylelint-disable-next-line no-descending-specificity */
+}
+.tox .tox-collection--toolbar .tox-collection__group {
+  display: flex;
+  padding: 0;
+}
+.tox .tox-collection--grid .tox-collection__group {
+  display: flex;
+  flex-wrap: wrap;
+  max-height: 208px;
+  overflow-x: hidden;
+  overflow-y: auto;
+  padding: 0;
+}
+.tox .tox-collection--list .tox-collection__group {
+  border-bottom-width: 0;
+  border-color: #1a1a1a;
+  border-left-width: 0;
+  border-right-width: 0;
+  border-style: solid;
+  border-top-width: 1px;
+  padding: 4px 0;
+}
+.tox .tox-collection--list .tox-collection__group:first-child {
+  border-top-width: 0;
+}
+.tox .tox-collection__group-heading {
+  background-color: #333333;
+  color: #fff;
+  cursor: default;
+  font-size: 12px;
+  font-style: normal;
+  font-weight: normal;
+  margin-bottom: 4px;
+  margin-top: -4px;
+  padding: 4px 8px;
+  text-transform: none;
+  -webkit-touch-callout: none;
+  -webkit-user-select: none;
+     -moz-user-select: none;
+      -ms-user-select: none;
+          user-select: none;
+}
+.tox .tox-collection__item {
+  align-items: center;
+  color: #fff;
+  cursor: pointer;
+  display: flex;
+  -webkit-touch-callout: none;
+  -webkit-user-select: none;
+     -moz-user-select: none;
+      -ms-user-select: none;
+          user-select: none;
+}
+.tox .tox-collection--list .tox-collection__item {
+  padding: 4px 8px;
+}
+.tox .tox-collection--toolbar .tox-collection__item {
+  border-radius: 3px;
+  padding: 4px;
+}
+.tox .tox-collection--grid .tox-collection__item {
+  border-radius: 3px;
+  padding: 4px;
+}
+.tox .tox-collection--list .tox-collection__item--enabled {
+  background-color: #2b3b4e;
+  color: #fff;
+}
+.tox .tox-collection--list .tox-collection__item--active {
+  background-color: #4a5562;
+}
+.tox .tox-collection--toolbar .tox-collection__item--enabled {
+  background-color: #757d87;
+  color: #fff;
+}
+.tox .tox-collection--toolbar .tox-collection__item--active {
+  background-color: #4a5562;
+}
+.tox .tox-collection--grid .tox-collection__item--enabled {
+  background-color: #757d87;
+  color: #fff;
+}
+.tox .tox-collection--grid .tox-collection__item--active:not(.tox-collection__item--state-disabled) {
+  background-color: #4a5562;
+  color: #fff;
+}
+.tox .tox-collection--list .tox-collection__item--active:not(.tox-collection__item--state-disabled) {
+  color: #fff;
+}
+.tox .tox-collection--toolbar .tox-collection__item--active:not(.tox-collection__item--state-disabled) {
+  color: #fff;
+}
+.tox .tox-collection__item-icon,
+.tox .tox-collection__item-checkmark {
+  align-items: center;
+  display: flex;
+  height: 24px;
+  justify-content: center;
+  width: 24px;
+}
+.tox .tox-collection__item-icon svg,
+.tox .tox-collection__item-checkmark svg {
+  fill: currentColor;
+}
+.tox .tox-collection--toolbar-lg .tox-collection__item-icon {
+  height: 48px;
+  width: 48px;
+}
+.tox .tox-collection__item-label {
+  color: currentColor;
+  display: inline-block;
+  flex: 1;
+  -ms-flex-preferred-size: auto;
+  font-size: 14px;
+  font-style: normal;
+  font-weight: normal;
+  line-height: 24px;
+  text-transform: none;
+  word-break: break-all;
+}
+.tox .tox-collection__item-accessory {
+  color: rgba(255, 255, 255, 0.5);
+  display: inline-block;
+  font-size: 14px;
+  height: 24px;
+  line-height: 24px;
+  text-transform: none;
+}
+.tox .tox-collection__item-caret {
+  align-items: center;
+  display: flex;
+  min-height: 24px;
+}
+.tox .tox-collection__item-caret::after {
+  content: '';
+  font-size: 0;
+  min-height: inherit;
+}
+.tox .tox-collection__item-caret svg {
+  fill: #fff;
+}
+.tox .tox-collection__item--state-disabled {
+  background-color: transparent;
+  color: rgba(255, 255, 255, 0.5);
+  cursor: not-allowed;
+}
+.tox .tox-collection__item--state-disabled .tox-collection__item-caret svg {
+  fill: rgba(255, 255, 255, 0.5);
+}
+.tox .tox-collection--list .tox-collection__item:not(.tox-collection__item--enabled) .tox-collection__item-checkmark svg {
+  display: none;
+}
+.tox .tox-collection--list .tox-collection__item:not(.tox-collection__item--enabled) .tox-collection__item-accessory + .tox-collection__item-checkmark {
+  display: none;
+}
+.tox .tox-collection--horizontal {
+  background-color: #2b3b4e;
+  border: 1px solid #1a1a1a;
+  border-radius: 3px;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
+  display: flex;
+  flex: 0 0 auto;
+  flex-shrink: 0;
+  flex-wrap: nowrap;
+  margin-bottom: 0;
+  overflow-x: auto;
+  padding: 0;
+}
+.tox .tox-collection--horizontal .tox-collection__group {
+  align-items: center;
+  display: flex;
+  flex-wrap: nowrap;
+  margin: 0;
+  padding: 0 4px;
+}
+.tox .tox-collection--horizontal .tox-collection__item {
+  height: 34px;
+  margin: 2px 0 3px 0;
+  padding: 0 4px;
+}
+.tox .tox-collection--horizontal .tox-collection__item-label {
+  white-space: nowrap;
+}
+.tox .tox-collection--horizontal .tox-collection__item-caret {
+  margin-left: 4px;
+}
+.tox .tox-collection__item-container {
+  display: flex;
+}
+.tox .tox-collection__item-container--row {
+  align-items: center;
+  flex: 1 1 auto;
+  flex-direction: row;
+}
+.tox .tox-collection__item-container--row.tox-collection__item-container--align-left {
+  margin-right: auto;
+}
+.tox .tox-collection__item-container--row.tox-collection__item-container--align-right {
+  justify-content: flex-end;
+  margin-left: auto;
+}
+.tox .tox-collection__item-container--row.tox-collection__item-container--valign-top {
+  align-items: flex-start;
+  margin-bottom: auto;
+}
+.tox .tox-collection__item-container--row.tox-collection__item-container--valign-middle {
+  align-items: center;
+}
+.tox .tox-collection__item-container--row.tox-collection__item-container--valign-bottom {
+  align-items: flex-end;
+  margin-top: auto;
+}
+.tox .tox-collection__item-container--column {
+  -ms-grid-row-align: center;
+      align-self: center;
+  flex: 1 1 auto;
+  flex-direction: column;
+}
+.tox .tox-collection__item-container--column.tox-collection__item-container--align-left {
+  align-items: flex-start;
+}
+.tox .tox-collection__item-container--column.tox-collection__item-container--align-right {
+  align-items: flex-end;
+}
+.tox .tox-collection__item-container--column.tox-collection__item-container--valign-top {
+  align-self: flex-start;
+}
+.tox .tox-collection__item-container--column.tox-collection__item-container--valign-middle {
+  -ms-grid-row-align: center;
+      align-self: center;
+}
+.tox .tox-collection__item-container--column.tox-collection__item-container--valign-bottom {
+  align-self: flex-end;
+}
+.tox:not([dir=rtl]) .tox-collection--horizontal .tox-collection__group:not(:last-of-type) {
+  border-right: 1px solid #000000;
+}
+.tox:not([dir=rtl]) .tox-collection--list .tox-collection__item > *:not(:first-child) {
+  margin-left: 8px;
+}
+.tox:not([dir=rtl]) .tox-collection--list .tox-collection__item > .tox-collection__item-label:first-child {
+  margin-left: 4px;
+}
+.tox:not([dir=rtl]) .tox-collection__item-accessory {
+  margin-left: 16px;
+  text-align: right;
+}
+.tox:not([dir=rtl]) .tox-collection .tox-collection__item-caret {
+  margin-left: 16px;
+}
+.tox[dir=rtl] .tox-collection--horizontal .tox-collection__group:not(:last-of-type) {
+  border-left: 1px solid #000000;
+}
+.tox[dir=rtl] .tox-collection--list .tox-collection__item > *:not(:first-child) {
+  margin-right: 8px;
+}
+.tox[dir=rtl] .tox-collection--list .tox-collection__item > .tox-collection__item-label:first-child {
+  margin-right: 4px;
+}
+.tox[dir=rtl] .tox-collection__item-accessory {
+  margin-right: 16px;
+  text-align: left;
+}
+.tox[dir=rtl] .tox-collection .tox-collection__item-caret {
+  margin-right: 16px;
+  transform: rotateY(180deg);
+}
+.tox[dir=rtl] .tox-collection--horizontal .tox-collection__item-caret {
+  margin-right: 4px;
+}
+.tox .tox-color-picker-container {
+  display: flex;
+  flex-direction: row;
+  height: 225px;
+  margin: 0;
+}
+.tox .tox-sv-palette {
+  box-sizing: border-box;
+  display: flex;
+  height: 100%;
+}
+.tox .tox-sv-palette-spectrum {
+  height: 100%;
+}
+.tox .tox-sv-palette,
+.tox .tox-sv-palette-spectrum {
+  width: 225px;
+}
+.tox .tox-sv-palette-thumb {
+  background: none;
+  border: 1px solid black;
+  border-radius: 50%;
+  box-sizing: content-box;
+  height: 12px;
+  position: absolute;
+  width: 12px;
+}
+.tox .tox-sv-palette-inner-thumb {
+  border: 1px solid white;
+  border-radius: 50%;
+  height: 10px;
+  position: absolute;
+  width: 10px;
+}
+.tox .tox-hue-slider {
+  box-sizing: border-box;
+  height: 100%;
+  width: 25px;
+}
+.tox .tox-hue-slider-spectrum {
+  background: linear-gradient(to bottom, #f00, #ff0080, #f0f, #8000ff, #00f, #0080ff, #0ff, #00ff80, #0f0, #80ff00, #ff0, #ff8000, #f00);
+  height: 100%;
+  width: 100%;
+}
+.tox .tox-hue-slider,
+.tox .tox-hue-slider-spectrum {
+  width: 20px;
+}
+.tox .tox-hue-slider-thumb {
+  background: white;
+  border: 1px solid black;
+  box-sizing: content-box;
+  height: 4px;
+  width: 100%;
+}
+.tox .tox-rgb-form {
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+}
+.tox .tox-rgb-form div {
+  align-items: center;
+  display: flex;
+  justify-content: space-between;
+  margin-bottom: 5px;
+  width: inherit;
+}
+.tox .tox-rgb-form input {
+  width: 6em;
+}
+.tox .tox-rgb-form input.tox-invalid {
+  /* Need !important to override Chrome's focus styling unfortunately */
+  border: 1px solid red !important;
+}
+.tox .tox-rgb-form .tox-rgba-preview {
+  border: 1px solid black;
+  flex-grow: 2;
+  margin-bottom: 0;
+}
+.tox:not([dir=rtl]) .tox-sv-palette {
+  margin-right: 15px;
+}
+.tox:not([dir=rtl]) .tox-hue-slider {
+  margin-right: 15px;
+}
+.tox:not([dir=rtl]) .tox-hue-slider-thumb {
+  margin-left: -1px;
+}
+.tox:not([dir=rtl]) .tox-rgb-form label {
+  margin-right: 0.5em;
+}
+.tox[dir=rtl] .tox-sv-palette {
+  margin-left: 15px;
+}
+.tox[dir=rtl] .tox-hue-slider {
+  margin-left: 15px;
+}
+.tox[dir=rtl] .tox-hue-slider-thumb {
+  margin-right: -1px;
+}
+.tox[dir=rtl] .tox-rgb-form label {
+  margin-left: 0.5em;
+}
+.tox .tox-toolbar .tox-swatches,
+.tox .tox-toolbar__primary .tox-swatches,
+.tox .tox-toolbar__overflow .tox-swatches {
+  margin: 2px 0 3px 4px;
+}
+.tox .tox-collection--list .tox-collection__group .tox-swatches-menu {
+  border: 0;
+  margin: -4px 0;
+}
+.tox .tox-swatches__row {
+  display: flex;
+}
+.tox .tox-swatch {
+  height: 30px;
+  transition: transform 0.15s, box-shadow 0.15s;
+  width: 30px;
+}
+.tox .tox-swatch:hover,
+.tox .tox-swatch:focus {
+  box-shadow: 0 0 0 1px rgba(127, 127, 127, 0.3) inset;
+  transform: scale(0.8);
+}
+.tox .tox-swatch--remove {
+  align-items: center;
+  display: flex;
+  justify-content: center;
+}
+.tox .tox-swatch--remove svg path {
+  stroke: #e74c3c;
+}
+.tox .tox-swatches__picker-btn {
+  align-items: center;
+  background-color: transparent;
+  border: 0;
+  cursor: pointer;
+  display: flex;
+  height: 30px;
+  justify-content: center;
+  outline: none;
+  padding: 0;
+  width: 30px;
+}
+.tox .tox-swatches__picker-btn svg {
+  height: 24px;
+  width: 24px;
+}
+.tox .tox-swatches__picker-btn:hover {
+  background: #4a5562;
+}
+.tox:not([dir=rtl]) .tox-swatches__picker-btn {
+  margin-left: auto;
+}
+.tox[dir=rtl] .tox-swatches__picker-btn {
+  margin-right: auto;
+}
+.tox .tox-comment-thread {
+  background: #2b3b4e;
+  position: relative;
+}
+.tox .tox-comment-thread > *:not(:first-child) {
+  margin-top: 8px;
+}
+.tox .tox-comment {
+  background: #2b3b4e;
+  border: 1px solid #000000;
+  border-radius: 3px;
+  box-shadow: 0 4px 8px 0 rgba(42, 55, 70, 0.1);
+  padding: 8px 8px 16px 8px;
+  position: relative;
+}
+.tox .tox-comment__header {
+  align-items: center;
+  color: #fff;
+  display: flex;
+  justify-content: space-between;
+}
+.tox .tox-comment__date {
+  color: rgba(255, 255, 255, 0.5);
+  font-size: 12px;
+}
+.tox .tox-comment__body {
+  color: #fff;
+  font-size: 14px;
+  font-style: normal;
+  font-weight: normal;
+  line-height: 1.3;
+  margin-top: 8px;
+  position: relative;
+  text-transform: initial;
+}
+.tox .tox-comment__body textarea {
+  resize: none;
+  white-space: normal;
+  width: 100%;
+}
+.tox .tox-comment__expander {
+  padding-top: 8px;
+}
+.tox .tox-comment__expander p {
+  color: rgba(255, 255, 255, 0.5);
+  font-size: 14px;
+  font-style: normal;
+}
+.tox .tox-comment__body p {
+  margin: 0;
+}
+.tox .tox-comment__buttonspacing {
+  padding-top: 16px;
+  text-align: center;
+}
+.tox .tox-comment-thread__overlay::after {
+  background: #2b3b4e;
+  bottom: 0;
+  content: "";
+  display: flex;
+  left: 0;
+  opacity: 0.9;
+  position: absolute;
+  right: 0;
+  top: 0;
+  z-index: 5;
+}
+.tox .tox-comment__reply {
+  display: flex;
+  flex-shrink: 0;
+  flex-wrap: wrap;
+  justify-content: flex-end;
+  margin-top: 8px;
+}
+.tox .tox-comment__reply > *:first-child {
+  margin-bottom: 8px;
+  width: 100%;
+}
+.tox .tox-comment__edit {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: flex-end;
+  margin-top: 16px;
+}
+.tox .tox-comment__gradient::after {
+  background: linear-gradient(rgba(43, 59, 78, 0), #2b3b4e);
+  bottom: 0;
+  content: "";
+  display: block;
+  height: 5em;
+  margin-top: -40px;
+  position: absolute;
+  width: 100%;
+}
+.tox .tox-comment__overlay {
+  background: #2b3b4e;
+  bottom: 0;
+  display: flex;
+  flex-direction: column;
+  flex-grow: 1;
+  left: 0;
+  opacity: 0.9;
+  position: absolute;
+  right: 0;
+  text-align: center;
+  top: 0;
+  z-index: 5;
+}
+.tox .tox-comment__loading-text {
+  align-items: center;
+  color: #fff;
+  display: flex;
+  flex-direction: column;
+  position: relative;
+}
+.tox .tox-comment__loading-text > div {
+  padding-bottom: 16px;
+}
+.tox .tox-comment__overlaytext {
+  bottom: 0;
+  flex-direction: column;
+  font-size: 14px;
+  left: 0;
+  padding: 1em;
+  position: absolute;
+  right: 0;
+  top: 0;
+  z-index: 10;
+}
+.tox .tox-comment__overlaytext p {
+  background-color: #2b3b4e;
+  box-shadow: 0 0 8px 8px #2b3b4e;
+  color: #fff;
+  text-align: center;
+}
+.tox .tox-comment__overlaytext div:nth-of-type(2) {
+  font-size: 0.8em;
+}
+.tox .tox-comment__busy-spinner {
+  align-items: center;
+  background-color: #2b3b4e;
+  bottom: 0;
+  display: flex;
+  justify-content: center;
+  left: 0;
+  position: absolute;
+  right: 0;
+  top: 0;
+  z-index: 20;
+}
+.tox .tox-comment__scroll {
+  display: flex;
+  flex-direction: column;
+  flex-shrink: 1;
+  overflow: auto;
+}
+.tox .tox-conversations {
+  margin: 8px;
+}
+.tox:not([dir=rtl]) .tox-comment__edit {
+  margin-left: 8px;
+}
+.tox:not([dir=rtl]) .tox-comment__buttonspacing > *:last-child,
+.tox:not([dir=rtl]) .tox-comment__edit > *:last-child,
+.tox:not([dir=rtl]) .tox-comment__reply > *:last-child {
+  margin-left: 8px;
+}
+.tox[dir=rtl] .tox-comment__edit {
+  margin-right: 8px;
+}
+.tox[dir=rtl] .tox-comment__buttonspacing > *:last-child,
+.tox[dir=rtl] .tox-comment__edit > *:last-child,
+.tox[dir=rtl] .tox-comment__reply > *:last-child {
+  margin-right: 8px;
+}
+.tox .tox-user {
+  align-items: center;
+  display: flex;
+}
+.tox .tox-user__avatar svg {
+  fill: rgba(255, 255, 255, 0.5);
+}
+.tox .tox-user__name {
+  color: rgba(255, 255, 255, 0.5);
+  font-size: 12px;
+  font-style: normal;
+  font-weight: bold;
+  text-transform: uppercase;
+}
+.tox:not([dir=rtl]) .tox-user__avatar svg {
+  margin-right: 8px;
+}
+.tox:not([dir=rtl]) .tox-user__avatar + .tox-user__name {
+  margin-left: 8px;
+}
+.tox[dir=rtl] .tox-user__avatar svg {
+  margin-left: 8px;
+}
+.tox[dir=rtl] .tox-user__avatar + .tox-user__name {
+  margin-right: 8px;
+}
+.tox .tox-dialog-wrap {
+  align-items: center;
+  bottom: 0;
+  display: flex;
+  justify-content: center;
+  left: 0;
+  position: fixed;
+  right: 0;
+  top: 0;
+  z-index: 1100;
+}
+.tox .tox-dialog-wrap__backdrop {
+  background-color: rgba(34, 47, 62, 0.75);
+  bottom: 0;
+  left: 0;
+  position: absolute;
+  right: 0;
+  top: 0;
+  z-index: 1;
+}
+.tox .tox-dialog-wrap__backdrop--opaque {
+  background-color: #222f3e;
+}
+.tox .tox-dialog {
+  background-color: #2b3b4e;
+  border-color: #000000;
+  border-radius: 3px;
+  border-style: solid;
+  border-width: 1px;
+  box-shadow: 0 16px 16px -10px rgba(42, 55, 70, 0.15), 0 0 40px 1px rgba(42, 55, 70, 0.15);
+  display: flex;
+  flex-direction: column;
+  max-height: 100%;
+  max-width: 480px;
+  overflow: hidden;
+  position: relative;
+  width: 95vw;
+  z-index: 2;
+}
+@media only screen and (max-width:767px) {
+  body:not(.tox-force-desktop) .tox .tox-dialog {
+    align-self: flex-start;
+    margin: 8px auto;
+    width: calc(100vw - 16px);
+  }
+}
+.tox .tox-dialog-inline {
+  z-index: 1100;
+}
+.tox .tox-dialog__header {
+  align-items: center;
+  background-color: #2b3b4e;
+  border-bottom: none;
+  color: #fff;
+  display: flex;
+  font-size: 16px;
+  justify-content: space-between;
+  padding: 8px 16px 0 16px;
+  position: relative;
+}
+.tox .tox-dialog__header .tox-button {
+  z-index: 1;
+}
+.tox .tox-dialog__draghandle {
+  cursor: grab;
+  height: 100%;
+  left: 0;
+  position: absolute;
+  top: 0;
+  width: 100%;
+}
+.tox .tox-dialog__draghandle:active {
+  cursor: grabbing;
+}
+.tox .tox-dialog__dismiss {
+  margin-left: auto;
+}
+.tox .tox-dialog__title {
+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
+  font-size: 20px;
+  font-style: normal;
+  font-weight: normal;
+  line-height: 1.3;
+  margin: 0;
+  text-transform: none;
+}
+.tox .tox-dialog__body {
+  color: #fff;
+  display: flex;
+  flex: 1;
+  -ms-flex-preferred-size: auto;
+  font-size: 16px;
+  font-style: normal;
+  font-weight: normal;
+  line-height: 1.3;
+  min-width: 0;
+  text-align: left;
+  text-transform: none;
+}
+@media only screen and (max-width:767px) {
+  body:not(.tox-force-desktop) .tox .tox-dialog__body {
+    flex-direction: column;
+  }
+}
+.tox .tox-dialog__body-nav {
+  align-items: flex-start;
+  display: flex;
+  flex-direction: column;
+  padding: 16px 16px;
+}
+@media only screen and (max-width:767px) {
+  body:not(.tox-force-desktop) .tox .tox-dialog__body-nav {
+    flex-direction: row;
+    -webkit-overflow-scrolling: touch;
+    overflow-x: auto;
+    padding-bottom: 0;
+  }
+}
+.tox .tox-dialog__body-nav-item {
+  border-bottom: 2px solid transparent;
+  color: rgba(255, 255, 255, 0.5);
+  display: inline-block;
+  font-size: 14px;
+  line-height: 1.3;
+  margin-bottom: 8px;
+  text-decoration: none;
+  white-space: nowrap;
+}
+.tox .tox-dialog__body-nav-item:focus {
+  background-color: rgba(32, 122, 183, 0.1);
+}
+.tox .tox-dialog__body-nav-item--active {
+  border-bottom: 2px solid #207ab7;
+  color: #207ab7;
+}
+.tox .tox-dialog__body-content {
+  box-sizing: border-box;
+  display: flex;
+  flex: 1;
+  flex-direction: column;
+  -ms-flex-preferred-size: auto;
+  max-height: 650px;
+  overflow: auto;
+  -webkit-overflow-scrolling: touch;
+  padding: 16px 16px;
+}
+.tox .tox-dialog__body-content > * {
+  margin-bottom: 0;
+  margin-top: 16px;
+}
+.tox .tox-dialog__body-content > *:first-child {
+  margin-top: 0;
+}
+.tox .tox-dialog__body-content > *:last-child {
+  margin-bottom: 0;
+}
+.tox .tox-dialog__body-content > *:only-child {
+  margin-bottom: 0;
+  margin-top: 0;
+}
+.tox .tox-dialog__body-content a {
+  color: #207ab7;
+  cursor: pointer;
+  text-decoration: none;
+}
+.tox .tox-dialog__body-content a:hover,
+.tox .tox-dialog__body-content a:focus {
+  color: #185d8c;
+  text-decoration: none;
+}
+.tox .tox-dialog__body-content a:active {
+  color: #185d8c;
+  text-decoration: none;
+}
+.tox .tox-dialog__body-content svg {
+  fill: #fff;
+}
+.tox .tox-dialog__body-content ul {
+  display: block;
+  list-style-type: disc;
+  margin-bottom: 16px;
+  -webkit-margin-end: 0;
+          margin-inline-end: 0;
+  -webkit-margin-start: 0;
+          margin-inline-start: 0;
+  -webkit-padding-start: 2.5rem;
+          padding-inline-start: 2.5rem;
+}
+.tox .tox-dialog__body-content .tox-form__group h1 {
+  color: #fff;
+  font-size: 20px;
+  font-style: normal;
+  font-weight: bold;
+  letter-spacing: normal;
+  margin-bottom: 16px;
+  margin-top: 2rem;
+  text-transform: none;
+}
+.tox .tox-dialog__body-content .tox-form__group h2 {
+  color: #fff;
+  font-size: 16px;
+  font-style: normal;
+  font-weight: bold;
+  letter-spacing: normal;
+  margin-bottom: 16px;
+  margin-top: 2rem;
+  text-transform: none;
+}
+.tox .tox-dialog__body-content .tox-form__group p {
+  margin-bottom: 16px;
+}
+.tox .tox-dialog__body-content .tox-form__group h1:first-child,
+.tox .tox-dialog__body-content .tox-form__group h2:first-child,
+.tox .tox-dialog__body-content .tox-form__group p:first-child {
+  margin-top: 0;
+}
+.tox .tox-dialog__body-content .tox-form__group h1:last-child,
+.tox .tox-dialog__body-content .tox-form__group h2:last-child,
+.tox .tox-dialog__body-content .tox-form__group p:last-child {
+  margin-bottom: 0;
+}
+.tox .tox-dialog__body-content .tox-form__group h1:only-child,
+.tox .tox-dialog__body-content .tox-form__group h2:only-child,
+.tox .tox-dialog__body-content .tox-form__group p:only-child {
+  margin-bottom: 0;
+  margin-top: 0;
+}
+.tox .tox-dialog--width-lg {
+  height: 650px;
+  max-width: 1200px;
+}
+.tox .tox-dialog--width-md {
+  max-width: 800px;
+}
+.tox .tox-dialog--width-md .tox-dialog__body-content {
+  overflow: auto;
+}
+.tox .tox-dialog__body-content--centered {
+  text-align: center;
+}
+.tox .tox-dialog__footer {
+  align-items: center;
+  background-color: #2b3b4e;
+  border-top: 1px solid #000000;
+  display: flex;
+  justify-content: space-between;
+  padding: 8px 16px;
+}
+.tox .tox-dialog__footer-start,
+.tox .tox-dialog__footer-end {
+  display: flex;
+}
+.tox .tox-dialog__busy-spinner {
+  align-items: center;
+  background-color: rgba(34, 47, 62, 0.75);
+  bottom: 0;
+  display: flex;
+  justify-content: center;
+  left: 0;
+  position: absolute;
+  right: 0;
+  top: 0;
+  z-index: 3;
+}
+.tox .tox-dialog__table {
+  border-collapse: collapse;
+  width: 100%;
+}
+.tox .tox-dialog__table thead th {
+  font-weight: bold;
+  padding-bottom: 8px;
+}
+.tox .tox-dialog__table tbody tr {
+  border-bottom: 1px solid #000000;
+}
+.tox .tox-dialog__table tbody tr:last-child {
+  border-bottom: none;
+}
+.tox .tox-dialog__table td {
+  padding-bottom: 8px;
+  padding-top: 8px;
+}
+.tox .tox-dialog__popups {
+  position: absolute;
+  width: 100%;
+  z-index: 1100;
+}
+.tox .tox-dialog__body-iframe {
+  display: flex;
+  flex: 1;
+  flex-direction: column;
+  -ms-flex-preferred-size: auto;
+}
+.tox .tox-dialog__body-iframe .tox-navobj {
+  display: flex;
+  flex: 1;
+  -ms-flex-preferred-size: auto;
+}
+.tox .tox-dialog__body-iframe .tox-navobj :nth-child(2) {
+  flex: 1;
+  -ms-flex-preferred-size: auto;
+  height: 100%;
+}
+.tox .tox-dialog-dock-fadeout {
+  opacity: 0;
+  visibility: hidden;
+}
+.tox .tox-dialog-dock-fadein {
+  opacity: 1;
+  visibility: visible;
+}
+.tox .tox-dialog-dock-transition {
+  transition: visibility 0s linear 0.3s, opacity 0.3s ease;
+}
+.tox .tox-dialog-dock-transition.tox-dialog-dock-fadein {
+  transition-delay: 0s;
+}
+.tox.tox-platform-ie {
+  /* IE11 CSS styles go here */
+}
+.tox.tox-platform-ie .tox-dialog-wrap {
+  position: -ms-device-fixed;
+}
+@media only screen and (max-width:767px) {
+  body:not(.tox-force-desktop) .tox:not([dir=rtl]) .tox-dialog__body-nav {
+    margin-right: 0;
+  }
+}
+@media only screen and (max-width:767px) {
+  body:not(.tox-force-desktop) .tox:not([dir=rtl]) .tox-dialog__body-nav-item:not(:first-child) {
+    margin-left: 8px;
+  }
+}
+.tox:not([dir=rtl]) .tox-dialog__footer .tox-dialog__footer-start > *,
+.tox:not([dir=rtl]) .tox-dialog__footer .tox-dialog__footer-end > * {
+  margin-left: 8px;
+}
+.tox[dir=rtl] .tox-dialog__body {
+  text-align: right;
+}
+@media only screen and (max-width:767px) {
+  body:not(.tox-force-desktop) .tox[dir=rtl] .tox-dialog__body-nav {
+    margin-left: 0;
+  }
+}
+@media only screen and (max-width:767px) {
+  body:not(.tox-force-desktop) .tox[dir=rtl] .tox-dialog__body-nav-item:not(:first-child) {
+    margin-right: 8px;
+  }
+}
+.tox[dir=rtl] .tox-dialog__footer .tox-dialog__footer-start > *,
+.tox[dir=rtl] .tox-dialog__footer .tox-dialog__footer-end > * {
+  margin-right: 8px;
+}
+body.tox-dialog__disable-scroll {
+  overflow: hidden;
+}
+.tox .tox-dropzone-container {
+  display: flex;
+  flex: 1;
+  -ms-flex-preferred-size: auto;
+}
+.tox .tox-dropzone {
+  align-items: center;
+  background: #fff;
+  border: 2px dashed #000000;
+  box-sizing: border-box;
+  display: flex;
+  flex-direction: column;
+  flex-grow: 1;
+  justify-content: center;
+  min-height: 100px;
+  padding: 10px;
+}
+.tox .tox-dropzone p {
+  color: rgba(255, 255, 255, 0.5);
+  margin: 0 0 16px 0;
+}
+.tox .tox-edit-area {
+  display: flex;
+  flex: 1;
+  -ms-flex-preferred-size: auto;
+  overflow: hidden;
+  position: relative;
+}
+.tox .tox-edit-area__iframe {
+  background-color: #fff;
+  border: 0;
+  box-sizing: border-box;
+  flex: 1;
+  -ms-flex-preferred-size: auto;
+  height: 100%;
+  position: absolute;
+  width: 100%;
+}
+.tox.tox-inline-edit-area {
+  border: 1px dotted #000000;
+}
+.tox .tox-editor-container {
+  display: flex;
+  flex: 1 1 auto;
+  flex-direction: column;
+  overflow: hidden;
+}
+.tox .tox-editor-header {
+  z-index: 1;
+}
+.tox:not(.tox-tinymce-inline) .tox-editor-header {
+  box-shadow: none;
+  transition: box-shadow 0.5s;
+}
+.tox.tox-tinymce--toolbar-bottom .tox-editor-header,
+.tox.tox-tinymce-inline .tox-editor-header {
+  margin-bottom: -1px;
+}
+.tox.tox-tinymce--toolbar-sticky-on .tox-editor-header {
+  background-color: transparent;
+  box-shadow: 0 4px 4px -3px rgba(0, 0, 0, 0.25);
+}
+.tox-editor-dock-fadeout {
+  opacity: 0;
+  visibility: hidden;
+}
+.tox-editor-dock-fadein {
+  opacity: 1;
+  visibility: visible;
+}
+.tox-editor-dock-transition {
+  transition: visibility 0s linear 0.25s, opacity 0.25s ease;
+}
+.tox-editor-dock-transition.tox-editor-dock-fadein {
+  transition-delay: 0s;
+}
+.tox .tox-control-wrap {
+  flex: 1;
+  position: relative;
+}
+.tox .tox-control-wrap:not(.tox-control-wrap--status-invalid) .tox-control-wrap__status-icon-invalid,
+.tox .tox-control-wrap:not(.tox-control-wrap--status-unknown) .tox-control-wrap__status-icon-unknown,
+.tox .tox-control-wrap:not(.tox-control-wrap--status-valid) .tox-control-wrap__status-icon-valid {
+  display: none;
+}
+.tox .tox-control-wrap svg {
+  display: block;
+}
+.tox .tox-control-wrap__status-icon-wrap {
+  position: absolute;
+  top: 50%;
+  transform: translateY(-50%);
+}
+.tox .tox-control-wrap__status-icon-invalid svg {
+  fill: #c00;
+}
+.tox .tox-control-wrap__status-icon-unknown svg {
+  fill: orange;
+}
+.tox .tox-control-wrap__status-icon-valid svg {
+  fill: green;
+}
+.tox:not([dir=rtl]) .tox-control-wrap--status-invalid .tox-textfield,
+.tox:not([dir=rtl]) .tox-control-wrap--status-unknown .tox-textfield,
+.tox:not([dir=rtl]) .tox-control-wrap--status-valid .tox-textfield {
+  padding-right: 32px;
+}
+.tox:not([dir=rtl]) .tox-control-wrap__status-icon-wrap {
+  right: 4px;
+}
+.tox[dir=rtl] .tox-control-wrap--status-invalid .tox-textfield,
+.tox[dir=rtl] .tox-control-wrap--status-unknown .tox-textfield,
+.tox[dir=rtl] .tox-control-wrap--status-valid .tox-textfield {
+  padding-left: 32px;
+}
+.tox[dir=rtl] .tox-control-wrap__status-icon-wrap {
+  left: 4px;
+}
+.tox .tox-autocompleter {
+  max-width: 25em;
+}
+.tox .tox-autocompleter .tox-menu {
+  max-width: 25em;
+}
+.tox .tox-autocompleter .tox-autocompleter-highlight {
+  font-weight: bold;
+}
+.tox .tox-color-input {
+  display: flex;
+  position: relative;
+  z-index: 1;
+}
+.tox .tox-color-input .tox-textfield {
+  z-index: -1;
+}
+.tox .tox-color-input span {
+  border-color: rgba(42, 55, 70, 0.2);
+  border-radius: 3px;
+  border-style: solid;
+  border-width: 1px;
+  box-shadow: none;
+  box-sizing: border-box;
+  height: 24px;
+  position: absolute;
+  top: 6px;
+  width: 24px;
+}
+.tox .tox-color-input span:hover:not([aria-disabled=true]),
+.tox .tox-color-input span:focus:not([aria-disabled=true]) {
+  border-color: #207ab7;
+  cursor: pointer;
+}
+.tox .tox-color-input span::before {
+  background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.25) 25%, transparent 25%), linear-gradient(-45deg, rgba(255, 255, 255, 0.25) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, rgba(255, 255, 255, 0.25) 75%), linear-gradient(-45deg, transparent 75%, rgba(255, 255, 255, 0.25) 75%);
+  background-position: 0 0, 0 6px, 6px -6px, -6px 0;
+  background-size: 12px 12px;
+  border: 1px solid #2b3b4e;
+  border-radius: 3px;
+  box-sizing: border-box;
+  content: '';
+  height: 24px;
+  left: -1px;
+  position: absolute;
+  top: -1px;
+  width: 24px;
+  z-index: -1;
+}
+.tox .tox-color-input span[aria-disabled=true] {
+  cursor: not-allowed;
+}
+.tox:not([dir=rtl]) .tox-color-input {
+  /* stylelint-disable-next-line no-descending-specificity */
+}
+.tox:not([dir=rtl]) .tox-color-input .tox-textfield {
+  padding-left: 36px;
+}
+.tox:not([dir=rtl]) .tox-color-input span {
+  left: 6px;
+}
+.tox[dir="rtl"] .tox-color-input {
+  /* stylelint-disable-next-line no-descending-specificity */
+}
+.tox[dir="rtl"] .tox-color-input .tox-textfield {
+  padding-right: 36px;
+}
+.tox[dir="rtl"] .tox-color-input span {
+  right: 6px;
+}
+.tox .tox-label,
+.tox .tox-toolbar-label {
+  color: rgba(255, 255, 255, 0.5);
+  display: block;
+  font-size: 14px;
+  font-style: normal;
+  font-weight: normal;
+  line-height: 1.3;
+  padding: 0 8px 0 0;
+  text-transform: none;
+  white-space: nowrap;
+}
+.tox .tox-toolbar-label {
+  padding: 0 8px;
+}
+.tox[dir=rtl] .tox-label {
+  padding: 0 0 0 8px;
+}
+.tox .tox-form {
+  display: flex;
+  flex: 1;
+  flex-direction: column;
+  -ms-flex-preferred-size: auto;
+}
+.tox .tox-form__group {
+  box-sizing: border-box;
+  margin-bottom: 4px;
+}
+.tox .tox-form-group--maximize {
+  flex: 1;
+}
+.tox .tox-form__group--error {
+  color: #c00;
+}
+.tox .tox-form__group--collection {
+  display: flex;
+}
+.tox .tox-form__grid {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  justify-content: space-between;
+}
+.tox .tox-form__grid--2col > .tox-form__group {
+  width: calc(50% - (8px / 2));
+}
+.tox .tox-form__grid--3col > .tox-form__group {
+  width: calc(100% / 3 - (8px / 2));
+}
+.tox .tox-form__grid--4col > .tox-form__group {
+  width: calc(25% - (8px / 2));
+}
+.tox .tox-form__controls-h-stack {
+  align-items: center;
+  display: flex;
+}
+.tox .tox-form__group--inline {
+  align-items: center;
+  display: flex;
+}
+.tox .tox-form__group--stretched {
+  display: flex;
+  flex: 1;
+  flex-direction: column;
+  -ms-flex-preferred-size: auto;
+}
+.tox .tox-form__group--stretched .tox-textarea {
+  flex: 1;
+  -ms-flex-preferred-size: auto;
+}
+.tox .tox-form__group--stretched .tox-navobj {
+  display: flex;
+  flex: 1;
+  -ms-flex-preferred-size: auto;
+}
+.tox .tox-form__group--stretched .tox-navobj :nth-child(2) {
+  flex: 1;
+  -ms-flex-preferred-size: auto;
+  height: 100%;
+}
+.tox:not([dir=rtl]) .tox-form__controls-h-stack > *:not(:first-child) {
+  margin-left: 4px;
+}
+.tox[dir=rtl] .tox-form__controls-h-stack > *:not(:first-child) {
+  margin-right: 4px;
+}
+.tox .tox-lock.tox-locked .tox-lock-icon__unlock,
+.tox .tox-lock:not(.tox-locked) .tox-lock-icon__lock {
+  display: none;
+}
+.tox .tox-textfield,
+.tox .tox-toolbar-textfield,
+.tox .tox-listboxfield .tox-listbox--select,
+.tox .tox-textarea {
+  -webkit-appearance: none;
+     -moz-appearance: none;
+          appearance: none;
+  background-color: #2b3b4e;
+  border-color: #000000;
+  border-radius: 3px;
+  border-style: solid;
+  border-width: 1px;
+  box-shadow: none;
+  box-sizing: border-box;
+  color: #fff;
+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
+  font-size: 16px;
+  line-height: 24px;
+  margin: 0;
+  min-height: 34px;
+  outline: none;
+  padding: 5px 4.75px;
+  resize: none;
+  width: 100%;
+}
+.tox .tox-textfield[disabled],
+.tox .tox-textarea[disabled] {
+  background-color: #222f3e;
+  color: rgba(255, 255, 255, 0.85);
+  cursor: not-allowed;
+}
+.tox .tox-textfield:focus,
+.tox .tox-listboxfield .tox-listbox--select:focus,
+.tox .tox-textarea:focus {
+  background-color: #2b3b4e;
+  border-color: #207ab7;
+  box-shadow: none;
+  outline: none;
+}
+.tox .tox-toolbar-textfield {
+  border-width: 0;
+  margin-bottom: 3px;
+  margin-top: 2px;
+  max-width: 250px;
+}
+.tox .tox-naked-btn {
+  background-color: transparent;
+  border: 0;
+  border-color: transparent;
+  box-shadow: unset;
+  color: #207ab7;
+  cursor: pointer;
+  display: block;
+  margin: 0;
+  padding: 0;
+}
+.tox .tox-naked-btn svg {
+  display: block;
+  fill: #fff;
+}
+.tox:not([dir=rtl]) .tox-toolbar-textfield + * {
+  margin-left: 4px;
+}
+.tox[dir=rtl] .tox-toolbar-textfield + * {
+  margin-right: 4px;
+}
+.tox .tox-listboxfield {
+  cursor: pointer;
+  position: relative;
+}
+.tox .tox-listboxfield .tox-listbox--select[disabled] {
+  background-color: #19232e;
+  color: rgba(255, 255, 255, 0.85);
+  cursor: not-allowed;
+}
+.tox .tox-listbox__select-label {
+  cursor: default;
+  flex: 1;
+  margin: 0 4px;
+}
+.tox .tox-listbox__select-chevron {
+  align-items: center;
+  display: flex;
+  justify-content: center;
+  width: 16px;
+}
+.tox .tox-listbox__select-chevron svg {
+  fill: #fff;
+}
+.tox .tox-listboxfield .tox-listbox--select {
+  align-items: center;
+  display: flex;
+}
+.tox:not([dir=rtl]) .tox-listboxfield svg {
+  right: 8px;
+}
+.tox[dir=rtl] .tox-listboxfield svg {
+  left: 8px;
+}
+.tox .tox-selectfield {
+  cursor: pointer;
+  position: relative;
+}
+.tox .tox-selectfield select {
+  -webkit-appearance: none;
+     -moz-appearance: none;
+          appearance: none;
+  background-color: #2b3b4e;
+  border-color: #000000;
+  border-radius: 3px;
+  border-style: solid;
+  border-width: 1px;
+  box-shadow: none;
+  box-sizing: border-box;
+  color: #fff;
+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
+  font-size: 16px;
+  line-height: 24px;
+  margin: 0;
+  min-height: 34px;
+  outline: none;
+  padding: 5px 4.75px;
+  resize: none;
+  width: 100%;
+}
+.tox .tox-selectfield select[disabled] {
+  background-color: #19232e;
+  color: rgba(255, 255, 255, 0.85);
+  cursor: not-allowed;
+}
+.tox .tox-selectfield select::-ms-expand {
+  display: none;
+}
+.tox .tox-selectfield select:focus {
+  background-color: #2b3b4e;
+  border-color: #207ab7;
+  box-shadow: none;
+  outline: none;
+}
+.tox .tox-selectfield svg {
+  pointer-events: none;
+  position: absolute;
+  top: 50%;
+  transform: translateY(-50%);
+}
+.tox:not([dir=rtl]) .tox-selectfield select[size="0"],
+.tox:not([dir=rtl]) .tox-selectfield select[size="1"] {
+  padding-right: 24px;
+}
+.tox:not([dir=rtl]) .tox-selectfield svg {
+  right: 8px;
+}
+.tox[dir=rtl] .tox-selectfield select[size="0"],
+.tox[dir=rtl] .tox-selectfield select[size="1"] {
+  padding-left: 24px;
+}
+.tox[dir=rtl] .tox-selectfield svg {
+  left: 8px;
+}
+.tox .tox-textarea {
+  -webkit-appearance: textarea;
+     -moz-appearance: textarea;
+          appearance: textarea;
+  white-space: pre-wrap;
+}
+.tox-fullscreen {
+  border: 0;
+  height: 100%;
+  margin: 0;
+  overflow: hidden;
+  -ms-scroll-chaining: none;
+      overscroll-behavior: none;
+  padding: 0;
+  touch-action: pinch-zoom;
+  width: 100%;
+}
+.tox.tox-tinymce.tox-fullscreen .tox-statusbar__resize-handle {
+  display: none;
+}
+.tox.tox-tinymce.tox-fullscreen,
+.tox-shadowhost.tox-fullscreen {
+  left: 0;
+  position: fixed;
+  top: 0;
+  z-index: 1200;
+}
+.tox.tox-tinymce.tox-fullscreen {
+  background-color: transparent;
+}
+.tox-fullscreen .tox.tox-tinymce-aux,
+.tox-fullscreen ~ .tox.tox-tinymce-aux {
+  z-index: 1201;
+}
+.tox .tox-help__more-link {
+  list-style: none;
+  margin-top: 1em;
+}
+.tox .tox-image-tools {
+  width: 100%;
+}
+.tox .tox-image-tools__toolbar {
+  align-items: center;
+  display: flex;
+  justify-content: center;
+}
+.tox .tox-image-tools__image {
+  background-color: #666;
+  height: 380px;
+  overflow: auto;
+  position: relative;
+  width: 100%;
+}
+.tox .tox-image-tools__image,
+.tox .tox-image-tools__image + .tox-image-tools__toolbar {
+  margin-top: 8px;
+}
+.tox .tox-image-tools__image-bg {
+  background: url(data:image/gif;base64,R0lGODdhDAAMAIABAMzMzP///ywAAAAADAAMAAACFoQfqYeabNyDMkBQb81Uat85nxguUAEAOw==);
+}
+.tox .tox-image-tools__toolbar > .tox-spacer {
+  flex: 1;
+  -ms-flex-preferred-size: auto;
+}
+.tox .tox-croprect-block {
+  background: black;
+  filter: alpha(opacity=50);
+  opacity: 0.5;
+  position: absolute;
+  zoom: 1;
+}
+.tox .tox-croprect-handle {
+  border: 2px solid white;
+  height: 20px;
+  left: 0;
+  position: absolute;
+  top: 0;
+  width: 20px;
+}
+.tox .tox-croprect-handle-move {
+  border: 0;
+  cursor: move;
+  position: absolute;
+}
+.tox .tox-croprect-handle-nw {
+  border-width: 2px 0 0 2px;
+  cursor: nw-resize;
+  left: 100px;
+  margin: -2px 0 0 -2px;
+  top: 100px;
+}
+.tox .tox-croprect-handle-ne {
+  border-width: 2px 2px 0 0;
+  cursor: ne-resize;
+  left: 200px;
+  margin: -2px 0 0 -20px;
+  top: 100px;
+}
+.tox .tox-croprect-handle-sw {
+  border-width: 0 0 2px 2px;
+  cursor: sw-resize;
+  left: 100px;
+  margin: -20px 2px 0 -2px;
+  top: 200px;
+}
+.tox .tox-croprect-handle-se {
+  border-width: 0 2px 2px 0;
+  cursor: se-resize;
+  left: 200px;
+  margin: -20px 0 0 -20px;
+  top: 200px;
+}
+.tox:not([dir=rtl]) .tox-image-tools__toolbar > .tox-slider:not(:first-of-type) {
+  margin-left: 8px;
+}
+.tox:not([dir=rtl]) .tox-image-tools__toolbar > .tox-button + .tox-slider {
+  margin-left: 32px;
+}
+.tox:not([dir=rtl]) .tox-image-tools__toolbar > .tox-slider + .tox-button {
+  margin-left: 32px;
+}
+.tox[dir=rtl] .tox-image-tools__toolbar > .tox-slider:not(:first-of-type) {
+  margin-right: 8px;
+}
+.tox[dir=rtl] .tox-image-tools__toolbar > .tox-button + .tox-slider {
+  margin-right: 32px;
+}
+.tox[dir=rtl] .tox-image-tools__toolbar > .tox-slider + .tox-button {
+  margin-right: 32px;
+}
+.tox .tox-insert-table-picker {
+  display: flex;
+  flex-wrap: wrap;
+  width: 170px;
+}
+.tox .tox-insert-table-picker > div {
+  border-color: #000000;
+  border-style: solid;
+  border-width: 0 1px 1px 0;
+  box-sizing: border-box;
+  height: 17px;
+  width: 17px;
+}
+.tox .tox-collection--list .tox-collection__group .tox-insert-table-picker {
+  margin: -4px 0;
+}
+.tox .tox-insert-table-picker .tox-insert-table-picker__selected {
+  background-color: rgba(32, 122, 183, 0.5);
+  border-color: rgba(32, 122, 183, 0.5);
+}
+.tox .tox-insert-table-picker__label {
+  color: #fff;
+  display: block;
+  font-size: 14px;
+  padding: 4px;
+  text-align: center;
+  width: 100%;
+}
+.tox:not([dir=rtl]) {
+  /* stylelint-disable-next-line no-descending-specificity */
+}
+.tox:not([dir=rtl]) .tox-insert-table-picker > div:nth-child(10n) {
+  border-right: 0;
+}
+.tox[dir=rtl] {
+  /* stylelint-disable-next-line no-descending-specificity */
+}
+.tox[dir=rtl] .tox-insert-table-picker > div:nth-child(10n+1) {
+  border-right: 0;
+}
+.tox {
+  /* stylelint-disable */
+  /* stylelint-enable */
+}
+.tox .tox-menu {
+  background-color: #2b3b4e;
+  border: 1px solid #000000;
+  border-radius: 3px;
+  box-shadow: 0 4px 8px 0 rgba(42, 55, 70, 0.1);
+  display: inline-block;
+  overflow: hidden;
+  vertical-align: top;
+  z-index: 1150;
+}
+.tox .tox-menu.tox-collection.tox-collection--list {
+  padding: 0;
+}
+.tox .tox-menu.tox-collection.tox-collection--toolbar {
+  padding: 4px;
+}
+.tox .tox-menu.tox-collection.tox-collection--grid {
+  padding: 4px;
+}
+.tox .tox-menu__label h1,
+.tox .tox-menu__label h2,
+.tox .tox-menu__label h3,
+.tox .tox-menu__label h4,
+.tox .tox-menu__label h5,
+.tox .tox-menu__label h6,
+.tox .tox-menu__label p,
+.tox .tox-menu__label blockquote,
+.tox .tox-menu__label code {
+  margin: 0;
+}
+.tox .tox-menubar {
+  background: url("data:image/svg+xml;charset=utf8,%3Csvg height='39px' viewBox='0 0 40 39px' width='40' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='0' y='38px' width='100' height='1' fill='%23000000'/%3E%3C/svg%3E") left 0 top 0 #222f3e;
+  background-color: #222f3e;
+  display: flex;
+  flex: 0 0 auto;
+  flex-shrink: 0;
+  flex-wrap: wrap;
+  padding: 0 4px 0 4px;
+}
+.tox.tox-tinymce:not(.tox-tinymce-inline) .tox-editor-header:not(:first-child) .tox-menubar {
+  border-top: 1px solid #000000;
+}
+/* Deprecated. Remove in next major release */
+.tox .tox-mbtn {
+  align-items: center;
+  background: transparent;
+  border: 0;
+  border-radius: 3px;
+  box-shadow: none;
+  color: #fff;
+  display: flex;
+  flex: 0 0 auto;
+  font-size: 14px;
+  font-style: normal;
+  font-weight: normal;
+  height: 34px;
+  justify-content: center;
+  margin: 2px 0 3px 0;
+  outline: none;
+  overflow: hidden;
+  padding: 0 4px;
+  text-transform: none;
+  width: auto;
+}
+.tox .tox-mbtn[disabled] {
+  background-color: transparent;
+  border: 0;
+  box-shadow: none;
+  color: rgba(255, 255, 255, 0.5);
+  cursor: not-allowed;
+}
+.tox .tox-mbtn:focus:not(:disabled) {
+  background: #4a5562;
+  border: 0;
+  box-shadow: none;
+  color: #fff;
+}
+.tox .tox-mbtn--active {
+  background: #757d87;
+  border: 0;
+  box-shadow: none;
+  color: #fff;
+}
+.tox .tox-mbtn:hover:not(:disabled):not(.tox-mbtn--active) {
+  background: #4a5562;
+  border: 0;
+  box-shadow: none;
+  color: #fff;
+}
+.tox .tox-mbtn__select-label {
+  cursor: default;
+  font-weight: normal;
+  margin: 0 4px;
+}
+.tox .tox-mbtn[disabled] .tox-mbtn__select-label {
+  cursor: not-allowed;
+}
+.tox .tox-mbtn__select-chevron {
+  align-items: center;
+  display: flex;
+  justify-content: center;
+  width: 16px;
+  display: none;
+}
+.tox .tox-notification {
+  border-radius: 3px;
+  border-style: solid;
+  border-width: 1px;
+  box-shadow: none;
+  box-sizing: border-box;
+  display: -ms-grid;
+  display: grid;
+  font-size: 14px;
+  font-weight: normal;
+  -ms-grid-columns: minmax(40px, 1fr) auto minmax(40px, 1fr);
+      grid-template-columns: minmax(40px, 1fr) auto minmax(40px, 1fr);
+  margin-top: 4px;
+  opacity: 0;
+  padding: 4px;
+  transition: transform 100ms ease-in, opacity 150ms ease-in;
+}
+.tox .tox-notification p {
+  font-size: 14px;
+  font-weight: normal;
+}
+.tox .tox-notification a {
+  cursor: pointer;
+  text-decoration: underline;
+}
+.tox .tox-notification--in {
+  opacity: 1;
+}
+.tox .tox-notification--success {
+  background-color: #e4eeda;
+  border-color: #d7e6c8;
+  color: #fff;
+}
+.tox .tox-notification--success p {
+  color: #fff;
+}
+.tox .tox-notification--success a {
+  color: #547831;
+}
+.tox .tox-notification--success svg {
+  fill: #fff;
+}
+.tox .tox-notification--error {
+  background-color: #f8dede;
+  border-color: #f2bfbf;
+  color: #fff;
+}
+.tox .tox-notification--error p {
+  color: #fff;
+}
+.tox .tox-notification--error a {
+  color: #c00;
+}
+.tox .tox-notification--error svg {
+  fill: #fff;
+}
+.tox .tox-notification--warn,
+.tox .tox-notification--warning {
+  background-color: #fffaea;
+  border-color: #ffe89d;
+  color: #fff;
+}
+.tox .tox-notification--warn p,
+.tox .tox-notification--warning p {
+  color: #fff;
+}
+.tox .tox-notification--warn a,
+.tox .tox-notification--warning a {
+  color: #fff;
+}
+.tox .tox-notification--warn svg,
+.tox .tox-notification--warning svg {
+  fill: #fff;
+}
+.tox .tox-notification--info {
+  background-color: #d9edf7;
+  border-color: #779ecb;
+  color: #fff;
+}
+.tox .tox-notification--info p {
+  color: #fff;
+}
+.tox .tox-notification--info a {
+  color: #fff;
+}
+.tox .tox-notification--info svg {
+  fill: #fff;
+}
+.tox .tox-notification__body {
+  -ms-grid-row-align: center;
+      align-self: center;
+  color: #fff;
+  font-size: 14px;
+  -ms-grid-column-span: 1;
+  grid-column-end: 3;
+  -ms-grid-column: 2;
+      grid-column-start: 2;
+  -ms-grid-row-span: 1;
+  grid-row-end: 2;
+  -ms-grid-row: 1;
+      grid-row-start: 1;
+  text-align: center;
+  white-space: normal;
+  word-break: break-all;
+  word-break: break-word;
+}
+.tox .tox-notification__body > * {
+  margin: 0;
+}
+.tox .tox-notification__body > * + * {
+  margin-top: 1rem;
+}
+.tox .tox-notification__icon {
+  -ms-grid-row-align: center;
+      align-self: center;
+  -ms-grid-column-span: 1;
+  grid-column-end: 2;
+  -ms-grid-column: 1;
+      grid-column-start: 1;
+  -ms-grid-row-span: 1;
+  grid-row-end: 2;
+  -ms-grid-row: 1;
+      grid-row-start: 1;
+  -ms-grid-column-align: end;
+      justify-self: end;
+}
+.tox .tox-notification__icon svg {
+  display: block;
+}
+.tox .tox-notification__dismiss {
+  -ms-grid-row-align: start;
+      align-self: start;
+  -ms-grid-column-span: 1;
+  grid-column-end: 4;
+  -ms-grid-column: 3;
+      grid-column-start: 3;
+  -ms-grid-row-span: 1;
+  grid-row-end: 2;
+  -ms-grid-row: 1;
+      grid-row-start: 1;
+  -ms-grid-column-align: end;
+      justify-self: end;
+}
+.tox .tox-notification .tox-progress-bar {
+  -ms-grid-column-span: 3;
+  grid-column-end: 4;
+  -ms-grid-column: 1;
+      grid-column-start: 1;
+  -ms-grid-row-span: 1;
+  grid-row-end: 3;
+  -ms-grid-row: 2;
+      grid-row-start: 2;
+  -ms-grid-column-align: center;
+      justify-self: center;
+}
+.tox .tox-pop {
+  display: inline-block;
+  position: relative;
+}
+.tox .tox-pop--resizing {
+  transition: width 0.1s ease;
+}
+.tox .tox-pop--resizing .tox-toolbar,
+.tox .tox-pop--resizing .tox-toolbar__group {
+  flex-wrap: nowrap;
+}
+.tox .tox-pop--transition {
+  transition: 0.15s ease;
+  transition-property: left, right, top, bottom;
+}
+.tox .tox-pop--transition::before,
+.tox .tox-pop--transition::after {
+  transition: all 0.15s, visibility 0s, opacity 0.075s ease 0.075s;
+}
+.tox .tox-pop__dialog {
+  background-color: #222f3e;
+  border: 1px solid #000000;
+  border-radius: 3px;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
+  min-width: 0;
+  overflow: hidden;
+}
+.tox .tox-pop__dialog > *:not(.tox-toolbar) {
+  margin: 4px 4px 4px 8px;
+}
+.tox .tox-pop__dialog .tox-toolbar {
+  background-color: transparent;
+  margin-bottom: -1px;
+}
+.tox .tox-pop::before,
+.tox .tox-pop::after {
+  border-style: solid;
+  content: '';
+  display: block;
+  height: 0;
+  opacity: 1;
+  position: absolute;
+  width: 0;
+}
+.tox .tox-pop.tox-pop--inset::before,
+.tox .tox-pop.tox-pop--inset::after {
+  opacity: 0;
+  transition: all 0s 0.15s, visibility 0s, opacity 0.075s ease;
+}
+.tox .tox-pop.tox-pop--bottom::before,
+.tox .tox-pop.tox-pop--bottom::after {
+  left: 50%;
+  top: 100%;
+}
+.tox .tox-pop.tox-pop--bottom::after {
+  border-color: #222f3e transparent transparent transparent;
+  border-width: 8px;
+  margin-left: -8px;
+  margin-top: -1px;
+}
+.tox .tox-pop.tox-pop--bottom::before {
+  border-color: #000000 transparent transparent transparent;
+  border-width: 9px;
+  margin-left: -9px;
+}
+.tox .tox-pop.tox-pop--top::before,
+.tox .tox-pop.tox-pop--top::after {
+  left: 50%;
+  top: 0;
+  transform: translateY(-100%);
+}
+.tox .tox-pop.tox-pop--top::after {
+  border-color: transparent transparent #222f3e transparent;
+  border-width: 8px;
+  margin-left: -8px;
+  margin-top: 1px;
+}
+.tox .tox-pop.tox-pop--top::before {
+  border-color: transparent transparent #000000 transparent;
+  border-width: 9px;
+  margin-left: -9px;
+}
+.tox .tox-pop.tox-pop--left::before,
+.tox .tox-pop.tox-pop--left::after {
+  left: 0;
+  top: calc(50% - 1px);
+  transform: translateY(-50%);
+}
+.tox .tox-pop.tox-pop--left::after {
+  border-color: transparent #222f3e transparent transparent;
+  border-width: 8px;
+  margin-left: -15px;
+}
+.tox .tox-pop.tox-pop--left::before {
+  border-color: transparent #000000 transparent transparent;
+  border-width: 10px;
+  margin-left: -19px;
+}
+.tox .tox-pop.tox-pop--right::before,
+.tox .tox-pop.tox-pop--right::after {
+  left: 100%;
+  top: calc(50% + 1px);
+  transform: translateY(-50%);
+}
+.tox .tox-pop.tox-pop--right::after {
+  border-color: transparent transparent transparent #222f3e;
+  border-width: 8px;
+  margin-left: -1px;
+}
+.tox .tox-pop.tox-pop--right::before {
+  border-color: transparent transparent transparent #000000;
+  border-width: 10px;
+  margin-left: -1px;
+}
+.tox .tox-pop.tox-pop--align-left::before,
+.tox .tox-pop.tox-pop--align-left::after {
+  left: 20px;
+}
+.tox .tox-pop.tox-pop--align-right::before,
+.tox .tox-pop.tox-pop--align-right::after {
+  left: calc(100% - 20px);
+}
+.tox .tox-sidebar-wrap {
+  display: flex;
+  flex-direction: row;
+  flex-grow: 1;
+  -ms-flex-preferred-size: 0;
+  min-height: 0;
+}
+.tox .tox-sidebar {
+  background-color: #222f3e;
+  display: flex;
+  flex-direction: row;
+  justify-content: flex-end;
+}
+.tox .tox-sidebar__slider {
+  display: flex;
+  overflow: hidden;
+}
+.tox .tox-sidebar__pane-container {
+  display: flex;
+}
+.tox .tox-sidebar__pane {
+  display: flex;
+}
+.tox .tox-sidebar--sliding-closed {
+  opacity: 0;
+}
+.tox .tox-sidebar--sliding-open {
+  opacity: 1;
+}
+.tox .tox-sidebar--sliding-growing,
+.tox .tox-sidebar--sliding-shrinking {
+  transition: width 0.5s ease, opacity 0.5s ease;
+}
+.tox .tox-selector {
+  background-color: #4099ff;
+  border-color: #4099ff;
+  border-style: solid;
+  border-width: 1px;
+  box-sizing: border-box;
+  display: inline-block;
+  height: 10px;
+  position: absolute;
+  width: 10px;
+}
+.tox.tox-platform-touch .tox-selector {
+  height: 12px;
+  width: 12px;
+}
+.tox .tox-slider {
+  align-items: center;
+  display: flex;
+  flex: 1;
+  -ms-flex-preferred-size: auto;
+  height: 24px;
+  justify-content: center;
+  position: relative;
+}
+.tox .tox-slider__rail {
+  background-color: transparent;
+  border: 1px solid #000000;
+  border-radius: 3px;
+  height: 10px;
+  min-width: 120px;
+  width: 100%;
+}
+.tox .tox-slider__handle {
+  background-color: #207ab7;
+  border: 2px solid #185d8c;
+  border-radius: 3px;
+  box-shadow: none;
+  height: 24px;
+  left: 50%;
+  position: absolute;
+  top: 50%;
+  transform: translateX(-50%) translateY(-50%);
+  width: 14px;
+}
+.tox .tox-source-code {
+  overflow: auto;
+}
+.tox .tox-spinner {
+  display: flex;
+}
+.tox .tox-spinner > div {
+  animation: tam-bouncing-dots 1.5s ease-in-out 0s infinite both;
+  background-color: rgba(255, 255, 255, 0.5);
+  border-radius: 100%;
+  height: 8px;
+  width: 8px;
+}
+.tox .tox-spinner > div:nth-child(1) {
+  animation-delay: -0.32s;
+}
+.tox .tox-spinner > div:nth-child(2) {
+  animation-delay: -0.16s;
+}
+@keyframes tam-bouncing-dots {
+  0%,
+  80%,
+  100% {
+    transform: scale(0);
+  }
+  40% {
+    transform: scale(1);
+  }
+}
+.tox:not([dir=rtl]) .tox-spinner > div:not(:first-child) {
+  margin-left: 4px;
+}
+.tox[dir=rtl] .tox-spinner > div:not(:first-child) {
+  margin-right: 4px;
+}
+.tox .tox-statusbar {
+  align-items: center;
+  background-color: #222f3e;
+  border-top: 1px solid #000000;
+  color: #fff;
+  display: flex;
+  flex: 0 0 auto;
+  font-size: 12px;
+  font-weight: normal;
+  height: 18px;
+  overflow: hidden;
+  padding: 0 8px;
+  position: relative;
+  text-transform: uppercase;
+}
+.tox .tox-statusbar__text-container {
+  display: flex;
+  flex: 1 1 auto;
+  justify-content: flex-end;
+  overflow: hidden;
+}
+.tox .tox-statusbar__path {
+  display: flex;
+  flex: 1 1 auto;
+  margin-right: auto;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.tox .tox-statusbar__path > * {
+  display: inline;
+  white-space: nowrap;
+}
+.tox .tox-statusbar__wordcount {
+  flex: 0 0 auto;
+  margin-left: 1ch;
+}
+.tox .tox-statusbar a,
+.tox .tox-statusbar__path-item,
+.tox .tox-statusbar__wordcount {
+  color: #fff;
+  text-decoration: none;
+}
+.tox .tox-statusbar a:hover:not(:disabled):not([aria-disabled=true]),
+.tox .tox-statusbar__path-item:hover:not(:disabled):not([aria-disabled=true]),
+.tox .tox-statusbar__wordcount:hover:not(:disabled):not([aria-disabled=true]),
+.tox .tox-statusbar a:focus:not(:disabled):not([aria-disabled=true]),
+.tox .tox-statusbar__path-item:focus:not(:disabled):not([aria-disabled=true]),
+.tox .tox-statusbar__wordcount:focus:not(:disabled):not([aria-disabled=true]) {
+  cursor: pointer;
+  text-decoration: underline;
+}
+.tox .tox-statusbar__resize-handle {
+  align-items: flex-end;
+  align-self: stretch;
+  cursor: nwse-resize;
+  display: flex;
+  flex: 0 0 auto;
+  justify-content: flex-end;
+  margin-left: auto;
+  margin-right: -8px;
+  padding-left: 1ch;
+}
+.tox .tox-statusbar__resize-handle svg {
+  display: block;
+  fill: #fff;
+}
+.tox .tox-statusbar__resize-handle:focus svg {
+  background-color: #4a5562;
+  border-radius: 1px;
+  box-shadow: 0 0 0 2px #4a5562;
+}
+.tox:not([dir=rtl]) .tox-statusbar__path > * {
+  margin-right: 4px;
+}
+.tox:not([dir=rtl]) .tox-statusbar__branding {
+  margin-left: 1ch;
+}
+.tox[dir=rtl] .tox-statusbar {
+  flex-direction: row-reverse;
+}
+.tox[dir=rtl] .tox-statusbar__path > * {
+  margin-left: 4px;
+}
+.tox .tox-throbber {
+  z-index: 1299;
+}
+.tox .tox-throbber__busy-spinner {
+  align-items: center;
+  background-color: rgba(34, 47, 62, 0.6);
+  bottom: 0;
+  display: flex;
+  justify-content: center;
+  left: 0;
+  position: absolute;
+  right: 0;
+  top: 0;
+}
+.tox .tox-tbtn {
+  align-items: center;
+  background: transparent;
+  border: 0;
+  border-radius: 3px;
+  box-shadow: none;
+  color: #fff;
+  display: flex;
+  flex: 0 0 auto;
+  font-size: 14px;
+  font-style: normal;
+  font-weight: normal;
+  height: 34px;
+  justify-content: center;
+  margin: 2px 0 3px 0;
+  outline: none;
+  overflow: hidden;
+  padding: 0;
+  text-transform: none;
+  width: 34px;
+}
+.tox .tox-tbtn svg {
+  display: block;
+  fill: #fff;
+}
+.tox .tox-tbtn.tox-tbtn-more {
+  padding-left: 5px;
+  padding-right: 5px;
+  width: inherit;
+}
+.tox .tox-tbtn:focus {
+  background: #4a5562;
+  border: 0;
+  box-shadow: none;
+}
+.tox .tox-tbtn:hover {
+  background: #4a5562;
+  border: 0;
+  box-shadow: none;
+  color: #fff;
+}
+.tox .tox-tbtn:hover svg {
+  fill: #fff;
+}
+.tox .tox-tbtn:active {
+  background: #757d87;
+  border: 0;
+  box-shadow: none;
+  color: #fff;
+}
+.tox .tox-tbtn:active svg {
+  fill: #fff;
+}
+.tox .tox-tbtn--disabled,
+.tox .tox-tbtn--disabled:hover,
+.tox .tox-tbtn:disabled,
+.tox .tox-tbtn:disabled:hover {
+  background: transparent;
+  border: 0;
+  box-shadow: none;
+  color: rgba(255, 255, 255, 0.5);
+  cursor: not-allowed;
+}
+.tox .tox-tbtn--disabled svg,
+.tox .tox-tbtn--disabled:hover svg,
+.tox .tox-tbtn:disabled svg,
+.tox .tox-tbtn:disabled:hover svg {
+  /* stylelint-disable-line no-descending-specificity */
+  fill: rgba(255, 255, 255, 0.5);
+}
+.tox .tox-tbtn--enabled,
+.tox .tox-tbtn--enabled:hover {
+  background: #757d87;
+  border: 0;
+  box-shadow: none;
+  color: #fff;
+}
+.tox .tox-tbtn--enabled > *,
+.tox .tox-tbtn--enabled:hover > * {
+  transform: none;
+}
+.tox .tox-tbtn--enabled svg,
+.tox .tox-tbtn--enabled:hover svg {
+  /* stylelint-disable-line no-descending-specificity */
+  fill: #fff;
+}
+.tox .tox-tbtn:focus:not(.tox-tbtn--disabled) {
+  color: #fff;
+}
+.tox .tox-tbtn:focus:not(.tox-tbtn--disabled) svg {
+  fill: #fff;
+}
+.tox .tox-tbtn:active > * {
+  transform: none;
+}
+.tox .tox-tbtn--md {
+  height: 51px;
+  width: 51px;
+}
+.tox .tox-tbtn--lg {
+  flex-direction: column;
+  height: 68px;
+  width: 68px;
+}
+.tox .tox-tbtn--return {
+  -ms-grid-row-align: stretch;
+      align-self: stretch;
+  height: unset;
+  width: 16px;
+}
+.tox .tox-tbtn--labeled {
+  padding: 0 4px;
+  width: unset;
+}
+.tox .tox-tbtn__vlabel {
+  display: block;
+  font-size: 10px;
+  font-weight: normal;
+  letter-spacing: -0.025em;
+  margin-bottom: 4px;
+  white-space: nowrap;
+}
+.tox .tox-tbtn--select {
+  margin: 2px 0 3px 0;
+  padding: 0 4px;
+  width: auto;
+}
+.tox .tox-tbtn__select-label {
+  cursor: default;
+  font-weight: normal;
+  margin: 0 4px;
+}
+.tox .tox-tbtn__select-chevron {
+  align-items: center;
+  display: flex;
+  justify-content: center;
+  width: 16px;
+}
+.tox .tox-tbtn__select-chevron svg {
+  fill: rgba(255, 255, 255, 0.5);
+}
+.tox .tox-tbtn--bespoke .tox-tbtn__select-label {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  width: 7em;
+}
+.tox .tox-split-button {
+  border: 0;
+  border-radius: 3px;
+  box-sizing: border-box;
+  display: flex;
+  margin: 2px 0 3px 0;
+  overflow: hidden;
+}
+.tox .tox-split-button:hover {
+  box-shadow: 0 0 0 1px #4a5562 inset;
+}
+.tox .tox-split-button:focus {
+  background: #4a5562;
+  box-shadow: none;
+  color: #fff;
+}
+.tox .tox-split-button > * {
+  border-radius: 0;
+}
+.tox .tox-split-button__chevron {
+  width: 16px;
+}
+.tox .tox-split-button__chevron svg {
+  fill: rgba(255, 255, 255, 0.5);
+}
+.tox .tox-split-button .tox-tbtn {
+  margin: 0;
+}
+.tox.tox-platform-touch .tox-split-button .tox-tbtn:first-child {
+  width: 30px;
+}
+.tox.tox-platform-touch .tox-split-button__chevron {
+  width: 20px;
+}
+.tox .tox-split-button.tox-tbtn--disabled:hover,
+.tox .tox-split-button.tox-tbtn--disabled:focus,
+.tox .tox-split-button.tox-tbtn--disabled .tox-tbtn:hover,
+.tox .tox-split-button.tox-tbtn--disabled .tox-tbtn:focus {
+  background: transparent;
+  box-shadow: none;
+  color: rgba(255, 255, 255, 0.5);
+}
+.tox .tox-toolbar-overlord {
+  background-color: #222f3e;
+}
+.tox .tox-toolbar,
+.tox .tox-toolbar__primary,
+.tox .tox-toolbar__overflow {
+  background: url("data:image/svg+xml;charset=utf8,%3Csvg height='39px' viewBox='0 0 40 39px' width='40' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='0' y='38px' width='100' height='1' fill='%23000000'/%3E%3C/svg%3E") left 0 top 0 #222f3e;
+  background-color: #222f3e;
+  display: flex;
+  flex: 0 0 auto;
+  flex-shrink: 0;
+  flex-wrap: wrap;
+  padding: 0 0;
+}
+.tox .tox-toolbar__overflow.tox-toolbar__overflow--closed {
+  height: 0;
+  opacity: 0;
+  padding-bottom: 0;
+  padding-top: 0;
+  visibility: hidden;
+}
+.tox .tox-toolbar__overflow--growing {
+  transition: height 0.3s ease, opacity 0.2s linear 0.1s;
+}
+.tox .tox-toolbar__overflow--shrinking {
+  transition: opacity 0.3s ease, height 0.2s linear 0.1s, visibility 0s linear 0.3s;
+}
+.tox .tox-menubar + .tox-toolbar,
+.tox .tox-menubar + .tox-toolbar-overlord .tox-toolbar__primary {
+  border-top: 1px solid #000000;
+  margin-top: -1px;
+}
+.tox .tox-toolbar--scrolling {
+  flex-wrap: nowrap;
+  overflow-x: auto;
+}
+.tox .tox-pop .tox-toolbar {
+  border-width: 0;
+}
+.tox .tox-toolbar--no-divider {
+  background-image: none;
+}
+.tox-tinymce:not(.tox-tinymce-inline) .tox-editor-header:not(:first-child) .tox-toolbar:first-child,
+.tox-tinymce:not(.tox-tinymce-inline) .tox-editor-header:not(:first-child) .tox-toolbar-overlord:first-child .tox-toolbar__primary {
+  border-top: 1px solid #000000;
+}
+.tox.tox-tinymce-aux .tox-toolbar__overflow {
+  background-color: #222f3e;
+  border: 1px solid #000000;
+  border-radius: 3px;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
+}
+.tox .tox-toolbar__group {
+  align-items: center;
+  display: flex;
+  flex-wrap: wrap;
+  margin: 0 0;
+  padding: 0 4px 0 4px;
+}
+.tox .tox-toolbar__group--pull-right {
+  margin-left: auto;
+}
+.tox .tox-toolbar--scrolling .tox-toolbar__group {
+  flex-shrink: 0;
+  flex-wrap: nowrap;
+}
+.tox:not([dir=rtl]) .tox-toolbar__group:not(:last-of-type) {
+  border-right: 1px solid #000000;
+}
+.tox[dir=rtl] .tox-toolbar__group:not(:last-of-type) {
+  border-left: 1px solid #000000;
+}
+.tox .tox-tooltip {
+  display: inline-block;
+  padding: 8px;
+  position: relative;
+}
+.tox .tox-tooltip__body {
+  background-color: #3d546f;
+  border-radius: 3px;
+  box-shadow: 0 2px 4px rgba(42, 55, 70, 0.3);
+  color: rgba(255, 255, 255, 0.75);
+  font-size: 14px;
+  font-style: normal;
+  font-weight: normal;
+  padding: 4px 8px;
+  text-transform: none;
+}
+.tox .tox-tooltip__arrow {
+  position: absolute;
+}
+.tox .tox-tooltip--down .tox-tooltip__arrow {
+  border-left: 8px solid transparent;
+  border-right: 8px solid transparent;
+  border-top: 8px solid #3d546f;
+  bottom: 0;
+  left: 50%;
+  position: absolute;
+  transform: translateX(-50%);
+}
+.tox .tox-tooltip--up .tox-tooltip__arrow {
+  border-bottom: 8px solid #3d546f;
+  border-left: 8px solid transparent;
+  border-right: 8px solid transparent;
+  left: 50%;
+  position: absolute;
+  top: 0;
+  transform: translateX(-50%);
+}
+.tox .tox-tooltip--right .tox-tooltip__arrow {
+  border-bottom: 8px solid transparent;
+  border-left: 8px solid #3d546f;
+  border-top: 8px solid transparent;
+  position: absolute;
+  right: 0;
+  top: 50%;
+  transform: translateY(-50%);
+}
+.tox .tox-tooltip--left .tox-tooltip__arrow {
+  border-bottom: 8px solid transparent;
+  border-right: 8px solid #3d546f;
+  border-top: 8px solid transparent;
+  left: 0;
+  position: absolute;
+  top: 50%;
+  transform: translateY(-50%);
+}
+.tox .tox-well {
+  border: 1px solid #000000;
+  border-radius: 3px;
+  padding: 8px;
+  width: 100%;
+}
+.tox .tox-well > *:first-child {
+  margin-top: 0;
+}
+.tox .tox-well > *:last-child {
+  margin-bottom: 0;
+}
+.tox .tox-well > *:only-child {
+  margin: 0;
+}
+.tox .tox-custom-editor {
+  border: 1px solid #000000;
+  border-radius: 3px;
+  display: flex;
+  flex: 1;
+  position: relative;
+}
+/* stylelint-disable */
+.tox {
+  /* stylelint-enable */
+}
+.tox .tox-dialog-loading::before {
+  background-color: rgba(0, 0, 0, 0.5);
+  content: "";
+  height: 100%;
+  position: absolute;
+  width: 100%;
+  z-index: 1000;
+}
+.tox .tox-tab {
+  cursor: pointer;
+}
+.tox .tox-dialog__content-js {
+  display: flex;
+  flex: 1;
+  -ms-flex-preferred-size: auto;
+}
+.tox .tox-dialog__body-content .tox-collection {
+  display: flex;
+  flex: 1;
+  -ms-flex-preferred-size: auto;
+}
+.tox .tox-image-tools-edit-panel {
+  height: 60px;
+}
+.tox .tox-image-tools__sidebar {
+  height: 60px;
+}
diff --git a/public/tinymce/skins/ui/oxide-dark/skin.min.css b/public/tinymce/skins/ui/oxide-dark/skin.min.css
new file mode 100644
index 0000000..e71f6f0
--- /dev/null
+++ b/public/tinymce/skins/ui/oxide-dark/skin.min.css
@@ -0,0 +1,7 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+.tox{box-shadow:none;box-sizing:content-box;color:#2a3746;cursor:auto;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;font-size:16px;font-style:normal;font-weight:400;line-height:normal;-webkit-tap-highlight-color:transparent;text-decoration:none;text-shadow:none;text-transform:none;vertical-align:initial;white-space:normal}.tox :not(svg):not(rect){box-sizing:inherit;color:inherit;cursor:inherit;direction:inherit;font-family:inherit;font-size:inherit;font-style:inherit;font-weight:inherit;line-height:inherit;-webkit-tap-highlight-color:inherit;text-align:inherit;text-decoration:inherit;text-shadow:inherit;text-transform:inherit;vertical-align:inherit;white-space:inherit}.tox :not(svg):not(rect){background:0 0;border:0;box-shadow:none;float:none;height:auto;margin:0;max-width:none;outline:0;padding:0;position:static;width:auto}.tox:not([dir=rtl]){direction:ltr;text-align:left}.tox[dir=rtl]{direction:rtl;text-align:right}.tox-tinymce{border:1px solid #000;border-radius:0;box-shadow:none;box-sizing:border-box;display:flex;flex-direction:column;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;overflow:hidden;position:relative;visibility:inherit!important}.tox-tinymce-inline{border:none;box-shadow:none}.tox-tinymce-inline .tox-editor-header{background-color:transparent;border:1px solid #000;border-radius:0;box-shadow:none}.tox-tinymce-aux{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;z-index:1300}.tox-tinymce :focus,.tox-tinymce-aux :focus{outline:0}button::-moz-focus-inner{border:0}.tox[dir=rtl] .tox-icon--flip svg{transform:rotateY(180deg)}.tox .accessibility-issue__header{align-items:center;display:flex;margin-bottom:4px}.tox .accessibility-issue__description{align-items:stretch;border:1px solid #000;border-radius:3px;display:flex;justify-content:space-between}.tox .accessibility-issue__description>div{padding-bottom:4px}.tox .accessibility-issue__description>div>div{align-items:center;display:flex;margin-bottom:4px}.tox .accessibility-issue__description>:last-child:not(:only-child){border-color:#000;border-style:solid}.tox .accessibility-issue__repair{margin-top:16px}.tox .tox-dialog__body-content .accessibility-issue--info .accessibility-issue__description{background-color:rgba(32,122,183,.5);border-color:#207ab7;color:#fff}.tox .tox-dialog__body-content .accessibility-issue--info .accessibility-issue__description>:last-child{border-color:#207ab7}.tox .tox-dialog__body-content .accessibility-issue--info .tox-form__group h2{color:#fff}.tox .tox-dialog__body-content .accessibility-issue--info .tox-icon svg{fill:#fff}.tox .tox-dialog__body-content .accessibility-issue--info a .tox-icon{color:#fff}.tox .tox-dialog__body-content .accessibility-issue--warn .accessibility-issue__description{background-color:rgba(255,165,0,.5);border-color:rgba(255,165,0,.8);color:#fff}.tox .tox-dialog__body-content .accessibility-issue--warn .accessibility-issue__description>:last-child{border-color:rgba(255,165,0,.8)}.tox .tox-dialog__body-content .accessibility-issue--warn .tox-form__group h2{color:#fff}.tox .tox-dialog__body-content .accessibility-issue--warn .tox-icon svg{fill:#fff}.tox .tox-dialog__body-content .accessibility-issue--warn a .tox-icon{color:#fff}.tox .tox-dialog__body-content .accessibility-issue--error .accessibility-issue__description{background-color:rgba(204,0,0,.5);border-color:rgba(204,0,0,.8);color:#fff}.tox .tox-dialog__body-content .accessibility-issue--error .accessibility-issue__description>:last-child{border-color:rgba(204,0,0,.8)}.tox .tox-dialog__body-content .accessibility-issue--error .tox-form__group h2{color:#fff}.tox .tox-dialog__body-content .accessibility-issue--error .tox-icon svg{fill:#fff}.tox .tox-dialog__body-content .accessibility-issue--error a .tox-icon{color:#fff}.tox .tox-dialog__body-content .accessibility-issue--success .accessibility-issue__description{background-color:rgba(120,171,70,.5);border-color:rgba(120,171,70,.8);color:#fff}.tox .tox-dialog__body-content .accessibility-issue--success .accessibility-issue__description>:last-child{border-color:rgba(120,171,70,.8)}.tox .tox-dialog__body-content .accessibility-issue--success .tox-form__group h2{color:#fff}.tox .tox-dialog__body-content .accessibility-issue--success .tox-icon svg{fill:#fff}.tox .tox-dialog__body-content .accessibility-issue--success a .tox-icon{color:#fff}.tox .tox-dialog__body-content .accessibility-issue__header h1,.tox .tox-dialog__body-content .tox-form__group .accessibility-issue__description h2{margin-top:0}.tox:not([dir=rtl]) .tox-dialog__body-content .accessibility-issue__header .tox-button{margin-left:4px}.tox:not([dir=rtl]) .tox-dialog__body-content .accessibility-issue__header>:nth-last-child(2){margin-left:auto}.tox:not([dir=rtl]) .tox-dialog__body-content .accessibility-issue__description{padding:4px 4px 4px 8px}.tox:not([dir=rtl]) .tox-dialog__body-content .accessibility-issue__description>:last-child{border-left-width:1px;padding-left:4px}.tox[dir=rtl] .tox-dialog__body-content .accessibility-issue__header .tox-button{margin-right:4px}.tox[dir=rtl] .tox-dialog__body-content .accessibility-issue__header>:nth-last-child(2){margin-right:auto}.tox[dir=rtl] .tox-dialog__body-content .accessibility-issue__description{padding:4px 8px 4px 4px}.tox[dir=rtl] .tox-dialog__body-content .accessibility-issue__description>:last-child{border-right-width:1px;padding-right:4px}.tox .tox-anchorbar{display:flex;flex:0 0 auto}.tox .tox-bar{display:flex;flex:0 0 auto}.tox .tox-button{background-color:#207ab7;background-image:none;background-position:0 0;background-repeat:repeat;border-color:#207ab7;border-radius:3px;border-style:solid;border-width:1px;box-shadow:none;box-sizing:border-box;color:#fff;cursor:pointer;display:inline-block;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;font-size:14px;font-style:normal;font-weight:700;letter-spacing:normal;line-height:24px;margin:0;outline:0;padding:4px 16px;text-align:center;text-decoration:none;text-transform:none;white-space:nowrap}.tox .tox-button[disabled]{background-color:#207ab7;background-image:none;border-color:#207ab7;box-shadow:none;color:rgba(255,255,255,.5);cursor:not-allowed}.tox .tox-button:focus:not(:disabled){background-color:#1c6ca1;background-image:none;border-color:#1c6ca1;box-shadow:none;color:#fff}.tox .tox-button:hover:not(:disabled){background-color:#1c6ca1;background-image:none;border-color:#1c6ca1;box-shadow:none;color:#fff}.tox .tox-button:active:not(:disabled){background-color:#185d8c;background-image:none;border-color:#185d8c;box-shadow:none;color:#fff}.tox .tox-button--secondary{background-color:#3d546f;background-image:none;background-position:0 0;background-repeat:repeat;border-color:#3d546f;border-radius:3px;border-style:solid;border-width:1px;box-shadow:none;color:#fff;font-size:14px;font-style:normal;font-weight:700;letter-spacing:normal;outline:0;padding:4px 16px;text-decoration:none;text-transform:none}.tox .tox-button--secondary[disabled]{background-color:#3d546f;background-image:none;border-color:#3d546f;box-shadow:none;color:rgba(255,255,255,.5)}.tox .tox-button--secondary:focus:not(:disabled){background-color:#34485f;background-image:none;border-color:#34485f;box-shadow:none;color:#fff}.tox .tox-button--secondary:hover:not(:disabled){background-color:#34485f;background-image:none;border-color:#34485f;box-shadow:none;color:#fff}.tox .tox-button--secondary:active:not(:disabled){background-color:#2b3b4e;background-image:none;border-color:#2b3b4e;box-shadow:none;color:#fff}.tox .tox-button--icon,.tox .tox-button.tox-button--icon,.tox .tox-button.tox-button--secondary.tox-button--icon{padding:4px}.tox .tox-button--icon .tox-icon svg,.tox .tox-button.tox-button--icon .tox-icon svg,.tox .tox-button.tox-button--secondary.tox-button--icon .tox-icon svg{display:block;fill:currentColor}.tox .tox-button-link{background:0;border:none;box-sizing:border-box;cursor:pointer;display:inline-block;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;font-size:16px;font-weight:400;line-height:1.3;margin:0;padding:0;white-space:nowrap}.tox .tox-button-link--sm{font-size:14px}.tox .tox-button--naked{background-color:transparent;border-color:transparent;box-shadow:unset;color:#fff}.tox .tox-button--naked[disabled]{background-color:#3d546f;border-color:#3d546f;box-shadow:none;color:rgba(255,255,255,.5)}.tox .tox-button--naked:hover:not(:disabled){background-color:#34485f;border-color:#34485f;box-shadow:none;color:#fff}.tox .tox-button--naked:focus:not(:disabled){background-color:#34485f;border-color:#34485f;box-shadow:none;color:#fff}.tox .tox-button--naked:active:not(:disabled){background-color:#2b3b4e;border-color:#2b3b4e;box-shadow:none;color:#fff}.tox .tox-button--naked .tox-icon svg{fill:currentColor}.tox .tox-button--naked.tox-button--icon:hover:not(:disabled){color:#fff}.tox .tox-checkbox{align-items:center;border-radius:3px;cursor:pointer;display:flex;height:36px;min-width:36px}.tox .tox-checkbox__input{height:1px;overflow:hidden;position:absolute;top:auto;width:1px}.tox .tox-checkbox__icons{align-items:center;border-radius:3px;box-shadow:0 0 0 2px transparent;box-sizing:content-box;display:flex;height:24px;justify-content:center;padding:calc(4px - 1px);width:24px}.tox .tox-checkbox__icons .tox-checkbox-icon__unchecked svg{display:block;fill:rgba(255,255,255,.2)}.tox .tox-checkbox__icons .tox-checkbox-icon__indeterminate svg{display:none;fill:#207ab7}.tox .tox-checkbox__icons .tox-checkbox-icon__checked svg{display:none;fill:#207ab7}.tox .tox-checkbox--disabled{color:rgba(255,255,255,.5);cursor:not-allowed}.tox .tox-checkbox--disabled .tox-checkbox__icons .tox-checkbox-icon__checked svg{fill:rgba(255,255,255,.5)}.tox .tox-checkbox--disabled .tox-checkbox__icons .tox-checkbox-icon__unchecked svg{fill:rgba(255,255,255,.5)}.tox .tox-checkbox--disabled .tox-checkbox__icons .tox-checkbox-icon__indeterminate svg{fill:rgba(255,255,255,.5)}.tox input.tox-checkbox__input:checked+.tox-checkbox__icons .tox-checkbox-icon__unchecked svg{display:none}.tox input.tox-checkbox__input:checked+.tox-checkbox__icons .tox-checkbox-icon__checked svg{display:block}.tox input.tox-checkbox__input:indeterminate+.tox-checkbox__icons .tox-checkbox-icon__unchecked svg{display:none}.tox input.tox-checkbox__input:indeterminate+.tox-checkbox__icons .tox-checkbox-icon__indeterminate svg{display:block}.tox input.tox-checkbox__input:focus+.tox-checkbox__icons{border-radius:3px;box-shadow:inset 0 0 0 1px #207ab7;padding:calc(4px - 1px)}.tox:not([dir=rtl]) .tox-checkbox__label{margin-left:4px}.tox:not([dir=rtl]) .tox-checkbox__input{left:-10000px}.tox:not([dir=rtl]) .tox-bar .tox-checkbox{margin-left:4px}.tox[dir=rtl] .tox-checkbox__label{margin-right:4px}.tox[dir=rtl] .tox-checkbox__input{right:-10000px}.tox[dir=rtl] .tox-bar .tox-checkbox{margin-right:4px}.tox .tox-collection--toolbar .tox-collection__group{display:flex;padding:0}.tox .tox-collection--grid .tox-collection__group{display:flex;flex-wrap:wrap;max-height:208px;overflow-x:hidden;overflow-y:auto;padding:0}.tox .tox-collection--list .tox-collection__group{border-bottom-width:0;border-color:#1a1a1a;border-left-width:0;border-right-width:0;border-style:solid;border-top-width:1px;padding:4px 0}.tox .tox-collection--list .tox-collection__group:first-child{border-top-width:0}.tox .tox-collection__group-heading{background-color:#333;color:#fff;cursor:default;font-size:12px;font-style:normal;font-weight:400;margin-bottom:4px;margin-top:-4px;padding:4px 8px;text-transform:none;-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.tox .tox-collection__item{align-items:center;color:#fff;cursor:pointer;display:flex;-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.tox .tox-collection--list .tox-collection__item{padding:4px 8px}.tox .tox-collection--toolbar .tox-collection__item{border-radius:3px;padding:4px}.tox .tox-collection--grid .tox-collection__item{border-radius:3px;padding:4px}.tox .tox-collection--list .tox-collection__item--enabled{background-color:#2b3b4e;color:#fff}.tox .tox-collection--list .tox-collection__item--active{background-color:#4a5562}.tox .tox-collection--toolbar .tox-collection__item--enabled{background-color:#757d87;color:#fff}.tox .tox-collection--toolbar .tox-collection__item--active{background-color:#4a5562}.tox .tox-collection--grid .tox-collection__item--enabled{background-color:#757d87;color:#fff}.tox .tox-collection--grid .tox-collection__item--active:not(.tox-collection__item--state-disabled){background-color:#4a5562;color:#fff}.tox .tox-collection--list .tox-collection__item--active:not(.tox-collection__item--state-disabled){color:#fff}.tox .tox-collection--toolbar .tox-collection__item--active:not(.tox-collection__item--state-disabled){color:#fff}.tox .tox-collection__item-checkmark,.tox .tox-collection__item-icon{align-items:center;display:flex;height:24px;justify-content:center;width:24px}.tox .tox-collection__item-checkmark svg,.tox .tox-collection__item-icon svg{fill:currentColor}.tox .tox-collection--toolbar-lg .tox-collection__item-icon{height:48px;width:48px}.tox .tox-collection__item-label{color:currentColor;display:inline-block;flex:1;-ms-flex-preferred-size:auto;font-size:14px;font-style:normal;font-weight:400;line-height:24px;text-transform:none;word-break:break-all}.tox .tox-collection__item-accessory{color:rgba(255,255,255,.5);display:inline-block;font-size:14px;height:24px;line-height:24px;text-transform:none}.tox .tox-collection__item-caret{align-items:center;display:flex;min-height:24px}.tox .tox-collection__item-caret::after{content:'';font-size:0;min-height:inherit}.tox .tox-collection__item-caret svg{fill:#fff}.tox .tox-collection__item--state-disabled{background-color:transparent;color:rgba(255,255,255,.5);cursor:not-allowed}.tox .tox-collection__item--state-disabled .tox-collection__item-caret svg{fill:rgba(255,255,255,.5)}.tox .tox-collection--list .tox-collection__item:not(.tox-collection__item--enabled) .tox-collection__item-checkmark svg{display:none}.tox .tox-collection--list .tox-collection__item:not(.tox-collection__item--enabled) .tox-collection__item-accessory+.tox-collection__item-checkmark{display:none}.tox .tox-collection--horizontal{background-color:#2b3b4e;border:1px solid #1a1a1a;border-radius:3px;box-shadow:0 1px 3px rgba(0,0,0,.15);display:flex;flex:0 0 auto;flex-shrink:0;flex-wrap:nowrap;margin-bottom:0;overflow-x:auto;padding:0}.tox .tox-collection--horizontal .tox-collection__group{align-items:center;display:flex;flex-wrap:nowrap;margin:0;padding:0 4px}.tox .tox-collection--horizontal .tox-collection__item{height:34px;margin:2px 0 3px 0;padding:0 4px}.tox .tox-collection--horizontal .tox-collection__item-label{white-space:nowrap}.tox .tox-collection--horizontal .tox-collection__item-caret{margin-left:4px}.tox .tox-collection__item-container{display:flex}.tox .tox-collection__item-container--row{align-items:center;flex:1 1 auto;flex-direction:row}.tox .tox-collection__item-container--row.tox-collection__item-container--align-left{margin-right:auto}.tox .tox-collection__item-container--row.tox-collection__item-container--align-right{justify-content:flex-end;margin-left:auto}.tox .tox-collection__item-container--row.tox-collection__item-container--valign-top{align-items:flex-start;margin-bottom:auto}.tox .tox-collection__item-container--row.tox-collection__item-container--valign-middle{align-items:center}.tox .tox-collection__item-container--row.tox-collection__item-container--valign-bottom{align-items:flex-end;margin-top:auto}.tox .tox-collection__item-container--column{-ms-grid-row-align:center;align-self:center;flex:1 1 auto;flex-direction:column}.tox .tox-collection__item-container--column.tox-collection__item-container--align-left{align-items:flex-start}.tox .tox-collection__item-container--column.tox-collection__item-container--align-right{align-items:flex-end}.tox .tox-collection__item-container--column.tox-collection__item-container--valign-top{align-self:flex-start}.tox .tox-collection__item-container--column.tox-collection__item-container--valign-middle{-ms-grid-row-align:center;align-self:center}.tox .tox-collection__item-container--column.tox-collection__item-container--valign-bottom{align-self:flex-end}.tox:not([dir=rtl]) .tox-collection--horizontal .tox-collection__group:not(:last-of-type){border-right:1px solid #000}.tox:not([dir=rtl]) .tox-collection--list .tox-collection__item>:not(:first-child){margin-left:8px}.tox:not([dir=rtl]) .tox-collection--list .tox-collection__item>.tox-collection__item-label:first-child{margin-left:4px}.tox:not([dir=rtl]) .tox-collection__item-accessory{margin-left:16px;text-align:right}.tox:not([dir=rtl]) .tox-collection .tox-collection__item-caret{margin-left:16px}.tox[dir=rtl] .tox-collection--horizontal .tox-collection__group:not(:last-of-type){border-left:1px solid #000}.tox[dir=rtl] .tox-collection--list .tox-collection__item>:not(:first-child){margin-right:8px}.tox[dir=rtl] .tox-collection--list .tox-collection__item>.tox-collection__item-label:first-child{margin-right:4px}.tox[dir=rtl] .tox-collection__item-accessory{margin-right:16px;text-align:left}.tox[dir=rtl] .tox-collection .tox-collection__item-caret{margin-right:16px;transform:rotateY(180deg)}.tox[dir=rtl] .tox-collection--horizontal .tox-collection__item-caret{margin-right:4px}.tox .tox-color-picker-container{display:flex;flex-direction:row;height:225px;margin:0}.tox .tox-sv-palette{box-sizing:border-box;display:flex;height:100%}.tox .tox-sv-palette-spectrum{height:100%}.tox .tox-sv-palette,.tox .tox-sv-palette-spectrum{width:225px}.tox .tox-sv-palette-thumb{background:0 0;border:1px solid #000;border-radius:50%;box-sizing:content-box;height:12px;position:absolute;width:12px}.tox .tox-sv-palette-inner-thumb{border:1px solid #fff;border-radius:50%;height:10px;position:absolute;width:10px}.tox .tox-hue-slider{box-sizing:border-box;height:100%;width:25px}.tox .tox-hue-slider-spectrum{background:linear-gradient(to bottom,red,#ff0080,#f0f,#8000ff,#00f,#0080ff,#0ff,#00ff80,#0f0,#80ff00,#ff0,#ff8000,red);height:100%;width:100%}.tox .tox-hue-slider,.tox .tox-hue-slider-spectrum{width:20px}.tox .tox-hue-slider-thumb{background:#fff;border:1px solid #000;box-sizing:content-box;height:4px;width:100%}.tox .tox-rgb-form{display:flex;flex-direction:column;justify-content:space-between}.tox .tox-rgb-form div{align-items:center;display:flex;justify-content:space-between;margin-bottom:5px;width:inherit}.tox .tox-rgb-form input{width:6em}.tox .tox-rgb-form input.tox-invalid{border:1px solid red!important}.tox .tox-rgb-form .tox-rgba-preview{border:1px solid #000;flex-grow:2;margin-bottom:0}.tox:not([dir=rtl]) .tox-sv-palette{margin-right:15px}.tox:not([dir=rtl]) .tox-hue-slider{margin-right:15px}.tox:not([dir=rtl]) .tox-hue-slider-thumb{margin-left:-1px}.tox:not([dir=rtl]) .tox-rgb-form label{margin-right:.5em}.tox[dir=rtl] .tox-sv-palette{margin-left:15px}.tox[dir=rtl] .tox-hue-slider{margin-left:15px}.tox[dir=rtl] .tox-hue-slider-thumb{margin-right:-1px}.tox[dir=rtl] .tox-rgb-form label{margin-left:.5em}.tox .tox-toolbar .tox-swatches,.tox .tox-toolbar__overflow .tox-swatches,.tox .tox-toolbar__primary .tox-swatches{margin:2px 0 3px 4px}.tox .tox-collection--list .tox-collection__group .tox-swatches-menu{border:0;margin:-4px 0}.tox .tox-swatches__row{display:flex}.tox .tox-swatch{height:30px;transition:transform .15s,box-shadow .15s;width:30px}.tox .tox-swatch:focus,.tox .tox-swatch:hover{box-shadow:0 0 0 1px rgba(127,127,127,.3) inset;transform:scale(.8)}.tox .tox-swatch--remove{align-items:center;display:flex;justify-content:center}.tox .tox-swatch--remove svg path{stroke:#e74c3c}.tox .tox-swatches__picker-btn{align-items:center;background-color:transparent;border:0;cursor:pointer;display:flex;height:30px;justify-content:center;outline:0;padding:0;width:30px}.tox .tox-swatches__picker-btn svg{height:24px;width:24px}.tox .tox-swatches__picker-btn:hover{background:#4a5562}.tox:not([dir=rtl]) .tox-swatches__picker-btn{margin-left:auto}.tox[dir=rtl] .tox-swatches__picker-btn{margin-right:auto}.tox .tox-comment-thread{background:#2b3b4e;position:relative}.tox .tox-comment-thread>:not(:first-child){margin-top:8px}.tox .tox-comment{background:#2b3b4e;border:1px solid #000;border-radius:3px;box-shadow:0 4px 8px 0 rgba(42,55,70,.1);padding:8px 8px 16px 8px;position:relative}.tox .tox-comment__header{align-items:center;color:#fff;display:flex;justify-content:space-between}.tox .tox-comment__date{color:rgba(255,255,255,.5);font-size:12px}.tox .tox-comment__body{color:#fff;font-size:14px;font-style:normal;font-weight:400;line-height:1.3;margin-top:8px;position:relative;text-transform:initial}.tox .tox-comment__body textarea{resize:none;white-space:normal;width:100%}.tox .tox-comment__expander{padding-top:8px}.tox .tox-comment__expander p{color:rgba(255,255,255,.5);font-size:14px;font-style:normal}.tox .tox-comment__body p{margin:0}.tox .tox-comment__buttonspacing{padding-top:16px;text-align:center}.tox .tox-comment-thread__overlay::after{background:#2b3b4e;bottom:0;content:"";display:flex;left:0;opacity:.9;position:absolute;right:0;top:0;z-index:5}.tox .tox-comment__reply{display:flex;flex-shrink:0;flex-wrap:wrap;justify-content:flex-end;margin-top:8px}.tox .tox-comment__reply>:first-child{margin-bottom:8px;width:100%}.tox .tox-comment__edit{display:flex;flex-wrap:wrap;justify-content:flex-end;margin-top:16px}.tox .tox-comment__gradient::after{background:linear-gradient(rgba(43,59,78,0),#2b3b4e);bottom:0;content:"";display:block;height:5em;margin-top:-40px;position:absolute;width:100%}.tox .tox-comment__overlay{background:#2b3b4e;bottom:0;display:flex;flex-direction:column;flex-grow:1;left:0;opacity:.9;position:absolute;right:0;text-align:center;top:0;z-index:5}.tox .tox-comment__loading-text{align-items:center;color:#fff;display:flex;flex-direction:column;position:relative}.tox .tox-comment__loading-text>div{padding-bottom:16px}.tox .tox-comment__overlaytext{bottom:0;flex-direction:column;font-size:14px;left:0;padding:1em;position:absolute;right:0;top:0;z-index:10}.tox .tox-comment__overlaytext p{background-color:#2b3b4e;box-shadow:0 0 8px 8px #2b3b4e;color:#fff;text-align:center}.tox .tox-comment__overlaytext div:nth-of-type(2){font-size:.8em}.tox .tox-comment__busy-spinner{align-items:center;background-color:#2b3b4e;bottom:0;display:flex;justify-content:center;left:0;position:absolute;right:0;top:0;z-index:20}.tox .tox-comment__scroll{display:flex;flex-direction:column;flex-shrink:1;overflow:auto}.tox .tox-conversations{margin:8px}.tox:not([dir=rtl]) .tox-comment__edit{margin-left:8px}.tox:not([dir=rtl]) .tox-comment__buttonspacing>:last-child,.tox:not([dir=rtl]) .tox-comment__edit>:last-child,.tox:not([dir=rtl]) .tox-comment__reply>:last-child{margin-left:8px}.tox[dir=rtl] .tox-comment__edit{margin-right:8px}.tox[dir=rtl] .tox-comment__buttonspacing>:last-child,.tox[dir=rtl] .tox-comment__edit>:last-child,.tox[dir=rtl] .tox-comment__reply>:last-child{margin-right:8px}.tox .tox-user{align-items:center;display:flex}.tox .tox-user__avatar svg{fill:rgba(255,255,255,.5)}.tox .tox-user__name{color:rgba(255,255,255,.5);font-size:12px;font-style:normal;font-weight:700;text-transform:uppercase}.tox:not([dir=rtl]) .tox-user__avatar svg{margin-right:8px}.tox:not([dir=rtl]) .tox-user__avatar+.tox-user__name{margin-left:8px}.tox[dir=rtl] .tox-user__avatar svg{margin-left:8px}.tox[dir=rtl] .tox-user__avatar+.tox-user__name{margin-right:8px}.tox .tox-dialog-wrap{align-items:center;bottom:0;display:flex;justify-content:center;left:0;position:fixed;right:0;top:0;z-index:1100}.tox .tox-dialog-wrap__backdrop{background-color:rgba(34,47,62,.75);bottom:0;left:0;position:absolute;right:0;top:0;z-index:1}.tox .tox-dialog-wrap__backdrop--opaque{background-color:#222f3e}.tox .tox-dialog{background-color:#2b3b4e;border-color:#000;border-radius:3px;border-style:solid;border-width:1px;box-shadow:0 16px 16px -10px rgba(42,55,70,.15),0 0 40px 1px rgba(42,55,70,.15);display:flex;flex-direction:column;max-height:100%;max-width:480px;overflow:hidden;position:relative;width:95vw;z-index:2}@media only screen and (max-width:767px){body:not(.tox-force-desktop) .tox .tox-dialog{align-self:flex-start;margin:8px auto;width:calc(100vw - 16px)}}.tox .tox-dialog-inline{z-index:1100}.tox .tox-dialog__header{align-items:center;background-color:#2b3b4e;border-bottom:none;color:#fff;display:flex;font-size:16px;justify-content:space-between;padding:8px 16px 0 16px;position:relative}.tox .tox-dialog__header .tox-button{z-index:1}.tox .tox-dialog__draghandle{cursor:grab;height:100%;left:0;position:absolute;top:0;width:100%}.tox .tox-dialog__draghandle:active{cursor:grabbing}.tox .tox-dialog__dismiss{margin-left:auto}.tox .tox-dialog__title{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;font-size:20px;font-style:normal;font-weight:400;line-height:1.3;margin:0;text-transform:none}.tox .tox-dialog__body{color:#fff;display:flex;flex:1;-ms-flex-preferred-size:auto;font-size:16px;font-style:normal;font-weight:400;line-height:1.3;min-width:0;text-align:left;text-transform:none}@media only screen and (max-width:767px){body:not(.tox-force-desktop) .tox .tox-dialog__body{flex-direction:column}}.tox .tox-dialog__body-nav{align-items:flex-start;display:flex;flex-direction:column;padding:16px 16px}@media only screen and (max-width:767px){body:not(.tox-force-desktop) .tox .tox-dialog__body-nav{flex-direction:row;-webkit-overflow-scrolling:touch;overflow-x:auto;padding-bottom:0}}.tox .tox-dialog__body-nav-item{border-bottom:2px solid transparent;color:rgba(255,255,255,.5);display:inline-block;font-size:14px;line-height:1.3;margin-bottom:8px;text-decoration:none;white-space:nowrap}.tox .tox-dialog__body-nav-item:focus{background-color:rgba(32,122,183,.1)}.tox .tox-dialog__body-nav-item--active{border-bottom:2px solid #207ab7;color:#207ab7}.tox .tox-dialog__body-content{box-sizing:border-box;display:flex;flex:1;flex-direction:column;-ms-flex-preferred-size:auto;max-height:650px;overflow:auto;-webkit-overflow-scrolling:touch;padding:16px 16px}.tox .tox-dialog__body-content>*{margin-bottom:0;margin-top:16px}.tox .tox-dialog__body-content>:first-child{margin-top:0}.tox .tox-dialog__body-content>:last-child{margin-bottom:0}.tox .tox-dialog__body-content>:only-child{margin-bottom:0;margin-top:0}.tox .tox-dialog__body-content a{color:#207ab7;cursor:pointer;text-decoration:none}.tox .tox-dialog__body-content a:focus,.tox .tox-dialog__body-content a:hover{color:#185d8c;text-decoration:none}.tox .tox-dialog__body-content a:active{color:#185d8c;text-decoration:none}.tox .tox-dialog__body-content svg{fill:#fff}.tox .tox-dialog__body-content ul{display:block;list-style-type:disc;margin-bottom:16px;-webkit-margin-end:0;margin-inline-end:0;-webkit-margin-start:0;margin-inline-start:0;-webkit-padding-start:2.5rem;padding-inline-start:2.5rem}.tox .tox-dialog__body-content .tox-form__group h1{color:#fff;font-size:20px;font-style:normal;font-weight:700;letter-spacing:normal;margin-bottom:16px;margin-top:2rem;text-transform:none}.tox .tox-dialog__body-content .tox-form__group h2{color:#fff;font-size:16px;font-style:normal;font-weight:700;letter-spacing:normal;margin-bottom:16px;margin-top:2rem;text-transform:none}.tox .tox-dialog__body-content .tox-form__group p{margin-bottom:16px}.tox .tox-dialog__body-content .tox-form__group h1:first-child,.tox .tox-dialog__body-content .tox-form__group h2:first-child,.tox .tox-dialog__body-content .tox-form__group p:first-child{margin-top:0}.tox .tox-dialog__body-content .tox-form__group h1:last-child,.tox .tox-dialog__body-content .tox-form__group h2:last-child,.tox .tox-dialog__body-content .tox-form__group p:last-child{margin-bottom:0}.tox .tox-dialog__body-content .tox-form__group h1:only-child,.tox .tox-dialog__body-content .tox-form__group h2:only-child,.tox .tox-dialog__body-content .tox-form__group p:only-child{margin-bottom:0;margin-top:0}.tox .tox-dialog--width-lg{height:650px;max-width:1200px}.tox .tox-dialog--width-md{max-width:800px}.tox .tox-dialog--width-md .tox-dialog__body-content{overflow:auto}.tox .tox-dialog__body-content--centered{text-align:center}.tox .tox-dialog__footer{align-items:center;background-color:#2b3b4e;border-top:1px solid #000;display:flex;justify-content:space-between;padding:8px 16px}.tox .tox-dialog__footer-end,.tox .tox-dialog__footer-start{display:flex}.tox .tox-dialog__busy-spinner{align-items:center;background-color:rgba(34,47,62,.75);bottom:0;display:flex;justify-content:center;left:0;position:absolute;right:0;top:0;z-index:3}.tox .tox-dialog__table{border-collapse:collapse;width:100%}.tox .tox-dialog__table thead th{font-weight:700;padding-bottom:8px}.tox .tox-dialog__table tbody tr{border-bottom:1px solid #000}.tox .tox-dialog__table tbody tr:last-child{border-bottom:none}.tox .tox-dialog__table td{padding-bottom:8px;padding-top:8px}.tox .tox-dialog__popups{position:absolute;width:100%;z-index:1100}.tox .tox-dialog__body-iframe{display:flex;flex:1;flex-direction:column;-ms-flex-preferred-size:auto}.tox .tox-dialog__body-iframe .tox-navobj{display:flex;flex:1;-ms-flex-preferred-size:auto}.tox .tox-dialog__body-iframe .tox-navobj :nth-child(2){flex:1;-ms-flex-preferred-size:auto;height:100%}.tox .tox-dialog-dock-fadeout{opacity:0;visibility:hidden}.tox .tox-dialog-dock-fadein{opacity:1;visibility:visible}.tox .tox-dialog-dock-transition{transition:visibility 0s linear .3s,opacity .3s ease}.tox .tox-dialog-dock-transition.tox-dialog-dock-fadein{transition-delay:0s}.tox.tox-platform-ie .tox-dialog-wrap{position:-ms-device-fixed}@media only screen and (max-width:767px){body:not(.tox-force-desktop) .tox:not([dir=rtl]) .tox-dialog__body-nav{margin-right:0}}@media only screen and (max-width:767px){body:not(.tox-force-desktop) .tox:not([dir=rtl]) .tox-dialog__body-nav-item:not(:first-child){margin-left:8px}}.tox:not([dir=rtl]) .tox-dialog__footer .tox-dialog__footer-end>*,.tox:not([dir=rtl]) .tox-dialog__footer .tox-dialog__footer-start>*{margin-left:8px}.tox[dir=rtl] .tox-dialog__body{text-align:right}@media only screen and (max-width:767px){body:not(.tox-force-desktop) .tox[dir=rtl] .tox-dialog__body-nav{margin-left:0}}@media only screen and (max-width:767px){body:not(.tox-force-desktop) .tox[dir=rtl] .tox-dialog__body-nav-item:not(:first-child){margin-right:8px}}.tox[dir=rtl] .tox-dialog__footer .tox-dialog__footer-end>*,.tox[dir=rtl] .tox-dialog__footer .tox-dialog__footer-start>*{margin-right:8px}body.tox-dialog__disable-scroll{overflow:hidden}.tox .tox-dropzone-container{display:flex;flex:1;-ms-flex-preferred-size:auto}.tox .tox-dropzone{align-items:center;background:#fff;border:2px dashed #000;box-sizing:border-box;display:flex;flex-direction:column;flex-grow:1;justify-content:center;min-height:100px;padding:10px}.tox .tox-dropzone p{color:rgba(255,255,255,.5);margin:0 0 16px 0}.tox .tox-edit-area{display:flex;flex:1;-ms-flex-preferred-size:auto;overflow:hidden;position:relative}.tox .tox-edit-area__iframe{background-color:#fff;border:0;box-sizing:border-box;flex:1;-ms-flex-preferred-size:auto;height:100%;position:absolute;width:100%}.tox.tox-inline-edit-area{border:1px dotted #000}.tox .tox-editor-container{display:flex;flex:1 1 auto;flex-direction:column;overflow:hidden}.tox .tox-editor-header{z-index:1}.tox:not(.tox-tinymce-inline) .tox-editor-header{box-shadow:none;transition:box-shadow .5s}.tox.tox-tinymce--toolbar-bottom .tox-editor-header,.tox.tox-tinymce-inline .tox-editor-header{margin-bottom:-1px}.tox.tox-tinymce--toolbar-sticky-on .tox-editor-header{background-color:transparent;box-shadow:0 4px 4px -3px rgba(0,0,0,.25)}.tox-editor-dock-fadeout{opacity:0;visibility:hidden}.tox-editor-dock-fadein{opacity:1;visibility:visible}.tox-editor-dock-transition{transition:visibility 0s linear .25s,opacity .25s ease}.tox-editor-dock-transition.tox-editor-dock-fadein{transition-delay:0s}.tox .tox-control-wrap{flex:1;position:relative}.tox .tox-control-wrap:not(.tox-control-wrap--status-invalid) .tox-control-wrap__status-icon-invalid,.tox .tox-control-wrap:not(.tox-control-wrap--status-unknown) .tox-control-wrap__status-icon-unknown,.tox .tox-control-wrap:not(.tox-control-wrap--status-valid) .tox-control-wrap__status-icon-valid{display:none}.tox .tox-control-wrap svg{display:block}.tox .tox-control-wrap__status-icon-wrap{position:absolute;top:50%;transform:translateY(-50%)}.tox .tox-control-wrap__status-icon-invalid svg{fill:#c00}.tox .tox-control-wrap__status-icon-unknown svg{fill:orange}.tox .tox-control-wrap__status-icon-valid svg{fill:green}.tox:not([dir=rtl]) .tox-control-wrap--status-invalid .tox-textfield,.tox:not([dir=rtl]) .tox-control-wrap--status-unknown .tox-textfield,.tox:not([dir=rtl]) .tox-control-wrap--status-valid .tox-textfield{padding-right:32px}.tox:not([dir=rtl]) .tox-control-wrap__status-icon-wrap{right:4px}.tox[dir=rtl] .tox-control-wrap--status-invalid .tox-textfield,.tox[dir=rtl] .tox-control-wrap--status-unknown .tox-textfield,.tox[dir=rtl] .tox-control-wrap--status-valid .tox-textfield{padding-left:32px}.tox[dir=rtl] .tox-control-wrap__status-icon-wrap{left:4px}.tox .tox-autocompleter{max-width:25em}.tox .tox-autocompleter .tox-menu{max-width:25em}.tox .tox-autocompleter .tox-autocompleter-highlight{font-weight:700}.tox .tox-color-input{display:flex;position:relative;z-index:1}.tox .tox-color-input .tox-textfield{z-index:-1}.tox .tox-color-input span{border-color:rgba(42,55,70,.2);border-radius:3px;border-style:solid;border-width:1px;box-shadow:none;box-sizing:border-box;height:24px;position:absolute;top:6px;width:24px}.tox .tox-color-input span:focus:not([aria-disabled=true]),.tox .tox-color-input span:hover:not([aria-disabled=true]){border-color:#207ab7;cursor:pointer}.tox .tox-color-input span::before{background-image:linear-gradient(45deg,rgba(255,255,255,.25) 25%,transparent 25%),linear-gradient(-45deg,rgba(255,255,255,.25) 25%,transparent 25%),linear-gradient(45deg,transparent 75%,rgba(255,255,255,.25) 75%),linear-gradient(-45deg,transparent 75%,rgba(255,255,255,.25) 75%);background-position:0 0,0 6px,6px -6px,-6px 0;background-size:12px 12px;border:1px solid #2b3b4e;border-radius:3px;box-sizing:border-box;content:'';height:24px;left:-1px;position:absolute;top:-1px;width:24px;z-index:-1}.tox .tox-color-input span[aria-disabled=true]{cursor:not-allowed}.tox:not([dir=rtl]) .tox-color-input .tox-textfield{padding-left:36px}.tox:not([dir=rtl]) .tox-color-input span{left:6px}.tox[dir=rtl] .tox-color-input .tox-textfield{padding-right:36px}.tox[dir=rtl] .tox-color-input span{right:6px}.tox .tox-label,.tox .tox-toolbar-label{color:rgba(255,255,255,.5);display:block;font-size:14px;font-style:normal;font-weight:400;line-height:1.3;padding:0 8px 0 0;text-transform:none;white-space:nowrap}.tox .tox-toolbar-label{padding:0 8px}.tox[dir=rtl] .tox-label{padding:0 0 0 8px}.tox .tox-form{display:flex;flex:1;flex-direction:column;-ms-flex-preferred-size:auto}.tox .tox-form__group{box-sizing:border-box;margin-bottom:4px}.tox .tox-form-group--maximize{flex:1}.tox .tox-form__group--error{color:#c00}.tox .tox-form__group--collection{display:flex}.tox .tox-form__grid{display:flex;flex-direction:row;flex-wrap:wrap;justify-content:space-between}.tox .tox-form__grid--2col>.tox-form__group{width:calc(50% - (8px / 2))}.tox .tox-form__grid--3col>.tox-form__group{width:calc(100% / 3 - (8px / 2))}.tox .tox-form__grid--4col>.tox-form__group{width:calc(25% - (8px / 2))}.tox .tox-form__controls-h-stack{align-items:center;display:flex}.tox .tox-form__group--inline{align-items:center;display:flex}.tox .tox-form__group--stretched{display:flex;flex:1;flex-direction:column;-ms-flex-preferred-size:auto}.tox .tox-form__group--stretched .tox-textarea{flex:1;-ms-flex-preferred-size:auto}.tox .tox-form__group--stretched .tox-navobj{display:flex;flex:1;-ms-flex-preferred-size:auto}.tox .tox-form__group--stretched .tox-navobj :nth-child(2){flex:1;-ms-flex-preferred-size:auto;height:100%}.tox:not([dir=rtl]) .tox-form__controls-h-stack>:not(:first-child){margin-left:4px}.tox[dir=rtl] .tox-form__controls-h-stack>:not(:first-child){margin-right:4px}.tox .tox-lock.tox-locked .tox-lock-icon__unlock,.tox .tox-lock:not(.tox-locked) .tox-lock-icon__lock{display:none}.tox .tox-listboxfield .tox-listbox--select,.tox .tox-textarea,.tox .tox-textfield,.tox .tox-toolbar-textfield{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#2b3b4e;border-color:#000;border-radius:3px;border-style:solid;border-width:1px;box-shadow:none;box-sizing:border-box;color:#fff;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;font-size:16px;line-height:24px;margin:0;min-height:34px;outline:0;padding:5px 4.75px;resize:none;width:100%}.tox .tox-textarea[disabled],.tox .tox-textfield[disabled]{background-color:#222f3e;color:rgba(255,255,255,.85);cursor:not-allowed}.tox .tox-listboxfield .tox-listbox--select:focus,.tox .tox-textarea:focus,.tox .tox-textfield:focus{background-color:#2b3b4e;border-color:#207ab7;box-shadow:none;outline:0}.tox .tox-toolbar-textfield{border-width:0;margin-bottom:3px;margin-top:2px;max-width:250px}.tox .tox-naked-btn{background-color:transparent;border:0;border-color:transparent;box-shadow:unset;color:#207ab7;cursor:pointer;display:block;margin:0;padding:0}.tox .tox-naked-btn svg{display:block;fill:#fff}.tox:not([dir=rtl]) .tox-toolbar-textfield+*{margin-left:4px}.tox[dir=rtl] .tox-toolbar-textfield+*{margin-right:4px}.tox .tox-listboxfield{cursor:pointer;position:relative}.tox .tox-listboxfield .tox-listbox--select[disabled]{background-color:#19232e;color:rgba(255,255,255,.85);cursor:not-allowed}.tox .tox-listbox__select-label{cursor:default;flex:1;margin:0 4px}.tox .tox-listbox__select-chevron{align-items:center;display:flex;justify-content:center;width:16px}.tox .tox-listbox__select-chevron svg{fill:#fff}.tox .tox-listboxfield .tox-listbox--select{align-items:center;display:flex}.tox:not([dir=rtl]) .tox-listboxfield svg{right:8px}.tox[dir=rtl] .tox-listboxfield svg{left:8px}.tox .tox-selectfield{cursor:pointer;position:relative}.tox .tox-selectfield select{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#2b3b4e;border-color:#000;border-radius:3px;border-style:solid;border-width:1px;box-shadow:none;box-sizing:border-box;color:#fff;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;font-size:16px;line-height:24px;margin:0;min-height:34px;outline:0;padding:5px 4.75px;resize:none;width:100%}.tox .tox-selectfield select[disabled]{background-color:#19232e;color:rgba(255,255,255,.85);cursor:not-allowed}.tox .tox-selectfield select::-ms-expand{display:none}.tox .tox-selectfield select:focus{background-color:#2b3b4e;border-color:#207ab7;box-shadow:none;outline:0}.tox .tox-selectfield svg{pointer-events:none;position:absolute;top:50%;transform:translateY(-50%)}.tox:not([dir=rtl]) .tox-selectfield select[size="0"],.tox:not([dir=rtl]) .tox-selectfield select[size="1"]{padding-right:24px}.tox:not([dir=rtl]) .tox-selectfield svg{right:8px}.tox[dir=rtl] .tox-selectfield select[size="0"],.tox[dir=rtl] .tox-selectfield select[size="1"]{padding-left:24px}.tox[dir=rtl] .tox-selectfield svg{left:8px}.tox .tox-textarea{-webkit-appearance:textarea;-moz-appearance:textarea;appearance:textarea;white-space:pre-wrap}.tox-fullscreen{border:0;height:100%;margin:0;overflow:hidden;-ms-scroll-chaining:none;overscroll-behavior:none;padding:0;touch-action:pinch-zoom;width:100%}.tox.tox-tinymce.tox-fullscreen .tox-statusbar__resize-handle{display:none}.tox-shadowhost.tox-fullscreen,.tox.tox-tinymce.tox-fullscreen{left:0;position:fixed;top:0;z-index:1200}.tox.tox-tinymce.tox-fullscreen{background-color:transparent}.tox-fullscreen .tox.tox-tinymce-aux,.tox-fullscreen~.tox.tox-tinymce-aux{z-index:1201}.tox .tox-help__more-link{list-style:none;margin-top:1em}.tox .tox-image-tools{width:100%}.tox .tox-image-tools__toolbar{align-items:center;display:flex;justify-content:center}.tox .tox-image-tools__image{background-color:#666;height:380px;overflow:auto;position:relative;width:100%}.tox .tox-image-tools__image,.tox .tox-image-tools__image+.tox-image-tools__toolbar{margin-top:8px}.tox .tox-image-tools__image-bg{background:url(data:image/gif;base64,R0lGODdhDAAMAIABAMzMzP///ywAAAAADAAMAAACFoQfqYeabNyDMkBQb81Uat85nxguUAEAOw==)}.tox .tox-image-tools__toolbar>.tox-spacer{flex:1;-ms-flex-preferred-size:auto}.tox .tox-croprect-block{background:#000;opacity:.5;position:absolute;zoom:1}.tox .tox-croprect-handle{border:2px solid #fff;height:20px;left:0;position:absolute;top:0;width:20px}.tox .tox-croprect-handle-move{border:0;cursor:move;position:absolute}.tox .tox-croprect-handle-nw{border-width:2px 0 0 2px;cursor:nw-resize;left:100px;margin:-2px 0 0 -2px;top:100px}.tox .tox-croprect-handle-ne{border-width:2px 2px 0 0;cursor:ne-resize;left:200px;margin:-2px 0 0 -20px;top:100px}.tox .tox-croprect-handle-sw{border-width:0 0 2px 2px;cursor:sw-resize;left:100px;margin:-20px 2px 0 -2px;top:200px}.tox .tox-croprect-handle-se{border-width:0 2px 2px 0;cursor:se-resize;left:200px;margin:-20px 0 0 -20px;top:200px}.tox:not([dir=rtl]) .tox-image-tools__toolbar>.tox-slider:not(:first-of-type){margin-left:8px}.tox:not([dir=rtl]) .tox-image-tools__toolbar>.tox-button+.tox-slider{margin-left:32px}.tox:not([dir=rtl]) .tox-image-tools__toolbar>.tox-slider+.tox-button{margin-left:32px}.tox[dir=rtl] .tox-image-tools__toolbar>.tox-slider:not(:first-of-type){margin-right:8px}.tox[dir=rtl] .tox-image-tools__toolbar>.tox-button+.tox-slider{margin-right:32px}.tox[dir=rtl] .tox-image-tools__toolbar>.tox-slider+.tox-button{margin-right:32px}.tox .tox-insert-table-picker{display:flex;flex-wrap:wrap;width:170px}.tox .tox-insert-table-picker>div{border-color:#000;border-style:solid;border-width:0 1px 1px 0;box-sizing:border-box;height:17px;width:17px}.tox .tox-collection--list .tox-collection__group .tox-insert-table-picker{margin:-4px 0}.tox .tox-insert-table-picker .tox-insert-table-picker__selected{background-color:rgba(32,122,183,.5);border-color:rgba(32,122,183,.5)}.tox .tox-insert-table-picker__label{color:#fff;display:block;font-size:14px;padding:4px;text-align:center;width:100%}.tox:not([dir=rtl]) .tox-insert-table-picker>div:nth-child(10n){border-right:0}.tox[dir=rtl] .tox-insert-table-picker>div:nth-child(10n+1){border-right:0}.tox .tox-menu{background-color:#2b3b4e;border:1px solid #000;border-radius:3px;box-shadow:0 4px 8px 0 rgba(42,55,70,.1);display:inline-block;overflow:hidden;vertical-align:top;z-index:1150}.tox .tox-menu.tox-collection.tox-collection--list{padding:0}.tox .tox-menu.tox-collection.tox-collection--toolbar{padding:4px}.tox .tox-menu.tox-collection.tox-collection--grid{padding:4px}.tox .tox-menu__label blockquote,.tox .tox-menu__label code,.tox .tox-menu__label h1,.tox .tox-menu__label h2,.tox .tox-menu__label h3,.tox .tox-menu__label h4,.tox .tox-menu__label h5,.tox .tox-menu__label h6,.tox .tox-menu__label p{margin:0}.tox .tox-menubar{background:url("data:image/svg+xml;charset=utf8,%3Csvg height='39px' viewBox='0 0 40 39px' width='40' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='0' y='38px' width='100' height='1' fill='%23000000'/%3E%3C/svg%3E") left 0 top 0 #222f3e;background-color:#222f3e;display:flex;flex:0 0 auto;flex-shrink:0;flex-wrap:wrap;padding:0 4px 0 4px}.tox.tox-tinymce:not(.tox-tinymce-inline) .tox-editor-header:not(:first-child) .tox-menubar{border-top:1px solid #000}.tox .tox-mbtn{align-items:center;background:0 0;border:0;border-radius:3px;box-shadow:none;color:#fff;display:flex;flex:0 0 auto;font-size:14px;font-style:normal;font-weight:400;height:34px;justify-content:center;margin:2px 0 3px 0;outline:0;overflow:hidden;padding:0 4px;text-transform:none;width:auto}.tox .tox-mbtn[disabled]{background-color:transparent;border:0;box-shadow:none;color:rgba(255,255,255,.5);cursor:not-allowed}.tox .tox-mbtn:focus:not(:disabled){background:#4a5562;border:0;box-shadow:none;color:#fff}.tox .tox-mbtn--active{background:#757d87;border:0;box-shadow:none;color:#fff}.tox .tox-mbtn:hover:not(:disabled):not(.tox-mbtn--active){background:#4a5562;border:0;box-shadow:none;color:#fff}.tox .tox-mbtn__select-label{cursor:default;font-weight:400;margin:0 4px}.tox .tox-mbtn[disabled] .tox-mbtn__select-label{cursor:not-allowed}.tox .tox-mbtn__select-chevron{align-items:center;display:flex;justify-content:center;width:16px;display:none}.tox .tox-notification{border-radius:3px;border-style:solid;border-width:1px;box-shadow:none;box-sizing:border-box;display:-ms-grid;display:grid;font-size:14px;font-weight:400;-ms-grid-columns:minmax(40px,1fr) auto minmax(40px,1fr);grid-template-columns:minmax(40px,1fr) auto minmax(40px,1fr);margin-top:4px;opacity:0;padding:4px;transition:transform .1s ease-in,opacity 150ms ease-in}.tox .tox-notification p{font-size:14px;font-weight:400}.tox .tox-notification a{cursor:pointer;text-decoration:underline}.tox .tox-notification--in{opacity:1}.tox .tox-notification--success{background-color:#e4eeda;border-color:#d7e6c8;color:#fff}.tox .tox-notification--success p{color:#fff}.tox .tox-notification--success a{color:#547831}.tox .tox-notification--success svg{fill:#fff}.tox .tox-notification--error{background-color:#f8dede;border-color:#f2bfbf;color:#fff}.tox .tox-notification--error p{color:#fff}.tox .tox-notification--error a{color:#c00}.tox .tox-notification--error svg{fill:#fff}.tox .tox-notification--warn,.tox .tox-notification--warning{background-color:#fffaea;border-color:#ffe89d;color:#fff}.tox .tox-notification--warn p,.tox .tox-notification--warning p{color:#fff}.tox .tox-notification--warn a,.tox .tox-notification--warning a{color:#fff}.tox .tox-notification--warn svg,.tox .tox-notification--warning svg{fill:#fff}.tox .tox-notification--info{background-color:#d9edf7;border-color:#779ecb;color:#fff}.tox .tox-notification--info p{color:#fff}.tox .tox-notification--info a{color:#fff}.tox .tox-notification--info svg{fill:#fff}.tox .tox-notification__body{-ms-grid-row-align:center;align-self:center;color:#fff;font-size:14px;-ms-grid-column-span:1;grid-column-end:3;-ms-grid-column:2;grid-column-start:2;-ms-grid-row-span:1;grid-row-end:2;-ms-grid-row:1;grid-row-start:1;text-align:center;white-space:normal;word-break:break-all;word-break:break-word}.tox .tox-notification__body>*{margin:0}.tox .tox-notification__body>*+*{margin-top:1rem}.tox .tox-notification__icon{-ms-grid-row-align:center;align-self:center;-ms-grid-column-span:1;grid-column-end:2;-ms-grid-column:1;grid-column-start:1;-ms-grid-row-span:1;grid-row-end:2;-ms-grid-row:1;grid-row-start:1;-ms-grid-column-align:end;justify-self:end}.tox .tox-notification__icon svg{display:block}.tox .tox-notification__dismiss{-ms-grid-row-align:start;align-self:start;-ms-grid-column-span:1;grid-column-end:4;-ms-grid-column:3;grid-column-start:3;-ms-grid-row-span:1;grid-row-end:2;-ms-grid-row:1;grid-row-start:1;-ms-grid-column-align:end;justify-self:end}.tox .tox-notification .tox-progress-bar{-ms-grid-column-span:3;grid-column-end:4;-ms-grid-column:1;grid-column-start:1;-ms-grid-row-span:1;grid-row-end:3;-ms-grid-row:2;grid-row-start:2;-ms-grid-column-align:center;justify-self:center}.tox .tox-pop{display:inline-block;position:relative}.tox .tox-pop--resizing{transition:width .1s ease}.tox .tox-pop--resizing .tox-toolbar,.tox .tox-pop--resizing .tox-toolbar__group{flex-wrap:nowrap}.tox .tox-pop--transition{transition:.15s ease;transition-property:left,right,top,bottom}.tox .tox-pop--transition::after,.tox .tox-pop--transition::before{transition:all .15s,visibility 0s,opacity 75ms ease 75ms}.tox .tox-pop__dialog{background-color:#222f3e;border:1px solid #000;border-radius:3px;box-shadow:0 1px 3px rgba(0,0,0,.15);min-width:0;overflow:hidden}.tox .tox-pop__dialog>:not(.tox-toolbar){margin:4px 4px 4px 8px}.tox .tox-pop__dialog .tox-toolbar{background-color:transparent;margin-bottom:-1px}.tox .tox-pop::after,.tox .tox-pop::before{border-style:solid;content:'';display:block;height:0;opacity:1;position:absolute;width:0}.tox .tox-pop.tox-pop--inset::after,.tox .tox-pop.tox-pop--inset::before{opacity:0;transition:all 0s .15s,visibility 0s,opacity 75ms ease}.tox .tox-pop.tox-pop--bottom::after,.tox .tox-pop.tox-pop--bottom::before{left:50%;top:100%}.tox .tox-pop.tox-pop--bottom::after{border-color:#222f3e transparent transparent transparent;border-width:8px;margin-left:-8px;margin-top:-1px}.tox .tox-pop.tox-pop--bottom::before{border-color:#000 transparent transparent transparent;border-width:9px;margin-left:-9px}.tox .tox-pop.tox-pop--top::after,.tox .tox-pop.tox-pop--top::before{left:50%;top:0;transform:translateY(-100%)}.tox .tox-pop.tox-pop--top::after{border-color:transparent transparent #222f3e transparent;border-width:8px;margin-left:-8px;margin-top:1px}.tox .tox-pop.tox-pop--top::before{border-color:transparent transparent #000 transparent;border-width:9px;margin-left:-9px}.tox .tox-pop.tox-pop--left::after,.tox .tox-pop.tox-pop--left::before{left:0;top:calc(50% - 1px);transform:translateY(-50%)}.tox .tox-pop.tox-pop--left::after{border-color:transparent #222f3e transparent transparent;border-width:8px;margin-left:-15px}.tox .tox-pop.tox-pop--left::before{border-color:transparent #000 transparent transparent;border-width:10px;margin-left:-19px}.tox .tox-pop.tox-pop--right::after,.tox .tox-pop.tox-pop--right::before{left:100%;top:calc(50% + 1px);transform:translateY(-50%)}.tox .tox-pop.tox-pop--right::after{border-color:transparent transparent transparent #222f3e;border-width:8px;margin-left:-1px}.tox .tox-pop.tox-pop--right::before{border-color:transparent transparent transparent #000;border-width:10px;margin-left:-1px}.tox .tox-pop.tox-pop--align-left::after,.tox .tox-pop.tox-pop--align-left::before{left:20px}.tox .tox-pop.tox-pop--align-right::after,.tox .tox-pop.tox-pop--align-right::before{left:calc(100% - 20px)}.tox .tox-sidebar-wrap{display:flex;flex-direction:row;flex-grow:1;-ms-flex-preferred-size:0;min-height:0}.tox .tox-sidebar{background-color:#222f3e;display:flex;flex-direction:row;justify-content:flex-end}.tox .tox-sidebar__slider{display:flex;overflow:hidden}.tox .tox-sidebar__pane-container{display:flex}.tox .tox-sidebar__pane{display:flex}.tox .tox-sidebar--sliding-closed{opacity:0}.tox .tox-sidebar--sliding-open{opacity:1}.tox .tox-sidebar--sliding-growing,.tox .tox-sidebar--sliding-shrinking{transition:width .5s ease,opacity .5s ease}.tox .tox-selector{background-color:#4099ff;border-color:#4099ff;border-style:solid;border-width:1px;box-sizing:border-box;display:inline-block;height:10px;position:absolute;width:10px}.tox.tox-platform-touch .tox-selector{height:12px;width:12px}.tox .tox-slider{align-items:center;display:flex;flex:1;-ms-flex-preferred-size:auto;height:24px;justify-content:center;position:relative}.tox .tox-slider__rail{background-color:transparent;border:1px solid #000;border-radius:3px;height:10px;min-width:120px;width:100%}.tox .tox-slider__handle{background-color:#207ab7;border:2px solid #185d8c;border-radius:3px;box-shadow:none;height:24px;left:50%;position:absolute;top:50%;transform:translateX(-50%) translateY(-50%);width:14px}.tox .tox-source-code{overflow:auto}.tox .tox-spinner{display:flex}.tox .tox-spinner>div{animation:tam-bouncing-dots 1.5s ease-in-out 0s infinite both;background-color:rgba(255,255,255,.5);border-radius:100%;height:8px;width:8px}.tox .tox-spinner>div:nth-child(1){animation-delay:-.32s}.tox .tox-spinner>div:nth-child(2){animation-delay:-.16s}@keyframes tam-bouncing-dots{0%,100%,80%{transform:scale(0)}40%{transform:scale(1)}}.tox:not([dir=rtl]) .tox-spinner>div:not(:first-child){margin-left:4px}.tox[dir=rtl] .tox-spinner>div:not(:first-child){margin-right:4px}.tox .tox-statusbar{align-items:center;background-color:#222f3e;border-top:1px solid #000;color:#fff;display:flex;flex:0 0 auto;font-size:12px;font-weight:400;height:18px;overflow:hidden;padding:0 8px;position:relative;text-transform:uppercase}.tox .tox-statusbar__text-container{display:flex;flex:1 1 auto;justify-content:flex-end;overflow:hidden}.tox .tox-statusbar__path{display:flex;flex:1 1 auto;margin-right:auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tox .tox-statusbar__path>*{display:inline;white-space:nowrap}.tox .tox-statusbar__wordcount{flex:0 0 auto;margin-left:1ch}.tox .tox-statusbar a,.tox .tox-statusbar__path-item,.tox .tox-statusbar__wordcount{color:#fff;text-decoration:none}.tox .tox-statusbar a:focus:not(:disabled):not([aria-disabled=true]),.tox .tox-statusbar a:hover:not(:disabled):not([aria-disabled=true]),.tox .tox-statusbar__path-item:focus:not(:disabled):not([aria-disabled=true]),.tox .tox-statusbar__path-item:hover:not(:disabled):not([aria-disabled=true]),.tox .tox-statusbar__wordcount:focus:not(:disabled):not([aria-disabled=true]),.tox .tox-statusbar__wordcount:hover:not(:disabled):not([aria-disabled=true]){cursor:pointer;text-decoration:underline}.tox .tox-statusbar__resize-handle{align-items:flex-end;align-self:stretch;cursor:nwse-resize;display:flex;flex:0 0 auto;justify-content:flex-end;margin-left:auto;margin-right:-8px;padding-left:1ch}.tox .tox-statusbar__resize-handle svg{display:block;fill:#fff}.tox .tox-statusbar__resize-handle:focus svg{background-color:#4a5562;border-radius:1px;box-shadow:0 0 0 2px #4a5562}.tox:not([dir=rtl]) .tox-statusbar__path>*{margin-right:4px}.tox:not([dir=rtl]) .tox-statusbar__branding{margin-left:1ch}.tox[dir=rtl] .tox-statusbar{flex-direction:row-reverse}.tox[dir=rtl] .tox-statusbar__path>*{margin-left:4px}.tox .tox-throbber{z-index:1299}.tox .tox-throbber__busy-spinner{align-items:center;background-color:rgba(34,47,62,.6);bottom:0;display:flex;justify-content:center;left:0;position:absolute;right:0;top:0}.tox .tox-tbtn{align-items:center;background:0 0;border:0;border-radius:3px;box-shadow:none;color:#fff;display:flex;flex:0 0 auto;font-size:14px;font-style:normal;font-weight:400;height:34px;justify-content:center;margin:2px 0 3px 0;outline:0;overflow:hidden;padding:0;text-transform:none;width:34px}.tox .tox-tbtn svg{display:block;fill:#fff}.tox .tox-tbtn.tox-tbtn-more{padding-left:5px;padding-right:5px;width:inherit}.tox .tox-tbtn:focus{background:#4a5562;border:0;box-shadow:none}.tox .tox-tbtn:hover{background:#4a5562;border:0;box-shadow:none;color:#fff}.tox .tox-tbtn:hover svg{fill:#fff}.tox .tox-tbtn:active{background:#757d87;border:0;box-shadow:none;color:#fff}.tox .tox-tbtn:active svg{fill:#fff}.tox .tox-tbtn--disabled,.tox .tox-tbtn--disabled:hover,.tox .tox-tbtn:disabled,.tox .tox-tbtn:disabled:hover{background:0 0;border:0;box-shadow:none;color:rgba(255,255,255,.5);cursor:not-allowed}.tox .tox-tbtn--disabled svg,.tox .tox-tbtn--disabled:hover svg,.tox .tox-tbtn:disabled svg,.tox .tox-tbtn:disabled:hover svg{fill:rgba(255,255,255,.5)}.tox .tox-tbtn--enabled,.tox .tox-tbtn--enabled:hover{background:#757d87;border:0;box-shadow:none;color:#fff}.tox .tox-tbtn--enabled:hover>*,.tox .tox-tbtn--enabled>*{transform:none}.tox .tox-tbtn--enabled svg,.tox .tox-tbtn--enabled:hover svg{fill:#fff}.tox .tox-tbtn:focus:not(.tox-tbtn--disabled){color:#fff}.tox .tox-tbtn:focus:not(.tox-tbtn--disabled) svg{fill:#fff}.tox .tox-tbtn:active>*{transform:none}.tox .tox-tbtn--md{height:51px;width:51px}.tox .tox-tbtn--lg{flex-direction:column;height:68px;width:68px}.tox .tox-tbtn--return{-ms-grid-row-align:stretch;align-self:stretch;height:unset;width:16px}.tox .tox-tbtn--labeled{padding:0 4px;width:unset}.tox .tox-tbtn__vlabel{display:block;font-size:10px;font-weight:400;letter-spacing:-.025em;margin-bottom:4px;white-space:nowrap}.tox .tox-tbtn--select{margin:2px 0 3px 0;padding:0 4px;width:auto}.tox .tox-tbtn__select-label{cursor:default;font-weight:400;margin:0 4px}.tox .tox-tbtn__select-chevron{align-items:center;display:flex;justify-content:center;width:16px}.tox .tox-tbtn__select-chevron svg{fill:rgba(255,255,255,.5)}.tox .tox-tbtn--bespoke .tox-tbtn__select-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;width:7em}.tox .tox-split-button{border:0;border-radius:3px;box-sizing:border-box;display:flex;margin:2px 0 3px 0;overflow:hidden}.tox .tox-split-button:hover{box-shadow:0 0 0 1px #4a5562 inset}.tox .tox-split-button:focus{background:#4a5562;box-shadow:none;color:#fff}.tox .tox-split-button>*{border-radius:0}.tox .tox-split-button__chevron{width:16px}.tox .tox-split-button__chevron svg{fill:rgba(255,255,255,.5)}.tox .tox-split-button .tox-tbtn{margin:0}.tox.tox-platform-touch .tox-split-button .tox-tbtn:first-child{width:30px}.tox.tox-platform-touch .tox-split-button__chevron{width:20px}.tox .tox-split-button.tox-tbtn--disabled .tox-tbtn:focus,.tox .tox-split-button.tox-tbtn--disabled .tox-tbtn:hover,.tox .tox-split-button.tox-tbtn--disabled:focus,.tox .tox-split-button.tox-tbtn--disabled:hover{background:0 0;box-shadow:none;color:rgba(255,255,255,.5)}.tox .tox-toolbar-overlord{background-color:#222f3e}.tox .tox-toolbar,.tox .tox-toolbar__overflow,.tox .tox-toolbar__primary{background:url("data:image/svg+xml;charset=utf8,%3Csvg height='39px' viewBox='0 0 40 39px' width='40' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='0' y='38px' width='100' height='1' fill='%23000000'/%3E%3C/svg%3E") left 0 top 0 #222f3e;background-color:#222f3e;display:flex;flex:0 0 auto;flex-shrink:0;flex-wrap:wrap;padding:0 0}.tox .tox-toolbar__overflow.tox-toolbar__overflow--closed{height:0;opacity:0;padding-bottom:0;padding-top:0;visibility:hidden}.tox .tox-toolbar__overflow--growing{transition:height .3s ease,opacity .2s linear .1s}.tox .tox-toolbar__overflow--shrinking{transition:opacity .3s ease,height .2s linear .1s,visibility 0s linear .3s}.tox .tox-menubar+.tox-toolbar,.tox .tox-menubar+.tox-toolbar-overlord .tox-toolbar__primary{border-top:1px solid #000;margin-top:-1px}.tox .tox-toolbar--scrolling{flex-wrap:nowrap;overflow-x:auto}.tox .tox-pop .tox-toolbar{border-width:0}.tox .tox-toolbar--no-divider{background-image:none}.tox-tinymce:not(.tox-tinymce-inline) .tox-editor-header:not(:first-child) .tox-toolbar-overlord:first-child .tox-toolbar__primary,.tox-tinymce:not(.tox-tinymce-inline) .tox-editor-header:not(:first-child) .tox-toolbar:first-child{border-top:1px solid #000}.tox.tox-tinymce-aux .tox-toolbar__overflow{background-color:#222f3e;border:1px solid #000;border-radius:3px;box-shadow:0 1px 3px rgba(0,0,0,.15)}.tox .tox-toolbar__group{align-items:center;display:flex;flex-wrap:wrap;margin:0 0;padding:0 4px 0 4px}.tox .tox-toolbar__group--pull-right{margin-left:auto}.tox .tox-toolbar--scrolling .tox-toolbar__group{flex-shrink:0;flex-wrap:nowrap}.tox:not([dir=rtl]) .tox-toolbar__group:not(:last-of-type){border-right:1px solid #000}.tox[dir=rtl] .tox-toolbar__group:not(:last-of-type){border-left:1px solid #000}.tox .tox-tooltip{display:inline-block;padding:8px;position:relative}.tox .tox-tooltip__body{background-color:#3d546f;border-radius:3px;box-shadow:0 2px 4px rgba(42,55,70,.3);color:rgba(255,255,255,.75);font-size:14px;font-style:normal;font-weight:400;padding:4px 8px;text-transform:none}.tox .tox-tooltip__arrow{position:absolute}.tox .tox-tooltip--down .tox-tooltip__arrow{border-left:8px solid transparent;border-right:8px solid transparent;border-top:8px solid #3d546f;bottom:0;left:50%;position:absolute;transform:translateX(-50%)}.tox .tox-tooltip--up .tox-tooltip__arrow{border-bottom:8px solid #3d546f;border-left:8px solid transparent;border-right:8px solid transparent;left:50%;position:absolute;top:0;transform:translateX(-50%)}.tox .tox-tooltip--right .tox-tooltip__arrow{border-bottom:8px solid transparent;border-left:8px solid #3d546f;border-top:8px solid transparent;position:absolute;right:0;top:50%;transform:translateY(-50%)}.tox .tox-tooltip--left .tox-tooltip__arrow{border-bottom:8px solid transparent;border-right:8px solid #3d546f;border-top:8px solid transparent;left:0;position:absolute;top:50%;transform:translateY(-50%)}.tox .tox-well{border:1px solid #000;border-radius:3px;padding:8px;width:100%}.tox .tox-well>:first-child{margin-top:0}.tox .tox-well>:last-child{margin-bottom:0}.tox .tox-well>:only-child{margin:0}.tox .tox-custom-editor{border:1px solid #000;border-radius:3px;display:flex;flex:1;position:relative}.tox .tox-dialog-loading::before{background-color:rgba(0,0,0,.5);content:"";height:100%;position:absolute;width:100%;z-index:1000}.tox .tox-tab{cursor:pointer}.tox .tox-dialog__content-js{display:flex;flex:1;-ms-flex-preferred-size:auto}.tox .tox-dialog__body-content .tox-collection{display:flex;flex:1;-ms-flex-preferred-size:auto}.tox .tox-image-tools-edit-panel{height:60px}.tox .tox-image-tools__sidebar{height:60px}
diff --git a/public/tinymce/skins/ui/oxide-dark/skin.mobile.css b/public/tinymce/skins/ui/oxide-dark/skin.mobile.css
new file mode 100644
index 0000000..875721a
--- /dev/null
+++ b/public/tinymce/skins/ui/oxide-dark/skin.mobile.css
@@ -0,0 +1,673 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+/* RESET all the things! */
+.tinymce-mobile-outer-container {
+  all: initial;
+  display: block;
+}
+.tinymce-mobile-outer-container * {
+  border: 0;
+  box-sizing: initial;
+  cursor: inherit;
+  float: none;
+  line-height: 1;
+  margin: 0;
+  outline: 0;
+  padding: 0;
+  -webkit-tap-highlight-color: transparent;
+  /* TBIO-3691, stop the gray flicker on touch. */
+  text-shadow: none;
+  white-space: nowrap;
+}
+.tinymce-mobile-icon-arrow-back::before {
+  content: "\e5cd";
+}
+.tinymce-mobile-icon-image::before {
+  content: "\e412";
+}
+.tinymce-mobile-icon-cancel-circle::before {
+  content: "\e5c9";
+}
+.tinymce-mobile-icon-full-dot::before {
+  content: "\e061";
+}
+.tinymce-mobile-icon-align-center::before {
+  content: "\e234";
+}
+.tinymce-mobile-icon-align-left::before {
+  content: "\e236";
+}
+.tinymce-mobile-icon-align-right::before {
+  content: "\e237";
+}
+.tinymce-mobile-icon-bold::before {
+  content: "\e238";
+}
+.tinymce-mobile-icon-italic::before {
+  content: "\e23f";
+}
+.tinymce-mobile-icon-unordered-list::before {
+  content: "\e241";
+}
+.tinymce-mobile-icon-ordered-list::before {
+  content: "\e242";
+}
+.tinymce-mobile-icon-font-size::before {
+  content: "\e245";
+}
+.tinymce-mobile-icon-underline::before {
+  content: "\e249";
+}
+.tinymce-mobile-icon-link::before {
+  content: "\e157";
+}
+.tinymce-mobile-icon-unlink::before {
+  content: "\eca2";
+}
+.tinymce-mobile-icon-color::before {
+  content: "\e891";
+}
+.tinymce-mobile-icon-previous::before {
+  content: "\e314";
+}
+.tinymce-mobile-icon-next::before {
+  content: "\e315";
+}
+.tinymce-mobile-icon-large-font::before,
+.tinymce-mobile-icon-style-formats::before {
+  content: "\e264";
+}
+.tinymce-mobile-icon-undo::before {
+  content: "\e166";
+}
+.tinymce-mobile-icon-redo::before {
+  content: "\e15a";
+}
+.tinymce-mobile-icon-removeformat::before {
+  content: "\e239";
+}
+.tinymce-mobile-icon-small-font::before {
+  content: "\e906";
+}
+.tinymce-mobile-icon-readonly-back::before,
+.tinymce-mobile-format-matches::after {
+  content: "\e5ca";
+}
+.tinymce-mobile-icon-small-heading::before {
+  content: "small";
+}
+.tinymce-mobile-icon-large-heading::before {
+  content: "large";
+}
+.tinymce-mobile-icon-small-heading::before,
+.tinymce-mobile-icon-large-heading::before {
+  font-family: sans-serif;
+  font-size: 80%;
+}
+.tinymce-mobile-mask-edit-icon::before {
+  content: "\e254";
+}
+.tinymce-mobile-icon-back::before {
+  content: "\e5c4";
+}
+.tinymce-mobile-icon-heading::before {
+  /* TODO: Translate */
+  content: "Headings";
+  font-family: sans-serif;
+  font-size: 80%;
+  font-weight: bold;
+}
+.tinymce-mobile-icon-h1::before {
+  content: "H1";
+  font-weight: bold;
+}
+.tinymce-mobile-icon-h2::before {
+  content: "H2";
+  font-weight: bold;
+}
+.tinymce-mobile-icon-h3::before {
+  content: "H3";
+  font-weight: bold;
+}
+.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask {
+  align-items: center;
+  display: flex;
+  justify-content: center;
+  background: rgba(51, 51, 51, 0.5);
+  height: 100%;
+  position: absolute;
+  top: 0;
+  width: 100%;
+}
+.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container {
+  align-items: center;
+  border-radius: 50%;
+  display: flex;
+  flex-direction: column;
+  font-family: sans-serif;
+  font-size: 1em;
+  justify-content: space-between;
+}
+.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .mixin-menu-item {
+  align-items: center;
+  display: flex;
+  justify-content: center;
+  border-radius: 50%;
+  height: 2.1em;
+  width: 2.1em;
+}
+.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section {
+  align-items: center;
+  display: flex;
+  justify-content: center;
+  flex-direction: column;
+  font-size: 1em;
+}
+@media only screen and (min-device-width:700px) {
+  .tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section {
+    font-size: 1.2em;
+  }
+}
+.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section .tinymce-mobile-mask-tap-icon {
+  align-items: center;
+  display: flex;
+  justify-content: center;
+  border-radius: 50%;
+  height: 2.1em;
+  width: 2.1em;
+  background-color: white;
+  color: #207ab7;
+}
+.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section .tinymce-mobile-mask-tap-icon::before {
+  content: "\e900";
+  font-family: 'tinymce-mobile', sans-serif;
+}
+.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section:not(.tinymce-mobile-mask-tap-icon-selected) .tinymce-mobile-mask-tap-icon {
+  z-index: 2;
+}
+.tinymce-mobile-android-container.tinymce-mobile-android-maximized {
+  background: #ffffff;
+  border: none;
+  bottom: 0;
+  display: flex;
+  flex-direction: column;
+  left: 0;
+  position: fixed;
+  right: 0;
+  top: 0;
+}
+.tinymce-mobile-android-container:not(.tinymce-mobile-android-maximized) {
+  position: relative;
+}
+.tinymce-mobile-android-container .tinymce-mobile-editor-socket {
+  display: flex;
+  flex-grow: 1;
+}
+.tinymce-mobile-android-container .tinymce-mobile-editor-socket iframe {
+  display: flex !important;
+  flex-grow: 1;
+  height: auto !important;
+}
+.tinymce-mobile-android-scroll-reload {
+  overflow: hidden;
+}
+:not(.tinymce-mobile-readonly-mode) > .tinymce-mobile-android-selection-context-toolbar {
+  margin-top: 23px;
+}
+.tinymce-mobile-toolstrip {
+  background: #fff;
+  display: flex;
+  flex: 0 0 auto;
+  z-index: 1;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar {
+  align-items: center;
+  background-color: #fff;
+  border-bottom: 1px solid #cccccc;
+  display: flex;
+  flex: 1;
+  height: 2.5em;
+  width: 100%;
+  /* Make it no larger than the toolstrip, so that it needs to scroll */
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group {
+  align-items: center;
+  display: flex;
+  height: 100%;
+  flex-shrink: 1;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group > div {
+  align-items: center;
+  display: flex;
+  height: 100%;
+  flex: 1;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group.tinymce-mobile-exit-container {
+  background: #f44336;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group.tinymce-mobile-toolbar-scrollable-group {
+  flex-grow: 1;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item {
+  padding-left: 0.5em;
+  padding-right: 0.5em;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item.tinymce-mobile-toolbar-button {
+  align-items: center;
+  display: flex;
+  height: 80%;
+  margin-left: 2px;
+  margin-right: 2px;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item.tinymce-mobile-toolbar-button.tinymce-mobile-toolbar-button-selected {
+  background: #c8cbcf;
+  color: #cccccc;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group:first-of-type,
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group:last-of-type {
+  background: #207ab7;
+  color: #eceff1;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar {
+  /* Note, this file is imported inside .tinymce-mobile-context-toolbar, so that prefix is on everything here. */
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group {
+  align-items: center;
+  display: flex;
+  height: 100%;
+  flex: 1;
+  padding-bottom: 0.4em;
+  padding-top: 0.4em;
+  /* Make any buttons appearing on the left and right display in the centre (e.g. color edges) */
+  /* For widgets like the colour picker, use the whole height */
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog {
+  display: flex;
+  min-height: 1.5em;
+  overflow: hidden;
+  padding-left: 0;
+  padding-right: 0;
+  position: relative;
+  width: 100%;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain {
+  display: flex;
+  height: 100%;
+  transition: left cubic-bezier(0.4, 0, 1, 1) 0.15s;
+  width: 100%;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen {
+  display: flex;
+  flex: 0 0 auto;
+  justify-content: space-between;
+  width: 100%;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen input {
+  font-family: Sans-serif;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-input-container {
+  display: flex;
+  flex-grow: 1;
+  position: relative;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-input-container .tinymce-mobile-input-container-x {
+  -ms-grid-row-align: center;
+      align-self: center;
+  background: inherit;
+  border: none;
+  border-radius: 50%;
+  color: #888;
+  font-size: 0.6em;
+  font-weight: bold;
+  height: 100%;
+  padding-right: 2px;
+  position: absolute;
+  right: 0;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-input-container.tinymce-mobile-input-container-empty .tinymce-mobile-input-container-x {
+  display: none;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-previous,
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-next {
+  align-items: center;
+  display: flex;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-previous::before,
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-next::before {
+  align-items: center;
+  display: flex;
+  font-weight: bold;
+  height: 100%;
+  padding-left: 0.5em;
+  padding-right: 0.5em;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-previous.tinymce-mobile-toolbar-navigation-disabled::before,
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-next.tinymce-mobile-toolbar-navigation-disabled::before {
+  visibility: hidden;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-dot-item {
+  color: #cccccc;
+  font-size: 10px;
+  line-height: 10px;
+  margin: 0 2px;
+  padding-top: 3px;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-dot-item.tinymce-mobile-dot-active {
+  color: #c8cbcf;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-icon-large-font::before,
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-icon-large-heading::before {
+  margin-left: 0.5em;
+  margin-right: 0.9em;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-icon-small-font::before,
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-icon-small-heading::before {
+  margin-left: 0.9em;
+  margin-right: 0.5em;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider {
+  display: flex;
+  flex: 1;
+  margin-left: 0;
+  margin-right: 0;
+  padding: 0.28em 0;
+  position: relative;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider .tinymce-mobile-slider-size-container {
+  align-items: center;
+  display: flex;
+  flex-grow: 1;
+  height: 100%;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider .tinymce-mobile-slider-size-container .tinymce-mobile-slider-size-line {
+  background: #cccccc;
+  display: flex;
+  flex: 1;
+  height: 0.2em;
+  margin-bottom: 0.3em;
+  margin-top: 0.3em;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container {
+  padding-left: 2em;
+  padding-right: 2em;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container .tinymce-mobile-slider-gradient-container {
+  align-items: center;
+  display: flex;
+  flex-grow: 1;
+  height: 100%;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container .tinymce-mobile-slider-gradient-container .tinymce-mobile-slider-gradient {
+  background: linear-gradient(to right, hsl(0, 100%, 50%) 0%, hsl(60, 100%, 50%) 17%, hsl(120, 100%, 50%) 33%, hsl(180, 100%, 50%) 50%, hsl(240, 100%, 50%) 67%, hsl(300, 100%, 50%) 83%, hsl(0, 100%, 50%) 100%);
+  display: flex;
+  flex: 1;
+  height: 0.2em;
+  margin-bottom: 0.3em;
+  margin-top: 0.3em;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container .tinymce-mobile-hue-slider-black {
+  /* Not part of theming */
+  background: black;
+  height: 0.2em;
+  margin-bottom: 0.3em;
+  margin-top: 0.3em;
+  width: 1.2em;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container .tinymce-mobile-hue-slider-white {
+  /* Not part of theming */
+  background: white;
+  height: 0.2em;
+  margin-bottom: 0.3em;
+  margin-top: 0.3em;
+  width: 1.2em;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider .tinymce-mobile-slider-thumb {
+  /* vertically centering trick (margin: auto, top: 0, bottom: 0). On iOS and Safari, if you leave
+     * out these values, then it shows the thumb at the top of the spectrum. This is probably because it is
+     * absolutely positioned with only a left value, and not a top. Note, on Chrome it seems to be fine without
+     * this approach.
+    */
+  align-items: center;
+  background-clip: padding-box;
+  background-color: #455a64;
+  border: 0.5em solid rgba(136, 136, 136, 0);
+  border-radius: 3em;
+  bottom: 0;
+  color: #fff;
+  display: flex;
+  height: 0.5em;
+  justify-content: center;
+  left: -10px;
+  margin: auto;
+  position: absolute;
+  top: 0;
+  transition: border 120ms cubic-bezier(0.39, 0.58, 0.57, 1);
+  width: 0.5em;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider .tinymce-mobile-slider-thumb.tinymce-mobile-thumb-active {
+  border: 0.5em solid rgba(136, 136, 136, 0.39);
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serializer-wrapper,
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group > div {
+  align-items: center;
+  display: flex;
+  height: 100%;
+  flex: 1;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serializer-wrapper {
+  flex-direction: column;
+  justify-content: center;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item {
+  align-items: center;
+  display: flex;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item:not(.tinymce-mobile-serialised-dialog) {
+  height: 100%;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-dot-container {
+  display: flex;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group input {
+  background: #ffffff;
+  border: none;
+  border-radius: 0;
+  color: #455a64;
+  flex-grow: 1;
+  font-size: 0.85em;
+  padding-bottom: 0.1em;
+  padding-left: 5px;
+  padding-top: 0.1em;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group input::-webkit-input-placeholder {
+  /* WebKit, Blink, Edge */
+  color: #888;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group input::placeholder {
+  /* WebKit, Blink, Edge */
+  color: #888;
+}
+/* dropup */
+.tinymce-mobile-dropup {
+  background: white;
+  display: flex;
+  overflow: hidden;
+  width: 100%;
+}
+.tinymce-mobile-dropup.tinymce-mobile-dropup-shrinking {
+  transition: height 0.3s ease-out;
+}
+.tinymce-mobile-dropup.tinymce-mobile-dropup-growing {
+  transition: height 0.3s ease-in;
+}
+.tinymce-mobile-dropup.tinymce-mobile-dropup-closed {
+  flex-grow: 0;
+}
+.tinymce-mobile-dropup.tinymce-mobile-dropup-open:not(.tinymce-mobile-dropup-growing) {
+  flex-grow: 1;
+}
+/* TODO min-height for device size and orientation */
+.tinymce-mobile-ios-container .tinymce-mobile-dropup:not(.tinymce-mobile-dropup-closed) {
+  min-height: 200px;
+}
+@media only screen and (orientation: landscape) {
+  .tinymce-mobile-dropup:not(.tinymce-mobile-dropup-closed) {
+    min-height: 200px;
+  }
+}
+@media only screen and (min-device-width : 320px) and (max-device-width : 568px) and (orientation : landscape) {
+  .tinymce-mobile-ios-container .tinymce-mobile-dropup:not(.tinymce-mobile-dropup-closed) {
+    min-height: 150px;
+  }
+}
+/* styles menu */
+.tinymce-mobile-styles-menu {
+  font-family: sans-serif;
+  outline: 4px solid black;
+  overflow: hidden;
+  position: relative;
+  width: 100%;
+}
+.tinymce-mobile-styles-menu [role="menu"] {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  position: absolute;
+  width: 100%;
+}
+.tinymce-mobile-styles-menu [role="menu"].transitioning {
+  transition: transform 0.5s ease-in-out;
+}
+.tinymce-mobile-styles-menu .tinymce-mobile-styles-item {
+  border-bottom: 1px solid #ddd;
+  color: #455a64;
+  cursor: pointer;
+  display: flex;
+  padding: 1em 1em;
+  position: relative;
+}
+.tinymce-mobile-styles-menu .tinymce-mobile-styles-collapser .tinymce-mobile-styles-collapse-icon::before {
+  color: #455a64;
+  content: "\e314";
+  font-family: 'tinymce-mobile', sans-serif;
+}
+.tinymce-mobile-styles-menu .tinymce-mobile-styles-item.tinymce-mobile-styles-item-is-menu::after {
+  color: #455a64;
+  content: "\e315";
+  font-family: 'tinymce-mobile', sans-serif;
+  padding-left: 1em;
+  padding-right: 1em;
+  position: absolute;
+  right: 0;
+}
+.tinymce-mobile-styles-menu .tinymce-mobile-styles-item.tinymce-mobile-format-matches::after {
+  font-family: 'tinymce-mobile', sans-serif;
+  padding-left: 1em;
+  padding-right: 1em;
+  position: absolute;
+  right: 0;
+}
+.tinymce-mobile-styles-menu .tinymce-mobile-styles-separator,
+.tinymce-mobile-styles-menu .tinymce-mobile-styles-collapser {
+  align-items: center;
+  background: #fff;
+  border-top: #455a64;
+  color: #455a64;
+  display: flex;
+  min-height: 2.5em;
+  padding-left: 1em;
+  padding-right: 1em;
+}
+.tinymce-mobile-styles-menu [data-transitioning-destination="before"][data-transitioning-state],
+.tinymce-mobile-styles-menu [data-transitioning-state="before"] {
+  transform: translate(-100%);
+}
+.tinymce-mobile-styles-menu [data-transitioning-destination="current"][data-transitioning-state],
+.tinymce-mobile-styles-menu [data-transitioning-state="current"] {
+  transform: translate(0%);
+}
+.tinymce-mobile-styles-menu [data-transitioning-destination="after"][data-transitioning-state],
+.tinymce-mobile-styles-menu [data-transitioning-state="after"] {
+  transform: translate(100%);
+}
+@font-face {
+  font-family: 'tinymce-mobile';
+  font-style: normal;
+  font-weight: normal;
+  src: url('fonts/tinymce-mobile.woff?8x92w3') format('woff');
+}
+@media (min-device-width: 700px) {
+  .tinymce-mobile-outer-container,
+  .tinymce-mobile-outer-container input {
+    font-size: 25px;
+  }
+}
+@media (max-device-width: 700px) {
+  .tinymce-mobile-outer-container,
+  .tinymce-mobile-outer-container input {
+    font-size: 18px;
+  }
+}
+.tinymce-mobile-icon {
+  font-family: 'tinymce-mobile', sans-serif;
+}
+.mixin-flex-and-centre {
+  align-items: center;
+  display: flex;
+  justify-content: center;
+}
+.mixin-flex-bar {
+  align-items: center;
+  display: flex;
+  height: 100%;
+}
+.tinymce-mobile-outer-container .tinymce-mobile-editor-socket iframe {
+  background-color: #fff;
+  width: 100%;
+}
+.tinymce-mobile-editor-socket .tinymce-mobile-mask-edit-icon {
+  /* Note, on the iPod touch in landscape, this isn't visible when the navbar appears */
+  background-color: #207ab7;
+  border-radius: 50%;
+  bottom: 1em;
+  color: white;
+  font-size: 1em;
+  height: 2.1em;
+  position: fixed;
+  right: 2em;
+  width: 2.1em;
+  align-items: center;
+  display: flex;
+  justify-content: center;
+}
+@media only screen and (min-device-width:700px) {
+  .tinymce-mobile-editor-socket .tinymce-mobile-mask-edit-icon {
+    font-size: 1.2em;
+  }
+}
+.tinymce-mobile-outer-container:not(.tinymce-mobile-fullscreen-maximized) .tinymce-mobile-editor-socket {
+  height: 300px;
+  overflow: hidden;
+}
+.tinymce-mobile-outer-container:not(.tinymce-mobile-fullscreen-maximized) .tinymce-mobile-editor-socket iframe {
+  height: 100%;
+}
+.tinymce-mobile-outer-container:not(.tinymce-mobile-fullscreen-maximized) .tinymce-mobile-toolstrip {
+  display: none;
+}
+/*
+  Note, that if you don't include this (::-webkit-file-upload-button), the toolbar width gets
+  increased and the whole body becomes scrollable. It's important!
+ */
+input[type="file"]::-webkit-file-upload-button {
+  display: none;
+}
+@media only screen and (min-device-width : 320px) and (max-device-width : 568px) and (orientation : landscape) {
+  .tinymce-mobile-ios-container .tinymce-mobile-editor-socket .tinymce-mobile-mask-edit-icon {
+    bottom: 50%;
+  }
+}
diff --git a/public/tinymce/skins/ui/oxide-dark/skin.mobile.min.css b/public/tinymce/skins/ui/oxide-dark/skin.mobile.min.css
new file mode 100644
index 0000000..3a45cac
--- /dev/null
+++ b/public/tinymce/skins/ui/oxide-dark/skin.mobile.min.css
@@ -0,0 +1,7 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+.tinymce-mobile-outer-container{all:initial;display:block}.tinymce-mobile-outer-container *{border:0;box-sizing:initial;cursor:inherit;float:none;line-height:1;margin:0;outline:0;padding:0;-webkit-tap-highlight-color:transparent;text-shadow:none;white-space:nowrap}.tinymce-mobile-icon-arrow-back::before{content:"\e5cd"}.tinymce-mobile-icon-image::before{content:"\e412"}.tinymce-mobile-icon-cancel-circle::before{content:"\e5c9"}.tinymce-mobile-icon-full-dot::before{content:"\e061"}.tinymce-mobile-icon-align-center::before{content:"\e234"}.tinymce-mobile-icon-align-left::before{content:"\e236"}.tinymce-mobile-icon-align-right::before{content:"\e237"}.tinymce-mobile-icon-bold::before{content:"\e238"}.tinymce-mobile-icon-italic::before{content:"\e23f"}.tinymce-mobile-icon-unordered-list::before{content:"\e241"}.tinymce-mobile-icon-ordered-list::before{content:"\e242"}.tinymce-mobile-icon-font-size::before{content:"\e245"}.tinymce-mobile-icon-underline::before{content:"\e249"}.tinymce-mobile-icon-link::before{content:"\e157"}.tinymce-mobile-icon-unlink::before{content:"\eca2"}.tinymce-mobile-icon-color::before{content:"\e891"}.tinymce-mobile-icon-previous::before{content:"\e314"}.tinymce-mobile-icon-next::before{content:"\e315"}.tinymce-mobile-icon-large-font::before,.tinymce-mobile-icon-style-formats::before{content:"\e264"}.tinymce-mobile-icon-undo::before{content:"\e166"}.tinymce-mobile-icon-redo::before{content:"\e15a"}.tinymce-mobile-icon-removeformat::before{content:"\e239"}.tinymce-mobile-icon-small-font::before{content:"\e906"}.tinymce-mobile-format-matches::after,.tinymce-mobile-icon-readonly-back::before{content:"\e5ca"}.tinymce-mobile-icon-small-heading::before{content:"small"}.tinymce-mobile-icon-large-heading::before{content:"large"}.tinymce-mobile-icon-large-heading::before,.tinymce-mobile-icon-small-heading::before{font-family:sans-serif;font-size:80%}.tinymce-mobile-mask-edit-icon::before{content:"\e254"}.tinymce-mobile-icon-back::before{content:"\e5c4"}.tinymce-mobile-icon-heading::before{content:"Headings";font-family:sans-serif;font-size:80%;font-weight:700}.tinymce-mobile-icon-h1::before{content:"H1";font-weight:700}.tinymce-mobile-icon-h2::before{content:"H2";font-weight:700}.tinymce-mobile-icon-h3::before{content:"H3";font-weight:700}.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask{align-items:center;display:flex;justify-content:center;background:rgba(51,51,51,.5);height:100%;position:absolute;top:0;width:100%}.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container{align-items:center;border-radius:50%;display:flex;flex-direction:column;font-family:sans-serif;font-size:1em;justify-content:space-between}.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .mixin-menu-item{align-items:center;display:flex;justify-content:center;border-radius:50%;height:2.1em;width:2.1em}.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section{align-items:center;display:flex;justify-content:center;flex-direction:column;font-size:1em}@media only screen and (min-device-width:700px){.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section{font-size:1.2em}}.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section .tinymce-mobile-mask-tap-icon{align-items:center;display:flex;justify-content:center;border-radius:50%;height:2.1em;width:2.1em;background-color:#fff;color:#207ab7}.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section .tinymce-mobile-mask-tap-icon::before{content:"\e900";font-family:tinymce-mobile,sans-serif}.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section:not(.tinymce-mobile-mask-tap-icon-selected) .tinymce-mobile-mask-tap-icon{z-index:2}.tinymce-mobile-android-container.tinymce-mobile-android-maximized{background:#fff;border:none;bottom:0;display:flex;flex-direction:column;left:0;position:fixed;right:0;top:0}.tinymce-mobile-android-container:not(.tinymce-mobile-android-maximized){position:relative}.tinymce-mobile-android-container .tinymce-mobile-editor-socket{display:flex;flex-grow:1}.tinymce-mobile-android-container .tinymce-mobile-editor-socket iframe{display:flex!important;flex-grow:1;height:auto!important}.tinymce-mobile-android-scroll-reload{overflow:hidden}:not(.tinymce-mobile-readonly-mode)>.tinymce-mobile-android-selection-context-toolbar{margin-top:23px}.tinymce-mobile-toolstrip{background:#fff;display:flex;flex:0 0 auto;z-index:1}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar{align-items:center;background-color:#fff;border-bottom:1px solid #ccc;display:flex;flex:1;height:2.5em;width:100%}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group{align-items:center;display:flex;height:100%;flex-shrink:1}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group>div{align-items:center;display:flex;height:100%;flex:1}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group.tinymce-mobile-exit-container{background:#f44336}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group.tinymce-mobile-toolbar-scrollable-group{flex-grow:1}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item{padding-left:.5em;padding-right:.5em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item.tinymce-mobile-toolbar-button{align-items:center;display:flex;height:80%;margin-left:2px;margin-right:2px}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item.tinymce-mobile-toolbar-button.tinymce-mobile-toolbar-button-selected{background:#c8cbcf;color:#ccc}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group:first-of-type,.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group:last-of-type{background:#207ab7;color:#eceff1}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group{align-items:center;display:flex;height:100%;flex:1;padding-bottom:.4em;padding-top:.4em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog{display:flex;min-height:1.5em;overflow:hidden;padding-left:0;padding-right:0;position:relative;width:100%}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain{display:flex;height:100%;transition:left cubic-bezier(.4,0,1,1) .15s;width:100%}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen{display:flex;flex:0 0 auto;justify-content:space-between;width:100%}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen input{font-family:Sans-serif}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-input-container{display:flex;flex-grow:1;position:relative}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-input-container .tinymce-mobile-input-container-x{-ms-grid-row-align:center;align-self:center;background:inherit;border:none;border-radius:50%;color:#888;font-size:.6em;font-weight:700;height:100%;padding-right:2px;position:absolute;right:0}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-input-container.tinymce-mobile-input-container-empty .tinymce-mobile-input-container-x{display:none}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-next,.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-previous{align-items:center;display:flex}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-next::before,.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-previous::before{align-items:center;display:flex;font-weight:700;height:100%;padding-left:.5em;padding-right:.5em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-next.tinymce-mobile-toolbar-navigation-disabled::before,.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-previous.tinymce-mobile-toolbar-navigation-disabled::before{visibility:hidden}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-dot-item{color:#ccc;font-size:10px;line-height:10px;margin:0 2px;padding-top:3px}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-dot-item.tinymce-mobile-dot-active{color:#c8cbcf}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-icon-large-font::before,.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-icon-large-heading::before{margin-left:.5em;margin-right:.9em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-icon-small-font::before,.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-icon-small-heading::before{margin-left:.9em;margin-right:.5em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider{display:flex;flex:1;margin-left:0;margin-right:0;padding:.28em 0;position:relative}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider .tinymce-mobile-slider-size-container{align-items:center;display:flex;flex-grow:1;height:100%}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider .tinymce-mobile-slider-size-container .tinymce-mobile-slider-size-line{background:#ccc;display:flex;flex:1;height:.2em;margin-bottom:.3em;margin-top:.3em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container{padding-left:2em;padding-right:2em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container .tinymce-mobile-slider-gradient-container{align-items:center;display:flex;flex-grow:1;height:100%}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container .tinymce-mobile-slider-gradient-container .tinymce-mobile-slider-gradient{background:linear-gradient(to right,red 0,#feff00 17%,#0f0 33%,#00feff 50%,#00f 67%,#ff00fe 83%,red 100%);display:flex;flex:1;height:.2em;margin-bottom:.3em;margin-top:.3em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container .tinymce-mobile-hue-slider-black{background:#000;height:.2em;margin-bottom:.3em;margin-top:.3em;width:1.2em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container .tinymce-mobile-hue-slider-white{background:#fff;height:.2em;margin-bottom:.3em;margin-top:.3em;width:1.2em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider .tinymce-mobile-slider-thumb{align-items:center;background-clip:padding-box;background-color:#455a64;border:.5em solid rgba(136,136,136,0);border-radius:3em;bottom:0;color:#fff;display:flex;height:.5em;justify-content:center;left:-10px;margin:auto;position:absolute;top:0;transition:border 120ms cubic-bezier(.39,.58,.57,1);width:.5em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider .tinymce-mobile-slider-thumb.tinymce-mobile-thumb-active{border:.5em solid rgba(136,136,136,.39)}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serializer-wrapper,.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group>div{align-items:center;display:flex;height:100%;flex:1}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serializer-wrapper{flex-direction:column;justify-content:center}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item{align-items:center;display:flex}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item:not(.tinymce-mobile-serialised-dialog){height:100%}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-dot-container{display:flex}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group input{background:#fff;border:none;border-radius:0;color:#455a64;flex-grow:1;font-size:.85em;padding-bottom:.1em;padding-left:5px;padding-top:.1em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group input::-webkit-input-placeholder{color:#888}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group input::placeholder{color:#888}.tinymce-mobile-dropup{background:#fff;display:flex;overflow:hidden;width:100%}.tinymce-mobile-dropup.tinymce-mobile-dropup-shrinking{transition:height .3s ease-out}.tinymce-mobile-dropup.tinymce-mobile-dropup-growing{transition:height .3s ease-in}.tinymce-mobile-dropup.tinymce-mobile-dropup-closed{flex-grow:0}.tinymce-mobile-dropup.tinymce-mobile-dropup-open:not(.tinymce-mobile-dropup-growing){flex-grow:1}.tinymce-mobile-ios-container .tinymce-mobile-dropup:not(.tinymce-mobile-dropup-closed){min-height:200px}@media only screen and (orientation:landscape){.tinymce-mobile-dropup:not(.tinymce-mobile-dropup-closed){min-height:200px}}@media only screen and (min-device-width :320px) and (max-device-width :568px) and (orientation :landscape){.tinymce-mobile-ios-container .tinymce-mobile-dropup:not(.tinymce-mobile-dropup-closed){min-height:150px}}.tinymce-mobile-styles-menu{font-family:sans-serif;outline:4px solid #000;overflow:hidden;position:relative;width:100%}.tinymce-mobile-styles-menu [role=menu]{display:flex;flex-direction:column;height:100%;position:absolute;width:100%}.tinymce-mobile-styles-menu [role=menu].transitioning{transition:transform .5s ease-in-out}.tinymce-mobile-styles-menu .tinymce-mobile-styles-item{border-bottom:1px solid #ddd;color:#455a64;cursor:pointer;display:flex;padding:1em 1em;position:relative}.tinymce-mobile-styles-menu .tinymce-mobile-styles-collapser .tinymce-mobile-styles-collapse-icon::before{color:#455a64;content:"\e314";font-family:tinymce-mobile,sans-serif}.tinymce-mobile-styles-menu .tinymce-mobile-styles-item.tinymce-mobile-styles-item-is-menu::after{color:#455a64;content:"\e315";font-family:tinymce-mobile,sans-serif;padding-left:1em;padding-right:1em;position:absolute;right:0}.tinymce-mobile-styles-menu .tinymce-mobile-styles-item.tinymce-mobile-format-matches::after{font-family:tinymce-mobile,sans-serif;padding-left:1em;padding-right:1em;position:absolute;right:0}.tinymce-mobile-styles-menu .tinymce-mobile-styles-collapser,.tinymce-mobile-styles-menu .tinymce-mobile-styles-separator{align-items:center;background:#fff;border-top:#455a64;color:#455a64;display:flex;min-height:2.5em;padding-left:1em;padding-right:1em}.tinymce-mobile-styles-menu [data-transitioning-destination=before][data-transitioning-state],.tinymce-mobile-styles-menu [data-transitioning-state=before]{transform:translate(-100%)}.tinymce-mobile-styles-menu [data-transitioning-destination=current][data-transitioning-state],.tinymce-mobile-styles-menu [data-transitioning-state=current]{transform:translate(0)}.tinymce-mobile-styles-menu [data-transitioning-destination=after][data-transitioning-state],.tinymce-mobile-styles-menu [data-transitioning-state=after]{transform:translate(100%)}@font-face{font-family:tinymce-mobile;font-style:normal;font-weight:400;src:url(fonts/tinymce-mobile.woff?8x92w3) format('woff')}@media (min-device-width:700px){.tinymce-mobile-outer-container,.tinymce-mobile-outer-container input{font-size:25px}}@media (max-device-width:700px){.tinymce-mobile-outer-container,.tinymce-mobile-outer-container input{font-size:18px}}.tinymce-mobile-icon{font-family:tinymce-mobile,sans-serif}.mixin-flex-and-centre{align-items:center;display:flex;justify-content:center}.mixin-flex-bar{align-items:center;display:flex;height:100%}.tinymce-mobile-outer-container .tinymce-mobile-editor-socket iframe{background-color:#fff;width:100%}.tinymce-mobile-editor-socket .tinymce-mobile-mask-edit-icon{background-color:#207ab7;border-radius:50%;bottom:1em;color:#fff;font-size:1em;height:2.1em;position:fixed;right:2em;width:2.1em;align-items:center;display:flex;justify-content:center}@media only screen and (min-device-width:700px){.tinymce-mobile-editor-socket .tinymce-mobile-mask-edit-icon{font-size:1.2em}}.tinymce-mobile-outer-container:not(.tinymce-mobile-fullscreen-maximized) .tinymce-mobile-editor-socket{height:300px;overflow:hidden}.tinymce-mobile-outer-container:not(.tinymce-mobile-fullscreen-maximized) .tinymce-mobile-editor-socket iframe{height:100%}.tinymce-mobile-outer-container:not(.tinymce-mobile-fullscreen-maximized) .tinymce-mobile-toolstrip{display:none}input[type=file]::-webkit-file-upload-button{display:none}@media only screen and (min-device-width :320px) and (max-device-width :568px) and (orientation :landscape){.tinymce-mobile-ios-container .tinymce-mobile-editor-socket .tinymce-mobile-mask-edit-icon{bottom:50%}}
diff --git a/public/tinymce/skins/ui/oxide-dark/skin.shadowdom.css b/public/tinymce/skins/ui/oxide-dark/skin.shadowdom.css
new file mode 100644
index 0000000..d2adc4d
--- /dev/null
+++ b/public/tinymce/skins/ui/oxide-dark/skin.shadowdom.css
@@ -0,0 +1,37 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+body.tox-dialog__disable-scroll {
+  overflow: hidden;
+}
+.tox-fullscreen {
+  border: 0;
+  height: 100%;
+  margin: 0;
+  overflow: hidden;
+  -ms-scroll-chaining: none;
+      overscroll-behavior: none;
+  padding: 0;
+  touch-action: pinch-zoom;
+  width: 100%;
+}
+.tox.tox-tinymce.tox-fullscreen .tox-statusbar__resize-handle {
+  display: none;
+}
+.tox.tox-tinymce.tox-fullscreen,
+.tox-shadowhost.tox-fullscreen {
+  left: 0;
+  position: fixed;
+  top: 0;
+  z-index: 1200;
+}
+.tox.tox-tinymce.tox-fullscreen {
+  background-color: transparent;
+}
+.tox-fullscreen .tox.tox-tinymce-aux,
+.tox-fullscreen ~ .tox.tox-tinymce-aux {
+  z-index: 1201;
+}
diff --git a/public/tinymce/skins/ui/oxide-dark/skin.shadowdom.min.css b/public/tinymce/skins/ui/oxide-dark/skin.shadowdom.min.css
new file mode 100644
index 0000000..a0893b9
--- /dev/null
+++ b/public/tinymce/skins/ui/oxide-dark/skin.shadowdom.min.css
@@ -0,0 +1,7 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+body.tox-dialog__disable-scroll{overflow:hidden}.tox-fullscreen{border:0;height:100%;margin:0;overflow:hidden;-ms-scroll-chaining:none;overscroll-behavior:none;padding:0;touch-action:pinch-zoom;width:100%}.tox.tox-tinymce.tox-fullscreen .tox-statusbar__resize-handle{display:none}.tox-shadowhost.tox-fullscreen,.tox.tox-tinymce.tox-fullscreen{left:0;position:fixed;top:0;z-index:1200}.tox.tox-tinymce.tox-fullscreen{background-color:transparent}.tox-fullscreen .tox.tox-tinymce-aux,.tox-fullscreen~.tox.tox-tinymce-aux{z-index:1201}
diff --git a/public/tinymce/skins/ui/oxide/content.css b/public/tinymce/skins/ui/oxide/content.css
new file mode 100644
index 0000000..2ac0cca
--- /dev/null
+++ b/public/tinymce/skins/ui/oxide/content.css
@@ -0,0 +1,732 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+.mce-content-body .mce-item-anchor {
+  background: transparent url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'8'%20height%3D'12'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20d%3D'M0%200L8%200%208%2012%204.09117821%209%200%2012z'%2F%3E%3C%2Fsvg%3E%0A") no-repeat center;
+  cursor: default;
+  display: inline-block;
+  height: 12px !important;
+  padding: 0 2px;
+  -webkit-user-modify: read-only;
+  -moz-user-modify: read-only;
+  -webkit-user-select: all;
+  -moz-user-select: all;
+  -ms-user-select: all;
+      user-select: all;
+  width: 8px !important;
+}
+.mce-content-body .mce-item-anchor[data-mce-selected] {
+  outline-offset: 1px;
+}
+.tox-comments-visible .tox-comment {
+  background-color: #fff0b7;
+}
+.tox-comments-visible .tox-comment--active {
+  background-color: #ffe168;
+}
+.tox-checklist > li:not(.tox-checklist--hidden) {
+  list-style: none;
+  margin: 0.25em 0;
+}
+.tox-checklist > li:not(.tox-checklist--hidden)::before {
+  content: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cg%20id%3D%22checklist-unchecked%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Crect%20id%3D%22Rectangle%22%20width%3D%2215%22%20height%3D%2215%22%20x%3D%22.5%22%20y%3D%22.5%22%20fill-rule%3D%22nonzero%22%20stroke%3D%22%234C4C4C%22%20rx%3D%222%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E%0A");
+  cursor: pointer;
+  height: 1em;
+  margin-left: -1.5em;
+  margin-top: 0.125em;
+  position: absolute;
+  width: 1em;
+}
+.tox-checklist li:not(.tox-checklist--hidden).tox-checklist--checked::before {
+  content: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cg%20id%3D%22checklist-checked%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Crect%20id%3D%22Rectangle%22%20width%3D%2216%22%20height%3D%2216%22%20fill%3D%22%234099FF%22%20fill-rule%3D%22nonzero%22%20rx%3D%222%22%2F%3E%3Cpath%20id%3D%22Path%22%20fill%3D%22%23FFF%22%20fill-rule%3D%22nonzero%22%20d%3D%22M11.5703186%2C3.14417309%20C11.8516238%2C2.73724603%2012.4164781%2C2.62829933%2012.83558%2C2.89774797%20C13.260121%2C3.17069355%2013.3759736%2C3.72932262%2013.0909105%2C4.14168582%20L7.7580587%2C11.8560195%20C7.43776896%2C12.3193404%206.76483983%2C12.3852142%206.35607322%2C11.9948725%20L3.02491697%2C8.8138662%20C2.66090143%2C8.46625845%202.65798871%2C7.89594698%203.01850234%2C7.54483354%20C3.373942%2C7.19866177%203.94940006%2C7.19592841%204.30829608%2C7.5386474%20L6.85276923%2C9.9684299%20L11.5703186%2C3.14417309%20Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E%0A");
+}
+[dir=rtl] .tox-checklist > li:not(.tox-checklist--hidden)::before {
+  margin-left: 0;
+  margin-right: -1.5em;
+}
+/* stylelint-disable */
+/* http://prismjs.com/ */
+/**
+ * prism.js default theme for JavaScript, CSS and HTML
+ * Based on dabblet (http://dabblet.com)
+ * @author Lea Verou
+ */
+code[class*="language-"],
+pre[class*="language-"] {
+  color: black;
+  background: none;
+  text-shadow: 0 1px white;
+  font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
+  font-size: 1em;
+  text-align: left;
+  white-space: pre;
+  word-spacing: normal;
+  word-break: normal;
+  word-wrap: normal;
+  line-height: 1.5;
+  -moz-tab-size: 4;
+  tab-size: 4;
+  -webkit-hyphens: none;
+  -ms-hyphens: none;
+  hyphens: none;
+}
+pre[class*="language-"]::-moz-selection,
+pre[class*="language-"] ::-moz-selection,
+code[class*="language-"]::-moz-selection,
+code[class*="language-"] ::-moz-selection {
+  text-shadow: none;
+  background: #b3d4fc;
+}
+pre[class*="language-"]::selection,
+pre[class*="language-"] ::selection,
+code[class*="language-"]::selection,
+code[class*="language-"] ::selection {
+  text-shadow: none;
+  background: #b3d4fc;
+}
+@media print {
+  code[class*="language-"],
+  pre[class*="language-"] {
+    text-shadow: none;
+  }
+}
+/* Code blocks */
+pre[class*="language-"] {
+  padding: 1em;
+  margin: 0.5em 0;
+  overflow: auto;
+}
+:not(pre) > code[class*="language-"],
+pre[class*="language-"] {
+  background: #f5f2f0;
+}
+/* Inline code */
+:not(pre) > code[class*="language-"] {
+  padding: 0.1em;
+  border-radius: 0.3em;
+  white-space: normal;
+}
+.token.comment,
+.token.prolog,
+.token.doctype,
+.token.cdata {
+  color: slategray;
+}
+.token.punctuation {
+  color: #999;
+}
+.namespace {
+  opacity: 0.7;
+}
+.token.property,
+.token.tag,
+.token.boolean,
+.token.number,
+.token.constant,
+.token.symbol,
+.token.deleted {
+  color: #905;
+}
+.token.selector,
+.token.attr-name,
+.token.string,
+.token.char,
+.token.builtin,
+.token.inserted {
+  color: #690;
+}
+.token.operator,
+.token.entity,
+.token.url,
+.language-css .token.string,
+.style .token.string {
+  color: #9a6e3a;
+  background: hsla(0, 0%, 100%, 0.5);
+}
+.token.atrule,
+.token.attr-value,
+.token.keyword {
+  color: #07a;
+}
+.token.function,
+.token.class-name {
+  color: #DD4A68;
+}
+.token.regex,
+.token.important,
+.token.variable {
+  color: #e90;
+}
+.token.important,
+.token.bold {
+  font-weight: bold;
+}
+.token.italic {
+  font-style: italic;
+}
+.token.entity {
+  cursor: help;
+}
+/* stylelint-enable */
+.mce-content-body {
+  overflow-wrap: break-word;
+  word-wrap: break-word;
+}
+.mce-content-body .mce-visual-caret {
+  background-color: black;
+  background-color: currentColor;
+  position: absolute;
+}
+.mce-content-body .mce-visual-caret-hidden {
+  display: none;
+}
+.mce-content-body *[data-mce-caret] {
+  left: -1000px;
+  margin: 0;
+  padding: 0;
+  position: absolute;
+  right: auto;
+  top: 0;
+}
+.mce-content-body .mce-offscreen-selection {
+  left: -2000000px;
+  max-width: 1000000px;
+  position: absolute;
+}
+.mce-content-body *[contentEditable=false] {
+  cursor: default;
+}
+.mce-content-body *[contentEditable=true] {
+  cursor: text;
+}
+.tox-cursor-format-painter {
+  cursor: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%3E%0A%20%20%3Cg%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%0A%20%20%20%20%3Cpath%20fill%3D%22%23000%22%20fill-rule%3D%22nonzero%22%20d%3D%22M15%2C6%20C15%2C5.45%2014.55%2C5%2014%2C5%20L6%2C5%20C5.45%2C5%205%2C5.45%205%2C6%20L5%2C10%20C5%2C10.55%205.45%2C11%206%2C11%20L14%2C11%20C14.55%2C11%2015%2C10.55%2015%2C10%20L15%2C9%20L16%2C9%20L16%2C12%20L9%2C12%20L9%2C19%20C9%2C19.55%209.45%2C20%2010%2C20%20L11%2C20%20C11.55%2C20%2012%2C19.55%2012%2C19%20L12%2C14%20L18%2C14%20L18%2C7%20L15%2C7%20L15%2C6%20Z%22%2F%3E%0A%20%20%20%20%3Cpath%20fill%3D%22%23000%22%20fill-rule%3D%22nonzero%22%20d%3D%22M1%2C1%20L8.25%2C1%20C8.66421356%2C1%209%2C1.33578644%209%2C1.75%20L9%2C1.75%20C9%2C2.16421356%208.66421356%2C2.5%208.25%2C2.5%20L2.5%2C2.5%20L2.5%2C8.25%20C2.5%2C8.66421356%202.16421356%2C9%201.75%2C9%20L1.75%2C9%20C1.33578644%2C9%201%2C8.66421356%201%2C8.25%20L1%2C1%20Z%22%2F%3E%0A%20%20%3C%2Fg%3E%0A%3C%2Fsvg%3E%0A"), default;
+}
+.mce-content-body figure.align-left {
+  float: left;
+}
+.mce-content-body figure.align-right {
+  float: right;
+}
+.mce-content-body figure.image.align-center {
+  display: table;
+  margin-left: auto;
+  margin-right: auto;
+}
+.mce-preview-object {
+  border: 1px solid gray;
+  display: inline-block;
+  line-height: 0;
+  margin: 0 2px 0 2px;
+  position: relative;
+}
+.mce-preview-object .mce-shim {
+  background: url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7);
+  height: 100%;
+  left: 0;
+  position: absolute;
+  top: 0;
+  width: 100%;
+}
+.mce-preview-object[data-mce-selected="2"] .mce-shim {
+  display: none;
+}
+.mce-object {
+  background: transparent url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M4%203h16a1%201%200%200%201%201%201v16a1%201%200%200%201-1%201H4a1%201%200%200%201-1-1V4a1%201%200%200%201%201-1zm1%202v14h14V5H5zm4.79%202.565l5.64%204.028a.5.5%200%200%201%200%20.814l-5.64%204.028a.5.5%200%200%201-.79-.407V7.972a.5.5%200%200%201%20.79-.407z%22%2F%3E%3C%2Fsvg%3E%0A") no-repeat center;
+  border: 1px dashed #aaa;
+}
+.mce-pagebreak {
+  border: 1px dashed #aaa;
+  cursor: default;
+  display: block;
+  height: 5px;
+  margin-top: 15px;
+  page-break-before: always;
+  width: 100%;
+}
+@media print {
+  .mce-pagebreak {
+    border: 0;
+  }
+}
+.tiny-pageembed .mce-shim {
+  background: url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7);
+  height: 100%;
+  left: 0;
+  position: absolute;
+  top: 0;
+  width: 100%;
+}
+.tiny-pageembed[data-mce-selected="2"] .mce-shim {
+  display: none;
+}
+.tiny-pageembed {
+  display: inline-block;
+  position: relative;
+}
+.tiny-pageembed--21by9,
+.tiny-pageembed--16by9,
+.tiny-pageembed--4by3,
+.tiny-pageembed--1by1 {
+  display: block;
+  overflow: hidden;
+  padding: 0;
+  position: relative;
+  width: 100%;
+}
+.tiny-pageembed--21by9 {
+  padding-top: 42.857143%;
+}
+.tiny-pageembed--16by9 {
+  padding-top: 56.25%;
+}
+.tiny-pageembed--4by3 {
+  padding-top: 75%;
+}
+.tiny-pageembed--1by1 {
+  padding-top: 100%;
+}
+.tiny-pageembed--21by9 iframe,
+.tiny-pageembed--16by9 iframe,
+.tiny-pageembed--4by3 iframe,
+.tiny-pageembed--1by1 iframe {
+  border: 0;
+  height: 100%;
+  left: 0;
+  position: absolute;
+  top: 0;
+  width: 100%;
+}
+.mce-content-body[data-mce-placeholder] {
+  position: relative;
+}
+.mce-content-body[data-mce-placeholder]:not(.mce-visualblocks)::before {
+  color: rgba(34, 47, 62, 0.7);
+  content: attr(data-mce-placeholder);
+  position: absolute;
+}
+.mce-content-body:not([dir=rtl])[data-mce-placeholder]:not(.mce-visualblocks)::before {
+  left: 1px;
+}
+.mce-content-body[dir=rtl][data-mce-placeholder]:not(.mce-visualblocks)::before {
+  right: 1px;
+}
+.mce-content-body div.mce-resizehandle {
+  background-color: #4099ff;
+  border-color: #4099ff;
+  border-style: solid;
+  border-width: 1px;
+  box-sizing: border-box;
+  height: 10px;
+  position: absolute;
+  width: 10px;
+  z-index: 1298;
+}
+.mce-content-body div.mce-resizehandle:hover {
+  background-color: #4099ff;
+}
+.mce-content-body div.mce-resizehandle:nth-of-type(1) {
+  cursor: nwse-resize;
+}
+.mce-content-body div.mce-resizehandle:nth-of-type(2) {
+  cursor: nesw-resize;
+}
+.mce-content-body div.mce-resizehandle:nth-of-type(3) {
+  cursor: nwse-resize;
+}
+.mce-content-body div.mce-resizehandle:nth-of-type(4) {
+  cursor: nesw-resize;
+}
+.mce-content-body .mce-resize-backdrop {
+  z-index: 10000;
+}
+.mce-content-body .mce-clonedresizable {
+  cursor: default;
+  opacity: 0.5;
+  outline: 1px dashed black;
+  position: absolute;
+  z-index: 10001;
+}
+.mce-content-body .mce-clonedresizable.mce-resizetable-columns th,
+.mce-content-body .mce-clonedresizable.mce-resizetable-columns td {
+  border: 0;
+}
+.mce-content-body .mce-resize-helper {
+  background: #555;
+  background: rgba(0, 0, 0, 0.75);
+  border: 1px;
+  border-radius: 3px;
+  color: white;
+  display: none;
+  font-family: sans-serif;
+  font-size: 12px;
+  line-height: 14px;
+  margin: 5px 10px;
+  padding: 5px;
+  position: absolute;
+  white-space: nowrap;
+  z-index: 10002;
+}
+.tox-rtc-user-selection {
+  position: relative;
+}
+.tox-rtc-user-cursor {
+  bottom: 0;
+  cursor: default;
+  position: absolute;
+  top: 0;
+  width: 2px;
+}
+.tox-rtc-user-cursor::before {
+  background-color: inherit;
+  border-radius: 50%;
+  content: '';
+  display: block;
+  height: 8px;
+  position: absolute;
+  right: -3px;
+  top: -3px;
+  width: 8px;
+}
+.tox-rtc-user-cursor:hover::after {
+  background-color: inherit;
+  border-radius: 100px;
+  box-sizing: border-box;
+  color: #fff;
+  content: attr(data-user);
+  display: block;
+  font-size: 12px;
+  font-weight: bold;
+  left: -5px;
+  min-height: 8px;
+  min-width: 8px;
+  padding: 0 12px;
+  position: absolute;
+  top: -11px;
+  white-space: nowrap;
+  z-index: 1000;
+}
+.tox-rtc-user-selection--1 .tox-rtc-user-cursor {
+  background-color: #2dc26b;
+}
+.tox-rtc-user-selection--2 .tox-rtc-user-cursor {
+  background-color: #e03e2d;
+}
+.tox-rtc-user-selection--3 .tox-rtc-user-cursor {
+  background-color: #f1c40f;
+}
+.tox-rtc-user-selection--4 .tox-rtc-user-cursor {
+  background-color: #3598db;
+}
+.tox-rtc-user-selection--5 .tox-rtc-user-cursor {
+  background-color: #b96ad9;
+}
+.tox-rtc-user-selection--6 .tox-rtc-user-cursor {
+  background-color: #e67e23;
+}
+.tox-rtc-user-selection--7 .tox-rtc-user-cursor {
+  background-color: #aaa69d;
+}
+.tox-rtc-user-selection--8 .tox-rtc-user-cursor {
+  background-color: #f368e0;
+}
+.tox-rtc-remote-image {
+  background: #eaeaea url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2236%22%20height%3D%2212%22%20viewBox%3D%220%200%2036%2012%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Ccircle%20cx%3D%226%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%20%20%3Ccircle%20cx%3D%2218%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20begin%3D%22.33s%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%20%20%3Ccircle%20cx%3D%2230%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20begin%3D%22.66s%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%3C%2Fsvg%3E%0A") no-repeat center center;
+  border: 1px solid #ccc;
+  min-height: 240px;
+  min-width: 320px;
+}
+.mce-match-marker {
+  background: #aaa;
+  color: #fff;
+}
+.mce-match-marker-selected {
+  background: #39f;
+  color: #fff;
+}
+.mce-match-marker-selected::-moz-selection {
+  background: #39f;
+  color: #fff;
+}
+.mce-match-marker-selected::selection {
+  background: #39f;
+  color: #fff;
+}
+.mce-content-body img[data-mce-selected],
+.mce-content-body video[data-mce-selected],
+.mce-content-body audio[data-mce-selected],
+.mce-content-body object[data-mce-selected],
+.mce-content-body embed[data-mce-selected],
+.mce-content-body table[data-mce-selected] {
+  outline: 3px solid #b4d7ff;
+}
+.mce-content-body hr[data-mce-selected] {
+  outline: 3px solid #b4d7ff;
+  outline-offset: 1px;
+}
+.mce-content-body *[contentEditable=false] *[contentEditable=true]:focus {
+  outline: 3px solid #b4d7ff;
+}
+.mce-content-body *[contentEditable=false] *[contentEditable=true]:hover {
+  outline: 3px solid #b4d7ff;
+}
+.mce-content-body *[contentEditable=false][data-mce-selected] {
+  cursor: not-allowed;
+  outline: 3px solid #b4d7ff;
+}
+.mce-content-body.mce-content-readonly *[contentEditable=true]:focus,
+.mce-content-body.mce-content-readonly *[contentEditable=true]:hover {
+  outline: none;
+}
+.mce-content-body *[data-mce-selected="inline-boundary"] {
+  background-color: #b4d7ff;
+}
+.mce-content-body .mce-edit-focus {
+  outline: 3px solid #b4d7ff;
+}
+.mce-content-body td[data-mce-selected],
+.mce-content-body th[data-mce-selected] {
+  position: relative;
+}
+.mce-content-body td[data-mce-selected]::-moz-selection,
+.mce-content-body th[data-mce-selected]::-moz-selection {
+  background: none;
+}
+.mce-content-body td[data-mce-selected]::selection,
+.mce-content-body th[data-mce-selected]::selection {
+  background: none;
+}
+.mce-content-body td[data-mce-selected] *,
+.mce-content-body th[data-mce-selected] * {
+  outline: none;
+  -webkit-touch-callout: none;
+  -webkit-user-select: none;
+     -moz-user-select: none;
+      -ms-user-select: none;
+          user-select: none;
+}
+.mce-content-body td[data-mce-selected]::after,
+.mce-content-body th[data-mce-selected]::after {
+  background-color: rgba(180, 215, 255, 0.7);
+  border: 1px solid rgba(180, 215, 255, 0.7);
+  bottom: -1px;
+  content: '';
+  left: -1px;
+  mix-blend-mode: multiply;
+  position: absolute;
+  right: -1px;
+  top: -1px;
+}
+@media screen and (-ms-high-contrast: active), (-ms-high-contrast: none) {
+  .mce-content-body td[data-mce-selected]::after,
+  .mce-content-body th[data-mce-selected]::after {
+    border-color: rgba(0, 84, 180, 0.7);
+  }
+}
+.mce-content-body img::-moz-selection {
+  background: none;
+}
+.mce-content-body img::selection {
+  background: none;
+}
+.ephox-snooker-resizer-bar {
+  background-color: #b4d7ff;
+  opacity: 0;
+  -webkit-user-select: none;
+  -moz-user-select: none;
+  -ms-user-select: none;
+  user-select: none;
+}
+.ephox-snooker-resizer-cols {
+  cursor: col-resize;
+}
+.ephox-snooker-resizer-rows {
+  cursor: row-resize;
+}
+.ephox-snooker-resizer-bar.ephox-snooker-resizer-bar-dragging {
+  opacity: 1;
+}
+.mce-spellchecker-word {
+  background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'4'%20height%3D'4'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20stroke%3D'%23ff0000'%20fill%3D'none'%20stroke-linecap%3D'round'%20stroke-opacity%3D'.75'%20d%3D'M0%203L2%201%204%203'%2F%3E%3C%2Fsvg%3E%0A");
+  background-position: 0 calc(100% + 1px);
+  background-repeat: repeat-x;
+  background-size: auto 6px;
+  cursor: default;
+  height: 2rem;
+}
+.mce-spellchecker-grammar {
+  background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'4'%20height%3D'4'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20stroke%3D'%2300A835'%20fill%3D'none'%20stroke-linecap%3D'round'%20d%3D'M0%203L2%201%204%203'%2F%3E%3C%2Fsvg%3E%0A");
+  background-position: 0 calc(100% + 1px);
+  background-repeat: repeat-x;
+  background-size: auto 6px;
+  cursor: default;
+}
+.mce-toc {
+  border: 1px solid gray;
+}
+.mce-toc h2 {
+  margin: 4px;
+}
+.mce-toc li {
+  list-style-type: none;
+}
+table[style*="border-width: 0px"],
+.mce-item-table:not([border]),
+.mce-item-table[border="0"],
+table[style*="border-width: 0px"] td,
+.mce-item-table:not([border]) td,
+.mce-item-table[border="0"] td,
+table[style*="border-width: 0px"] th,
+.mce-item-table:not([border]) th,
+.mce-item-table[border="0"] th,
+table[style*="border-width: 0px"] caption,
+.mce-item-table:not([border]) caption,
+.mce-item-table[border="0"] caption {
+  border: 1px dashed #bbb;
+}
+.mce-visualblocks p,
+.mce-visualblocks h1,
+.mce-visualblocks h2,
+.mce-visualblocks h3,
+.mce-visualblocks h4,
+.mce-visualblocks h5,
+.mce-visualblocks h6,
+.mce-visualblocks div:not([data-mce-bogus]),
+.mce-visualblocks section,
+.mce-visualblocks article,
+.mce-visualblocks blockquote,
+.mce-visualblocks address,
+.mce-visualblocks pre,
+.mce-visualblocks figure,
+.mce-visualblocks figcaption,
+.mce-visualblocks hgroup,
+.mce-visualblocks aside,
+.mce-visualblocks ul,
+.mce-visualblocks ol,
+.mce-visualblocks dl {
+  background-repeat: no-repeat;
+  border: 1px dashed #bbb;
+  margin-left: 3px;
+  padding-top: 10px;
+}
+.mce-visualblocks p {
+  background-image: url(data:image/gif;base64,R0lGODlhCQAJAJEAAAAAAP///7u7u////yH5BAEAAAMALAAAAAAJAAkAAAIQnG+CqCN/mlyvsRUpThG6AgA7);
+}
+.mce-visualblocks h1 {
+  background-image: url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybGu1JuxHoAfRNRW3TWXyF2YiRUAOw==);
+}
+.mce-visualblocks h2 {
+  background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8Hybbx4oOuqgTynJd6bGlWg3DkJzoaUAAAOw==);
+}
+.mce-visualblocks h3 {
+  background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIZjI8Hybbx4oOuqgTynJf2Ln2NOHpQpmhAAQA7);
+}
+.mce-visualblocks h4 {
+  background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxInR0zqeAdhtJlXwV1oCll2HaWgAAOw==);
+}
+.mce-visualblocks h5 {
+  background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxIoiuwjane4iq5GlW05GgIkIZUAAAOw==);
+}
+.mce-visualblocks h6 {
+  background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxIoiuwjan04jep1iZ1XRlAo5bVgAAOw==);
+}
+.mce-visualblocks div:not([data-mce-bogus]) {
+  background-image: url(data:image/gif;base64,R0lGODlhEgAKAIABALu7u////yH5BAEAAAEALAAAAAASAAoAAAIfjI9poI0cgDywrhuxfbrzDEbQM2Ei5aRjmoySW4pAAQA7);
+}
+.mce-visualblocks section {
+  background-image: url(data:image/gif;base64,R0lGODlhKAAKAIABALu7u////yH5BAEAAAEALAAAAAAoAAoAAAI5jI+pywcNY3sBWHdNrplytD2ellDeSVbp+GmWqaDqDMepc8t17Y4vBsK5hDyJMcI6KkuYU+jpjLoKADs=);
+}
+.mce-visualblocks article {
+  background-image: url(data:image/gif;base64,R0lGODlhKgAKAIABALu7u////yH5BAEAAAEALAAAAAAqAAoAAAI6jI+pywkNY3wG0GBvrsd2tXGYSGnfiF7ikpXemTpOiJScasYoDJJrjsG9gkCJ0ag6KhmaIe3pjDYBBQA7);
+}
+.mce-visualblocks blockquote {
+  background-image: url(data:image/gif;base64,R0lGODlhPgAKAIABALu7u////yH5BAEAAAEALAAAAAA+AAoAAAJPjI+py+0Knpz0xQDyuUhvfoGgIX5iSKZYgq5uNL5q69asZ8s5rrf0yZmpNkJZzFesBTu8TOlDVAabUyatguVhWduud3EyiUk45xhTTgMBBQA7);
+}
+.mce-visualblocks address {
+  background-image: url(data:image/gif;base64,R0lGODlhLQAKAIABALu7u////yH5BAEAAAEALAAAAAAtAAoAAAI/jI+pywwNozSP1gDyyZcjb3UaRpXkWaXmZW4OqKLhBmLs+K263DkJK7OJeifh7FicKD9A1/IpGdKkyFpNmCkAADs=);
+}
+.mce-visualblocks pre {
+  background-image: url(data:image/gif;base64,R0lGODlhFQAKAIABALu7uwAAACH5BAEAAAEALAAAAAAVAAoAAAIjjI+ZoN0cgDwSmnpz1NCueYERhnibZVKLNnbOq8IvKpJtVQAAOw==);
+}
+.mce-visualblocks figure {
+  background-image: url(data:image/gif;base64,R0lGODlhJAAKAIAAALu7u////yH5BAEAAAEALAAAAAAkAAoAAAI0jI+py+2fwAHUSFvD3RlvG4HIp4nX5JFSpnZUJ6LlrM52OE7uSWosBHScgkSZj7dDKnWAAgA7);
+}
+.mce-visualblocks figcaption {
+  border: 1px dashed #bbb;
+}
+.mce-visualblocks hgroup {
+  background-image: url(data:image/gif;base64,R0lGODlhJwAKAIABALu7uwAAACH5BAEAAAEALAAAAAAnAAoAAAI3jI+pywYNI3uB0gpsRtt5fFnfNZaVSYJil4Wo03Hv6Z62uOCgiXH1kZIIJ8NiIxRrAZNMZAtQAAA7);
+}
+.mce-visualblocks aside {
+  background-image: url(data:image/gif;base64,R0lGODlhHgAKAIABAKqqqv///yH5BAEAAAEALAAAAAAeAAoAAAItjI+pG8APjZOTzgtqy7I3f1yehmQcFY4WKZbqByutmW4aHUd6vfcVbgudgpYCADs=);
+}
+.mce-visualblocks ul {
+  background-image: url(data:image/gif;base64,R0lGODlhDQAKAIAAALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybGuYnqUVSjvw26DzzXiqIDlVwAAOw==);
+}
+.mce-visualblocks ol {
+  background-image: url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybH6HHt0qourxC6CvzXieHyeWQAAOw==);
+}
+.mce-visualblocks dl {
+  background-image: url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybEOnmOvUoWznTqeuEjNSCqeGRUAOw==);
+}
+.mce-visualblocks:not([dir=rtl]) p,
+.mce-visualblocks:not([dir=rtl]) h1,
+.mce-visualblocks:not([dir=rtl]) h2,
+.mce-visualblocks:not([dir=rtl]) h3,
+.mce-visualblocks:not([dir=rtl]) h4,
+.mce-visualblocks:not([dir=rtl]) h5,
+.mce-visualblocks:not([dir=rtl]) h6,
+.mce-visualblocks:not([dir=rtl]) div:not([data-mce-bogus]),
+.mce-visualblocks:not([dir=rtl]) section,
+.mce-visualblocks:not([dir=rtl]) article,
+.mce-visualblocks:not([dir=rtl]) blockquote,
+.mce-visualblocks:not([dir=rtl]) address,
+.mce-visualblocks:not([dir=rtl]) pre,
+.mce-visualblocks:not([dir=rtl]) figure,
+.mce-visualblocks:not([dir=rtl]) figcaption,
+.mce-visualblocks:not([dir=rtl]) hgroup,
+.mce-visualblocks:not([dir=rtl]) aside,
+.mce-visualblocks:not([dir=rtl]) ul,
+.mce-visualblocks:not([dir=rtl]) ol,
+.mce-visualblocks:not([dir=rtl]) dl {
+  margin-left: 3px;
+}
+.mce-visualblocks[dir=rtl] p,
+.mce-visualblocks[dir=rtl] h1,
+.mce-visualblocks[dir=rtl] h2,
+.mce-visualblocks[dir=rtl] h3,
+.mce-visualblocks[dir=rtl] h4,
+.mce-visualblocks[dir=rtl] h5,
+.mce-visualblocks[dir=rtl] h6,
+.mce-visualblocks[dir=rtl] div:not([data-mce-bogus]),
+.mce-visualblocks[dir=rtl] section,
+.mce-visualblocks[dir=rtl] article,
+.mce-visualblocks[dir=rtl] blockquote,
+.mce-visualblocks[dir=rtl] address,
+.mce-visualblocks[dir=rtl] pre,
+.mce-visualblocks[dir=rtl] figure,
+.mce-visualblocks[dir=rtl] figcaption,
+.mce-visualblocks[dir=rtl] hgroup,
+.mce-visualblocks[dir=rtl] aside,
+.mce-visualblocks[dir=rtl] ul,
+.mce-visualblocks[dir=rtl] ol,
+.mce-visualblocks[dir=rtl] dl {
+  background-position-x: right;
+  margin-right: 3px;
+}
+.mce-nbsp,
+.mce-shy {
+  background: #aaa;
+}
+.mce-shy::after {
+  content: '-';
+}
+body {
+  font-family: sans-serif;
+}
+table {
+  border-collapse: collapse;
+}
diff --git a/public/tinymce/skins/ui/oxide/content.inline.css b/public/tinymce/skins/ui/oxide/content.inline.css
new file mode 100644
index 0000000..8e7521d
--- /dev/null
+++ b/public/tinymce/skins/ui/oxide/content.inline.css
@@ -0,0 +1,726 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+.mce-content-body .mce-item-anchor {
+  background: transparent url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'8'%20height%3D'12'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20d%3D'M0%200L8%200%208%2012%204.09117821%209%200%2012z'%2F%3E%3C%2Fsvg%3E%0A") no-repeat center;
+  cursor: default;
+  display: inline-block;
+  height: 12px !important;
+  padding: 0 2px;
+  -webkit-user-modify: read-only;
+  -moz-user-modify: read-only;
+  -webkit-user-select: all;
+  -moz-user-select: all;
+  -ms-user-select: all;
+      user-select: all;
+  width: 8px !important;
+}
+.mce-content-body .mce-item-anchor[data-mce-selected] {
+  outline-offset: 1px;
+}
+.tox-comments-visible .tox-comment {
+  background-color: #fff0b7;
+}
+.tox-comments-visible .tox-comment--active {
+  background-color: #ffe168;
+}
+.tox-checklist > li:not(.tox-checklist--hidden) {
+  list-style: none;
+  margin: 0.25em 0;
+}
+.tox-checklist > li:not(.tox-checklist--hidden)::before {
+  content: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cg%20id%3D%22checklist-unchecked%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Crect%20id%3D%22Rectangle%22%20width%3D%2215%22%20height%3D%2215%22%20x%3D%22.5%22%20y%3D%22.5%22%20fill-rule%3D%22nonzero%22%20stroke%3D%22%234C4C4C%22%20rx%3D%222%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E%0A");
+  cursor: pointer;
+  height: 1em;
+  margin-left: -1.5em;
+  margin-top: 0.125em;
+  position: absolute;
+  width: 1em;
+}
+.tox-checklist li:not(.tox-checklist--hidden).tox-checklist--checked::before {
+  content: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cg%20id%3D%22checklist-checked%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Crect%20id%3D%22Rectangle%22%20width%3D%2216%22%20height%3D%2216%22%20fill%3D%22%234099FF%22%20fill-rule%3D%22nonzero%22%20rx%3D%222%22%2F%3E%3Cpath%20id%3D%22Path%22%20fill%3D%22%23FFF%22%20fill-rule%3D%22nonzero%22%20d%3D%22M11.5703186%2C3.14417309%20C11.8516238%2C2.73724603%2012.4164781%2C2.62829933%2012.83558%2C2.89774797%20C13.260121%2C3.17069355%2013.3759736%2C3.72932262%2013.0909105%2C4.14168582%20L7.7580587%2C11.8560195%20C7.43776896%2C12.3193404%206.76483983%2C12.3852142%206.35607322%2C11.9948725%20L3.02491697%2C8.8138662%20C2.66090143%2C8.46625845%202.65798871%2C7.89594698%203.01850234%2C7.54483354%20C3.373942%2C7.19866177%203.94940006%2C7.19592841%204.30829608%2C7.5386474%20L6.85276923%2C9.9684299%20L11.5703186%2C3.14417309%20Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E%0A");
+}
+[dir=rtl] .tox-checklist > li:not(.tox-checklist--hidden)::before {
+  margin-left: 0;
+  margin-right: -1.5em;
+}
+/* stylelint-disable */
+/* http://prismjs.com/ */
+/**
+ * prism.js default theme for JavaScript, CSS and HTML
+ * Based on dabblet (http://dabblet.com)
+ * @author Lea Verou
+ */
+code[class*="language-"],
+pre[class*="language-"] {
+  color: black;
+  background: none;
+  text-shadow: 0 1px white;
+  font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
+  font-size: 1em;
+  text-align: left;
+  white-space: pre;
+  word-spacing: normal;
+  word-break: normal;
+  word-wrap: normal;
+  line-height: 1.5;
+  -moz-tab-size: 4;
+  tab-size: 4;
+  -webkit-hyphens: none;
+  -ms-hyphens: none;
+  hyphens: none;
+}
+pre[class*="language-"]::-moz-selection,
+pre[class*="language-"] ::-moz-selection,
+code[class*="language-"]::-moz-selection,
+code[class*="language-"] ::-moz-selection {
+  text-shadow: none;
+  background: #b3d4fc;
+}
+pre[class*="language-"]::selection,
+pre[class*="language-"] ::selection,
+code[class*="language-"]::selection,
+code[class*="language-"] ::selection {
+  text-shadow: none;
+  background: #b3d4fc;
+}
+@media print {
+  code[class*="language-"],
+  pre[class*="language-"] {
+    text-shadow: none;
+  }
+}
+/* Code blocks */
+pre[class*="language-"] {
+  padding: 1em;
+  margin: 0.5em 0;
+  overflow: auto;
+}
+:not(pre) > code[class*="language-"],
+pre[class*="language-"] {
+  background: #f5f2f0;
+}
+/* Inline code */
+:not(pre) > code[class*="language-"] {
+  padding: 0.1em;
+  border-radius: 0.3em;
+  white-space: normal;
+}
+.token.comment,
+.token.prolog,
+.token.doctype,
+.token.cdata {
+  color: slategray;
+}
+.token.punctuation {
+  color: #999;
+}
+.namespace {
+  opacity: 0.7;
+}
+.token.property,
+.token.tag,
+.token.boolean,
+.token.number,
+.token.constant,
+.token.symbol,
+.token.deleted {
+  color: #905;
+}
+.token.selector,
+.token.attr-name,
+.token.string,
+.token.char,
+.token.builtin,
+.token.inserted {
+  color: #690;
+}
+.token.operator,
+.token.entity,
+.token.url,
+.language-css .token.string,
+.style .token.string {
+  color: #9a6e3a;
+  background: hsla(0, 0%, 100%, 0.5);
+}
+.token.atrule,
+.token.attr-value,
+.token.keyword {
+  color: #07a;
+}
+.token.function,
+.token.class-name {
+  color: #DD4A68;
+}
+.token.regex,
+.token.important,
+.token.variable {
+  color: #e90;
+}
+.token.important,
+.token.bold {
+  font-weight: bold;
+}
+.token.italic {
+  font-style: italic;
+}
+.token.entity {
+  cursor: help;
+}
+/* stylelint-enable */
+.mce-content-body {
+  overflow-wrap: break-word;
+  word-wrap: break-word;
+}
+.mce-content-body .mce-visual-caret {
+  background-color: black;
+  background-color: currentColor;
+  position: absolute;
+}
+.mce-content-body .mce-visual-caret-hidden {
+  display: none;
+}
+.mce-content-body *[data-mce-caret] {
+  left: -1000px;
+  margin: 0;
+  padding: 0;
+  position: absolute;
+  right: auto;
+  top: 0;
+}
+.mce-content-body .mce-offscreen-selection {
+  left: -2000000px;
+  max-width: 1000000px;
+  position: absolute;
+}
+.mce-content-body *[contentEditable=false] {
+  cursor: default;
+}
+.mce-content-body *[contentEditable=true] {
+  cursor: text;
+}
+.tox-cursor-format-painter {
+  cursor: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%3E%0A%20%20%3Cg%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%0A%20%20%20%20%3Cpath%20fill%3D%22%23000%22%20fill-rule%3D%22nonzero%22%20d%3D%22M15%2C6%20C15%2C5.45%2014.55%2C5%2014%2C5%20L6%2C5%20C5.45%2C5%205%2C5.45%205%2C6%20L5%2C10%20C5%2C10.55%205.45%2C11%206%2C11%20L14%2C11%20C14.55%2C11%2015%2C10.55%2015%2C10%20L15%2C9%20L16%2C9%20L16%2C12%20L9%2C12%20L9%2C19%20C9%2C19.55%209.45%2C20%2010%2C20%20L11%2C20%20C11.55%2C20%2012%2C19.55%2012%2C19%20L12%2C14%20L18%2C14%20L18%2C7%20L15%2C7%20L15%2C6%20Z%22%2F%3E%0A%20%20%20%20%3Cpath%20fill%3D%22%23000%22%20fill-rule%3D%22nonzero%22%20d%3D%22M1%2C1%20L8.25%2C1%20C8.66421356%2C1%209%2C1.33578644%209%2C1.75%20L9%2C1.75%20C9%2C2.16421356%208.66421356%2C2.5%208.25%2C2.5%20L2.5%2C2.5%20L2.5%2C8.25%20C2.5%2C8.66421356%202.16421356%2C9%201.75%2C9%20L1.75%2C9%20C1.33578644%2C9%201%2C8.66421356%201%2C8.25%20L1%2C1%20Z%22%2F%3E%0A%20%20%3C%2Fg%3E%0A%3C%2Fsvg%3E%0A"), default;
+}
+.mce-content-body figure.align-left {
+  float: left;
+}
+.mce-content-body figure.align-right {
+  float: right;
+}
+.mce-content-body figure.image.align-center {
+  display: table;
+  margin-left: auto;
+  margin-right: auto;
+}
+.mce-preview-object {
+  border: 1px solid gray;
+  display: inline-block;
+  line-height: 0;
+  margin: 0 2px 0 2px;
+  position: relative;
+}
+.mce-preview-object .mce-shim {
+  background: url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7);
+  height: 100%;
+  left: 0;
+  position: absolute;
+  top: 0;
+  width: 100%;
+}
+.mce-preview-object[data-mce-selected="2"] .mce-shim {
+  display: none;
+}
+.mce-object {
+  background: transparent url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M4%203h16a1%201%200%200%201%201%201v16a1%201%200%200%201-1%201H4a1%201%200%200%201-1-1V4a1%201%200%200%201%201-1zm1%202v14h14V5H5zm4.79%202.565l5.64%204.028a.5.5%200%200%201%200%20.814l-5.64%204.028a.5.5%200%200%201-.79-.407V7.972a.5.5%200%200%201%20.79-.407z%22%2F%3E%3C%2Fsvg%3E%0A") no-repeat center;
+  border: 1px dashed #aaa;
+}
+.mce-pagebreak {
+  border: 1px dashed #aaa;
+  cursor: default;
+  display: block;
+  height: 5px;
+  margin-top: 15px;
+  page-break-before: always;
+  width: 100%;
+}
+@media print {
+  .mce-pagebreak {
+    border: 0;
+  }
+}
+.tiny-pageembed .mce-shim {
+  background: url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7);
+  height: 100%;
+  left: 0;
+  position: absolute;
+  top: 0;
+  width: 100%;
+}
+.tiny-pageembed[data-mce-selected="2"] .mce-shim {
+  display: none;
+}
+.tiny-pageembed {
+  display: inline-block;
+  position: relative;
+}
+.tiny-pageembed--21by9,
+.tiny-pageembed--16by9,
+.tiny-pageembed--4by3,
+.tiny-pageembed--1by1 {
+  display: block;
+  overflow: hidden;
+  padding: 0;
+  position: relative;
+  width: 100%;
+}
+.tiny-pageembed--21by9 {
+  padding-top: 42.857143%;
+}
+.tiny-pageembed--16by9 {
+  padding-top: 56.25%;
+}
+.tiny-pageembed--4by3 {
+  padding-top: 75%;
+}
+.tiny-pageembed--1by1 {
+  padding-top: 100%;
+}
+.tiny-pageembed--21by9 iframe,
+.tiny-pageembed--16by9 iframe,
+.tiny-pageembed--4by3 iframe,
+.tiny-pageembed--1by1 iframe {
+  border: 0;
+  height: 100%;
+  left: 0;
+  position: absolute;
+  top: 0;
+  width: 100%;
+}
+.mce-content-body[data-mce-placeholder] {
+  position: relative;
+}
+.mce-content-body[data-mce-placeholder]:not(.mce-visualblocks)::before {
+  color: rgba(34, 47, 62, 0.7);
+  content: attr(data-mce-placeholder);
+  position: absolute;
+}
+.mce-content-body:not([dir=rtl])[data-mce-placeholder]:not(.mce-visualblocks)::before {
+  left: 1px;
+}
+.mce-content-body[dir=rtl][data-mce-placeholder]:not(.mce-visualblocks)::before {
+  right: 1px;
+}
+.mce-content-body div.mce-resizehandle {
+  background-color: #4099ff;
+  border-color: #4099ff;
+  border-style: solid;
+  border-width: 1px;
+  box-sizing: border-box;
+  height: 10px;
+  position: absolute;
+  width: 10px;
+  z-index: 1298;
+}
+.mce-content-body div.mce-resizehandle:hover {
+  background-color: #4099ff;
+}
+.mce-content-body div.mce-resizehandle:nth-of-type(1) {
+  cursor: nwse-resize;
+}
+.mce-content-body div.mce-resizehandle:nth-of-type(2) {
+  cursor: nesw-resize;
+}
+.mce-content-body div.mce-resizehandle:nth-of-type(3) {
+  cursor: nwse-resize;
+}
+.mce-content-body div.mce-resizehandle:nth-of-type(4) {
+  cursor: nesw-resize;
+}
+.mce-content-body .mce-resize-backdrop {
+  z-index: 10000;
+}
+.mce-content-body .mce-clonedresizable {
+  cursor: default;
+  opacity: 0.5;
+  outline: 1px dashed black;
+  position: absolute;
+  z-index: 10001;
+}
+.mce-content-body .mce-clonedresizable.mce-resizetable-columns th,
+.mce-content-body .mce-clonedresizable.mce-resizetable-columns td {
+  border: 0;
+}
+.mce-content-body .mce-resize-helper {
+  background: #555;
+  background: rgba(0, 0, 0, 0.75);
+  border: 1px;
+  border-radius: 3px;
+  color: white;
+  display: none;
+  font-family: sans-serif;
+  font-size: 12px;
+  line-height: 14px;
+  margin: 5px 10px;
+  padding: 5px;
+  position: absolute;
+  white-space: nowrap;
+  z-index: 10002;
+}
+.tox-rtc-user-selection {
+  position: relative;
+}
+.tox-rtc-user-cursor {
+  bottom: 0;
+  cursor: default;
+  position: absolute;
+  top: 0;
+  width: 2px;
+}
+.tox-rtc-user-cursor::before {
+  background-color: inherit;
+  border-radius: 50%;
+  content: '';
+  display: block;
+  height: 8px;
+  position: absolute;
+  right: -3px;
+  top: -3px;
+  width: 8px;
+}
+.tox-rtc-user-cursor:hover::after {
+  background-color: inherit;
+  border-radius: 100px;
+  box-sizing: border-box;
+  color: #fff;
+  content: attr(data-user);
+  display: block;
+  font-size: 12px;
+  font-weight: bold;
+  left: -5px;
+  min-height: 8px;
+  min-width: 8px;
+  padding: 0 12px;
+  position: absolute;
+  top: -11px;
+  white-space: nowrap;
+  z-index: 1000;
+}
+.tox-rtc-user-selection--1 .tox-rtc-user-cursor {
+  background-color: #2dc26b;
+}
+.tox-rtc-user-selection--2 .tox-rtc-user-cursor {
+  background-color: #e03e2d;
+}
+.tox-rtc-user-selection--3 .tox-rtc-user-cursor {
+  background-color: #f1c40f;
+}
+.tox-rtc-user-selection--4 .tox-rtc-user-cursor {
+  background-color: #3598db;
+}
+.tox-rtc-user-selection--5 .tox-rtc-user-cursor {
+  background-color: #b96ad9;
+}
+.tox-rtc-user-selection--6 .tox-rtc-user-cursor {
+  background-color: #e67e23;
+}
+.tox-rtc-user-selection--7 .tox-rtc-user-cursor {
+  background-color: #aaa69d;
+}
+.tox-rtc-user-selection--8 .tox-rtc-user-cursor {
+  background-color: #f368e0;
+}
+.tox-rtc-remote-image {
+  background: #eaeaea url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2236%22%20height%3D%2212%22%20viewBox%3D%220%200%2036%2012%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Ccircle%20cx%3D%226%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%20%20%3Ccircle%20cx%3D%2218%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20begin%3D%22.33s%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%20%20%3Ccircle%20cx%3D%2230%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20begin%3D%22.66s%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%3C%2Fsvg%3E%0A") no-repeat center center;
+  border: 1px solid #ccc;
+  min-height: 240px;
+  min-width: 320px;
+}
+.mce-match-marker {
+  background: #aaa;
+  color: #fff;
+}
+.mce-match-marker-selected {
+  background: #39f;
+  color: #fff;
+}
+.mce-match-marker-selected::-moz-selection {
+  background: #39f;
+  color: #fff;
+}
+.mce-match-marker-selected::selection {
+  background: #39f;
+  color: #fff;
+}
+.mce-content-body img[data-mce-selected],
+.mce-content-body video[data-mce-selected],
+.mce-content-body audio[data-mce-selected],
+.mce-content-body object[data-mce-selected],
+.mce-content-body embed[data-mce-selected],
+.mce-content-body table[data-mce-selected] {
+  outline: 3px solid #b4d7ff;
+}
+.mce-content-body hr[data-mce-selected] {
+  outline: 3px solid #b4d7ff;
+  outline-offset: 1px;
+}
+.mce-content-body *[contentEditable=false] *[contentEditable=true]:focus {
+  outline: 3px solid #b4d7ff;
+}
+.mce-content-body *[contentEditable=false] *[contentEditable=true]:hover {
+  outline: 3px solid #b4d7ff;
+}
+.mce-content-body *[contentEditable=false][data-mce-selected] {
+  cursor: not-allowed;
+  outline: 3px solid #b4d7ff;
+}
+.mce-content-body.mce-content-readonly *[contentEditable=true]:focus,
+.mce-content-body.mce-content-readonly *[contentEditable=true]:hover {
+  outline: none;
+}
+.mce-content-body *[data-mce-selected="inline-boundary"] {
+  background-color: #b4d7ff;
+}
+.mce-content-body .mce-edit-focus {
+  outline: 3px solid #b4d7ff;
+}
+.mce-content-body td[data-mce-selected],
+.mce-content-body th[data-mce-selected] {
+  position: relative;
+}
+.mce-content-body td[data-mce-selected]::-moz-selection,
+.mce-content-body th[data-mce-selected]::-moz-selection {
+  background: none;
+}
+.mce-content-body td[data-mce-selected]::selection,
+.mce-content-body th[data-mce-selected]::selection {
+  background: none;
+}
+.mce-content-body td[data-mce-selected] *,
+.mce-content-body th[data-mce-selected] * {
+  outline: none;
+  -webkit-touch-callout: none;
+  -webkit-user-select: none;
+     -moz-user-select: none;
+      -ms-user-select: none;
+          user-select: none;
+}
+.mce-content-body td[data-mce-selected]::after,
+.mce-content-body th[data-mce-selected]::after {
+  background-color: rgba(180, 215, 255, 0.7);
+  border: 1px solid rgba(180, 215, 255, 0.7);
+  bottom: -1px;
+  content: '';
+  left: -1px;
+  mix-blend-mode: multiply;
+  position: absolute;
+  right: -1px;
+  top: -1px;
+}
+@media screen and (-ms-high-contrast: active), (-ms-high-contrast: none) {
+  .mce-content-body td[data-mce-selected]::after,
+  .mce-content-body th[data-mce-selected]::after {
+    border-color: rgba(0, 84, 180, 0.7);
+  }
+}
+.mce-content-body img::-moz-selection {
+  background: none;
+}
+.mce-content-body img::selection {
+  background: none;
+}
+.ephox-snooker-resizer-bar {
+  background-color: #b4d7ff;
+  opacity: 0;
+  -webkit-user-select: none;
+  -moz-user-select: none;
+  -ms-user-select: none;
+  user-select: none;
+}
+.ephox-snooker-resizer-cols {
+  cursor: col-resize;
+}
+.ephox-snooker-resizer-rows {
+  cursor: row-resize;
+}
+.ephox-snooker-resizer-bar.ephox-snooker-resizer-bar-dragging {
+  opacity: 1;
+}
+.mce-spellchecker-word {
+  background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'4'%20height%3D'4'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20stroke%3D'%23ff0000'%20fill%3D'none'%20stroke-linecap%3D'round'%20stroke-opacity%3D'.75'%20d%3D'M0%203L2%201%204%203'%2F%3E%3C%2Fsvg%3E%0A");
+  background-position: 0 calc(100% + 1px);
+  background-repeat: repeat-x;
+  background-size: auto 6px;
+  cursor: default;
+  height: 2rem;
+}
+.mce-spellchecker-grammar {
+  background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'4'%20height%3D'4'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20stroke%3D'%2300A835'%20fill%3D'none'%20stroke-linecap%3D'round'%20d%3D'M0%203L2%201%204%203'%2F%3E%3C%2Fsvg%3E%0A");
+  background-position: 0 calc(100% + 1px);
+  background-repeat: repeat-x;
+  background-size: auto 6px;
+  cursor: default;
+}
+.mce-toc {
+  border: 1px solid gray;
+}
+.mce-toc h2 {
+  margin: 4px;
+}
+.mce-toc li {
+  list-style-type: none;
+}
+table[style*="border-width: 0px"],
+.mce-item-table:not([border]),
+.mce-item-table[border="0"],
+table[style*="border-width: 0px"] td,
+.mce-item-table:not([border]) td,
+.mce-item-table[border="0"] td,
+table[style*="border-width: 0px"] th,
+.mce-item-table:not([border]) th,
+.mce-item-table[border="0"] th,
+table[style*="border-width: 0px"] caption,
+.mce-item-table:not([border]) caption,
+.mce-item-table[border="0"] caption {
+  border: 1px dashed #bbb;
+}
+.mce-visualblocks p,
+.mce-visualblocks h1,
+.mce-visualblocks h2,
+.mce-visualblocks h3,
+.mce-visualblocks h4,
+.mce-visualblocks h5,
+.mce-visualblocks h6,
+.mce-visualblocks div:not([data-mce-bogus]),
+.mce-visualblocks section,
+.mce-visualblocks article,
+.mce-visualblocks blockquote,
+.mce-visualblocks address,
+.mce-visualblocks pre,
+.mce-visualblocks figure,
+.mce-visualblocks figcaption,
+.mce-visualblocks hgroup,
+.mce-visualblocks aside,
+.mce-visualblocks ul,
+.mce-visualblocks ol,
+.mce-visualblocks dl {
+  background-repeat: no-repeat;
+  border: 1px dashed #bbb;
+  margin-left: 3px;
+  padding-top: 10px;
+}
+.mce-visualblocks p {
+  background-image: url(data:image/gif;base64,R0lGODlhCQAJAJEAAAAAAP///7u7u////yH5BAEAAAMALAAAAAAJAAkAAAIQnG+CqCN/mlyvsRUpThG6AgA7);
+}
+.mce-visualblocks h1 {
+  background-image: url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybGu1JuxHoAfRNRW3TWXyF2YiRUAOw==);
+}
+.mce-visualblocks h2 {
+  background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8Hybbx4oOuqgTynJd6bGlWg3DkJzoaUAAAOw==);
+}
+.mce-visualblocks h3 {
+  background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIZjI8Hybbx4oOuqgTynJf2Ln2NOHpQpmhAAQA7);
+}
+.mce-visualblocks h4 {
+  background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxInR0zqeAdhtJlXwV1oCll2HaWgAAOw==);
+}
+.mce-visualblocks h5 {
+  background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxIoiuwjane4iq5GlW05GgIkIZUAAAOw==);
+}
+.mce-visualblocks h6 {
+  background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxIoiuwjan04jep1iZ1XRlAo5bVgAAOw==);
+}
+.mce-visualblocks div:not([data-mce-bogus]) {
+  background-image: url(data:image/gif;base64,R0lGODlhEgAKAIABALu7u////yH5BAEAAAEALAAAAAASAAoAAAIfjI9poI0cgDywrhuxfbrzDEbQM2Ei5aRjmoySW4pAAQA7);
+}
+.mce-visualblocks section {
+  background-image: url(data:image/gif;base64,R0lGODlhKAAKAIABALu7u////yH5BAEAAAEALAAAAAAoAAoAAAI5jI+pywcNY3sBWHdNrplytD2ellDeSVbp+GmWqaDqDMepc8t17Y4vBsK5hDyJMcI6KkuYU+jpjLoKADs=);
+}
+.mce-visualblocks article {
+  background-image: url(data:image/gif;base64,R0lGODlhKgAKAIABALu7u////yH5BAEAAAEALAAAAAAqAAoAAAI6jI+pywkNY3wG0GBvrsd2tXGYSGnfiF7ikpXemTpOiJScasYoDJJrjsG9gkCJ0ag6KhmaIe3pjDYBBQA7);
+}
+.mce-visualblocks blockquote {
+  background-image: url(data:image/gif;base64,R0lGODlhPgAKAIABALu7u////yH5BAEAAAEALAAAAAA+AAoAAAJPjI+py+0Knpz0xQDyuUhvfoGgIX5iSKZYgq5uNL5q69asZ8s5rrf0yZmpNkJZzFesBTu8TOlDVAabUyatguVhWduud3EyiUk45xhTTgMBBQA7);
+}
+.mce-visualblocks address {
+  background-image: url(data:image/gif;base64,R0lGODlhLQAKAIABALu7u////yH5BAEAAAEALAAAAAAtAAoAAAI/jI+pywwNozSP1gDyyZcjb3UaRpXkWaXmZW4OqKLhBmLs+K263DkJK7OJeifh7FicKD9A1/IpGdKkyFpNmCkAADs=);
+}
+.mce-visualblocks pre {
+  background-image: url(data:image/gif;base64,R0lGODlhFQAKAIABALu7uwAAACH5BAEAAAEALAAAAAAVAAoAAAIjjI+ZoN0cgDwSmnpz1NCueYERhnibZVKLNnbOq8IvKpJtVQAAOw==);
+}
+.mce-visualblocks figure {
+  background-image: url(data:image/gif;base64,R0lGODlhJAAKAIAAALu7u////yH5BAEAAAEALAAAAAAkAAoAAAI0jI+py+2fwAHUSFvD3RlvG4HIp4nX5JFSpnZUJ6LlrM52OE7uSWosBHScgkSZj7dDKnWAAgA7);
+}
+.mce-visualblocks figcaption {
+  border: 1px dashed #bbb;
+}
+.mce-visualblocks hgroup {
+  background-image: url(data:image/gif;base64,R0lGODlhJwAKAIABALu7uwAAACH5BAEAAAEALAAAAAAnAAoAAAI3jI+pywYNI3uB0gpsRtt5fFnfNZaVSYJil4Wo03Hv6Z62uOCgiXH1kZIIJ8NiIxRrAZNMZAtQAAA7);
+}
+.mce-visualblocks aside {
+  background-image: url(data:image/gif;base64,R0lGODlhHgAKAIABAKqqqv///yH5BAEAAAEALAAAAAAeAAoAAAItjI+pG8APjZOTzgtqy7I3f1yehmQcFY4WKZbqByutmW4aHUd6vfcVbgudgpYCADs=);
+}
+.mce-visualblocks ul {
+  background-image: url(data:image/gif;base64,R0lGODlhDQAKAIAAALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybGuYnqUVSjvw26DzzXiqIDlVwAAOw==);
+}
+.mce-visualblocks ol {
+  background-image: url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybH6HHt0qourxC6CvzXieHyeWQAAOw==);
+}
+.mce-visualblocks dl {
+  background-image: url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybEOnmOvUoWznTqeuEjNSCqeGRUAOw==);
+}
+.mce-visualblocks:not([dir=rtl]) p,
+.mce-visualblocks:not([dir=rtl]) h1,
+.mce-visualblocks:not([dir=rtl]) h2,
+.mce-visualblocks:not([dir=rtl]) h3,
+.mce-visualblocks:not([dir=rtl]) h4,
+.mce-visualblocks:not([dir=rtl]) h5,
+.mce-visualblocks:not([dir=rtl]) h6,
+.mce-visualblocks:not([dir=rtl]) div:not([data-mce-bogus]),
+.mce-visualblocks:not([dir=rtl]) section,
+.mce-visualblocks:not([dir=rtl]) article,
+.mce-visualblocks:not([dir=rtl]) blockquote,
+.mce-visualblocks:not([dir=rtl]) address,
+.mce-visualblocks:not([dir=rtl]) pre,
+.mce-visualblocks:not([dir=rtl]) figure,
+.mce-visualblocks:not([dir=rtl]) figcaption,
+.mce-visualblocks:not([dir=rtl]) hgroup,
+.mce-visualblocks:not([dir=rtl]) aside,
+.mce-visualblocks:not([dir=rtl]) ul,
+.mce-visualblocks:not([dir=rtl]) ol,
+.mce-visualblocks:not([dir=rtl]) dl {
+  margin-left: 3px;
+}
+.mce-visualblocks[dir=rtl] p,
+.mce-visualblocks[dir=rtl] h1,
+.mce-visualblocks[dir=rtl] h2,
+.mce-visualblocks[dir=rtl] h3,
+.mce-visualblocks[dir=rtl] h4,
+.mce-visualblocks[dir=rtl] h5,
+.mce-visualblocks[dir=rtl] h6,
+.mce-visualblocks[dir=rtl] div:not([data-mce-bogus]),
+.mce-visualblocks[dir=rtl] section,
+.mce-visualblocks[dir=rtl] article,
+.mce-visualblocks[dir=rtl] blockquote,
+.mce-visualblocks[dir=rtl] address,
+.mce-visualblocks[dir=rtl] pre,
+.mce-visualblocks[dir=rtl] figure,
+.mce-visualblocks[dir=rtl] figcaption,
+.mce-visualblocks[dir=rtl] hgroup,
+.mce-visualblocks[dir=rtl] aside,
+.mce-visualblocks[dir=rtl] ul,
+.mce-visualblocks[dir=rtl] ol,
+.mce-visualblocks[dir=rtl] dl {
+  background-position-x: right;
+  margin-right: 3px;
+}
+.mce-nbsp,
+.mce-shy {
+  background: #aaa;
+}
+.mce-shy::after {
+  content: '-';
+}
diff --git a/public/tinymce/skins/ui/oxide/content.inline.min.css b/public/tinymce/skins/ui/oxide/content.inline.min.css
new file mode 100644
index 0000000..b4ab9a3
--- /dev/null
+++ b/public/tinymce/skins/ui/oxide/content.inline.min.css
@@ -0,0 +1,7 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+.mce-content-body .mce-item-anchor{background:transparent url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'8'%20height%3D'12'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20d%3D'M0%200L8%200%208%2012%204.09117821%209%200%2012z'%2F%3E%3C%2Fsvg%3E%0A") no-repeat center;cursor:default;display:inline-block;height:12px!important;padding:0 2px;-webkit-user-modify:read-only;-moz-user-modify:read-only;-webkit-user-select:all;-moz-user-select:all;-ms-user-select:all;user-select:all;width:8px!important}.mce-content-body .mce-item-anchor[data-mce-selected]{outline-offset:1px}.tox-comments-visible .tox-comment{background-color:#fff0b7}.tox-comments-visible .tox-comment--active{background-color:#ffe168}.tox-checklist>li:not(.tox-checklist--hidden){list-style:none;margin:.25em 0}.tox-checklist>li:not(.tox-checklist--hidden)::before{content:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cg%20id%3D%22checklist-unchecked%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Crect%20id%3D%22Rectangle%22%20width%3D%2215%22%20height%3D%2215%22%20x%3D%22.5%22%20y%3D%22.5%22%20fill-rule%3D%22nonzero%22%20stroke%3D%22%234C4C4C%22%20rx%3D%222%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E%0A");cursor:pointer;height:1em;margin-left:-1.5em;margin-top:.125em;position:absolute;width:1em}.tox-checklist li:not(.tox-checklist--hidden).tox-checklist--checked::before{content:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cg%20id%3D%22checklist-checked%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Crect%20id%3D%22Rectangle%22%20width%3D%2216%22%20height%3D%2216%22%20fill%3D%22%234099FF%22%20fill-rule%3D%22nonzero%22%20rx%3D%222%22%2F%3E%3Cpath%20id%3D%22Path%22%20fill%3D%22%23FFF%22%20fill-rule%3D%22nonzero%22%20d%3D%22M11.5703186%2C3.14417309%20C11.8516238%2C2.73724603%2012.4164781%2C2.62829933%2012.83558%2C2.89774797%20C13.260121%2C3.17069355%2013.3759736%2C3.72932262%2013.0909105%2C4.14168582%20L7.7580587%2C11.8560195%20C7.43776896%2C12.3193404%206.76483983%2C12.3852142%206.35607322%2C11.9948725%20L3.02491697%2C8.8138662%20C2.66090143%2C8.46625845%202.65798871%2C7.89594698%203.01850234%2C7.54483354%20C3.373942%2C7.19866177%203.94940006%2C7.19592841%204.30829608%2C7.5386474%20L6.85276923%2C9.9684299%20L11.5703186%2C3.14417309%20Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E%0A")}[dir=rtl] .tox-checklist>li:not(.tox-checklist--hidden)::before{margin-left:0;margin-right:-1.5em}code[class*=language-],pre[class*=language-]{color:#000;background:0 0;text-shadow:0 1px #fff;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;tab-size:4;-webkit-hyphens:none;-ms-hyphens:none;hyphens:none}code[class*=language-] ::-moz-selection,code[class*=language-]::-moz-selection,pre[class*=language-] ::-moz-selection,pre[class*=language-]::-moz-selection{text-shadow:none;background:#b3d4fc}code[class*=language-] ::selection,code[class*=language-]::selection,pre[class*=language-] ::selection,pre[class*=language-]::selection{text-shadow:none;background:#b3d4fc}@media print{code[class*=language-],pre[class*=language-]{text-shadow:none}}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#f5f2f0}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#708090}.token.punctuation{color:#999}.namespace{opacity:.7}.token.boolean,.token.constant,.token.deleted,.token.number,.token.property,.token.symbol,.token.tag{color:#905}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string{color:#690}.language-css .token.string,.style .token.string,.token.entity,.token.operator,.token.url{color:#9a6e3a;background:hsla(0,0%,100%,.5)}.token.atrule,.token.attr-value,.token.keyword{color:#07a}.token.class-name,.token.function{color:#dd4a68}.token.important,.token.regex,.token.variable{color:#e90}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}.mce-content-body{overflow-wrap:break-word;word-wrap:break-word}.mce-content-body .mce-visual-caret{background-color:#000;background-color:currentColor;position:absolute}.mce-content-body .mce-visual-caret-hidden{display:none}.mce-content-body [data-mce-caret]{left:-1000px;margin:0;padding:0;position:absolute;right:auto;top:0}.mce-content-body .mce-offscreen-selection{left:-2000000px;max-width:1000000px;position:absolute}.mce-content-body [contentEditable=false]{cursor:default}.mce-content-body [contentEditable=true]{cursor:text}.tox-cursor-format-painter{cursor:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%3E%0A%20%20%3Cg%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%0A%20%20%20%20%3Cpath%20fill%3D%22%23000%22%20fill-rule%3D%22nonzero%22%20d%3D%22M15%2C6%20C15%2C5.45%2014.55%2C5%2014%2C5%20L6%2C5%20C5.45%2C5%205%2C5.45%205%2C6%20L5%2C10%20C5%2C10.55%205.45%2C11%206%2C11%20L14%2C11%20C14.55%2C11%2015%2C10.55%2015%2C10%20L15%2C9%20L16%2C9%20L16%2C12%20L9%2C12%20L9%2C19%20C9%2C19.55%209.45%2C20%2010%2C20%20L11%2C20%20C11.55%2C20%2012%2C19.55%2012%2C19%20L12%2C14%20L18%2C14%20L18%2C7%20L15%2C7%20L15%2C6%20Z%22%2F%3E%0A%20%20%20%20%3Cpath%20fill%3D%22%23000%22%20fill-rule%3D%22nonzero%22%20d%3D%22M1%2C1%20L8.25%2C1%20C8.66421356%2C1%209%2C1.33578644%209%2C1.75%20L9%2C1.75%20C9%2C2.16421356%208.66421356%2C2.5%208.25%2C2.5%20L2.5%2C2.5%20L2.5%2C8.25%20C2.5%2C8.66421356%202.16421356%2C9%201.75%2C9%20L1.75%2C9%20C1.33578644%2C9%201%2C8.66421356%201%2C8.25%20L1%2C1%20Z%22%2F%3E%0A%20%20%3C%2Fg%3E%0A%3C%2Fsvg%3E%0A"),default}.mce-content-body figure.align-left{float:left}.mce-content-body figure.align-right{float:right}.mce-content-body figure.image.align-center{display:table;margin-left:auto;margin-right:auto}.mce-preview-object{border:1px solid gray;display:inline-block;line-height:0;margin:0 2px 0 2px;position:relative}.mce-preview-object .mce-shim{background:url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7);height:100%;left:0;position:absolute;top:0;width:100%}.mce-preview-object[data-mce-selected="2"] .mce-shim{display:none}.mce-object{background:transparent url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M4%203h16a1%201%200%200%201%201%201v16a1%201%200%200%201-1%201H4a1%201%200%200%201-1-1V4a1%201%200%200%201%201-1zm1%202v14h14V5H5zm4.79%202.565l5.64%204.028a.5.5%200%200%201%200%20.814l-5.64%204.028a.5.5%200%200%201-.79-.407V7.972a.5.5%200%200%201%20.79-.407z%22%2F%3E%3C%2Fsvg%3E%0A") no-repeat center;border:1px dashed #aaa}.mce-pagebreak{border:1px dashed #aaa;cursor:default;display:block;height:5px;margin-top:15px;page-break-before:always;width:100%}@media print{.mce-pagebreak{border:0}}.tiny-pageembed .mce-shim{background:url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7);height:100%;left:0;position:absolute;top:0;width:100%}.tiny-pageembed[data-mce-selected="2"] .mce-shim{display:none}.tiny-pageembed{display:inline-block;position:relative}.tiny-pageembed--16by9,.tiny-pageembed--1by1,.tiny-pageembed--21by9,.tiny-pageembed--4by3{display:block;overflow:hidden;padding:0;position:relative;width:100%}.tiny-pageembed--21by9{padding-top:42.857143%}.tiny-pageembed--16by9{padding-top:56.25%}.tiny-pageembed--4by3{padding-top:75%}.tiny-pageembed--1by1{padding-top:100%}.tiny-pageembed--16by9 iframe,.tiny-pageembed--1by1 iframe,.tiny-pageembed--21by9 iframe,.tiny-pageembed--4by3 iframe{border:0;height:100%;left:0;position:absolute;top:0;width:100%}.mce-content-body[data-mce-placeholder]{position:relative}.mce-content-body[data-mce-placeholder]:not(.mce-visualblocks)::before{color:rgba(34,47,62,.7);content:attr(data-mce-placeholder);position:absolute}.mce-content-body:not([dir=rtl])[data-mce-placeholder]:not(.mce-visualblocks)::before{left:1px}.mce-content-body[dir=rtl][data-mce-placeholder]:not(.mce-visualblocks)::before{right:1px}.mce-content-body div.mce-resizehandle{background-color:#4099ff;border-color:#4099ff;border-style:solid;border-width:1px;box-sizing:border-box;height:10px;position:absolute;width:10px;z-index:1298}.mce-content-body div.mce-resizehandle:hover{background-color:#4099ff}.mce-content-body div.mce-resizehandle:nth-of-type(1){cursor:nwse-resize}.mce-content-body div.mce-resizehandle:nth-of-type(2){cursor:nesw-resize}.mce-content-body div.mce-resizehandle:nth-of-type(3){cursor:nwse-resize}.mce-content-body div.mce-resizehandle:nth-of-type(4){cursor:nesw-resize}.mce-content-body .mce-resize-backdrop{z-index:10000}.mce-content-body .mce-clonedresizable{cursor:default;opacity:.5;outline:1px dashed #000;position:absolute;z-index:10001}.mce-content-body .mce-clonedresizable.mce-resizetable-columns td,.mce-content-body .mce-clonedresizable.mce-resizetable-columns th{border:0}.mce-content-body .mce-resize-helper{background:#555;background:rgba(0,0,0,.75);border:1px;border-radius:3px;color:#fff;display:none;font-family:sans-serif;font-size:12px;line-height:14px;margin:5px 10px;padding:5px;position:absolute;white-space:nowrap;z-index:10002}.tox-rtc-user-selection{position:relative}.tox-rtc-user-cursor{bottom:0;cursor:default;position:absolute;top:0;width:2px}.tox-rtc-user-cursor::before{background-color:inherit;border-radius:50%;content:'';display:block;height:8px;position:absolute;right:-3px;top:-3px;width:8px}.tox-rtc-user-cursor:hover::after{background-color:inherit;border-radius:100px;box-sizing:border-box;color:#fff;content:attr(data-user);display:block;font-size:12px;font-weight:700;left:-5px;min-height:8px;min-width:8px;padding:0 12px;position:absolute;top:-11px;white-space:nowrap;z-index:1000}.tox-rtc-user-selection--1 .tox-rtc-user-cursor{background-color:#2dc26b}.tox-rtc-user-selection--2 .tox-rtc-user-cursor{background-color:#e03e2d}.tox-rtc-user-selection--3 .tox-rtc-user-cursor{background-color:#f1c40f}.tox-rtc-user-selection--4 .tox-rtc-user-cursor{background-color:#3598db}.tox-rtc-user-selection--5 .tox-rtc-user-cursor{background-color:#b96ad9}.tox-rtc-user-selection--6 .tox-rtc-user-cursor{background-color:#e67e23}.tox-rtc-user-selection--7 .tox-rtc-user-cursor{background-color:#aaa69d}.tox-rtc-user-selection--8 .tox-rtc-user-cursor{background-color:#f368e0}.tox-rtc-remote-image{background:#eaeaea url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2236%22%20height%3D%2212%22%20viewBox%3D%220%200%2036%2012%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Ccircle%20cx%3D%226%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%20%20%3Ccircle%20cx%3D%2218%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20begin%3D%22.33s%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%20%20%3Ccircle%20cx%3D%2230%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20begin%3D%22.66s%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%3C%2Fsvg%3E%0A") no-repeat center center;border:1px solid #ccc;min-height:240px;min-width:320px}.mce-match-marker{background:#aaa;color:#fff}.mce-match-marker-selected{background:#39f;color:#fff}.mce-match-marker-selected::-moz-selection{background:#39f;color:#fff}.mce-match-marker-selected::selection{background:#39f;color:#fff}.mce-content-body audio[data-mce-selected],.mce-content-body embed[data-mce-selected],.mce-content-body img[data-mce-selected],.mce-content-body object[data-mce-selected],.mce-content-body table[data-mce-selected],.mce-content-body video[data-mce-selected]{outline:3px solid #b4d7ff}.mce-content-body hr[data-mce-selected]{outline:3px solid #b4d7ff;outline-offset:1px}.mce-content-body [contentEditable=false] [contentEditable=true]:focus{outline:3px solid #b4d7ff}.mce-content-body [contentEditable=false] [contentEditable=true]:hover{outline:3px solid #b4d7ff}.mce-content-body [contentEditable=false][data-mce-selected]{cursor:not-allowed;outline:3px solid #b4d7ff}.mce-content-body.mce-content-readonly [contentEditable=true]:focus,.mce-content-body.mce-content-readonly [contentEditable=true]:hover{outline:0}.mce-content-body [data-mce-selected=inline-boundary]{background-color:#b4d7ff}.mce-content-body .mce-edit-focus{outline:3px solid #b4d7ff}.mce-content-body td[data-mce-selected],.mce-content-body th[data-mce-selected]{position:relative}.mce-content-body td[data-mce-selected]::-moz-selection,.mce-content-body th[data-mce-selected]::-moz-selection{background:0 0}.mce-content-body td[data-mce-selected]::selection,.mce-content-body th[data-mce-selected]::selection{background:0 0}.mce-content-body td[data-mce-selected] *,.mce-content-body th[data-mce-selected] *{outline:0;-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.mce-content-body td[data-mce-selected]::after,.mce-content-body th[data-mce-selected]::after{background-color:rgba(180,215,255,.7);border:1px solid rgba(180,215,255,.7);bottom:-1px;content:'';left:-1px;mix-blend-mode:multiply;position:absolute;right:-1px;top:-1px}@media screen and (-ms-high-contrast:active),(-ms-high-contrast:none){.mce-content-body td[data-mce-selected]::after,.mce-content-body th[data-mce-selected]::after{border-color:rgba(0,84,180,.7)}}.mce-content-body img::-moz-selection{background:0 0}.mce-content-body img::selection{background:0 0}.ephox-snooker-resizer-bar{background-color:#b4d7ff;opacity:0;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.ephox-snooker-resizer-cols{cursor:col-resize}.ephox-snooker-resizer-rows{cursor:row-resize}.ephox-snooker-resizer-bar.ephox-snooker-resizer-bar-dragging{opacity:1}.mce-spellchecker-word{background-image:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'4'%20height%3D'4'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20stroke%3D'%23ff0000'%20fill%3D'none'%20stroke-linecap%3D'round'%20stroke-opacity%3D'.75'%20d%3D'M0%203L2%201%204%203'%2F%3E%3C%2Fsvg%3E%0A");background-position:0 calc(100% + 1px);background-repeat:repeat-x;background-size:auto 6px;cursor:default;height:2rem}.mce-spellchecker-grammar{background-image:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'4'%20height%3D'4'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20stroke%3D'%2300A835'%20fill%3D'none'%20stroke-linecap%3D'round'%20d%3D'M0%203L2%201%204%203'%2F%3E%3C%2Fsvg%3E%0A");background-position:0 calc(100% + 1px);background-repeat:repeat-x;background-size:auto 6px;cursor:default}.mce-toc{border:1px solid gray}.mce-toc h2{margin:4px}.mce-toc li{list-style-type:none}.mce-item-table:not([border]),.mce-item-table:not([border]) caption,.mce-item-table:not([border]) td,.mce-item-table:not([border]) th,.mce-item-table[border="0"],.mce-item-table[border="0"] caption,.mce-item-table[border="0"] td,.mce-item-table[border="0"] th,table[style*="border-width: 0px"],table[style*="border-width: 0px"] caption,table[style*="border-width: 0px"] td,table[style*="border-width: 0px"] th{border:1px dashed #bbb}.mce-visualblocks address,.mce-visualblocks article,.mce-visualblocks aside,.mce-visualblocks blockquote,.mce-visualblocks div:not([data-mce-bogus]),.mce-visualblocks dl,.mce-visualblocks figcaption,.mce-visualblocks figure,.mce-visualblocks h1,.mce-visualblocks h2,.mce-visualblocks h3,.mce-visualblocks h4,.mce-visualblocks h5,.mce-visualblocks h6,.mce-visualblocks hgroup,.mce-visualblocks ol,.mce-visualblocks p,.mce-visualblocks pre,.mce-visualblocks section,.mce-visualblocks ul{background-repeat:no-repeat;border:1px dashed #bbb;margin-left:3px;padding-top:10px}.mce-visualblocks p{background-image:url(data:image/gif;base64,R0lGODlhCQAJAJEAAAAAAP///7u7u////yH5BAEAAAMALAAAAAAJAAkAAAIQnG+CqCN/mlyvsRUpThG6AgA7)}.mce-visualblocks h1{background-image:url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybGu1JuxHoAfRNRW3TWXyF2YiRUAOw==)}.mce-visualblocks h2{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8Hybbx4oOuqgTynJd6bGlWg3DkJzoaUAAAOw==)}.mce-visualblocks h3{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIZjI8Hybbx4oOuqgTynJf2Ln2NOHpQpmhAAQA7)}.mce-visualblocks h4{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxInR0zqeAdhtJlXwV1oCll2HaWgAAOw==)}.mce-visualblocks h5{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxIoiuwjane4iq5GlW05GgIkIZUAAAOw==)}.mce-visualblocks h6{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxIoiuwjan04jep1iZ1XRlAo5bVgAAOw==)}.mce-visualblocks div:not([data-mce-bogus]){background-image:url(data:image/gif;base64,R0lGODlhEgAKAIABALu7u////yH5BAEAAAEALAAAAAASAAoAAAIfjI9poI0cgDywrhuxfbrzDEbQM2Ei5aRjmoySW4pAAQA7)}.mce-visualblocks section{background-image:url(data:image/gif;base64,R0lGODlhKAAKAIABALu7u////yH5BAEAAAEALAAAAAAoAAoAAAI5jI+pywcNY3sBWHdNrplytD2ellDeSVbp+GmWqaDqDMepc8t17Y4vBsK5hDyJMcI6KkuYU+jpjLoKADs=)}.mce-visualblocks article{background-image:url(data:image/gif;base64,R0lGODlhKgAKAIABALu7u////yH5BAEAAAEALAAAAAAqAAoAAAI6jI+pywkNY3wG0GBvrsd2tXGYSGnfiF7ikpXemTpOiJScasYoDJJrjsG9gkCJ0ag6KhmaIe3pjDYBBQA7)}.mce-visualblocks blockquote{background-image:url(data:image/gif;base64,R0lGODlhPgAKAIABALu7u////yH5BAEAAAEALAAAAAA+AAoAAAJPjI+py+0Knpz0xQDyuUhvfoGgIX5iSKZYgq5uNL5q69asZ8s5rrf0yZmpNkJZzFesBTu8TOlDVAabUyatguVhWduud3EyiUk45xhTTgMBBQA7)}.mce-visualblocks address{background-image:url(data:image/gif;base64,R0lGODlhLQAKAIABALu7u////yH5BAEAAAEALAAAAAAtAAoAAAI/jI+pywwNozSP1gDyyZcjb3UaRpXkWaXmZW4OqKLhBmLs+K263DkJK7OJeifh7FicKD9A1/IpGdKkyFpNmCkAADs=)}.mce-visualblocks pre{background-image:url(data:image/gif;base64,R0lGODlhFQAKAIABALu7uwAAACH5BAEAAAEALAAAAAAVAAoAAAIjjI+ZoN0cgDwSmnpz1NCueYERhnibZVKLNnbOq8IvKpJtVQAAOw==)}.mce-visualblocks figure{background-image:url(data:image/gif;base64,R0lGODlhJAAKAIAAALu7u////yH5BAEAAAEALAAAAAAkAAoAAAI0jI+py+2fwAHUSFvD3RlvG4HIp4nX5JFSpnZUJ6LlrM52OE7uSWosBHScgkSZj7dDKnWAAgA7)}.mce-visualblocks figcaption{border:1px dashed #bbb}.mce-visualblocks hgroup{background-image:url(data:image/gif;base64,R0lGODlhJwAKAIABALu7uwAAACH5BAEAAAEALAAAAAAnAAoAAAI3jI+pywYNI3uB0gpsRtt5fFnfNZaVSYJil4Wo03Hv6Z62uOCgiXH1kZIIJ8NiIxRrAZNMZAtQAAA7)}.mce-visualblocks aside{background-image:url(data:image/gif;base64,R0lGODlhHgAKAIABAKqqqv///yH5BAEAAAEALAAAAAAeAAoAAAItjI+pG8APjZOTzgtqy7I3f1yehmQcFY4WKZbqByutmW4aHUd6vfcVbgudgpYCADs=)}.mce-visualblocks ul{background-image:url(data:image/gif;base64,R0lGODlhDQAKAIAAALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybGuYnqUVSjvw26DzzXiqIDlVwAAOw==)}.mce-visualblocks ol{background-image:url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybH6HHt0qourxC6CvzXieHyeWQAAOw==)}.mce-visualblocks dl{background-image:url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybEOnmOvUoWznTqeuEjNSCqeGRUAOw==)}.mce-visualblocks:not([dir=rtl]) address,.mce-visualblocks:not([dir=rtl]) article,.mce-visualblocks:not([dir=rtl]) aside,.mce-visualblocks:not([dir=rtl]) blockquote,.mce-visualblocks:not([dir=rtl]) div:not([data-mce-bogus]),.mce-visualblocks:not([dir=rtl]) dl,.mce-visualblocks:not([dir=rtl]) figcaption,.mce-visualblocks:not([dir=rtl]) figure,.mce-visualblocks:not([dir=rtl]) h1,.mce-visualblocks:not([dir=rtl]) h2,.mce-visualblocks:not([dir=rtl]) h3,.mce-visualblocks:not([dir=rtl]) h4,.mce-visualblocks:not([dir=rtl]) h5,.mce-visualblocks:not([dir=rtl]) h6,.mce-visualblocks:not([dir=rtl]) hgroup,.mce-visualblocks:not([dir=rtl]) ol,.mce-visualblocks:not([dir=rtl]) p,.mce-visualblocks:not([dir=rtl]) pre,.mce-visualblocks:not([dir=rtl]) section,.mce-visualblocks:not([dir=rtl]) ul{margin-left:3px}.mce-visualblocks[dir=rtl] address,.mce-visualblocks[dir=rtl] article,.mce-visualblocks[dir=rtl] aside,.mce-visualblocks[dir=rtl] blockquote,.mce-visualblocks[dir=rtl] div:not([data-mce-bogus]),.mce-visualblocks[dir=rtl] dl,.mce-visualblocks[dir=rtl] figcaption,.mce-visualblocks[dir=rtl] figure,.mce-visualblocks[dir=rtl] h1,.mce-visualblocks[dir=rtl] h2,.mce-visualblocks[dir=rtl] h3,.mce-visualblocks[dir=rtl] h4,.mce-visualblocks[dir=rtl] h5,.mce-visualblocks[dir=rtl] h6,.mce-visualblocks[dir=rtl] hgroup,.mce-visualblocks[dir=rtl] ol,.mce-visualblocks[dir=rtl] p,.mce-visualblocks[dir=rtl] pre,.mce-visualblocks[dir=rtl] section,.mce-visualblocks[dir=rtl] ul{background-position-x:right;margin-right:3px}.mce-nbsp,.mce-shy{background:#aaa}.mce-shy::after{content:'-'}
diff --git a/public/tinymce/skins/ui/oxide/content.min.css b/public/tinymce/skins/ui/oxide/content.min.css
new file mode 100644
index 0000000..844858d
--- /dev/null
+++ b/public/tinymce/skins/ui/oxide/content.min.css
@@ -0,0 +1,7 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+.mce-content-body .mce-item-anchor{background:transparent url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'8'%20height%3D'12'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20d%3D'M0%200L8%200%208%2012%204.09117821%209%200%2012z'%2F%3E%3C%2Fsvg%3E%0A") no-repeat center;cursor:default;display:inline-block;height:12px!important;padding:0 2px;-webkit-user-modify:read-only;-moz-user-modify:read-only;-webkit-user-select:all;-moz-user-select:all;-ms-user-select:all;user-select:all;width:8px!important}.mce-content-body .mce-item-anchor[data-mce-selected]{outline-offset:1px}.tox-comments-visible .tox-comment{background-color:#fff0b7}.tox-comments-visible .tox-comment--active{background-color:#ffe168}.tox-checklist>li:not(.tox-checklist--hidden){list-style:none;margin:.25em 0}.tox-checklist>li:not(.tox-checklist--hidden)::before{content:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cg%20id%3D%22checklist-unchecked%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Crect%20id%3D%22Rectangle%22%20width%3D%2215%22%20height%3D%2215%22%20x%3D%22.5%22%20y%3D%22.5%22%20fill-rule%3D%22nonzero%22%20stroke%3D%22%234C4C4C%22%20rx%3D%222%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E%0A");cursor:pointer;height:1em;margin-left:-1.5em;margin-top:.125em;position:absolute;width:1em}.tox-checklist li:not(.tox-checklist--hidden).tox-checklist--checked::before{content:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cg%20id%3D%22checklist-checked%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Crect%20id%3D%22Rectangle%22%20width%3D%2216%22%20height%3D%2216%22%20fill%3D%22%234099FF%22%20fill-rule%3D%22nonzero%22%20rx%3D%222%22%2F%3E%3Cpath%20id%3D%22Path%22%20fill%3D%22%23FFF%22%20fill-rule%3D%22nonzero%22%20d%3D%22M11.5703186%2C3.14417309%20C11.8516238%2C2.73724603%2012.4164781%2C2.62829933%2012.83558%2C2.89774797%20C13.260121%2C3.17069355%2013.3759736%2C3.72932262%2013.0909105%2C4.14168582%20L7.7580587%2C11.8560195%20C7.43776896%2C12.3193404%206.76483983%2C12.3852142%206.35607322%2C11.9948725%20L3.02491697%2C8.8138662%20C2.66090143%2C8.46625845%202.65798871%2C7.89594698%203.01850234%2C7.54483354%20C3.373942%2C7.19866177%203.94940006%2C7.19592841%204.30829608%2C7.5386474%20L6.85276923%2C9.9684299%20L11.5703186%2C3.14417309%20Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E%0A")}[dir=rtl] .tox-checklist>li:not(.tox-checklist--hidden)::before{margin-left:0;margin-right:-1.5em}code[class*=language-],pre[class*=language-]{color:#000;background:0 0;text-shadow:0 1px #fff;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;tab-size:4;-webkit-hyphens:none;-ms-hyphens:none;hyphens:none}code[class*=language-] ::-moz-selection,code[class*=language-]::-moz-selection,pre[class*=language-] ::-moz-selection,pre[class*=language-]::-moz-selection{text-shadow:none;background:#b3d4fc}code[class*=language-] ::selection,code[class*=language-]::selection,pre[class*=language-] ::selection,pre[class*=language-]::selection{text-shadow:none;background:#b3d4fc}@media print{code[class*=language-],pre[class*=language-]{text-shadow:none}}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#f5f2f0}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#708090}.token.punctuation{color:#999}.namespace{opacity:.7}.token.boolean,.token.constant,.token.deleted,.token.number,.token.property,.token.symbol,.token.tag{color:#905}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string{color:#690}.language-css .token.string,.style .token.string,.token.entity,.token.operator,.token.url{color:#9a6e3a;background:hsla(0,0%,100%,.5)}.token.atrule,.token.attr-value,.token.keyword{color:#07a}.token.class-name,.token.function{color:#dd4a68}.token.important,.token.regex,.token.variable{color:#e90}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}.mce-content-body{overflow-wrap:break-word;word-wrap:break-word}.mce-content-body .mce-visual-caret{background-color:#000;background-color:currentColor;position:absolute}.mce-content-body .mce-visual-caret-hidden{display:none}.mce-content-body [data-mce-caret]{left:-1000px;margin:0;padding:0;position:absolute;right:auto;top:0}.mce-content-body .mce-offscreen-selection{left:-2000000px;max-width:1000000px;position:absolute}.mce-content-body [contentEditable=false]{cursor:default}.mce-content-body [contentEditable=true]{cursor:text}.tox-cursor-format-painter{cursor:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%3E%0A%20%20%3Cg%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%0A%20%20%20%20%3Cpath%20fill%3D%22%23000%22%20fill-rule%3D%22nonzero%22%20d%3D%22M15%2C6%20C15%2C5.45%2014.55%2C5%2014%2C5%20L6%2C5%20C5.45%2C5%205%2C5.45%205%2C6%20L5%2C10%20C5%2C10.55%205.45%2C11%206%2C11%20L14%2C11%20C14.55%2C11%2015%2C10.55%2015%2C10%20L15%2C9%20L16%2C9%20L16%2C12%20L9%2C12%20L9%2C19%20C9%2C19.55%209.45%2C20%2010%2C20%20L11%2C20%20C11.55%2C20%2012%2C19.55%2012%2C19%20L12%2C14%20L18%2C14%20L18%2C7%20L15%2C7%20L15%2C6%20Z%22%2F%3E%0A%20%20%20%20%3Cpath%20fill%3D%22%23000%22%20fill-rule%3D%22nonzero%22%20d%3D%22M1%2C1%20L8.25%2C1%20C8.66421356%2C1%209%2C1.33578644%209%2C1.75%20L9%2C1.75%20C9%2C2.16421356%208.66421356%2C2.5%208.25%2C2.5%20L2.5%2C2.5%20L2.5%2C8.25%20C2.5%2C8.66421356%202.16421356%2C9%201.75%2C9%20L1.75%2C9%20C1.33578644%2C9%201%2C8.66421356%201%2C8.25%20L1%2C1%20Z%22%2F%3E%0A%20%20%3C%2Fg%3E%0A%3C%2Fsvg%3E%0A"),default}.mce-content-body figure.align-left{float:left}.mce-content-body figure.align-right{float:right}.mce-content-body figure.image.align-center{display:table;margin-left:auto;margin-right:auto}.mce-preview-object{border:1px solid gray;display:inline-block;line-height:0;margin:0 2px 0 2px;position:relative}.mce-preview-object .mce-shim{background:url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7);height:100%;left:0;position:absolute;top:0;width:100%}.mce-preview-object[data-mce-selected="2"] .mce-shim{display:none}.mce-object{background:transparent url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M4%203h16a1%201%200%200%201%201%201v16a1%201%200%200%201-1%201H4a1%201%200%200%201-1-1V4a1%201%200%200%201%201-1zm1%202v14h14V5H5zm4.79%202.565l5.64%204.028a.5.5%200%200%201%200%20.814l-5.64%204.028a.5.5%200%200%201-.79-.407V7.972a.5.5%200%200%201%20.79-.407z%22%2F%3E%3C%2Fsvg%3E%0A") no-repeat center;border:1px dashed #aaa}.mce-pagebreak{border:1px dashed #aaa;cursor:default;display:block;height:5px;margin-top:15px;page-break-before:always;width:100%}@media print{.mce-pagebreak{border:0}}.tiny-pageembed .mce-shim{background:url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7);height:100%;left:0;position:absolute;top:0;width:100%}.tiny-pageembed[data-mce-selected="2"] .mce-shim{display:none}.tiny-pageembed{display:inline-block;position:relative}.tiny-pageembed--16by9,.tiny-pageembed--1by1,.tiny-pageembed--21by9,.tiny-pageembed--4by3{display:block;overflow:hidden;padding:0;position:relative;width:100%}.tiny-pageembed--21by9{padding-top:42.857143%}.tiny-pageembed--16by9{padding-top:56.25%}.tiny-pageembed--4by3{padding-top:75%}.tiny-pageembed--1by1{padding-top:100%}.tiny-pageembed--16by9 iframe,.tiny-pageembed--1by1 iframe,.tiny-pageembed--21by9 iframe,.tiny-pageembed--4by3 iframe{border:0;height:100%;left:0;position:absolute;top:0;width:100%}.mce-content-body[data-mce-placeholder]{position:relative}.mce-content-body[data-mce-placeholder]:not(.mce-visualblocks)::before{color:rgba(34,47,62,.7);content:attr(data-mce-placeholder);position:absolute}.mce-content-body:not([dir=rtl])[data-mce-placeholder]:not(.mce-visualblocks)::before{left:1px}.mce-content-body[dir=rtl][data-mce-placeholder]:not(.mce-visualblocks)::before{right:1px}.mce-content-body div.mce-resizehandle{background-color:#4099ff;border-color:#4099ff;border-style:solid;border-width:1px;box-sizing:border-box;height:10px;position:absolute;width:10px;z-index:1298}.mce-content-body div.mce-resizehandle:hover{background-color:#4099ff}.mce-content-body div.mce-resizehandle:nth-of-type(1){cursor:nwse-resize}.mce-content-body div.mce-resizehandle:nth-of-type(2){cursor:nesw-resize}.mce-content-body div.mce-resizehandle:nth-of-type(3){cursor:nwse-resize}.mce-content-body div.mce-resizehandle:nth-of-type(4){cursor:nesw-resize}.mce-content-body .mce-resize-backdrop{z-index:10000}.mce-content-body .mce-clonedresizable{cursor:default;opacity:.5;outline:1px dashed #000;position:absolute;z-index:10001}.mce-content-body .mce-clonedresizable.mce-resizetable-columns td,.mce-content-body .mce-clonedresizable.mce-resizetable-columns th{border:0}.mce-content-body .mce-resize-helper{background:#555;background:rgba(0,0,0,.75);border:1px;border-radius:3px;color:#fff;display:none;font-family:sans-serif;font-size:12px;line-height:14px;margin:5px 10px;padding:5px;position:absolute;white-space:nowrap;z-index:10002}.tox-rtc-user-selection{position:relative}.tox-rtc-user-cursor{bottom:0;cursor:default;position:absolute;top:0;width:2px}.tox-rtc-user-cursor::before{background-color:inherit;border-radius:50%;content:'';display:block;height:8px;position:absolute;right:-3px;top:-3px;width:8px}.tox-rtc-user-cursor:hover::after{background-color:inherit;border-radius:100px;box-sizing:border-box;color:#fff;content:attr(data-user);display:block;font-size:12px;font-weight:700;left:-5px;min-height:8px;min-width:8px;padding:0 12px;position:absolute;top:-11px;white-space:nowrap;z-index:1000}.tox-rtc-user-selection--1 .tox-rtc-user-cursor{background-color:#2dc26b}.tox-rtc-user-selection--2 .tox-rtc-user-cursor{background-color:#e03e2d}.tox-rtc-user-selection--3 .tox-rtc-user-cursor{background-color:#f1c40f}.tox-rtc-user-selection--4 .tox-rtc-user-cursor{background-color:#3598db}.tox-rtc-user-selection--5 .tox-rtc-user-cursor{background-color:#b96ad9}.tox-rtc-user-selection--6 .tox-rtc-user-cursor{background-color:#e67e23}.tox-rtc-user-selection--7 .tox-rtc-user-cursor{background-color:#aaa69d}.tox-rtc-user-selection--8 .tox-rtc-user-cursor{background-color:#f368e0}.tox-rtc-remote-image{background:#eaeaea url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2236%22%20height%3D%2212%22%20viewBox%3D%220%200%2036%2012%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Ccircle%20cx%3D%226%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%20%20%3Ccircle%20cx%3D%2218%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20begin%3D%22.33s%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%20%20%3Ccircle%20cx%3D%2230%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20begin%3D%22.66s%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%3C%2Fsvg%3E%0A") no-repeat center center;border:1px solid #ccc;min-height:240px;min-width:320px}.mce-match-marker{background:#aaa;color:#fff}.mce-match-marker-selected{background:#39f;color:#fff}.mce-match-marker-selected::-moz-selection{background:#39f;color:#fff}.mce-match-marker-selected::selection{background:#39f;color:#fff}.mce-content-body audio[data-mce-selected],.mce-content-body embed[data-mce-selected],.mce-content-body img[data-mce-selected],.mce-content-body object[data-mce-selected],.mce-content-body table[data-mce-selected],.mce-content-body video[data-mce-selected]{outline:3px solid #b4d7ff}.mce-content-body hr[data-mce-selected]{outline:3px solid #b4d7ff;outline-offset:1px}.mce-content-body [contentEditable=false] [contentEditable=true]:focus{outline:3px solid #b4d7ff}.mce-content-body [contentEditable=false] [contentEditable=true]:hover{outline:3px solid #b4d7ff}.mce-content-body [contentEditable=false][data-mce-selected]{cursor:not-allowed;outline:3px solid #b4d7ff}.mce-content-body.mce-content-readonly [contentEditable=true]:focus,.mce-content-body.mce-content-readonly [contentEditable=true]:hover{outline:0}.mce-content-body [data-mce-selected=inline-boundary]{background-color:#b4d7ff}.mce-content-body .mce-edit-focus{outline:3px solid #b4d7ff}.mce-content-body td[data-mce-selected],.mce-content-body th[data-mce-selected]{position:relative}.mce-content-body td[data-mce-selected]::-moz-selection,.mce-content-body th[data-mce-selected]::-moz-selection{background:0 0}.mce-content-body td[data-mce-selected]::selection,.mce-content-body th[data-mce-selected]::selection{background:0 0}.mce-content-body td[data-mce-selected] *,.mce-content-body th[data-mce-selected] *{outline:0;-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.mce-content-body td[data-mce-selected]::after,.mce-content-body th[data-mce-selected]::after{background-color:rgba(180,215,255,.7);border:1px solid rgba(180,215,255,.7);bottom:-1px;content:'';left:-1px;mix-blend-mode:multiply;position:absolute;right:-1px;top:-1px}@media screen and (-ms-high-contrast:active),(-ms-high-contrast:none){.mce-content-body td[data-mce-selected]::after,.mce-content-body th[data-mce-selected]::after{border-color:rgba(0,84,180,.7)}}.mce-content-body img::-moz-selection{background:0 0}.mce-content-body img::selection{background:0 0}.ephox-snooker-resizer-bar{background-color:#b4d7ff;opacity:0;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.ephox-snooker-resizer-cols{cursor:col-resize}.ephox-snooker-resizer-rows{cursor:row-resize}.ephox-snooker-resizer-bar.ephox-snooker-resizer-bar-dragging{opacity:1}.mce-spellchecker-word{background-image:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'4'%20height%3D'4'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20stroke%3D'%23ff0000'%20fill%3D'none'%20stroke-linecap%3D'round'%20stroke-opacity%3D'.75'%20d%3D'M0%203L2%201%204%203'%2F%3E%3C%2Fsvg%3E%0A");background-position:0 calc(100% + 1px);background-repeat:repeat-x;background-size:auto 6px;cursor:default;height:2rem}.mce-spellchecker-grammar{background-image:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'4'%20height%3D'4'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20stroke%3D'%2300A835'%20fill%3D'none'%20stroke-linecap%3D'round'%20d%3D'M0%203L2%201%204%203'%2F%3E%3C%2Fsvg%3E%0A");background-position:0 calc(100% + 1px);background-repeat:repeat-x;background-size:auto 6px;cursor:default}.mce-toc{border:1px solid gray}.mce-toc h2{margin:4px}.mce-toc li{list-style-type:none}.mce-item-table:not([border]),.mce-item-table:not([border]) caption,.mce-item-table:not([border]) td,.mce-item-table:not([border]) th,.mce-item-table[border="0"],.mce-item-table[border="0"] caption,.mce-item-table[border="0"] td,.mce-item-table[border="0"] th,table[style*="border-width: 0px"],table[style*="border-width: 0px"] caption,table[style*="border-width: 0px"] td,table[style*="border-width: 0px"] th{border:1px dashed #bbb}.mce-visualblocks address,.mce-visualblocks article,.mce-visualblocks aside,.mce-visualblocks blockquote,.mce-visualblocks div:not([data-mce-bogus]),.mce-visualblocks dl,.mce-visualblocks figcaption,.mce-visualblocks figure,.mce-visualblocks h1,.mce-visualblocks h2,.mce-visualblocks h3,.mce-visualblocks h4,.mce-visualblocks h5,.mce-visualblocks h6,.mce-visualblocks hgroup,.mce-visualblocks ol,.mce-visualblocks p,.mce-visualblocks pre,.mce-visualblocks section,.mce-visualblocks ul{background-repeat:no-repeat;border:1px dashed #bbb;margin-left:3px;padding-top:10px}.mce-visualblocks p{background-image:url(data:image/gif;base64,R0lGODlhCQAJAJEAAAAAAP///7u7u////yH5BAEAAAMALAAAAAAJAAkAAAIQnG+CqCN/mlyvsRUpThG6AgA7)}.mce-visualblocks h1{background-image:url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybGu1JuxHoAfRNRW3TWXyF2YiRUAOw==)}.mce-visualblocks h2{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8Hybbx4oOuqgTynJd6bGlWg3DkJzoaUAAAOw==)}.mce-visualblocks h3{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIZjI8Hybbx4oOuqgTynJf2Ln2NOHpQpmhAAQA7)}.mce-visualblocks h4{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxInR0zqeAdhtJlXwV1oCll2HaWgAAOw==)}.mce-visualblocks h5{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxIoiuwjane4iq5GlW05GgIkIZUAAAOw==)}.mce-visualblocks h6{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxIoiuwjan04jep1iZ1XRlAo5bVgAAOw==)}.mce-visualblocks div:not([data-mce-bogus]){background-image:url(data:image/gif;base64,R0lGODlhEgAKAIABALu7u////yH5BAEAAAEALAAAAAASAAoAAAIfjI9poI0cgDywrhuxfbrzDEbQM2Ei5aRjmoySW4pAAQA7)}.mce-visualblocks section{background-image:url(data:image/gif;base64,R0lGODlhKAAKAIABALu7u////yH5BAEAAAEALAAAAAAoAAoAAAI5jI+pywcNY3sBWHdNrplytD2ellDeSVbp+GmWqaDqDMepc8t17Y4vBsK5hDyJMcI6KkuYU+jpjLoKADs=)}.mce-visualblocks article{background-image:url(data:image/gif;base64,R0lGODlhKgAKAIABALu7u////yH5BAEAAAEALAAAAAAqAAoAAAI6jI+pywkNY3wG0GBvrsd2tXGYSGnfiF7ikpXemTpOiJScasYoDJJrjsG9gkCJ0ag6KhmaIe3pjDYBBQA7)}.mce-visualblocks blockquote{background-image:url(data:image/gif;base64,R0lGODlhPgAKAIABALu7u////yH5BAEAAAEALAAAAAA+AAoAAAJPjI+py+0Knpz0xQDyuUhvfoGgIX5iSKZYgq5uNL5q69asZ8s5rrf0yZmpNkJZzFesBTu8TOlDVAabUyatguVhWduud3EyiUk45xhTTgMBBQA7)}.mce-visualblocks address{background-image:url(data:image/gif;base64,R0lGODlhLQAKAIABALu7u////yH5BAEAAAEALAAAAAAtAAoAAAI/jI+pywwNozSP1gDyyZcjb3UaRpXkWaXmZW4OqKLhBmLs+K263DkJK7OJeifh7FicKD9A1/IpGdKkyFpNmCkAADs=)}.mce-visualblocks pre{background-image:url(data:image/gif;base64,R0lGODlhFQAKAIABALu7uwAAACH5BAEAAAEALAAAAAAVAAoAAAIjjI+ZoN0cgDwSmnpz1NCueYERhnibZVKLNnbOq8IvKpJtVQAAOw==)}.mce-visualblocks figure{background-image:url(data:image/gif;base64,R0lGODlhJAAKAIAAALu7u////yH5BAEAAAEALAAAAAAkAAoAAAI0jI+py+2fwAHUSFvD3RlvG4HIp4nX5JFSpnZUJ6LlrM52OE7uSWosBHScgkSZj7dDKnWAAgA7)}.mce-visualblocks figcaption{border:1px dashed #bbb}.mce-visualblocks hgroup{background-image:url(data:image/gif;base64,R0lGODlhJwAKAIABALu7uwAAACH5BAEAAAEALAAAAAAnAAoAAAI3jI+pywYNI3uB0gpsRtt5fFnfNZaVSYJil4Wo03Hv6Z62uOCgiXH1kZIIJ8NiIxRrAZNMZAtQAAA7)}.mce-visualblocks aside{background-image:url(data:image/gif;base64,R0lGODlhHgAKAIABAKqqqv///yH5BAEAAAEALAAAAAAeAAoAAAItjI+pG8APjZOTzgtqy7I3f1yehmQcFY4WKZbqByutmW4aHUd6vfcVbgudgpYCADs=)}.mce-visualblocks ul{background-image:url(data:image/gif;base64,R0lGODlhDQAKAIAAALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybGuYnqUVSjvw26DzzXiqIDlVwAAOw==)}.mce-visualblocks ol{background-image:url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybH6HHt0qourxC6CvzXieHyeWQAAOw==)}.mce-visualblocks dl{background-image:url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybEOnmOvUoWznTqeuEjNSCqeGRUAOw==)}.mce-visualblocks:not([dir=rtl]) address,.mce-visualblocks:not([dir=rtl]) article,.mce-visualblocks:not([dir=rtl]) aside,.mce-visualblocks:not([dir=rtl]) blockquote,.mce-visualblocks:not([dir=rtl]) div:not([data-mce-bogus]),.mce-visualblocks:not([dir=rtl]) dl,.mce-visualblocks:not([dir=rtl]) figcaption,.mce-visualblocks:not([dir=rtl]) figure,.mce-visualblocks:not([dir=rtl]) h1,.mce-visualblocks:not([dir=rtl]) h2,.mce-visualblocks:not([dir=rtl]) h3,.mce-visualblocks:not([dir=rtl]) h4,.mce-visualblocks:not([dir=rtl]) h5,.mce-visualblocks:not([dir=rtl]) h6,.mce-visualblocks:not([dir=rtl]) hgroup,.mce-visualblocks:not([dir=rtl]) ol,.mce-visualblocks:not([dir=rtl]) p,.mce-visualblocks:not([dir=rtl]) pre,.mce-visualblocks:not([dir=rtl]) section,.mce-visualblocks:not([dir=rtl]) ul{margin-left:3px}.mce-visualblocks[dir=rtl] address,.mce-visualblocks[dir=rtl] article,.mce-visualblocks[dir=rtl] aside,.mce-visualblocks[dir=rtl] blockquote,.mce-visualblocks[dir=rtl] div:not([data-mce-bogus]),.mce-visualblocks[dir=rtl] dl,.mce-visualblocks[dir=rtl] figcaption,.mce-visualblocks[dir=rtl] figure,.mce-visualblocks[dir=rtl] h1,.mce-visualblocks[dir=rtl] h2,.mce-visualblocks[dir=rtl] h3,.mce-visualblocks[dir=rtl] h4,.mce-visualblocks[dir=rtl] h5,.mce-visualblocks[dir=rtl] h6,.mce-visualblocks[dir=rtl] hgroup,.mce-visualblocks[dir=rtl] ol,.mce-visualblocks[dir=rtl] p,.mce-visualblocks[dir=rtl] pre,.mce-visualblocks[dir=rtl] section,.mce-visualblocks[dir=rtl] ul{background-position-x:right;margin-right:3px}.mce-nbsp,.mce-shy{background:#aaa}.mce-shy::after{content:'-'}body{font-family:sans-serif}table{border-collapse:collapse}
diff --git a/public/tinymce/skins/ui/oxide/content.mobile.css b/public/tinymce/skins/ui/oxide/content.mobile.css
new file mode 100644
index 0000000..4bdb8ba
--- /dev/null
+++ b/public/tinymce/skins/ui/oxide/content.mobile.css
@@ -0,0 +1,29 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+.tinymce-mobile-unfocused-selections .tinymce-mobile-unfocused-selection {
+  /* Note: this file is used inside the content, so isn't part of theming */
+  background-color: green;
+  display: inline-block;
+  opacity: 0.5;
+  position: absolute;
+}
+body {
+  -webkit-text-size-adjust: none;
+}
+body img {
+  /* this is related to the content margin */
+  max-width: 96vw;
+}
+body table img {
+  max-width: 95%;
+}
+body {
+  font-family: sans-serif;
+}
+table {
+  border-collapse: collapse;
+}
diff --git a/public/tinymce/skins/ui/oxide/content.mobile.min.css b/public/tinymce/skins/ui/oxide/content.mobile.min.css
new file mode 100644
index 0000000..35f7dc0
--- /dev/null
+++ b/public/tinymce/skins/ui/oxide/content.mobile.min.css
@@ -0,0 +1,7 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+.tinymce-mobile-unfocused-selections .tinymce-mobile-unfocused-selection{background-color:green;display:inline-block;opacity:.5;position:absolute}body{-webkit-text-size-adjust:none}body img{max-width:96vw}body table img{max-width:95%}body{font-family:sans-serif}table{border-collapse:collapse}
diff --git a/public/tinymce/skins/ui/oxide/fonts/tinymce-mobile.woff b/public/tinymce/skins/ui/oxide/fonts/tinymce-mobile.woff
new file mode 100644
index 0000000..1e3be03
Binary files /dev/null and b/public/tinymce/skins/ui/oxide/fonts/tinymce-mobile.woff differ
diff --git a/public/tinymce/skins/ui/oxide/skin.css b/public/tinymce/skins/ui/oxide/skin.css
new file mode 100644
index 0000000..49a82fa
--- /dev/null
+++ b/public/tinymce/skins/ui/oxide/skin.css
@@ -0,0 +1,3047 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+.tox {
+  box-shadow: none;
+  box-sizing: content-box;
+  color: #222f3e;
+  cursor: auto;
+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
+  font-size: 16px;
+  font-style: normal;
+  font-weight: normal;
+  line-height: normal;
+  -webkit-tap-highlight-color: transparent;
+  text-decoration: none;
+  text-shadow: none;
+  text-transform: none;
+  vertical-align: initial;
+  white-space: normal;
+}
+.tox *:not(svg):not(rect) {
+  box-sizing: inherit;
+  color: inherit;
+  cursor: inherit;
+  direction: inherit;
+  font-family: inherit;
+  font-size: inherit;
+  font-style: inherit;
+  font-weight: inherit;
+  line-height: inherit;
+  -webkit-tap-highlight-color: inherit;
+  text-align: inherit;
+  text-decoration: inherit;
+  text-shadow: inherit;
+  text-transform: inherit;
+  vertical-align: inherit;
+  white-space: inherit;
+}
+.tox *:not(svg):not(rect) {
+  /* stylelint-disable-line no-duplicate-selectors */
+  background: transparent;
+  border: 0;
+  box-shadow: none;
+  float: none;
+  height: auto;
+  margin: 0;
+  max-width: none;
+  outline: 0;
+  padding: 0;
+  position: static;
+  width: auto;
+}
+.tox:not([dir=rtl]) {
+  direction: ltr;
+  text-align: left;
+}
+.tox[dir=rtl] {
+  direction: rtl;
+  text-align: right;
+}
+.tox-tinymce {
+  border: 1px solid #cccccc;
+  border-radius: 0;
+  box-shadow: none;
+  box-sizing: border-box;
+  display: flex;
+  flex-direction: column;
+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
+  overflow: hidden;
+  position: relative;
+  visibility: inherit !important;
+}
+.tox-tinymce-inline {
+  border: none;
+  box-shadow: none;
+}
+.tox-tinymce-inline .tox-editor-header {
+  background-color: transparent;
+  border: 1px solid #cccccc;
+  border-radius: 0;
+  box-shadow: none;
+}
+.tox-tinymce-aux {
+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
+  z-index: 1300;
+}
+.tox-tinymce *:focus,
+.tox-tinymce-aux *:focus {
+  outline: none;
+}
+button::-moz-focus-inner {
+  border: 0;
+}
+.tox[dir=rtl] .tox-icon--flip svg {
+  transform: rotateY(180deg);
+}
+.tox .accessibility-issue__header {
+  align-items: center;
+  display: flex;
+  margin-bottom: 4px;
+}
+.tox .accessibility-issue__description {
+  align-items: stretch;
+  border: 1px solid #cccccc;
+  border-radius: 3px;
+  display: flex;
+  justify-content: space-between;
+}
+.tox .accessibility-issue__description > div {
+  padding-bottom: 4px;
+}
+.tox .accessibility-issue__description > div > div {
+  align-items: center;
+  display: flex;
+  margin-bottom: 4px;
+}
+.tox .accessibility-issue__description > *:last-child:not(:only-child) {
+  border-color: #cccccc;
+  border-style: solid;
+}
+.tox .accessibility-issue__repair {
+  margin-top: 16px;
+}
+.tox .tox-dialog__body-content .accessibility-issue--info .accessibility-issue__description {
+  background-color: rgba(32, 122, 183, 0.1);
+  border-color: rgba(32, 122, 183, 0.4);
+  color: #222f3e;
+}
+.tox .tox-dialog__body-content .accessibility-issue--info .accessibility-issue__description > *:last-child {
+  border-color: rgba(32, 122, 183, 0.4);
+}
+.tox .tox-dialog__body-content .accessibility-issue--info .tox-form__group h2 {
+  color: #207ab7;
+}
+.tox .tox-dialog__body-content .accessibility-issue--info .tox-icon svg {
+  fill: #207ab7;
+}
+.tox .tox-dialog__body-content .accessibility-issue--info a .tox-icon {
+  color: #207ab7;
+}
+.tox .tox-dialog__body-content .accessibility-issue--warn .accessibility-issue__description {
+  background-color: rgba(255, 165, 0, 0.1);
+  border-color: rgba(255, 165, 0, 0.5);
+  color: #222f3e;
+}
+.tox .tox-dialog__body-content .accessibility-issue--warn .accessibility-issue__description > *:last-child {
+  border-color: rgba(255, 165, 0, 0.5);
+}
+.tox .tox-dialog__body-content .accessibility-issue--warn .tox-form__group h2 {
+  color: #cc8500;
+}
+.tox .tox-dialog__body-content .accessibility-issue--warn .tox-icon svg {
+  fill: #cc8500;
+}
+.tox .tox-dialog__body-content .accessibility-issue--warn a .tox-icon {
+  color: #cc8500;
+}
+.tox .tox-dialog__body-content .accessibility-issue--error .accessibility-issue__description {
+  background-color: rgba(204, 0, 0, 0.1);
+  border-color: rgba(204, 0, 0, 0.4);
+  color: #222f3e;
+}
+.tox .tox-dialog__body-content .accessibility-issue--error .accessibility-issue__description > *:last-child {
+  border-color: rgba(204, 0, 0, 0.4);
+}
+.tox .tox-dialog__body-content .accessibility-issue--error .tox-form__group h2 {
+  color: #c00;
+}
+.tox .tox-dialog__body-content .accessibility-issue--error .tox-icon svg {
+  fill: #c00;
+}
+.tox .tox-dialog__body-content .accessibility-issue--error a .tox-icon {
+  color: #c00;
+}
+.tox .tox-dialog__body-content .accessibility-issue--success .accessibility-issue__description {
+  background-color: rgba(120, 171, 70, 0.1);
+  border-color: rgba(120, 171, 70, 0.4);
+  color: #222f3e;
+}
+.tox .tox-dialog__body-content .accessibility-issue--success .accessibility-issue__description > *:last-child {
+  border-color: rgba(120, 171, 70, 0.4);
+}
+.tox .tox-dialog__body-content .accessibility-issue--success .tox-form__group h2 {
+  color: #78AB46;
+}
+.tox .tox-dialog__body-content .accessibility-issue--success .tox-icon svg {
+  fill: #78AB46;
+}
+.tox .tox-dialog__body-content .accessibility-issue--success a .tox-icon {
+  color: #78AB46;
+}
+.tox .tox-dialog__body-content .accessibility-issue__header h1,
+.tox .tox-dialog__body-content .tox-form__group .accessibility-issue__description h2 {
+  margin-top: 0;
+}
+.tox:not([dir=rtl]) .tox-dialog__body-content .accessibility-issue__header .tox-button {
+  margin-left: 4px;
+}
+.tox:not([dir=rtl]) .tox-dialog__body-content .accessibility-issue__header > *:nth-last-child(2) {
+  margin-left: auto;
+}
+.tox:not([dir=rtl]) .tox-dialog__body-content .accessibility-issue__description {
+  padding: 4px 4px 4px 8px;
+}
+.tox:not([dir=rtl]) .tox-dialog__body-content .accessibility-issue__description > *:last-child {
+  border-left-width: 1px;
+  padding-left: 4px;
+}
+.tox[dir=rtl] .tox-dialog__body-content .accessibility-issue__header .tox-button {
+  margin-right: 4px;
+}
+.tox[dir=rtl] .tox-dialog__body-content .accessibility-issue__header > *:nth-last-child(2) {
+  margin-right: auto;
+}
+.tox[dir=rtl] .tox-dialog__body-content .accessibility-issue__description {
+  padding: 4px 8px 4px 4px;
+}
+.tox[dir=rtl] .tox-dialog__body-content .accessibility-issue__description > *:last-child {
+  border-right-width: 1px;
+  padding-right: 4px;
+}
+.tox .tox-anchorbar {
+  display: flex;
+  flex: 0 0 auto;
+}
+.tox .tox-bar {
+  display: flex;
+  flex: 0 0 auto;
+}
+.tox .tox-button {
+  background-color: #207ab7;
+  background-image: none;
+  background-position: 0 0;
+  background-repeat: repeat;
+  border-color: #207ab7;
+  border-radius: 3px;
+  border-style: solid;
+  border-width: 1px;
+  box-shadow: none;
+  box-sizing: border-box;
+  color: #fff;
+  cursor: pointer;
+  display: inline-block;
+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
+  font-size: 14px;
+  font-style: normal;
+  font-weight: bold;
+  letter-spacing: normal;
+  line-height: 24px;
+  margin: 0;
+  outline: none;
+  padding: 4px 16px;
+  text-align: center;
+  text-decoration: none;
+  text-transform: none;
+  white-space: nowrap;
+}
+.tox .tox-button[disabled] {
+  background-color: #207ab7;
+  background-image: none;
+  border-color: #207ab7;
+  box-shadow: none;
+  color: rgba(255, 255, 255, 0.5);
+  cursor: not-allowed;
+}
+.tox .tox-button:focus:not(:disabled) {
+  background-color: #1c6ca1;
+  background-image: none;
+  border-color: #1c6ca1;
+  box-shadow: none;
+  color: #fff;
+}
+.tox .tox-button:hover:not(:disabled) {
+  background-color: #1c6ca1;
+  background-image: none;
+  border-color: #1c6ca1;
+  box-shadow: none;
+  color: #fff;
+}
+.tox .tox-button:active:not(:disabled) {
+  background-color: #185d8c;
+  background-image: none;
+  border-color: #185d8c;
+  box-shadow: none;
+  color: #fff;
+}
+.tox .tox-button--secondary {
+  background-color: #f0f0f0;
+  background-image: none;
+  background-position: 0 0;
+  background-repeat: repeat;
+  border-color: #f0f0f0;
+  border-radius: 3px;
+  border-style: solid;
+  border-width: 1px;
+  box-shadow: none;
+  color: #222f3e;
+  font-size: 14px;
+  font-style: normal;
+  font-weight: bold;
+  letter-spacing: normal;
+  outline: none;
+  padding: 4px 16px;
+  text-decoration: none;
+  text-transform: none;
+}
+.tox .tox-button--secondary[disabled] {
+  background-color: #f0f0f0;
+  background-image: none;
+  border-color: #f0f0f0;
+  box-shadow: none;
+  color: rgba(34, 47, 62, 0.5);
+}
+.tox .tox-button--secondary:focus:not(:disabled) {
+  background-color: #e3e3e3;
+  background-image: none;
+  border-color: #e3e3e3;
+  box-shadow: none;
+  color: #222f3e;
+}
+.tox .tox-button--secondary:hover:not(:disabled) {
+  background-color: #e3e3e3;
+  background-image: none;
+  border-color: #e3e3e3;
+  box-shadow: none;
+  color: #222f3e;
+}
+.tox .tox-button--secondary:active:not(:disabled) {
+  background-color: #d6d6d6;
+  background-image: none;
+  border-color: #d6d6d6;
+  box-shadow: none;
+  color: #222f3e;
+}
+.tox .tox-button--icon,
+.tox .tox-button.tox-button--icon,
+.tox .tox-button.tox-button--secondary.tox-button--icon {
+  padding: 4px;
+}
+.tox .tox-button--icon .tox-icon svg,
+.tox .tox-button.tox-button--icon .tox-icon svg,
+.tox .tox-button.tox-button--secondary.tox-button--icon .tox-icon svg {
+  display: block;
+  fill: currentColor;
+}
+.tox .tox-button-link {
+  background: 0;
+  border: none;
+  box-sizing: border-box;
+  cursor: pointer;
+  display: inline-block;
+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
+  font-size: 16px;
+  font-weight: normal;
+  line-height: 1.3;
+  margin: 0;
+  padding: 0;
+  white-space: nowrap;
+}
+.tox .tox-button-link--sm {
+  font-size: 14px;
+}
+.tox .tox-button--naked {
+  background-color: transparent;
+  border-color: transparent;
+  box-shadow: unset;
+  color: #222f3e;
+}
+.tox .tox-button--naked[disabled] {
+  background-color: #f0f0f0;
+  border-color: #f0f0f0;
+  box-shadow: none;
+  color: rgba(34, 47, 62, 0.5);
+}
+.tox .tox-button--naked:hover:not(:disabled) {
+  background-color: #e3e3e3;
+  border-color: #e3e3e3;
+  box-shadow: none;
+  color: #222f3e;
+}
+.tox .tox-button--naked:focus:not(:disabled) {
+  background-color: #e3e3e3;
+  border-color: #e3e3e3;
+  box-shadow: none;
+  color: #222f3e;
+}
+.tox .tox-button--naked:active:not(:disabled) {
+  background-color: #d6d6d6;
+  border-color: #d6d6d6;
+  box-shadow: none;
+  color: #222f3e;
+}
+.tox .tox-button--naked .tox-icon svg {
+  fill: currentColor;
+}
+.tox .tox-button--naked.tox-button--icon:hover:not(:disabled) {
+  color: #222f3e;
+}
+.tox .tox-checkbox {
+  align-items: center;
+  border-radius: 3px;
+  cursor: pointer;
+  display: flex;
+  height: 36px;
+  min-width: 36px;
+}
+.tox .tox-checkbox__input {
+  /* Hide from view but visible to screen readers */
+  height: 1px;
+  overflow: hidden;
+  position: absolute;
+  top: auto;
+  width: 1px;
+}
+.tox .tox-checkbox__icons {
+  align-items: center;
+  border-radius: 3px;
+  box-shadow: 0 0 0 2px transparent;
+  box-sizing: content-box;
+  display: flex;
+  height: 24px;
+  justify-content: center;
+  padding: calc(4px - 1px);
+  width: 24px;
+}
+.tox .tox-checkbox__icons .tox-checkbox-icon__unchecked svg {
+  display: block;
+  fill: rgba(34, 47, 62, 0.3);
+}
+.tox .tox-checkbox__icons .tox-checkbox-icon__indeterminate svg {
+  display: none;
+  fill: #207ab7;
+}
+.tox .tox-checkbox__icons .tox-checkbox-icon__checked svg {
+  display: none;
+  fill: #207ab7;
+}
+.tox .tox-checkbox--disabled {
+  color: rgba(34, 47, 62, 0.5);
+  cursor: not-allowed;
+}
+.tox .tox-checkbox--disabled .tox-checkbox__icons .tox-checkbox-icon__checked svg {
+  fill: rgba(34, 47, 62, 0.5);
+}
+.tox .tox-checkbox--disabled .tox-checkbox__icons .tox-checkbox-icon__unchecked svg {
+  fill: rgba(34, 47, 62, 0.5);
+}
+.tox .tox-checkbox--disabled .tox-checkbox__icons .tox-checkbox-icon__indeterminate svg {
+  fill: rgba(34, 47, 62, 0.5);
+}
+.tox input.tox-checkbox__input:checked + .tox-checkbox__icons .tox-checkbox-icon__unchecked svg {
+  display: none;
+}
+.tox input.tox-checkbox__input:checked + .tox-checkbox__icons .tox-checkbox-icon__checked svg {
+  display: block;
+}
+.tox input.tox-checkbox__input:indeterminate + .tox-checkbox__icons .tox-checkbox-icon__unchecked svg {
+  display: none;
+}
+.tox input.tox-checkbox__input:indeterminate + .tox-checkbox__icons .tox-checkbox-icon__indeterminate svg {
+  display: block;
+}
+.tox input.tox-checkbox__input:focus + .tox-checkbox__icons {
+  border-radius: 3px;
+  box-shadow: inset 0 0 0 1px #207ab7;
+  padding: calc(4px - 1px);
+}
+.tox:not([dir=rtl]) .tox-checkbox__label {
+  margin-left: 4px;
+}
+.tox:not([dir=rtl]) .tox-checkbox__input {
+  left: -10000px;
+}
+.tox:not([dir=rtl]) .tox-bar .tox-checkbox {
+  margin-left: 4px;
+}
+.tox[dir=rtl] .tox-checkbox__label {
+  margin-right: 4px;
+}
+.tox[dir=rtl] .tox-checkbox__input {
+  right: -10000px;
+}
+.tox[dir=rtl] .tox-bar .tox-checkbox {
+  margin-right: 4px;
+}
+.tox {
+  /* stylelint-disable-next-line no-descending-specificity */
+}
+.tox .tox-collection--toolbar .tox-collection__group {
+  display: flex;
+  padding: 0;
+}
+.tox .tox-collection--grid .tox-collection__group {
+  display: flex;
+  flex-wrap: wrap;
+  max-height: 208px;
+  overflow-x: hidden;
+  overflow-y: auto;
+  padding: 0;
+}
+.tox .tox-collection--list .tox-collection__group {
+  border-bottom-width: 0;
+  border-color: #cccccc;
+  border-left-width: 0;
+  border-right-width: 0;
+  border-style: solid;
+  border-top-width: 1px;
+  padding: 4px 0;
+}
+.tox .tox-collection--list .tox-collection__group:first-child {
+  border-top-width: 0;
+}
+.tox .tox-collection__group-heading {
+  background-color: #e6e6e6;
+  color: rgba(34, 47, 62, 0.7);
+  cursor: default;
+  font-size: 12px;
+  font-style: normal;
+  font-weight: normal;
+  margin-bottom: 4px;
+  margin-top: -4px;
+  padding: 4px 8px;
+  text-transform: none;
+  -webkit-touch-callout: none;
+  -webkit-user-select: none;
+     -moz-user-select: none;
+      -ms-user-select: none;
+          user-select: none;
+}
+.tox .tox-collection__item {
+  align-items: center;
+  color: #222f3e;
+  cursor: pointer;
+  display: flex;
+  -webkit-touch-callout: none;
+  -webkit-user-select: none;
+     -moz-user-select: none;
+      -ms-user-select: none;
+          user-select: none;
+}
+.tox .tox-collection--list .tox-collection__item {
+  padding: 4px 8px;
+}
+.tox .tox-collection--toolbar .tox-collection__item {
+  border-radius: 3px;
+  padding: 4px;
+}
+.tox .tox-collection--grid .tox-collection__item {
+  border-radius: 3px;
+  padding: 4px;
+}
+.tox .tox-collection--list .tox-collection__item--enabled {
+  background-color: #fff;
+  color: #222f3e;
+}
+.tox .tox-collection--list .tox-collection__item--active {
+  background-color: #dee0e2;
+}
+.tox .tox-collection--toolbar .tox-collection__item--enabled {
+  background-color: #c8cbcf;
+  color: #222f3e;
+}
+.tox .tox-collection--toolbar .tox-collection__item--active {
+  background-color: #dee0e2;
+}
+.tox .tox-collection--grid .tox-collection__item--enabled {
+  background-color: #c8cbcf;
+  color: #222f3e;
+}
+.tox .tox-collection--grid .tox-collection__item--active:not(.tox-collection__item--state-disabled) {
+  background-color: #dee0e2;
+  color: #222f3e;
+}
+.tox .tox-collection--list .tox-collection__item--active:not(.tox-collection__item--state-disabled) {
+  color: #222f3e;
+}
+.tox .tox-collection--toolbar .tox-collection__item--active:not(.tox-collection__item--state-disabled) {
+  color: #222f3e;
+}
+.tox .tox-collection__item-icon,
+.tox .tox-collection__item-checkmark {
+  align-items: center;
+  display: flex;
+  height: 24px;
+  justify-content: center;
+  width: 24px;
+}
+.tox .tox-collection__item-icon svg,
+.tox .tox-collection__item-checkmark svg {
+  fill: currentColor;
+}
+.tox .tox-collection--toolbar-lg .tox-collection__item-icon {
+  height: 48px;
+  width: 48px;
+}
+.tox .tox-collection__item-label {
+  color: currentColor;
+  display: inline-block;
+  flex: 1;
+  -ms-flex-preferred-size: auto;
+  font-size: 14px;
+  font-style: normal;
+  font-weight: normal;
+  line-height: 24px;
+  text-transform: none;
+  word-break: break-all;
+}
+.tox .tox-collection__item-accessory {
+  color: rgba(34, 47, 62, 0.7);
+  display: inline-block;
+  font-size: 14px;
+  height: 24px;
+  line-height: 24px;
+  text-transform: none;
+}
+.tox .tox-collection__item-caret {
+  align-items: center;
+  display: flex;
+  min-height: 24px;
+}
+.tox .tox-collection__item-caret::after {
+  content: '';
+  font-size: 0;
+  min-height: inherit;
+}
+.tox .tox-collection__item-caret svg {
+  fill: #222f3e;
+}
+.tox .tox-collection__item--state-disabled {
+  background-color: transparent;
+  color: rgba(34, 47, 62, 0.5);
+  cursor: not-allowed;
+}
+.tox .tox-collection__item--state-disabled .tox-collection__item-caret svg {
+  fill: rgba(34, 47, 62, 0.5);
+}
+.tox .tox-collection--list .tox-collection__item:not(.tox-collection__item--enabled) .tox-collection__item-checkmark svg {
+  display: none;
+}
+.tox .tox-collection--list .tox-collection__item:not(.tox-collection__item--enabled) .tox-collection__item-accessory + .tox-collection__item-checkmark {
+  display: none;
+}
+.tox .tox-collection--horizontal {
+  background-color: #fff;
+  border: 1px solid #cccccc;
+  border-radius: 3px;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
+  display: flex;
+  flex: 0 0 auto;
+  flex-shrink: 0;
+  flex-wrap: nowrap;
+  margin-bottom: 0;
+  overflow-x: auto;
+  padding: 0;
+}
+.tox .tox-collection--horizontal .tox-collection__group {
+  align-items: center;
+  display: flex;
+  flex-wrap: nowrap;
+  margin: 0;
+  padding: 0 4px;
+}
+.tox .tox-collection--horizontal .tox-collection__item {
+  height: 34px;
+  margin: 2px 0 3px 0;
+  padding: 0 4px;
+}
+.tox .tox-collection--horizontal .tox-collection__item-label {
+  white-space: nowrap;
+}
+.tox .tox-collection--horizontal .tox-collection__item-caret {
+  margin-left: 4px;
+}
+.tox .tox-collection__item-container {
+  display: flex;
+}
+.tox .tox-collection__item-container--row {
+  align-items: center;
+  flex: 1 1 auto;
+  flex-direction: row;
+}
+.tox .tox-collection__item-container--row.tox-collection__item-container--align-left {
+  margin-right: auto;
+}
+.tox .tox-collection__item-container--row.tox-collection__item-container--align-right {
+  justify-content: flex-end;
+  margin-left: auto;
+}
+.tox .tox-collection__item-container--row.tox-collection__item-container--valign-top {
+  align-items: flex-start;
+  margin-bottom: auto;
+}
+.tox .tox-collection__item-container--row.tox-collection__item-container--valign-middle {
+  align-items: center;
+}
+.tox .tox-collection__item-container--row.tox-collection__item-container--valign-bottom {
+  align-items: flex-end;
+  margin-top: auto;
+}
+.tox .tox-collection__item-container--column {
+  -ms-grid-row-align: center;
+      align-self: center;
+  flex: 1 1 auto;
+  flex-direction: column;
+}
+.tox .tox-collection__item-container--column.tox-collection__item-container--align-left {
+  align-items: flex-start;
+}
+.tox .tox-collection__item-container--column.tox-collection__item-container--align-right {
+  align-items: flex-end;
+}
+.tox .tox-collection__item-container--column.tox-collection__item-container--valign-top {
+  align-self: flex-start;
+}
+.tox .tox-collection__item-container--column.tox-collection__item-container--valign-middle {
+  -ms-grid-row-align: center;
+      align-self: center;
+}
+.tox .tox-collection__item-container--column.tox-collection__item-container--valign-bottom {
+  align-self: flex-end;
+}
+.tox:not([dir=rtl]) .tox-collection--horizontal .tox-collection__group:not(:last-of-type) {
+  border-right: 1px solid #cccccc;
+}
+.tox:not([dir=rtl]) .tox-collection--list .tox-collection__item > *:not(:first-child) {
+  margin-left: 8px;
+}
+.tox:not([dir=rtl]) .tox-collection--list .tox-collection__item > .tox-collection__item-label:first-child {
+  margin-left: 4px;
+}
+.tox:not([dir=rtl]) .tox-collection__item-accessory {
+  margin-left: 16px;
+  text-align: right;
+}
+.tox:not([dir=rtl]) .tox-collection .tox-collection__item-caret {
+  margin-left: 16px;
+}
+.tox[dir=rtl] .tox-collection--horizontal .tox-collection__group:not(:last-of-type) {
+  border-left: 1px solid #cccccc;
+}
+.tox[dir=rtl] .tox-collection--list .tox-collection__item > *:not(:first-child) {
+  margin-right: 8px;
+}
+.tox[dir=rtl] .tox-collection--list .tox-collection__item > .tox-collection__item-label:first-child {
+  margin-right: 4px;
+}
+.tox[dir=rtl] .tox-collection__item-accessory {
+  margin-right: 16px;
+  text-align: left;
+}
+.tox[dir=rtl] .tox-collection .tox-collection__item-caret {
+  margin-right: 16px;
+  transform: rotateY(180deg);
+}
+.tox[dir=rtl] .tox-collection--horizontal .tox-collection__item-caret {
+  margin-right: 4px;
+}
+.tox .tox-color-picker-container {
+  display: flex;
+  flex-direction: row;
+  height: 225px;
+  margin: 0;
+}
+.tox .tox-sv-palette {
+  box-sizing: border-box;
+  display: flex;
+  height: 100%;
+}
+.tox .tox-sv-palette-spectrum {
+  height: 100%;
+}
+.tox .tox-sv-palette,
+.tox .tox-sv-palette-spectrum {
+  width: 225px;
+}
+.tox .tox-sv-palette-thumb {
+  background: none;
+  border: 1px solid black;
+  border-radius: 50%;
+  box-sizing: content-box;
+  height: 12px;
+  position: absolute;
+  width: 12px;
+}
+.tox .tox-sv-palette-inner-thumb {
+  border: 1px solid white;
+  border-radius: 50%;
+  height: 10px;
+  position: absolute;
+  width: 10px;
+}
+.tox .tox-hue-slider {
+  box-sizing: border-box;
+  height: 100%;
+  width: 25px;
+}
+.tox .tox-hue-slider-spectrum {
+  background: linear-gradient(to bottom, #f00, #ff0080, #f0f, #8000ff, #00f, #0080ff, #0ff, #00ff80, #0f0, #80ff00, #ff0, #ff8000, #f00);
+  height: 100%;
+  width: 100%;
+}
+.tox .tox-hue-slider,
+.tox .tox-hue-slider-spectrum {
+  width: 20px;
+}
+.tox .tox-hue-slider-thumb {
+  background: white;
+  border: 1px solid black;
+  box-sizing: content-box;
+  height: 4px;
+  width: 100%;
+}
+.tox .tox-rgb-form {
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+}
+.tox .tox-rgb-form div {
+  align-items: center;
+  display: flex;
+  justify-content: space-between;
+  margin-bottom: 5px;
+  width: inherit;
+}
+.tox .tox-rgb-form input {
+  width: 6em;
+}
+.tox .tox-rgb-form input.tox-invalid {
+  /* Need !important to override Chrome's focus styling unfortunately */
+  border: 1px solid red !important;
+}
+.tox .tox-rgb-form .tox-rgba-preview {
+  border: 1px solid black;
+  flex-grow: 2;
+  margin-bottom: 0;
+}
+.tox:not([dir=rtl]) .tox-sv-palette {
+  margin-right: 15px;
+}
+.tox:not([dir=rtl]) .tox-hue-slider {
+  margin-right: 15px;
+}
+.tox:not([dir=rtl]) .tox-hue-slider-thumb {
+  margin-left: -1px;
+}
+.tox:not([dir=rtl]) .tox-rgb-form label {
+  margin-right: 0.5em;
+}
+.tox[dir=rtl] .tox-sv-palette {
+  margin-left: 15px;
+}
+.tox[dir=rtl] .tox-hue-slider {
+  margin-left: 15px;
+}
+.tox[dir=rtl] .tox-hue-slider-thumb {
+  margin-right: -1px;
+}
+.tox[dir=rtl] .tox-rgb-form label {
+  margin-left: 0.5em;
+}
+.tox .tox-toolbar .tox-swatches,
+.tox .tox-toolbar__primary .tox-swatches,
+.tox .tox-toolbar__overflow .tox-swatches {
+  margin: 2px 0 3px 4px;
+}
+.tox .tox-collection--list .tox-collection__group .tox-swatches-menu {
+  border: 0;
+  margin: -4px 0;
+}
+.tox .tox-swatches__row {
+  display: flex;
+}
+.tox .tox-swatch {
+  height: 30px;
+  transition: transform 0.15s, box-shadow 0.15s;
+  width: 30px;
+}
+.tox .tox-swatch:hover,
+.tox .tox-swatch:focus {
+  box-shadow: 0 0 0 1px rgba(127, 127, 127, 0.3) inset;
+  transform: scale(0.8);
+}
+.tox .tox-swatch--remove {
+  align-items: center;
+  display: flex;
+  justify-content: center;
+}
+.tox .tox-swatch--remove svg path {
+  stroke: #e74c3c;
+}
+.tox .tox-swatches__picker-btn {
+  align-items: center;
+  background-color: transparent;
+  border: 0;
+  cursor: pointer;
+  display: flex;
+  height: 30px;
+  justify-content: center;
+  outline: none;
+  padding: 0;
+  width: 30px;
+}
+.tox .tox-swatches__picker-btn svg {
+  height: 24px;
+  width: 24px;
+}
+.tox .tox-swatches__picker-btn:hover {
+  background: #dee0e2;
+}
+.tox:not([dir=rtl]) .tox-swatches__picker-btn {
+  margin-left: auto;
+}
+.tox[dir=rtl] .tox-swatches__picker-btn {
+  margin-right: auto;
+}
+.tox .tox-comment-thread {
+  background: #fff;
+  position: relative;
+}
+.tox .tox-comment-thread > *:not(:first-child) {
+  margin-top: 8px;
+}
+.tox .tox-comment {
+  background: #fff;
+  border: 1px solid #cccccc;
+  border-radius: 3px;
+  box-shadow: 0 4px 8px 0 rgba(34, 47, 62, 0.1);
+  padding: 8px 8px 16px 8px;
+  position: relative;
+}
+.tox .tox-comment__header {
+  align-items: center;
+  color: #222f3e;
+  display: flex;
+  justify-content: space-between;
+}
+.tox .tox-comment__date {
+  color: rgba(34, 47, 62, 0.7);
+  font-size: 12px;
+}
+.tox .tox-comment__body {
+  color: #222f3e;
+  font-size: 14px;
+  font-style: normal;
+  font-weight: normal;
+  line-height: 1.3;
+  margin-top: 8px;
+  position: relative;
+  text-transform: initial;
+}
+.tox .tox-comment__body textarea {
+  resize: none;
+  white-space: normal;
+  width: 100%;
+}
+.tox .tox-comment__expander {
+  padding-top: 8px;
+}
+.tox .tox-comment__expander p {
+  color: rgba(34, 47, 62, 0.7);
+  font-size: 14px;
+  font-style: normal;
+}
+.tox .tox-comment__body p {
+  margin: 0;
+}
+.tox .tox-comment__buttonspacing {
+  padding-top: 16px;
+  text-align: center;
+}
+.tox .tox-comment-thread__overlay::after {
+  background: #fff;
+  bottom: 0;
+  content: "";
+  display: flex;
+  left: 0;
+  opacity: 0.9;
+  position: absolute;
+  right: 0;
+  top: 0;
+  z-index: 5;
+}
+.tox .tox-comment__reply {
+  display: flex;
+  flex-shrink: 0;
+  flex-wrap: wrap;
+  justify-content: flex-end;
+  margin-top: 8px;
+}
+.tox .tox-comment__reply > *:first-child {
+  margin-bottom: 8px;
+  width: 100%;
+}
+.tox .tox-comment__edit {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: flex-end;
+  margin-top: 16px;
+}
+.tox .tox-comment__gradient::after {
+  background: linear-gradient(rgba(255, 255, 255, 0), #fff);
+  bottom: 0;
+  content: "";
+  display: block;
+  height: 5em;
+  margin-top: -40px;
+  position: absolute;
+  width: 100%;
+}
+.tox .tox-comment__overlay {
+  background: #fff;
+  bottom: 0;
+  display: flex;
+  flex-direction: column;
+  flex-grow: 1;
+  left: 0;
+  opacity: 0.9;
+  position: absolute;
+  right: 0;
+  text-align: center;
+  top: 0;
+  z-index: 5;
+}
+.tox .tox-comment__loading-text {
+  align-items: center;
+  color: #222f3e;
+  display: flex;
+  flex-direction: column;
+  position: relative;
+}
+.tox .tox-comment__loading-text > div {
+  padding-bottom: 16px;
+}
+.tox .tox-comment__overlaytext {
+  bottom: 0;
+  flex-direction: column;
+  font-size: 14px;
+  left: 0;
+  padding: 1em;
+  position: absolute;
+  right: 0;
+  top: 0;
+  z-index: 10;
+}
+.tox .tox-comment__overlaytext p {
+  background-color: #fff;
+  box-shadow: 0 0 8px 8px #fff;
+  color: #222f3e;
+  text-align: center;
+}
+.tox .tox-comment__overlaytext div:nth-of-type(2) {
+  font-size: 0.8em;
+}
+.tox .tox-comment__busy-spinner {
+  align-items: center;
+  background-color: #fff;
+  bottom: 0;
+  display: flex;
+  justify-content: center;
+  left: 0;
+  position: absolute;
+  right: 0;
+  top: 0;
+  z-index: 20;
+}
+.tox .tox-comment__scroll {
+  display: flex;
+  flex-direction: column;
+  flex-shrink: 1;
+  overflow: auto;
+}
+.tox .tox-conversations {
+  margin: 8px;
+}
+.tox:not([dir=rtl]) .tox-comment__edit {
+  margin-left: 8px;
+}
+.tox:not([dir=rtl]) .tox-comment__buttonspacing > *:last-child,
+.tox:not([dir=rtl]) .tox-comment__edit > *:last-child,
+.tox:not([dir=rtl]) .tox-comment__reply > *:last-child {
+  margin-left: 8px;
+}
+.tox[dir=rtl] .tox-comment__edit {
+  margin-right: 8px;
+}
+.tox[dir=rtl] .tox-comment__buttonspacing > *:last-child,
+.tox[dir=rtl] .tox-comment__edit > *:last-child,
+.tox[dir=rtl] .tox-comment__reply > *:last-child {
+  margin-right: 8px;
+}
+.tox .tox-user {
+  align-items: center;
+  display: flex;
+}
+.tox .tox-user__avatar svg {
+  fill: rgba(34, 47, 62, 0.7);
+}
+.tox .tox-user__name {
+  color: rgba(34, 47, 62, 0.7);
+  font-size: 12px;
+  font-style: normal;
+  font-weight: bold;
+  text-transform: uppercase;
+}
+.tox:not([dir=rtl]) .tox-user__avatar svg {
+  margin-right: 8px;
+}
+.tox:not([dir=rtl]) .tox-user__avatar + .tox-user__name {
+  margin-left: 8px;
+}
+.tox[dir=rtl] .tox-user__avatar svg {
+  margin-left: 8px;
+}
+.tox[dir=rtl] .tox-user__avatar + .tox-user__name {
+  margin-right: 8px;
+}
+.tox .tox-dialog-wrap {
+  align-items: center;
+  bottom: 0;
+  display: flex;
+  justify-content: center;
+  left: 0;
+  position: fixed;
+  right: 0;
+  top: 0;
+  z-index: 1100;
+}
+.tox .tox-dialog-wrap__backdrop {
+  background-color: rgba(255, 255, 255, 0.75);
+  bottom: 0;
+  left: 0;
+  position: absolute;
+  right: 0;
+  top: 0;
+  z-index: 1;
+}
+.tox .tox-dialog-wrap__backdrop--opaque {
+  background-color: #fff;
+}
+.tox .tox-dialog {
+  background-color: #fff;
+  border-color: #cccccc;
+  border-radius: 3px;
+  border-style: solid;
+  border-width: 1px;
+  box-shadow: 0 16px 16px -10px rgba(34, 47, 62, 0.15), 0 0 40px 1px rgba(34, 47, 62, 0.15);
+  display: flex;
+  flex-direction: column;
+  max-height: 100%;
+  max-width: 480px;
+  overflow: hidden;
+  position: relative;
+  width: 95vw;
+  z-index: 2;
+}
+@media only screen and (max-width:767px) {
+  body:not(.tox-force-desktop) .tox .tox-dialog {
+    align-self: flex-start;
+    margin: 8px auto;
+    width: calc(100vw - 16px);
+  }
+}
+.tox .tox-dialog-inline {
+  z-index: 1100;
+}
+.tox .tox-dialog__header {
+  align-items: center;
+  background-color: #fff;
+  border-bottom: none;
+  color: #222f3e;
+  display: flex;
+  font-size: 16px;
+  justify-content: space-between;
+  padding: 8px 16px 0 16px;
+  position: relative;
+}
+.tox .tox-dialog__header .tox-button {
+  z-index: 1;
+}
+.tox .tox-dialog__draghandle {
+  cursor: grab;
+  height: 100%;
+  left: 0;
+  position: absolute;
+  top: 0;
+  width: 100%;
+}
+.tox .tox-dialog__draghandle:active {
+  cursor: grabbing;
+}
+.tox .tox-dialog__dismiss {
+  margin-left: auto;
+}
+.tox .tox-dialog__title {
+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
+  font-size: 20px;
+  font-style: normal;
+  font-weight: normal;
+  line-height: 1.3;
+  margin: 0;
+  text-transform: none;
+}
+.tox .tox-dialog__body {
+  color: #222f3e;
+  display: flex;
+  flex: 1;
+  -ms-flex-preferred-size: auto;
+  font-size: 16px;
+  font-style: normal;
+  font-weight: normal;
+  line-height: 1.3;
+  min-width: 0;
+  text-align: left;
+  text-transform: none;
+}
+@media only screen and (max-width:767px) {
+  body:not(.tox-force-desktop) .tox .tox-dialog__body {
+    flex-direction: column;
+  }
+}
+.tox .tox-dialog__body-nav {
+  align-items: flex-start;
+  display: flex;
+  flex-direction: column;
+  padding: 16px 16px;
+}
+@media only screen and (max-width:767px) {
+  body:not(.tox-force-desktop) .tox .tox-dialog__body-nav {
+    flex-direction: row;
+    -webkit-overflow-scrolling: touch;
+    overflow-x: auto;
+    padding-bottom: 0;
+  }
+}
+.tox .tox-dialog__body-nav-item {
+  border-bottom: 2px solid transparent;
+  color: rgba(34, 47, 62, 0.7);
+  display: inline-block;
+  font-size: 14px;
+  line-height: 1.3;
+  margin-bottom: 8px;
+  text-decoration: none;
+  white-space: nowrap;
+}
+.tox .tox-dialog__body-nav-item:focus {
+  background-color: rgba(32, 122, 183, 0.1);
+}
+.tox .tox-dialog__body-nav-item--active {
+  border-bottom: 2px solid #207ab7;
+  color: #207ab7;
+}
+.tox .tox-dialog__body-content {
+  box-sizing: border-box;
+  display: flex;
+  flex: 1;
+  flex-direction: column;
+  -ms-flex-preferred-size: auto;
+  max-height: 650px;
+  overflow: auto;
+  -webkit-overflow-scrolling: touch;
+  padding: 16px 16px;
+}
+.tox .tox-dialog__body-content > * {
+  margin-bottom: 0;
+  margin-top: 16px;
+}
+.tox .tox-dialog__body-content > *:first-child {
+  margin-top: 0;
+}
+.tox .tox-dialog__body-content > *:last-child {
+  margin-bottom: 0;
+}
+.tox .tox-dialog__body-content > *:only-child {
+  margin-bottom: 0;
+  margin-top: 0;
+}
+.tox .tox-dialog__body-content a {
+  color: #207ab7;
+  cursor: pointer;
+  text-decoration: none;
+}
+.tox .tox-dialog__body-content a:hover,
+.tox .tox-dialog__body-content a:focus {
+  color: #185d8c;
+  text-decoration: none;
+}
+.tox .tox-dialog__body-content a:active {
+  color: #185d8c;
+  text-decoration: none;
+}
+.tox .tox-dialog__body-content svg {
+  fill: #222f3e;
+}
+.tox .tox-dialog__body-content ul {
+  display: block;
+  list-style-type: disc;
+  margin-bottom: 16px;
+  -webkit-margin-end: 0;
+          margin-inline-end: 0;
+  -webkit-margin-start: 0;
+          margin-inline-start: 0;
+  -webkit-padding-start: 2.5rem;
+          padding-inline-start: 2.5rem;
+}
+.tox .tox-dialog__body-content .tox-form__group h1 {
+  color: #222f3e;
+  font-size: 20px;
+  font-style: normal;
+  font-weight: bold;
+  letter-spacing: normal;
+  margin-bottom: 16px;
+  margin-top: 2rem;
+  text-transform: none;
+}
+.tox .tox-dialog__body-content .tox-form__group h2 {
+  color: #222f3e;
+  font-size: 16px;
+  font-style: normal;
+  font-weight: bold;
+  letter-spacing: normal;
+  margin-bottom: 16px;
+  margin-top: 2rem;
+  text-transform: none;
+}
+.tox .tox-dialog__body-content .tox-form__group p {
+  margin-bottom: 16px;
+}
+.tox .tox-dialog__body-content .tox-form__group h1:first-child,
+.tox .tox-dialog__body-content .tox-form__group h2:first-child,
+.tox .tox-dialog__body-content .tox-form__group p:first-child {
+  margin-top: 0;
+}
+.tox .tox-dialog__body-content .tox-form__group h1:last-child,
+.tox .tox-dialog__body-content .tox-form__group h2:last-child,
+.tox .tox-dialog__body-content .tox-form__group p:last-child {
+  margin-bottom: 0;
+}
+.tox .tox-dialog__body-content .tox-form__group h1:only-child,
+.tox .tox-dialog__body-content .tox-form__group h2:only-child,
+.tox .tox-dialog__body-content .tox-form__group p:only-child {
+  margin-bottom: 0;
+  margin-top: 0;
+}
+.tox .tox-dialog--width-lg {
+  height: 650px;
+  max-width: 1200px;
+}
+.tox .tox-dialog--width-md {
+  max-width: 800px;
+}
+.tox .tox-dialog--width-md .tox-dialog__body-content {
+  overflow: auto;
+}
+.tox .tox-dialog__body-content--centered {
+  text-align: center;
+}
+.tox .tox-dialog__footer {
+  align-items: center;
+  background-color: #fff;
+  border-top: 1px solid #cccccc;
+  display: flex;
+  justify-content: space-between;
+  padding: 8px 16px;
+}
+.tox .tox-dialog__footer-start,
+.tox .tox-dialog__footer-end {
+  display: flex;
+}
+.tox .tox-dialog__busy-spinner {
+  align-items: center;
+  background-color: rgba(255, 255, 255, 0.75);
+  bottom: 0;
+  display: flex;
+  justify-content: center;
+  left: 0;
+  position: absolute;
+  right: 0;
+  top: 0;
+  z-index: 3;
+}
+.tox .tox-dialog__table {
+  border-collapse: collapse;
+  width: 100%;
+}
+.tox .tox-dialog__table thead th {
+  font-weight: bold;
+  padding-bottom: 8px;
+}
+.tox .tox-dialog__table tbody tr {
+  border-bottom: 1px solid #cccccc;
+}
+.tox .tox-dialog__table tbody tr:last-child {
+  border-bottom: none;
+}
+.tox .tox-dialog__table td {
+  padding-bottom: 8px;
+  padding-top: 8px;
+}
+.tox .tox-dialog__popups {
+  position: absolute;
+  width: 100%;
+  z-index: 1100;
+}
+.tox .tox-dialog__body-iframe {
+  display: flex;
+  flex: 1;
+  flex-direction: column;
+  -ms-flex-preferred-size: auto;
+}
+.tox .tox-dialog__body-iframe .tox-navobj {
+  display: flex;
+  flex: 1;
+  -ms-flex-preferred-size: auto;
+}
+.tox .tox-dialog__body-iframe .tox-navobj :nth-child(2) {
+  flex: 1;
+  -ms-flex-preferred-size: auto;
+  height: 100%;
+}
+.tox .tox-dialog-dock-fadeout {
+  opacity: 0;
+  visibility: hidden;
+}
+.tox .tox-dialog-dock-fadein {
+  opacity: 1;
+  visibility: visible;
+}
+.tox .tox-dialog-dock-transition {
+  transition: visibility 0s linear 0.3s, opacity 0.3s ease;
+}
+.tox .tox-dialog-dock-transition.tox-dialog-dock-fadein {
+  transition-delay: 0s;
+}
+.tox.tox-platform-ie {
+  /* IE11 CSS styles go here */
+}
+.tox.tox-platform-ie .tox-dialog-wrap {
+  position: -ms-device-fixed;
+}
+@media only screen and (max-width:767px) {
+  body:not(.tox-force-desktop) .tox:not([dir=rtl]) .tox-dialog__body-nav {
+    margin-right: 0;
+  }
+}
+@media only screen and (max-width:767px) {
+  body:not(.tox-force-desktop) .tox:not([dir=rtl]) .tox-dialog__body-nav-item:not(:first-child) {
+    margin-left: 8px;
+  }
+}
+.tox:not([dir=rtl]) .tox-dialog__footer .tox-dialog__footer-start > *,
+.tox:not([dir=rtl]) .tox-dialog__footer .tox-dialog__footer-end > * {
+  margin-left: 8px;
+}
+.tox[dir=rtl] .tox-dialog__body {
+  text-align: right;
+}
+@media only screen and (max-width:767px) {
+  body:not(.tox-force-desktop) .tox[dir=rtl] .tox-dialog__body-nav {
+    margin-left: 0;
+  }
+}
+@media only screen and (max-width:767px) {
+  body:not(.tox-force-desktop) .tox[dir=rtl] .tox-dialog__body-nav-item:not(:first-child) {
+    margin-right: 8px;
+  }
+}
+.tox[dir=rtl] .tox-dialog__footer .tox-dialog__footer-start > *,
+.tox[dir=rtl] .tox-dialog__footer .tox-dialog__footer-end > * {
+  margin-right: 8px;
+}
+body.tox-dialog__disable-scroll {
+  overflow: hidden;
+}
+.tox .tox-dropzone-container {
+  display: flex;
+  flex: 1;
+  -ms-flex-preferred-size: auto;
+}
+.tox .tox-dropzone {
+  align-items: center;
+  background: #fff;
+  border: 2px dashed #cccccc;
+  box-sizing: border-box;
+  display: flex;
+  flex-direction: column;
+  flex-grow: 1;
+  justify-content: center;
+  min-height: 100px;
+  padding: 10px;
+}
+.tox .tox-dropzone p {
+  color: rgba(34, 47, 62, 0.7);
+  margin: 0 0 16px 0;
+}
+.tox .tox-edit-area {
+  display: flex;
+  flex: 1;
+  -ms-flex-preferred-size: auto;
+  overflow: hidden;
+  position: relative;
+}
+.tox .tox-edit-area__iframe {
+  background-color: #fff;
+  border: 0;
+  box-sizing: border-box;
+  flex: 1;
+  -ms-flex-preferred-size: auto;
+  height: 100%;
+  position: absolute;
+  width: 100%;
+}
+.tox.tox-inline-edit-area {
+  border: 1px dotted #cccccc;
+}
+.tox .tox-editor-container {
+  display: flex;
+  flex: 1 1 auto;
+  flex-direction: column;
+  overflow: hidden;
+}
+.tox .tox-editor-header {
+  z-index: 1;
+}
+.tox:not(.tox-tinymce-inline) .tox-editor-header {
+  box-shadow: none;
+  transition: box-shadow 0.5s;
+}
+.tox.tox-tinymce--toolbar-bottom .tox-editor-header,
+.tox.tox-tinymce-inline .tox-editor-header {
+  margin-bottom: -1px;
+}
+.tox.tox-tinymce--toolbar-sticky-on .tox-editor-header {
+  background-color: transparent;
+  box-shadow: 0 4px 4px -3px rgba(0, 0, 0, 0.25);
+}
+.tox-editor-dock-fadeout {
+  opacity: 0;
+  visibility: hidden;
+}
+.tox-editor-dock-fadein {
+  opacity: 1;
+  visibility: visible;
+}
+.tox-editor-dock-transition {
+  transition: visibility 0s linear 0.25s, opacity 0.25s ease;
+}
+.tox-editor-dock-transition.tox-editor-dock-fadein {
+  transition-delay: 0s;
+}
+.tox .tox-control-wrap {
+  flex: 1;
+  position: relative;
+}
+.tox .tox-control-wrap:not(.tox-control-wrap--status-invalid) .tox-control-wrap__status-icon-invalid,
+.tox .tox-control-wrap:not(.tox-control-wrap--status-unknown) .tox-control-wrap__status-icon-unknown,
+.tox .tox-control-wrap:not(.tox-control-wrap--status-valid) .tox-control-wrap__status-icon-valid {
+  display: none;
+}
+.tox .tox-control-wrap svg {
+  display: block;
+}
+.tox .tox-control-wrap__status-icon-wrap {
+  position: absolute;
+  top: 50%;
+  transform: translateY(-50%);
+}
+.tox .tox-control-wrap__status-icon-invalid svg {
+  fill: #c00;
+}
+.tox .tox-control-wrap__status-icon-unknown svg {
+  fill: orange;
+}
+.tox .tox-control-wrap__status-icon-valid svg {
+  fill: green;
+}
+.tox:not([dir=rtl]) .tox-control-wrap--status-invalid .tox-textfield,
+.tox:not([dir=rtl]) .tox-control-wrap--status-unknown .tox-textfield,
+.tox:not([dir=rtl]) .tox-control-wrap--status-valid .tox-textfield {
+  padding-right: 32px;
+}
+.tox:not([dir=rtl]) .tox-control-wrap__status-icon-wrap {
+  right: 4px;
+}
+.tox[dir=rtl] .tox-control-wrap--status-invalid .tox-textfield,
+.tox[dir=rtl] .tox-control-wrap--status-unknown .tox-textfield,
+.tox[dir=rtl] .tox-control-wrap--status-valid .tox-textfield {
+  padding-left: 32px;
+}
+.tox[dir=rtl] .tox-control-wrap__status-icon-wrap {
+  left: 4px;
+}
+.tox .tox-autocompleter {
+  max-width: 25em;
+}
+.tox .tox-autocompleter .tox-menu {
+  max-width: 25em;
+}
+.tox .tox-autocompleter .tox-autocompleter-highlight {
+  font-weight: bold;
+}
+.tox .tox-color-input {
+  display: flex;
+  position: relative;
+  z-index: 1;
+}
+.tox .tox-color-input .tox-textfield {
+  z-index: -1;
+}
+.tox .tox-color-input span {
+  border-color: rgba(34, 47, 62, 0.2);
+  border-radius: 3px;
+  border-style: solid;
+  border-width: 1px;
+  box-shadow: none;
+  box-sizing: border-box;
+  height: 24px;
+  position: absolute;
+  top: 6px;
+  width: 24px;
+}
+.tox .tox-color-input span:hover:not([aria-disabled=true]),
+.tox .tox-color-input span:focus:not([aria-disabled=true]) {
+  border-color: #207ab7;
+  cursor: pointer;
+}
+.tox .tox-color-input span::before {
+  background-image: linear-gradient(45deg, rgba(0, 0, 0, 0.25) 25%, transparent 25%), linear-gradient(-45deg, rgba(0, 0, 0, 0.25) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, rgba(0, 0, 0, 0.25) 75%), linear-gradient(-45deg, transparent 75%, rgba(0, 0, 0, 0.25) 75%);
+  background-position: 0 0, 0 6px, 6px -6px, -6px 0;
+  background-size: 12px 12px;
+  border: 1px solid #fff;
+  border-radius: 3px;
+  box-sizing: border-box;
+  content: '';
+  height: 24px;
+  left: -1px;
+  position: absolute;
+  top: -1px;
+  width: 24px;
+  z-index: -1;
+}
+.tox .tox-color-input span[aria-disabled=true] {
+  cursor: not-allowed;
+}
+.tox:not([dir=rtl]) .tox-color-input {
+  /* stylelint-disable-next-line no-descending-specificity */
+}
+.tox:not([dir=rtl]) .tox-color-input .tox-textfield {
+  padding-left: 36px;
+}
+.tox:not([dir=rtl]) .tox-color-input span {
+  left: 6px;
+}
+.tox[dir="rtl"] .tox-color-input {
+  /* stylelint-disable-next-line no-descending-specificity */
+}
+.tox[dir="rtl"] .tox-color-input .tox-textfield {
+  padding-right: 36px;
+}
+.tox[dir="rtl"] .tox-color-input span {
+  right: 6px;
+}
+.tox .tox-label,
+.tox .tox-toolbar-label {
+  color: rgba(34, 47, 62, 0.7);
+  display: block;
+  font-size: 14px;
+  font-style: normal;
+  font-weight: normal;
+  line-height: 1.3;
+  padding: 0 8px 0 0;
+  text-transform: none;
+  white-space: nowrap;
+}
+.tox .tox-toolbar-label {
+  padding: 0 8px;
+}
+.tox[dir=rtl] .tox-label {
+  padding: 0 0 0 8px;
+}
+.tox .tox-form {
+  display: flex;
+  flex: 1;
+  flex-direction: column;
+  -ms-flex-preferred-size: auto;
+}
+.tox .tox-form__group {
+  box-sizing: border-box;
+  margin-bottom: 4px;
+}
+.tox .tox-form-group--maximize {
+  flex: 1;
+}
+.tox .tox-form__group--error {
+  color: #c00;
+}
+.tox .tox-form__group--collection {
+  display: flex;
+}
+.tox .tox-form__grid {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  justify-content: space-between;
+}
+.tox .tox-form__grid--2col > .tox-form__group {
+  width: calc(50% - (8px / 2));
+}
+.tox .tox-form__grid--3col > .tox-form__group {
+  width: calc(100% / 3 - (8px / 2));
+}
+.tox .tox-form__grid--4col > .tox-form__group {
+  width: calc(25% - (8px / 2));
+}
+.tox .tox-form__controls-h-stack {
+  align-items: center;
+  display: flex;
+}
+.tox .tox-form__group--inline {
+  align-items: center;
+  display: flex;
+}
+.tox .tox-form__group--stretched {
+  display: flex;
+  flex: 1;
+  flex-direction: column;
+  -ms-flex-preferred-size: auto;
+}
+.tox .tox-form__group--stretched .tox-textarea {
+  flex: 1;
+  -ms-flex-preferred-size: auto;
+}
+.tox .tox-form__group--stretched .tox-navobj {
+  display: flex;
+  flex: 1;
+  -ms-flex-preferred-size: auto;
+}
+.tox .tox-form__group--stretched .tox-navobj :nth-child(2) {
+  flex: 1;
+  -ms-flex-preferred-size: auto;
+  height: 100%;
+}
+.tox:not([dir=rtl]) .tox-form__controls-h-stack > *:not(:first-child) {
+  margin-left: 4px;
+}
+.tox[dir=rtl] .tox-form__controls-h-stack > *:not(:first-child) {
+  margin-right: 4px;
+}
+.tox .tox-lock.tox-locked .tox-lock-icon__unlock,
+.tox .tox-lock:not(.tox-locked) .tox-lock-icon__lock {
+  display: none;
+}
+.tox .tox-textfield,
+.tox .tox-toolbar-textfield,
+.tox .tox-listboxfield .tox-listbox--select,
+.tox .tox-textarea {
+  -webkit-appearance: none;
+     -moz-appearance: none;
+          appearance: none;
+  background-color: #fff;
+  border-color: #cccccc;
+  border-radius: 3px;
+  border-style: solid;
+  border-width: 1px;
+  box-shadow: none;
+  box-sizing: border-box;
+  color: #222f3e;
+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
+  font-size: 16px;
+  line-height: 24px;
+  margin: 0;
+  min-height: 34px;
+  outline: none;
+  padding: 5px 4.75px;
+  resize: none;
+  width: 100%;
+}
+.tox .tox-textfield[disabled],
+.tox .tox-textarea[disabled] {
+  background-color: #f2f2f2;
+  color: rgba(34, 47, 62, 0.85);
+  cursor: not-allowed;
+}
+.tox .tox-textfield:focus,
+.tox .tox-listboxfield .tox-listbox--select:focus,
+.tox .tox-textarea:focus {
+  background-color: #fff;
+  border-color: #207ab7;
+  box-shadow: none;
+  outline: none;
+}
+.tox .tox-toolbar-textfield {
+  border-width: 0;
+  margin-bottom: 3px;
+  margin-top: 2px;
+  max-width: 250px;
+}
+.tox .tox-naked-btn {
+  background-color: transparent;
+  border: 0;
+  border-color: transparent;
+  box-shadow: unset;
+  color: #207ab7;
+  cursor: pointer;
+  display: block;
+  margin: 0;
+  padding: 0;
+}
+.tox .tox-naked-btn svg {
+  display: block;
+  fill: #222f3e;
+}
+.tox:not([dir=rtl]) .tox-toolbar-textfield + * {
+  margin-left: 4px;
+}
+.tox[dir=rtl] .tox-toolbar-textfield + * {
+  margin-right: 4px;
+}
+.tox .tox-listboxfield {
+  cursor: pointer;
+  position: relative;
+}
+.tox .tox-listboxfield .tox-listbox--select[disabled] {
+  background-color: #f2f2f2;
+  color: rgba(34, 47, 62, 0.85);
+  cursor: not-allowed;
+}
+.tox .tox-listbox__select-label {
+  cursor: default;
+  flex: 1;
+  margin: 0 4px;
+}
+.tox .tox-listbox__select-chevron {
+  align-items: center;
+  display: flex;
+  justify-content: center;
+  width: 16px;
+}
+.tox .tox-listbox__select-chevron svg {
+  fill: #222f3e;
+}
+.tox .tox-listboxfield .tox-listbox--select {
+  align-items: center;
+  display: flex;
+}
+.tox:not([dir=rtl]) .tox-listboxfield svg {
+  right: 8px;
+}
+.tox[dir=rtl] .tox-listboxfield svg {
+  left: 8px;
+}
+.tox .tox-selectfield {
+  cursor: pointer;
+  position: relative;
+}
+.tox .tox-selectfield select {
+  -webkit-appearance: none;
+     -moz-appearance: none;
+          appearance: none;
+  background-color: #fff;
+  border-color: #cccccc;
+  border-radius: 3px;
+  border-style: solid;
+  border-width: 1px;
+  box-shadow: none;
+  box-sizing: border-box;
+  color: #222f3e;
+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
+  font-size: 16px;
+  line-height: 24px;
+  margin: 0;
+  min-height: 34px;
+  outline: none;
+  padding: 5px 4.75px;
+  resize: none;
+  width: 100%;
+}
+.tox .tox-selectfield select[disabled] {
+  background-color: #f2f2f2;
+  color: rgba(34, 47, 62, 0.85);
+  cursor: not-allowed;
+}
+.tox .tox-selectfield select::-ms-expand {
+  display: none;
+}
+.tox .tox-selectfield select:focus {
+  background-color: #fff;
+  border-color: #207ab7;
+  box-shadow: none;
+  outline: none;
+}
+.tox .tox-selectfield svg {
+  pointer-events: none;
+  position: absolute;
+  top: 50%;
+  transform: translateY(-50%);
+}
+.tox:not([dir=rtl]) .tox-selectfield select[size="0"],
+.tox:not([dir=rtl]) .tox-selectfield select[size="1"] {
+  padding-right: 24px;
+}
+.tox:not([dir=rtl]) .tox-selectfield svg {
+  right: 8px;
+}
+.tox[dir=rtl] .tox-selectfield select[size="0"],
+.tox[dir=rtl] .tox-selectfield select[size="1"] {
+  padding-left: 24px;
+}
+.tox[dir=rtl] .tox-selectfield svg {
+  left: 8px;
+}
+.tox .tox-textarea {
+  -webkit-appearance: textarea;
+     -moz-appearance: textarea;
+          appearance: textarea;
+  white-space: pre-wrap;
+}
+.tox-fullscreen {
+  border: 0;
+  height: 100%;
+  margin: 0;
+  overflow: hidden;
+  -ms-scroll-chaining: none;
+      overscroll-behavior: none;
+  padding: 0;
+  touch-action: pinch-zoom;
+  width: 100%;
+}
+.tox.tox-tinymce.tox-fullscreen .tox-statusbar__resize-handle {
+  display: none;
+}
+.tox.tox-tinymce.tox-fullscreen,
+.tox-shadowhost.tox-fullscreen {
+  left: 0;
+  position: fixed;
+  top: 0;
+  z-index: 1200;
+}
+.tox.tox-tinymce.tox-fullscreen {
+  background-color: transparent;
+}
+.tox-fullscreen .tox.tox-tinymce-aux,
+.tox-fullscreen ~ .tox.tox-tinymce-aux {
+  z-index: 1201;
+}
+.tox .tox-help__more-link {
+  list-style: none;
+  margin-top: 1em;
+}
+.tox .tox-image-tools {
+  width: 100%;
+}
+.tox .tox-image-tools__toolbar {
+  align-items: center;
+  display: flex;
+  justify-content: center;
+}
+.tox .tox-image-tools__image {
+  background-color: #666;
+  height: 380px;
+  overflow: auto;
+  position: relative;
+  width: 100%;
+}
+.tox .tox-image-tools__image,
+.tox .tox-image-tools__image + .tox-image-tools__toolbar {
+  margin-top: 8px;
+}
+.tox .tox-image-tools__image-bg {
+  background: url(data:image/gif;base64,R0lGODdhDAAMAIABAMzMzP///ywAAAAADAAMAAACFoQfqYeabNyDMkBQb81Uat85nxguUAEAOw==);
+}
+.tox .tox-image-tools__toolbar > .tox-spacer {
+  flex: 1;
+  -ms-flex-preferred-size: auto;
+}
+.tox .tox-croprect-block {
+  background: black;
+  filter: alpha(opacity=50);
+  opacity: 0.5;
+  position: absolute;
+  zoom: 1;
+}
+.tox .tox-croprect-handle {
+  border: 2px solid white;
+  height: 20px;
+  left: 0;
+  position: absolute;
+  top: 0;
+  width: 20px;
+}
+.tox .tox-croprect-handle-move {
+  border: 0;
+  cursor: move;
+  position: absolute;
+}
+.tox .tox-croprect-handle-nw {
+  border-width: 2px 0 0 2px;
+  cursor: nw-resize;
+  left: 100px;
+  margin: -2px 0 0 -2px;
+  top: 100px;
+}
+.tox .tox-croprect-handle-ne {
+  border-width: 2px 2px 0 0;
+  cursor: ne-resize;
+  left: 200px;
+  margin: -2px 0 0 -20px;
+  top: 100px;
+}
+.tox .tox-croprect-handle-sw {
+  border-width: 0 0 2px 2px;
+  cursor: sw-resize;
+  left: 100px;
+  margin: -20px 2px 0 -2px;
+  top: 200px;
+}
+.tox .tox-croprect-handle-se {
+  border-width: 0 2px 2px 0;
+  cursor: se-resize;
+  left: 200px;
+  margin: -20px 0 0 -20px;
+  top: 200px;
+}
+.tox:not([dir=rtl]) .tox-image-tools__toolbar > .tox-slider:not(:first-of-type) {
+  margin-left: 8px;
+}
+.tox:not([dir=rtl]) .tox-image-tools__toolbar > .tox-button + .tox-slider {
+  margin-left: 32px;
+}
+.tox:not([dir=rtl]) .tox-image-tools__toolbar > .tox-slider + .tox-button {
+  margin-left: 32px;
+}
+.tox[dir=rtl] .tox-image-tools__toolbar > .tox-slider:not(:first-of-type) {
+  margin-right: 8px;
+}
+.tox[dir=rtl] .tox-image-tools__toolbar > .tox-button + .tox-slider {
+  margin-right: 32px;
+}
+.tox[dir=rtl] .tox-image-tools__toolbar > .tox-slider + .tox-button {
+  margin-right: 32px;
+}
+.tox .tox-insert-table-picker {
+  display: flex;
+  flex-wrap: wrap;
+  width: 170px;
+}
+.tox .tox-insert-table-picker > div {
+  border-color: #cccccc;
+  border-style: solid;
+  border-width: 0 1px 1px 0;
+  box-sizing: border-box;
+  height: 17px;
+  width: 17px;
+}
+.tox .tox-collection--list .tox-collection__group .tox-insert-table-picker {
+  margin: -4px 0;
+}
+.tox .tox-insert-table-picker .tox-insert-table-picker__selected {
+  background-color: rgba(32, 122, 183, 0.5);
+  border-color: rgba(32, 122, 183, 0.5);
+}
+.tox .tox-insert-table-picker__label {
+  color: rgba(34, 47, 62, 0.7);
+  display: block;
+  font-size: 14px;
+  padding: 4px;
+  text-align: center;
+  width: 100%;
+}
+.tox:not([dir=rtl]) {
+  /* stylelint-disable-next-line no-descending-specificity */
+}
+.tox:not([dir=rtl]) .tox-insert-table-picker > div:nth-child(10n) {
+  border-right: 0;
+}
+.tox[dir=rtl] {
+  /* stylelint-disable-next-line no-descending-specificity */
+}
+.tox[dir=rtl] .tox-insert-table-picker > div:nth-child(10n+1) {
+  border-right: 0;
+}
+.tox {
+  /* stylelint-disable */
+  /* stylelint-enable */
+}
+.tox .tox-menu {
+  background-color: #fff;
+  border: 1px solid #cccccc;
+  border-radius: 3px;
+  box-shadow: 0 4px 8px 0 rgba(34, 47, 62, 0.1);
+  display: inline-block;
+  overflow: hidden;
+  vertical-align: top;
+  z-index: 1150;
+}
+.tox .tox-menu.tox-collection.tox-collection--list {
+  padding: 0;
+}
+.tox .tox-menu.tox-collection.tox-collection--toolbar {
+  padding: 4px;
+}
+.tox .tox-menu.tox-collection.tox-collection--grid {
+  padding: 4px;
+}
+.tox .tox-menu__label h1,
+.tox .tox-menu__label h2,
+.tox .tox-menu__label h3,
+.tox .tox-menu__label h4,
+.tox .tox-menu__label h5,
+.tox .tox-menu__label h6,
+.tox .tox-menu__label p,
+.tox .tox-menu__label blockquote,
+.tox .tox-menu__label code {
+  margin: 0;
+}
+.tox .tox-menubar {
+  background: url("data:image/svg+xml;charset=utf8,%3Csvg height='39px' viewBox='0 0 40 39px' width='40' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='0' y='38px' width='100' height='1' fill='%23cccccc'/%3E%3C/svg%3E") left 0 top 0 #fff;
+  background-color: #fff;
+  display: flex;
+  flex: 0 0 auto;
+  flex-shrink: 0;
+  flex-wrap: wrap;
+  padding: 0 4px 0 4px;
+}
+.tox.tox-tinymce:not(.tox-tinymce-inline) .tox-editor-header:not(:first-child) .tox-menubar {
+  border-top: 1px solid #cccccc;
+}
+/* Deprecated. Remove in next major release */
+.tox .tox-mbtn {
+  align-items: center;
+  background: transparent;
+  border: 0;
+  border-radius: 3px;
+  box-shadow: none;
+  color: #222f3e;
+  display: flex;
+  flex: 0 0 auto;
+  font-size: 14px;
+  font-style: normal;
+  font-weight: normal;
+  height: 34px;
+  justify-content: center;
+  margin: 2px 0 3px 0;
+  outline: none;
+  overflow: hidden;
+  padding: 0 4px;
+  text-transform: none;
+  width: auto;
+}
+.tox .tox-mbtn[disabled] {
+  background-color: transparent;
+  border: 0;
+  box-shadow: none;
+  color: rgba(34, 47, 62, 0.5);
+  cursor: not-allowed;
+}
+.tox .tox-mbtn:focus:not(:disabled) {
+  background: #dee0e2;
+  border: 0;
+  box-shadow: none;
+  color: #222f3e;
+}
+.tox .tox-mbtn--active {
+  background: #c8cbcf;
+  border: 0;
+  box-shadow: none;
+  color: #222f3e;
+}
+.tox .tox-mbtn:hover:not(:disabled):not(.tox-mbtn--active) {
+  background: #dee0e2;
+  border: 0;
+  box-shadow: none;
+  color: #222f3e;
+}
+.tox .tox-mbtn__select-label {
+  cursor: default;
+  font-weight: normal;
+  margin: 0 4px;
+}
+.tox .tox-mbtn[disabled] .tox-mbtn__select-label {
+  cursor: not-allowed;
+}
+.tox .tox-mbtn__select-chevron {
+  align-items: center;
+  display: flex;
+  justify-content: center;
+  width: 16px;
+  display: none;
+}
+.tox .tox-notification {
+  border-radius: 3px;
+  border-style: solid;
+  border-width: 1px;
+  box-shadow: none;
+  box-sizing: border-box;
+  display: -ms-grid;
+  display: grid;
+  font-size: 14px;
+  font-weight: normal;
+  -ms-grid-columns: minmax(40px, 1fr) auto minmax(40px, 1fr);
+      grid-template-columns: minmax(40px, 1fr) auto minmax(40px, 1fr);
+  margin-top: 4px;
+  opacity: 0;
+  padding: 4px;
+  transition: transform 100ms ease-in, opacity 150ms ease-in;
+}
+.tox .tox-notification p {
+  font-size: 14px;
+  font-weight: normal;
+}
+.tox .tox-notification a {
+  cursor: pointer;
+  text-decoration: underline;
+}
+.tox .tox-notification--in {
+  opacity: 1;
+}
+.tox .tox-notification--success {
+  background-color: #e4eeda;
+  border-color: #d7e6c8;
+  color: #222f3e;
+}
+.tox .tox-notification--success p {
+  color: #222f3e;
+}
+.tox .tox-notification--success a {
+  color: #547831;
+}
+.tox .tox-notification--success svg {
+  fill: #222f3e;
+}
+.tox .tox-notification--error {
+  background-color: #f8dede;
+  border-color: #f2bfbf;
+  color: #222f3e;
+}
+.tox .tox-notification--error p {
+  color: #222f3e;
+}
+.tox .tox-notification--error a {
+  color: #c00;
+}
+.tox .tox-notification--error svg {
+  fill: #222f3e;
+}
+.tox .tox-notification--warn,
+.tox .tox-notification--warning {
+  background-color: #fffaea;
+  border-color: #ffe89d;
+  color: #222f3e;
+}
+.tox .tox-notification--warn p,
+.tox .tox-notification--warning p {
+  color: #222f3e;
+}
+.tox .tox-notification--warn a,
+.tox .tox-notification--warning a {
+  color: #222f3e;
+}
+.tox .tox-notification--warn svg,
+.tox .tox-notification--warning svg {
+  fill: #222f3e;
+}
+.tox .tox-notification--info {
+  background-color: #d9edf7;
+  border-color: #779ecb;
+  color: #222f3e;
+}
+.tox .tox-notification--info p {
+  color: #222f3e;
+}
+.tox .tox-notification--info a {
+  color: #222f3e;
+}
+.tox .tox-notification--info svg {
+  fill: #222f3e;
+}
+.tox .tox-notification__body {
+  -ms-grid-row-align: center;
+      align-self: center;
+  color: #222f3e;
+  font-size: 14px;
+  -ms-grid-column-span: 1;
+  grid-column-end: 3;
+  -ms-grid-column: 2;
+      grid-column-start: 2;
+  -ms-grid-row-span: 1;
+  grid-row-end: 2;
+  -ms-grid-row: 1;
+      grid-row-start: 1;
+  text-align: center;
+  white-space: normal;
+  word-break: break-all;
+  word-break: break-word;
+}
+.tox .tox-notification__body > * {
+  margin: 0;
+}
+.tox .tox-notification__body > * + * {
+  margin-top: 1rem;
+}
+.tox .tox-notification__icon {
+  -ms-grid-row-align: center;
+      align-self: center;
+  -ms-grid-column-span: 1;
+  grid-column-end: 2;
+  -ms-grid-column: 1;
+      grid-column-start: 1;
+  -ms-grid-row-span: 1;
+  grid-row-end: 2;
+  -ms-grid-row: 1;
+      grid-row-start: 1;
+  -ms-grid-column-align: end;
+      justify-self: end;
+}
+.tox .tox-notification__icon svg {
+  display: block;
+}
+.tox .tox-notification__dismiss {
+  -ms-grid-row-align: start;
+      align-self: start;
+  -ms-grid-column-span: 1;
+  grid-column-end: 4;
+  -ms-grid-column: 3;
+      grid-column-start: 3;
+  -ms-grid-row-span: 1;
+  grid-row-end: 2;
+  -ms-grid-row: 1;
+      grid-row-start: 1;
+  -ms-grid-column-align: end;
+      justify-self: end;
+}
+.tox .tox-notification .tox-progress-bar {
+  -ms-grid-column-span: 3;
+  grid-column-end: 4;
+  -ms-grid-column: 1;
+      grid-column-start: 1;
+  -ms-grid-row-span: 1;
+  grid-row-end: 3;
+  -ms-grid-row: 2;
+      grid-row-start: 2;
+  -ms-grid-column-align: center;
+      justify-self: center;
+}
+.tox .tox-pop {
+  display: inline-block;
+  position: relative;
+}
+.tox .tox-pop--resizing {
+  transition: width 0.1s ease;
+}
+.tox .tox-pop--resizing .tox-toolbar,
+.tox .tox-pop--resizing .tox-toolbar__group {
+  flex-wrap: nowrap;
+}
+.tox .tox-pop--transition {
+  transition: 0.15s ease;
+  transition-property: left, right, top, bottom;
+}
+.tox .tox-pop--transition::before,
+.tox .tox-pop--transition::after {
+  transition: all 0.15s, visibility 0s, opacity 0.075s ease 0.075s;
+}
+.tox .tox-pop__dialog {
+  background-color: #fff;
+  border: 1px solid #cccccc;
+  border-radius: 3px;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
+  min-width: 0;
+  overflow: hidden;
+}
+.tox .tox-pop__dialog > *:not(.tox-toolbar) {
+  margin: 4px 4px 4px 8px;
+}
+.tox .tox-pop__dialog .tox-toolbar {
+  background-color: transparent;
+  margin-bottom: -1px;
+}
+.tox .tox-pop::before,
+.tox .tox-pop::after {
+  border-style: solid;
+  content: '';
+  display: block;
+  height: 0;
+  opacity: 1;
+  position: absolute;
+  width: 0;
+}
+.tox .tox-pop.tox-pop--inset::before,
+.tox .tox-pop.tox-pop--inset::after {
+  opacity: 0;
+  transition: all 0s 0.15s, visibility 0s, opacity 0.075s ease;
+}
+.tox .tox-pop.tox-pop--bottom::before,
+.tox .tox-pop.tox-pop--bottom::after {
+  left: 50%;
+  top: 100%;
+}
+.tox .tox-pop.tox-pop--bottom::after {
+  border-color: #fff transparent transparent transparent;
+  border-width: 8px;
+  margin-left: -8px;
+  margin-top: -1px;
+}
+.tox .tox-pop.tox-pop--bottom::before {
+  border-color: #cccccc transparent transparent transparent;
+  border-width: 9px;
+  margin-left: -9px;
+}
+.tox .tox-pop.tox-pop--top::before,
+.tox .tox-pop.tox-pop--top::after {
+  left: 50%;
+  top: 0;
+  transform: translateY(-100%);
+}
+.tox .tox-pop.tox-pop--top::after {
+  border-color: transparent transparent #fff transparent;
+  border-width: 8px;
+  margin-left: -8px;
+  margin-top: 1px;
+}
+.tox .tox-pop.tox-pop--top::before {
+  border-color: transparent transparent #cccccc transparent;
+  border-width: 9px;
+  margin-left: -9px;
+}
+.tox .tox-pop.tox-pop--left::before,
+.tox .tox-pop.tox-pop--left::after {
+  left: 0;
+  top: calc(50% - 1px);
+  transform: translateY(-50%);
+}
+.tox .tox-pop.tox-pop--left::after {
+  border-color: transparent #fff transparent transparent;
+  border-width: 8px;
+  margin-left: -15px;
+}
+.tox .tox-pop.tox-pop--left::before {
+  border-color: transparent #cccccc transparent transparent;
+  border-width: 10px;
+  margin-left: -19px;
+}
+.tox .tox-pop.tox-pop--right::before,
+.tox .tox-pop.tox-pop--right::after {
+  left: 100%;
+  top: calc(50% + 1px);
+  transform: translateY(-50%);
+}
+.tox .tox-pop.tox-pop--right::after {
+  border-color: transparent transparent transparent #fff;
+  border-width: 8px;
+  margin-left: -1px;
+}
+.tox .tox-pop.tox-pop--right::before {
+  border-color: transparent transparent transparent #cccccc;
+  border-width: 10px;
+  margin-left: -1px;
+}
+.tox .tox-pop.tox-pop--align-left::before,
+.tox .tox-pop.tox-pop--align-left::after {
+  left: 20px;
+}
+.tox .tox-pop.tox-pop--align-right::before,
+.tox .tox-pop.tox-pop--align-right::after {
+  left: calc(100% - 20px);
+}
+.tox .tox-sidebar-wrap {
+  display: flex;
+  flex-direction: row;
+  flex-grow: 1;
+  -ms-flex-preferred-size: 0;
+  min-height: 0;
+}
+.tox .tox-sidebar {
+  background-color: #fff;
+  display: flex;
+  flex-direction: row;
+  justify-content: flex-end;
+}
+.tox .tox-sidebar__slider {
+  display: flex;
+  overflow: hidden;
+}
+.tox .tox-sidebar__pane-container {
+  display: flex;
+}
+.tox .tox-sidebar__pane {
+  display: flex;
+}
+.tox .tox-sidebar--sliding-closed {
+  opacity: 0;
+}
+.tox .tox-sidebar--sliding-open {
+  opacity: 1;
+}
+.tox .tox-sidebar--sliding-growing,
+.tox .tox-sidebar--sliding-shrinking {
+  transition: width 0.5s ease, opacity 0.5s ease;
+}
+.tox .tox-selector {
+  background-color: #4099ff;
+  border-color: #4099ff;
+  border-style: solid;
+  border-width: 1px;
+  box-sizing: border-box;
+  display: inline-block;
+  height: 10px;
+  position: absolute;
+  width: 10px;
+}
+.tox.tox-platform-touch .tox-selector {
+  height: 12px;
+  width: 12px;
+}
+.tox .tox-slider {
+  align-items: center;
+  display: flex;
+  flex: 1;
+  -ms-flex-preferred-size: auto;
+  height: 24px;
+  justify-content: center;
+  position: relative;
+}
+.tox .tox-slider__rail {
+  background-color: transparent;
+  border: 1px solid #cccccc;
+  border-radius: 3px;
+  height: 10px;
+  min-width: 120px;
+  width: 100%;
+}
+.tox .tox-slider__handle {
+  background-color: #207ab7;
+  border: 2px solid #185d8c;
+  border-radius: 3px;
+  box-shadow: none;
+  height: 24px;
+  left: 50%;
+  position: absolute;
+  top: 50%;
+  transform: translateX(-50%) translateY(-50%);
+  width: 14px;
+}
+.tox .tox-source-code {
+  overflow: auto;
+}
+.tox .tox-spinner {
+  display: flex;
+}
+.tox .tox-spinner > div {
+  animation: tam-bouncing-dots 1.5s ease-in-out 0s infinite both;
+  background-color: rgba(34, 47, 62, 0.7);
+  border-radius: 100%;
+  height: 8px;
+  width: 8px;
+}
+.tox .tox-spinner > div:nth-child(1) {
+  animation-delay: -0.32s;
+}
+.tox .tox-spinner > div:nth-child(2) {
+  animation-delay: -0.16s;
+}
+@keyframes tam-bouncing-dots {
+  0%,
+  80%,
+  100% {
+    transform: scale(0);
+  }
+  40% {
+    transform: scale(1);
+  }
+}
+.tox:not([dir=rtl]) .tox-spinner > div:not(:first-child) {
+  margin-left: 4px;
+}
+.tox[dir=rtl] .tox-spinner > div:not(:first-child) {
+  margin-right: 4px;
+}
+.tox .tox-statusbar {
+  align-items: center;
+  background-color: #fff;
+  border-top: 1px solid #cccccc;
+  color: rgba(34, 47, 62, 0.7);
+  display: flex;
+  flex: 0 0 auto;
+  font-size: 12px;
+  font-weight: normal;
+  height: 18px;
+  overflow: hidden;
+  padding: 0 8px;
+  position: relative;
+  text-transform: uppercase;
+}
+.tox .tox-statusbar__text-container {
+  display: flex;
+  flex: 1 1 auto;
+  justify-content: flex-end;
+  overflow: hidden;
+}
+.tox .tox-statusbar__path {
+  display: flex;
+  flex: 1 1 auto;
+  margin-right: auto;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.tox .tox-statusbar__path > * {
+  display: inline;
+  white-space: nowrap;
+}
+.tox .tox-statusbar__wordcount {
+  flex: 0 0 auto;
+  margin-left: 1ch;
+}
+.tox .tox-statusbar a,
+.tox .tox-statusbar__path-item,
+.tox .tox-statusbar__wordcount {
+  color: rgba(34, 47, 62, 0.7);
+  text-decoration: none;
+}
+.tox .tox-statusbar a:hover:not(:disabled):not([aria-disabled=true]),
+.tox .tox-statusbar__path-item:hover:not(:disabled):not([aria-disabled=true]),
+.tox .tox-statusbar__wordcount:hover:not(:disabled):not([aria-disabled=true]),
+.tox .tox-statusbar a:focus:not(:disabled):not([aria-disabled=true]),
+.tox .tox-statusbar__path-item:focus:not(:disabled):not([aria-disabled=true]),
+.tox .tox-statusbar__wordcount:focus:not(:disabled):not([aria-disabled=true]) {
+  cursor: pointer;
+  text-decoration: underline;
+}
+.tox .tox-statusbar__resize-handle {
+  align-items: flex-end;
+  align-self: stretch;
+  cursor: nwse-resize;
+  display: flex;
+  flex: 0 0 auto;
+  justify-content: flex-end;
+  margin-left: auto;
+  margin-right: -8px;
+  padding-left: 1ch;
+}
+.tox .tox-statusbar__resize-handle svg {
+  display: block;
+  fill: rgba(34, 47, 62, 0.7);
+}
+.tox .tox-statusbar__resize-handle:focus svg {
+  background-color: #dee0e2;
+  border-radius: 1px;
+  box-shadow: 0 0 0 2px #dee0e2;
+}
+.tox:not([dir=rtl]) .tox-statusbar__path > * {
+  margin-right: 4px;
+}
+.tox:not([dir=rtl]) .tox-statusbar__branding {
+  margin-left: 1ch;
+}
+.tox[dir=rtl] .tox-statusbar {
+  flex-direction: row-reverse;
+}
+.tox[dir=rtl] .tox-statusbar__path > * {
+  margin-left: 4px;
+}
+.tox .tox-throbber {
+  z-index: 1299;
+}
+.tox .tox-throbber__busy-spinner {
+  align-items: center;
+  background-color: rgba(255, 255, 255, 0.6);
+  bottom: 0;
+  display: flex;
+  justify-content: center;
+  left: 0;
+  position: absolute;
+  right: 0;
+  top: 0;
+}
+.tox .tox-tbtn {
+  align-items: center;
+  background: transparent;
+  border: 0;
+  border-radius: 3px;
+  box-shadow: none;
+  color: #222f3e;
+  display: flex;
+  flex: 0 0 auto;
+  font-size: 14px;
+  font-style: normal;
+  font-weight: normal;
+  height: 34px;
+  justify-content: center;
+  margin: 2px 0 3px 0;
+  outline: none;
+  overflow: hidden;
+  padding: 0;
+  text-transform: none;
+  width: 34px;
+}
+.tox .tox-tbtn svg {
+  display: block;
+  fill: #222f3e;
+}
+.tox .tox-tbtn.tox-tbtn-more {
+  padding-left: 5px;
+  padding-right: 5px;
+  width: inherit;
+}
+.tox .tox-tbtn:focus {
+  background: #dee0e2;
+  border: 0;
+  box-shadow: none;
+}
+.tox .tox-tbtn:hover {
+  background: #dee0e2;
+  border: 0;
+  box-shadow: none;
+  color: #222f3e;
+}
+.tox .tox-tbtn:hover svg {
+  fill: #222f3e;
+}
+.tox .tox-tbtn:active {
+  background: #c8cbcf;
+  border: 0;
+  box-shadow: none;
+  color: #222f3e;
+}
+.tox .tox-tbtn:active svg {
+  fill: #222f3e;
+}
+.tox .tox-tbtn--disabled,
+.tox .tox-tbtn--disabled:hover,
+.tox .tox-tbtn:disabled,
+.tox .tox-tbtn:disabled:hover {
+  background: transparent;
+  border: 0;
+  box-shadow: none;
+  color: rgba(34, 47, 62, 0.5);
+  cursor: not-allowed;
+}
+.tox .tox-tbtn--disabled svg,
+.tox .tox-tbtn--disabled:hover svg,
+.tox .tox-tbtn:disabled svg,
+.tox .tox-tbtn:disabled:hover svg {
+  /* stylelint-disable-line no-descending-specificity */
+  fill: rgba(34, 47, 62, 0.5);
+}
+.tox .tox-tbtn--enabled,
+.tox .tox-tbtn--enabled:hover {
+  background: #c8cbcf;
+  border: 0;
+  box-shadow: none;
+  color: #222f3e;
+}
+.tox .tox-tbtn--enabled > *,
+.tox .tox-tbtn--enabled:hover > * {
+  transform: none;
+}
+.tox .tox-tbtn--enabled svg,
+.tox .tox-tbtn--enabled:hover svg {
+  /* stylelint-disable-line no-descending-specificity */
+  fill: #222f3e;
+}
+.tox .tox-tbtn:focus:not(.tox-tbtn--disabled) {
+  color: #222f3e;
+}
+.tox .tox-tbtn:focus:not(.tox-tbtn--disabled) svg {
+  fill: #222f3e;
+}
+.tox .tox-tbtn:active > * {
+  transform: none;
+}
+.tox .tox-tbtn--md {
+  height: 51px;
+  width: 51px;
+}
+.tox .tox-tbtn--lg {
+  flex-direction: column;
+  height: 68px;
+  width: 68px;
+}
+.tox .tox-tbtn--return {
+  -ms-grid-row-align: stretch;
+      align-self: stretch;
+  height: unset;
+  width: 16px;
+}
+.tox .tox-tbtn--labeled {
+  padding: 0 4px;
+  width: unset;
+}
+.tox .tox-tbtn__vlabel {
+  display: block;
+  font-size: 10px;
+  font-weight: normal;
+  letter-spacing: -0.025em;
+  margin-bottom: 4px;
+  white-space: nowrap;
+}
+.tox .tox-tbtn--select {
+  margin: 2px 0 3px 0;
+  padding: 0 4px;
+  width: auto;
+}
+.tox .tox-tbtn__select-label {
+  cursor: default;
+  font-weight: normal;
+  margin: 0 4px;
+}
+.tox .tox-tbtn__select-chevron {
+  align-items: center;
+  display: flex;
+  justify-content: center;
+  width: 16px;
+}
+.tox .tox-tbtn__select-chevron svg {
+  fill: rgba(34, 47, 62, 0.5);
+}
+.tox .tox-tbtn--bespoke .tox-tbtn__select-label {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  width: 7em;
+}
+.tox .tox-split-button {
+  border: 0;
+  border-radius: 3px;
+  box-sizing: border-box;
+  display: flex;
+  margin: 2px 0 3px 0;
+  overflow: hidden;
+}
+.tox .tox-split-button:hover {
+  box-shadow: 0 0 0 1px #dee0e2 inset;
+}
+.tox .tox-split-button:focus {
+  background: #dee0e2;
+  box-shadow: none;
+  color: #222f3e;
+}
+.tox .tox-split-button > * {
+  border-radius: 0;
+}
+.tox .tox-split-button__chevron {
+  width: 16px;
+}
+.tox .tox-split-button__chevron svg {
+  fill: rgba(34, 47, 62, 0.5);
+}
+.tox .tox-split-button .tox-tbtn {
+  margin: 0;
+}
+.tox.tox-platform-touch .tox-split-button .tox-tbtn:first-child {
+  width: 30px;
+}
+.tox.tox-platform-touch .tox-split-button__chevron {
+  width: 20px;
+}
+.tox .tox-split-button.tox-tbtn--disabled:hover,
+.tox .tox-split-button.tox-tbtn--disabled:focus,
+.tox .tox-split-button.tox-tbtn--disabled .tox-tbtn:hover,
+.tox .tox-split-button.tox-tbtn--disabled .tox-tbtn:focus {
+  background: transparent;
+  box-shadow: none;
+  color: rgba(34, 47, 62, 0.5);
+}
+.tox .tox-toolbar-overlord {
+  background-color: #fff;
+}
+.tox .tox-toolbar,
+.tox .tox-toolbar__primary,
+.tox .tox-toolbar__overflow {
+  background: url("data:image/svg+xml;charset=utf8,%3Csvg height='39px' viewBox='0 0 40 39px' width='40' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='0' y='38px' width='100' height='1' fill='%23cccccc'/%3E%3C/svg%3E") left 0 top 0 #fff;
+  background-color: #fff;
+  display: flex;
+  flex: 0 0 auto;
+  flex-shrink: 0;
+  flex-wrap: wrap;
+  padding: 0 0;
+}
+.tox .tox-toolbar__overflow.tox-toolbar__overflow--closed {
+  height: 0;
+  opacity: 0;
+  padding-bottom: 0;
+  padding-top: 0;
+  visibility: hidden;
+}
+.tox .tox-toolbar__overflow--growing {
+  transition: height 0.3s ease, opacity 0.2s linear 0.1s;
+}
+.tox .tox-toolbar__overflow--shrinking {
+  transition: opacity 0.3s ease, height 0.2s linear 0.1s, visibility 0s linear 0.3s;
+}
+.tox .tox-menubar + .tox-toolbar,
+.tox .tox-menubar + .tox-toolbar-overlord .tox-toolbar__primary {
+  border-top: 1px solid #cccccc;
+  margin-top: -1px;
+}
+.tox .tox-toolbar--scrolling {
+  flex-wrap: nowrap;
+  overflow-x: auto;
+}
+.tox .tox-pop .tox-toolbar {
+  border-width: 0;
+}
+.tox .tox-toolbar--no-divider {
+  background-image: none;
+}
+.tox-tinymce:not(.tox-tinymce-inline) .tox-editor-header:not(:first-child) .tox-toolbar:first-child,
+.tox-tinymce:not(.tox-tinymce-inline) .tox-editor-header:not(:first-child) .tox-toolbar-overlord:first-child .tox-toolbar__primary {
+  border-top: 1px solid #cccccc;
+}
+.tox.tox-tinymce-aux .tox-toolbar__overflow {
+  background-color: #fff;
+  border: 1px solid #cccccc;
+  border-radius: 3px;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
+}
+.tox .tox-toolbar__group {
+  align-items: center;
+  display: flex;
+  flex-wrap: wrap;
+  margin: 0 0;
+  padding: 0 4px 0 4px;
+}
+.tox .tox-toolbar__group--pull-right {
+  margin-left: auto;
+}
+.tox .tox-toolbar--scrolling .tox-toolbar__group {
+  flex-shrink: 0;
+  flex-wrap: nowrap;
+}
+.tox:not([dir=rtl]) .tox-toolbar__group:not(:last-of-type) {
+  border-right: 1px solid #cccccc;
+}
+.tox[dir=rtl] .tox-toolbar__group:not(:last-of-type) {
+  border-left: 1px solid #cccccc;
+}
+.tox .tox-tooltip {
+  display: inline-block;
+  padding: 8px;
+  position: relative;
+}
+.tox .tox-tooltip__body {
+  background-color: #222f3e;
+  border-radius: 3px;
+  box-shadow: 0 2px 4px rgba(34, 47, 62, 0.3);
+  color: rgba(255, 255, 255, 0.75);
+  font-size: 14px;
+  font-style: normal;
+  font-weight: normal;
+  padding: 4px 8px;
+  text-transform: none;
+}
+.tox .tox-tooltip__arrow {
+  position: absolute;
+}
+.tox .tox-tooltip--down .tox-tooltip__arrow {
+  border-left: 8px solid transparent;
+  border-right: 8px solid transparent;
+  border-top: 8px solid #222f3e;
+  bottom: 0;
+  left: 50%;
+  position: absolute;
+  transform: translateX(-50%);
+}
+.tox .tox-tooltip--up .tox-tooltip__arrow {
+  border-bottom: 8px solid #222f3e;
+  border-left: 8px solid transparent;
+  border-right: 8px solid transparent;
+  left: 50%;
+  position: absolute;
+  top: 0;
+  transform: translateX(-50%);
+}
+.tox .tox-tooltip--right .tox-tooltip__arrow {
+  border-bottom: 8px solid transparent;
+  border-left: 8px solid #222f3e;
+  border-top: 8px solid transparent;
+  position: absolute;
+  right: 0;
+  top: 50%;
+  transform: translateY(-50%);
+}
+.tox .tox-tooltip--left .tox-tooltip__arrow {
+  border-bottom: 8px solid transparent;
+  border-right: 8px solid #222f3e;
+  border-top: 8px solid transparent;
+  left: 0;
+  position: absolute;
+  top: 50%;
+  transform: translateY(-50%);
+}
+.tox .tox-well {
+  border: 1px solid #cccccc;
+  border-radius: 3px;
+  padding: 8px;
+  width: 100%;
+}
+.tox .tox-well > *:first-child {
+  margin-top: 0;
+}
+.tox .tox-well > *:last-child {
+  margin-bottom: 0;
+}
+.tox .tox-well > *:only-child {
+  margin: 0;
+}
+.tox .tox-custom-editor {
+  border: 1px solid #cccccc;
+  border-radius: 3px;
+  display: flex;
+  flex: 1;
+  position: relative;
+}
+/* stylelint-disable */
+.tox {
+  /* stylelint-enable */
+}
+.tox .tox-dialog-loading::before {
+  background-color: rgba(0, 0, 0, 0.5);
+  content: "";
+  height: 100%;
+  position: absolute;
+  width: 100%;
+  z-index: 1000;
+}
+.tox .tox-tab {
+  cursor: pointer;
+}
+.tox .tox-dialog__content-js {
+  display: flex;
+  flex: 1;
+  -ms-flex-preferred-size: auto;
+}
+.tox .tox-dialog__body-content .tox-collection {
+  display: flex;
+  flex: 1;
+  -ms-flex-preferred-size: auto;
+}
+.tox .tox-image-tools-edit-panel {
+  height: 60px;
+}
+.tox .tox-image-tools__sidebar {
+  height: 60px;
+}
diff --git a/public/tinymce/skins/ui/oxide/skin.min.css b/public/tinymce/skins/ui/oxide/skin.min.css
new file mode 100644
index 0000000..f570b8e
--- /dev/null
+++ b/public/tinymce/skins/ui/oxide/skin.min.css
@@ -0,0 +1,7 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+.tox{box-shadow:none;box-sizing:content-box;color:#222f3e;cursor:auto;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;font-size:16px;font-style:normal;font-weight:400;line-height:normal;-webkit-tap-highlight-color:transparent;text-decoration:none;text-shadow:none;text-transform:none;vertical-align:initial;white-space:normal}.tox :not(svg):not(rect){box-sizing:inherit;color:inherit;cursor:inherit;direction:inherit;font-family:inherit;font-size:inherit;font-style:inherit;font-weight:inherit;line-height:inherit;-webkit-tap-highlight-color:inherit;text-align:inherit;text-decoration:inherit;text-shadow:inherit;text-transform:inherit;vertical-align:inherit;white-space:inherit}.tox :not(svg):not(rect){background:0 0;border:0;box-shadow:none;float:none;height:auto;margin:0;max-width:none;outline:0;padding:0;position:static;width:auto}.tox:not([dir=rtl]){direction:ltr;text-align:left}.tox[dir=rtl]{direction:rtl;text-align:right}.tox-tinymce{border:1px solid #ccc;border-radius:0;box-shadow:none;box-sizing:border-box;display:flex;flex-direction:column;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;overflow:hidden;position:relative;visibility:inherit!important}.tox-tinymce-inline{border:none;box-shadow:none}.tox-tinymce-inline .tox-editor-header{background-color:transparent;border:1px solid #ccc;border-radius:0;box-shadow:none}.tox-tinymce-aux{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;z-index:1300}.tox-tinymce :focus,.tox-tinymce-aux :focus{outline:0}button::-moz-focus-inner{border:0}.tox[dir=rtl] .tox-icon--flip svg{transform:rotateY(180deg)}.tox .accessibility-issue__header{align-items:center;display:flex;margin-bottom:4px}.tox .accessibility-issue__description{align-items:stretch;border:1px solid #ccc;border-radius:3px;display:flex;justify-content:space-between}.tox .accessibility-issue__description>div{padding-bottom:4px}.tox .accessibility-issue__description>div>div{align-items:center;display:flex;margin-bottom:4px}.tox .accessibility-issue__description>:last-child:not(:only-child){border-color:#ccc;border-style:solid}.tox .accessibility-issue__repair{margin-top:16px}.tox .tox-dialog__body-content .accessibility-issue--info .accessibility-issue__description{background-color:rgba(32,122,183,.1);border-color:rgba(32,122,183,.4);color:#222f3e}.tox .tox-dialog__body-content .accessibility-issue--info .accessibility-issue__description>:last-child{border-color:rgba(32,122,183,.4)}.tox .tox-dialog__body-content .accessibility-issue--info .tox-form__group h2{color:#207ab7}.tox .tox-dialog__body-content .accessibility-issue--info .tox-icon svg{fill:#207ab7}.tox .tox-dialog__body-content .accessibility-issue--info a .tox-icon{color:#207ab7}.tox .tox-dialog__body-content .accessibility-issue--warn .accessibility-issue__description{background-color:rgba(255,165,0,.1);border-color:rgba(255,165,0,.5);color:#222f3e}.tox .tox-dialog__body-content .accessibility-issue--warn .accessibility-issue__description>:last-child{border-color:rgba(255,165,0,.5)}.tox .tox-dialog__body-content .accessibility-issue--warn .tox-form__group h2{color:#cc8500}.tox .tox-dialog__body-content .accessibility-issue--warn .tox-icon svg{fill:#cc8500}.tox .tox-dialog__body-content .accessibility-issue--warn a .tox-icon{color:#cc8500}.tox .tox-dialog__body-content .accessibility-issue--error .accessibility-issue__description{background-color:rgba(204,0,0,.1);border-color:rgba(204,0,0,.4);color:#222f3e}.tox .tox-dialog__body-content .accessibility-issue--error .accessibility-issue__description>:last-child{border-color:rgba(204,0,0,.4)}.tox .tox-dialog__body-content .accessibility-issue--error .tox-form__group h2{color:#c00}.tox .tox-dialog__body-content .accessibility-issue--error .tox-icon svg{fill:#c00}.tox .tox-dialog__body-content .accessibility-issue--error a .tox-icon{color:#c00}.tox .tox-dialog__body-content .accessibility-issue--success .accessibility-issue__description{background-color:rgba(120,171,70,.1);border-color:rgba(120,171,70,.4);color:#222f3e}.tox .tox-dialog__body-content .accessibility-issue--success .accessibility-issue__description>:last-child{border-color:rgba(120,171,70,.4)}.tox .tox-dialog__body-content .accessibility-issue--success .tox-form__group h2{color:#78ab46}.tox .tox-dialog__body-content .accessibility-issue--success .tox-icon svg{fill:#78ab46}.tox .tox-dialog__body-content .accessibility-issue--success a .tox-icon{color:#78ab46}.tox .tox-dialog__body-content .accessibility-issue__header h1,.tox .tox-dialog__body-content .tox-form__group .accessibility-issue__description h2{margin-top:0}.tox:not([dir=rtl]) .tox-dialog__body-content .accessibility-issue__header .tox-button{margin-left:4px}.tox:not([dir=rtl]) .tox-dialog__body-content .accessibility-issue__header>:nth-last-child(2){margin-left:auto}.tox:not([dir=rtl]) .tox-dialog__body-content .accessibility-issue__description{padding:4px 4px 4px 8px}.tox:not([dir=rtl]) .tox-dialog__body-content .accessibility-issue__description>:last-child{border-left-width:1px;padding-left:4px}.tox[dir=rtl] .tox-dialog__body-content .accessibility-issue__header .tox-button{margin-right:4px}.tox[dir=rtl] .tox-dialog__body-content .accessibility-issue__header>:nth-last-child(2){margin-right:auto}.tox[dir=rtl] .tox-dialog__body-content .accessibility-issue__description{padding:4px 8px 4px 4px}.tox[dir=rtl] .tox-dialog__body-content .accessibility-issue__description>:last-child{border-right-width:1px;padding-right:4px}.tox .tox-anchorbar{display:flex;flex:0 0 auto}.tox .tox-bar{display:flex;flex:0 0 auto}.tox .tox-button{background-color:#207ab7;background-image:none;background-position:0 0;background-repeat:repeat;border-color:#207ab7;border-radius:3px;border-style:solid;border-width:1px;box-shadow:none;box-sizing:border-box;color:#fff;cursor:pointer;display:inline-block;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;font-size:14px;font-style:normal;font-weight:700;letter-spacing:normal;line-height:24px;margin:0;outline:0;padding:4px 16px;text-align:center;text-decoration:none;text-transform:none;white-space:nowrap}.tox .tox-button[disabled]{background-color:#207ab7;background-image:none;border-color:#207ab7;box-shadow:none;color:rgba(255,255,255,.5);cursor:not-allowed}.tox .tox-button:focus:not(:disabled){background-color:#1c6ca1;background-image:none;border-color:#1c6ca1;box-shadow:none;color:#fff}.tox .tox-button:hover:not(:disabled){background-color:#1c6ca1;background-image:none;border-color:#1c6ca1;box-shadow:none;color:#fff}.tox .tox-button:active:not(:disabled){background-color:#185d8c;background-image:none;border-color:#185d8c;box-shadow:none;color:#fff}.tox .tox-button--secondary{background-color:#f0f0f0;background-image:none;background-position:0 0;background-repeat:repeat;border-color:#f0f0f0;border-radius:3px;border-style:solid;border-width:1px;box-shadow:none;color:#222f3e;font-size:14px;font-style:normal;font-weight:700;letter-spacing:normal;outline:0;padding:4px 16px;text-decoration:none;text-transform:none}.tox .tox-button--secondary[disabled]{background-color:#f0f0f0;background-image:none;border-color:#f0f0f0;box-shadow:none;color:rgba(34,47,62,.5)}.tox .tox-button--secondary:focus:not(:disabled){background-color:#e3e3e3;background-image:none;border-color:#e3e3e3;box-shadow:none;color:#222f3e}.tox .tox-button--secondary:hover:not(:disabled){background-color:#e3e3e3;background-image:none;border-color:#e3e3e3;box-shadow:none;color:#222f3e}.tox .tox-button--secondary:active:not(:disabled){background-color:#d6d6d6;background-image:none;border-color:#d6d6d6;box-shadow:none;color:#222f3e}.tox .tox-button--icon,.tox .tox-button.tox-button--icon,.tox .tox-button.tox-button--secondary.tox-button--icon{padding:4px}.tox .tox-button--icon .tox-icon svg,.tox .tox-button.tox-button--icon .tox-icon svg,.tox .tox-button.tox-button--secondary.tox-button--icon .tox-icon svg{display:block;fill:currentColor}.tox .tox-button-link{background:0;border:none;box-sizing:border-box;cursor:pointer;display:inline-block;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;font-size:16px;font-weight:400;line-height:1.3;margin:0;padding:0;white-space:nowrap}.tox .tox-button-link--sm{font-size:14px}.tox .tox-button--naked{background-color:transparent;border-color:transparent;box-shadow:unset;color:#222f3e}.tox .tox-button--naked[disabled]{background-color:#f0f0f0;border-color:#f0f0f0;box-shadow:none;color:rgba(34,47,62,.5)}.tox .tox-button--naked:hover:not(:disabled){background-color:#e3e3e3;border-color:#e3e3e3;box-shadow:none;color:#222f3e}.tox .tox-button--naked:focus:not(:disabled){background-color:#e3e3e3;border-color:#e3e3e3;box-shadow:none;color:#222f3e}.tox .tox-button--naked:active:not(:disabled){background-color:#d6d6d6;border-color:#d6d6d6;box-shadow:none;color:#222f3e}.tox .tox-button--naked .tox-icon svg{fill:currentColor}.tox .tox-button--naked.tox-button--icon:hover:not(:disabled){color:#222f3e}.tox .tox-checkbox{align-items:center;border-radius:3px;cursor:pointer;display:flex;height:36px;min-width:36px}.tox .tox-checkbox__input{height:1px;overflow:hidden;position:absolute;top:auto;width:1px}.tox .tox-checkbox__icons{align-items:center;border-radius:3px;box-shadow:0 0 0 2px transparent;box-sizing:content-box;display:flex;height:24px;justify-content:center;padding:calc(4px - 1px);width:24px}.tox .tox-checkbox__icons .tox-checkbox-icon__unchecked svg{display:block;fill:rgba(34,47,62,.3)}.tox .tox-checkbox__icons .tox-checkbox-icon__indeterminate svg{display:none;fill:#207ab7}.tox .tox-checkbox__icons .tox-checkbox-icon__checked svg{display:none;fill:#207ab7}.tox .tox-checkbox--disabled{color:rgba(34,47,62,.5);cursor:not-allowed}.tox .tox-checkbox--disabled .tox-checkbox__icons .tox-checkbox-icon__checked svg{fill:rgba(34,47,62,.5)}.tox .tox-checkbox--disabled .tox-checkbox__icons .tox-checkbox-icon__unchecked svg{fill:rgba(34,47,62,.5)}.tox .tox-checkbox--disabled .tox-checkbox__icons .tox-checkbox-icon__indeterminate svg{fill:rgba(34,47,62,.5)}.tox input.tox-checkbox__input:checked+.tox-checkbox__icons .tox-checkbox-icon__unchecked svg{display:none}.tox input.tox-checkbox__input:checked+.tox-checkbox__icons .tox-checkbox-icon__checked svg{display:block}.tox input.tox-checkbox__input:indeterminate+.tox-checkbox__icons .tox-checkbox-icon__unchecked svg{display:none}.tox input.tox-checkbox__input:indeterminate+.tox-checkbox__icons .tox-checkbox-icon__indeterminate svg{display:block}.tox input.tox-checkbox__input:focus+.tox-checkbox__icons{border-radius:3px;box-shadow:inset 0 0 0 1px #207ab7;padding:calc(4px - 1px)}.tox:not([dir=rtl]) .tox-checkbox__label{margin-left:4px}.tox:not([dir=rtl]) .tox-checkbox__input{left:-10000px}.tox:not([dir=rtl]) .tox-bar .tox-checkbox{margin-left:4px}.tox[dir=rtl] .tox-checkbox__label{margin-right:4px}.tox[dir=rtl] .tox-checkbox__input{right:-10000px}.tox[dir=rtl] .tox-bar .tox-checkbox{margin-right:4px}.tox .tox-collection--toolbar .tox-collection__group{display:flex;padding:0}.tox .tox-collection--grid .tox-collection__group{display:flex;flex-wrap:wrap;max-height:208px;overflow-x:hidden;overflow-y:auto;padding:0}.tox .tox-collection--list .tox-collection__group{border-bottom-width:0;border-color:#ccc;border-left-width:0;border-right-width:0;border-style:solid;border-top-width:1px;padding:4px 0}.tox .tox-collection--list .tox-collection__group:first-child{border-top-width:0}.tox .tox-collection__group-heading{background-color:#e6e6e6;color:rgba(34,47,62,.7);cursor:default;font-size:12px;font-style:normal;font-weight:400;margin-bottom:4px;margin-top:-4px;padding:4px 8px;text-transform:none;-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.tox .tox-collection__item{align-items:center;color:#222f3e;cursor:pointer;display:flex;-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.tox .tox-collection--list .tox-collection__item{padding:4px 8px}.tox .tox-collection--toolbar .tox-collection__item{border-radius:3px;padding:4px}.tox .tox-collection--grid .tox-collection__item{border-radius:3px;padding:4px}.tox .tox-collection--list .tox-collection__item--enabled{background-color:#fff;color:#222f3e}.tox .tox-collection--list .tox-collection__item--active{background-color:#dee0e2}.tox .tox-collection--toolbar .tox-collection__item--enabled{background-color:#c8cbcf;color:#222f3e}.tox .tox-collection--toolbar .tox-collection__item--active{background-color:#dee0e2}.tox .tox-collection--grid .tox-collection__item--enabled{background-color:#c8cbcf;color:#222f3e}.tox .tox-collection--grid .tox-collection__item--active:not(.tox-collection__item--state-disabled){background-color:#dee0e2;color:#222f3e}.tox .tox-collection--list .tox-collection__item--active:not(.tox-collection__item--state-disabled){color:#222f3e}.tox .tox-collection--toolbar .tox-collection__item--active:not(.tox-collection__item--state-disabled){color:#222f3e}.tox .tox-collection__item-checkmark,.tox .tox-collection__item-icon{align-items:center;display:flex;height:24px;justify-content:center;width:24px}.tox .tox-collection__item-checkmark svg,.tox .tox-collection__item-icon svg{fill:currentColor}.tox .tox-collection--toolbar-lg .tox-collection__item-icon{height:48px;width:48px}.tox .tox-collection__item-label{color:currentColor;display:inline-block;flex:1;-ms-flex-preferred-size:auto;font-size:14px;font-style:normal;font-weight:400;line-height:24px;text-transform:none;word-break:break-all}.tox .tox-collection__item-accessory{color:rgba(34,47,62,.7);display:inline-block;font-size:14px;height:24px;line-height:24px;text-transform:none}.tox .tox-collection__item-caret{align-items:center;display:flex;min-height:24px}.tox .tox-collection__item-caret::after{content:'';font-size:0;min-height:inherit}.tox .tox-collection__item-caret svg{fill:#222f3e}.tox .tox-collection__item--state-disabled{background-color:transparent;color:rgba(34,47,62,.5);cursor:not-allowed}.tox .tox-collection__item--state-disabled .tox-collection__item-caret svg{fill:rgba(34,47,62,.5)}.tox .tox-collection--list .tox-collection__item:not(.tox-collection__item--enabled) .tox-collection__item-checkmark svg{display:none}.tox .tox-collection--list .tox-collection__item:not(.tox-collection__item--enabled) .tox-collection__item-accessory+.tox-collection__item-checkmark{display:none}.tox .tox-collection--horizontal{background-color:#fff;border:1px solid #ccc;border-radius:3px;box-shadow:0 1px 3px rgba(0,0,0,.15);display:flex;flex:0 0 auto;flex-shrink:0;flex-wrap:nowrap;margin-bottom:0;overflow-x:auto;padding:0}.tox .tox-collection--horizontal .tox-collection__group{align-items:center;display:flex;flex-wrap:nowrap;margin:0;padding:0 4px}.tox .tox-collection--horizontal .tox-collection__item{height:34px;margin:2px 0 3px 0;padding:0 4px}.tox .tox-collection--horizontal .tox-collection__item-label{white-space:nowrap}.tox .tox-collection--horizontal .tox-collection__item-caret{margin-left:4px}.tox .tox-collection__item-container{display:flex}.tox .tox-collection__item-container--row{align-items:center;flex:1 1 auto;flex-direction:row}.tox .tox-collection__item-container--row.tox-collection__item-container--align-left{margin-right:auto}.tox .tox-collection__item-container--row.tox-collection__item-container--align-right{justify-content:flex-end;margin-left:auto}.tox .tox-collection__item-container--row.tox-collection__item-container--valign-top{align-items:flex-start;margin-bottom:auto}.tox .tox-collection__item-container--row.tox-collection__item-container--valign-middle{align-items:center}.tox .tox-collection__item-container--row.tox-collection__item-container--valign-bottom{align-items:flex-end;margin-top:auto}.tox .tox-collection__item-container--column{-ms-grid-row-align:center;align-self:center;flex:1 1 auto;flex-direction:column}.tox .tox-collection__item-container--column.tox-collection__item-container--align-left{align-items:flex-start}.tox .tox-collection__item-container--column.tox-collection__item-container--align-right{align-items:flex-end}.tox .tox-collection__item-container--column.tox-collection__item-container--valign-top{align-self:flex-start}.tox .tox-collection__item-container--column.tox-collection__item-container--valign-middle{-ms-grid-row-align:center;align-self:center}.tox .tox-collection__item-container--column.tox-collection__item-container--valign-bottom{align-self:flex-end}.tox:not([dir=rtl]) .tox-collection--horizontal .tox-collection__group:not(:last-of-type){border-right:1px solid #ccc}.tox:not([dir=rtl]) .tox-collection--list .tox-collection__item>:not(:first-child){margin-left:8px}.tox:not([dir=rtl]) .tox-collection--list .tox-collection__item>.tox-collection__item-label:first-child{margin-left:4px}.tox:not([dir=rtl]) .tox-collection__item-accessory{margin-left:16px;text-align:right}.tox:not([dir=rtl]) .tox-collection .tox-collection__item-caret{margin-left:16px}.tox[dir=rtl] .tox-collection--horizontal .tox-collection__group:not(:last-of-type){border-left:1px solid #ccc}.tox[dir=rtl] .tox-collection--list .tox-collection__item>:not(:first-child){margin-right:8px}.tox[dir=rtl] .tox-collection--list .tox-collection__item>.tox-collection__item-label:first-child{margin-right:4px}.tox[dir=rtl] .tox-collection__item-accessory{margin-right:16px;text-align:left}.tox[dir=rtl] .tox-collection .tox-collection__item-caret{margin-right:16px;transform:rotateY(180deg)}.tox[dir=rtl] .tox-collection--horizontal .tox-collection__item-caret{margin-right:4px}.tox .tox-color-picker-container{display:flex;flex-direction:row;height:225px;margin:0}.tox .tox-sv-palette{box-sizing:border-box;display:flex;height:100%}.tox .tox-sv-palette-spectrum{height:100%}.tox .tox-sv-palette,.tox .tox-sv-palette-spectrum{width:225px}.tox .tox-sv-palette-thumb{background:0 0;border:1px solid #000;border-radius:50%;box-sizing:content-box;height:12px;position:absolute;width:12px}.tox .tox-sv-palette-inner-thumb{border:1px solid #fff;border-radius:50%;height:10px;position:absolute;width:10px}.tox .tox-hue-slider{box-sizing:border-box;height:100%;width:25px}.tox .tox-hue-slider-spectrum{background:linear-gradient(to bottom,red,#ff0080,#f0f,#8000ff,#00f,#0080ff,#0ff,#00ff80,#0f0,#80ff00,#ff0,#ff8000,red);height:100%;width:100%}.tox .tox-hue-slider,.tox .tox-hue-slider-spectrum{width:20px}.tox .tox-hue-slider-thumb{background:#fff;border:1px solid #000;box-sizing:content-box;height:4px;width:100%}.tox .tox-rgb-form{display:flex;flex-direction:column;justify-content:space-between}.tox .tox-rgb-form div{align-items:center;display:flex;justify-content:space-between;margin-bottom:5px;width:inherit}.tox .tox-rgb-form input{width:6em}.tox .tox-rgb-form input.tox-invalid{border:1px solid red!important}.tox .tox-rgb-form .tox-rgba-preview{border:1px solid #000;flex-grow:2;margin-bottom:0}.tox:not([dir=rtl]) .tox-sv-palette{margin-right:15px}.tox:not([dir=rtl]) .tox-hue-slider{margin-right:15px}.tox:not([dir=rtl]) .tox-hue-slider-thumb{margin-left:-1px}.tox:not([dir=rtl]) .tox-rgb-form label{margin-right:.5em}.tox[dir=rtl] .tox-sv-palette{margin-left:15px}.tox[dir=rtl] .tox-hue-slider{margin-left:15px}.tox[dir=rtl] .tox-hue-slider-thumb{margin-right:-1px}.tox[dir=rtl] .tox-rgb-form label{margin-left:.5em}.tox .tox-toolbar .tox-swatches,.tox .tox-toolbar__overflow .tox-swatches,.tox .tox-toolbar__primary .tox-swatches{margin:2px 0 3px 4px}.tox .tox-collection--list .tox-collection__group .tox-swatches-menu{border:0;margin:-4px 0}.tox .tox-swatches__row{display:flex}.tox .tox-swatch{height:30px;transition:transform .15s,box-shadow .15s;width:30px}.tox .tox-swatch:focus,.tox .tox-swatch:hover{box-shadow:0 0 0 1px rgba(127,127,127,.3) inset;transform:scale(.8)}.tox .tox-swatch--remove{align-items:center;display:flex;justify-content:center}.tox .tox-swatch--remove svg path{stroke:#e74c3c}.tox .tox-swatches__picker-btn{align-items:center;background-color:transparent;border:0;cursor:pointer;display:flex;height:30px;justify-content:center;outline:0;padding:0;width:30px}.tox .tox-swatches__picker-btn svg{height:24px;width:24px}.tox .tox-swatches__picker-btn:hover{background:#dee0e2}.tox:not([dir=rtl]) .tox-swatches__picker-btn{margin-left:auto}.tox[dir=rtl] .tox-swatches__picker-btn{margin-right:auto}.tox .tox-comment-thread{background:#fff;position:relative}.tox .tox-comment-thread>:not(:first-child){margin-top:8px}.tox .tox-comment{background:#fff;border:1px solid #ccc;border-radius:3px;box-shadow:0 4px 8px 0 rgba(34,47,62,.1);padding:8px 8px 16px 8px;position:relative}.tox .tox-comment__header{align-items:center;color:#222f3e;display:flex;justify-content:space-between}.tox .tox-comment__date{color:rgba(34,47,62,.7);font-size:12px}.tox .tox-comment__body{color:#222f3e;font-size:14px;font-style:normal;font-weight:400;line-height:1.3;margin-top:8px;position:relative;text-transform:initial}.tox .tox-comment__body textarea{resize:none;white-space:normal;width:100%}.tox .tox-comment__expander{padding-top:8px}.tox .tox-comment__expander p{color:rgba(34,47,62,.7);font-size:14px;font-style:normal}.tox .tox-comment__body p{margin:0}.tox .tox-comment__buttonspacing{padding-top:16px;text-align:center}.tox .tox-comment-thread__overlay::after{background:#fff;bottom:0;content:"";display:flex;left:0;opacity:.9;position:absolute;right:0;top:0;z-index:5}.tox .tox-comment__reply{display:flex;flex-shrink:0;flex-wrap:wrap;justify-content:flex-end;margin-top:8px}.tox .tox-comment__reply>:first-child{margin-bottom:8px;width:100%}.tox .tox-comment__edit{display:flex;flex-wrap:wrap;justify-content:flex-end;margin-top:16px}.tox .tox-comment__gradient::after{background:linear-gradient(rgba(255,255,255,0),#fff);bottom:0;content:"";display:block;height:5em;margin-top:-40px;position:absolute;width:100%}.tox .tox-comment__overlay{background:#fff;bottom:0;display:flex;flex-direction:column;flex-grow:1;left:0;opacity:.9;position:absolute;right:0;text-align:center;top:0;z-index:5}.tox .tox-comment__loading-text{align-items:center;color:#222f3e;display:flex;flex-direction:column;position:relative}.tox .tox-comment__loading-text>div{padding-bottom:16px}.tox .tox-comment__overlaytext{bottom:0;flex-direction:column;font-size:14px;left:0;padding:1em;position:absolute;right:0;top:0;z-index:10}.tox .tox-comment__overlaytext p{background-color:#fff;box-shadow:0 0 8px 8px #fff;color:#222f3e;text-align:center}.tox .tox-comment__overlaytext div:nth-of-type(2){font-size:.8em}.tox .tox-comment__busy-spinner{align-items:center;background-color:#fff;bottom:0;display:flex;justify-content:center;left:0;position:absolute;right:0;top:0;z-index:20}.tox .tox-comment__scroll{display:flex;flex-direction:column;flex-shrink:1;overflow:auto}.tox .tox-conversations{margin:8px}.tox:not([dir=rtl]) .tox-comment__edit{margin-left:8px}.tox:not([dir=rtl]) .tox-comment__buttonspacing>:last-child,.tox:not([dir=rtl]) .tox-comment__edit>:last-child,.tox:not([dir=rtl]) .tox-comment__reply>:last-child{margin-left:8px}.tox[dir=rtl] .tox-comment__edit{margin-right:8px}.tox[dir=rtl] .tox-comment__buttonspacing>:last-child,.tox[dir=rtl] .tox-comment__edit>:last-child,.tox[dir=rtl] .tox-comment__reply>:last-child{margin-right:8px}.tox .tox-user{align-items:center;display:flex}.tox .tox-user__avatar svg{fill:rgba(34,47,62,.7)}.tox .tox-user__name{color:rgba(34,47,62,.7);font-size:12px;font-style:normal;font-weight:700;text-transform:uppercase}.tox:not([dir=rtl]) .tox-user__avatar svg{margin-right:8px}.tox:not([dir=rtl]) .tox-user__avatar+.tox-user__name{margin-left:8px}.tox[dir=rtl] .tox-user__avatar svg{margin-left:8px}.tox[dir=rtl] .tox-user__avatar+.tox-user__name{margin-right:8px}.tox .tox-dialog-wrap{align-items:center;bottom:0;display:flex;justify-content:center;left:0;position:fixed;right:0;top:0;z-index:1100}.tox .tox-dialog-wrap__backdrop{background-color:rgba(255,255,255,.75);bottom:0;left:0;position:absolute;right:0;top:0;z-index:1}.tox .tox-dialog-wrap__backdrop--opaque{background-color:#fff}.tox .tox-dialog{background-color:#fff;border-color:#ccc;border-radius:3px;border-style:solid;border-width:1px;box-shadow:0 16px 16px -10px rgba(34,47,62,.15),0 0 40px 1px rgba(34,47,62,.15);display:flex;flex-direction:column;max-height:100%;max-width:480px;overflow:hidden;position:relative;width:95vw;z-index:2}@media only screen and (max-width:767px){body:not(.tox-force-desktop) .tox .tox-dialog{align-self:flex-start;margin:8px auto;width:calc(100vw - 16px)}}.tox .tox-dialog-inline{z-index:1100}.tox .tox-dialog__header{align-items:center;background-color:#fff;border-bottom:none;color:#222f3e;display:flex;font-size:16px;justify-content:space-between;padding:8px 16px 0 16px;position:relative}.tox .tox-dialog__header .tox-button{z-index:1}.tox .tox-dialog__draghandle{cursor:grab;height:100%;left:0;position:absolute;top:0;width:100%}.tox .tox-dialog__draghandle:active{cursor:grabbing}.tox .tox-dialog__dismiss{margin-left:auto}.tox .tox-dialog__title{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;font-size:20px;font-style:normal;font-weight:400;line-height:1.3;margin:0;text-transform:none}.tox .tox-dialog__body{color:#222f3e;display:flex;flex:1;-ms-flex-preferred-size:auto;font-size:16px;font-style:normal;font-weight:400;line-height:1.3;min-width:0;text-align:left;text-transform:none}@media only screen and (max-width:767px){body:not(.tox-force-desktop) .tox .tox-dialog__body{flex-direction:column}}.tox .tox-dialog__body-nav{align-items:flex-start;display:flex;flex-direction:column;padding:16px 16px}@media only screen and (max-width:767px){body:not(.tox-force-desktop) .tox .tox-dialog__body-nav{flex-direction:row;-webkit-overflow-scrolling:touch;overflow-x:auto;padding-bottom:0}}.tox .tox-dialog__body-nav-item{border-bottom:2px solid transparent;color:rgba(34,47,62,.7);display:inline-block;font-size:14px;line-height:1.3;margin-bottom:8px;text-decoration:none;white-space:nowrap}.tox .tox-dialog__body-nav-item:focus{background-color:rgba(32,122,183,.1)}.tox .tox-dialog__body-nav-item--active{border-bottom:2px solid #207ab7;color:#207ab7}.tox .tox-dialog__body-content{box-sizing:border-box;display:flex;flex:1;flex-direction:column;-ms-flex-preferred-size:auto;max-height:650px;overflow:auto;-webkit-overflow-scrolling:touch;padding:16px 16px}.tox .tox-dialog__body-content>*{margin-bottom:0;margin-top:16px}.tox .tox-dialog__body-content>:first-child{margin-top:0}.tox .tox-dialog__body-content>:last-child{margin-bottom:0}.tox .tox-dialog__body-content>:only-child{margin-bottom:0;margin-top:0}.tox .tox-dialog__body-content a{color:#207ab7;cursor:pointer;text-decoration:none}.tox .tox-dialog__body-content a:focus,.tox .tox-dialog__body-content a:hover{color:#185d8c;text-decoration:none}.tox .tox-dialog__body-content a:active{color:#185d8c;text-decoration:none}.tox .tox-dialog__body-content svg{fill:#222f3e}.tox .tox-dialog__body-content ul{display:block;list-style-type:disc;margin-bottom:16px;-webkit-margin-end:0;margin-inline-end:0;-webkit-margin-start:0;margin-inline-start:0;-webkit-padding-start:2.5rem;padding-inline-start:2.5rem}.tox .tox-dialog__body-content .tox-form__group h1{color:#222f3e;font-size:20px;font-style:normal;font-weight:700;letter-spacing:normal;margin-bottom:16px;margin-top:2rem;text-transform:none}.tox .tox-dialog__body-content .tox-form__group h2{color:#222f3e;font-size:16px;font-style:normal;font-weight:700;letter-spacing:normal;margin-bottom:16px;margin-top:2rem;text-transform:none}.tox .tox-dialog__body-content .tox-form__group p{margin-bottom:16px}.tox .tox-dialog__body-content .tox-form__group h1:first-child,.tox .tox-dialog__body-content .tox-form__group h2:first-child,.tox .tox-dialog__body-content .tox-form__group p:first-child{margin-top:0}.tox .tox-dialog__body-content .tox-form__group h1:last-child,.tox .tox-dialog__body-content .tox-form__group h2:last-child,.tox .tox-dialog__body-content .tox-form__group p:last-child{margin-bottom:0}.tox .tox-dialog__body-content .tox-form__group h1:only-child,.tox .tox-dialog__body-content .tox-form__group h2:only-child,.tox .tox-dialog__body-content .tox-form__group p:only-child{margin-bottom:0;margin-top:0}.tox .tox-dialog--width-lg{height:650px;max-width:1200px}.tox .tox-dialog--width-md{max-width:800px}.tox .tox-dialog--width-md .tox-dialog__body-content{overflow:auto}.tox .tox-dialog__body-content--centered{text-align:center}.tox .tox-dialog__footer{align-items:center;background-color:#fff;border-top:1px solid #ccc;display:flex;justify-content:space-between;padding:8px 16px}.tox .tox-dialog__footer-end,.tox .tox-dialog__footer-start{display:flex}.tox .tox-dialog__busy-spinner{align-items:center;background-color:rgba(255,255,255,.75);bottom:0;display:flex;justify-content:center;left:0;position:absolute;right:0;top:0;z-index:3}.tox .tox-dialog__table{border-collapse:collapse;width:100%}.tox .tox-dialog__table thead th{font-weight:700;padding-bottom:8px}.tox .tox-dialog__table tbody tr{border-bottom:1px solid #ccc}.tox .tox-dialog__table tbody tr:last-child{border-bottom:none}.tox .tox-dialog__table td{padding-bottom:8px;padding-top:8px}.tox .tox-dialog__popups{position:absolute;width:100%;z-index:1100}.tox .tox-dialog__body-iframe{display:flex;flex:1;flex-direction:column;-ms-flex-preferred-size:auto}.tox .tox-dialog__body-iframe .tox-navobj{display:flex;flex:1;-ms-flex-preferred-size:auto}.tox .tox-dialog__body-iframe .tox-navobj :nth-child(2){flex:1;-ms-flex-preferred-size:auto;height:100%}.tox .tox-dialog-dock-fadeout{opacity:0;visibility:hidden}.tox .tox-dialog-dock-fadein{opacity:1;visibility:visible}.tox .tox-dialog-dock-transition{transition:visibility 0s linear .3s,opacity .3s ease}.tox .tox-dialog-dock-transition.tox-dialog-dock-fadein{transition-delay:0s}.tox.tox-platform-ie .tox-dialog-wrap{position:-ms-device-fixed}@media only screen and (max-width:767px){body:not(.tox-force-desktop) .tox:not([dir=rtl]) .tox-dialog__body-nav{margin-right:0}}@media only screen and (max-width:767px){body:not(.tox-force-desktop) .tox:not([dir=rtl]) .tox-dialog__body-nav-item:not(:first-child){margin-left:8px}}.tox:not([dir=rtl]) .tox-dialog__footer .tox-dialog__footer-end>*,.tox:not([dir=rtl]) .tox-dialog__footer .tox-dialog__footer-start>*{margin-left:8px}.tox[dir=rtl] .tox-dialog__body{text-align:right}@media only screen and (max-width:767px){body:not(.tox-force-desktop) .tox[dir=rtl] .tox-dialog__body-nav{margin-left:0}}@media only screen and (max-width:767px){body:not(.tox-force-desktop) .tox[dir=rtl] .tox-dialog__body-nav-item:not(:first-child){margin-right:8px}}.tox[dir=rtl] .tox-dialog__footer .tox-dialog__footer-end>*,.tox[dir=rtl] .tox-dialog__footer .tox-dialog__footer-start>*{margin-right:8px}body.tox-dialog__disable-scroll{overflow:hidden}.tox .tox-dropzone-container{display:flex;flex:1;-ms-flex-preferred-size:auto}.tox .tox-dropzone{align-items:center;background:#fff;border:2px dashed #ccc;box-sizing:border-box;display:flex;flex-direction:column;flex-grow:1;justify-content:center;min-height:100px;padding:10px}.tox .tox-dropzone p{color:rgba(34,47,62,.7);margin:0 0 16px 0}.tox .tox-edit-area{display:flex;flex:1;-ms-flex-preferred-size:auto;overflow:hidden;position:relative}.tox .tox-edit-area__iframe{background-color:#fff;border:0;box-sizing:border-box;flex:1;-ms-flex-preferred-size:auto;height:100%;position:absolute;width:100%}.tox.tox-inline-edit-area{border:1px dotted #ccc}.tox .tox-editor-container{display:flex;flex:1 1 auto;flex-direction:column;overflow:hidden}.tox .tox-editor-header{z-index:1}.tox:not(.tox-tinymce-inline) .tox-editor-header{box-shadow:none;transition:box-shadow .5s}.tox.tox-tinymce--toolbar-bottom .tox-editor-header,.tox.tox-tinymce-inline .tox-editor-header{margin-bottom:-1px}.tox.tox-tinymce--toolbar-sticky-on .tox-editor-header{background-color:transparent;box-shadow:0 4px 4px -3px rgba(0,0,0,.25)}.tox-editor-dock-fadeout{opacity:0;visibility:hidden}.tox-editor-dock-fadein{opacity:1;visibility:visible}.tox-editor-dock-transition{transition:visibility 0s linear .25s,opacity .25s ease}.tox-editor-dock-transition.tox-editor-dock-fadein{transition-delay:0s}.tox .tox-control-wrap{flex:1;position:relative}.tox .tox-control-wrap:not(.tox-control-wrap--status-invalid) .tox-control-wrap__status-icon-invalid,.tox .tox-control-wrap:not(.tox-control-wrap--status-unknown) .tox-control-wrap__status-icon-unknown,.tox .tox-control-wrap:not(.tox-control-wrap--status-valid) .tox-control-wrap__status-icon-valid{display:none}.tox .tox-control-wrap svg{display:block}.tox .tox-control-wrap__status-icon-wrap{position:absolute;top:50%;transform:translateY(-50%)}.tox .tox-control-wrap__status-icon-invalid svg{fill:#c00}.tox .tox-control-wrap__status-icon-unknown svg{fill:orange}.tox .tox-control-wrap__status-icon-valid svg{fill:green}.tox:not([dir=rtl]) .tox-control-wrap--status-invalid .tox-textfield,.tox:not([dir=rtl]) .tox-control-wrap--status-unknown .tox-textfield,.tox:not([dir=rtl]) .tox-control-wrap--status-valid .tox-textfield{padding-right:32px}.tox:not([dir=rtl]) .tox-control-wrap__status-icon-wrap{right:4px}.tox[dir=rtl] .tox-control-wrap--status-invalid .tox-textfield,.tox[dir=rtl] .tox-control-wrap--status-unknown .tox-textfield,.tox[dir=rtl] .tox-control-wrap--status-valid .tox-textfield{padding-left:32px}.tox[dir=rtl] .tox-control-wrap__status-icon-wrap{left:4px}.tox .tox-autocompleter{max-width:25em}.tox .tox-autocompleter .tox-menu{max-width:25em}.tox .tox-autocompleter .tox-autocompleter-highlight{font-weight:700}.tox .tox-color-input{display:flex;position:relative;z-index:1}.tox .tox-color-input .tox-textfield{z-index:-1}.tox .tox-color-input span{border-color:rgba(34,47,62,.2);border-radius:3px;border-style:solid;border-width:1px;box-shadow:none;box-sizing:border-box;height:24px;position:absolute;top:6px;width:24px}.tox .tox-color-input span:focus:not([aria-disabled=true]),.tox .tox-color-input span:hover:not([aria-disabled=true]){border-color:#207ab7;cursor:pointer}.tox .tox-color-input span::before{background-image:linear-gradient(45deg,rgba(0,0,0,.25) 25%,transparent 25%),linear-gradient(-45deg,rgba(0,0,0,.25) 25%,transparent 25%),linear-gradient(45deg,transparent 75%,rgba(0,0,0,.25) 75%),linear-gradient(-45deg,transparent 75%,rgba(0,0,0,.25) 75%);background-position:0 0,0 6px,6px -6px,-6px 0;background-size:12px 12px;border:1px solid #fff;border-radius:3px;box-sizing:border-box;content:'';height:24px;left:-1px;position:absolute;top:-1px;width:24px;z-index:-1}.tox .tox-color-input span[aria-disabled=true]{cursor:not-allowed}.tox:not([dir=rtl]) .tox-color-input .tox-textfield{padding-left:36px}.tox:not([dir=rtl]) .tox-color-input span{left:6px}.tox[dir=rtl] .tox-color-input .tox-textfield{padding-right:36px}.tox[dir=rtl] .tox-color-input span{right:6px}.tox .tox-label,.tox .tox-toolbar-label{color:rgba(34,47,62,.7);display:block;font-size:14px;font-style:normal;font-weight:400;line-height:1.3;padding:0 8px 0 0;text-transform:none;white-space:nowrap}.tox .tox-toolbar-label{padding:0 8px}.tox[dir=rtl] .tox-label{padding:0 0 0 8px}.tox .tox-form{display:flex;flex:1;flex-direction:column;-ms-flex-preferred-size:auto}.tox .tox-form__group{box-sizing:border-box;margin-bottom:4px}.tox .tox-form-group--maximize{flex:1}.tox .tox-form__group--error{color:#c00}.tox .tox-form__group--collection{display:flex}.tox .tox-form__grid{display:flex;flex-direction:row;flex-wrap:wrap;justify-content:space-between}.tox .tox-form__grid--2col>.tox-form__group{width:calc(50% - (8px / 2))}.tox .tox-form__grid--3col>.tox-form__group{width:calc(100% / 3 - (8px / 2))}.tox .tox-form__grid--4col>.tox-form__group{width:calc(25% - (8px / 2))}.tox .tox-form__controls-h-stack{align-items:center;display:flex}.tox .tox-form__group--inline{align-items:center;display:flex}.tox .tox-form__group--stretched{display:flex;flex:1;flex-direction:column;-ms-flex-preferred-size:auto}.tox .tox-form__group--stretched .tox-textarea{flex:1;-ms-flex-preferred-size:auto}.tox .tox-form__group--stretched .tox-navobj{display:flex;flex:1;-ms-flex-preferred-size:auto}.tox .tox-form__group--stretched .tox-navobj :nth-child(2){flex:1;-ms-flex-preferred-size:auto;height:100%}.tox:not([dir=rtl]) .tox-form__controls-h-stack>:not(:first-child){margin-left:4px}.tox[dir=rtl] .tox-form__controls-h-stack>:not(:first-child){margin-right:4px}.tox .tox-lock.tox-locked .tox-lock-icon__unlock,.tox .tox-lock:not(.tox-locked) .tox-lock-icon__lock{display:none}.tox .tox-listboxfield .tox-listbox--select,.tox .tox-textarea,.tox .tox-textfield,.tox .tox-toolbar-textfield{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#ccc;border-radius:3px;border-style:solid;border-width:1px;box-shadow:none;box-sizing:border-box;color:#222f3e;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;font-size:16px;line-height:24px;margin:0;min-height:34px;outline:0;padding:5px 4.75px;resize:none;width:100%}.tox .tox-textarea[disabled],.tox .tox-textfield[disabled]{background-color:#f2f2f2;color:rgba(34,47,62,.85);cursor:not-allowed}.tox .tox-listboxfield .tox-listbox--select:focus,.tox .tox-textarea:focus,.tox .tox-textfield:focus{background-color:#fff;border-color:#207ab7;box-shadow:none;outline:0}.tox .tox-toolbar-textfield{border-width:0;margin-bottom:3px;margin-top:2px;max-width:250px}.tox .tox-naked-btn{background-color:transparent;border:0;border-color:transparent;box-shadow:unset;color:#207ab7;cursor:pointer;display:block;margin:0;padding:0}.tox .tox-naked-btn svg{display:block;fill:#222f3e}.tox:not([dir=rtl]) .tox-toolbar-textfield+*{margin-left:4px}.tox[dir=rtl] .tox-toolbar-textfield+*{margin-right:4px}.tox .tox-listboxfield{cursor:pointer;position:relative}.tox .tox-listboxfield .tox-listbox--select[disabled]{background-color:#f2f2f2;color:rgba(34,47,62,.85);cursor:not-allowed}.tox .tox-listbox__select-label{cursor:default;flex:1;margin:0 4px}.tox .tox-listbox__select-chevron{align-items:center;display:flex;justify-content:center;width:16px}.tox .tox-listbox__select-chevron svg{fill:#222f3e}.tox .tox-listboxfield .tox-listbox--select{align-items:center;display:flex}.tox:not([dir=rtl]) .tox-listboxfield svg{right:8px}.tox[dir=rtl] .tox-listboxfield svg{left:8px}.tox .tox-selectfield{cursor:pointer;position:relative}.tox .tox-selectfield select{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#ccc;border-radius:3px;border-style:solid;border-width:1px;box-shadow:none;box-sizing:border-box;color:#222f3e;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;font-size:16px;line-height:24px;margin:0;min-height:34px;outline:0;padding:5px 4.75px;resize:none;width:100%}.tox .tox-selectfield select[disabled]{background-color:#f2f2f2;color:rgba(34,47,62,.85);cursor:not-allowed}.tox .tox-selectfield select::-ms-expand{display:none}.tox .tox-selectfield select:focus{background-color:#fff;border-color:#207ab7;box-shadow:none;outline:0}.tox .tox-selectfield svg{pointer-events:none;position:absolute;top:50%;transform:translateY(-50%)}.tox:not([dir=rtl]) .tox-selectfield select[size="0"],.tox:not([dir=rtl]) .tox-selectfield select[size="1"]{padding-right:24px}.tox:not([dir=rtl]) .tox-selectfield svg{right:8px}.tox[dir=rtl] .tox-selectfield select[size="0"],.tox[dir=rtl] .tox-selectfield select[size="1"]{padding-left:24px}.tox[dir=rtl] .tox-selectfield svg{left:8px}.tox .tox-textarea{-webkit-appearance:textarea;-moz-appearance:textarea;appearance:textarea;white-space:pre-wrap}.tox-fullscreen{border:0;height:100%;margin:0;overflow:hidden;-ms-scroll-chaining:none;overscroll-behavior:none;padding:0;touch-action:pinch-zoom;width:100%}.tox.tox-tinymce.tox-fullscreen .tox-statusbar__resize-handle{display:none}.tox-shadowhost.tox-fullscreen,.tox.tox-tinymce.tox-fullscreen{left:0;position:fixed;top:0;z-index:1200}.tox.tox-tinymce.tox-fullscreen{background-color:transparent}.tox-fullscreen .tox.tox-tinymce-aux,.tox-fullscreen~.tox.tox-tinymce-aux{z-index:1201}.tox .tox-help__more-link{list-style:none;margin-top:1em}.tox .tox-image-tools{width:100%}.tox .tox-image-tools__toolbar{align-items:center;display:flex;justify-content:center}.tox .tox-image-tools__image{background-color:#666;height:380px;overflow:auto;position:relative;width:100%}.tox .tox-image-tools__image,.tox .tox-image-tools__image+.tox-image-tools__toolbar{margin-top:8px}.tox .tox-image-tools__image-bg{background:url(data:image/gif;base64,R0lGODdhDAAMAIABAMzMzP///ywAAAAADAAMAAACFoQfqYeabNyDMkBQb81Uat85nxguUAEAOw==)}.tox .tox-image-tools__toolbar>.tox-spacer{flex:1;-ms-flex-preferred-size:auto}.tox .tox-croprect-block{background:#000;opacity:.5;position:absolute;zoom:1}.tox .tox-croprect-handle{border:2px solid #fff;height:20px;left:0;position:absolute;top:0;width:20px}.tox .tox-croprect-handle-move{border:0;cursor:move;position:absolute}.tox .tox-croprect-handle-nw{border-width:2px 0 0 2px;cursor:nw-resize;left:100px;margin:-2px 0 0 -2px;top:100px}.tox .tox-croprect-handle-ne{border-width:2px 2px 0 0;cursor:ne-resize;left:200px;margin:-2px 0 0 -20px;top:100px}.tox .tox-croprect-handle-sw{border-width:0 0 2px 2px;cursor:sw-resize;left:100px;margin:-20px 2px 0 -2px;top:200px}.tox .tox-croprect-handle-se{border-width:0 2px 2px 0;cursor:se-resize;left:200px;margin:-20px 0 0 -20px;top:200px}.tox:not([dir=rtl]) .tox-image-tools__toolbar>.tox-slider:not(:first-of-type){margin-left:8px}.tox:not([dir=rtl]) .tox-image-tools__toolbar>.tox-button+.tox-slider{margin-left:32px}.tox:not([dir=rtl]) .tox-image-tools__toolbar>.tox-slider+.tox-button{margin-left:32px}.tox[dir=rtl] .tox-image-tools__toolbar>.tox-slider:not(:first-of-type){margin-right:8px}.tox[dir=rtl] .tox-image-tools__toolbar>.tox-button+.tox-slider{margin-right:32px}.tox[dir=rtl] .tox-image-tools__toolbar>.tox-slider+.tox-button{margin-right:32px}.tox .tox-insert-table-picker{display:flex;flex-wrap:wrap;width:170px}.tox .tox-insert-table-picker>div{border-color:#ccc;border-style:solid;border-width:0 1px 1px 0;box-sizing:border-box;height:17px;width:17px}.tox .tox-collection--list .tox-collection__group .tox-insert-table-picker{margin:-4px 0}.tox .tox-insert-table-picker .tox-insert-table-picker__selected{background-color:rgba(32,122,183,.5);border-color:rgba(32,122,183,.5)}.tox .tox-insert-table-picker__label{color:rgba(34,47,62,.7);display:block;font-size:14px;padding:4px;text-align:center;width:100%}.tox:not([dir=rtl]) .tox-insert-table-picker>div:nth-child(10n){border-right:0}.tox[dir=rtl] .tox-insert-table-picker>div:nth-child(10n+1){border-right:0}.tox .tox-menu{background-color:#fff;border:1px solid #ccc;border-radius:3px;box-shadow:0 4px 8px 0 rgba(34,47,62,.1);display:inline-block;overflow:hidden;vertical-align:top;z-index:1150}.tox .tox-menu.tox-collection.tox-collection--list{padding:0}.tox .tox-menu.tox-collection.tox-collection--toolbar{padding:4px}.tox .tox-menu.tox-collection.tox-collection--grid{padding:4px}.tox .tox-menu__label blockquote,.tox .tox-menu__label code,.tox .tox-menu__label h1,.tox .tox-menu__label h2,.tox .tox-menu__label h3,.tox .tox-menu__label h4,.tox .tox-menu__label h5,.tox .tox-menu__label h6,.tox .tox-menu__label p{margin:0}.tox .tox-menubar{background:url("data:image/svg+xml;charset=utf8,%3Csvg height='39px' viewBox='0 0 40 39px' width='40' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='0' y='38px' width='100' height='1' fill='%23cccccc'/%3E%3C/svg%3E") left 0 top 0 #fff;background-color:#fff;display:flex;flex:0 0 auto;flex-shrink:0;flex-wrap:wrap;padding:0 4px 0 4px}.tox.tox-tinymce:not(.tox-tinymce-inline) .tox-editor-header:not(:first-child) .tox-menubar{border-top:1px solid #ccc}.tox .tox-mbtn{align-items:center;background:0 0;border:0;border-radius:3px;box-shadow:none;color:#222f3e;display:flex;flex:0 0 auto;font-size:14px;font-style:normal;font-weight:400;height:34px;justify-content:center;margin:2px 0 3px 0;outline:0;overflow:hidden;padding:0 4px;text-transform:none;width:auto}.tox .tox-mbtn[disabled]{background-color:transparent;border:0;box-shadow:none;color:rgba(34,47,62,.5);cursor:not-allowed}.tox .tox-mbtn:focus:not(:disabled){background:#dee0e2;border:0;box-shadow:none;color:#222f3e}.tox .tox-mbtn--active{background:#c8cbcf;border:0;box-shadow:none;color:#222f3e}.tox .tox-mbtn:hover:not(:disabled):not(.tox-mbtn--active){background:#dee0e2;border:0;box-shadow:none;color:#222f3e}.tox .tox-mbtn__select-label{cursor:default;font-weight:400;margin:0 4px}.tox .tox-mbtn[disabled] .tox-mbtn__select-label{cursor:not-allowed}.tox .tox-mbtn__select-chevron{align-items:center;display:flex;justify-content:center;width:16px;display:none}.tox .tox-notification{border-radius:3px;border-style:solid;border-width:1px;box-shadow:none;box-sizing:border-box;display:-ms-grid;display:grid;font-size:14px;font-weight:400;-ms-grid-columns:minmax(40px,1fr) auto minmax(40px,1fr);grid-template-columns:minmax(40px,1fr) auto minmax(40px,1fr);margin-top:4px;opacity:0;padding:4px;transition:transform .1s ease-in,opacity 150ms ease-in}.tox .tox-notification p{font-size:14px;font-weight:400}.tox .tox-notification a{cursor:pointer;text-decoration:underline}.tox .tox-notification--in{opacity:1}.tox .tox-notification--success{background-color:#e4eeda;border-color:#d7e6c8;color:#222f3e}.tox .tox-notification--success p{color:#222f3e}.tox .tox-notification--success a{color:#547831}.tox .tox-notification--success svg{fill:#222f3e}.tox .tox-notification--error{background-color:#f8dede;border-color:#f2bfbf;color:#222f3e}.tox .tox-notification--error p{color:#222f3e}.tox .tox-notification--error a{color:#c00}.tox .tox-notification--error svg{fill:#222f3e}.tox .tox-notification--warn,.tox .tox-notification--warning{background-color:#fffaea;border-color:#ffe89d;color:#222f3e}.tox .tox-notification--warn p,.tox .tox-notification--warning p{color:#222f3e}.tox .tox-notification--warn a,.tox .tox-notification--warning a{color:#222f3e}.tox .tox-notification--warn svg,.tox .tox-notification--warning svg{fill:#222f3e}.tox .tox-notification--info{background-color:#d9edf7;border-color:#779ecb;color:#222f3e}.tox .tox-notification--info p{color:#222f3e}.tox .tox-notification--info a{color:#222f3e}.tox .tox-notification--info svg{fill:#222f3e}.tox .tox-notification__body{-ms-grid-row-align:center;align-self:center;color:#222f3e;font-size:14px;-ms-grid-column-span:1;grid-column-end:3;-ms-grid-column:2;grid-column-start:2;-ms-grid-row-span:1;grid-row-end:2;-ms-grid-row:1;grid-row-start:1;text-align:center;white-space:normal;word-break:break-all;word-break:break-word}.tox .tox-notification__body>*{margin:0}.tox .tox-notification__body>*+*{margin-top:1rem}.tox .tox-notification__icon{-ms-grid-row-align:center;align-self:center;-ms-grid-column-span:1;grid-column-end:2;-ms-grid-column:1;grid-column-start:1;-ms-grid-row-span:1;grid-row-end:2;-ms-grid-row:1;grid-row-start:1;-ms-grid-column-align:end;justify-self:end}.tox .tox-notification__icon svg{display:block}.tox .tox-notification__dismiss{-ms-grid-row-align:start;align-self:start;-ms-grid-column-span:1;grid-column-end:4;-ms-grid-column:3;grid-column-start:3;-ms-grid-row-span:1;grid-row-end:2;-ms-grid-row:1;grid-row-start:1;-ms-grid-column-align:end;justify-self:end}.tox .tox-notification .tox-progress-bar{-ms-grid-column-span:3;grid-column-end:4;-ms-grid-column:1;grid-column-start:1;-ms-grid-row-span:1;grid-row-end:3;-ms-grid-row:2;grid-row-start:2;-ms-grid-column-align:center;justify-self:center}.tox .tox-pop{display:inline-block;position:relative}.tox .tox-pop--resizing{transition:width .1s ease}.tox .tox-pop--resizing .tox-toolbar,.tox .tox-pop--resizing .tox-toolbar__group{flex-wrap:nowrap}.tox .tox-pop--transition{transition:.15s ease;transition-property:left,right,top,bottom}.tox .tox-pop--transition::after,.tox .tox-pop--transition::before{transition:all .15s,visibility 0s,opacity 75ms ease 75ms}.tox .tox-pop__dialog{background-color:#fff;border:1px solid #ccc;border-radius:3px;box-shadow:0 1px 3px rgba(0,0,0,.15);min-width:0;overflow:hidden}.tox .tox-pop__dialog>:not(.tox-toolbar){margin:4px 4px 4px 8px}.tox .tox-pop__dialog .tox-toolbar{background-color:transparent;margin-bottom:-1px}.tox .tox-pop::after,.tox .tox-pop::before{border-style:solid;content:'';display:block;height:0;opacity:1;position:absolute;width:0}.tox .tox-pop.tox-pop--inset::after,.tox .tox-pop.tox-pop--inset::before{opacity:0;transition:all 0s .15s,visibility 0s,opacity 75ms ease}.tox .tox-pop.tox-pop--bottom::after,.tox .tox-pop.tox-pop--bottom::before{left:50%;top:100%}.tox .tox-pop.tox-pop--bottom::after{border-color:#fff transparent transparent transparent;border-width:8px;margin-left:-8px;margin-top:-1px}.tox .tox-pop.tox-pop--bottom::before{border-color:#ccc transparent transparent transparent;border-width:9px;margin-left:-9px}.tox .tox-pop.tox-pop--top::after,.tox .tox-pop.tox-pop--top::before{left:50%;top:0;transform:translateY(-100%)}.tox .tox-pop.tox-pop--top::after{border-color:transparent transparent #fff transparent;border-width:8px;margin-left:-8px;margin-top:1px}.tox .tox-pop.tox-pop--top::before{border-color:transparent transparent #ccc transparent;border-width:9px;margin-left:-9px}.tox .tox-pop.tox-pop--left::after,.tox .tox-pop.tox-pop--left::before{left:0;top:calc(50% - 1px);transform:translateY(-50%)}.tox .tox-pop.tox-pop--left::after{border-color:transparent #fff transparent transparent;border-width:8px;margin-left:-15px}.tox .tox-pop.tox-pop--left::before{border-color:transparent #ccc transparent transparent;border-width:10px;margin-left:-19px}.tox .tox-pop.tox-pop--right::after,.tox .tox-pop.tox-pop--right::before{left:100%;top:calc(50% + 1px);transform:translateY(-50%)}.tox .tox-pop.tox-pop--right::after{border-color:transparent transparent transparent #fff;border-width:8px;margin-left:-1px}.tox .tox-pop.tox-pop--right::before{border-color:transparent transparent transparent #ccc;border-width:10px;margin-left:-1px}.tox .tox-pop.tox-pop--align-left::after,.tox .tox-pop.tox-pop--align-left::before{left:20px}.tox .tox-pop.tox-pop--align-right::after,.tox .tox-pop.tox-pop--align-right::before{left:calc(100% - 20px)}.tox .tox-sidebar-wrap{display:flex;flex-direction:row;flex-grow:1;-ms-flex-preferred-size:0;min-height:0}.tox .tox-sidebar{background-color:#fff;display:flex;flex-direction:row;justify-content:flex-end}.tox .tox-sidebar__slider{display:flex;overflow:hidden}.tox .tox-sidebar__pane-container{display:flex}.tox .tox-sidebar__pane{display:flex}.tox .tox-sidebar--sliding-closed{opacity:0}.tox .tox-sidebar--sliding-open{opacity:1}.tox .tox-sidebar--sliding-growing,.tox .tox-sidebar--sliding-shrinking{transition:width .5s ease,opacity .5s ease}.tox .tox-selector{background-color:#4099ff;border-color:#4099ff;border-style:solid;border-width:1px;box-sizing:border-box;display:inline-block;height:10px;position:absolute;width:10px}.tox.tox-platform-touch .tox-selector{height:12px;width:12px}.tox .tox-slider{align-items:center;display:flex;flex:1;-ms-flex-preferred-size:auto;height:24px;justify-content:center;position:relative}.tox .tox-slider__rail{background-color:transparent;border:1px solid #ccc;border-radius:3px;height:10px;min-width:120px;width:100%}.tox .tox-slider__handle{background-color:#207ab7;border:2px solid #185d8c;border-radius:3px;box-shadow:none;height:24px;left:50%;position:absolute;top:50%;transform:translateX(-50%) translateY(-50%);width:14px}.tox .tox-source-code{overflow:auto}.tox .tox-spinner{display:flex}.tox .tox-spinner>div{animation:tam-bouncing-dots 1.5s ease-in-out 0s infinite both;background-color:rgba(34,47,62,.7);border-radius:100%;height:8px;width:8px}.tox .tox-spinner>div:nth-child(1){animation-delay:-.32s}.tox .tox-spinner>div:nth-child(2){animation-delay:-.16s}@keyframes tam-bouncing-dots{0%,100%,80%{transform:scale(0)}40%{transform:scale(1)}}.tox:not([dir=rtl]) .tox-spinner>div:not(:first-child){margin-left:4px}.tox[dir=rtl] .tox-spinner>div:not(:first-child){margin-right:4px}.tox .tox-statusbar{align-items:center;background-color:#fff;border-top:1px solid #ccc;color:rgba(34,47,62,.7);display:flex;flex:0 0 auto;font-size:12px;font-weight:400;height:18px;overflow:hidden;padding:0 8px;position:relative;text-transform:uppercase}.tox .tox-statusbar__text-container{display:flex;flex:1 1 auto;justify-content:flex-end;overflow:hidden}.tox .tox-statusbar__path{display:flex;flex:1 1 auto;margin-right:auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tox .tox-statusbar__path>*{display:inline;white-space:nowrap}.tox .tox-statusbar__wordcount{flex:0 0 auto;margin-left:1ch}.tox .tox-statusbar a,.tox .tox-statusbar__path-item,.tox .tox-statusbar__wordcount{color:rgba(34,47,62,.7);text-decoration:none}.tox .tox-statusbar a:focus:not(:disabled):not([aria-disabled=true]),.tox .tox-statusbar a:hover:not(:disabled):not([aria-disabled=true]),.tox .tox-statusbar__path-item:focus:not(:disabled):not([aria-disabled=true]),.tox .tox-statusbar__path-item:hover:not(:disabled):not([aria-disabled=true]),.tox .tox-statusbar__wordcount:focus:not(:disabled):not([aria-disabled=true]),.tox .tox-statusbar__wordcount:hover:not(:disabled):not([aria-disabled=true]){cursor:pointer;text-decoration:underline}.tox .tox-statusbar__resize-handle{align-items:flex-end;align-self:stretch;cursor:nwse-resize;display:flex;flex:0 0 auto;justify-content:flex-end;margin-left:auto;margin-right:-8px;padding-left:1ch}.tox .tox-statusbar__resize-handle svg{display:block;fill:rgba(34,47,62,.7)}.tox .tox-statusbar__resize-handle:focus svg{background-color:#dee0e2;border-radius:1px;box-shadow:0 0 0 2px #dee0e2}.tox:not([dir=rtl]) .tox-statusbar__path>*{margin-right:4px}.tox:not([dir=rtl]) .tox-statusbar__branding{margin-left:1ch}.tox[dir=rtl] .tox-statusbar{flex-direction:row-reverse}.tox[dir=rtl] .tox-statusbar__path>*{margin-left:4px}.tox .tox-throbber{z-index:1299}.tox .tox-throbber__busy-spinner{align-items:center;background-color:rgba(255,255,255,.6);bottom:0;display:flex;justify-content:center;left:0;position:absolute;right:0;top:0}.tox .tox-tbtn{align-items:center;background:0 0;border:0;border-radius:3px;box-shadow:none;color:#222f3e;display:flex;flex:0 0 auto;font-size:14px;font-style:normal;font-weight:400;height:34px;justify-content:center;margin:2px 0 3px 0;outline:0;overflow:hidden;padding:0;text-transform:none;width:34px}.tox .tox-tbtn svg{display:block;fill:#222f3e}.tox .tox-tbtn.tox-tbtn-more{padding-left:5px;padding-right:5px;width:inherit}.tox .tox-tbtn:focus{background:#dee0e2;border:0;box-shadow:none}.tox .tox-tbtn:hover{background:#dee0e2;border:0;box-shadow:none;color:#222f3e}.tox .tox-tbtn:hover svg{fill:#222f3e}.tox .tox-tbtn:active{background:#c8cbcf;border:0;box-shadow:none;color:#222f3e}.tox .tox-tbtn:active svg{fill:#222f3e}.tox .tox-tbtn--disabled,.tox .tox-tbtn--disabled:hover,.tox .tox-tbtn:disabled,.tox .tox-tbtn:disabled:hover{background:0 0;border:0;box-shadow:none;color:rgba(34,47,62,.5);cursor:not-allowed}.tox .tox-tbtn--disabled svg,.tox .tox-tbtn--disabled:hover svg,.tox .tox-tbtn:disabled svg,.tox .tox-tbtn:disabled:hover svg{fill:rgba(34,47,62,.5)}.tox .tox-tbtn--enabled,.tox .tox-tbtn--enabled:hover{background:#c8cbcf;border:0;box-shadow:none;color:#222f3e}.tox .tox-tbtn--enabled:hover>*,.tox .tox-tbtn--enabled>*{transform:none}.tox .tox-tbtn--enabled svg,.tox .tox-tbtn--enabled:hover svg{fill:#222f3e}.tox .tox-tbtn:focus:not(.tox-tbtn--disabled){color:#222f3e}.tox .tox-tbtn:focus:not(.tox-tbtn--disabled) svg{fill:#222f3e}.tox .tox-tbtn:active>*{transform:none}.tox .tox-tbtn--md{height:51px;width:51px}.tox .tox-tbtn--lg{flex-direction:column;height:68px;width:68px}.tox .tox-tbtn--return{-ms-grid-row-align:stretch;align-self:stretch;height:unset;width:16px}.tox .tox-tbtn--labeled{padding:0 4px;width:unset}.tox .tox-tbtn__vlabel{display:block;font-size:10px;font-weight:400;letter-spacing:-.025em;margin-bottom:4px;white-space:nowrap}.tox .tox-tbtn--select{margin:2px 0 3px 0;padding:0 4px;width:auto}.tox .tox-tbtn__select-label{cursor:default;font-weight:400;margin:0 4px}.tox .tox-tbtn__select-chevron{align-items:center;display:flex;justify-content:center;width:16px}.tox .tox-tbtn__select-chevron svg{fill:rgba(34,47,62,.5)}.tox .tox-tbtn--bespoke .tox-tbtn__select-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;width:7em}.tox .tox-split-button{border:0;border-radius:3px;box-sizing:border-box;display:flex;margin:2px 0 3px 0;overflow:hidden}.tox .tox-split-button:hover{box-shadow:0 0 0 1px #dee0e2 inset}.tox .tox-split-button:focus{background:#dee0e2;box-shadow:none;color:#222f3e}.tox .tox-split-button>*{border-radius:0}.tox .tox-split-button__chevron{width:16px}.tox .tox-split-button__chevron svg{fill:rgba(34,47,62,.5)}.tox .tox-split-button .tox-tbtn{margin:0}.tox.tox-platform-touch .tox-split-button .tox-tbtn:first-child{width:30px}.tox.tox-platform-touch .tox-split-button__chevron{width:20px}.tox .tox-split-button.tox-tbtn--disabled .tox-tbtn:focus,.tox .tox-split-button.tox-tbtn--disabled .tox-tbtn:hover,.tox .tox-split-button.tox-tbtn--disabled:focus,.tox .tox-split-button.tox-tbtn--disabled:hover{background:0 0;box-shadow:none;color:rgba(34,47,62,.5)}.tox .tox-toolbar-overlord{background-color:#fff}.tox .tox-toolbar,.tox .tox-toolbar__overflow,.tox .tox-toolbar__primary{background:url("data:image/svg+xml;charset=utf8,%3Csvg height='39px' viewBox='0 0 40 39px' width='40' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='0' y='38px' width='100' height='1' fill='%23cccccc'/%3E%3C/svg%3E") left 0 top 0 #fff;background-color:#fff;display:flex;flex:0 0 auto;flex-shrink:0;flex-wrap:wrap;padding:0 0}.tox .tox-toolbar__overflow.tox-toolbar__overflow--closed{height:0;opacity:0;padding-bottom:0;padding-top:0;visibility:hidden}.tox .tox-toolbar__overflow--growing{transition:height .3s ease,opacity .2s linear .1s}.tox .tox-toolbar__overflow--shrinking{transition:opacity .3s ease,height .2s linear .1s,visibility 0s linear .3s}.tox .tox-menubar+.tox-toolbar,.tox .tox-menubar+.tox-toolbar-overlord .tox-toolbar__primary{border-top:1px solid #ccc;margin-top:-1px}.tox .tox-toolbar--scrolling{flex-wrap:nowrap;overflow-x:auto}.tox .tox-pop .tox-toolbar{border-width:0}.tox .tox-toolbar--no-divider{background-image:none}.tox-tinymce:not(.tox-tinymce-inline) .tox-editor-header:not(:first-child) .tox-toolbar-overlord:first-child .tox-toolbar__primary,.tox-tinymce:not(.tox-tinymce-inline) .tox-editor-header:not(:first-child) .tox-toolbar:first-child{border-top:1px solid #ccc}.tox.tox-tinymce-aux .tox-toolbar__overflow{background-color:#fff;border:1px solid #ccc;border-radius:3px;box-shadow:0 1px 3px rgba(0,0,0,.15)}.tox .tox-toolbar__group{align-items:center;display:flex;flex-wrap:wrap;margin:0 0;padding:0 4px 0 4px}.tox .tox-toolbar__group--pull-right{margin-left:auto}.tox .tox-toolbar--scrolling .tox-toolbar__group{flex-shrink:0;flex-wrap:nowrap}.tox:not([dir=rtl]) .tox-toolbar__group:not(:last-of-type){border-right:1px solid #ccc}.tox[dir=rtl] .tox-toolbar__group:not(:last-of-type){border-left:1px solid #ccc}.tox .tox-tooltip{display:inline-block;padding:8px;position:relative}.tox .tox-tooltip__body{background-color:#222f3e;border-radius:3px;box-shadow:0 2px 4px rgba(34,47,62,.3);color:rgba(255,255,255,.75);font-size:14px;font-style:normal;font-weight:400;padding:4px 8px;text-transform:none}.tox .tox-tooltip__arrow{position:absolute}.tox .tox-tooltip--down .tox-tooltip__arrow{border-left:8px solid transparent;border-right:8px solid transparent;border-top:8px solid #222f3e;bottom:0;left:50%;position:absolute;transform:translateX(-50%)}.tox .tox-tooltip--up .tox-tooltip__arrow{border-bottom:8px solid #222f3e;border-left:8px solid transparent;border-right:8px solid transparent;left:50%;position:absolute;top:0;transform:translateX(-50%)}.tox .tox-tooltip--right .tox-tooltip__arrow{border-bottom:8px solid transparent;border-left:8px solid #222f3e;border-top:8px solid transparent;position:absolute;right:0;top:50%;transform:translateY(-50%)}.tox .tox-tooltip--left .tox-tooltip__arrow{border-bottom:8px solid transparent;border-right:8px solid #222f3e;border-top:8px solid transparent;left:0;position:absolute;top:50%;transform:translateY(-50%)}.tox .tox-well{border:1px solid #ccc;border-radius:3px;padding:8px;width:100%}.tox .tox-well>:first-child{margin-top:0}.tox .tox-well>:last-child{margin-bottom:0}.tox .tox-well>:only-child{margin:0}.tox .tox-custom-editor{border:1px solid #ccc;border-radius:3px;display:flex;flex:1;position:relative}.tox .tox-dialog-loading::before{background-color:rgba(0,0,0,.5);content:"";height:100%;position:absolute;width:100%;z-index:1000}.tox .tox-tab{cursor:pointer}.tox .tox-dialog__content-js{display:flex;flex:1;-ms-flex-preferred-size:auto}.tox .tox-dialog__body-content .tox-collection{display:flex;flex:1;-ms-flex-preferred-size:auto}.tox .tox-image-tools-edit-panel{height:60px}.tox .tox-image-tools__sidebar{height:60px}
diff --git a/public/tinymce/skins/ui/oxide/skin.mobile.css b/public/tinymce/skins/ui/oxide/skin.mobile.css
new file mode 100644
index 0000000..875721a
--- /dev/null
+++ b/public/tinymce/skins/ui/oxide/skin.mobile.css
@@ -0,0 +1,673 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+/* RESET all the things! */
+.tinymce-mobile-outer-container {
+  all: initial;
+  display: block;
+}
+.tinymce-mobile-outer-container * {
+  border: 0;
+  box-sizing: initial;
+  cursor: inherit;
+  float: none;
+  line-height: 1;
+  margin: 0;
+  outline: 0;
+  padding: 0;
+  -webkit-tap-highlight-color: transparent;
+  /* TBIO-3691, stop the gray flicker on touch. */
+  text-shadow: none;
+  white-space: nowrap;
+}
+.tinymce-mobile-icon-arrow-back::before {
+  content: "\e5cd";
+}
+.tinymce-mobile-icon-image::before {
+  content: "\e412";
+}
+.tinymce-mobile-icon-cancel-circle::before {
+  content: "\e5c9";
+}
+.tinymce-mobile-icon-full-dot::before {
+  content: "\e061";
+}
+.tinymce-mobile-icon-align-center::before {
+  content: "\e234";
+}
+.tinymce-mobile-icon-align-left::before {
+  content: "\e236";
+}
+.tinymce-mobile-icon-align-right::before {
+  content: "\e237";
+}
+.tinymce-mobile-icon-bold::before {
+  content: "\e238";
+}
+.tinymce-mobile-icon-italic::before {
+  content: "\e23f";
+}
+.tinymce-mobile-icon-unordered-list::before {
+  content: "\e241";
+}
+.tinymce-mobile-icon-ordered-list::before {
+  content: "\e242";
+}
+.tinymce-mobile-icon-font-size::before {
+  content: "\e245";
+}
+.tinymce-mobile-icon-underline::before {
+  content: "\e249";
+}
+.tinymce-mobile-icon-link::before {
+  content: "\e157";
+}
+.tinymce-mobile-icon-unlink::before {
+  content: "\eca2";
+}
+.tinymce-mobile-icon-color::before {
+  content: "\e891";
+}
+.tinymce-mobile-icon-previous::before {
+  content: "\e314";
+}
+.tinymce-mobile-icon-next::before {
+  content: "\e315";
+}
+.tinymce-mobile-icon-large-font::before,
+.tinymce-mobile-icon-style-formats::before {
+  content: "\e264";
+}
+.tinymce-mobile-icon-undo::before {
+  content: "\e166";
+}
+.tinymce-mobile-icon-redo::before {
+  content: "\e15a";
+}
+.tinymce-mobile-icon-removeformat::before {
+  content: "\e239";
+}
+.tinymce-mobile-icon-small-font::before {
+  content: "\e906";
+}
+.tinymce-mobile-icon-readonly-back::before,
+.tinymce-mobile-format-matches::after {
+  content: "\e5ca";
+}
+.tinymce-mobile-icon-small-heading::before {
+  content: "small";
+}
+.tinymce-mobile-icon-large-heading::before {
+  content: "large";
+}
+.tinymce-mobile-icon-small-heading::before,
+.tinymce-mobile-icon-large-heading::before {
+  font-family: sans-serif;
+  font-size: 80%;
+}
+.tinymce-mobile-mask-edit-icon::before {
+  content: "\e254";
+}
+.tinymce-mobile-icon-back::before {
+  content: "\e5c4";
+}
+.tinymce-mobile-icon-heading::before {
+  /* TODO: Translate */
+  content: "Headings";
+  font-family: sans-serif;
+  font-size: 80%;
+  font-weight: bold;
+}
+.tinymce-mobile-icon-h1::before {
+  content: "H1";
+  font-weight: bold;
+}
+.tinymce-mobile-icon-h2::before {
+  content: "H2";
+  font-weight: bold;
+}
+.tinymce-mobile-icon-h3::before {
+  content: "H3";
+  font-weight: bold;
+}
+.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask {
+  align-items: center;
+  display: flex;
+  justify-content: center;
+  background: rgba(51, 51, 51, 0.5);
+  height: 100%;
+  position: absolute;
+  top: 0;
+  width: 100%;
+}
+.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container {
+  align-items: center;
+  border-radius: 50%;
+  display: flex;
+  flex-direction: column;
+  font-family: sans-serif;
+  font-size: 1em;
+  justify-content: space-between;
+}
+.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .mixin-menu-item {
+  align-items: center;
+  display: flex;
+  justify-content: center;
+  border-radius: 50%;
+  height: 2.1em;
+  width: 2.1em;
+}
+.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section {
+  align-items: center;
+  display: flex;
+  justify-content: center;
+  flex-direction: column;
+  font-size: 1em;
+}
+@media only screen and (min-device-width:700px) {
+  .tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section {
+    font-size: 1.2em;
+  }
+}
+.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section .tinymce-mobile-mask-tap-icon {
+  align-items: center;
+  display: flex;
+  justify-content: center;
+  border-radius: 50%;
+  height: 2.1em;
+  width: 2.1em;
+  background-color: white;
+  color: #207ab7;
+}
+.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section .tinymce-mobile-mask-tap-icon::before {
+  content: "\e900";
+  font-family: 'tinymce-mobile', sans-serif;
+}
+.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section:not(.tinymce-mobile-mask-tap-icon-selected) .tinymce-mobile-mask-tap-icon {
+  z-index: 2;
+}
+.tinymce-mobile-android-container.tinymce-mobile-android-maximized {
+  background: #ffffff;
+  border: none;
+  bottom: 0;
+  display: flex;
+  flex-direction: column;
+  left: 0;
+  position: fixed;
+  right: 0;
+  top: 0;
+}
+.tinymce-mobile-android-container:not(.tinymce-mobile-android-maximized) {
+  position: relative;
+}
+.tinymce-mobile-android-container .tinymce-mobile-editor-socket {
+  display: flex;
+  flex-grow: 1;
+}
+.tinymce-mobile-android-container .tinymce-mobile-editor-socket iframe {
+  display: flex !important;
+  flex-grow: 1;
+  height: auto !important;
+}
+.tinymce-mobile-android-scroll-reload {
+  overflow: hidden;
+}
+:not(.tinymce-mobile-readonly-mode) > .tinymce-mobile-android-selection-context-toolbar {
+  margin-top: 23px;
+}
+.tinymce-mobile-toolstrip {
+  background: #fff;
+  display: flex;
+  flex: 0 0 auto;
+  z-index: 1;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar {
+  align-items: center;
+  background-color: #fff;
+  border-bottom: 1px solid #cccccc;
+  display: flex;
+  flex: 1;
+  height: 2.5em;
+  width: 100%;
+  /* Make it no larger than the toolstrip, so that it needs to scroll */
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group {
+  align-items: center;
+  display: flex;
+  height: 100%;
+  flex-shrink: 1;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group > div {
+  align-items: center;
+  display: flex;
+  height: 100%;
+  flex: 1;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group.tinymce-mobile-exit-container {
+  background: #f44336;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group.tinymce-mobile-toolbar-scrollable-group {
+  flex-grow: 1;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item {
+  padding-left: 0.5em;
+  padding-right: 0.5em;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item.tinymce-mobile-toolbar-button {
+  align-items: center;
+  display: flex;
+  height: 80%;
+  margin-left: 2px;
+  margin-right: 2px;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item.tinymce-mobile-toolbar-button.tinymce-mobile-toolbar-button-selected {
+  background: #c8cbcf;
+  color: #cccccc;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group:first-of-type,
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group:last-of-type {
+  background: #207ab7;
+  color: #eceff1;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar {
+  /* Note, this file is imported inside .tinymce-mobile-context-toolbar, so that prefix is on everything here. */
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group {
+  align-items: center;
+  display: flex;
+  height: 100%;
+  flex: 1;
+  padding-bottom: 0.4em;
+  padding-top: 0.4em;
+  /* Make any buttons appearing on the left and right display in the centre (e.g. color edges) */
+  /* For widgets like the colour picker, use the whole height */
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog {
+  display: flex;
+  min-height: 1.5em;
+  overflow: hidden;
+  padding-left: 0;
+  padding-right: 0;
+  position: relative;
+  width: 100%;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain {
+  display: flex;
+  height: 100%;
+  transition: left cubic-bezier(0.4, 0, 1, 1) 0.15s;
+  width: 100%;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen {
+  display: flex;
+  flex: 0 0 auto;
+  justify-content: space-between;
+  width: 100%;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen input {
+  font-family: Sans-serif;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-input-container {
+  display: flex;
+  flex-grow: 1;
+  position: relative;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-input-container .tinymce-mobile-input-container-x {
+  -ms-grid-row-align: center;
+      align-self: center;
+  background: inherit;
+  border: none;
+  border-radius: 50%;
+  color: #888;
+  font-size: 0.6em;
+  font-weight: bold;
+  height: 100%;
+  padding-right: 2px;
+  position: absolute;
+  right: 0;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-input-container.tinymce-mobile-input-container-empty .tinymce-mobile-input-container-x {
+  display: none;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-previous,
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-next {
+  align-items: center;
+  display: flex;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-previous::before,
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-next::before {
+  align-items: center;
+  display: flex;
+  font-weight: bold;
+  height: 100%;
+  padding-left: 0.5em;
+  padding-right: 0.5em;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-previous.tinymce-mobile-toolbar-navigation-disabled::before,
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-next.tinymce-mobile-toolbar-navigation-disabled::before {
+  visibility: hidden;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-dot-item {
+  color: #cccccc;
+  font-size: 10px;
+  line-height: 10px;
+  margin: 0 2px;
+  padding-top: 3px;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-dot-item.tinymce-mobile-dot-active {
+  color: #c8cbcf;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-icon-large-font::before,
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-icon-large-heading::before {
+  margin-left: 0.5em;
+  margin-right: 0.9em;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-icon-small-font::before,
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-icon-small-heading::before {
+  margin-left: 0.9em;
+  margin-right: 0.5em;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider {
+  display: flex;
+  flex: 1;
+  margin-left: 0;
+  margin-right: 0;
+  padding: 0.28em 0;
+  position: relative;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider .tinymce-mobile-slider-size-container {
+  align-items: center;
+  display: flex;
+  flex-grow: 1;
+  height: 100%;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider .tinymce-mobile-slider-size-container .tinymce-mobile-slider-size-line {
+  background: #cccccc;
+  display: flex;
+  flex: 1;
+  height: 0.2em;
+  margin-bottom: 0.3em;
+  margin-top: 0.3em;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container {
+  padding-left: 2em;
+  padding-right: 2em;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container .tinymce-mobile-slider-gradient-container {
+  align-items: center;
+  display: flex;
+  flex-grow: 1;
+  height: 100%;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container .tinymce-mobile-slider-gradient-container .tinymce-mobile-slider-gradient {
+  background: linear-gradient(to right, hsl(0, 100%, 50%) 0%, hsl(60, 100%, 50%) 17%, hsl(120, 100%, 50%) 33%, hsl(180, 100%, 50%) 50%, hsl(240, 100%, 50%) 67%, hsl(300, 100%, 50%) 83%, hsl(0, 100%, 50%) 100%);
+  display: flex;
+  flex: 1;
+  height: 0.2em;
+  margin-bottom: 0.3em;
+  margin-top: 0.3em;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container .tinymce-mobile-hue-slider-black {
+  /* Not part of theming */
+  background: black;
+  height: 0.2em;
+  margin-bottom: 0.3em;
+  margin-top: 0.3em;
+  width: 1.2em;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container .tinymce-mobile-hue-slider-white {
+  /* Not part of theming */
+  background: white;
+  height: 0.2em;
+  margin-bottom: 0.3em;
+  margin-top: 0.3em;
+  width: 1.2em;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider .tinymce-mobile-slider-thumb {
+  /* vertically centering trick (margin: auto, top: 0, bottom: 0). On iOS and Safari, if you leave
+     * out these values, then it shows the thumb at the top of the spectrum. This is probably because it is
+     * absolutely positioned with only a left value, and not a top. Note, on Chrome it seems to be fine without
+     * this approach.
+    */
+  align-items: center;
+  background-clip: padding-box;
+  background-color: #455a64;
+  border: 0.5em solid rgba(136, 136, 136, 0);
+  border-radius: 3em;
+  bottom: 0;
+  color: #fff;
+  display: flex;
+  height: 0.5em;
+  justify-content: center;
+  left: -10px;
+  margin: auto;
+  position: absolute;
+  top: 0;
+  transition: border 120ms cubic-bezier(0.39, 0.58, 0.57, 1);
+  width: 0.5em;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider .tinymce-mobile-slider-thumb.tinymce-mobile-thumb-active {
+  border: 0.5em solid rgba(136, 136, 136, 0.39);
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serializer-wrapper,
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group > div {
+  align-items: center;
+  display: flex;
+  height: 100%;
+  flex: 1;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serializer-wrapper {
+  flex-direction: column;
+  justify-content: center;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item {
+  align-items: center;
+  display: flex;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item:not(.tinymce-mobile-serialised-dialog) {
+  height: 100%;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-dot-container {
+  display: flex;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group input {
+  background: #ffffff;
+  border: none;
+  border-radius: 0;
+  color: #455a64;
+  flex-grow: 1;
+  font-size: 0.85em;
+  padding-bottom: 0.1em;
+  padding-left: 5px;
+  padding-top: 0.1em;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group input::-webkit-input-placeholder {
+  /* WebKit, Blink, Edge */
+  color: #888;
+}
+.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group input::placeholder {
+  /* WebKit, Blink, Edge */
+  color: #888;
+}
+/* dropup */
+.tinymce-mobile-dropup {
+  background: white;
+  display: flex;
+  overflow: hidden;
+  width: 100%;
+}
+.tinymce-mobile-dropup.tinymce-mobile-dropup-shrinking {
+  transition: height 0.3s ease-out;
+}
+.tinymce-mobile-dropup.tinymce-mobile-dropup-growing {
+  transition: height 0.3s ease-in;
+}
+.tinymce-mobile-dropup.tinymce-mobile-dropup-closed {
+  flex-grow: 0;
+}
+.tinymce-mobile-dropup.tinymce-mobile-dropup-open:not(.tinymce-mobile-dropup-growing) {
+  flex-grow: 1;
+}
+/* TODO min-height for device size and orientation */
+.tinymce-mobile-ios-container .tinymce-mobile-dropup:not(.tinymce-mobile-dropup-closed) {
+  min-height: 200px;
+}
+@media only screen and (orientation: landscape) {
+  .tinymce-mobile-dropup:not(.tinymce-mobile-dropup-closed) {
+    min-height: 200px;
+  }
+}
+@media only screen and (min-device-width : 320px) and (max-device-width : 568px) and (orientation : landscape) {
+  .tinymce-mobile-ios-container .tinymce-mobile-dropup:not(.tinymce-mobile-dropup-closed) {
+    min-height: 150px;
+  }
+}
+/* styles menu */
+.tinymce-mobile-styles-menu {
+  font-family: sans-serif;
+  outline: 4px solid black;
+  overflow: hidden;
+  position: relative;
+  width: 100%;
+}
+.tinymce-mobile-styles-menu [role="menu"] {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  position: absolute;
+  width: 100%;
+}
+.tinymce-mobile-styles-menu [role="menu"].transitioning {
+  transition: transform 0.5s ease-in-out;
+}
+.tinymce-mobile-styles-menu .tinymce-mobile-styles-item {
+  border-bottom: 1px solid #ddd;
+  color: #455a64;
+  cursor: pointer;
+  display: flex;
+  padding: 1em 1em;
+  position: relative;
+}
+.tinymce-mobile-styles-menu .tinymce-mobile-styles-collapser .tinymce-mobile-styles-collapse-icon::before {
+  color: #455a64;
+  content: "\e314";
+  font-family: 'tinymce-mobile', sans-serif;
+}
+.tinymce-mobile-styles-menu .tinymce-mobile-styles-item.tinymce-mobile-styles-item-is-menu::after {
+  color: #455a64;
+  content: "\e315";
+  font-family: 'tinymce-mobile', sans-serif;
+  padding-left: 1em;
+  padding-right: 1em;
+  position: absolute;
+  right: 0;
+}
+.tinymce-mobile-styles-menu .tinymce-mobile-styles-item.tinymce-mobile-format-matches::after {
+  font-family: 'tinymce-mobile', sans-serif;
+  padding-left: 1em;
+  padding-right: 1em;
+  position: absolute;
+  right: 0;
+}
+.tinymce-mobile-styles-menu .tinymce-mobile-styles-separator,
+.tinymce-mobile-styles-menu .tinymce-mobile-styles-collapser {
+  align-items: center;
+  background: #fff;
+  border-top: #455a64;
+  color: #455a64;
+  display: flex;
+  min-height: 2.5em;
+  padding-left: 1em;
+  padding-right: 1em;
+}
+.tinymce-mobile-styles-menu [data-transitioning-destination="before"][data-transitioning-state],
+.tinymce-mobile-styles-menu [data-transitioning-state="before"] {
+  transform: translate(-100%);
+}
+.tinymce-mobile-styles-menu [data-transitioning-destination="current"][data-transitioning-state],
+.tinymce-mobile-styles-menu [data-transitioning-state="current"] {
+  transform: translate(0%);
+}
+.tinymce-mobile-styles-menu [data-transitioning-destination="after"][data-transitioning-state],
+.tinymce-mobile-styles-menu [data-transitioning-state="after"] {
+  transform: translate(100%);
+}
+@font-face {
+  font-family: 'tinymce-mobile';
+  font-style: normal;
+  font-weight: normal;
+  src: url('fonts/tinymce-mobile.woff?8x92w3') format('woff');
+}
+@media (min-device-width: 700px) {
+  .tinymce-mobile-outer-container,
+  .tinymce-mobile-outer-container input {
+    font-size: 25px;
+  }
+}
+@media (max-device-width: 700px) {
+  .tinymce-mobile-outer-container,
+  .tinymce-mobile-outer-container input {
+    font-size: 18px;
+  }
+}
+.tinymce-mobile-icon {
+  font-family: 'tinymce-mobile', sans-serif;
+}
+.mixin-flex-and-centre {
+  align-items: center;
+  display: flex;
+  justify-content: center;
+}
+.mixin-flex-bar {
+  align-items: center;
+  display: flex;
+  height: 100%;
+}
+.tinymce-mobile-outer-container .tinymce-mobile-editor-socket iframe {
+  background-color: #fff;
+  width: 100%;
+}
+.tinymce-mobile-editor-socket .tinymce-mobile-mask-edit-icon {
+  /* Note, on the iPod touch in landscape, this isn't visible when the navbar appears */
+  background-color: #207ab7;
+  border-radius: 50%;
+  bottom: 1em;
+  color: white;
+  font-size: 1em;
+  height: 2.1em;
+  position: fixed;
+  right: 2em;
+  width: 2.1em;
+  align-items: center;
+  display: flex;
+  justify-content: center;
+}
+@media only screen and (min-device-width:700px) {
+  .tinymce-mobile-editor-socket .tinymce-mobile-mask-edit-icon {
+    font-size: 1.2em;
+  }
+}
+.tinymce-mobile-outer-container:not(.tinymce-mobile-fullscreen-maximized) .tinymce-mobile-editor-socket {
+  height: 300px;
+  overflow: hidden;
+}
+.tinymce-mobile-outer-container:not(.tinymce-mobile-fullscreen-maximized) .tinymce-mobile-editor-socket iframe {
+  height: 100%;
+}
+.tinymce-mobile-outer-container:not(.tinymce-mobile-fullscreen-maximized) .tinymce-mobile-toolstrip {
+  display: none;
+}
+/*
+  Note, that if you don't include this (::-webkit-file-upload-button), the toolbar width gets
+  increased and the whole body becomes scrollable. It's important!
+ */
+input[type="file"]::-webkit-file-upload-button {
+  display: none;
+}
+@media only screen and (min-device-width : 320px) and (max-device-width : 568px) and (orientation : landscape) {
+  .tinymce-mobile-ios-container .tinymce-mobile-editor-socket .tinymce-mobile-mask-edit-icon {
+    bottom: 50%;
+  }
+}
diff --git a/public/tinymce/skins/ui/oxide/skin.mobile.min.css b/public/tinymce/skins/ui/oxide/skin.mobile.min.css
new file mode 100644
index 0000000..3a45cac
--- /dev/null
+++ b/public/tinymce/skins/ui/oxide/skin.mobile.min.css
@@ -0,0 +1,7 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+.tinymce-mobile-outer-container{all:initial;display:block}.tinymce-mobile-outer-container *{border:0;box-sizing:initial;cursor:inherit;float:none;line-height:1;margin:0;outline:0;padding:0;-webkit-tap-highlight-color:transparent;text-shadow:none;white-space:nowrap}.tinymce-mobile-icon-arrow-back::before{content:"\e5cd"}.tinymce-mobile-icon-image::before{content:"\e412"}.tinymce-mobile-icon-cancel-circle::before{content:"\e5c9"}.tinymce-mobile-icon-full-dot::before{content:"\e061"}.tinymce-mobile-icon-align-center::before{content:"\e234"}.tinymce-mobile-icon-align-left::before{content:"\e236"}.tinymce-mobile-icon-align-right::before{content:"\e237"}.tinymce-mobile-icon-bold::before{content:"\e238"}.tinymce-mobile-icon-italic::before{content:"\e23f"}.tinymce-mobile-icon-unordered-list::before{content:"\e241"}.tinymce-mobile-icon-ordered-list::before{content:"\e242"}.tinymce-mobile-icon-font-size::before{content:"\e245"}.tinymce-mobile-icon-underline::before{content:"\e249"}.tinymce-mobile-icon-link::before{content:"\e157"}.tinymce-mobile-icon-unlink::before{content:"\eca2"}.tinymce-mobile-icon-color::before{content:"\e891"}.tinymce-mobile-icon-previous::before{content:"\e314"}.tinymce-mobile-icon-next::before{content:"\e315"}.tinymce-mobile-icon-large-font::before,.tinymce-mobile-icon-style-formats::before{content:"\e264"}.tinymce-mobile-icon-undo::before{content:"\e166"}.tinymce-mobile-icon-redo::before{content:"\e15a"}.tinymce-mobile-icon-removeformat::before{content:"\e239"}.tinymce-mobile-icon-small-font::before{content:"\e906"}.tinymce-mobile-format-matches::after,.tinymce-mobile-icon-readonly-back::before{content:"\e5ca"}.tinymce-mobile-icon-small-heading::before{content:"small"}.tinymce-mobile-icon-large-heading::before{content:"large"}.tinymce-mobile-icon-large-heading::before,.tinymce-mobile-icon-small-heading::before{font-family:sans-serif;font-size:80%}.tinymce-mobile-mask-edit-icon::before{content:"\e254"}.tinymce-mobile-icon-back::before{content:"\e5c4"}.tinymce-mobile-icon-heading::before{content:"Headings";font-family:sans-serif;font-size:80%;font-weight:700}.tinymce-mobile-icon-h1::before{content:"H1";font-weight:700}.tinymce-mobile-icon-h2::before{content:"H2";font-weight:700}.tinymce-mobile-icon-h3::before{content:"H3";font-weight:700}.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask{align-items:center;display:flex;justify-content:center;background:rgba(51,51,51,.5);height:100%;position:absolute;top:0;width:100%}.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container{align-items:center;border-radius:50%;display:flex;flex-direction:column;font-family:sans-serif;font-size:1em;justify-content:space-between}.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .mixin-menu-item{align-items:center;display:flex;justify-content:center;border-radius:50%;height:2.1em;width:2.1em}.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section{align-items:center;display:flex;justify-content:center;flex-direction:column;font-size:1em}@media only screen and (min-device-width:700px){.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section{font-size:1.2em}}.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section .tinymce-mobile-mask-tap-icon{align-items:center;display:flex;justify-content:center;border-radius:50%;height:2.1em;width:2.1em;background-color:#fff;color:#207ab7}.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section .tinymce-mobile-mask-tap-icon::before{content:"\e900";font-family:tinymce-mobile,sans-serif}.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section:not(.tinymce-mobile-mask-tap-icon-selected) .tinymce-mobile-mask-tap-icon{z-index:2}.tinymce-mobile-android-container.tinymce-mobile-android-maximized{background:#fff;border:none;bottom:0;display:flex;flex-direction:column;left:0;position:fixed;right:0;top:0}.tinymce-mobile-android-container:not(.tinymce-mobile-android-maximized){position:relative}.tinymce-mobile-android-container .tinymce-mobile-editor-socket{display:flex;flex-grow:1}.tinymce-mobile-android-container .tinymce-mobile-editor-socket iframe{display:flex!important;flex-grow:1;height:auto!important}.tinymce-mobile-android-scroll-reload{overflow:hidden}:not(.tinymce-mobile-readonly-mode)>.tinymce-mobile-android-selection-context-toolbar{margin-top:23px}.tinymce-mobile-toolstrip{background:#fff;display:flex;flex:0 0 auto;z-index:1}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar{align-items:center;background-color:#fff;border-bottom:1px solid #ccc;display:flex;flex:1;height:2.5em;width:100%}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group{align-items:center;display:flex;height:100%;flex-shrink:1}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group>div{align-items:center;display:flex;height:100%;flex:1}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group.tinymce-mobile-exit-container{background:#f44336}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group.tinymce-mobile-toolbar-scrollable-group{flex-grow:1}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item{padding-left:.5em;padding-right:.5em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item.tinymce-mobile-toolbar-button{align-items:center;display:flex;height:80%;margin-left:2px;margin-right:2px}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item.tinymce-mobile-toolbar-button.tinymce-mobile-toolbar-button-selected{background:#c8cbcf;color:#ccc}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group:first-of-type,.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group:last-of-type{background:#207ab7;color:#eceff1}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group{align-items:center;display:flex;height:100%;flex:1;padding-bottom:.4em;padding-top:.4em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog{display:flex;min-height:1.5em;overflow:hidden;padding-left:0;padding-right:0;position:relative;width:100%}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain{display:flex;height:100%;transition:left cubic-bezier(.4,0,1,1) .15s;width:100%}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen{display:flex;flex:0 0 auto;justify-content:space-between;width:100%}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen input{font-family:Sans-serif}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-input-container{display:flex;flex-grow:1;position:relative}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-input-container .tinymce-mobile-input-container-x{-ms-grid-row-align:center;align-self:center;background:inherit;border:none;border-radius:50%;color:#888;font-size:.6em;font-weight:700;height:100%;padding-right:2px;position:absolute;right:0}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-input-container.tinymce-mobile-input-container-empty .tinymce-mobile-input-container-x{display:none}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-next,.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-previous{align-items:center;display:flex}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-next::before,.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-previous::before{align-items:center;display:flex;font-weight:700;height:100%;padding-left:.5em;padding-right:.5em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-next.tinymce-mobile-toolbar-navigation-disabled::before,.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-previous.tinymce-mobile-toolbar-navigation-disabled::before{visibility:hidden}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-dot-item{color:#ccc;font-size:10px;line-height:10px;margin:0 2px;padding-top:3px}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-dot-item.tinymce-mobile-dot-active{color:#c8cbcf}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-icon-large-font::before,.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-icon-large-heading::before{margin-left:.5em;margin-right:.9em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-icon-small-font::before,.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-icon-small-heading::before{margin-left:.9em;margin-right:.5em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider{display:flex;flex:1;margin-left:0;margin-right:0;padding:.28em 0;position:relative}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider .tinymce-mobile-slider-size-container{align-items:center;display:flex;flex-grow:1;height:100%}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider .tinymce-mobile-slider-size-container .tinymce-mobile-slider-size-line{background:#ccc;display:flex;flex:1;height:.2em;margin-bottom:.3em;margin-top:.3em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container{padding-left:2em;padding-right:2em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container .tinymce-mobile-slider-gradient-container{align-items:center;display:flex;flex-grow:1;height:100%}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container .tinymce-mobile-slider-gradient-container .tinymce-mobile-slider-gradient{background:linear-gradient(to right,red 0,#feff00 17%,#0f0 33%,#00feff 50%,#00f 67%,#ff00fe 83%,red 100%);display:flex;flex:1;height:.2em;margin-bottom:.3em;margin-top:.3em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container .tinymce-mobile-hue-slider-black{background:#000;height:.2em;margin-bottom:.3em;margin-top:.3em;width:1.2em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container .tinymce-mobile-hue-slider-white{background:#fff;height:.2em;margin-bottom:.3em;margin-top:.3em;width:1.2em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider .tinymce-mobile-slider-thumb{align-items:center;background-clip:padding-box;background-color:#455a64;border:.5em solid rgba(136,136,136,0);border-radius:3em;bottom:0;color:#fff;display:flex;height:.5em;justify-content:center;left:-10px;margin:auto;position:absolute;top:0;transition:border 120ms cubic-bezier(.39,.58,.57,1);width:.5em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider .tinymce-mobile-slider-thumb.tinymce-mobile-thumb-active{border:.5em solid rgba(136,136,136,.39)}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serializer-wrapper,.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group>div{align-items:center;display:flex;height:100%;flex:1}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serializer-wrapper{flex-direction:column;justify-content:center}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item{align-items:center;display:flex}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item:not(.tinymce-mobile-serialised-dialog){height:100%}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-dot-container{display:flex}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group input{background:#fff;border:none;border-radius:0;color:#455a64;flex-grow:1;font-size:.85em;padding-bottom:.1em;padding-left:5px;padding-top:.1em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group input::-webkit-input-placeholder{color:#888}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group input::placeholder{color:#888}.tinymce-mobile-dropup{background:#fff;display:flex;overflow:hidden;width:100%}.tinymce-mobile-dropup.tinymce-mobile-dropup-shrinking{transition:height .3s ease-out}.tinymce-mobile-dropup.tinymce-mobile-dropup-growing{transition:height .3s ease-in}.tinymce-mobile-dropup.tinymce-mobile-dropup-closed{flex-grow:0}.tinymce-mobile-dropup.tinymce-mobile-dropup-open:not(.tinymce-mobile-dropup-growing){flex-grow:1}.tinymce-mobile-ios-container .tinymce-mobile-dropup:not(.tinymce-mobile-dropup-closed){min-height:200px}@media only screen and (orientation:landscape){.tinymce-mobile-dropup:not(.tinymce-mobile-dropup-closed){min-height:200px}}@media only screen and (min-device-width :320px) and (max-device-width :568px) and (orientation :landscape){.tinymce-mobile-ios-container .tinymce-mobile-dropup:not(.tinymce-mobile-dropup-closed){min-height:150px}}.tinymce-mobile-styles-menu{font-family:sans-serif;outline:4px solid #000;overflow:hidden;position:relative;width:100%}.tinymce-mobile-styles-menu [role=menu]{display:flex;flex-direction:column;height:100%;position:absolute;width:100%}.tinymce-mobile-styles-menu [role=menu].transitioning{transition:transform .5s ease-in-out}.tinymce-mobile-styles-menu .tinymce-mobile-styles-item{border-bottom:1px solid #ddd;color:#455a64;cursor:pointer;display:flex;padding:1em 1em;position:relative}.tinymce-mobile-styles-menu .tinymce-mobile-styles-collapser .tinymce-mobile-styles-collapse-icon::before{color:#455a64;content:"\e314";font-family:tinymce-mobile,sans-serif}.tinymce-mobile-styles-menu .tinymce-mobile-styles-item.tinymce-mobile-styles-item-is-menu::after{color:#455a64;content:"\e315";font-family:tinymce-mobile,sans-serif;padding-left:1em;padding-right:1em;position:absolute;right:0}.tinymce-mobile-styles-menu .tinymce-mobile-styles-item.tinymce-mobile-format-matches::after{font-family:tinymce-mobile,sans-serif;padding-left:1em;padding-right:1em;position:absolute;right:0}.tinymce-mobile-styles-menu .tinymce-mobile-styles-collapser,.tinymce-mobile-styles-menu .tinymce-mobile-styles-separator{align-items:center;background:#fff;border-top:#455a64;color:#455a64;display:flex;min-height:2.5em;padding-left:1em;padding-right:1em}.tinymce-mobile-styles-menu [data-transitioning-destination=before][data-transitioning-state],.tinymce-mobile-styles-menu [data-transitioning-state=before]{transform:translate(-100%)}.tinymce-mobile-styles-menu [data-transitioning-destination=current][data-transitioning-state],.tinymce-mobile-styles-menu [data-transitioning-state=current]{transform:translate(0)}.tinymce-mobile-styles-menu [data-transitioning-destination=after][data-transitioning-state],.tinymce-mobile-styles-menu [data-transitioning-state=after]{transform:translate(100%)}@font-face{font-family:tinymce-mobile;font-style:normal;font-weight:400;src:url(fonts/tinymce-mobile.woff?8x92w3) format('woff')}@media (min-device-width:700px){.tinymce-mobile-outer-container,.tinymce-mobile-outer-container input{font-size:25px}}@media (max-device-width:700px){.tinymce-mobile-outer-container,.tinymce-mobile-outer-container input{font-size:18px}}.tinymce-mobile-icon{font-family:tinymce-mobile,sans-serif}.mixin-flex-and-centre{align-items:center;display:flex;justify-content:center}.mixin-flex-bar{align-items:center;display:flex;height:100%}.tinymce-mobile-outer-container .tinymce-mobile-editor-socket iframe{background-color:#fff;width:100%}.tinymce-mobile-editor-socket .tinymce-mobile-mask-edit-icon{background-color:#207ab7;border-radius:50%;bottom:1em;color:#fff;font-size:1em;height:2.1em;position:fixed;right:2em;width:2.1em;align-items:center;display:flex;justify-content:center}@media only screen and (min-device-width:700px){.tinymce-mobile-editor-socket .tinymce-mobile-mask-edit-icon{font-size:1.2em}}.tinymce-mobile-outer-container:not(.tinymce-mobile-fullscreen-maximized) .tinymce-mobile-editor-socket{height:300px;overflow:hidden}.tinymce-mobile-outer-container:not(.tinymce-mobile-fullscreen-maximized) .tinymce-mobile-editor-socket iframe{height:100%}.tinymce-mobile-outer-container:not(.tinymce-mobile-fullscreen-maximized) .tinymce-mobile-toolstrip{display:none}input[type=file]::-webkit-file-upload-button{display:none}@media only screen and (min-device-width :320px) and (max-device-width :568px) and (orientation :landscape){.tinymce-mobile-ios-container .tinymce-mobile-editor-socket .tinymce-mobile-mask-edit-icon{bottom:50%}}
diff --git a/public/tinymce/skins/ui/oxide/skin.shadowdom.css b/public/tinymce/skins/ui/oxide/skin.shadowdom.css
new file mode 100644
index 0000000..d2adc4d
--- /dev/null
+++ b/public/tinymce/skins/ui/oxide/skin.shadowdom.css
@@ -0,0 +1,37 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+body.tox-dialog__disable-scroll {
+  overflow: hidden;
+}
+.tox-fullscreen {
+  border: 0;
+  height: 100%;
+  margin: 0;
+  overflow: hidden;
+  -ms-scroll-chaining: none;
+      overscroll-behavior: none;
+  padding: 0;
+  touch-action: pinch-zoom;
+  width: 100%;
+}
+.tox.tox-tinymce.tox-fullscreen .tox-statusbar__resize-handle {
+  display: none;
+}
+.tox.tox-tinymce.tox-fullscreen,
+.tox-shadowhost.tox-fullscreen {
+  left: 0;
+  position: fixed;
+  top: 0;
+  z-index: 1200;
+}
+.tox.tox-tinymce.tox-fullscreen {
+  background-color: transparent;
+}
+.tox-fullscreen .tox.tox-tinymce-aux,
+.tox-fullscreen ~ .tox.tox-tinymce-aux {
+  z-index: 1201;
+}
diff --git a/public/tinymce/skins/ui/oxide/skin.shadowdom.min.css b/public/tinymce/skins/ui/oxide/skin.shadowdom.min.css
new file mode 100644
index 0000000..a0893b9
--- /dev/null
+++ b/public/tinymce/skins/ui/oxide/skin.shadowdom.min.css
@@ -0,0 +1,7 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+body.tox-dialog__disable-scroll{overflow:hidden}.tox-fullscreen{border:0;height:100%;margin:0;overflow:hidden;-ms-scroll-chaining:none;overscroll-behavior:none;padding:0;touch-action:pinch-zoom;width:100%}.tox.tox-tinymce.tox-fullscreen .tox-statusbar__resize-handle{display:none}.tox-shadowhost.tox-fullscreen,.tox.tox-tinymce.tox-fullscreen{left:0;position:fixed;top:0;z-index:1200}.tox.tox-tinymce.tox-fullscreen{background-color:transparent}.tox-fullscreen .tox.tox-tinymce-aux,.tox-fullscreen~.tox.tox-tinymce-aux{z-index:1201}
diff --git a/src/App.vue b/src/App.vue
new file mode 100644
index 0000000..3beb7f1
--- /dev/null
+++ b/src/App.vue
@@ -0,0 +1,36 @@
+<template>
+  <ele-config-provider
+    :map-key="MAP_KEY"
+    :locale="eleLocale"
+    :keep-alive="keepAlive"
+    :license="LICENSE_CODE"
+  >
+    <a-config-provider :locale="antLocale">
+      <router-view />
+    </a-config-provider>
+  </ele-config-provider>
+</template>
+
+<script lang="ts" setup>
+  import { unref, computed } from 'vue';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import { MAP_KEY, LICENSE_CODE, TAB_KEEP_ALIVE } from '@/config/setting';
+  import { useSetDocumentTitle } from '@/utils/document-title-util';
+  import { useLocale } from '@/i18n/use-locale';
+
+  const themeStore = useThemeStore();
+  const { showTabs } = storeToRefs(themeStore);
+
+  // 恢复主题
+  themeStore.recoverTheme();
+
+  // 切换路由自动更新浏览器页签标题
+  useSetDocumentTitle();
+
+  // 国际化配置
+  const { antLocale, eleLocale } = useLocale();
+
+  // 用于内链 iframe 组件获取 KeepAlive
+  const keepAlive = computed(() => TAB_KEEP_ALIVE && unref(showTabs));
+</script>
diff --git a/src/api/content/article/index.ts b/src/api/content/article/index.ts
new file mode 100644
index 0000000..419f091
--- /dev/null
+++ b/src/api/content/article/index.ts
@@ -0,0 +1,66 @@
+import request from '@/utils/request';
+import type { ApiResult, PageResult } from '@/api';
+import type { Article, ArticleParam } from './model';
+
+/**
+ * 分页查询
+ */
+export async function pageArticles(params: ArticleParam) {
+  const res = await request.get<ApiResult<PageResult<Article>>>(
+    '/content/article/page',
+    { params }
+  );
+  if (res.data.code === 0) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 添加
+ */
+export async function addArticle(data: Article) {
+  const res = await request.post<ApiResult<unknown>>('/content/article', data);
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 修改
+ */
+export async function updateArticle(data: Article) {
+  const res = await request.put<ApiResult<unknown>>('/content/article', data);
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 删除
+ */
+export async function removeArticle(id?: number) {
+  const res = await request.delete<ApiResult<unknown>>(
+    '/content/article/' + id
+  );
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 修改状态
+ */
+export async function updateArticleStatus(id?: number, status?: number) {
+  const res = await request.put<ApiResult<unknown>>('/content/article/status', {
+    id,
+    status
+  });
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
diff --git a/src/api/content/article/model/index.ts b/src/api/content/article/model/index.ts
new file mode 100644
index 0000000..966aa5e
--- /dev/null
+++ b/src/api/content/article/model/index.ts
@@ -0,0 +1,51 @@
+import type { PageParam } from '@/api';
+
+/**
+ * 文章
+ */
+export interface Article {
+  // id
+  id?: number;
+  // 栏目ID
+  cateId?: number;
+  // 发布者
+  user?: string;
+  // 标题
+  title?: string;
+  // 来源
+  origin?: string;
+  // 类型
+  type?: number;
+  // 外链地址
+  link?: string;
+  //模板路径
+  template?: string;
+  // 图片
+  image?: string;
+  // 关键字
+  keywords?: string;
+  // 摘要
+  summary?: string;
+  // 内容
+  content?: string;
+  // 点击量
+  clickNum?: number;
+  // 添加量
+  addNum?: number;
+  // 状态
+  status?: number;
+  // 删除
+  deleted?: number;
+  // 创建时间
+  createTime: string;
+}
+
+/**
+ * 搜索条件
+ */
+export interface ArticleParam extends PageParam {
+  title?: string;
+  user?: string;
+  status?: number;
+  cateId?: number;
+}
diff --git a/src/api/content/category/index.ts b/src/api/content/category/index.ts
new file mode 100644
index 0000000..0b952a3
--- /dev/null
+++ b/src/api/content/category/index.ts
@@ -0,0 +1,80 @@
+import request from '@/utils/request';
+import type { ApiResult, PageResult } from '@/api';
+import type { Category, CategoryParam } from './model';
+
+/**
+ * 分页查询分类
+ */
+export async function pageCategories(params: CategoryParam) {
+  const res = await request.get<ApiResult<PageResult<Category>>>(
+    '/content/category/page',
+    { params }
+  );
+  if (res.data.code === 0) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * Id查询
+ * @param params
+ * @returns
+ */
+export async function getCategory(id: number) {
+  const res = await request.get<ApiResult<Category[]>>(
+    '/content/category/' + id
+  );
+  if (res.data.code === 0 && res.data.data) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 查询列表
+ */
+export async function listCategories(params?: CategoryParam) {
+  const res = await request.get<ApiResult<Category[]>>('/content/category', {
+    params
+  });
+  if (res.data.code === 0 && res.data.data) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 添加
+ */
+export async function addCategory(data: Category) {
+  const res = await request.post<ApiResult<unknown>>('/content/category', data);
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 修改
+ */
+export async function updateCategory(data: Category) {
+  const res = await request.put<ApiResult<unknown>>('/content/category', data);
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 删除
+ */
+export async function removeCategory(id?: number) {
+  const res = await request.delete<ApiResult<unknown>>(
+    '/content/category/' + id
+  );
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
diff --git a/src/api/content/category/model/index.ts b/src/api/content/category/model/index.ts
new file mode 100644
index 0000000..d34dd82
--- /dev/null
+++ b/src/api/content/category/model/index.ts
@@ -0,0 +1,44 @@
+import { PageParam } from '@/api';
+
+/**
+ * 分类
+ */
+export interface Category {
+  // 分类id
+  cateId?: number;
+  // 上级id, 0是顶级
+  parentId?: number;
+  // 分类名称
+  cateName?: string;
+  //栏目类型
+  menuType?: number;
+  //外链
+  url?: string;
+  //模板
+  template?: string;
+  //color
+  color?: string;
+  //图片
+  image?: string;
+  // 排序号
+  sortNumber?: number;
+  // 备注
+  introduction?: string;
+  // 创建时间
+  created_at?: string;
+  //
+  key?: number;
+  //
+  value?: number;
+  //
+  title?: string;
+
+  disabled?: boolean | number;
+}
+
+/**
+ * 搜索条件
+ */
+export interface CategoryParam extends PageParam {
+  cateName?: string;
+}
diff --git a/src/api/dashboard/analysis/index.ts b/src/api/dashboard/analysis/index.ts
new file mode 100644
index 0000000..19354b6
--- /dev/null
+++ b/src/api/dashboard/analysis/index.ts
@@ -0,0 +1,56 @@
+import request from '@/utils/request';
+import type { ApiResult } from '@/api';
+import type { PayNumData, SaleroomResult, VisitData, CloudData } from './model';
+
+/**
+ * 获取支付笔数数据
+ */
+export async function getPayNumList() {
+  const res = await request.get<ApiResult<PayNumData[]>>(
+    'https://cdn.eleadmin.com/20200610/analysis-pay-num.json'
+  );
+  if (res.data.code === 0 && res.data.data) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 获取销售量数据
+ */
+export async function getSaleroomList() {
+  const res = await request.get<ApiResult<SaleroomResult>>(
+    'https://cdn.eleadmin.com/20200610/analysis-saleroom.json'
+  );
+  if (res.data.code === 0 && res.data.data) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 获取最近 1 小时访问情况数据
+ * @returns {Promise<Object>}
+ */
+export async function getVisitHourList() {
+  const res = await request.get<ApiResult<VisitData[]>>(
+    'https://cdn.eleadmin.com/20200610/analysis-visits.json'
+  );
+  if (res.data.code === 0 && res.data.data) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 获取词云数据
+ */
+export async function getWordCloudList() {
+  const res = await request.get<ApiResult<CloudData[]>>(
+    'https://cdn.eleadmin.com/20200610/analysis-hot-search.json'
+  );
+  if (res.data.code === 0 && res.data.data) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
diff --git a/src/api/dashboard/analysis/model/index.ts b/src/api/dashboard/analysis/model/index.ts
new file mode 100644
index 0000000..026ecbd
--- /dev/null
+++ b/src/api/dashboard/analysis/model/index.ts
@@ -0,0 +1,46 @@
+/**
+ * 支付笔数数据格式
+ */
+export interface PayNumData {
+  // 日期
+  date?: string;
+  // 支付笔数
+  value?: number;
+}
+
+/**
+ * 销售量数据格式
+ */
+export interface SaleroomData {
+  // 月份
+  month?: string;
+  // 销售量
+  value?: number;
+}
+
+export interface SaleroomResult {
+  list1: SaleroomData[];
+  list2: SaleroomData[];
+}
+
+/**
+ * 访问情况数据格式
+ */
+export interface VisitData {
+  // 时间
+  time?: string;
+  // 访问量
+  visits?: number;
+  // 浏览量
+  views?: number;
+}
+
+/**
+ * 词云数据格式
+ */
+export interface CloudData {
+  // 标题
+  name: string;
+  // 数量
+  value: number;
+}
diff --git a/src/api/dashboard/monitor/index.ts b/src/api/dashboard/monitor/index.ts
new file mode 100644
index 0000000..37af9f4
--- /dev/null
+++ b/src/api/dashboard/monitor/index.ts
@@ -0,0 +1,44 @@
+import request from '@/utils/request';
+import type { ApiResult } from '@/api';
+import type { UserCount, BrowserCount } from './model';
+const BASE_URL = import.meta.env.BASE_URL;
+
+/**
+ * 获取中国地图geo数据
+ */
+export async function getChinaMapData() {
+  const res = await request.get<any>(
+    BASE_URL + 'json/china-provinces.geo.json',
+    { baseURL: '' }
+  );
+  if (res.data) {
+    return res.data;
+  }
+  return Promise.reject(new Error('获取地图数据失败'));
+}
+
+/**
+ * 获取用户分布数据
+ */
+export async function getUserCountList() {
+  const res = await request.get<ApiResult<UserCount[]>>(
+    'https://cdn.eleadmin.com/20200610/monitor-user-count.json'
+  );
+  if (res.data.code === 0 && res.data.data) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 获取用户浏览器分布数据
+ */
+export async function getBrowserCountList() {
+  const res = await request.get<ApiResult<BrowserCount[]>>(
+    'https://cdn.eleadmin.com/20200610/monitor-browser-count.json'
+  );
+  if (res.data.code === 0 && res.data.data) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
diff --git a/src/api/dashboard/monitor/model/index.ts b/src/api/dashboard/monitor/model/index.ts
new file mode 100644
index 0000000..e7d21a8
--- /dev/null
+++ b/src/api/dashboard/monitor/model/index.ts
@@ -0,0 +1,21 @@
+/**
+ * 用户分布数据格式
+ */
+export interface UserCount {
+  // 省份
+  name: string;
+  // 用户数量
+  value: number;
+  // 百分比
+  percent?: number;
+}
+
+/**
+ * 浏览器分布数据格式
+ */
+export interface BrowserCount {
+  // 浏览器
+  name: string;
+  // 用户数量
+  value: number;
+}
diff --git a/src/api/employ/category/index.ts b/src/api/employ/category/index.ts
new file mode 100644
index 0000000..1d508d1
--- /dev/null
+++ b/src/api/employ/category/index.ts
@@ -0,0 +1,81 @@
+import request from '@/utils/request';
+import type { ApiResult, PageResult } from '@/api';
+import type { Category, CategoryParam } from './model';
+
+/**
+ * 分页查询
+ */
+export async function pageCategory(params: CategoryParam) {
+  const res = await request.get<ApiResult<PageResult<Category>>>(
+    '/employ/category/page',
+    { params }
+  );
+  if (res.data.code === 0) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 查询列表
+ */
+export async function listCategory(params?: CategoryParam) {
+  const res = await request.get<ApiResult<Category[]>>('/employ/category', {
+    params
+  });
+  if (res.data.code === 0 && res.data.data) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 添加
+ */
+export async function addCategory(data: Category) {
+  const res = await request.post<ApiResult<unknown>>('/employ/category', data);
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 修改
+ */
+export async function updateCategory(data: Category) {
+  const res = await request.put<ApiResult<unknown>>('/employ/category', data);
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 删除
+ */
+export async function removeCategory(id?: number) {
+  const res = await request.delete<ApiResult<unknown>>(
+    '/employ/category/' + id
+  );
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 批量删除
+ */
+export async function removeCategoryBatch(data: (number | undefined)[]) {
+  const res = await request.delete<ApiResult<unknown>>(
+    '/employ/category/batch',
+    {
+      data
+    }
+  );
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
diff --git a/src/api/employ/category/model/index.ts b/src/api/employ/category/model/index.ts
new file mode 100644
index 0000000..6522b14
--- /dev/null
+++ b/src/api/employ/category/model/index.ts
@@ -0,0 +1,23 @@
+import type { PageParam } from '@/api';
+
+/**
+ * 职位分类
+ */
+export interface Category {
+  // id
+  categoryId?: number;
+  // 名称
+  name?: string;
+  // 备注
+  comments?: string;
+  // 创建时间
+  createTime?: string;
+}
+
+/**
+ * 分类搜索条件
+ */
+export interface CategoryParam extends PageParam {
+  name?: string;
+  comments?: string;
+}
diff --git a/src/api/employ/company/index.ts b/src/api/employ/company/index.ts
new file mode 100644
index 0000000..3988d5c
--- /dev/null
+++ b/src/api/employ/company/index.ts
@@ -0,0 +1,124 @@
+import request from '@/utils/request';
+import type { ApiResult, PageResult } from '@/api';
+import type { Company, CompanyParam } from './model';
+
+/**
+ * 分页查询公司
+ */
+export async function pageCompany(params: CompanyParam) {
+  const res = await request.get<ApiResult<PageResult<Company>>>(
+    '/employ/company/page',
+    { params }
+  );
+  if (res.data.code === 0) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 查询公司列表
+ */
+export async function listCompany(params?: CompanyParam) {
+  const res = await request.get<ApiResult<Company[]>>('/employ/company', {
+    params
+  });
+  if (res.data.code === 0 && res.data.data) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 根据id查询公司
+ */
+export async function getCompany(id: number) {
+  const res = await request.get<ApiResult<Company>>('/employ/company' + id);
+  if (res.data.code === 0 && res.data.data) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 添加公司
+ */
+export async function addCompany(data: Company) {
+  const res = await request.post<ApiResult<unknown>>('/employ/company', data);
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 修改公司
+ */
+export async function updateCompany(data: Company) {
+  const res = await request.put<ApiResult<unknown>>('/employ/company', data);
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 删除公司
+ */
+export async function removeCompany(id?: number) {
+  const res = await request.delete<ApiResult<unknown>>('/employ/company/' + id);
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 批量删除公司
+ */
+export async function removeCompanyBatch(data: (number | undefined)[]) {
+  const res = await request.delete<ApiResult<unknown>>(
+    '/employ/company/batch',
+    {
+      data
+    }
+  );
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 修改公司状态
+ */
+export async function updateCompanyStatus(companyId?: number, status?: number) {
+  const res = await request.put<ApiResult<unknown>>('/employ/company/status', {
+    companyId,
+    status
+  });
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 检查公司是否存在
+ */
+export async function checkExistence(
+  field: string,
+  value: string,
+  id?: number
+) {
+  const res = await request.get<ApiResult<unknown>>(
+    '/employ/company/existence',
+    {
+      params: { field, value, id }
+    }
+  );
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
diff --git a/src/api/employ/company/model/index.ts b/src/api/employ/company/model/index.ts
new file mode 100644
index 0000000..1416517
--- /dev/null
+++ b/src/api/employ/company/model/index.ts
@@ -0,0 +1,40 @@
+import type { PageParam } from '@/api';
+
+/**
+ * 公司
+ */
+export interface Company {
+  // 公司id
+  companyId?: number;
+  // 名称
+  name?: string;
+  // logo
+  logo?: string;
+  //联系人
+  hr?: string;
+  // 联系电话
+  phone?: string;
+  // 邮箱
+  email?: string;
+  // 联系地址
+  address?: string;
+  // 地图导航
+  location?: string;
+  // 公司简介
+  comment?: string;
+  // 状态, 0正常, 1冻结
+  status?: number;
+  //是否删除
+  deleted?: number;
+  // 创建时间
+  createTime?: string;
+}
+
+/**
+ * 企业搜索条件
+ */
+export interface CompanyParam extends PageParam {
+  name?: string;
+  address?: string;
+  status?: string;
+}
diff --git a/src/api/employ/jobs/index.ts b/src/api/employ/jobs/index.ts
new file mode 100644
index 0000000..f3c2dc3
--- /dev/null
+++ b/src/api/employ/jobs/index.ts
@@ -0,0 +1,79 @@
+import request from '@/utils/request';
+import type { ApiResult, PageResult } from '@/api';
+import type { Job, JobParam } from './model';
+
+/**
+ * 分页查询
+ */
+export async function pageJobs(params: JobParam) {
+  const res = await request.get<ApiResult<PageResult<Job>>>(
+    '/employ/jobs/page',
+    { params }
+  );
+  if (res.data.code === 0) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 添加
+ */
+export async function addJob(data: Job) {
+  const res = await request.post<ApiResult<unknown>>('/employ/jobs', data);
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 修改
+ */
+export async function updateJob(data: Job) {
+  const res = await request.put<ApiResult<unknown>>('/employ/jobs', data);
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 删除
+ */
+export async function removeJob(id?: number) {
+  const res = await request.delete<ApiResult<unknown>>('/employ/jobs/' + id);
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 批量删除
+ */
+export async function removeJobBatch(data: (number | undefined)[]) {
+  const res = await request.delete<ApiResult<unknown>>(
+    '/employ/jobs/batch',
+    {
+      data
+    });
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 修改状态
+ */
+export async function updateJobStatus(jobId?: number, status?: number) {
+  const res = await request.put<ApiResult<unknown>>('/employ/jobs/status', {
+    jobId,
+    status
+  });
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
diff --git a/src/api/employ/jobs/model/index.ts b/src/api/employ/jobs/model/index.ts
new file mode 100644
index 0000000..9e205d4
--- /dev/null
+++ b/src/api/employ/jobs/model/index.ts
@@ -0,0 +1,39 @@
+import type { PageParam } from '@/api';
+import { Company } from '@/api/employ/company/model';
+import { Category } from '@/api/employ/category/model';
+
+/**
+ * 职位
+ */
+export interface Job {
+  // id
+  jobId?: number;
+  seniority?: string;
+  companyId?: string;
+  salary?: string;
+  company?: Company[];
+  categoryId?: number;
+  degree?: string;
+  category?: Category[];
+  needs?: string;
+  tags?: [];
+  // 名称
+  title?: string;
+  // 职位简介
+  description?: string;
+  // 状态, 0正常, 1冻结
+  status?: number;
+  //是否删除
+  deleted?: number;
+  // 创建时间
+  createTime?: string;
+}
+
+/**
+ * 搜索条件
+ */
+export interface JobParam extends PageParam {
+  title?: string;
+  company?: string;
+  status?: string;
+}
diff --git a/src/api/employ/tags/index.ts b/src/api/employ/tags/index.ts
new file mode 100644
index 0000000..e4b5cf5
--- /dev/null
+++ b/src/api/employ/tags/index.ts
@@ -0,0 +1,76 @@
+import request from '@/utils/request';
+import type { ApiResult, PageResult } from '@/api';
+import { Tag, TagParam } from '@/api/employ/tags/model';
+
+/**
+ * 分页查询
+ */
+export async function pageTags(params: TagParam) {
+  const res = await request.get<ApiResult<PageResult<Tag>>>(
+    '/employ/tags/page',
+    { params }
+  );
+  if (res.data.code === 0) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 查询列表
+ */
+export async function listTags(params?: TagParam) {
+  const res = await request.get<ApiResult<Tag[]>>('/employ/tags', {
+    params
+  });
+  if (res.data.code === 0 && res.data.data) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 添加
+ */
+export async function addTag(data: Tag) {
+  const res = await request.post<ApiResult<unknown>>('/employ/tags', data);
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 修改
+ */
+export async function updateTag(data: Tag) {
+  const res = await request.put<ApiResult<unknown>>('/employ/tags', data);
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 删除
+ */
+export async function removeTag(id?: number) {
+  const res = await request.delete<ApiResult<unknown>>('/employ/tags/' + id);
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 批量删除
+ */
+export async function removeTagBatch(data: (number | undefined)[]) {
+  const res = await request.delete<ApiResult<unknown>>('/employ/tags/batch', {
+    data
+  });
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
diff --git a/src/api/employ/tags/model/index.ts b/src/api/employ/tags/model/index.ts
new file mode 100644
index 0000000..25eb85a
--- /dev/null
+++ b/src/api/employ/tags/model/index.ts
@@ -0,0 +1,23 @@
+import type { PageParam } from '@/api';
+
+/**
+ * 职位标签
+ */
+export interface Tag {
+  // id
+  tagId?: number;
+  // 名称
+  name?: string;
+  // 备注
+  comments?: string;
+  // 创建时间
+  createTime?: string;
+}
+
+/**
+ * 分类搜索条件
+ */
+export interface TagParam extends PageParam {
+  name?: string;
+  comments?: string;
+}
diff --git a/src/api/example/choose/index.ts b/src/api/example/choose/index.ts
new file mode 100644
index 0000000..3033438
--- /dev/null
+++ b/src/api/example/choose/index.ts
@@ -0,0 +1,17 @@
+import request from '@/utils/request';
+import type { ApiResult } from '@/api';
+import type { Classes, ClassesParam } from './model';
+
+/**
+ * 获取全部的班级数据
+ */
+export async function getAllClasses(params?: ClassesParam) {
+  const res = await request.get<ApiResult<Classes[]>>(
+    'https://cdn.eleadmin.com/20200610/classes.json',
+    { params }
+  );
+  if (res.data.code === 0 && res.data.data) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
diff --git a/src/api/example/choose/model/index.ts b/src/api/example/choose/model/index.ts
new file mode 100644
index 0000000..8fb0520
--- /dev/null
+++ b/src/api/example/choose/model/index.ts
@@ -0,0 +1,21 @@
+/**
+ * 班级
+ */
+export interface Classes {
+  // 班级id
+  classesId?: number;
+  // 班级名称
+  classesName?: string;
+  // 学院名称
+  college?: string;
+  // 专业名称
+  major?: string;
+}
+
+/**
+ * 班级查询参数
+ */
+export interface ClassesParam {
+  classesId?: number;
+  classesName?: string;
+}
diff --git a/src/api/example/document/index.ts b/src/api/example/document/index.ts
new file mode 100644
index 0000000..fa00964
--- /dev/null
+++ b/src/api/example/document/index.ts
@@ -0,0 +1,31 @@
+import request from '@/utils/request';
+import type { ApiResult, PageResult } from '@/api';
+import type { Piece, PieceParam, Archive, ArchiveParam } from './model';
+
+/**
+ * 获取案卷列表
+ */
+export async function getPieceList(params: PieceParam) {
+  const res = await request.get<ApiResult<PageResult<Piece>>>(
+    'https://cdn.eleadmin.com/20200610/document.json',
+    { params }
+  );
+  if (res.data.code === 0 && res.data.data) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 获取卷内文件列表
+ */
+export async function getArchiveList(params: ArchiveParam) {
+  const res = await request.get<ApiResult<Archive[]>>(
+    'https://cdn.eleadmin.com/20200610/archive.json',
+    { params }
+  );
+  if (res.data.code === 0 && res.data.data) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
diff --git a/src/api/example/document/model/index.ts b/src/api/example/document/model/index.ts
new file mode 100644
index 0000000..f9847b5
--- /dev/null
+++ b/src/api/example/document/model/index.ts
@@ -0,0 +1,71 @@
+import { PageParam } from '@/api';
+
+/**
+ * 案卷
+ */
+export interface Piece {
+  // 案卷id
+  id?: number;
+  // 案卷题名
+  title?: string;
+  // 案卷档号
+  piece_no?: string;
+  // 密级
+  secret?: string;
+  // 存放位置
+  location?: string;
+  // 案卷类型
+  type?: string;
+  // 保管期限
+  retention?: string;
+  // 载体类型
+  carrier?: string;
+  // 归档年度
+  year?: string;
+  // 件数
+  amount?: number;
+}
+
+/**
+ * 案卷查询参数
+ */
+export interface PieceParam extends PageParam {
+  title?: string;
+  piece_no?: string;
+}
+
+/**
+ * 文档
+ */
+export interface Archive {
+  // 文件题名
+  title?: string;
+  // 案卷档号
+  piece_no?: string;
+  // 文件档号
+  archive_no?: string;
+  // 密级
+  secret?: string;
+  // 存放位置
+  location?: string;
+  // 文件类型
+  type?: string;
+  // 保管期限
+  retention?: string;
+  // 载体类型
+  carrier?: string;
+  // 归档年度
+  year?: string;
+  // 排序号
+  sort_number?: number;
+}
+
+/**
+ * 文档查询参数
+ */
+export interface ArchiveParam {
+  title?: string;
+  piece_no?: string;
+  archive_no?: string;
+  piece_no_in?: (string | undefined)[];
+}
diff --git a/src/api/example/table/index.ts b/src/api/example/table/index.ts
new file mode 100644
index 0000000..cead949
--- /dev/null
+++ b/src/api/example/table/index.ts
@@ -0,0 +1,13 @@
+import request from '@/utils/request';
+import type { ApiResult, PageResult } from '@/api';
+import type { UserScore } from './model';
+
+export async function pageUserScores() {
+  const res = await request.get<ApiResult<PageResult<UserScore>>>(
+    'https://cdn.eleadmin.com/20200610/example-table-merge.json'
+  );
+  if (res.data.code === 0 && res.data.data) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
diff --git a/src/api/example/table/model/index.ts b/src/api/example/table/model/index.ts
new file mode 100644
index 0000000..c497242
--- /dev/null
+++ b/src/api/example/table/model/index.ts
@@ -0,0 +1,7 @@
+export interface UserScore {
+  id: number;
+  userName: string;
+  courseName: string;
+  score: number;
+  userNameRowSpan: number;
+}
diff --git a/src/api/form/advanced/index.ts b/src/api/form/advanced/index.ts
new file mode 100644
index 0000000..138c89f
--- /dev/null
+++ b/src/api/form/advanced/index.ts
@@ -0,0 +1,28 @@
+import type { UserItem } from './model';
+
+/**
+ * 获取数据
+ */
+export async function queryList() {
+  const data: UserItem[] = [
+    {
+      key: '1',
+      number: '00001',
+      name: 'John Brown',
+      department: '研发部'
+    },
+    {
+      key: '2',
+      number: '00002',
+      name: 'Jim Green',
+      department: '产品部'
+    },
+    {
+      key: '3',
+      number: '00003',
+      name: 'Joe Black',
+      department: '产品部'
+    }
+  ];
+  return data;
+}
diff --git a/src/api/form/advanced/model/index.ts b/src/api/form/advanced/model/index.ts
new file mode 100644
index 0000000..fcfabad
--- /dev/null
+++ b/src/api/form/advanced/model/index.ts
@@ -0,0 +1,7 @@
+export interface UserItem {
+  key: string;
+  isEdit?: boolean;
+  number?: string;
+  name?: string;
+  department?: string;
+}
diff --git a/src/api/index.ts b/src/api/index.ts
new file mode 100644
index 0000000..9cbda77
--- /dev/null
+++ b/src/api/index.ts
@@ -0,0 +1,35 @@
+/**
+ * 接口统一返回结果
+ */
+export interface ApiResult<T> {
+  // 状态码
+  code: number;
+  // 状态信息
+  message?: string;
+  // 返回数据
+  data?: T;
+}
+
+/**
+ * 分页查询统一结果
+ */
+export interface PageResult<T> {
+  // 返回数据
+  list: T[];
+  // 总数量
+  count: number;
+}
+
+/**
+ * 分页查询基本参数
+ */
+export interface PageParam {
+  // 第几页
+  page?: number;
+  // 每页多少条
+  limit?: number;
+  // 排序字段
+  sort?: string;
+  // 排序方式, asc升序, desc降序
+  order?: string;
+}
diff --git a/src/api/layout/index.ts b/src/api/layout/index.ts
new file mode 100644
index 0000000..024a145
--- /dev/null
+++ b/src/api/layout/index.ts
@@ -0,0 +1,120 @@
+import request from '@/utils/request';
+import type { ApiResult } from '@/api';
+import type { User } from '@/api/system/user/model';
+import type { UpdatePasswordParam, NoticeResult } from './model';
+
+/**
+ * 获取当前登录的用户信息、菜单、权限、角色
+ */
+export async function getUserInfo(): Promise<User> {
+  const res = await request.get<ApiResult<User>>('/auth/user');
+  if (res.data.code === 0 && res.data.data) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 修改当前登录的用户密码
+ */
+export async function updatePassword(
+  data: UpdatePasswordParam
+): Promise<string> {
+  const res = await request.put<ApiResult<unknown>>('/auth/password', data);
+  if (res.data.code === 0) {
+    return res.data.message ?? '修改成功';
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 查询未读通知
+ */
+export async function getUnreadNotice(): Promise<NoticeResult> {
+  return {
+    notice: [
+      {
+        color: '#60B2FC',
+        icon: 'NotificationFilled',
+        title: '你收到了一封14份新周报',
+        time: '2020-07-27 18:30:18'
+      },
+      {
+        color: '#F5686F',
+        icon: 'PushpinFilled',
+        title: '许经理同意了你的请假申请',
+        time: '2020-07-27 09:08:36'
+      },
+      {
+        color: '#7CD734',
+        icon: 'VideoCameraFilled',
+        title: '陈总邀请你参加视频会议',
+        time: '2020-07-26 18:30:01'
+      },
+      {
+        color: '#FAAD14',
+        icon: 'CarryOutFilled',
+        title: '你推荐的刘诗雨已通过第三轮面试',
+        time: '2020-07-25 16:38:46'
+      },
+      {
+        color: '#2BCACD',
+        icon: 'BellFilled',
+        title: '你的6月加班奖金已发放',
+        time: '2020-07-25 11:03:31'
+      }
+    ],
+    letter: [
+      {
+        avatar:
+          'https://cdn.eleadmin.com/20200609/c184eef391ae48dba87e3057e70238fb.jpg',
+        title: 'SunSmile 评论了你的日志',
+        content: '写的不错, 以后多多向你学习~',
+        time: '2020-07-27 18:30:18'
+      },
+      {
+        avatar:
+          'https://cdn.eleadmin.com/20200609/948344a2a77c47a7a7b332fe12ff749a.jpg',
+        title: '刘诗雨 点赞了你的日志',
+        content: '写的不错, 以后多多向你学习~',
+        time: '2020-07-27 09:08:36'
+      },
+      {
+        avatar:
+          'https://cdn.eleadmin.com/20200609/2d98970a51b34b6b859339c96b240dcd.jpg',
+        title: '酷酷的大叔 评论了你的周报',
+        content: '写的不错, 以后多多向你学习~',
+        time: '2020-07-26 18:30:01'
+      },
+      {
+        avatar:
+          'https://cdn.eleadmin.com/20200609/f6bc05af944a4f738b54128717952107.jpg',
+        title: 'Jasmine 点赞了你的周报',
+        content: '写的不错, 以后多多向你学习~',
+        time: '2020-07-25 11:03:31'
+      }
+    ],
+    todo: [
+      {
+        status: 0,
+        title: '刘诗雨的请假审批',
+        description: '刘诗雨在 07-27 18:30 提交的请假申请'
+      },
+      {
+        status: 1,
+        title: '第三方代码紧急变更',
+        description: '需要在 2020-07-27 之前完成'
+      },
+      {
+        status: 2,
+        title: '信息安全考试',
+        description: '需要在 2020-07-26 18:30 前完成'
+      },
+      {
+        status: 2,
+        title: 'EleAdmin发布新版本',
+        description: '需要在 2020-07-25 11:03 前完成'
+      }
+    ]
+  };
+}
diff --git a/src/api/layout/model/index.ts b/src/api/layout/model/index.ts
new file mode 100644
index 0000000..c0bdbf8
--- /dev/null
+++ b/src/api/layout/model/index.ts
@@ -0,0 +1,58 @@
+/**
+ * 修改密码参数
+ */
+export interface UpdatePasswordParam {
+  // 新密码
+  password: string;
+  // 原始密码
+  oldPassword: string;
+}
+
+/**
+ * 通知数据格式
+ */
+export interface NoticeModel {
+  // 图标颜色
+  color?: string;
+  // 图标
+  icon?: string;
+  // 标题
+  title?: string;
+  // 时间
+  time?: string;
+}
+
+/**
+ * 私信数据格式
+ */
+export interface LetterModel {
+  // 头像
+  avatar?: string;
+  // 标题
+  title?: string;
+  // 内容
+  content?: string;
+  // 时间
+  time?: string;
+}
+
+/**
+ * 代办数据格式
+ */
+export interface TodoModel {
+  // 状态
+  status?: number;
+  // 标题
+  title?: string;
+  // 描述
+  description?: string;
+}
+
+/**
+ * 查询未读通知返回结果
+ */
+export interface NoticeResult {
+  notice: NoticeModel[];
+  letter: LetterModel[];
+  todo: TodoModel[];
+}
diff --git a/src/api/login/index.ts b/src/api/login/index.ts
new file mode 100644
index 0000000..079a4f8
--- /dev/null
+++ b/src/api/login/index.ts
@@ -0,0 +1,28 @@
+import request from '@/utils/request';
+import { setToken } from '@/utils/token-util';
+import type { ApiResult } from '@/api';
+import type { LoginParam, LoginResult, CaptchaResult } from './model';
+
+/**
+ * 登录
+ */
+export async function login(data: LoginParam) {
+  data.tenantId = 2; // 租户id
+  const res = await request.post<ApiResult<LoginResult>>('/login', data);
+  if (res.data.code === 0) {
+    setToken(res.data.data?.access_token, data.remember);
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 获取验证码
+ */
+export async function getCaptcha() {
+  const res = await request.get<ApiResult<CaptchaResult>>('/captcha');
+  if (res.data.code === 0 && res.data.data) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
diff --git a/src/api/login/model/index.ts b/src/api/login/model/index.ts
new file mode 100644
index 0000000..521f562
--- /dev/null
+++ b/src/api/login/model/index.ts
@@ -0,0 +1,36 @@
+import type { User } from '../../system/user/model';
+/**
+ * 登录参数
+ */
+export interface LoginParam {
+  // 账号
+  username?: string;
+  // 密码
+  password?: string;
+  // 租户id
+  tenantId?: number;
+  // 是否记住密码
+  remember?: boolean;
+}
+
+/**
+ * 登录返回结果
+ */
+export interface LoginResult {
+  // token
+  access_token?: string;
+  // 用户信息
+  user?: User;
+}
+
+/**
+ * 图形验证码返回结果
+ */
+export interface CaptchaResult {
+  // 图形验证码base64数据
+  base64: string;
+  // 验证码文本
+  text: string;
+
+  key: string;
+}
diff --git a/src/api/meeting/index.ts b/src/api/meeting/index.ts
new file mode 100644
index 0000000..425a247
--- /dev/null
+++ b/src/api/meeting/index.ts
@@ -0,0 +1,84 @@
+import request from '@/utils/request';
+import type { ApiResult, PageResult } from '@/api';
+import type { Meeting, User, MeetingParam, UserParam } from './model';
+
+/**
+ * 分页查询角色
+ */
+export async function pageMeeting(params: MeetingParam) {
+  const res = await request.get<ApiResult<PageResult<Meeting>>>(
+    '/sign/meeting/page',
+    { params }
+  );
+  if (res.data.code === 0) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 查询用户列表
+ */
+export async function listUsers(params?: UserParam) {
+  const res = await request.get<ApiResult<User[]>>('/sign/users', {
+    params
+  });
+  if (res.data.code === 0 && res.data.data) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 添加
+ */
+export async function addMeeting(data: Meeting) {
+  const res = await request.post<ApiResult<unknown>>('/sign/meeting', data);
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 修改
+ */
+export async function updateMeeting(data: Meeting) {
+  const res = await request.put<ApiResult<unknown>>('/sign/meeting', data);
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 删除
+ */
+export async function removeMeeting(id?: number) {
+  const res = await request.delete<ApiResult<unknown>>('/sign/meeting/' + id);
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 批量删除角色
+ */
+export async function removeMeetingBatch(data: (number | undefined)[]) {
+  const res = await request.delete<ApiResult<unknown>>('/sign/meeting/batch', {
+    data
+  });
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+export async function updateSignUser(data: User) {
+  const res = await request.put<ApiResult<unknown>>('/sign/userSeat', data);
+  if (res.data.code === 0) {
+    return res.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
diff --git a/src/api/meeting/model/index.ts b/src/api/meeting/model/index.ts
new file mode 100644
index 0000000..1a0e3a8
--- /dev/null
+++ b/src/api/meeting/model/index.ts
@@ -0,0 +1,48 @@
+import type { PageParam } from '@/api';
+
+/**
+ * 会议
+ */
+export interface Meeting {
+  id?: number;
+  title?: string;
+  room?: string;
+  mode?: number;
+  image?: string;
+  location?: string;
+  meeting_time?: string;
+  entry_time?: [string, string];
+  sign_time?: [string, string];
+  status?: number;
+  content?: string;
+  // 创建时间
+  createTime?: string;
+}
+
+export interface User {
+  meetingId?: number;
+  name?: string;
+  company?: string;
+  position?: string;
+  phone?: string;
+  isSign?: string;
+  entry_time?: string;
+  sign_time?: string;
+  openid?: string;
+  row?: number;
+  column?: number;
+}
+/**
+ * 角色搜索条件
+ */
+export interface MeetingParam extends PageParam {
+  title?: string;
+  room?: string;
+  content?: string;
+}
+
+export interface UserParam extends PageParam {
+  meetingId?: string | number;
+  name?: string;
+  company?: string;
+}
diff --git a/src/api/setting/index.ts b/src/api/setting/index.ts
new file mode 100644
index 0000000..b526ec5
--- /dev/null
+++ b/src/api/setting/index.ts
@@ -0,0 +1,51 @@
+import request from '@/utils/request';
+import { ApiResult } from '@/api';
+
+/**
+ * 获取回显数据
+ */
+export async function getConfig() {
+  const res = await request.get<ApiResult<any>>('/setting/get');
+  if (res.data.code === 0 && res.data.data) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 提交表单
+ * @param data
+ */
+export async function submitForm(data: any) {
+  const res = await request.post<ApiResult<unknown>>('/setting/update', data);
+  if (res.data.code === 0) {
+    return res.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 上传资料
+ * @param data
+ */
+export async function upload(data: any) {
+  const res = await request.post<ApiResult<any>>('/setting/upload', data);
+  if (res.data.code === 0) {
+    return res.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 操作
+ * @param field
+ */
+export async function clearData(field) {
+  const res = await request.get<ApiResult<any>>('clear-cache', {
+    params: { field }
+  });
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
diff --git a/src/api/setting/model/index.ts b/src/api/setting/model/index.ts
new file mode 100644
index 0000000..cdf1f01
--- /dev/null
+++ b/src/api/setting/model/index.ts
@@ -0,0 +1,25 @@
+export interface SiteForm {
+  type?: string;
+  urlPre?: string;
+  url?: string;
+  icon?: string;
+  logo?: string;
+  siteName?: string;
+  keywords?: string;
+  description?: string;
+  icp?: string;
+  beian?: string;
+  key?: string
+}
+export interface FileForm {
+  type?: string;
+  file_path?: string;
+  domain?: string;
+  bucket?: string;
+  access_key?: string;
+  secret_key?: string;
+}
+export interface WeChatForm {
+  appid?: string;
+  appsecret?: string;
+}
diff --git a/src/api/system/dictionary-data/index.ts b/src/api/system/dictionary-data/index.ts
new file mode 100644
index 0000000..30a411c
--- /dev/null
+++ b/src/api/system/dictionary-data/index.ts
@@ -0,0 +1,86 @@
+import request from '@/utils/request';
+import type { ApiResult, PageResult } from '@/api';
+import type { DictionaryData, DictionaryDataParam } from './model';
+
+/**
+ * 分页查询字典数据
+ */
+export async function pageDictionaryData(params: DictionaryDataParam) {
+  const res = await request.get<ApiResult<PageResult<DictionaryData>>>(
+    '/system/dictionary-data/page',
+    { params }
+  );
+  if (res.data.code === 0) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 查询字典数据列表
+ */
+export async function listDictionaryData(params: DictionaryDataParam) {
+  const res = await request.get<ApiResult<DictionaryData[]>>(
+    '/system/dictionary-data',
+    { params }
+  );
+  if (res.data.code === 0 && res.data.data) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 添加字典数据
+ */
+export async function addDictionaryData(data: DictionaryData) {
+  const res = await request.post<ApiResult<unknown>>(
+    '/system/dictionary-data',
+    data
+  );
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 修改字典数据
+ */
+export async function updateDictionaryData(data: DictionaryData) {
+  const res = await request.put<ApiResult<unknown>>(
+    '/system/dictionary-data',
+    data
+  );
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 删除字典数据
+ */
+export async function removeDictionaryData(id?: number) {
+  const res = await request.delete<ApiResult<unknown>>(
+    '/system/dictionary-data/' + id
+  );
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 批量删除字典数据
+ */
+export async function removeDictionaryDataBatch(data: (number | undefined)[]) {
+  const res = await request.delete<ApiResult<unknown>>(
+    '/system/dictionary-data/batch',
+    { data }
+  );
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
diff --git a/src/api/system/dictionary-data/model/index.ts b/src/api/system/dictionary-data/model/index.ts
new file mode 100644
index 0000000..e1301bd
--- /dev/null
+++ b/src/api/system/dictionary-data/model/index.ts
@@ -0,0 +1,33 @@
+import { PageParam } from '@/api';
+
+/**
+ * 字典数据
+ */
+export interface DictionaryData {
+  // 字典数据id
+  dictDataId?: number;
+  // 字典id
+  dictId?: number;
+  // 字典数据标识
+  dictDataCode?: string;
+  // 字典数据名称
+  dictDataName?: string;
+  // 排序号
+  sortNumber?: string;
+  // 备注
+  comments?: string;
+  // 创建时间
+  createTime?: string;
+}
+
+/**
+ * 字典数据搜索条件
+ */
+export interface DictionaryDataParam extends PageParam {
+  // 关键字
+  keywords?: string;
+  // 字典标识
+  dictCode?: string;
+  // 字典id
+  dictId?: number;
+}
diff --git a/src/api/system/dictionary/index.ts b/src/api/system/dictionary/index.ts
new file mode 100644
index 0000000..17daa8e
--- /dev/null
+++ b/src/api/system/dictionary/index.ts
@@ -0,0 +1,54 @@
+import request from '@/utils/request';
+import type { ApiResult } from '@/api';
+import type { Dictionary, DictionaryParam } from './model';
+
+/**
+ * 查询字典列表
+ */
+export async function listDictionaries(params?: DictionaryParam) {
+  const res = await request.get<ApiResult<Dictionary[]>>('/system/dictionary', {
+    params
+  });
+  if (res.data.code === 0) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 添加字典
+ */
+export async function addDictionary(data: Dictionary) {
+  const res = await request.post<ApiResult<unknown>>(
+    '/system/dictionary',
+    data
+  );
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 修改字典
+ */
+export async function updateDictionary(data: Dictionary) {
+  const res = await request.put<ApiResult<unknown>>('/system/dictionary', data);
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 删除字典
+ */
+export async function removeDictionary(id?: number) {
+  const res = await request.delete<ApiResult<unknown>>(
+    '/system/dictionary/' + id
+  );
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
diff --git a/src/api/system/dictionary/model/index.ts b/src/api/system/dictionary/model/index.ts
new file mode 100644
index 0000000..2b30009
--- /dev/null
+++ b/src/api/system/dictionary/model/index.ts
@@ -0,0 +1,25 @@
+/**
+ * 字典
+ */
+export interface Dictionary {
+  // 字典id
+  dictId?: number;
+  // 字典标识
+  dictCode?: string;
+  // 字典名称
+  dictName?: string;
+  // 排序号
+  sortNumber?: number;
+  // 备注
+  comments?: string;
+  // 创建时间
+  createTime?: string;
+}
+
+/**
+ * 字典搜索条件
+ */
+export interface DictionaryParam {
+  dictCode?: string;
+  dictName?: string;
+}
diff --git a/src/api/system/file/index.ts b/src/api/system/file/index.ts
new file mode 100644
index 0000000..d624cf9
--- /dev/null
+++ b/src/api/system/file/index.ts
@@ -0,0 +1,78 @@
+import request from '@/utils/request';
+import type { ApiResult, PageResult } from '@/api';
+import type { FileRecord, FileRecordParam } from './model';
+
+/**
+ * 上传文件
+ */
+export async function uploadFile(file: File) {
+  const formData = new FormData();
+  formData.append('file', file);
+  const res = await request.post<ApiResult<FileRecord>>(
+    '/file/upload',
+    formData
+  );
+  if (res.data.code === 0 && res.data.data) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 上传 base64 文件
+ * @param base64 文件数据
+ * @param fileName 文件名称
+ */
+export async function uploadBase64File(base64: string, fileName?: string) {
+  const formData = new FormData();
+  formData.append('base64', base64);
+  if (fileName) {
+    formData.append('fileName', fileName);
+  }
+  const res = await request.post<ApiResult<FileRecord>>(
+    '/file/upload/base64',
+    formData
+  );
+  if (res.data.code === 0 && res.data.data) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 分页查询文件上传记录
+ */
+export async function pageFiles(params: FileRecordParam) {
+  const res = await request.get<ApiResult<PageResult<FileRecord>>>(
+    '/file/page',
+    { params }
+  );
+  if (res.data.code === 0) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 删除文件
+ */
+export async function removeFile(id?: number) {
+  const res = await request.delete<ApiResult<unknown>>('/file/remove/' + id);
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 批量删除文件
+ */
+export async function removeFiles(data: (number | undefined)[]) {
+  const res = await request.delete<ApiResult<unknown>>('/file/remove/batch', {
+    data
+  });
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
diff --git a/src/api/system/file/model/index.ts b/src/api/system/file/model/index.ts
new file mode 100644
index 0000000..32f4eb0
--- /dev/null
+++ b/src/api/system/file/model/index.ts
@@ -0,0 +1,40 @@
+import { PageParam } from '@/api';
+
+/**
+ * 文件上传记录
+ */
+export interface FileRecord {
+  // id
+  id: number;
+  // 文件名称
+  name?: string;
+  // 文件存储路径
+  path?: string;
+  // 文件大小
+  length?: number;
+  // 文件类型
+  contentType?: string;
+  // 上传人id
+  createUserId?: number;
+  // 上传时间
+  createTime?: string;
+  // 文件访问地址
+  url?: string;
+  // 文件缩略图访问地址
+  thumbnail?: string;
+  // 文件下载地址
+  downloadUrl?: string;
+  // 上传人账号
+  createUsername?: string;
+  // 上传人名称
+  createNickname?: string;
+}
+
+/**
+ * 文件上传记录查询参数
+ */
+export interface FileRecordParam extends PageParam {
+  name?: string;
+  path?: string;
+  createNickname?: string;
+}
diff --git a/src/api/system/login-record/index.ts b/src/api/system/login-record/index.ts
new file mode 100644
index 0000000..ca76fd7
--- /dev/null
+++ b/src/api/system/login-record/index.ts
@@ -0,0 +1,31 @@
+import request from '@/utils/request';
+import type { ApiResult, PageResult } from '@/api';
+import type { LoginRecord, LoginRecordParam } from './model';
+
+/**
+ * 分页查询登录日志
+ */
+export async function pageLoginRecords(params: LoginRecordParam) {
+  const res = await request.get<ApiResult<PageResult<LoginRecord>>>(
+    '/system/login-record/page',
+    { params }
+  );
+  if (res.data.code === 0) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 查询登录日志列表
+ */
+export async function listLoginRecords(params?: LoginRecordParam) {
+  const res = await request.get<ApiResult<LoginRecord[]>>(
+    '/system/login-record',
+    { params }
+  );
+  if (res.data.code === 0 && res.data.data) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
diff --git a/src/api/system/login-record/model/index.ts b/src/api/system/login-record/model/index.ts
new file mode 100644
index 0000000..0836eb5
--- /dev/null
+++ b/src/api/system/login-record/model/index.ts
@@ -0,0 +1,38 @@
+import { PageParam } from '@/api';
+
+/**
+ * 登录日志
+ */
+export interface LoginRecord {
+  // 登录日志id
+  id: number;
+  // 用户账号
+  username: string;
+  // 操作系统
+  os: string;
+  // 设备名称
+  device: string;
+  // 浏览器类型
+  browser: string;
+  // ip地址
+  ip: string;
+  // 操作类型, 0登录成功, 1登录失败, 2退出登录, 3续签token
+  loginType: number;
+  // 备注
+  comments: string;
+  // 操作时间
+  createTime: string;
+  // 用户昵称
+  nickname: string;
+}
+
+/**
+ * 登录日志搜索条件
+ */
+export interface LoginRecordParam extends PageParam {
+  username?: string;
+  nickname?: string;
+  createTimeStart?: string;
+  createTimeEnd?: string;
+  loginType?: number;
+}
diff --git a/src/api/system/menu/index.ts b/src/api/system/menu/index.ts
new file mode 100644
index 0000000..6772e15
--- /dev/null
+++ b/src/api/system/menu/index.ts
@@ -0,0 +1,49 @@
+import request from '@/utils/request';
+import type { ApiResult } from '@/api';
+import type { Menu, MenuParam } from './model';
+
+/**
+ * 查询菜单列表
+ */
+export async function listMenus(params: MenuParam) {
+  const res = await request.get<ApiResult<Menu[]>>('/system/menu', {
+    params
+  });
+  if (res.data.code === 0) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 添加菜单
+ */
+export async function addMenu(data: Menu) {
+  const res = await request.post<ApiResult<unknown>>('/system/menu', data);
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 修改菜单
+ */
+export async function updateMenu(data: Menu) {
+  const res = await request.put<ApiResult<unknown>>('/system/menu', data);
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 删除菜单
+ */
+export async function removeMenu(id?: number) {
+  const res = await request.delete<ApiResult<unknown>>('/system/menu/' + id);
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
diff --git a/src/api/system/menu/model/index.ts b/src/api/system/menu/model/index.ts
new file mode 100644
index 0000000..dc43164
--- /dev/null
+++ b/src/api/system/menu/model/index.ts
@@ -0,0 +1,51 @@
+/**
+ * 菜单
+ */
+export interface Menu {
+  // 菜单id
+  menuId?: number;
+  // 上级id, 0是顶级
+  parentId?: number;
+  // 菜单名称
+  title: string;
+  // 菜单路由地址
+  path: string;
+  // 菜单组件地址
+  component: string;
+  // 菜单类型, 0菜单, 1按钮
+  menuType?: number;
+  // 排序号
+  sortNumber?: number;
+  // 权限标识
+  authority?: string;
+  // 菜单图标
+  icon?: string;
+  // 是否隐藏, 0否,1是(仅注册路由不显示左侧菜单)
+  hide?: number;
+  // 路由元信息
+  meta?: string;
+  // 创建时间
+  createTime?: string;
+  // 子菜单
+  children?: Menu[];
+  // 权限树回显选中状态, 0未选中, 1选中
+  checked?: boolean;
+  //
+  key?: number;
+  //
+  value?: number;
+  //
+  parentIds?: number[];
+  //
+  openType?: number;
+}
+
+/**
+ * 菜单搜索参数
+ */
+export interface MenuParam {
+  title?: string;
+  path?: string;
+  authority?: string;
+  parentId?: number;
+}
diff --git a/src/api/system/operation-record/index.ts b/src/api/system/operation-record/index.ts
new file mode 100644
index 0000000..7fb288e
--- /dev/null
+++ b/src/api/system/operation-record/index.ts
@@ -0,0 +1,31 @@
+import request from '@/utils/request';
+import type { ApiResult, PageResult } from '@/api';
+import type { OperationRecord, OperationRecordParam } from './model';
+
+/**
+ * 分页查询操作日志
+ */
+export async function pageOperationRecords(params: OperationRecordParam) {
+  const res = await request.get<ApiResult<PageResult<OperationRecord>>>(
+    '/system/operation-record/page',
+    { params }
+  );
+  if (res.data.code === 0) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 查询操作日志列表
+ */
+export async function listOperationRecords(params?: OperationRecordParam) {
+  const res = await request.get<ApiResult<OperationRecord[]>>(
+    '/system/operation-record',
+    { params }
+  );
+  if (res.data.code === 0 && res.data.data) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
diff --git a/src/api/system/operation-record/model/index.ts b/src/api/system/operation-record/model/index.ts
new file mode 100644
index 0000000..fc1425e
--- /dev/null
+++ b/src/api/system/operation-record/model/index.ts
@@ -0,0 +1,56 @@
+import { PageParam } from '@/api';
+
+/**
+ * 操作日志
+ */
+export interface OperationRecord {
+  // 操作日志id
+  id?: number;
+  // 用户id
+  userId?: number;
+  // 操作模块
+  module: string;
+  // 操作功能
+  description: string;
+  // 请求地址
+  url: string;
+  // 请求方式
+  requestMethod: string;
+  // 调用方法
+  method: string;
+  // 请求参数
+  params: string;
+  // 返回结果
+  result: string;
+  // 异常信息
+  error: string;
+  // 消耗时间, 单位毫秒
+  spendTime: number;
+  // 操作系统
+  os: string;
+  // 设备名称
+  device: string;
+  // 浏览器类型
+  browser: string;
+  // ip地址
+  ip: string;
+  // 状态, 0成功, 1异常
+  status: number;
+  // 操作时间
+  createTime: string;
+  // 用户昵称
+  nickname: string;
+  // 用户账号
+  username: string;
+}
+
+/**
+ * 操作日志搜索条件
+ */
+export interface OperationRecordParam extends PageParam {
+  username?: string;
+  module?: string;
+  createTimeStart?: string;
+  createTimeEnd?: string;
+  status?: number;
+}
diff --git a/src/api/system/organization/index.ts b/src/api/system/organization/index.ts
new file mode 100644
index 0000000..bf374b7
--- /dev/null
+++ b/src/api/system/organization/index.ts
@@ -0,0 +1,72 @@
+import request from '@/utils/request';
+import type { ApiResult, PageResult } from '@/api';
+import type { Organization, OrganizationParam } from './model';
+
+/**
+ * 分页查询机构
+ */
+export async function pageOrganizations(params: OrganizationParam) {
+  const res = await request.get<ApiResult<PageResult<Organization>>>(
+    '/system/organization/page',
+    { params }
+  );
+  if (res.data.code === 0) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 查询机构列表
+ */
+export async function listOrganizations(params?: OrganizationParam) {
+  const res = await request.get<ApiResult<Organization[]>>(
+    '/system/organization',
+    { params }
+  );
+  if (res.data.code === 0 && res.data.data) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 添加机构
+ */
+export async function addOrganization(data: Organization) {
+  const res = await request.post<ApiResult<unknown>>(
+    '/system/organization',
+    data
+  );
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 修改机构
+ */
+export async function updateOrganization(data: Organization) {
+  const res = await request.put<ApiResult<unknown>>(
+    '/system/organization',
+    data
+  );
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 删除机构
+ */
+export async function removeOrganization(id?: number) {
+  const res = await request.delete<ApiResult<unknown>>(
+    '/system/organization/' + id
+  );
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
diff --git a/src/api/system/organization/model/index.ts b/src/api/system/organization/model/index.ts
new file mode 100644
index 0000000..8a5a72a
--- /dev/null
+++ b/src/api/system/organization/model/index.ts
@@ -0,0 +1,40 @@
+import { PageParam } from '@/api';
+
+/**
+ * 机构
+ */
+export interface Organization {
+  // 机构id
+  organizationId?: number;
+  // 上级id, 0是顶级
+  parentId?: number;
+  // 机构名称
+  organizationName?: string;
+  // 机构全称
+  organizationFullName?: string;
+  // 机构代码
+  organizationCode?: string;
+  // 机构类型(字典)
+  organizationType?: string;
+  // 排序号
+  sortNumber?: number;
+  // 备注
+  comments?: string;
+  // 创建时间
+  createTime?: string;
+  // 机构类型名称
+  organizationTypeName?: string;
+  //
+  key?: number;
+  //
+  value?: number;
+  //
+  title?: string;
+}
+
+/**
+ * 机构搜索条件
+ */
+export interface OrganizationParam extends PageParam {
+  organizationName?: string;
+}
diff --git a/src/api/system/role/index.ts b/src/api/system/role/index.ts
new file mode 100644
index 0000000..9a89031
--- /dev/null
+++ b/src/api/system/role/index.ts
@@ -0,0 +1,104 @@
+import request from '@/utils/request';
+import type { ApiResult, PageResult } from '@/api';
+import type { Role, RoleParam } from './model';
+import type { Menu } from '../menu/model';
+
+/**
+ * 分页查询角色
+ */
+export async function pageRoles(params: RoleParam) {
+  const res = await request.get<ApiResult<PageResult<Role>>>(
+    '/system/role/page',
+    { params }
+  );
+  if (res.data.code === 0) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 查询角色列表
+ */
+export async function listRoles(params?: RoleParam) {
+  const res = await request.get<ApiResult<Role[]>>('/system/role', {
+    params
+  });
+  if (res.data.code === 0 && res.data.data) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 添加角色
+ */
+export async function addRole(data: Role) {
+  const res = await request.post<ApiResult<unknown>>('/system/role', data);
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 修改角色
+ */
+export async function updateRole(data: Role) {
+  const res = await request.put<ApiResult<unknown>>('/system/role', data);
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 删除角色
+ */
+export async function removeRole(id?: number) {
+  const res = await request.delete<ApiResult<unknown>>('/system/role/' + id);
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 批量删除角色
+ */
+export async function removeRoles(data: (number | undefined)[]) {
+  const res = await request.delete<ApiResult<unknown>>('/system/role/batch', {
+    data
+  });
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 获取角色分配的菜单
+ */
+export async function listRoleMenus(roleId?: number) {
+  const res = await request.get<ApiResult<Menu[]>>(
+    '/system/role-menu/' + roleId
+  );
+  if (res.data.code === 0) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 修改角色菜单
+ */
+export async function updateRoleMenus(roleId?: number, data?: number[]) {
+  const res = await request.put<ApiResult<unknown>>(
+    '/system/role-menu/' + roleId,
+    data
+  );
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
diff --git a/src/api/system/role/model/index.ts b/src/api/system/role/model/index.ts
new file mode 100644
index 0000000..1a81787
--- /dev/null
+++ b/src/api/system/role/model/index.ts
@@ -0,0 +1,26 @@
+import type { PageParam } from '@/api';
+
+/**
+ * 角色
+ */
+export interface Role {
+  // 角色id
+  roleId?: number;
+  // 角色标识
+  roleCode?: string;
+  // 角色名称
+  roleName?: string;
+  // 备注
+  comments?: string;
+  // 创建时间
+  createTime?: string;
+}
+
+/**
+ * 角色搜索条件
+ */
+export interface RoleParam extends PageParam {
+  roleName?: string;
+  roleCode?: string;
+  comments?: string;
+}
diff --git a/src/api/system/user-file/index.ts b/src/api/system/user-file/index.ts
new file mode 100644
index 0000000..1187f92
--- /dev/null
+++ b/src/api/system/user-file/index.ts
@@ -0,0 +1,79 @@
+import request from '@/utils/request';
+import type { ApiResult, PageResult } from '@/api';
+import type { UserFile, UserFileParam } from './model';
+
+/**
+ * 分页查询用户文件
+ */
+export async function pageUserFiles(params: UserFileParam) {
+  const res = await request.get<ApiResult<PageResult<UserFile>>>(
+    '/system/user-file/page',
+    { params }
+  );
+  if (res.data.code === 0) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 查询用户文件列表
+ */
+export async function listUserFiles(params: UserFileParam) {
+  const res = await request.get<ApiResult<UserFile[]>>('/system/user-file', {
+    params
+  });
+  if (res.data.code === 0 && res.data.data) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 添加用户文件
+ */
+export async function addUserFile(data: UserFile) {
+  const res = await request.post<ApiResult<unknown>>('/system/user-file', data);
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 修改用户文件
+ */
+export async function updateUserFile(data: UserFile) {
+  const res = await request.put<ApiResult<unknown>>('/system/user-file', data);
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 删除用户文件
+ */
+export async function removeUserFile(id?: number) {
+  const res = await request.delete<ApiResult<unknown>>(
+    '/system/user-file/' + id
+  );
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 批量删除用户文件
+ */
+export async function removeUserFiles(data: (number | undefined)[]) {
+  const res = await request.delete<ApiResult<unknown>>(
+    '/system/user-file/batch',
+    { data }
+  );
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
diff --git a/src/api/system/user-file/model/index.ts b/src/api/system/user-file/model/index.ts
new file mode 100644
index 0000000..ca51256
--- /dev/null
+++ b/src/api/system/user-file/model/index.ts
@@ -0,0 +1,39 @@
+import { PageParam } from '@/api';
+
+/**
+ * 用户文件
+ */
+export interface UserFile {
+  // id
+  id?: number;
+  // 用户id
+  userId?: number;
+  // 文件名称
+  name?: string;
+  // 是否是文件夹, 0否, 1是
+  isDirectory?: number;
+  // 上级id
+  parentId?: number;
+  // 文件存储路径
+  path?: string;
+  // 文件大小
+  length?: number;
+  // 文件类型
+  contentType?: string;
+  // 上传时间
+  createTime?: string;
+  // 文件访问地址
+  url?: string;
+  // 文件缩略图访问地址
+  thumbnail?: string;
+  // 文件下载地址
+  downloadUrl?: string;
+}
+
+/**
+ * 用户文件查询参数
+ */
+export interface UserFileParam extends PageParam {
+  name?: string;
+  parentId?: number;
+}
diff --git a/src/api/system/user/index.ts b/src/api/system/user/index.ts
new file mode 100644
index 0000000..c5f594e
--- /dev/null
+++ b/src/api/system/user/index.ts
@@ -0,0 +1,148 @@
+import request from '@/utils/request';
+import type { ApiResult, PageResult } from '@/api';
+import type { User, UserParam } from './model';
+
+/**
+ * 分页查询用户
+ */
+export async function pageUsers(params: UserParam) {
+  const res = await request.get<ApiResult<PageResult<User>>>(
+    '/system/user/page',
+    { params }
+  );
+  if (res.data.code === 0) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 查询用户列表
+ */
+export async function listUsers(params?: UserParam) {
+  const res = await request.get<ApiResult<User[]>>('/system/user', {
+    params
+  });
+  if (res.data.code === 0 && res.data.data) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 根据id查询用户
+ */
+export async function getUser(id: number) {
+  const res = await request.get<ApiResult<User>>('/system/user/' + id);
+  if (res.data.code === 0 && res.data.data) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 添加用户
+ */
+export async function addUser(data: User) {
+  const res = await request.post<ApiResult<unknown>>('/system/user', data);
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 修改用户
+ */
+export async function updateUser(data: User) {
+  const res = await request.put<ApiResult<unknown>>('/system/user', data);
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 删除用户
+ */
+export async function removeUser(id?: number) {
+  const res = await request.delete<ApiResult<unknown>>('/system/user/' + id);
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 批量删除用户
+ */
+export async function removeUsers(data: (number | undefined)[]) {
+  const res = await request.delete<ApiResult<unknown>>('/system/user/batch', {
+    data
+  });
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 修改用户状态
+ */
+export async function updateUserStatus(userId?: number, status?: number) {
+  const res = await request.put<ApiResult<unknown>>('/system/user/status', {
+    userId,
+    status
+  });
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 重置用户密码
+ */
+export async function updateUserPassword(userId?: number, password = '123456') {
+  const res = await request.put<ApiResult<unknown>>('/system/user/password', {
+    userId,
+    password
+  });
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 导入用户
+ */
+export async function importUsers(file: File) {
+  const formData = new FormData();
+  formData.append('file', file);
+  const res = await request.post<ApiResult<unknown>>(
+    '/system/user/import',
+    formData
+  );
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 检查用户是否存在
+ */
+export async function checkExistence(
+  field: string,
+  value: string,
+  id?: number
+) {
+  const res = await request.get<ApiResult<unknown>>('/system/user/existence', {
+    params: { field, value, id }
+  });
+  if (res.data.code === 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
diff --git a/src/api/system/user/model/index.ts b/src/api/system/user/model/index.ts
new file mode 100644
index 0000000..b6058cd
--- /dev/null
+++ b/src/api/system/user/model/index.ts
@@ -0,0 +1,56 @@
+import type { PageParam } from '@/api';
+import type { Role } from '../../role/model';
+import type { Menu } from '../../menu/model';
+
+/**
+ * 用户
+ */
+export interface User {
+  // 用户id
+  userId?: number;
+  // 账号
+  username?: string;
+  // 密码
+  password?: string;
+  // 昵称
+  nickname?: string;
+  // 头像
+  avatar?: string;
+  // 性别(字典)
+  sex?: string;
+  // 手机号
+  phone?: string;
+  // 邮箱
+  email?: string;
+  // 出生日期
+  birthday?: string;
+  // 个人简介
+  introduction?: string;
+  // 机构id
+  organizationId?: number;
+  // 状态, 0正常, 1冻结
+  status?: number;
+  // 性别名称
+  sexName?: string;
+  // 机构名称
+  organizationName?: string;
+  // 角色列表
+  roles?: Role[];
+  // 权限列表
+  authorities?: Menu[];
+  // 创建时间
+  createTime?: string;
+}
+
+/**
+ * 用户搜索条件
+ */
+export interface UserParam extends PageParam {
+  username?: string;
+  nickname?: string;
+  sex?: string;
+  phone?: string;
+  status?: number;
+  organizationId?: number;
+  sexName?: string;
+}
diff --git a/src/api/user/message/index.ts b/src/api/user/message/index.ts
new file mode 100644
index 0000000..5a98ea5
--- /dev/null
+++ b/src/api/user/message/index.ts
@@ -0,0 +1,229 @@
+import type { PageResult } from '@/api';
+import type { Message } from './model';
+
+/**
+ * 分页查询通知
+ */
+export async function pageNotices(_params: any) {
+  const result: PageResult<Message> = {
+    count: 10,
+    list: [
+      {
+        id: 21,
+        title: 'EleAdmin新版本发布,欢迎体验',
+        time: '2020-07-24 11:35',
+        status: 0
+      },
+      {
+        id: 22,
+        title: 'EleAdmin新版本发布,欢迎体验',
+        time: '2020-07-24 11:35',
+        status: 0
+      },
+      {
+        id: 23,
+        title: 'EleAdmin新版本发布,欢迎体验',
+        time: '2020-07-24 11:35',
+        status: 1
+      },
+      {
+        id: 24,
+        title: 'EleAdmin新版本发布,欢迎体验',
+        time: '2020-07-24 11:35',
+        status: 1
+      },
+      {
+        id: 25,
+        title: 'EleAdmin新版本发布,欢迎体验',
+        time: '2020-07-24 11:35',
+        status: 1
+      },
+      {
+        id: 26,
+        title: 'EleAdmin新版本发布,欢迎体验',
+        time: '2020-07-24 11:35',
+        status: 1
+      },
+      {
+        id: 27,
+        title: 'EleAdmin新版本发布,欢迎体验',
+        time: '2020-07-24 11:35',
+        status: 1
+      },
+      {
+        id: 28,
+        title: 'EleAdmin新版本发布,欢迎体验',
+        time: '2020-07-24 11:35',
+        status: 1
+      },
+      {
+        id: 29,
+        title: 'EleAdmin新版本发布,欢迎体验',
+        time: '2020-07-24 11:35',
+        status: 1
+      },
+      {
+        id: 30,
+        title: 'EleAdmin新版本发布,欢迎体验',
+        time: '2020-07-24 11:35',
+        status: 1
+      }
+    ]
+  };
+  return result;
+}
+
+/**
+ * 分页查询私信
+ */
+export async function pageLetters(_params: any) {
+  const result: PageResult<Message> = {
+    count: 10,
+    list: [
+      {
+        id: 11,
+        title: 'Jasmine给你发来了一条私信',
+        time: '2020-07-24 11:35',
+        status: 0
+      },
+      {
+        id: 12,
+        title: 'Jasmine给你发来了一条私信',
+        time: '2020-07-24 11:35',
+        status: 0
+      },
+      {
+        id: 13,
+        title: 'Jasmine给你发来了一条私信',
+        time: '2020-07-24 11:35',
+        status: 0
+      },
+      {
+        id: 14,
+        title: 'Jasmine给你发来了一条私信',
+        time: '2020-07-24 11:35',
+        status: 1
+      },
+      {
+        id: 15,
+        title: 'Jasmine给你发来了一条私信',
+        time: '2020-07-24 11:35',
+        status: 1
+      },
+      {
+        id: 16,
+        title: 'Jasmine给你发来了一条私信',
+        time: '2020-07-24 11:35',
+        status: 1
+      },
+      {
+        id: 17,
+        title: 'Jasmine给你发来了一条私信',
+        time: '2020-07-24 11:35',
+        status: 1
+      },
+      {
+        id: 18,
+        title: 'Jasmine给你发来了一条私信',
+        time: '2020-07-24 11:35',
+        status: 1
+      },
+      {
+        id: 19,
+        title: 'Jasmine给你发来了一条私信',
+        time: '2020-07-24 11:35',
+        status: 1
+      },
+      {
+        id: 20,
+        title: 'Jasmine给你发来了一条私信',
+        time: '2020-07-24 11:35',
+        status: 1
+      }
+    ]
+  };
+  return result;
+}
+
+/**
+ * 分页查询代办
+ */
+export async function pageTodos(_params: any) {
+  const result: PageResult<Message> = {
+    count: 10,
+    list: [
+      {
+        id: 1,
+        title: '你有两条任务待完成,不要忘了哦~',
+        time: '2020-07-24 11:35',
+        status: 0
+      },
+      {
+        id: 2,
+        title: '你有两条任务待完成,不要忘了哦~',
+        time: '2020-07-24 11:35',
+        status: 0
+      },
+      {
+        id: 3,
+        title: '你有两条任务待完成,不要忘了哦~',
+        time: '2020-07-24 11:35',
+        status: 0
+      },
+      {
+        id: 4,
+        title: '你有两条任务待完成,不要忘了哦~',
+        time: '2020-07-24 11:35',
+        status: 0
+      },
+      {
+        id: 5,
+        title: '你有两条任务待完成,不要忘了哦~',
+        time: '2020-07-24 11:35',
+        status: 1
+      },
+      {
+        id: 6,
+        title: '你有两条任务待完成,不要忘了哦~',
+        time: '2020-07-24 11:35',
+        status: 1
+      },
+      {
+        id: 7,
+        title: '你有两条任务待完成,不要忘了哦~',
+        time: '2020-07-24 11:35',
+        status: 1
+      },
+      {
+        id: 8,
+        title: '你有两条任务待完成,不要忘了哦~',
+        time: '2020-07-24 11:35',
+        status: 1
+      },
+      {
+        id: 9,
+        title: '你有两条任务待完成,不要忘了哦~',
+        time: '2020-07-24 11:35',
+        status: 1
+      },
+      {
+        id: 10,
+        title: '你有两条任务待完成,不要忘了哦~',
+        time: '2020-07-24 11:35',
+        status: 1
+      }
+    ]
+  };
+  return result;
+}
+
+/**
+ * 查询未读数量
+ */
+export async function getUnReadNum() {
+  return {
+    notice: 2,
+    letter: 3,
+    todo: 4
+  };
+}
diff --git a/src/api/user/message/model/index.ts b/src/api/user/message/model/index.ts
new file mode 100644
index 0000000..1bed997
--- /dev/null
+++ b/src/api/user/message/model/index.ts
@@ -0,0 +1,13 @@
+/**
+ * 消息
+ */
+export interface Message {
+  // 消息id
+  id?: number;
+  // 标题
+  title?: string;
+  // 时间
+  time?: string;
+  // 状态
+  status?: number;
+}
diff --git a/src/api/user/profile/index.ts b/src/api/user/profile/index.ts
new file mode 100644
index 0000000..71f84ae
--- /dev/null
+++ b/src/api/user/profile/index.ts
@@ -0,0 +1,18 @@
+import request from '@/utils/request';
+import { ApiResult } from '@/api';
+
+export async function uploadAvatar(data) {
+  const res = await request.post<ApiResult<unknown>>('/file/upload', data);
+  if (res.data.code === 0) {
+    return res.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+export async function updateUser(data) {
+  const res = await request.put<ApiResult<unknown>>('/system/user', data);
+  if (res.data.code === 0) {
+    return res.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
diff --git a/src/api/user/profile/model/index.ts b/src/api/user/profile/model/index.ts
new file mode 100644
index 0000000..c1dc66f
--- /dev/null
+++ b/src/api/user/profile/model/index.ts
@@ -0,0 +1,9 @@
+export interface ProfileForm {
+  userId?: number;
+  avatar?: string;
+  email?: string;
+  introduction?: string;
+  nickname?: string;
+  phone?: string;
+  sex?: number | string;
+}
diff --git a/src/api/wechat/index.ts b/src/api/wechat/index.ts
new file mode 100644
index 0000000..5eb77f0
--- /dev/null
+++ b/src/api/wechat/index.ts
@@ -0,0 +1,46 @@
+import request from "@/utils/request";
+
+/**
+ * 获取微信菜单
+ */
+export async function getWxMenu() {
+  const res = await request.get<any>('wechat/getWxMenu');
+  if (!res.data.errcode) {
+    return res.data;
+  }
+  return Promise.reject(new Error(res.data.errmsg));
+}
+
+/**
+ * 获取永久素材
+ */
+export async function getWxMaterial(id) {
+  const res = await request.get<any>('wechat/getMaterial', id);
+  if (!res.data.errcode) {
+    return res.data;
+  }
+  return Promise.reject(new Error(res.data.errmsg));
+}
+
+/**
+ * 永久素材列表
+ * @param params
+ */
+export async function getWxMaterialList(params) {
+  const res = await request.get<any>('wechat/getMaterialList', { params });
+  if (!res.data.errcode) {
+    return res.data;
+  }
+  return Promise.reject(new Error(res.data.errmsg));
+}
+
+/**
+ * 创建自定义菜单
+ */
+export async function addWxMenu(params) {
+  const res = await request.post<any>('wechat/addWxMenu', params);
+  if (!res.data.errcode) {
+    return res.data;
+  }
+  return Promise.reject(new Error(res.data.errmsg));
+}
diff --git a/src/api/wechat/model/index.ts b/src/api/wechat/model/index.ts
new file mode 100644
index 0000000..153c236
--- /dev/null
+++ b/src/api/wechat/model/index.ts
@@ -0,0 +1,11 @@
+export interface WxMenuForm {
+  button: any;
+  name?: string;
+  type?: string;
+  value?: string;
+  url?: string;
+  key?: string;
+  pagepath?: string;
+  sub_button?: WxMenuForm;
+}
+
diff --git a/src/assets/base.png b/src/assets/base.png
new file mode 100644
index 0000000..1aa53a0
Binary files /dev/null and b/src/assets/base.png differ
diff --git a/src/assets/bg-login.jpg b/src/assets/bg-login.jpg
new file mode 100644
index 0000000..26baa69
Binary files /dev/null and b/src/assets/bg-login.jpg differ
diff --git a/src/assets/logo.svg b/src/assets/logo.svg
new file mode 100644
index 0000000..3359d58
--- /dev/null
+++ b/src/assets/logo.svg
@@ -0,0 +1,24 @@
+<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="280" height="280" viewBox="-18.75 0 280 280">
+    <defs xmlns="http://www.w3.org/2000/svg">
+        <linearGradient x1="20%" y1="0%" x2="100%" y2="80%" id="linearGradient-1">
+            <stop stop-color="#4285EB" offset="0%"/>
+            <stop stop-color="#2EC7FF" offset="100%"/>
+        </linearGradient>
+        <linearGradient x1="60%" y1="0%" x2="50%" y2="120%" id="linearGradient-2">
+            <stop stop-color="#29CDFF" offset="0%"/>
+            <stop stop-color="#148EFF" offset="60%"/>
+            <stop stop-color="#0A60FF" offset="100%"/>
+        </linearGradient>
+        <linearGradient x1="120%" y1="60%" x2="20%" y2="40%" id="linearGradient-3">
+            <stop stop-color="#FA816E" offset="0%"/>
+            <stop stop-color="#F74A5C" offset="60%"/>
+            <stop stop-color="#F51D2C" offset="100%"/>
+        </linearGradient>
+    </defs>
+    <path d="M121.2435565 0 L242.4871131 70 L242.4871131 130 L121.2435565 200 L121.2435565 160 L207.8460969 110 L207.8460969 90 L121.2435565 40 Z"
+          fill="url(#linearGradient-1)"/>
+    <path d="M242.4871131 210 L121.2435565 280 L0 210 L0 70 L121.2435565 0 L181.8653348 35 Q 155.5544457 23.5 121.2435565 40 L34.64101615 90 L34.64101615 190 L121.2435565 240 L242.4871131 170 Z"
+          fill="url(#linearGradient-2)"/>
+    <path d="M173.2050808 170 L121.2435565 200 L69.2820323 170 L69.2820323 110 L121.2435565 80 L155.8845727 100 L103.9230485 130 L103.9230485 150 L121.2435565 160 L173.2050808 130 Z"
+          fill="url(#linearGradient-3)"/>
+</svg>
diff --git a/src/assets/menu_foot.png b/src/assets/menu_foot.png
new file mode 100644
index 0000000..4a89d4b
Binary files /dev/null and b/src/assets/menu_foot.png differ
diff --git a/src/assets/menu_head.png b/src/assets/menu_head.png
new file mode 100644
index 0000000..248cfb7
Binary files /dev/null and b/src/assets/menu_head.png differ
diff --git a/src/assets/msg_tab.png b/src/assets/msg_tab.png
new file mode 100644
index 0000000..6a18531
Binary files /dev/null and b/src/assets/msg_tab.png differ
diff --git a/src/assets/noimage.png b/src/assets/noimage.png
new file mode 100644
index 0000000..29c1c92
Binary files /dev/null and b/src/assets/noimage.png differ
diff --git a/src/components/ByteMdEditor/index.vue b/src/components/ByteMdEditor/index.vue
new file mode 100644
index 0000000..5193689
--- /dev/null
+++ b/src/components/ByteMdEditor/index.vue
@@ -0,0 +1,109 @@
+<!-- markdown 编辑器 -->
+<template>
+  <div ref="rootRef" class="ele-bytemd-wrap"></div>
+</template>
+
+<script lang="ts" setup>
+  import { onMounted, ref, watch } from 'vue';
+  import { Editor } from 'bytemd';
+  import type { BytemdPlugin, BytemdLocale, ViewerProps } from 'bytemd';
+  import 'bytemd/dist/index.min.css';
+
+  const props = withDefaults(
+    defineProps<{
+      value: string;
+      plugins?: BytemdPlugin[];
+      sanitize?: (schema: any) => any;
+      mode?: 'split' | 'tab' | 'auto';
+      previewDebounce?: number;
+      placeholder?: string;
+      editorConfig?: Record<string, any>;
+      locale?: Partial<BytemdLocale>;
+      uploadImages?: (
+        files: File[]
+      ) => Promise<Pick<any, 'url' | 'alt' | 'title'>[]>;
+      overridePreview?: (el: HTMLElement, props: ViewerProps) => void;
+      maxLength?: number;
+      height?: string;
+      fullZIndex?: number;
+    }>(),
+    {
+      fullZIndex: 999
+    }
+  );
+
+  const emit = defineEmits<{
+    (e: 'update:value', value?: string): void;
+    (e: 'change', value?: string): void;
+  }>();
+
+  const rootRef = ref<HTMLElement | null>(null);
+  const editor = ref<InstanceType<typeof Editor> | null>(null);
+
+  onMounted(() => {
+    editor.value = new Editor({
+      target: rootRef.value as HTMLElement,
+      props
+    });
+    editor.value.$on('change', (e: any) => {
+      emit('update:value', e.detail.value);
+      emit('change', e.detail.value);
+    });
+  });
+
+  watch(
+    [
+      () => props.value,
+      () => props.plugins,
+      () => props.sanitize,
+      () => props.mode,
+      () => props.previewDebounce,
+      () => props.placeholder,
+      () => props.editorConfig,
+      () => props.locale,
+      () => props.uploadImages,
+      () => props.maxLength
+    ],
+    () => {
+      const option = { ...props };
+      for (let key in option) {
+        if (typeof option[key] === 'undefined') {
+          delete option[key];
+        }
+      }
+      editor.value?.$set(option);
+    }
+  );
+</script>
+
+<style lang="less" scoped>
+  // 修改编辑器高度
+  .ele-bytemd-wrap :deep(.bytemd) {
+    height: v-bind(height);
+
+    // 修改全屏的 zIndex
+    &.bytemd-fullscreen {
+      z-index: v-bind(fullZIndex);
+    }
+
+    // 去掉默认的最大宽度限制
+    .CodeMirror .CodeMirror-lines {
+      max-width: 100%;
+    }
+
+    pre.CodeMirror-line,
+    pre.CodeMirror-line-like {
+      padding: 0 24px;
+    }
+
+    .markdown-body {
+      max-width: 100%;
+      padding: 16px 24px;
+    }
+
+    // 去掉 github 图标
+    .bytemd-toolbar-right > .bytemd-toolbar-icon:last-child {
+      display: none;
+    }
+  }
+</style>
diff --git a/src/components/ByteMdViewer/index.vue b/src/components/ByteMdViewer/index.vue
new file mode 100644
index 0000000..22e95a3
--- /dev/null
+++ b/src/components/ByteMdViewer/index.vue
@@ -0,0 +1,93 @@
+<!-- markdown 解析 -->
+<template>
+  <!-- eslint-disable vue/no-v-html -->
+  <div
+    ref="rootRef"
+    v-html="content"
+    class="markdown-body"
+    @click="handleClick"
+  >
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
+  import type { BytemdPlugin } from 'bytemd';
+  import { getProcessor } from 'bytemd';
+
+  const props = defineProps<{
+    value: string;
+    plugins?: BytemdPlugin[];
+    sanitize?: (schema: any) => any;
+  }>();
+
+  const rootRef = ref<HTMLElement | null>(null);
+  const content = ref<any | null>(null);
+  const cbs = ref<(void | (() => void))[]>([]);
+
+  const on = () => {
+    if (props.plugins && rootRef.value && content.value) {
+      cbs.value = props.plugins.map(({ viewerEffect }) => {
+        return (
+          viewerEffect &&
+          viewerEffect({
+            markdownBody: rootRef.value as HTMLElement,
+            file: content.value
+          })
+        );
+      });
+    }
+  };
+
+  const off = () => {
+    if (cbs.value) {
+      cbs.value.forEach((cb) => cb && cb());
+    }
+  };
+
+  const handleClick = (e: MouseEvent) => {
+    const $ = e.target as HTMLElement;
+    if ($.tagName !== 'A') {
+      return;
+    }
+
+    const href = $.getAttribute('href');
+    if (!href || !href.startsWith('#')) {
+      return;
+    }
+
+    const dest = rootRef.value?.querySelector('#user-content-' + href.slice(1));
+    if (dest) {
+      dest.scrollIntoView();
+    }
+  };
+
+  watch(
+    [() => props.value, () => props.plugins, () => props.sanitize],
+    () => {
+      try {
+        content.value = getProcessor({
+          plugins: props.plugins,
+          sanitize: props.sanitize
+        }).processSync(props.value);
+      } catch (e) {
+        console.error(e);
+      }
+      off();
+      nextTick(() => {
+        on();
+      });
+    },
+    {
+      immediate: true
+    }
+  );
+
+  onMounted(() => {
+    on();
+  });
+
+  onBeforeUnmount(() => {
+    off();
+  });
+</script>
diff --git a/src/components/RedirectLayout/index.ts b/src/components/RedirectLayout/index.ts
new file mode 100644
index 0000000..6c4def6
--- /dev/null
+++ b/src/components/RedirectLayout/index.ts
@@ -0,0 +1,21 @@
+/** 用于刷新的路由组件 */
+import { defineComponent, unref, h } from 'vue';
+import { useRouter } from 'vue-router';
+import { setRouteReload } from '@/utils/page-tab-util';
+
+export default defineComponent({
+  name: 'RedirectLayout',
+  setup() {
+    const { currentRoute, replace } = useRouter();
+    const { params, query } = unref(currentRoute);
+    const from = Array.isArray(params.path)
+      ? params.path.join('/')
+      : params.path;
+    const path = '/' + from;
+    setTimeout(() => {
+      setRouteReload(null);
+      replace({ path, query });
+    }, 100);
+    return () => h('div');
+  }
+});
diff --git a/src/components/RegionsSelect/index.vue b/src/components/RegionsSelect/index.vue
new file mode 100644
index 0000000..47fe28e
--- /dev/null
+++ b/src/components/RegionsSelect/index.vue
@@ -0,0 +1,127 @@
+<!-- 省市区级联选择器 -->
+<template>
+  <a-cascader
+    :value="value"
+    :options="regionsData"
+    :show-search="showSearch"
+    :placeholder="placeholder"
+    dropdown-class-name="ele-pop-wrap-higher"
+    @update:value="updateValue"
+  />
+</template>
+
+<script lang="ts" setup>
+  import { ref, watch } from 'vue';
+  import type { ValueType } from 'ant-design-vue/es/vc-cascader/Cascader';
+  import type { RegionsData } from './types';
+  import { getRegionsData } from './load-data';
+
+  const props = withDefaults(
+    defineProps<{
+      value?: string[];
+      placeholder?: string;
+      options?: RegionsData[];
+      valueField?: 'label';
+      type?: 'provinceCity' | 'province';
+      showSearch?: boolean;
+    }>(),
+    {
+      showSearch: true
+    }
+  );
+
+  const emit = defineEmits<{
+    (e: 'update:value', value?: string[]): void;
+    (e: 'load-data-done', value: RegionsData[]): void;
+  }>();
+
+  // 级联选择器数据
+  const regionsData = ref<RegionsData[]>([]);
+
+  /* 更新 value */
+  const updateValue = (value: ValueType) => {
+    emit('update:value', value as string[]);
+  };
+
+  /* 级联选择器数据 value 处理 */
+  const formatData = (data: RegionsData[]) => {
+    if (props.valueField === 'label') {
+      return data.map((d) => {
+        const item: RegionsData = {
+          label: d.label,
+          value: d.label
+        };
+        if (d.children) {
+          item.children = d.children.map((c) => {
+            const cItem: RegionsData = {
+              label: c.label,
+              value: c.label
+            };
+            if (c.children) {
+              cItem.children = c.children.map((cc) => {
+                return {
+                  label: cc.label,
+                  value: cc.label
+                };
+              });
+            }
+            return cItem;
+          });
+        }
+        return item;
+      });
+    } else {
+      return data;
+    }
+  };
+
+  /* 省市区数据筛选 */
+  const filterData = (data: RegionsData[]) => {
+    if (props.type === 'provinceCity') {
+      return formatData(
+        data.map((d) => {
+          const item: RegionsData = {
+            label: d.label,
+            value: d.value
+          };
+          if (d.children) {
+            item.children = d.children.map((c) => {
+              return {
+                label: c.label,
+                value: c.value
+              };
+            });
+          }
+          return item;
+        })
+      );
+    } else if (props.type === 'province') {
+      return formatData(
+        data.map((d) => {
+          return {
+            label: d.label,
+            value: d.value
+          };
+        })
+      );
+    } else {
+      return formatData(data);
+    }
+  };
+
+  watch(
+    () => props.options,
+    (options) => {
+      regionsData.value = filterData(options ?? []);
+      if (!options) {
+        getRegionsData().then((data) => {
+          regionsData.value = filterData(data ?? []);
+          emit('load-data-done', data);
+        });
+      }
+    },
+    {
+      immediate: true
+    }
+  );
+</script>
diff --git a/src/components/RegionsSelect/load-data.ts b/src/components/RegionsSelect/load-data.ts
new file mode 100644
index 0000000..bc7d756
--- /dev/null
+++ b/src/components/RegionsSelect/load-data.ts
@@ -0,0 +1,25 @@
+import request from '@/utils/request';
+import type { RegionsData } from './types';
+const BASE_URL = import.meta.env.BASE_URL;
+let reqPromise: Promise<RegionsData[]>;
+
+/**
+ * 获取省市区数据
+ */
+export function getRegionsData() {
+  if (!reqPromise) {
+    reqPromise = new Promise<RegionsData[]>((resolve, reject) => {
+      request
+        .get<RegionsData[]>(BASE_URL + 'json/regions-data.json', {
+          baseURL: ''
+        })
+        .then((res) => {
+          resolve(res.data ?? []);
+        })
+        .catch((e) => {
+          reject(e);
+        });
+    });
+  }
+  return reqPromise;
+}
diff --git a/src/components/RegionsSelect/types/index.ts b/src/components/RegionsSelect/types/index.ts
new file mode 100644
index 0000000..ebf2eca
--- /dev/null
+++ b/src/components/RegionsSelect/types/index.ts
@@ -0,0 +1,15 @@
+/**
+ * 省市区数据类型
+ */
+export interface RegionsData {
+  label: string;
+  value: string;
+  children?: {
+    value: string;
+    label: string;
+    children?: {
+      value: string;
+      label: string;
+    }[];
+  }[];
+}
diff --git a/src/components/RouterLayout/index.vue b/src/components/RouterLayout/index.vue
new file mode 100644
index 0000000..e304b5c
--- /dev/null
+++ b/src/components/RouterLayout/index.vue
@@ -0,0 +1,26 @@
+<!-- router-view 结合 keep-alive 组件 -->
+<template>
+  <router-view v-slot="{ Component }">
+    <transition :name="transitionName" mode="out-in" appear>
+      <keep-alive :include="keepAliveInclude">
+        <component :is="Component" />
+      </keep-alive>
+    </transition>
+  </router-view>
+</template>
+
+<script lang="ts">
+  import { defineComponent } from 'vue';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+
+  export default defineComponent({
+    name: 'RouterLayout',
+    setup() {
+      const themeStore = useThemeStore();
+      const { keepAliveInclude, transitionName } = storeToRefs(themeStore);
+
+      return { keepAliveInclude, transitionName };
+    }
+  });
+</script>
diff --git a/src/components/TinymceEditor/index.vue b/src/components/TinymceEditor/index.vue
new file mode 100644
index 0000000..aad1e50
--- /dev/null
+++ b/src/components/TinymceEditor/index.vue
@@ -0,0 +1,242 @@
+<!-- 富文本编辑器 -->
+<template>
+  <component v-if="inlineEditor" :is="tagName" :id="elementId" />
+  <textarea v-else :id="elementId"></textarea>
+</template>
+
+<script lang="ts" setup>
+  import {
+    watch,
+    onMounted,
+    onBeforeUnmount,
+    onActivated,
+    onDeactivated,
+    nextTick,
+    useAttrs
+  } from 'vue';
+  import tinymce from 'tinymce/tinymce';
+  import type {
+    Editor as TinyMCEEditor,
+    EditorEvent,
+    RawEditorSettings
+  } from 'tinymce';
+  import 'tinymce/themes/silver';
+  import 'tinymce/icons/default';
+  import 'tinymce/plugins/code';
+  import 'tinymce/plugins/preview';
+  import 'tinymce/plugins/fullscreen';
+  import 'tinymce/plugins/paste';
+  import 'tinymce/plugins/searchreplace';
+  import 'tinymce/plugins/save';
+  import 'tinymce/plugins/autosave';
+  import 'tinymce/plugins/link';
+  import 'tinymce/plugins/autolink';
+  import 'tinymce/plugins/image';
+  import 'tinymce/plugins/media';
+  import 'tinymce/plugins/table';
+  import 'tinymce/plugins/codesample';
+  import 'tinymce/plugins/lists';
+  import 'tinymce/plugins/advlist';
+  import 'tinymce/plugins/hr';
+  import 'tinymce/plugins/charmap';
+  import 'tinymce/plugins/emoticons';
+  import 'tinymce/plugins/anchor';
+  import 'tinymce/plugins/directionality';
+  import 'tinymce/plugins/pagebreak';
+  import 'tinymce/plugins/quickbars';
+  import 'tinymce/plugins/nonbreaking';
+  import 'tinymce/plugins/visualblocks';
+  import 'tinymce/plugins/visualchars';
+  import 'tinymce/plugins/wordcount';
+  import 'tinymce/plugins/emoticons/js/emojis';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import {
+    DEFAULT_CONFIG,
+    DARK_CONFIG,
+    uuid,
+    bindHandlers,
+    openAlert
+  } from './util';
+  import type { AlertOption } from './util';
+
+  const props = withDefaults(
+    defineProps<{
+      // 编辑器唯一 id
+      id?: string;
+      // v-model
+      value?: string;
+      // 编辑器配置
+      init?: RawEditorSettings;
+      // 是否内联模式
+      inline?: boolean;
+      // model events
+      modelEvents?: string;
+      // 内联模式标签名
+      tagName?: string;
+      // 是否禁用
+      disabled?: boolean;
+      // 是否跟随框架主题
+      autoTheme?: boolean;
+      // 不跟随框架主题时是否使用暗黑主题
+      darkTheme?: boolean;
+    }>(),
+    {
+      inline: false,
+      modelEvents: 'change input undo redo',
+      tagName: 'div',
+      autoTheme: true
+    }
+  );
+
+  const emit = defineEmits<{
+    (e: 'update:value', value: string): void;
+  }>();
+
+  const attrs = useAttrs();
+  const themeStore = useThemeStore();
+  const { darkMode } = storeToRefs(themeStore);
+
+  // 编辑器唯一 id
+  const elementId: string = props.id || uuid('tiny-vue');
+
+  // 编辑器实例
+  let editorIns: TinyMCEEditor | null = null;
+
+  // 是否内联模式
+  const inlineEditor: boolean = props.init?.inline || props.inline;
+
+  /* 更新 value */
+  const updateValue = (value: string) => {
+    emit('update:value', value);
+  };
+
+  /* 修改内容 */
+  const setContent = (value?: string) => {
+    if (
+      editorIns &&
+      typeof value === 'string' &&
+      value !== editorIns.getContent()
+    ) {
+      editorIns.setContent(value);
+    }
+  };
+
+  /* 渲染编辑器 */
+  const render = () => {
+    const isDark = props.autoTheme ? darkMode.value : props.darkTheme;
+    tinymce.init({
+      ...DEFAULT_CONFIG,
+      ...(isDark ? DARK_CONFIG : {}),
+      ...props.init,
+      selector: `#${elementId}`,
+      readonly: props.disabled,
+      inline: inlineEditor,
+      setup: (editor: TinyMCEEditor) => {
+        editorIns = editor;
+        editor.on('init', (e: EditorEvent<any>) => {
+          // 回显初始值
+          if (props.value) {
+            setContent(props.value);
+          }
+          // v-model
+          editor.on(props.modelEvents, () => {
+            updateValue(editor.getContent());
+          });
+          // valid events
+          bindHandlers(e, attrs, editor);
+        });
+        if (typeof props.init?.setup === 'function') {
+          props.init.setup(editor);
+        }
+      }
+    });
+  };
+
+  /* 销毁编辑器 */
+  const destory = () => {
+    if (tinymce != null && editorIns != null) {
+      tinymce.remove(editorIns as any);
+      editorIns = null;
+    }
+  };
+
+  /* 弹出提示框 */
+  const alert = (option?: AlertOption) => {
+    openAlert(editorIns, option);
+  };
+
+  defineExpose({ editorIns, alert });
+
+  watch(
+    () => props.value,
+    (val: string, prevVal: string) => {
+      if (val !== prevVal) {
+        setContent(val);
+      }
+    }
+  );
+
+  watch(
+    () => props.disabled,
+    (disable) => {
+      if (editorIns !== null) {
+        if (typeof editorIns.mode?.set === 'function') {
+          editorIns.mode.set(disable ? 'readonly' : 'design');
+        } else {
+          editorIns.setMode(disable ? 'readonly' : 'design');
+        }
+      }
+    }
+  );
+
+  watch(
+    () => props.tagName,
+    () => {
+      destory();
+      nextTick(() => {
+        render();
+      });
+    }
+  );
+
+  watch(darkMode, () => {
+    if (props.autoTheme) {
+      destory();
+      nextTick(() => {
+        render();
+      });
+    }
+  });
+
+  onMounted(() => {
+    render();
+  });
+
+  onBeforeUnmount(() => {
+    destory();
+  });
+
+  onActivated(() => {
+    render();
+  });
+
+  onDeactivated(() => {
+    destory();
+  });
+</script>
+
+<style>
+  body .tox-tinymce-aux {
+    z-index: 19990000;
+  }
+
+  textarea[id^='tiny-vue'] {
+    width: 0;
+    height: 0;
+    margin: 0;
+    padding: 0;
+    opacity: 0;
+    box-sizing: border-box;
+  }
+</style>
diff --git a/src/components/TinymceEditor/util.ts b/src/components/TinymceEditor/util.ts
new file mode 100644
index 0000000..c86373b
--- /dev/null
+++ b/src/components/TinymceEditor/util.ts
@@ -0,0 +1,248 @@
+import type {
+  Editor as TinyMCEEditor,
+  EditorEvent,
+  RawEditorSettings
+} from 'tinymce';
+const BASE_URL = import.meta.env.BASE_URL;
+
+// 默认加载插件
+const PLUGINS: string = [
+  'code',
+  'preview',
+  'fullscreen',
+  'paste',
+  'searchreplace',
+  'save',
+  'autosave',
+  'link',
+  'autolink',
+  'image',
+  'media',
+  'table',
+  'codesample',
+  'lists',
+  'advlist',
+  'hr',
+  'charmap',
+  'emoticons',
+  'anchor',
+  'directionality',
+  'pagebreak',
+  'quickbars',
+  'nonbreaking',
+  'visualblocks',
+  'visualchars',
+  'wordcount'
+].join(' ');
+
+// 默认工具栏布局
+const TOOLBAR: string = [
+  'fullscreen',
+  'preview',
+  'code',
+  '|',
+  'undo',
+  'redo',
+  '|',
+  'forecolor',
+  'backcolor',
+  '|',
+  'bold',
+  'italic',
+  'underline',
+  'strikethrough',
+  '|',
+  'alignleft',
+  'aligncenter',
+  'alignright',
+  'alignjustify',
+  '|',
+  'outdent',
+  'indent',
+  '|',
+  'numlist',
+  'bullist',
+  '|',
+  'formatselect',
+  'fontselect',
+  'fontsizeselect',
+  '|',
+  'link',
+  'image',
+  'media',
+  'emoticons',
+  'charmap',
+  'anchor',
+  'pagebreak',
+  'codesample',
+  '|',
+  'ltr',
+  'rtl'
+].join(' ');
+
+// 默认配置
+export const DEFAULT_CONFIG: RawEditorSettings = {
+  height: 300,
+  branding: false,
+  skin_url: BASE_URL + 'tinymce/skins/ui/oxide',
+  content_css: BASE_URL + 'tinymce/skins/content/default/content.min.css',
+  language_url: BASE_URL + 'tinymce/langs/zh_CN.js',
+  language: 'zh_CN',
+  plugins: PLUGINS,
+  toolbar: TOOLBAR,
+  draggable_modal: true,
+  toolbar_mode: 'sliding',
+  quickbars_insert_toolbar: '',
+  images_upload_handler: (blobInfo: any, success: any, error: any) => {
+    if (blobInfo.blob().size / 1024 > 400) {
+      error('大小不能超过 400KB');
+      return;
+    }
+    success('data:image/jpeg;base64,' + blobInfo.base64());
+  },
+  file_picker_types: 'media',
+  file_picker_callback: () => {}
+};
+
+// 暗黑主题配置
+export const DARK_CONFIG: RawEditorSettings = {
+  skin_url: BASE_URL + 'tinymce/skins/ui/oxide-dark',
+  content_css: BASE_URL + 'tinymce/skins/content/dark/content.min.css'
+};
+
+// 支持监听的事件
+export const VALID_EVENTS = [
+  'onActivate',
+  'onAddUndo',
+  'onBeforeAddUndo',
+  'onBeforeExecCommand',
+  'onBeforeGetContent',
+  'onBeforeRenderUI',
+  'onBeforeSetContent',
+  'onBeforePaste',
+  'onBlur',
+  'onChange',
+  'onClearUndos',
+  'onClick',
+  'onContextMenu',
+  'onCopy',
+  'onCut',
+  'onDblclick',
+  'onDeactivate',
+  'onDirty',
+  'onDrag',
+  'onDragDrop',
+  'onDragEnd',
+  'onDragGesture',
+  'onDragOver',
+  'onDrop',
+  'onExecCommand',
+  'onFocus',
+  'onFocusIn',
+  'onFocusOut',
+  'onGetContent',
+  'onHide',
+  'onInit',
+  'onKeyDown',
+  'onKeyPress',
+  'onKeyUp',
+  'onLoadContent',
+  'onMouseDown',
+  'onMouseEnter',
+  'onMouseLeave',
+  'onMouseMove',
+  'onMouseOut',
+  'onMouseOver',
+  'onMouseUp',
+  'onNodeChange',
+  'onObjectResizeStart',
+  'onObjectResized',
+  'onObjectSelected',
+  'onPaste',
+  'onPostProcess',
+  'onPostRender',
+  'onPreProcess',
+  'onProgressState',
+  'onRedo',
+  'onRemove',
+  'onReset',
+  'onSaveContent',
+  'onSelectionChange',
+  'onSetAttrib',
+  'onSetContent',
+  'onShow',
+  'onSubmit',
+  'onUndo',
+  'onVisualAid'
+];
+
+let unique = 0;
+
+/**
+ * 生成编辑器 id
+ */
+export function uuid(prefix: string): string {
+  const time = Date.now();
+  const random = Math.floor(Math.random() * 1000000000);
+  unique++;
+  return prefix + '_' + random + unique + String(time);
+}
+
+/**
+ * 绑定事件
+ */
+export function bindHandlers(
+  initEvent: EditorEvent<any>,
+  listeners: Record<string, any>,
+  editor: TinyMCEEditor
+): void {
+  const validEvents = VALID_EVENTS.map((event) => event.toLowerCase());
+  Object.keys(listeners)
+    .filter((key: string) => validEvents.includes(key.toLowerCase()))
+    .forEach((key: string) => {
+      const handler = listeners[key];
+      if (typeof handler === 'function') {
+        if (key === 'onInit') {
+          handler(initEvent, editor);
+        } else {
+          editor.on(key.substring(2), (e: EditorEvent<any>) =>
+            handler(e, editor)
+          );
+        }
+      }
+    });
+}
+
+/**
+ * 弹出提示框
+ */
+export function openAlert(
+  editor: TinyMCEEditor | null,
+  option: AlertOption = {}
+) {
+  editor?.windowManager?.open({
+    title: option.title ?? '提示',
+    body: {
+      type: 'panel',
+      items: [
+        {
+          type: 'htmlpanel',
+          html: `<p>${option.content ?? ''}</p>`
+        }
+      ]
+    },
+    buttons: [
+      {
+        type: 'cancel',
+        name: 'closeButton',
+        text: '确定',
+        primary: true
+      }
+    ]
+  });
+}
+
+export interface AlertOption {
+  title?: string;
+  content?: string;
+}
diff --git a/src/config/setting.ts b/src/config/setting.ts
new file mode 100644
index 0000000..d61937d
--- /dev/null
+++ b/src/config/setting.ts
@@ -0,0 +1,68 @@
+// 接口地址
+export const API_BASE_URL: string = import.meta.env.VITE_API_URL;
+
+//主页地址
+export const HOME_BASE_URL: string = import.meta.env.VITE_HOME_URL;
+
+// 项目名称
+export const PROJECT_NAME: string = import.meta.env.VITE_APP_NAME;
+
+// 不显示侧栏的路由
+export const HIDE_SIDEBARS: string[] = [];
+
+// 不显示页脚的路由
+export const HIDE_FOOTERS: string[] = [
+  '/system/dictionary',
+  '/system/content/category',
+  '/form/advanced'
+];
+
+// 页签同路由不同参数可重复打开的路由
+export const REPEATABLE_TABS: string[] = [];
+
+// 不需要登录的路由
+export const WHITE_LIST: string[] = ['/login', '/forget'];
+
+// 开启 KeepAlive 后仍然不需要缓存的路由地址
+export const KEEP_ALIVE_EXCLUDES: string[] = [];
+
+// 直接指定菜单数据
+export const USER_MENUS: Array<any> | undefined = undefined;
+
+// 首页名称, 为空则取第一个菜单的名称
+export const HOME_TITLE: string | undefined = undefined;
+
+// 首页路径, 为空则取第一个菜单的地址
+export const HOME_PATH: string | undefined = undefined;
+
+// 外层布局的路由地址
+export const LAYOUT_PATH = '/';
+
+// 刷新路由的路由地址
+export const REDIRECT_PATH = '/redirect';
+
+// 开启页签栏是否缓存组件
+//export const TAB_KEEP_ALIVE = !import.meta.env.DEV;
+export const TAB_KEEP_ALIVE = true;
+
+// token 传递的 header 名称
+export const TOKEN_HEADER_NAME = 'Authorization';
+
+// token 存储的名称
+export const TOKEN_STORE_NAME = 'access_token';
+
+// 主题配置存储的名称
+export const THEME_STORE_NAME = 'theme';
+
+// i18n 缓存的名称
+export const I18N_CACHE_NAME = 'i18n-lang';
+
+// 是否开启国际化功能
+export const I18N_ENABLE = false;
+
+// 高德地图 key , 自带的只能用于测试, 正式项目请自行到高德地图官网申请 key
+export const MAP_KEY = '006d995d433058322319fa797f2876f5';
+
+// EleAdminPro 授权码, 自带的只能用于演示, 正式项目请更换为自己的授权码
+export const LICENSE_CODE =
+  'dk9mcwJyetRWQlxWRiojIzJCLi8mcQ5Wa3ojI0NWZqJWd6ICZpJCL0UjMYd2VEFjWwcjIvl2cyVmdiwiIiETMuEjI6IibQf0NW==';
diff --git a/src/i18n/index.ts b/src/i18n/index.ts
new file mode 100644
index 0000000..7af0dbd
--- /dev/null
+++ b/src/i18n/index.ts
@@ -0,0 +1,20 @@
+/**
+ * 国际化配置
+ */
+import { createI18n } from 'vue-i18n';
+import { I18N_CACHE_NAME } from '@/config/setting';
+import zh_CN from './lang/zh_CN';
+import zh_TW from './lang/zh_TW';
+import en from './lang/en';
+
+const messages = { zh_CN, zh_TW, en };
+
+const i18n = createI18n({
+  messages,
+  legacy: false,
+  silentTranslationWarn: true,
+  // 默认语言
+  locale: localStorage.getItem(I18N_CACHE_NAME) || 'zh_CN'
+});
+
+export default i18n;
diff --git a/src/i18n/lang/en/index.ts b/src/i18n/lang/en/index.ts
new file mode 100644
index 0000000..743cea1
--- /dev/null
+++ b/src/i18n/lang/en/index.ts
@@ -0,0 +1,14 @@
+/**
+ * 英语
+ */
+import route from './route';
+import layout from './layout';
+import login from './login';
+import list from './list';
+
+export default {
+  route,
+  layout,
+  login,
+  list
+};
diff --git a/src/i18n/lang/en/layout.ts b/src/i18n/lang/en/layout.ts
new file mode 100644
index 0000000..ce9467e
--- /dev/null
+++ b/src/i18n/lang/en/layout.ts
@@ -0,0 +1,77 @@
+/* 主框架 */
+export default {
+  home: 'Home',
+  header: {
+    profile: 'Profile',
+    password: 'Password',
+    logout: 'SignOut'
+  },
+  footer: {
+    website: 'Website',
+    document: 'Document',
+    authorization: 'Authorization',
+    copyright: 'Copyright © 2021 Wuhan EClouds Technology Co., Ltd'
+  },
+  logout: {
+    title: 'Confirm',
+    message: 'Are you sure you want to log out?'
+  },
+  setting: {
+    title: 'Theme Setting',
+    sideStyles: {
+      dark: 'Dark Sidebar',
+      light: 'Light Sidebar'
+    },
+    headStyles: {
+      light: 'Light Header',
+      dark: 'Dark Header',
+      primary: 'Primary Header'
+    },
+    layoutStyles: {
+      side: 'Side Menu Layout',
+      top: 'Top Menu Layout',
+      mix: 'Mix Menu Layout'
+    },
+    colors: {
+      default: 'Daybreak Blue',
+      dust: 'Dust Blue',
+      sunset: 'Sunset Orange',
+      volcano: 'Volcano',
+      purple: 'Golden Purple',
+      cyan: 'Cyan',
+      green: 'Polar Green',
+      geekblue: 'Geek Blue'
+    },
+    darkMode: 'Dark Mode',
+    layoutStyle: 'Navigation Mode',
+    sideMenuStyle: 'Sidebar Double Menu',
+    bodyFull: 'Body Fixed Width',
+    other: 'Other Setting',
+    fixedHeader: 'Fixed Header',
+    fixedSidebar: 'Fixed Sidebar',
+    fixedBody: 'Fixed Body',
+    logoAutoSize: 'Logo In Header',
+    styleResponsive: 'Responsive',
+    colorfulIcon: 'Colorful Icon',
+    sideUniqueOpen: 'Menu Unique Open',
+    weakMode: 'Weak Mode',
+    showFooter: 'Show Footer',
+    showTabs: 'Show Tabs',
+    tabStyle: 'Tab Style',
+    tabStyles: {
+      default: 'Default',
+      dot: 'Dot',
+      card: 'Card'
+    },
+    transitionName: 'Transition',
+    transitions: {
+      slideRight: 'Slide Right',
+      slideBottom: 'Slide Bottom',
+      zoomIn: 'Zoom In',
+      zoomOut: 'Zoom Out',
+      fade: 'Fade'
+    },
+    reset: 'Reset',
+    tips: 'It will remember your configuration the next time you open it.'
+  }
+};
diff --git a/src/i18n/lang/en/list.ts b/src/i18n/lang/en/list.ts
new file mode 100644
index 0000000..f809452
--- /dev/null
+++ b/src/i18n/lang/en/list.ts
@@ -0,0 +1,17 @@
+/* 列表页面 */
+export default {
+  // 基础列表
+  basic: {
+    table: {
+      avatar: 'Avatar',
+      username: 'Username',
+      nickname: 'Nickname',
+      organizationName: 'Organization',
+      phone: 'Phone',
+      sexName: 'Sex',
+      createTime: 'CreateTime',
+      status: 'Status',
+      action: 'Action'
+    }
+  }
+};
diff --git a/src/i18n/lang/en/login.ts b/src/i18n/lang/en/login.ts
new file mode 100644
index 0000000..860f1a4
--- /dev/null
+++ b/src/i18n/lang/en/login.ts
@@ -0,0 +1,11 @@
+/* 登录界面 */
+export default {
+  title: 'User Login',
+  username: 'please input username',
+  password: 'please input password',
+  code: 'please input code',
+  remember: 'remember',
+  forget: 'forget',
+  login: 'login',
+  loading: 'loading'
+};
diff --git a/src/i18n/lang/en/route.ts b/src/i18n/lang/en/route.ts
new file mode 100644
index 0000000..07bbdd4
--- /dev/null
+++ b/src/i18n/lang/en/route.ts
@@ -0,0 +1,100 @@
+/* 菜单路由 */
+export default {
+  login: { _name: 'Login' },
+  forget: { _name: 'Forget' },
+  dashboard: {
+    _name: 'Dashboard',
+    workplace: { _name: 'Workplace' },
+    analysis: { _name: 'Analysis' },
+    monitor: { _name: 'Monitor' }
+  },
+  system: {
+    _name: 'System',
+    user: {
+      _name: 'User',
+      details: { _name: '' }
+    },
+    role: { _name: 'Role' },
+    menu: { _name: 'Menu' },
+    dictionary: { _name: 'Dictionary' },
+    organization: { _name: 'Organization' },
+    loginRecord: { _name: 'LoginRecord' },
+    operationRecord: { _name: 'OperationRecord' },
+    file: { _name: 'File' },
+    userInfo: { _name: '' }
+  },
+  form: {
+    _name: 'Form',
+    basic: { _name: 'Basic Form' },
+    advanced: { _name: 'Advanced Form' },
+    step: { _name: 'Step Form' }
+  },
+  list: {
+    _name: 'List',
+    basic: {
+      _name: 'Basic List',
+      add: { _name: 'UserAdd' },
+      edit: { _name: 'UserEdit' },
+      details: {
+        ':id': { _name: '' }
+      }
+    },
+    advanced: { _name: 'Advanced List' },
+    card: {
+      _name: 'Card List',
+      project: { _name: 'Project' },
+      application: { _name: 'Application' },
+      article: { _name: 'Article' }
+    }
+  },
+  result: {
+    _name: 'Result',
+    success: { _name: 'Success' },
+    fail: { _name: 'Fail' }
+  },
+  exception: {
+    _name: 'Exception',
+    '403': { _name: '403' },
+    '404': { _name: '404' },
+    '500': { _name: '500' }
+  },
+  user: {
+    _name: 'User',
+    profile: { _name: 'Profile' },
+    message: { _name: 'Message' }
+  },
+  extension: {
+    _name: 'Extension',
+    tag: { _name: 'Tags' },
+    dialog: { _name: 'DragDialog' },
+    file: { _name: 'FileList' },
+    upload: { _name: 'ImageUpload' },
+    dragsort: { _name: 'DragSort' },
+    colorPicker: { _name: 'ColorPicker' },
+    regions: { _name: 'CitySelect' },
+    printer: { _name: 'Printer' },
+    excel: { _name: 'Excel' },
+    countUp: { _name: 'CountUp' },
+    tableSelect: { _name: 'TableSelect' },
+    player: { _name: 'Player' },
+    map: { _name: 'Map' },
+    qrCode: { _name: 'QRCode' },
+    barCode: { _name: 'BarCode' },
+    editor: { _name: 'Editor' },
+    markdown: { _name: 'Markdown' },
+    dashboard: { _name: 'Dashboard' },
+    tour: { _name: 'Tour' },
+    watermark: { _name: 'Watermark' },
+    split: { _name: 'Split' }
+  },
+  example: {
+    _name: 'Example',
+    table: { _name: 'ProTable' },
+    menuBadge: { _name: 'MenuBadge' },
+    eleadmin: { _name: 'IFrame' },
+    eleadminDoc: { _name: 'IFrame2' },
+    document: { _name: 'Document' },
+    choose: { _name: 'Choose' }
+  },
+  'https://eleadminCom/goods/9': { _name: 'Authorization' }
+};
diff --git a/src/i18n/lang/zh_CN/index.ts b/src/i18n/lang/zh_CN/index.ts
new file mode 100644
index 0000000..282e79e
--- /dev/null
+++ b/src/i18n/lang/zh_CN/index.ts
@@ -0,0 +1,14 @@
+/**
+ * 简体中文
+ */
+import route from './route';
+import layout from './layout';
+import login from './login';
+import list from './list';
+
+export default {
+  route,
+  layout,
+  login,
+  list
+};
diff --git a/src/i18n/lang/zh_CN/layout.ts b/src/i18n/lang/zh_CN/layout.ts
new file mode 100644
index 0000000..44b450b
--- /dev/null
+++ b/src/i18n/lang/zh_CN/layout.ts
@@ -0,0 +1,77 @@
+/* 主框架 */
+export default {
+  home: '主页',
+  header: {
+    profile: '个人中心',
+    password: '修改密码',
+    logout: '退出登录'
+  },
+  footer: {
+    website: '官网',
+    document: '文档',
+    authorization: '授权',
+    copyright: 'Copyright © 2022 CQTLCM'
+  },
+  logout: {
+    title: '提示',
+    message: '确定要退出登录吗?'
+  },
+  setting: {
+    title: '整体风格设置',
+    sideStyles: {
+      dark: '暗色侧边栏',
+      light: '亮色侧边栏'
+    },
+    headStyles: {
+      light: '亮色顶栏',
+      dark: '暗色顶栏',
+      primary: '主色顶栏'
+    },
+    layoutStyles: {
+      side: '左侧菜单布局',
+      top: '顶部菜单布局',
+      mix: '混合菜单布局'
+    },
+    colors: {
+      default: '拂晓蓝',
+      dust: '薄暮',
+      sunset: '日暮',
+      volcano: '火山',
+      purple: '酱紫',
+      cyan: '明青',
+      green: '极光绿',
+      geekblue: '极客蓝'
+    },
+    darkMode: '开启暗黑模式',
+    layoutStyle: '导航模式',
+    sideMenuStyle: '侧栏双排菜单',
+    bodyFull: '内容区域定宽',
+    other: '其它配置',
+    fixedHeader: '固定顶栏区域',
+    fixedSidebar: '固定侧栏区域',
+    fixedBody: '固定主体区域',
+    logoAutoSize: 'Logo置于顶栏',
+    styleResponsive: '移动端响应式',
+    colorfulIcon: '侧栏彩色图标',
+    sideUniqueOpen: '侧栏排他展开',
+    weakMode: '开启色弱模式',
+    showFooter: '开启全局页脚',
+    showTabs: '开启多页签栏',
+    tabStyle: '页签显示风格',
+    tabStyles: {
+      default: '默认',
+      dot: '圆点',
+      card: '卡片'
+    },
+    transitionName: '路由切换动画',
+    transitions: {
+      slideRight: '滑动消退',
+      slideBottom: '底部消退',
+      zoomIn: '放大渐变',
+      zoomOut: '缩小渐变',
+      fade: '淡入淡出'
+    },
+    reset: '重置',
+    tips: '该功能可实时预览各种布局效果, 修改后会缓存在本地, 下次打开会记忆主题配置.'
+  }
+};
diff --git a/src/i18n/lang/zh_CN/list.ts b/src/i18n/lang/zh_CN/list.ts
new file mode 100644
index 0000000..4011464
--- /dev/null
+++ b/src/i18n/lang/zh_CN/list.ts
@@ -0,0 +1,17 @@
+/* 列表页面 */
+export default {
+  // 基础列表
+  basic: {
+    table: {
+      avatar: '头像',
+      username: '用户账号',
+      nickname: '用户名',
+      organizationName: '组织机构',
+      phone: '手机号',
+      sexName: '性别',
+      createTime: '创建时间',
+      status: '状态',
+      action: '操作'
+    }
+  }
+};
diff --git a/src/i18n/lang/zh_CN/login.ts b/src/i18n/lang/zh_CN/login.ts
new file mode 100644
index 0000000..d7d54cf
--- /dev/null
+++ b/src/i18n/lang/zh_CN/login.ts
@@ -0,0 +1,11 @@
+/* 登录界面 */
+export default {
+  title: '用户登录',
+  username: '请输入登录账号',
+  password: '请输入登录密码',
+  code: '请输入验证码',
+  remember: '记住密码',
+  forget: '忘记密码',
+  login: '登录',
+  loading: '登录中'
+};
diff --git a/src/i18n/lang/zh_CN/route.ts b/src/i18n/lang/zh_CN/route.ts
new file mode 100644
index 0000000..db312d9
--- /dev/null
+++ b/src/i18n/lang/zh_CN/route.ts
@@ -0,0 +1,100 @@
+/* 菜单路由 */
+export default {
+  login: { _name: '登录' },
+  forget: { _name: '忘记密码' },
+  dashboard: {
+    _name: 'Dashboard',
+    workplace: { _name: '工作台' },
+    analysis: { _name: '分析页' },
+    monitor: { _name: '监控页' }
+  },
+  system: {
+    _name: '系统管理',
+    user: {
+      _name: '用户管理',
+      add: { _name: '添加用户' },
+      edit: { _name: '修改用户' },
+      details: { _name: '' }
+    },
+    role: { _name: '角色管理' },
+    menu: { _name: '菜单管理' },
+    dictionary: { _name: '字典管理' },
+    organization: { _name: '机构管理' },
+    loginRecord: { _name: '登录日志' },
+    operationRecord: { _name: '操作日志' },
+    file: { _name: '文件管理' }
+  },
+  form: {
+    _name: '表单页面',
+    basic: { _name: '基础表单' },
+    advanced: { _name: '复杂表单' },
+    step: { _name: '分步表单' }
+  },
+  list: {
+    _name: '列表页面',
+    basic: {
+      _name: '基础列表',
+      add: { _name: '添加用户' },
+      edit: { _name: '修改用户' },
+      details: {
+        ':id': { _name: '' }
+      }
+    },
+    advanced: { _name: '复杂列表' },
+    card: {
+      _name: '卡片列表',
+      project: { _name: '项目列表' },
+      application: { _name: '应用列表' },
+      article: { _name: '文章列表' }
+    }
+  },
+  result: {
+    _name: '结果页面',
+    success: { _name: '成功页' },
+    fail: { _name: '失败页' }
+  },
+  exception: {
+    _name: '异常页面',
+    '403': { _name: '403' },
+    '404': { _name: '404' },
+    '500': { _name: '500' }
+  },
+  user: {
+    _name: '个人中心',
+    profile: { _name: '个人资料' },
+    message: { _name: '我的消息' }
+  },
+  extension: {
+    _name: '扩展组件',
+    tag: { _name: '标签组件' },
+    dialog: { _name: '拖拽弹窗' },
+    file: { _name: '文件列表' },
+    upload: { _name: '图片上传' },
+    dragsort: { _name: '拖拽排序' },
+    colorPicker: { _name: '颜色选择' },
+    regions: { _name: '城市选择' },
+    printer: { _name: '打印插件' },
+    excel: { _name: 'excel插件' },
+    countUp: { _name: '滚动数字' },
+    tableSelect: { _name: '表格下拉' },
+    player: { _name: '视频播放' },
+    map: { _name: '地图组件' },
+    qrCode: { _name: '二维码' },
+    barCode: { _name: '条形码' },
+    editor: { _name: '富文本框' },
+    markdown: { _name: 'markdown' },
+    dashboard: { _name: '仪表盘' },
+    tour: { _name: '引导组件' },
+    watermark: { _name: '水印组件' },
+    split: { _name: '分割面板' }
+  },
+  example: {
+    _name: '常用实例',
+    table: { _name: '表格实例' },
+    menuBadge: { _name: '菜单徽章' },
+    eleadmin: { _name: '内嵌页面' },
+    eleadminDoc: { _name: '内嵌文档' },
+    document: { _name: '案卷调整' },
+    choose: { _name: '批量选择' }
+  }
+};
diff --git a/src/i18n/lang/zh_TW/index.ts b/src/i18n/lang/zh_TW/index.ts
new file mode 100644
index 0000000..50057e3
--- /dev/null
+++ b/src/i18n/lang/zh_TW/index.ts
@@ -0,0 +1,14 @@
+/**
+ * 繁体中文
+ */
+import route from './route';
+import layout from './layout';
+import login from './login';
+import list from './list';
+
+export default {
+  route,
+  layout,
+  login,
+  list
+};
diff --git a/src/i18n/lang/zh_TW/layout.ts b/src/i18n/lang/zh_TW/layout.ts
new file mode 100644
index 0000000..cfd0bd5
--- /dev/null
+++ b/src/i18n/lang/zh_TW/layout.ts
@@ -0,0 +1,77 @@
+/* 主框架 */
+export default {
+  home: '主頁',
+  header: {
+    profile: '個人中心',
+    password: '修改密碼',
+    logout: '安全登出'
+  },
+  footer: {
+    website: '官網',
+    document: '檔案',
+    authorization: '授權',
+    copyright: 'Copyright © 2022 武漢易雲智科技有限公司'
+  },
+  logout: {
+    title: '詢問',
+    message: '確定要登出嗎?'
+  },
+  setting: {
+    title: '整體風格設定',
+    sideStyles: {
+      dark: '暗色側邊欄',
+      light: '亮色側邊欄'
+    },
+    headStyles: {
+      light: '亮色頂欄',
+      dark: '暗色頂欄',
+      primary: '主色頂欄'
+    },
+    layoutStyles: {
+      side: '左側選單佈局',
+      top: '頂部選單佈局',
+      mix: '混合選單佈局'
+    },
+    colors: {
+      default: '拂曉藍',
+      dust: '薄暮',
+      sunset: '日暮',
+      volcano: '火山',
+      purple: '醬紫',
+      cyan: '明青',
+      green: '極光綠',
+      geekblue: '極客藍'
+    },
+    darkMode: '開啟暗黑模式',
+    layoutStyle: '導航模式',
+    sideMenuStyle: '側欄雙排選單',
+    bodyFull: '內容區域定寬',
+    other: '其它配寘',
+    fixedHeader: '固定頂欄區域',
+    fixedSidebar: '固定側欄區域',
+    fixedBody: '固定主體區域',
+    logoAutoSize: 'Logo置於頂欄',
+    styleResponsive: '移動端響應式',
+    colorfulIcon: '側欄彩色圖標',
+    sideUniqueOpen: '側欄排他展開',
+    weakMode: '開啟色弱模式',
+    showFooter: '開啟全域頁腳',
+    showTabs: '開啟多頁簽欄',
+    tabStyle: '頁簽顯示風格',
+    tabStyles: {
+      default: '默認',
+      dot: '圓點',
+      card: '卡片'
+    },
+    transitionName: '路由切換動畫',
+    transitions: {
+      slideRight: '滑動消退',
+      slideBottom: '底部消退',
+      zoomIn: '放大漸變',
+      zoomOut: '縮小漸變',
+      fade: '淡入淡出'
+    },
+    reset: '重置',
+    tips: '該功能可實时預覽各種佈局效果,修改後會緩存在本地,下次打開會記憶主題配寘.'
+  }
+};
diff --git a/src/i18n/lang/zh_TW/list.ts b/src/i18n/lang/zh_TW/list.ts
new file mode 100644
index 0000000..f24c25f
--- /dev/null
+++ b/src/i18n/lang/zh_TW/list.ts
@@ -0,0 +1,17 @@
+/* 列表页面 */
+export default {
+  // 基础列表
+  basic: {
+    table: {
+      avatar: '頭像',
+      username: '用戶賬號',
+      nickname: '用戶名',
+      organizationName: '組織機構',
+      phone: '手機號',
+      sexName: '性別',
+      createTime: '創建時間',
+      status: '狀態',
+      action: '操作'
+    }
+  }
+};
diff --git a/src/i18n/lang/zh_TW/login.ts b/src/i18n/lang/zh_TW/login.ts
new file mode 100644
index 0000000..5cd7b85
--- /dev/null
+++ b/src/i18n/lang/zh_TW/login.ts
@@ -0,0 +1,11 @@
+/* 登录界面 */
+export default {
+  title: '用戶登錄',
+  username: '請輸入登入帳號',
+  password: '請輸入登入密碼',
+  code: '請輸入驗證碼',
+  remember: '記住密碼',
+  forget: '忘記密碼',
+  login: '登入',
+  loading: '登入中'
+};
diff --git a/src/i18n/lang/zh_TW/route.ts b/src/i18n/lang/zh_TW/route.ts
new file mode 100644
index 0000000..73cf5d8
--- /dev/null
+++ b/src/i18n/lang/zh_TW/route.ts
@@ -0,0 +1,101 @@
+/* 菜单路由 */
+export default {
+  login: { _name: '登入' },
+  forget: { _name: '忘記密碼' },
+  dashboard: {
+    _name: 'Dashboard',
+    workplace: { _name: '工作臺' },
+    analysis: { _name: '分析頁' },
+    monitor: { _name: '監控頁' }
+  },
+  system: {
+    _name: '系統管理',
+    user: {
+      _name: '用戶管理',
+      add: { _name: '添加用戶' },
+      edit: { _name: '編輯用戶' },
+      details: { _name: '' }
+    },
+    role: { _name: '角色管理' },
+    menu: { _name: '選單管理' },
+    dictionary: { _name: '字典管理' },
+    organization: { _name: '機构管理' },
+    loginRecord: { _name: '登入日誌' },
+    operationRecord: { _name: '操作日誌' },
+    file: { _name: '檔案管理' }
+  },
+  form: {
+    _name: '表單頁面',
+    basic: { _name: '基礎表單' },
+    advanced: { _name: '複雜表單' },
+    step: { _name: '分步表單' }
+  },
+  list: {
+    _name: '清單頁面',
+    basic: {
+      _name: '基礎清單',
+      add: { _name: '添加用戶' },
+      edit: { _name: '編輯用戶' },
+      details: {
+        ':id': { _name: '' }
+      }
+    },
+    advanced: { _name: '複雜清單' },
+    card: {
+      _name: '卡片清單',
+      project: { _name: '項目清單' },
+      application: { _name: '應用清單' },
+      article: { _name: '文章清單' }
+    }
+  },
+  result: {
+    _name: '結果頁面',
+    success: { _name: '成功頁' },
+    fail: { _name: '失敗頁' }
+  },
+  exception: {
+    _name: '异常頁面',
+    '403': { _name: '403' },
+    '404': { _name: '404' },
+    '500': { _name: '500' }
+  },
+  user: {
+    _name: '個人中心',
+    profile: { _name: '個人資料' },
+    message: { _name: '我的消息' }
+  },
+  extension: {
+    _name: '擴展組件',
+    tag: { _name: '標籤組件' },
+    dialog: { _name: '拖拽彈窗' },
+    file: { _name: '檔案清單' },
+    upload: { _name: '圖片上傳' },
+    dragsort: { _name: '拖拽排序' },
+    colorPicker: { _name: '顏色選擇' },
+    regions: { _name: '城市選擇' },
+    printer: { _name: '列印挿件' },
+    excel: { _name: 'excel挿件' },
+    countUp: { _name: '滾動數字' },
+    tableSelect: { _name: '表格下拉' },
+    player: { _name: '視頻播放' },
+    map: { _name: '地圖組件' },
+    qrCode: { _name: '二維碼' },
+    barCode: { _name: '條形碼' },
+    editor: { _name: '富文本框' },
+    markdown: { _name: 'markdown' },
+    dashboard: { _name: '儀錶盤' },
+    tour: { _name: '引導組件' },
+    watermark: { _name: '水印組件' },
+    split: { _name: '分割面板' }
+  },
+  example: {
+    _name: '常用實例',
+    table: { _name: '表格實例' },
+    menuBadge: { _name: '選單徽章' },
+    eleadmin: { _name: '內嵌頁面' },
+    eleadminDoc: { _name: '内嵌文檔' },
+    document: { _name: '案卷調整' },
+    choose: { _name: '批量選擇' }
+  },
+  'https://eleadminCom/goods/9': { _name: '獲取授權' }
+};
diff --git a/src/i18n/use-locale.ts b/src/i18n/use-locale.ts
new file mode 100644
index 0000000..5c10eef
--- /dev/null
+++ b/src/i18n/use-locale.ts
@@ -0,0 +1,38 @@
+/**
+ * AntDesignVue、EleAdminPro、Dayjs 国际化配置
+ */
+import { ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import type { Locale } from 'ant-design-vue/es/locale-provider';
+import type { EleLocale } from 'ele-admin-pro/es';
+// AntDesignVue
+import zh_CN from 'ant-design-vue/es/locale/zh_CN';
+import zh_TW from 'ant-design-vue/es/locale/zh_TW';
+import en from 'ant-design-vue/es/locale/en_US';
+// EleAdminPro
+import eleZh_CN from 'ele-admin-pro/es/lang/zh_CN';
+import eleZh_TW from 'ele-admin-pro/es/lang/zh_TW';
+import eleEn from 'ele-admin-pro/es/lang/en_US';
+// Dayjs
+import dayjs from 'dayjs';
+import 'dayjs/locale/zh-cn';
+import 'dayjs/locale/zh-tw';
+const antLocales = { zh_CN, zh_TW, en };
+const eleLocales = { zh_CN: eleZh_CN, zh_TW: eleZh_TW, en: eleEn };
+
+export function useLocale() {
+  const { locale } = useI18n();
+  const antLocale = ref<Locale>();
+  const eleLocale = ref<EleLocale>();
+
+  watch(
+    locale,
+    () => {
+      antLocale.value = antLocales[locale.value];
+      eleLocale.value = eleLocales[locale.value];
+      dayjs.locale(locale.value.toLowerCase().replace(/_/g, '-'));
+    },
+    { immediate: true }
+  );
+  return { antLocale, eleLocale };
+}
diff --git a/src/layout/components/header-notice.vue b/src/layout/components/header-notice.vue
new file mode 100644
index 0000000..1179183
--- /dev/null
+++ b/src/layout/components/header-notice.vue
@@ -0,0 +1,251 @@
+<!-- 顶栏消息通知 -->
+<template>
+  <a-dropdown
+    v-model:visible="visible"
+    placement="bottom"
+    :trigger="['click']"
+    :overlay-style="{ padding: '0 10px' }"
+  >
+    <a-badge :count="unreadNum" class="ele-notice-trigger" :offset="[6, 4]">
+      <bell-outlined style="padding: 8px 0" />
+    </a-badge>
+    <template #overlay>
+      <div class="ant-dropdown-menu ele-notice-pop">
+        <div @click.stop="">
+          <a-tabs v-model:active-key="active" :centered="true">
+            <a-tab-pane key="notice" :tab="noticeTitle">
+              <a-list item-layout="horizontal" :data-source="notice">
+                <template #renderItem="{ item }">
+                  <a-list-item>
+                    <a-list-item-meta
+                      :title="item.title"
+                      :description="item.time"
+                    >
+                      <template #avatar>
+                        <a-avatar :style="{ background: item.color }">
+                          <template #icon>
+                            <component :is="item.icon" />
+                          </template>
+                        </a-avatar>
+                      </template>
+                    </a-list-item-meta>
+                  </a-list-item>
+                </template>
+              </a-list>
+              <div v-if="notice.length" class="ele-cell ele-notice-actions">
+                <div class="ele-cell-content" @click="clearNotice">
+                  清空通知
+                </div>
+                <a-divider type="vertical" />
+                <router-link
+                  to="/user/message?type=notice"
+                  class="ele-cell-content"
+                >
+                  查看更多
+                </router-link>
+              </div>
+            </a-tab-pane>
+            <a-tab-pane key="letter" :tab="letterTitle">
+              <a-list item-layout="horizontal" :data-source="letter">
+                <template #renderItem="{ item }">
+                  <a-list-item>
+                    <a-list-item-meta :title="item.title">
+                      <template #avatar>
+                        <a-avatar :src="item.avatar" />
+                      </template>
+                      <template #description>
+                        <div>{{ item.content }}</div>
+                        <div>{{ item.time }}</div>
+                      </template>
+                    </a-list-item-meta>
+                  </a-list-item>
+                </template>
+              </a-list>
+              <div v-if="letter.length" class="ele-cell ele-notice-actions">
+                <div class="ele-cell-content" @click="clearLetter">
+                  清空私信
+                </div>
+                <a-divider type="vertical" />
+                <router-link
+                  to="/user/message?type=letter"
+                  class="ele-cell-content"
+                >
+                  查看更多
+                </router-link>
+              </div>
+            </a-tab-pane>
+            <a-tab-pane key="todo" :tab="todoTitle">
+              <a-list item-layout="horizontal" :data-source="todo">
+                <template #renderItem="{ item }">
+                  <a-list-item>
+                    <a-list-item-meta :description="item.description">
+                      <template #title>
+                        <div class="ele-cell">
+                          <div class="ele-cell-content">{{ item.title }}</div>
+                          <a-tag :color="[void 0, 'red', 'blue'][item.status]">
+                            {{ ['未开始', '即将到期', '进行中'][item.status] }}
+                          </a-tag>
+                        </div>
+                      </template>
+                    </a-list-item-meta>
+                  </a-list-item>
+                </template>
+              </a-list>
+              <div v-if="todo.length" class="ele-cell ele-notice-actions">
+                <div class="ele-cell-content" @click="clearTodo">
+                  清空待办
+                </div>
+                <a-divider type="vertical" />
+                <router-link
+                  to="/user/message?type=todo"
+                  class="ele-cell-content"
+                >
+                  查看更多
+                </router-link>
+              </div>
+            </a-tab-pane>
+          </a-tabs>
+        </div>
+      </div>
+    </template>
+  </a-dropdown>
+</template>
+
+<script lang="ts" setup>
+  import { ref, computed } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import { getUnreadNotice } from '@/api/layout';
+  import type { NoticeModel, LetterModel, TodoModel } from '@/api/layout/model';
+
+  // 是否显示
+  const visible = ref<boolean>(false);
+  // 选项卡选中
+  const active = ref<string>('notice');
+  // 通知数据
+  const notice = ref<NoticeModel[]>([]);
+  // 私信数据
+  const letter = ref<LetterModel[]>([]);
+  // 待办数据
+  const todo = ref<TodoModel[]>([]);
+
+  // 通知标题
+  const noticeTitle = computed(() => {
+    return '通知' + (notice.value.length ? `(${notice.value.length})` : '');
+  });
+
+  // 私信标题
+  const letterTitle = computed(() => {
+    return '私信' + (letter.value.length ? `(${letter.value.length})` : '');
+  });
+
+  // 待办标题
+  const todoTitle = computed(() => {
+    return '待办' + (todo.value.length ? `(${todo.value.length})` : '');
+  });
+
+  // 未读数量
+  const unreadNum = computed(() => {
+    return notice.value.length + letter.value.length + todo.value.length;
+  });
+
+  /* 查询数据 */
+  const query = () => {
+    getUnreadNotice()
+      .then((result) => {
+        notice.value = result.notice;
+        letter.value = result.letter;
+        todo.value = result.todo;
+      })
+      .catch((e) => {
+        message.error(e.message);
+      });
+  };
+
+  /* 清空通知 */
+  const clearNotice = () => {
+    notice.value = [];
+  };
+
+  /* 清空通知 */
+  const clearLetter = () => {
+    letter.value = [];
+  };
+
+  /* 清空通知 */
+  const clearTodo = () => {
+    todo.value = [];
+  };
+
+  query();
+</script>
+
+<script lang="ts">
+  import {
+    BellOutlined,
+    NotificationFilled,
+    PushpinFilled,
+    VideoCameraFilled,
+    CarryOutFilled,
+    BellFilled
+  } from '@ant-design/icons-vue';
+
+  export default {
+    name: 'HeaderNotice',
+    components: {
+      BellOutlined,
+      NotificationFilled,
+      PushpinFilled,
+      VideoCameraFilled,
+      CarryOutFilled,
+      BellFilled
+    }
+  };
+</script>
+
+<style lang="less">
+  .ele-notice-trigger.ant-badge {
+    color: inherit;
+  }
+
+  .ele-notice-pop {
+    &.ant-dropdown-menu {
+      padding: 0;
+      width: 336px;
+      max-width: 100%;
+      margin-top: 11px;
+    }
+
+    // 内容
+    .ant-list-item {
+      padding-left: 24px;
+      padding-right: 24px;
+      transition: background-color 0.3s;
+      cursor: pointer;
+
+      &:hover {
+        background: hsla(0, 0%, 60%, 0.05);
+      }
+    }
+
+    .ant-tag {
+      margin: 0;
+    }
+
+    // 操作按钮
+    .ele-notice-actions {
+      border-top: 1px solid hsla(0, 0%, 60%, 0.15);
+
+      & > .ele-cell-content {
+        line-height: 46px;
+        text-align: center;
+        transition: background-color 0.3s;
+        cursor: pointer;
+        color: inherit;
+
+        &:hover {
+          background: hsla(0, 0%, 60%, 0.05);
+        }
+      }
+    }
+  }
+</style>
diff --git a/src/layout/components/header-tools.vue b/src/layout/components/header-tools.vue
new file mode 100644
index 0000000..af75064
--- /dev/null
+++ b/src/layout/components/header-tools.vue
@@ -0,0 +1,158 @@
+<!-- 顶栏右侧区域 -->
+<template>
+  <div class="ele-admin-header-tool">
+    <!-- 全屏切换 -->
+    <div
+      :class="[
+        'ele-admin-header-tool-item',
+        { 'hidden-sm-and-down': styleResponsive }
+      ]"
+      @click="toggleFullscreen"
+    >
+      <fullscreen-exit-outlined v-if="fullscreen" />
+      <fullscreen-outlined v-else />
+    </div>
+    <!-- 语言切换 -->
+    <div class="ele-admin-header-tool-item">
+      <i18n-icon />
+    </div>
+    <!-- 消息通知 -->
+    <div class="ele-admin-header-tool-item">
+      <header-notice />
+    </div>
+    <!-- 用户信息 -->
+    <div class="ele-admin-header-tool-item">
+      <a-dropdown placement="bottom" :overlay-style="{ minWidth: '120px' }">
+        <div class="ele-admin-header-avatar">
+          <a-avatar :src="loginUser.avatar">
+            <template v-if="!loginUser.avatar" #icon>
+              <user-outlined />
+            </template>
+          </a-avatar>
+          <span :class="{ 'hidden-sm-and-down': styleResponsive }">
+            {{ loginUser.nickname }}
+          </span>
+          <down-outlined style="margin-left: 6px" />
+        </div>
+        <template #overlay>
+          <a-menu :selectable="false" @click="onUserDropClick">
+            <a-menu-item key="profile">
+              <div class="ele-cell">
+                <user-outlined />
+                <div class="ele-cell-content">
+                  {{ t('layout.header.profile') }}
+                </div>
+              </div>
+            </a-menu-item>
+            <a-menu-item key="password">
+              <div class="ele-cell">
+                <key-outlined />
+                <div class="ele-cell-content">
+                  {{ t('layout.header.password') }}
+                </div>
+              </div>
+            </a-menu-item>
+            <a-menu-divider />
+            <a-menu-item key="logout">
+              <div class="ele-cell">
+                <logout-outlined />
+                <div class="ele-cell-content">
+                  {{ t('layout.header.logout') }}
+                </div>
+              </div>
+            </a-menu-item>
+          </a-menu>
+        </template>
+      </a-dropdown>
+    </div>
+    <!-- 主题设置 -->
+    <div class="ele-admin-header-tool-item" @click="openSetting">
+      <more-outlined />
+    </div>
+  </div>
+  <!-- 修改密码弹窗 -->
+  <password-modal v-model:visible="passwordVisible" />
+  <!-- 主题设置抽屉 -->
+  <setting-drawer v-model:visible="settingVisible" />
+</template>
+
+<script lang="ts" setup>
+  import { computed, createVNode, ref } from 'vue';
+  import { useRouter } from 'vue-router';
+  import { useI18n } from 'vue-i18n';
+  import { Modal } from 'ant-design-vue/es';
+  import {
+    DownOutlined,
+    MoreOutlined,
+    UserOutlined,
+    KeyOutlined,
+    LogoutOutlined,
+    ExclamationCircleOutlined,
+    FullscreenOutlined,
+    FullscreenExitOutlined
+  } from '@ant-design/icons-vue';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import HeaderNotice from './header-notice.vue';
+  import PasswordModal from './password-modal.vue';
+  import SettingDrawer from './setting-drawer.vue';
+  import I18nIcon from './i18n-icon.vue';
+  import { useUserStore } from '@/store/modules/user';
+  import { logout } from '@/utils/page-tab-util';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'fullscreen'): void;
+  }>();
+
+  defineProps<{
+    // 是否是全屏
+    fullscreen: boolean;
+  }>();
+
+  const { push } = useRouter();
+  const { t } = useI18n();
+  const userStore = useUserStore();
+
+  // 是否显示修改密码弹窗
+  const passwordVisible = ref(false);
+
+  // 是否显示主题设置抽屉
+  const settingVisible = ref(false);
+
+  // 当前用户信息
+  const loginUser = computed(() => userStore.info ?? {});
+
+  /* 用户信息下拉点击 */
+  const onUserDropClick = ({ key }) => {
+    if (key === 'password') {
+      passwordVisible.value = true;
+    } else if (key === 'profile') {
+      push('/user/profile');
+    } else if (key === 'logout') {
+      // 退出登录
+      Modal.confirm({
+        title: t('layout.logout.title'),
+        content: t('layout.logout.message'),
+        icon: createVNode(ExclamationCircleOutlined),
+        maskClosable: true,
+        onOk: () => {
+          logout();
+        }
+      });
+    }
+  };
+
+  /* 切换全屏 */
+  const toggleFullscreen = () => {
+    emit('fullscreen');
+  };
+
+  /* 打开主题设置抽屉 */
+  const openSetting = () => {
+    settingVisible.value = true;
+  };
+</script>
diff --git a/src/layout/components/i18n-icon.vue b/src/layout/components/i18n-icon.vue
new file mode 100644
index 0000000..82984d6
--- /dev/null
+++ b/src/layout/components/i18n-icon.vue
@@ -0,0 +1,52 @@
+<!-- 国际化语言切换组件 -->
+<template>
+  <a-dropdown
+    :placement="placement"
+    :overlay-style="{ minWidth: '120px', paddingTop: '17px' }"
+  >
+    <slot>
+      <global-outlined :style="style" />
+    </slot>
+    <template #overlay>
+      <a-menu :selected-keys="language" @click="changeLanguage">
+        <a-menu-item key="en">English</a-menu-item>
+        <a-menu-item key="zh_CN">简体中文</a-menu-item>
+        <a-menu-item key="zh_TW">繁體中文</a-menu-item>
+      </a-menu>
+    </template>
+  </a-dropdown>
+</template>
+
+<script lang="ts" setup>
+  import type { CSSProperties } from 'vue';
+  import { computed } from 'vue';
+  import { useI18n } from 'vue-i18n';
+  import { GlobalOutlined } from '@ant-design/icons-vue';
+  import { I18N_CACHE_NAME } from '@/config/setting';
+
+  withDefaults(
+    defineProps<{
+      // dropdown placement
+      placement?: any;
+      // 自定义样式
+      style?: CSSProperties;
+    }>(),
+    {
+      placement: 'bottom',
+      style: () => {
+        return { transform: 'scale(1.08)' };
+      }
+    }
+  );
+
+  const { locale } = useI18n();
+
+  // 当前显示语言
+  const language = computed(() => [locale.value]);
+
+  /* 切换语言 */
+  const changeLanguage = ({ key }) => {
+    locale.value = key;
+    localStorage.setItem(I18N_CACHE_NAME, key);
+  };
+</script>
diff --git a/src/layout/components/menu-title.vue b/src/layout/components/menu-title.vue
new file mode 100644
index 0000000..168a697
--- /dev/null
+++ b/src/layout/components/menu-title.vue
@@ -0,0 +1,17 @@
+<template>
+  <span>{{ item.meta.title }}</span>
+  <div v-if="item.meta && item.meta.badge" class="ele-menu-badge">
+    <a-badge
+      :count="item.meta.badge"
+      :number-style="{ background: item.meta.badgeColor as string }"
+    />
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import type { MenuItemType } from 'ele-admin-pro/es';
+
+  defineProps<{
+    item: MenuItemType;
+  }>();
+</script>
diff --git a/src/layout/components/page-footer.vue b/src/layout/components/page-footer.vue
new file mode 100644
index 0000000..8ef1f5c
--- /dev/null
+++ b/src/layout/components/page-footer.vue
@@ -0,0 +1,33 @@
+<!-- 全局页脚 -->
+<template>
+  <div class="ele-text-center" style="padding: 16px 0">
+    <a-space size="large">
+      <a class="ele-text-secondary" href="" target="_blank">
+        {{ t('layout.footer.website') }}
+      </a>
+      <a
+        class="ele-text-secondary"
+        href=""
+        target="_blank"
+      >
+        {{ t('layout.footer.document') }}
+      </a>
+      <a
+        class="ele-text-secondary"
+        href=""
+        target="_blank"
+      >
+        {{ t('layout.footer.authorization') }}
+      </a>
+    </a-space>
+    <div class="ele-text-secondary" style="margin-top: 8px">
+      {{ t('layout.footer.copyright') }}
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { useI18n } from 'vue-i18n';
+
+  const { t } = useI18n();
+</script>
diff --git a/src/layout/components/password-modal.vue b/src/layout/components/password-modal.vue
new file mode 100644
index 0000000..2075b7a
--- /dev/null
+++ b/src/layout/components/password-modal.vue
@@ -0,0 +1,146 @@
+<!-- 修改密码弹窗 -->
+<template>
+  <ele-modal
+    :width="420"
+    title="修改密码"
+    :visible="visible"
+    :confirm-loading="loading"
+    :body-style="{ paddingBottom: '16px' }"
+    @update:visible="updateVisible"
+    @cancel="onCancel"
+    @ok="onOk"
+  >
+    <a-form
+      ref="formRef"
+      :model="form"
+      :rules="rules"
+      :label-col="styleResponsive ? { sm: 6 } : { flex: '90px' }"
+      :wrapper-col="styleResponsive ? { sm: 18 } : { flex: '1' }"
+    >
+      <a-form-item label="旧密码" name="oldPassword">
+        <a-input-password
+          v-model:value="form.oldPassword"
+          placeholder="请输入旧密码"
+        />
+      </a-form-item>
+      <a-form-item label="新密码" name="password">
+        <a-input-password
+          v-model:value="form.password"
+          placeholder="请输入新密码"
+        />
+      </a-form-item>
+      <a-form-item label="确认密码" name="password2">
+        <a-input-password
+          v-model:value="form.password2"
+          placeholder="请再次输入新密码"
+        />
+      </a-form-item>
+    </a-form>
+  </ele-modal>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import { updatePassword } from '@/api/layout';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'update:visible', value: boolean): void;
+  }>();
+
+  defineProps<{
+    visible: boolean;
+  }>();
+
+  //
+  const formRef = ref<FormInstance | null>(null);
+
+  // 提交loading
+  const loading = ref<boolean>(false);
+
+  // 表单数据
+  const { form, resetFields } = useFormData({
+    oldPassword: '',
+    password: '',
+    password2: ''
+  });
+
+  // 表单验证规则
+  const rules = reactive<Record<string, Rule[]>>({
+    oldPassword: [
+      {
+        required: true,
+        type: 'string',
+        message: '请输入旧密码',
+        trigger: 'blur'
+      }
+    ],
+    password: [
+      {
+        required: true,
+        type: 'string',
+        message: '请输入新密码',
+        trigger: 'blur'
+      }
+    ],
+    password2: [
+      {
+        required: true,
+        type: 'string',
+        validator: async (_rule: Rule, value: string) => {
+          if (!value) {
+            return Promise.reject('请再次输入新密码');
+          }
+          if (value !== form.password) {
+            return Promise.reject('两次输入密码不一致');
+          }
+          return Promise.resolve();
+        },
+        trigger: 'blur'
+      }
+    ]
+  });
+
+  /* 修改visible */
+  const updateVisible = (value: boolean) => {
+    emit('update:visible', value);
+  };
+
+  /* 保存修改 */
+  const onOk = () => {
+    if (!formRef.value) {
+      return;
+    }
+    formRef.value
+      .validate()
+      .then(() => {
+        loading.value = true;
+        updatePassword(form)
+          .then((msg) => {
+            loading.value = false;
+            message.success(msg);
+            updateVisible(false);
+          })
+          .catch((e) => {
+            loading.value = false;
+            message.error(e.message);
+          });
+      })
+      .catch(() => {});
+  };
+
+  /* 关闭回调 */
+  const onCancel = () => {
+    resetFields();
+    formRef.value?.clearValidate();
+    loading.value = false;
+  };
+</script>
diff --git a/src/layout/components/setting-drawer.vue b/src/layout/components/setting-drawer.vue
new file mode 100644
index 0000000..1c47d32
--- /dev/null
+++ b/src/layout/components/setting-drawer.vue
@@ -0,0 +1,745 @@
+<!-- 主题设置抽屉 -->
+<template>
+  <a-drawer
+    :width="280"
+    :visible="visible"
+    :body-style="{ padding: 0 }"
+    :header-style="{
+      position: 'absolute',
+      top: '16px',
+      right: 0,
+      padding: 0,
+      background: 'none'
+    }"
+    :z-index="1001"
+    @update:visible="updateVisible"
+  >
+    <div :class="['ele-setting-wrapper', { 'ele-setting-dark': darkMode }]">
+      <div class="ele-setting-title">{{ t('layout.setting.title') }}</div>
+      <!-- 侧栏风格 -->
+      <div
+        v-if="layoutStyle !== 'top'"
+        class="ele-setting-theme ele-text-primary"
+      >
+        <a-tooltip :title="t('layout.setting.sideStyles.dark')">
+          <div
+            class="ele-bg-base ele-side-dark"
+            @click="updateSideStyle('dark')"
+          >
+            <check-outlined v-if="sideStyle === 'dark'" />
+          </div>
+        </a-tooltip>
+        <a-tooltip :title="t('layout.setting.sideStyles.light')">
+          <div class="ele-bg-base" @click="updateSideStyle('light')">
+            <check-outlined v-if="sideStyle === 'light'" />
+          </div>
+        </a-tooltip>
+      </div>
+      <!-- 顶栏风格 -->
+      <div class="ele-setting-theme ele-text-primary">
+        <a-tooltip :title="t('layout.setting.headStyles.light')">
+          <div
+            class="ele-bg-base ele-head-light"
+            @click="updateHeadStyle('light')"
+          >
+            <check-outlined v-if="headStyle === 'light'" />
+          </div>
+        </a-tooltip>
+        <a-tooltip :title="t('layout.setting.headStyles.dark')">
+          <div
+            class="ele-bg-base ele-head-dark"
+            @click="updateHeadStyle('dark')"
+          >
+            <check-outlined v-if="headStyle === 'dark'" />
+          </div>
+        </a-tooltip>
+        <a-tooltip :title="t('layout.setting.headStyles.primary')">
+          <div
+            class="ele-bg-base ele-head-primary"
+            @click="updateHeadStyle('primary')"
+          >
+            <div class="ele-bg-primary"></div>
+            <check-outlined v-if="headStyle === 'primary'" />
+          </div>
+        </a-tooltip>
+      </div>
+      <!-- 主题色 -->
+      <div class="ele-setting-colors">
+        <div
+          v-for="item in themes"
+          :key="item.name"
+          :style="{ 'background-color': item.color || item.value }"
+          class="ele-setting-color-item"
+          @click="updateColor(item.value)"
+        >
+          <check-outlined v-if="item.value ? item.value === color : !color" />
+          <a-tooltip :title="t('layout.setting.colors.' + item.name)">
+            <div class="ele-setting-color-tooltip"></div>
+          </a-tooltip>
+        </div>
+        <!-- 颜色选择器 -->
+        <ele-color-picker
+          v-model:value="colorValue"
+          :predefine="predefineColors"
+          custom-class="ele-setting-color-picker"
+          @change="updateColor"
+        />
+      </div>
+      <!-- 暗黑模式 -->
+      <div class="ele-setting-item">
+        <div class="setting-item-title">{{ t('layout.setting.darkMode') }}</div>
+        <div class="setting-item-control">
+          <a-switch size="small" :checked="darkMode" @change="updateDarkMode" />
+        </div>
+      </div>
+      <a-divider />
+      <!-- 导航布局 -->
+      <div
+        :class="[
+          'ele-setting-title ele-text-secondary',
+          { 'hidden-xs-only': styleResponsive }
+        ]"
+      >
+        {{ t('layout.setting.layoutStyle') }}
+      </div>
+      <div
+        :class="[
+          'ele-setting-theme ele-text-primary',
+          { 'hidden-xs-only': styleResponsive }
+        ]"
+      >
+        <a-tooltip :title="t('layout.setting.layoutStyles.side')">
+          <div
+            class="ele-bg-base ele-side-dark"
+            @click="updateLayoutStyle('side')"
+          >
+            <check-outlined v-if="layoutStyle === 'side'" />
+          </div>
+        </a-tooltip>
+        <a-tooltip :title="t('layout.setting.layoutStyles.top')">
+          <div
+            class="ele-bg-base ele-head-dark ele-layout-top"
+            @click="updateLayoutStyle('top')"
+          >
+            <check-outlined v-if="layoutStyle === 'top'" />
+          </div>
+        </a-tooltip>
+        <a-tooltip :title="t('layout.setting.layoutStyles.mix')">
+          <div
+            class="ele-bg-base ele-layout-mix"
+            @click="updateLayoutStyle('mix')"
+          >
+            <check-outlined v-if="layoutStyle === 'mix'" />
+          </div>
+        </a-tooltip>
+      </div>
+      <!-- 侧栏菜单布局 -->
+      <div
+        v-if="layoutStyle !== 'top'"
+        :class="['ele-setting-item', { 'hidden-xs-only': styleResponsive }]"
+      >
+        <div class="setting-item-title">
+          {{ t('layout.setting.sideMenuStyle') }}
+        </div>
+        <div class="setting-item-control">
+          <a-switch
+            size="small"
+            :checked="sideMenuStyle === 'mix'"
+            @change="updateSideMenuStyle"
+          />
+        </div>
+      </div>
+      <!-- 内容区域定宽 -->
+      <div :class="['ele-setting-item', { 'hidden-xs-only': styleResponsive }]">
+        <div class="setting-item-title">
+          {{ t('layout.setting.bodyFull') }}
+        </div>
+        <div class="setting-item-control">
+          <a-switch
+            size="small"
+            :checked="!bodyFull"
+            @change="updateBodyFull"
+          />
+        </div>
+      </div>
+      <a-divider :class="{ 'hidden-xs-only': styleResponsive }" />
+      <div class="ele-setting-title ele-text-secondary">
+        {{ t('layout.setting.other') }}
+      </div>
+      <!-- 固定主体 -->
+      <div class="ele-setting-item">
+        <div class="setting-item-title">
+          {{ t('layout.setting.fixedBody') }}
+        </div>
+        <div class="setting-item-control">
+          <a-switch
+            size="small"
+            :checked="fixedBody"
+            @change="updateFixedBody"
+          />
+        </div>
+      </div>
+      <!-- 固定顶栏 -->
+      <div class="ele-setting-item">
+        <div class="setting-item-title">
+          {{ t('layout.setting.fixedHeader') }}
+        </div>
+        <div class="setting-item-control">
+          <a-switch
+            size="small"
+            :disabled="fixedBody"
+            :checked="fixedHeader"
+            @change="updateFixedHeader"
+          />
+        </div>
+      </div>
+      <!-- 固定侧栏 -->
+      <div
+        v-if="layoutStyle !== 'top'"
+        :class="['ele-setting-item', { 'hidden-xs-only': styleResponsive }]"
+      >
+        <div class="setting-item-title">
+          {{ t('layout.setting.fixedSidebar') }}
+        </div>
+        <div class="setting-item-control">
+          <a-switch
+            size="small"
+            :disabled="fixedBody"
+            :checked="fixedSidebar"
+            @change="updateFixedSidebar"
+          />
+        </div>
+      </div>
+      <!-- logo 置于顶栏 -->
+      <div
+        v-if="layoutStyle !== 'top'"
+        :class="['ele-setting-item', { 'hidden-xs-only': styleResponsive }]"
+      >
+        <div class="setting-item-title">
+          {{ t('layout.setting.logoAutoSize') }}
+        </div>
+        <div class="setting-item-control">
+          <a-switch
+            size="small"
+            :checked="logoAutoSize"
+            @change="updateLogoAutoSize"
+          />
+        </div>
+      </div>
+      <!-- 移动端响应式 -->
+      <div class="ele-setting-item">
+        <div class="setting-item-title">
+          {{ t('layout.setting.styleResponsive') }}
+        </div>
+        <div class="setting-item-control">
+          <a-switch
+            size="small"
+            :checked="styleResponsive"
+            @change="updateStyleResponsive"
+          />
+        </div>
+      </div>
+      <!-- 侧栏彩色图标 -->
+      <div v-if="layoutStyle !== 'top'" class="ele-setting-item">
+        <div class="setting-item-title">
+          {{ t('layout.setting.colorfulIcon') }}
+        </div>
+        <div class="setting-item-control">
+          <a-switch
+            size="small"
+            :checked="colorfulIcon"
+            @change="updateColorfulIcon"
+          />
+        </div>
+      </div>
+      <!-- 侧栏排他展开 -->
+      <div v-if="layoutStyle !== 'top'" class="ele-setting-item">
+        <div class="setting-item-title">
+          {{ t('layout.setting.sideUniqueOpen') }}
+        </div>
+        <div class="setting-item-control">
+          <a-switch
+            size="small"
+            :checked="sideUniqueOpen"
+            @change="updateSideUniqueOpen"
+          />
+        </div>
+      </div>
+      <!-- 全局页脚 -->
+      <div class="ele-setting-item">
+        <div class="setting-item-title">
+          {{ t('layout.setting.showFooter') }}
+        </div>
+        <div class="setting-item-control">
+          <a-switch
+            size="small"
+            :checked="showFooter"
+            @change="updateShowFooter"
+          />
+        </div>
+      </div>
+      <!-- 色弱模式 -->
+      <div class="ele-setting-item">
+        <div class="setting-item-title">{{ t('layout.setting.weakMode') }}</div>
+        <div class="setting-item-control">
+          <a-switch size="small" :checked="weakMode" @change="updateWeakMode" />
+        </div>
+      </div>
+      <!-- 页签 -->
+      <div class="ele-setting-item">
+        <div class="setting-item-title">{{ t('layout.setting.showTabs') }}</div>
+        <div class="setting-item-control">
+          <a-switch size="small" :checked="showTabs" @change="updateShowTabs" />
+        </div>
+      </div>
+      <!-- 页签风格 -->
+      <div v-if="showTabs" class="ele-setting-item">
+        <div class="setting-item-title">{{ t('layout.setting.tabStyle') }}</div>
+        <div class="setting-item-control">
+          <a-select
+            size="small"
+            :value="tabStyle"
+            style="width: 80px"
+            @change="updateTabStyle"
+          >
+            <a-select-option value="default">
+              {{ t('layout.setting.tabStyles.default') }}
+            </a-select-option>
+            <a-select-option value="dot">
+              {{ t('layout.setting.tabStyles.dot') }}
+            </a-select-option>
+            <a-select-option value="card">
+              {{ t('layout.setting.tabStyles.card') }}
+            </a-select-option>
+          </a-select>
+        </div>
+      </div>
+      <!-- 切换动画 -->
+      <div class="ele-setting-item">
+        <div class="setting-item-title">
+          {{ t('layout.setting.transitionName') }}
+        </div>
+        <div class="setting-item-control">
+          <a-select
+            size="small"
+            :value="transitionName"
+            style="width: 100px"
+            @change="updateTransitionName"
+          >
+            <a-select-option value="slide-right">
+              {{ t('layout.setting.transitions.slideRight') }}
+            </a-select-option>
+            <a-select-option value="slide-bottom">
+              {{ t('layout.setting.transitions.slideBottom') }}
+            </a-select-option>
+            <a-select-option value="zoom-in">
+              {{ t('layout.setting.transitions.zoomIn') }}
+            </a-select-option>
+            <a-select-option value="zoom-out">
+              {{ t('layout.setting.transitions.zoomOut') }}
+            </a-select-option>
+            <a-select-option value="fade">
+              {{ t('layout.setting.transitions.fade') }}
+            </a-select-option>
+          </a-select>
+        </div>
+      </div>
+      <!-- 提示 -->
+      <a-divider />
+      <a-alert show-icon type="warning" :message="t('layout.setting.tips')">
+        <template #icon>
+          <sound-outlined />
+        </template>
+      </a-alert>
+      <!-- 重置 -->
+      <a-button block type="dashed" @click="resetSetting">
+        {{ t('layout.setting.reset') }}
+      </a-button>
+    </div>
+  </a-drawer>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import { useI18n } from 'vue-i18n';
+  import { storeToRefs } from 'pinia';
+  import { message } from 'ant-design-vue/es';
+  import { CheckOutlined, SoundOutlined } from '@ant-design/icons-vue';
+  import { messageLoading } from 'ele-admin-pro/es';
+  import type {
+    ThemeItem,
+    HeadStyleType,
+    SideStyleType,
+    LayoutStyleType,
+    TabStyleType
+  } from 'ele-admin-pro/es';
+  import { useThemeStore } from '@/store/modules/theme';
+
+  defineProps<{
+    // drawer 是否显示, v-model
+    visible: boolean;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'update:visible', value: boolean): void;
+  }>();
+
+  const { t } = useI18n();
+  const themeStore = useThemeStore();
+
+  const {
+    showTabs,
+    showFooter,
+    headStyle,
+    sideStyle,
+    layoutStyle,
+    sideMenuStyle,
+    tabStyle,
+    transitionName,
+    fixedHeader,
+    fixedSidebar,
+    fixedBody,
+    bodyFull,
+    logoAutoSize,
+    colorfulIcon,
+    sideUniqueOpen,
+    styleResponsive,
+    weakMode,
+    darkMode,
+    color
+  } = storeToRefs(themeStore);
+
+  // 主题列表
+  const themes = ref<ThemeItem[]>([
+    {
+      name: 'default',
+      color: '#1890ff'
+    },
+    {
+      name: 'dust',
+      value: '#5f80c7'
+    },
+    {
+      name: 'sunset',
+      value: '#faad14'
+    },
+    {
+      name: 'volcano',
+      value: '#f5686f'
+    },
+    {
+      name: 'purple',
+      value: '#9266f9'
+    },
+    {
+      name: 'green',
+      value: '#33cc99'
+    },
+    {
+      name: 'geekblue',
+      value: '#32a2d4'
+    }
+  ]);
+
+  // 颜色选择器预设颜色
+  const predefineColors = ref<string[]>([
+    '#f5222d',
+    '#fa541c',
+    '#fa8c16',
+    '#faad14',
+    '#a0d911',
+    '#52c41a',
+    '#13c2c2',
+    '#2f54eb',
+    '#722ed1',
+    '#eb2f96'
+  ]);
+
+  // 颜色选择器选中颜色
+  const colorValue = ref<string | undefined>(void 0);
+
+  const updateVisible = (value: boolean) => {
+    emit('update:visible', value);
+  };
+
+  const updateShowTabs = (value: boolean) => {
+    themeStore.setShowTabs(value);
+  };
+
+  const updateShowFooter = (value: boolean) => {
+    themeStore.setShowFooter(value);
+  };
+
+  const updateHeadStyle = (value: HeadStyleType) => {
+    themeStore.setHeadStyle(value);
+  };
+
+  const updateSideStyle = (value: SideStyleType) => {
+    themeStore.setSideStyle(value);
+  };
+
+  const updateLayoutStyle = (value: LayoutStyleType) => {
+    themeStore.setLayoutStyle(value);
+  };
+
+  const updateSideMenuStyle = (value: boolean) => {
+    themeStore.setSideMenuStyle(value ? 'mix' : 'default');
+  };
+
+  const updateTabStyle = (value: TabStyleType) => {
+    themeStore.setTabStyle(value);
+  };
+
+  const updateTransitionName = (value: string) => {
+    themeStore.setTransitionName(value);
+  };
+
+  const updateFixedHeader = (value: boolean) => {
+    themeStore.setFixedHeader(value);
+  };
+
+  const updateFixedSidebar = (value: boolean) => {
+    themeStore.setFixedSidebar(value);
+  };
+
+  const updateFixedBody = (value: boolean) => {
+    themeStore.setFixedBody(value);
+  };
+
+  const updateBodyFull = (value: boolean) => {
+    themeStore.setBodyFull(!value);
+  };
+
+  const updateLogoAutoSize = (value: boolean) => {
+    themeStore.setLogoAutoSize(value);
+  };
+
+  const updateStyleResponsive = (value: boolean) => {
+    themeStore.setStyleResponsive(value);
+    updateVisible(false);
+  };
+
+  const updateColorfulIcon = (value: boolean) => {
+    themeStore.setColorfulIcon(value);
+  };
+
+  const updateSideUniqueOpen = (value: boolean) => {
+    themeStore.setSideUniqueOpen(value);
+  };
+
+  const updateWeakMode = (value: boolean) => {
+    themeStore.setWeakMode(value);
+  };
+
+  const updateDarkMode = (value: boolean) => {
+    doWithLoading(() => themeStore.setDarkMode(value));
+  };
+
+  const updateColor = (value?: string) => {
+    doWithLoading(() => themeStore.setColor(value));
+  };
+
+  const resetSetting = () => {
+    doWithLoading(() => themeStore.resetSetting());
+  };
+
+  const doWithLoading = (fun: () => Promise<void>) => {
+    const hide = messageLoading('正在加载主题..', 0);
+    setTimeout(() => {
+      fun()
+        .then(() => {
+          hide();
+          initColorValue();
+        })
+        .catch((e) => {
+          hide();
+          console.error(e);
+          message.error('主题加载失败');
+        });
+    }, 0);
+  };
+
+  const initColorValue = () => {
+    if (color?.value && !themes.value.some((t) => t.value === color.value)) {
+      colorValue.value = color.value;
+    } else {
+      colorValue.value = void 0;
+    }
+  };
+
+  initColorValue();
+</script>
+
+<style lang="less">
+  .ele-setting-wrapper {
+    padding: 20px 18px;
+
+    .ele-setting-title {
+      font-size: 13px;
+      margin-bottom: 15px;
+    }
+
+    /* 主题风格 */
+    .ele-setting-theme > div {
+      width: 52px;
+      height: 36px;
+      line-height: 1;
+      border-radius: 3px;
+      margin: 0 20px 30px 0;
+      padding: 16px 0 0 26px;
+      box-sizing: border-box;
+      box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
+      display: inline-block;
+      vertical-align: top;
+      position: relative;
+      overflow: hidden;
+      cursor: pointer;
+      transition: background-color 0.2s;
+
+      &:before,
+      &:after,
+      & > .ele-bg-primary {
+        content: '';
+        width: 100%;
+        height: 10px;
+        background: #fff;
+        position: absolute;
+        left: 0;
+        top: 0;
+        transition: background-color 0.2s;
+      }
+
+      &:after {
+        width: 14px;
+        height: 100%;
+      }
+
+      &.ele-side-dark:after,
+      &.ele-head-dark:before,
+      &.ele-layout-mix:before,
+      &.ele-layout-mix:after {
+        background: #001529;
+      }
+
+      &.ele-head-light:before,
+      &.ele-head-dark:before,
+      & > .ele-bg-primary {
+        z-index: 1;
+      }
+
+      &.ele-layout-top {
+        padding-left: 19px;
+
+        &:after {
+          display: none;
+        }
+      }
+    }
+
+    /* 主题色选择 */
+    .ele-setting-colors {
+      color: #fff;
+      margin-bottom: 20px;
+    }
+
+    .ele-setting-color-item {
+      width: 20px;
+      height: 20px;
+      line-height: 20px;
+      border-radius: 2px;
+      margin: 8px 8px 0 0;
+      display: inline-block;
+      box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
+      vertical-align: top;
+      position: relative;
+      text-align: center;
+      cursor: pointer;
+
+      .ele-setting-color-tooltip {
+        position: absolute;
+        top: 0;
+        left: 0;
+        right: 0;
+        bottom: 0;
+      }
+    }
+
+    /* 主题配置项 */
+    .ele-setting-item {
+      display: flex;
+      align-items: center;
+      margin-bottom: 20px;
+
+      .setting-item-title {
+        flex: 1;
+        line-height: 28px;
+      }
+
+      .setting-item-control {
+        line-height: 1;
+      }
+    }
+
+    .ant-divider {
+      margin-bottom: 20px;
+    }
+
+    .ant-alert + .ant-btn {
+      margin-top: 12px;
+    }
+
+    /* 暗黑模式 */
+    &.ele-setting-dark .ele-setting-theme > div {
+      box-shadow: 0 1px 4px rgba(0, 0, 0, 0.55);
+
+      &:before,
+      &:after,
+      & > .ele-bg-primary {
+        background: #1f1f1f;
+      }
+
+      &.ele-side-dark:after,
+      &.ele-head-dark:before,
+      &.ele-layout-mix:before,
+      &.ele-layout-mix:after {
+        background: #262626;
+      }
+    }
+  }
+
+  /* 颜色选择器 */
+  .ele-setting-color-picker.ele-color-picker-trigger {
+    padding: 0;
+    width: 20px;
+    height: 20px;
+    margin-top: 8px;
+    border: none !important;
+    background: none !important;
+    box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
+
+    & > .ele-color-picker-trigger-inner {
+      background: none;
+
+      &.is-empty {
+        background: conic-gradient(
+          from 90deg at 50% 50%,
+          rgb(255, 0, 0) -19.41deg,
+          rgb(255, 0, 0) 18.76deg,
+          rgb(255, 138, 0) 59.32deg,
+          rgb(255, 230, 0) 99.87deg,
+          rgb(20, 255, 0) 141.65deg,
+          rgb(0, 163, 255) 177.72deg,
+          rgb(5, 0, 255) 220.23deg,
+          rgb(173, 0, 255) 260.13deg,
+          rgb(255, 0, 199) 300.69deg,
+          rgb(255, 0, 0) 340.59deg,
+          rgb(255, 0, 0) 378.76deg
+        );
+
+        & + .ele-color-picker-trigger-arrow {
+          display: none;
+        }
+      }
+    }
+  }
+</style>
diff --git a/src/layout/index.vue b/src/layout/index.vue
new file mode 100644
index 0000000..749ba16
--- /dev/null
+++ b/src/layout/index.vue
@@ -0,0 +1,343 @@
+<template>
+  <ele-pro-layout
+    :menus="menus"
+    :tabs="tabs"
+    :collapse="collapse"
+    :side-nav-collapse="sideNavCollapse"
+    :body-fullscreen="bodyFullscreen"
+    :show-tabs="showTabs"
+    :show-footer="showFooter"
+    :head-style="headStyle"
+    :side-style="sideStyle"
+    :layout-style="layoutStyle"
+    :side-menu-style="sideMenuStyle"
+    :tab-style="tabStyle"
+    :fixed-header="fixedHeader"
+    :fixed-sidebar="fixedSidebar"
+    :fixed-body="fixedBody"
+    :body-full="bodyFull"
+    :logo-auto-size="logoAutoSize"
+    :colorful-icon="colorfulIcon"
+    :side-unique-open="sideUniqueOpen"
+    :style-responsive="styleResponsive"
+    :project-name="projectName"
+    :hide-footers="HIDE_FOOTERS"
+    :hide-sidebars="HIDE_SIDEBARS"
+    :repeatable-tabs="REPEATABLE_TABS"
+    :home-title="HOME_TITLE || t('layout.home')"
+    :home-path="HOME_PATH"
+    :layout-path="LAYOUT_PATH"
+    :redirect-path="REDIRECT_PATH"
+    :tab-sortable="true"
+    :locale="locale"
+    :i18n="i18n"
+    @update:collapse="updateCollapse"
+    @update:side-nav-collapse="updateSideNavCollapse"
+    @update:body-fullscreen="updateBodyFullscreen"
+    @tab-add="addPageTab"
+    @tab-remove="removePageTab"
+    @tab-remove-all="removeAllPageTab"
+    @tab-remove-left="removeLeftPageTab"
+    @tab-remove-right="removeRightPageTab"
+    @tab-remove-other="removeOtherPageTab"
+    @tabSortChange="setPageTabs"
+    @reload-page="reloadPageTab"
+    @logo-click="onLogoClick"
+    @screen-size-change="screenSizeChange"
+    @set-home-components="setHomeComponents"
+    @tab-context-menu="onTabContextMenu"
+  >
+    <!-- 路由出口 -->
+    <router-layout />
+    <!-- logo 图标 -->
+    <template #logo>
+      <img src="/src/assets/logo.svg" alt="logo" />
+    </template>
+    <!-- 顶栏右侧区域 -->
+    <template #right>
+      <header-tools :fullscreen="fullscreen" @fullscreen="onFullscreen" />
+    </template>
+    <!-- 全局页脚 -->
+    <template #footer>
+      <page-footer />
+    </template>
+    <!-- 菜单图标 -->
+    <template #icon="{ icon }">
+      <component :is="icon" class="ant-menu-item-icon" />
+    </template>
+    <!-- 自定义菜单标题增加徽章、小红点 -->
+    <template #title="{ item }">
+      <menu-title :item="item" />
+    </template>
+    <template #top-title="{ item }">
+      <menu-title :item="item" />
+    </template>
+    <template #nav-title="{ item }">
+      <menu-title :item="item" />
+    </template>
+  </ele-pro-layout>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import { useRouter } from 'vue-router';
+  import { storeToRefs } from 'pinia';
+  import { useI18n } from 'vue-i18n';
+  import { message } from 'ant-design-vue/es';
+  import { toggleFullscreen, isFullscreen } from 'ele-admin-pro/es';
+  import { useUserStore } from '@/store/modules/user';
+  import { useThemeStore } from '@/store/modules/theme';
+  import RouterLayout from '@/components/RouterLayout/index.vue';
+  import HeaderTools from './components/header-tools.vue';
+  import PageFooter from './components/page-footer.vue';
+  import MenuTitle from './components/menu-title.vue';
+  import {
+    HIDE_SIDEBARS,
+    HIDE_FOOTERS,
+    REPEATABLE_TABS,
+    HOME_TITLE,
+    HOME_PATH,
+    LAYOUT_PATH,
+    REDIRECT_PATH,
+    I18N_ENABLE
+  } from '@/config/setting';
+  import {
+    addPageTab,
+    removePageTab,
+    removeAllPageTab,
+    removeLeftPageTab,
+    removeRightPageTab,
+    removeOtherPageTab,
+    reloadPageTab,
+    setHomeComponents,
+    setPageTabs
+  } from '@/utils/page-tab-util';
+  import type { TabCtxMenuOption } from 'ele-admin-pro/es/ele-pro-layout/types';
+
+  const { push } = useRouter();
+  const { t, locale } = useI18n();
+  const userStore = useUserStore();
+  const themeStore = useThemeStore();
+
+  // 项目名
+  const projectName = import.meta.env.VITE_APP_NAME as string;
+
+  // 是否全屏
+  const fullscreen = ref(false);
+
+  // 菜单数据
+  const { menus } = storeToRefs(userStore);
+
+  // 布局风格
+  const {
+    tabs,
+    collapse,
+    sideNavCollapse,
+    bodyFullscreen,
+    showTabs,
+    showFooter,
+    headStyle,
+    sideStyle,
+    layoutStyle,
+    sideMenuStyle,
+    tabStyle,
+    fixedHeader,
+    fixedSidebar,
+    fixedBody,
+    bodyFull,
+    logoAutoSize,
+    colorfulIcon,
+    sideUniqueOpen,
+    styleResponsive
+  } = storeToRefs(themeStore);
+
+  /* 侧栏折叠切换 */
+  const updateCollapse = (value: boolean) => {
+    themeStore.setCollapse(value);
+  };
+
+  /* 双侧栏一级折叠切换 */
+  const updateSideNavCollapse = (value: boolean) => {
+    themeStore.setSideNavCollapse(value);
+  };
+
+  /* 内容区域全屏切换 */
+  const updateBodyFullscreen = (value: boolean) => {
+    themeStore.setBodyFullscreen(value);
+  };
+
+  /* logo 点击事件 */
+  const onLogoClick = (isHome: boolean) => {
+    isHome || push(LAYOUT_PATH);
+  };
+
+  /* 监听屏幕尺寸改变 */
+  const screenSizeChange = () => {
+    themeStore.updateScreenSize();
+    fullscreen.value = isFullscreen();
+  };
+
+  /* 全屏切换 */
+  const onFullscreen = () => {
+    try {
+      fullscreen.value = toggleFullscreen();
+    } catch (e) {
+      message.error('您的浏览器不支持全屏模式');
+    }
+  };
+
+  /* 页签右键菜单点击事件 */
+  const onTabContextMenu = ({
+    key,
+    tabKey,
+    item,
+    active
+  }: TabCtxMenuOption) => {
+    switch (key) {
+      case 'reload': // 刷新
+        reloadPageTab({
+          isHome: !item,
+          fullPath: item?.fullPath ?? tabKey
+        });
+        break;
+      case 'close': // 关闭当前
+        removePageTab({
+          key: item?.fullPath ?? tabKey,
+          active
+        });
+        break;
+      case 'left': // 关闭左侧
+        removeLeftPageTab({
+          key: tabKey,
+          active
+        });
+        break;
+      case 'right': // 关闭右侧
+        removeRightPageTab({
+          key: tabKey,
+          active
+        });
+        break;
+      case 'other': // 关闭其他
+        removeOtherPageTab({
+          key: tabKey,
+          active
+        });
+        break;
+    }
+  };
+
+  /* 菜单标题国际化 */
+  const i18n = (_path: string, key?: string) => {
+    if (!I18N_ENABLE || !key) {
+      return;
+    }
+    const k = 'route.' + key + '._name';
+    const title = t(k);
+    if (title !== k) {
+      return title;
+    }
+  };
+</script>
+
+<script lang="ts">
+  import * as MenuIcons from './menu-icons';
+
+  export default {
+    name: 'EleLayout',
+    components: MenuIcons
+  };
+</script>
+
+<style lang="less">
+  // 侧栏菜单徽章样式,定位在右侧垂直居中并调小尺寸
+  .ele-menu-badge {
+    position: absolute;
+    top: 50%;
+    right: 14px;
+    line-height: 1;
+    margin-top: -9px;
+    font-size: 0;
+
+    .ant-badge-count {
+      height: 18px;
+      line-height: 18px;
+      border-radius: 9px;
+      box-shadow: none;
+      min-width: 18px;
+      padding: 0 4px;
+    }
+
+    .ant-scroll-number-only {
+      height: 18px;
+
+      & > p.ant-scroll-number-only-unit {
+        height: 18px;
+      }
+    }
+  }
+
+  // 父级菜单标题中右侧多定位一点,避免与箭头重合
+  .ant-menu-submenu-title > .ant-menu-title-content .ele-menu-badge {
+    right: 36px;
+  }
+
+  // 折叠悬浮中样式调整
+  .ant-menu-submenu-popup {
+    .ant-menu-submenu-title > .ant-menu-title-content .ele-menu-badge {
+      right: 30px;
+    }
+  }
+
+  // 顶栏菜单标题中样式调整
+  .ele-admin-header-nav > .ant-menu {
+    & > .ant-menu-item,
+    & > .ant-menu-submenu > .ant-menu-submenu-title {
+      & > .ant-menu-title-content .ele-menu-badge {
+        position: static;
+        right: auto;
+        top: auto;
+        display: inline-block;
+        vertical-align: 5px;
+        margin: 0 0 0 4px;
+      }
+    }
+  }
+
+  // 双侧栏时一级侧栏菜单中样式调整,定位在右上角
+  .ele-admin-sidebar-nav-menu > .ant-menu {
+    & > .ant-menu-item,
+    & > .ant-menu-submenu > .ant-menu-submenu-title {
+      & > .ant-menu-title-content .ele-menu-badge {
+        top: 0;
+        right: 0;
+        margin: 0;
+      }
+    }
+  }
+
+  // 双侧栏时一级侧栏菜单折叠后样式调整
+  .ele-admin-nav-collapse .ele-admin-sidebar-nav-menu > .ant-menu {
+    & > .ant-menu-item,
+    & > .ant-menu-submenu > .ant-menu-submenu-title {
+      & > .ant-menu-title-content .ele-menu-badge {
+        top: 0;
+        right: 0;
+      }
+    }
+  }
+
+  // 菜单折叠后在 tooltip 中不显示徽章
+  .ant-tooltip-inner .ele-menu-badge {
+    display: none;
+  }
+
+  // 分组菜单标题
+  .ant-menu-item-group-title {
+    position: relative;
+    font-size: 12px !important;
+    background: rgba(0, 0, 0, 0.02);
+    padding-top: 4px !important;
+    padding-bottom: 4px !important;
+  }
+</style>
diff --git a/src/layout/menu-icons.ts b/src/layout/menu-icons.ts
new file mode 100644
index 0000000..c3a45e4
--- /dev/null
+++ b/src/layout/menu-icons.ts
@@ -0,0 +1,48 @@
+/** 菜单用到的图标 */
+export {
+  HomeOutlined,
+  SettingOutlined,
+  TeamOutlined,
+  DesktopOutlined,
+  FileTextOutlined,
+  TableOutlined,
+  AppstoreOutlined,
+  CheckCircleOutlined,
+  ExclamationCircleOutlined,
+  UserOutlined,
+  TagOutlined,
+  IdcardOutlined,
+  BarChartOutlined,
+  AuditOutlined,
+  PicLeftOutlined,
+  CloseCircleOutlined,
+  QuestionCircleOutlined,
+  SoundOutlined,
+  ApartmentOutlined,
+  DashboardOutlined,
+  OneToOneOutlined,
+  DragOutlined,
+  InteractionOutlined,
+  BankOutlined,
+  BlockOutlined,
+  CheckSquareOutlined,
+  ProfileOutlined,
+  WarningOutlined,
+  FolderOutlined,
+  YoutubeOutlined,
+  ControlOutlined,
+  EllipsisOutlined,
+  CalendarOutlined,
+  AppstoreAddOutlined,
+  FileSearchOutlined,
+  EnvironmentOutlined,
+  CompassOutlined,
+  FontSizeOutlined,
+  SketchOutlined,
+  BgColorsOutlined,
+  PrinterOutlined,
+  QrcodeOutlined,
+  BarcodeOutlined,
+  PictureOutlined,
+  LinkOutlined
+} from '@ant-design/icons-vue';
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..876546c
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,16 @@
+import { createApp } from 'vue';
+import App from './App.vue';
+import store from './store';
+import router from './router';
+import permission from './utils/permission';
+import i18n from './i18n';
+import './styles/index.less';
+
+const app = createApp(App);
+
+app.use(store);
+app.use(router);
+app.use(permission);
+app.use(i18n);
+
+app.mount('#app');
diff --git a/src/router/index.ts b/src/router/index.ts
new file mode 100644
index 0000000..7bb8228
--- /dev/null
+++ b/src/router/index.ts
@@ -0,0 +1,63 @@
+/**
+ * 路由配置
+ */
+import NProgress from 'nprogress';
+import type { _RouteLocationBase } from 'vue-router';
+import { createRouter, createWebHistory } from 'vue-router';
+import { WHITE_LIST, REDIRECT_PATH, LAYOUT_PATH } from '@/config/setting';
+import { useUserStore } from '@/store/modules/user';
+import { getToken } from '@/utils/token-util';
+import { routes, getMenuRoutes } from './routes';
+
+NProgress.configure({
+  speed: 200,
+  minimum: 0.02,
+  trickleSpeed: 200,
+  showSpinner: false
+});
+
+const router = createRouter({
+  routes,
+  history: createWebHistory(),
+  scrollBehavior() {
+    return { top: 0 };
+  }
+});
+
+/**
+ * 路由守卫
+ */
+router.beforeEach(async (to, from) => {
+  if (!from.path.includes(REDIRECT_PATH)) {
+    NProgress.start();
+  }
+  if (!getToken()) {
+    // 未登录跳转登录界面
+    if (!WHITE_LIST.includes(to.path)) {
+      return {
+        path: '/login',
+        query: to.path === LAYOUT_PATH ? {} : { from: to.path }
+      };
+    }
+    return;
+  }
+  // 注册动态路由
+  const userStore = useUserStore();
+  if (!userStore.menus) {
+    const { menus, homePath } = await userStore.fetchUserInfo();
+    if (menus) {
+      router.addRoute(getMenuRoutes(menus, homePath));
+      return { ...to, replace: true };
+    }
+  }
+});
+
+router.afterEach((to) => {
+  if (!to.path.includes(REDIRECT_PATH) && NProgress.isStarted()) {
+    setTimeout(() => {
+      NProgress.done(true);
+    }, 200);
+  }
+});
+
+export default router;
diff --git a/src/router/routes.ts b/src/router/routes.ts
new file mode 100644
index 0000000..9b3a8d7
--- /dev/null
+++ b/src/router/routes.ts
@@ -0,0 +1,68 @@
+import type { RouteRecordRaw } from 'vue-router';
+import type { MenuItemType } from 'ele-admin-pro/es';
+import { menuToRoutes, eachTreeData } from 'ele-admin-pro/es';
+import { HOME_PATH, LAYOUT_PATH, REDIRECT_PATH } from '@/config/setting';
+import EleLayout from '@/layout/index.vue';
+import RedirectLayout from '@/components/RedirectLayout';
+const modules = import.meta.glob('/src/views/**/index.vue');
+
+/**
+ * 静态路由
+ */
+export const routes = [
+  {
+    path: '/login',
+    component: () => import('@/views/login/index.vue'),
+    meta: { title: '登录' }
+  },
+  {
+    path: '/forget',
+    component: () => import('@/views/forget/index.vue'),
+    meta: { title: '忘记密码' }
+  },
+  // 404
+  {
+    path: '/:path(.*)*',
+    component: () => import('@/views/exception/404/index.vue')
+  }
+];
+
+/**
+ * 动态路由
+ * @param menus 菜单数据
+ * @param homePath 主页地址
+ */
+export function getMenuRoutes(menus?: MenuItemType[], homePath?: string) {
+  const routes: RouteRecordRaw[] = [
+    // 用于刷新的路由
+    {
+      path: REDIRECT_PATH + '/:path(.*)',
+      component: RedirectLayout,
+      meta: { hideFooter: true }
+    }
+  ];
+  // 路由铺平处理
+  eachTreeData(menuToRoutes(menus, getComponent), (route) => {
+    routes.push(Object.assign({}, route, { children: void 0 }));
+  });
+  return {
+    path: LAYOUT_PATH,
+    component: EleLayout,
+    redirect: HOME_PATH ?? homePath,
+    children: routes
+  };
+}
+
+/**
+ * 解析路由组件
+ * @param component 组件名称
+ */
+function getComponent(component?: string) {
+  if (component) {
+    const module = modules[`/src/views/${component}.vue`];
+    if (!module) {
+      return modules[`/src/views/${component}/index.vue`];
+    }
+    return module;
+  }
+}
diff --git a/src/shims-vue.d.ts b/src/shims-vue.d.ts
new file mode 100644
index 0000000..fe7917e
--- /dev/null
+++ b/src/shims-vue.d.ts
@@ -0,0 +1,6 @@
+declare module '*.vue' {
+  import { DefineComponent } from 'vue';
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
+  const component: DefineComponent<{}, {}, any>;
+  export default component;
+}
diff --git a/src/store/index.ts b/src/store/index.ts
new file mode 100644
index 0000000..bf43b30
--- /dev/null
+++ b/src/store/index.ts
@@ -0,0 +1,6 @@
+/**
+ * pinia
+ */
+import { createPinia } from 'pinia';
+
+export default createPinia();
diff --git a/src/store/modules/theme.ts b/src/store/modules/theme.ts
new file mode 100644
index 0000000..8674cdb
--- /dev/null
+++ b/src/store/modules/theme.ts
@@ -0,0 +1,690 @@
+/**
+ * 主题状态管理
+ */
+import { defineStore } from 'pinia';
+import {
+  changeColor,
+  screenWidth,
+  screenHeight,
+  contentWidth,
+  contentHeight,
+  WEAK_CLASS,
+  BODY_LIMIT_CLASS,
+  DISABLES_CLASS
+} from 'ele-admin-pro/es';
+import type {
+  TabItem,
+  HeadStyleType,
+  SideStyleType,
+  LayoutStyleType,
+  SideMenuStyleType,
+  TabStyleType,
+  TabRemoveOption
+} from 'ele-admin-pro/es';
+import {
+  TAB_KEEP_ALIVE,
+  KEEP_ALIVE_EXCLUDES,
+  THEME_STORE_NAME
+} from '@/config/setting';
+
+/**
+ * state 默认值
+ */
+const DEFAULT_STATE: ThemeState = Object.freeze({
+  // 页签数据
+  tabs: [],
+  // 是否折叠侧栏
+  collapse: false,
+  // 是否折叠一级侧栏
+  sideNavCollapse: false,
+  // 内容区域是否全屏
+  bodyFullscreen: false,
+  // 是否开启页签栏
+  showTabs: true,
+  // 是否开启页脚
+  showFooter: true,
+  // 顶栏风格: light(亮色), dark(暗色), primary(主色)
+  headStyle: 'light',
+  // 侧栏风格: light(亮色), dark(暗色)
+  sideStyle: 'dark',
+  // 布局风格: side(默认), top(顶栏导航), mix(混合导航)
+  layoutStyle: 'side',
+  // 侧栏菜单风格: default(默认), mix(双排侧栏)
+  sideMenuStyle: 'default',
+  // 页签风格: default(默认), dot(圆点), card(卡片)
+  tabStyle: 'default',
+  // 路由切换动画
+  transitionName: 'slide-right',
+  // 是否固定顶栏
+  fixedHeader: false,
+  // 是否固定侧栏
+  fixedSidebar: true,
+  // 是否固定主体
+  fixedBody: true,
+  // 内容区域宽度铺满
+  bodyFull: true,
+  // logo 是否自适应宽度
+  logoAutoSize: false,
+  // 侧栏是否彩色图标
+  colorfulIcon: false,
+  // 侧栏是否只保持一个子菜单展开
+  sideUniqueOpen: true,
+  // 是否是色弱模式
+  weakMode: false,
+  // 是否是暗黑模式
+  darkMode: false,
+  // 主题色
+  color: null,
+  // 主页的组件名称
+  homeComponents: [],
+  // 刷新路由时的参数
+  routeReload: null,
+  // 屏幕宽度
+  screenWidth: screenWidth(),
+  // 屏幕高度
+  screenHeight: screenHeight(),
+  // 内容区域宽度
+  contentWidth: contentWidth(),
+  // 内容区域高度
+  contentHeight: contentHeight(),
+  // 是否开启响应式
+  styleResponsive: true
+});
+// 延时操作定时器
+let disableTransitionTimer: number, updateContentSizeTimer: number;
+
+/**
+ * 读取缓存配置
+ */
+function getCacheSetting(): any {
+  try {
+    const value = localStorage.getItem(THEME_STORE_NAME);
+    if (value) {
+      const cache = JSON.parse(value);
+      if (typeof cache === 'object') {
+        return cache;
+      }
+    }
+  } catch (e) {
+    console.error(e);
+  }
+  return {};
+}
+
+/**
+ * 缓存配置
+ */
+function cacheSetting(key: string, value: any) {
+  const cache = getCacheSetting();
+  if (cache[key] !== value) {
+    cache[key] = value;
+    localStorage.setItem(THEME_STORE_NAME, JSON.stringify(cache));
+  }
+}
+
+/**
+ * 开关响应式布局
+ */
+function changeStyleResponsive(styleResponsive: boolean) {
+  if (styleResponsive) {
+    document.body.classList.remove(BODY_LIMIT_CLASS);
+  } else {
+    document.body.classList.add(BODY_LIMIT_CLASS);
+  }
+}
+
+/**
+ * 切换色弱模式
+ */
+function changeWeakMode(weakMode: boolean) {
+  if (weakMode) {
+    document.body.classList.add(WEAK_CLASS);
+  } else {
+    document.body.classList.remove(WEAK_CLASS);
+  }
+}
+
+/**
+ * 切换主题
+ */
+function changeTheme(value?: string | null, dark?: boolean) {
+  return new Promise<void>((resolve, reject) => {
+    try {
+      changeColor(value, dark);
+      resolve();
+    } catch (e) {
+      reject(e);
+    }
+  });
+}
+
+/**
+ * 切换布局时禁用过渡动画
+ */
+function disableTransition() {
+  disableTransitionTimer && clearTimeout(disableTransitionTimer);
+  document.body.classList.add(DISABLES_CLASS);
+  disableTransitionTimer = setTimeout(() => {
+    document.body.classList.remove(DISABLES_CLASS);
+  }, 100) as unknown as number;
+}
+
+export const useThemeStore = defineStore({
+  id: 'theme',
+  state: (): ThemeState => {
+    const state = { ...DEFAULT_STATE };
+    // 读取本地缓存
+    const cache = getCacheSetting();
+    Object.keys(state).forEach((key) => {
+      if (typeof cache[key] !== 'undefined') {
+        state[key] = cache[key];
+      }
+    });
+    return state;
+  },
+  getters: {
+    // 需要 keep-alive 的组件
+    keepAliveInclude(): string[] {
+      if (!TAB_KEEP_ALIVE || !this.showTabs) {
+        return [];
+      }
+      const components = new Set<string>();
+      const { reloadPath, reloadHome } = this.routeReload || {};
+      this.tabs?.forEach((t) => {
+        const isAlive = t.meta?.keepAlive !== false;
+        const isExclude = KEEP_ALIVE_EXCLUDES.includes(t.path as string);
+        const isReload = reloadPath && reloadPath === t.fullPath;
+        if (isAlive && !isExclude && !isReload && t.components) {
+          t.components.forEach((c) => {
+            if (typeof c === 'string' && c) {
+              components.add(c);
+            }
+          });
+        }
+      });
+      if (!reloadHome) {
+        this.homeComponents?.forEach((c) => {
+          if (typeof c === 'string' && c) {
+            components.add(c);
+          }
+        });
+      }
+      return Array.from(components);
+    }
+  },
+  actions: {
+    setTabs(value: TabItem[]) {
+      this.tabs = value;
+      //cacheSetting('tabs', value);
+    },
+    setCollapse(value: boolean) {
+      this.collapse = value;
+      this.delayUpdateContentSize(800);
+    },
+    setSideNavCollapse(value: boolean) {
+      this.sideNavCollapse = value;
+      this.delayUpdateContentSize(800);
+    },
+    setBodyFullscreen(value: boolean) {
+      disableTransition();
+      this.bodyFullscreen = value;
+      this.delayUpdateContentSize(800);
+    },
+    setShowTabs(value: boolean) {
+      this.showTabs = value;
+      cacheSetting('showTabs', value);
+      this.delayUpdateContentSize();
+    },
+    setShowFooter(value: boolean) {
+      this.showFooter = value;
+      cacheSetting('showFooter', value);
+      this.delayUpdateContentSize();
+    },
+    setHeadStyle(value: HeadStyleType) {
+      this.headStyle = value;
+      cacheSetting('headStyle', value);
+    },
+    setSideStyle(value: SideStyleType) {
+      this.sideStyle = value;
+      cacheSetting('sideStyle', value);
+    },
+    setLayoutStyle(value: LayoutStyleType) {
+      disableTransition();
+      this.layoutStyle = value;
+      cacheSetting('layoutStyle', value);
+      this.delayUpdateContentSize();
+    },
+    setSideMenuStyle(value: SideMenuStyleType) {
+      disableTransition();
+      this.sideMenuStyle = value;
+      cacheSetting('sideMenuStyle', value);
+      this.delayUpdateContentSize();
+    },
+    setTabStyle(value: TabStyleType) {
+      this.tabStyle = value;
+      cacheSetting('tabStyle', value);
+    },
+    setTransitionName(value: string) {
+      this.transitionName = value;
+      cacheSetting('transitionName', value);
+    },
+    setFixedHeader(value: boolean) {
+      disableTransition();
+      this.fixedHeader = value;
+      cacheSetting('fixedHeader', value);
+    },
+    setFixedSidebar(value: boolean) {
+      disableTransition();
+      this.fixedSidebar = value;
+      cacheSetting('fixedSidebar', value);
+    },
+    setFixedBody(value: boolean) {
+      disableTransition();
+      this.fixedBody = value;
+      cacheSetting('fixedBody', value);
+    },
+    setBodyFull(value: boolean) {
+      this.bodyFull = value;
+      cacheSetting('bodyFull', value);
+      this.delayUpdateContentSize();
+    },
+    setLogoAutoSize(value: boolean) {
+      disableTransition();
+      this.logoAutoSize = value;
+      cacheSetting('logoAutoSize', value);
+    },
+    setColorfulIcon(value: boolean) {
+      this.colorfulIcon = value;
+      cacheSetting('colorfulIcon', value);
+    },
+    setSideUniqueOpen(value: boolean) {
+      this.sideUniqueOpen = value;
+      cacheSetting('sideUniqueOpen', value);
+    },
+    setStyleResponsive(value: boolean) {
+      changeStyleResponsive(value);
+      this.styleResponsive = value;
+      cacheSetting('styleResponsive', value);
+    },
+    /**
+     * 切换色弱模式
+     * @param value 是否是色弱模式
+     */
+    setWeakMode(value: boolean) {
+      return new Promise<void>((resolve) => {
+        changeWeakMode(value);
+        this.weakMode = value;
+        cacheSetting('weakMode', value);
+        resolve();
+      });
+    },
+    /**
+     * 切换暗黑模式
+     * @param value 是否是暗黑模式
+     */
+    setDarkMode(value: boolean) {
+      return new Promise<void>((resolve, reject) => {
+        changeTheme(this.color, value)
+          .then(() => {
+            this.darkMode = value;
+            cacheSetting('darkMode', value);
+            resolve();
+          })
+          .catch((e) => {
+            reject(e);
+          });
+      });
+    },
+    /**
+     * 切换主题色
+     * @param value 主题色
+     */
+    setColor(value?: string) {
+      return new Promise<void>((resolve, reject) => {
+        changeTheme(value, this.darkMode)
+          .then(() => {
+            this.color = value;
+            cacheSetting('color', value);
+            resolve();
+          })
+          .catch((e) => {
+            reject(e);
+          });
+      });
+    },
+    /**
+     * 设置主页路由对应的组件名称
+     * @param components 组件名称
+     */
+    setHomeComponents(components: string[]) {
+      this.homeComponents = components;
+    },
+    /**
+     * 设置刷新路由信息
+     * @param option 路由刷新参数
+     */
+    setRouteReload(option: RouteReloadOption | null) {
+      this.routeReload = option;
+    },
+    /**
+     * 更新屏幕尺寸
+     */
+    updateScreenSize() {
+      this.screenWidth = screenWidth();
+      this.screenHeight = screenHeight();
+      this.updateContentSize();
+    },
+    /**
+     * 更新内容区域尺寸
+     */
+    updateContentSize() {
+      this.contentWidth = contentWidth();
+      this.contentHeight = contentHeight();
+    },
+    /**
+     * 延时更新内容区域尺寸
+     * @param delay 延迟时间
+     */
+    delayUpdateContentSize(delay?: number) {
+      updateContentSizeTimer && clearTimeout(updateContentSizeTimer);
+      updateContentSizeTimer = setTimeout(() => {
+        this.updateContentSize();
+      }, delay ?? 100) as unknown as number;
+    },
+    /**
+     * 重置设置
+     */
+    resetSetting() {
+      return new Promise<void>((resolve, reject) => {
+        disableTransition();
+        this.showTabs = DEFAULT_STATE.showTabs;
+        this.showFooter = DEFAULT_STATE.showFooter;
+        this.headStyle = DEFAULT_STATE.headStyle;
+        this.sideStyle = DEFAULT_STATE.sideStyle;
+        this.layoutStyle = DEFAULT_STATE.layoutStyle;
+        this.sideMenuStyle = DEFAULT_STATE.sideMenuStyle;
+        this.tabStyle = DEFAULT_STATE.tabStyle;
+        this.transitionName = DEFAULT_STATE.transitionName;
+        this.fixedHeader = DEFAULT_STATE.fixedHeader;
+        this.fixedSidebar = DEFAULT_STATE.fixedSidebar;
+        this.fixedBody = DEFAULT_STATE.fixedBody;
+        this.bodyFull = DEFAULT_STATE.bodyFull;
+        this.logoAutoSize = DEFAULT_STATE.logoAutoSize;
+        this.colorfulIcon = DEFAULT_STATE.colorfulIcon;
+        this.sideUniqueOpen = DEFAULT_STATE.sideUniqueOpen;
+        this.styleResponsive = DEFAULT_STATE.styleResponsive;
+        this.weakMode = DEFAULT_STATE.weakMode;
+        this.darkMode = DEFAULT_STATE.darkMode;
+        this.color = DEFAULT_STATE.color;
+        localStorage.removeItem(THEME_STORE_NAME);
+        Promise.all([
+          changeStyleResponsive(this.styleResponsive),
+          changeWeakMode(this.weakMode),
+          changeTheme(this.color, this.darkMode)
+        ])
+          .then(() => {
+            resolve();
+          })
+          .catch((e) => {
+            reject(e);
+          });
+      });
+    },
+    /**
+     * 恢复主题
+     */
+    recoverTheme() {
+      // 关闭响应式布局
+      if (!this.styleResponsive) {
+        changeStyleResponsive(false);
+      }
+      // 恢复色弱模式
+      if (this.weakMode) {
+        changeWeakMode(true);
+      }
+      // 恢复主题色
+      if (this.color || this.darkMode) {
+        changeTheme(this.color, this.darkMode).catch((e) => {
+          console.error(e);
+        });
+      }
+    },
+    /**
+     * 添加页签或更新相同 key 的页签数据
+     * @param data 页签数据
+     */
+    tabAdd(data: TabItem | TabItem[]) {
+      if (Array.isArray(data)) {
+        data.forEach((d) => {
+          this.tabAdd(d);
+        });
+        return;
+      }
+      const i = this.tabs.findIndex((d) => d.key === data.key);
+      if (i === -1) {
+        this.setTabs(this.tabs.concat([data]));
+      } else if (data.fullPath !== this.tabs[i].fullPath) {
+        this.setTabs(
+          this.tabs
+            .slice(0, i)
+            .concat([data])
+            .concat(this.tabs.slice(i + 1))
+        );
+      }
+    },
+    /**
+     * 关闭页签
+     * @param key 页签 key
+     */
+    async tabRemove({
+      key,
+      active
+    }: TabRemoveOption): Promise<TabRemoveResult> {
+      const i = this.tabs.findIndex((t) => t.key === key || t.fullPath === key);
+      if (i === -1) {
+        return {};
+      }
+      const t = this.tabs[i];
+      if (!t.closable) {
+        return Promise.reject();
+      }
+      const path = this.tabs[i - 1]?.fullPath;
+      this.setTabs(this.tabs.filter((_d, j) => j !== i));
+      return t.key === active ? { path, home: !path } : {};
+    },
+    /**
+     * 关闭左侧页签
+     */
+    async tabRemoveLeft({
+      key,
+      active
+    }: TabRemoveOption): Promise<TabRemoveResult> {
+      let index = -1; // 选中页签的 index
+      for (let i = 0; i < this.tabs.length; i++) {
+        if (this.tabs[i].key === active) {
+          index = i;
+        }
+        if (this.tabs[i].key === key) {
+          if (i === 0) {
+            break;
+          }
+          const temp = this.tabs.filter((d, j) => !d.closable && j < i);
+          if (temp.length === i + 1) {
+            break;
+          }
+          const path = index === -1 ? void 0 : this.tabs[i].fullPath;
+          this.setTabs(temp.concat(this.tabs.slice(i)));
+          return { path };
+        }
+      }
+      return Promise.reject();
+    },
+    /**
+     * 关闭右侧页签
+     */
+    async tabRemoveRight({
+      key,
+      active
+    }: TabRemoveOption): Promise<TabRemoveResult> {
+      if (this.tabs.length) {
+        let index = -1; // 选中页签的 index
+        for (let i = 0; i < this.tabs.length; i++) {
+          if (this.tabs[i].key === active) {
+            index = i;
+          }
+          if (this.tabs[i].key === key) {
+            if (i === this.tabs.length - 1) {
+              return Promise.reject();
+            }
+            const temp = this.tabs.filter((d, j) => !d.closable && j > i);
+            if (temp.length === this.tabs.length - i - 1) {
+              return Promise.reject();
+            }
+            const path = index === -1 ? this.tabs[i].fullPath : void 0;
+            this.setTabs(
+              this.tabs
+                .slice(0, i + 1)
+                .concat(this.tabs.filter((d, j) => !d.closable && j > i))
+            );
+            return { path };
+          }
+        }
+        // 主页时关闭全部
+        const temp = this.tabs.filter((d) => !d.closable);
+        if (temp.length !== this.tabs.length) {
+          this.setTabs(temp);
+          return { home: index !== -1 };
+        }
+      }
+      return Promise.reject();
+    },
+    /**
+     * 关闭其它页签
+     */
+    async tabRemoveOther({
+      key,
+      active
+    }: TabRemoveOption): Promise<TabRemoveResult> {
+      let index = -1; // 选中页签的 index
+      let path: string | undefined; // 关闭后跳转的 path
+      const temp = this.tabs.filter((d, i) => {
+        if (d.key === active) {
+          index = i;
+        }
+        if (d.key === key) {
+          path = d.fullPath;
+        }
+        return !d.closable || d.key === key;
+      });
+      if (temp.length === this.tabs.length) {
+        return Promise.reject();
+      }
+      this.setTabs(temp);
+      if (index === -1) {
+        return {};
+      }
+      return key === active ? {} : { path, home: !path };
+    },
+    /**
+     * 关闭全部页签
+     * @param active 选中页签的 key
+     */
+    async tabRemoveAll(active: string): Promise<TabRemoveResult> {
+      const t = this.tabs.find((d) => d.key === active);
+      const home = typeof t !== 'undefined' && t.closable === true; // 是否跳转主页
+      const temp = this.tabs.filter((d) => !d.closable);
+      if (temp.length === this.tabs.length) {
+        return Promise.reject();
+      }
+      this.setTabs(temp);
+      return { home };
+    },
+    /**
+     * 修改页签
+     * @param data 页签数据
+     */
+    tabSetItem(data: TabItem) {
+      let i = -1;
+      if (data.key) {
+        i = this.tabs.findIndex((d) => d.key === data.key);
+      } else if (data.fullPath) {
+        i = this.tabs.findIndex((d) => d.fullPath === data.fullPath);
+      } else if (data.path) {
+        i = this.tabs.findIndex((d) => d.path === data.path);
+      }
+      if (i !== -1) {
+        const item = { ...this.tabs[i] };
+        if (data.title) {
+          item.title = data.title;
+        }
+        if (typeof data.closable === 'boolean') {
+          item.closable = data.closable;
+        }
+        if (data.components) {
+          item.components = data.components;
+        }
+        this.setTabs(
+          this.tabs
+            .slice(0, i)
+            .concat([item])
+            .concat(this.tabs.slice(i + 1))
+        );
+      }
+    }
+  }
+});
+
+/**
+ * 主题 State 类型
+ */
+export interface ThemeState {
+  tabs: TabItem[];
+  collapse: boolean;
+  sideNavCollapse: boolean;
+  bodyFullscreen: boolean;
+  showTabs: boolean;
+  showFooter: boolean;
+  headStyle: HeadStyleType;
+  sideStyle: SideStyleType;
+  layoutStyle: LayoutStyleType;
+  sideMenuStyle: SideMenuStyleType;
+  tabStyle: TabStyleType;
+  transitionName: string;
+  fixedHeader: boolean;
+  fixedSidebar: boolean;
+  fixedBody: boolean;
+  bodyFull: boolean;
+  logoAutoSize: boolean;
+  colorfulIcon: boolean;
+  sideUniqueOpen: boolean;
+  weakMode: boolean;
+  darkMode: boolean;
+  color?: string | null;
+  homeComponents: string[];
+  routeReload: RouteReloadOption | null;
+  screenWidth: number;
+  screenHeight: number;
+  contentWidth: number;
+  contentHeight: number;
+  styleResponsive: boolean;
+}
+
+/**
+ * 设置路由刷新方法的参数
+ */
+export interface RouteReloadOption {
+  // 是否是刷新主页
+  reloadHome?: boolean;
+  // 要刷新的页签路由地址
+  reloadPath?: string;
+}
+
+/**
+ * 关闭页签返回类型
+ */
+export interface TabRemoveResult {
+  // 关闭后要跳转的地址
+  path?: string;
+  // 关闭后是否跳转到主页
+  home?: boolean;
+}
diff --git a/src/store/modules/user.ts b/src/store/modules/user.ts
new file mode 100644
index 0000000..9592267
--- /dev/null
+++ b/src/store/modules/user.ts
@@ -0,0 +1,93 @@
+/**
+ * 登录用户 store
+ */
+import { defineStore } from 'pinia';
+import { formatMenus, toTreeData, formatTreeData } from 'ele-admin-pro/es';
+import type { MenuItemType } from 'ele-admin-pro/es';
+import type { User } from '@/api/system/user/model';
+import { USER_MENUS } from '@/config/setting';
+import { getUserInfo } from '@/api/layout';
+const EXTRA_MENUS: any = [];
+
+export interface UserState {
+  info: User | null;
+  menus: MenuItemType[] | null | undefined;
+  authorities: (string | undefined)[];
+  roles: (string | undefined)[];
+}
+
+export const useUserStore = defineStore({
+  id: 'user',
+  state: (): UserState => ({
+    // 当前登录用户的信息
+    info: null,
+    // 当前登录用户的菜单
+    menus: null,
+    // 当前登录用户的权限
+    authorities: [],
+    // 当前登录用户的角色
+    roles: []
+  }),
+  getters: {},
+  actions: {
+    /**
+     * 请求用户信息、权限、角色、菜单
+     */
+    async fetchUserInfo() {
+      const result = await getUserInfo().catch(() => {});
+      if (!result) {
+        return {};
+      }
+      // 用户信息
+      this.info = result;
+      // 用户权限
+      this.authorities =
+        result.authorities
+          ?.filter((d) => !!d.authority)
+          ?.map((d) => d.authority) ?? [];
+      // 用户角色
+      this.roles = result.roles?.map((d) => d.roleCode) ?? [];
+      // 用户菜单, 过滤掉按钮类型并转为 children 形式
+      const { menus, homePath } = formatMenus(
+        USER_MENUS ??
+          toTreeData({
+            data: result.authorities?.filter((d) => d.menuType !== 1),
+            idField: 'menuId',
+            parentIdField: 'parentId'
+          }).concat(EXTRA_MENUS)
+      );
+      this.menus = menus;
+      return { menus, homePath };
+    },
+    /**
+     * 更新用户信息
+     */
+    setInfo(value: User) {
+      this.info = value;
+    },
+    /**
+     * 更新菜单数据
+     */
+    setMenus(menus: MenuItemType[]) {
+      this.menus = menus;
+    },
+    /**
+     * 更新菜单的 badge
+     */
+    setMenuBadge(path: string, value?: number | string, color?: string) {
+      this.menus = formatTreeData(this.menus, (m) => {
+        if (path === m.path) {
+          return {
+            ...m,
+            meta: {
+              ...m.meta,
+              badge: value,
+              badgeColor: color
+            }
+          };
+        }
+        return m;
+      });
+    }
+  }
+});
diff --git a/src/styles/as-needed.less b/src/styles/as-needed.less
new file mode 100644
index 0000000..1bda515
--- /dev/null
+++ b/src/styles/as-needed.less
@@ -0,0 +1,6 @@
+/** 按需引入 */
+@import 'ant-design-vue/es/message/style/index.less';
+@import 'ant-design-vue/es/notification/style/index.less';
+@import 'ele-admin-pro/es/style/nprogress.less';
+@import 'ele-admin-pro/es/style/display.less';
+@import 'ele-admin-pro/es/style/common.less';
diff --git a/src/styles/global-import.less b/src/styles/global-import.less
new file mode 100644
index 0000000..0d47070
--- /dev/null
+++ b/src/styles/global-import.less
@@ -0,0 +1,5 @@
+/** 全局引入 */
+@import 'cropperjs/dist/cropper.css';
+@import 'xgplayer/dist/index.min.css';
+@import 'ant-design-vue/dist/antd.less';
+@import 'ele-admin-pro/es/style/index.less';
diff --git a/src/styles/index.less b/src/styles/index.less
new file mode 100644
index 0000000..33b2f2f
--- /dev/null
+++ b/src/styles/index.less
@@ -0,0 +1,7 @@
+/** 全局样式 */
+@style-entry-file: as-needed;
+@import './@{style-entry-file}.less';
+@import './transition/index.less';
+
+// 主题
+@import 'ele-admin-pro/es/style/themes/dynamic.less';
diff --git a/src/styles/transition/fade.less b/src/styles/transition/fade.less
new file mode 100644
index 0000000..9433fc0
--- /dev/null
+++ b/src/styles/transition/fade.less
@@ -0,0 +1,10 @@
+/* 渐变 */
+.fade-enter-active,
+.fade-leave-active {
+  transition: opacity 0.2s ease-in-out;
+}
+
+.fade-enter-from,
+.fade-leave-to {
+  opacity: 0;
+}
diff --git a/src/styles/transition/index.less b/src/styles/transition/index.less
new file mode 100644
index 0000000..be030fb
--- /dev/null
+++ b/src/styles/transition/index.less
@@ -0,0 +1,4 @@
+/** 路由切换动画 */
+@import './fade.less';
+@import './slide.less';
+@import './zoom.less';
diff --git a/src/styles/transition/slide.less b/src/styles/transition/slide.less
new file mode 100644
index 0000000..b1336e1
--- /dev/null
+++ b/src/styles/transition/slide.less
@@ -0,0 +1,31 @@
+/* 底部消退 */
+.slide-bottom-enter-active,
+.slide-bottom-leave-active {
+  transition: opacity 0.2s ease-out, transform 0.25s ease-out;
+}
+
+.slide-bottom-enter-from {
+  opacity: 0;
+  transform: translateY(-10%);
+}
+
+.slide-bottom-leave-to {
+  opacity: 0;
+  transform: translateY(10%);
+}
+
+/* 右侧消退 */
+.slide-right-leave-active,
+.slide-right-enter-active {
+  transition: opacity 0.2s ease-out, transform 0.25s ease-out;
+}
+
+.slide-right-enter-from {
+  opacity: 0;
+  transform: translateX(-60px);
+}
+
+.slide-right-leave-to {
+  opacity: 0;
+  transform: translateX(60px);
+}
diff --git a/src/styles/transition/zoom.less b/src/styles/transition/zoom.less
new file mode 100644
index 0000000..28fe401
--- /dev/null
+++ b/src/styles/transition/zoom.less
@@ -0,0 +1,31 @@
+/* 放大渐变 */
+.zoom-in-enter-active,
+.zoom-in-leave-active {
+  transition: opacity 0.2s ease-out, transform 0.25s ease-out;
+}
+
+.zoom-in-enter-from {
+  opacity: 0;
+  transform: scale(0.9);
+}
+
+.zoom-in-leave-to {
+  opacity: 0;
+  transform: scale(1.1);
+}
+
+/* 缩小渐变 */
+.zoom-out-leave-active,
+.zoom-out-enter-active {
+  transition: opacity 0.2s ease-out, transform 0.25s ease-out;
+}
+
+.zoom-out-enter-from {
+  opacity: 0;
+  transform: scale(1.2);
+}
+
+.zoom-out-leave-to {
+  opacity: 0;
+  transform: scale(0.8);
+}
diff --git a/src/utils/document-title-util.ts b/src/utils/document-title-util.ts
new file mode 100644
index 0000000..fc8202e
--- /dev/null
+++ b/src/utils/document-title-util.ts
@@ -0,0 +1,68 @@
+import { watch } from 'vue';
+import { useRouter } from 'vue-router';
+import { useI18n } from 'vue-i18n';
+import type { RouteLocationNormalizedLoaded } from 'vue-router';
+import {
+  routeI18nKey,
+  findTabByPath
+} from 'ele-admin-pro/es/ele-pro-layout/util';
+import { storeToRefs } from 'pinia';
+import { useThemeStore } from '@/store/modules/theme';
+import { PROJECT_NAME, REDIRECT_PATH, I18N_ENABLE } from '@/config/setting';
+
+/**
+ * 修改浏览器标题
+ * @param title 标题
+ */
+export function setDocumentTitle(title: string) {
+  const names: string[] = [];
+  if (title) {
+    names.push(title);
+  }
+  if (PROJECT_NAME) {
+    names.push(PROJECT_NAME);
+  }
+  document.title = names.join(' - ');
+}
+
+/**
+ * 路由切换更新浏览器标题
+ */
+export function useSetDocumentTitle() {
+  const { currentRoute } = useRouter();
+  const { t, locale } = useI18n();
+  const themeStore = useThemeStore();
+  const { tabs } = storeToRefs(themeStore);
+
+  const updateTitle = (route: RouteLocationNormalizedLoaded) => {
+    const { path, meta, fullPath } = route;
+    if (path.includes(REDIRECT_PATH)) {
+      return;
+    }
+    const pathKey = routeI18nKey(path);
+    if (!pathKey) {
+      return;
+    }
+    const tab = findTabByPath(fullPath, tabs.value);
+    const title = tab?.title || (meta?.title as string);
+    if (!I18N_ENABLE) {
+      setDocumentTitle(title);
+      return;
+    }
+    const k = `route.${pathKey}._name`;
+    const v = t(k);
+    setDocumentTitle(v === k || !v ? title : v);
+  };
+
+  watch(
+    currentRoute,
+    (route) => {
+      updateTitle(route);
+    },
+    { immediate: true }
+  );
+
+  watch(locale, () => {
+    updateTitle(currentRoute.value);
+  });
+}
diff --git a/src/utils/on-size-change.ts b/src/utils/on-size-change.ts
new file mode 100644
index 0000000..4cce32c
--- /dev/null
+++ b/src/utils/on-size-change.ts
@@ -0,0 +1,18 @@
+/**
+ * 监听屏幕尺寸改变封装
+ */
+import { watch } from 'vue';
+import { storeToRefs } from 'pinia';
+import { useThemeStore } from '@/store/modules/theme';
+
+export function onSizeChange(hook: Function) {
+  if (!hook) {
+    return;
+  }
+  const themeStore = useThemeStore();
+  const { contentWidth } = storeToRefs(themeStore);
+
+  watch(contentWidth, () => {
+    hook();
+  });
+}
diff --git a/src/utils/page-tab-util.ts b/src/utils/page-tab-util.ts
new file mode 100644
index 0000000..e651736
--- /dev/null
+++ b/src/utils/page-tab-util.ts
@@ -0,0 +1,262 @@
+/**
+ * 页签操作封装
+ */
+import { unref } from 'vue';
+import type { RouteLocationNormalizedLoaded } from 'vue-router';
+import type { TabItem, TabRemoveOption } from 'ele-admin-pro/es';
+import { message } from 'ant-design-vue/es';
+import router from '@/router';
+import { useThemeStore } from '@/store/modules/theme';
+import type { RouteReloadOption } from '@/store/modules/theme';
+import { removeToken } from '@/utils/token-util';
+import { setDocumentTitle } from '@/utils/document-title-util';
+import {
+  HOME_PATH,
+  LAYOUT_PATH,
+  REDIRECT_PATH,
+  REPEATABLE_TABS
+} from '@/config/setting';
+const HOME_ROUTE = HOME_PATH || LAYOUT_PATH;
+const BASE_URL = import.meta.env.BASE_URL;
+
+/**
+ * 刷新页签参数类型
+ */
+export interface TabReloadOptions {
+  // 是否是主页
+  isHome?: boolean;
+  // 路由地址
+  fullPath?: string;
+}
+
+/**
+ * 刷新当前路由
+ */
+export function reloadPageTab(option?: TabReloadOptions) {
+  if (!option) {
+    // 刷新当前路由
+    const { path, fullPath, query } = unref(router.currentRoute);
+    if (path.includes(REDIRECT_PATH)) {
+      return;
+    }
+    const isHome = isHomeRoute(unref(router.currentRoute));
+    setRouteReload({
+      reloadHome: isHome,
+      reloadPath: isHome ? void 0 : fullPath
+    });
+    router.replace({
+      path: REDIRECT_PATH + path,
+      query
+    });
+  } else {
+    // 刷新指定页签
+    const { fullPath, isHome } = option;
+    setRouteReload({
+      reloadHome: isHome,
+      reloadPath: isHome ? void 0 : fullPath
+    });
+    router.replace(REDIRECT_PATH + fullPath);
+  }
+}
+
+/**
+ * 关闭当前页签
+ */
+export function finishPageTab() {
+  const key = getRouteTabKey();
+  removePageTab({ key, active: key });
+}
+
+/**
+ * 关闭页签
+ */
+export function removePageTab(option: TabRemoveOption) {
+  useThemeStore()
+    .tabRemove(option)
+    .then(({ path, home }) => {
+      if (path) {
+        router.push(path);
+      } else if (home) {
+        router.push(HOME_ROUTE);
+      }
+    })
+    .catch(() => {
+      message.error('当前页签不可关闭');
+    });
+}
+
+/**
+ * 关闭左侧页签
+ */
+export function removeLeftPageTab(option: TabRemoveOption) {
+  useThemeStore()
+    .tabRemoveLeft(option)
+    .then(({ path }) => {
+      if (path) {
+        router.push(path);
+      }
+    })
+    .catch(() => {
+      message.error('左侧没有可关闭的页签');
+    });
+}
+
+/**
+ * 关闭右侧页签
+ */
+export function removeRightPageTab(option: TabRemoveOption) {
+  useThemeStore()
+    .tabRemoveRight(option)
+    .then(({ path, home }) => {
+      if (path) {
+        router.push(path);
+      } else if (home) {
+        router.push(HOME_ROUTE);
+      }
+    })
+    .catch(() => {
+      message.error('右侧没有可关闭的页签');
+    });
+}
+
+/**
+ * 关闭其它页签
+ */
+export function removeOtherPageTab(option: TabRemoveOption) {
+  useThemeStore()
+    .tabRemoveOther(option)
+    .then(({ path, home }) => {
+      if (path) {
+        router.push(path);
+      } else if (home) {
+        router.push(HOME_ROUTE);
+      }
+    })
+    .catch(() => {
+      message.error('没有可关闭的页签');
+    });
+}
+
+/**
+ * 关闭全部页签
+ * @param active 当前选中页签
+ */
+export function removeAllPageTab(active: string) {
+  useThemeStore()
+    .tabRemoveAll(active)
+    .then(({ home }) => {
+      if (home) {
+        router.push(HOME_ROUTE);
+      }
+    })
+    .catch(() => {
+      message.error('没有可关闭的页签');
+    });
+}
+
+/**
+ * 登录成功后清空页签
+ */
+export function cleanPageTabs() {
+  useThemeStore().setTabs([]);
+}
+
+/**
+ * 添加页签
+ * @param data 页签数据
+ */
+export function addPageTab(data: TabItem | TabItem[]) {
+  useThemeStore().tabAdd(data);
+}
+
+/**
+ * 修改页签
+ * @param data 页签数据
+ */
+export function setPageTab(data: TabItem) {
+  useThemeStore().tabSetItem(data);
+}
+
+/**
+ * 更新页签数据
+ * @param data 页签数据
+ */
+export function setPageTabs(data: TabItem[]) {
+  useThemeStore().setTabs(data);
+}
+
+/**
+ * 修改页签标题
+ * @param title 标题
+ */
+export function setPageTabTitle(title: string) {
+  setPageTab({ key: getRouteTabKey(), title });
+  setDocumentTitle(title);
+}
+
+/**
+ * 获取当前路由对应的页签 key
+ */
+export function getRouteTabKey() {
+  const { path, fullPath, meta } = unref(router.currentRoute);
+  const isUnique = meta.tabUnique === false || REPEATABLE_TABS.includes(path);
+  return isUnique ? fullPath : path;
+}
+
+/**
+ * 设置主页的组件名称
+ * @param components 组件名称
+ */
+export function setHomeComponents(components: string[]) {
+  useThemeStore().setHomeComponents(components);
+}
+
+/**
+ * 设置路由刷新信息
+ * @param option 路由刷新参数
+ */
+export function setRouteReload(option: RouteReloadOption | null) {
+  return useThemeStore().setRouteReload(option);
+}
+
+/**
+ * 判断路由是否是主页
+ * @param route 路由信息
+ */
+export function isHomeRoute(route: RouteLocationNormalizedLoaded) {
+  const { path, matched } = route;
+  if (HOME_ROUTE === path) {
+    return true;
+  }
+  return (
+    matched[0] &&
+    matched[0].path === LAYOUT_PATH &&
+    matched[0].redirect === path
+  );
+}
+
+/**
+ * 登录成功后跳转首页
+ * @param from 登录前的地址
+ */
+export function goHomeRoute(from?: string) {
+  router.replace(from || HOME_ROUTE);
+}
+
+/**
+ * 退出登录
+ * @param route 是否使用路由跳转
+ * @param from 登录后跳转的地址
+ */
+export function logout(route?: boolean, from?: string) {
+  removeToken();
+  if (route) {
+    router.push({
+      path: '/login',
+      query: from ? { from } : void 0
+    });
+  } else {
+    // 这样跳转避免再次登录重复注册动态路由
+    location.replace(BASE_URL + 'login' + (from ? '?from=' + from : ''));
+  }
+}
diff --git a/src/utils/permission.ts b/src/utils/permission.ts
new file mode 100644
index 0000000..4324d98
--- /dev/null
+++ b/src/utils/permission.ts
@@ -0,0 +1,119 @@
+/**
+ * 按钮级权限控制
+ */
+import type { App } from 'vue';
+import { useUserStore } from '@/store/modules/user';
+
+/* 判断数组是否有某些值 */
+function arrayHas(
+  array: (string | undefined)[],
+  value: string | string[]
+): boolean {
+  if (!value) {
+    return true;
+  }
+  if (!array) {
+    return false;
+  }
+  if (Array.isArray(value)) {
+    for (let i = 0; i < value.length; i++) {
+      if (array.indexOf(value[i]) === -1) {
+        return false;
+      }
+    }
+    return true;
+  }
+  return array.indexOf(value) !== -1;
+}
+
+/* 判断数组是否有任意值 */
+function arrayHasAny(
+  array: (string | undefined)[],
+  value: string | string[]
+): boolean {
+  if (!value) {
+    return true;
+  }
+  if (!array) {
+    return false;
+  }
+  if (Array.isArray(value)) {
+    for (let i = 0; i < value.length; i++) {
+      if (array.indexOf(value[i]) !== -1) {
+        return true;
+      }
+    }
+    return false;
+  }
+  return array.indexOf(value) !== -1;
+}
+
+/**
+ * 是否有某些角色
+ * @param value 角色字符或字符数组
+ */
+export function hasRole(value: string | string[]): boolean {
+  const userStore = useUserStore();
+  return arrayHas(userStore?.roles, value);
+}
+
+/**
+ * 是否有任意角色
+ * @param value 角色字符或字符数组
+ */
+export function hasAnyRole(value: string | string[]): boolean {
+  const userStore = useUserStore();
+  return arrayHasAny(userStore?.roles, value);
+}
+
+/**
+ * 是否有某些权限
+ * @param value 权限字符或字符数组
+ */
+export function hasPermission(value: string | string[]): boolean {
+  const userStore = useUserStore();
+  return arrayHas(userStore?.authorities, value);
+}
+
+/**
+ * 是否有任意权限
+ * @param value 权限字符或字符数组
+ */
+export function hasAnyPermission(value: string | string[]): boolean {
+  const userStore = useUserStore();
+  return arrayHasAny(userStore?.authorities, value);
+}
+
+export default {
+  install(app: App) {
+    // 添加自定义指令
+    app.directive('role', {
+      mounted: (el, binding) => {
+        if (!hasRole(binding.value)) {
+          el.parentNode?.removeChild(el);
+        }
+      }
+    });
+    app.directive('any-role', {
+      mounted: (el, binding) => {
+        if (!hasAnyRole(binding.value)) {
+          el.parentNode?.removeChild(el);
+        }
+      }
+    });
+    app.directive('permission', {
+      mounted: (el, binding) => {
+        if (!hasPermission(binding.value)) {
+          el.parentNode?.removeChild(el);
+        }
+      }
+    });
+    app.directive('any-permission', {
+      mounted: (el, binding) => {
+        if (!hasAnyPermission(binding.value)) {
+          el.parentNode?.removeChild(el);
+        }
+      }
+    });
+  }
+};
diff --git a/src/utils/request.ts b/src/utils/request.ts
new file mode 100644
index 0000000..6ba6634
--- /dev/null
+++ b/src/utils/request.ts
@@ -0,0 +1,70 @@
+/**
+ * axios 实例
+ */
+import axios from 'axios';
+import type { AxiosResponse } from 'axios';
+import { unref } from 'vue';
+import router from '@/router';
+import { Modal } from 'ant-design-vue/es';
+import { API_BASE_URL, TOKEN_HEADER_NAME, LAYOUT_PATH } from '@/config/setting';
+import { getToken, setToken } from './token-util';
+import { logout } from './page-tab-util';
+import type { ApiResult } from '@/api';
+
+const service = axios.create({
+  baseURL: API_BASE_URL
+});
+
+/**
+ * 添加请求拦截器
+ */
+service.interceptors.request.use(
+  (config) => {
+    // 添加 token 到 header
+    const token = getToken();
+    if (token && config.headers) {
+      config.headers[TOKEN_HEADER_NAME] = token;
+    }
+    return config;
+  },
+  (error) => {
+    return Promise.reject(error);
+  }
+);
+
+/**
+ * 添加响应拦截器
+ */
+service.interceptors.response.use(
+  (res: AxiosResponse<ApiResult<unknown>>) => {
+    // 登录过期处理
+    if (res.data?.code === 401) {
+      const currentPath = unref(router.currentRoute).path;
+      if (currentPath == LAYOUT_PATH) {
+        logout(true);
+      } else {
+        Modal.destroyAll();
+        Modal.info({
+          title: '系统提示',
+          content: '登录状态已过期, 请退出重新登录!',
+          okText: '重新登录',
+          onOk: () => {
+            logout(false, currentPath);
+          }
+        });
+      }
+      return Promise.reject(new Error(res.data.message));
+    }
+    // token 自动续期
+    const token = res.headers[TOKEN_HEADER_NAME.toLowerCase()];
+    if (token) {
+      setToken(token);
+    }
+    return res;
+  },
+  (error) => {
+    return Promise.reject(error);
+  }
+);
+
+export default service;
diff --git a/src/utils/token-util.ts b/src/utils/token-util.ts
new file mode 100644
index 0000000..48cbaf2
--- /dev/null
+++ b/src/utils/token-util.ts
@@ -0,0 +1,39 @@
+/**
+ * token 操作封装
+ */
+import { TOKEN_STORE_NAME } from '@/config/setting';
+
+/**
+ * 获取缓存的 token
+ */
+export function getToken(): string | null {
+  const token = localStorage.getItem(TOKEN_STORE_NAME);
+  if (!token) {
+    return sessionStorage.getItem(TOKEN_STORE_NAME);
+  }
+  return token;
+}
+
+/**
+ * 缓存 token
+ * @param token token
+ * @param remember 是否永久存储
+ */
+export function setToken(token?: string, remember?: boolean) {
+  removeToken();
+  if (token) {
+    if (remember) {
+      localStorage.setItem(TOKEN_STORE_NAME, token);
+    } else {
+      sessionStorage.setItem(TOKEN_STORE_NAME, token);
+    }
+  }
+}
+
+/**
+ * 移除 token
+ */
+export function removeToken() {
+  localStorage.removeItem(TOKEN_STORE_NAME);
+  sessionStorage.removeItem(TOKEN_STORE_NAME);
+}
diff --git a/src/utils/use-echarts.ts b/src/utils/use-echarts.ts
new file mode 100644
index 0000000..06531d8
--- /dev/null
+++ b/src/utils/use-echarts.ts
@@ -0,0 +1,76 @@
+/**
+ * echarts 自动切换主题、重置尺寸封装
+ */
+import type { Ref } from 'vue';
+import {
+  ref,
+  reactive,
+  unref,
+  provide,
+  watch,
+  onActivated,
+  onDeactivated,
+  nextTick
+} from 'vue';
+import { storeToRefs } from 'pinia';
+import { THEME_KEY } from 'vue-echarts';
+import type VChart from 'vue-echarts';
+import { ChartTheme, ChartThemeDark } from 'ele-admin-pro/es';
+import { useThemeStore } from '@/store/modules/theme';
+import { onSizeChange } from './on-size-change';
+
+export default function (chartRefs: Ref<InstanceType<typeof VChart> | null>[]) {
+  // 当前框架是否是暗黑主题
+  const themeStore = useThemeStore();
+  const { darkMode } = storeToRefs(themeStore);
+  // 是否为 deactivated 状态
+  const deactivated = ref<boolean>(false);
+  // 当前图表是否是暗黑主题
+  const isDark = ref<boolean>(unref(darkMode));
+  // 当前图表主题
+  const chartsTheme = reactive({
+    ...(unref(isDark) ? ChartThemeDark : ChartTheme)
+  });
+
+  // 设置图表主题
+  provide(THEME_KEY, chartsTheme);
+
+  /* 重置图表尺寸 */
+  const resizeCharts = () => {
+    chartRefs.forEach((chartRef) => {
+      unref(chartRef)?.resize();
+    });
+  };
+
+  /* 屏幕尺寸变化监听 */
+  onSizeChange(() => {
+    resizeCharts();
+  });
+
+  /* 更改图表主题 */
+  const changeTheme = (dark: boolean) => {
+    isDark.value = dark;
+    Object.assign(chartsTheme, dark ? ChartThemeDark : ChartTheme);
+  };
+
+  onActivated(() => {
+    deactivated.value = false;
+    nextTick(() => {
+      if (unref(isDark) !== unref(darkMode)) {
+        changeTheme(unref(darkMode));
+      } else {
+        resizeCharts();
+      }
+    });
+  });
+
+  onDeactivated(() => {
+    deactivated.value = true;
+  });
+
+  watch(darkMode, (dark) => {
+    if (!unref(deactivated)) {
+      changeTheme(dark);
+    }
+  });
+}
diff --git a/src/utils/use-form-data.ts b/src/utils/use-form-data.ts
new file mode 100644
index 0000000..a3e6511
--- /dev/null
+++ b/src/utils/use-form-data.ts
@@ -0,0 +1,29 @@
+import { reactive } from 'vue';
+
+/**
+ * 表单数据 hook
+ * @param initValue 默认值
+ */
+export default function <T extends object>(initValue?: T) {
+  const form = reactive<T>({ ...initValue } as T);
+
+  const resetFields = () => {
+    Object.keys(form).forEach((key) => {
+      form[key] = initValue ? initValue[key] : void 0;
+    });
+  };
+
+  const assignFields = (data: object) => {
+    Object.keys(form).forEach((key) => {
+      form[key] = data[key];
+    });
+  };
+
+  return {
+    form,
+    // 重置为初始值
+    resetFields,
+    // 赋值不改变字段
+    assignFields
+  };
+}
diff --git a/src/views-demo/dashboard/analysis/components/hot-search.vue b/src/views-demo/dashboard/analysis/components/hot-search.vue
new file mode 100644
index 0000000..c1a5531
--- /dev/null
+++ b/src/views-demo/dashboard/analysis/components/hot-search.vue
@@ -0,0 +1,72 @@
+<template>
+  <a-card :bordered="false" title="热门搜索">
+    <v-chart
+      ref="hotSearchChartRef"
+      :option="hotSearchChartOption"
+      style="height: 330px"
+    />
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import { use } from 'echarts/core';
+  import type { EChartsCoreOption } from 'echarts/core';
+  import { CanvasRenderer } from 'echarts/renderers';
+  import { LineChart, BarChart } from 'echarts/charts';
+  import { GridComponent, TooltipComponent } from 'echarts/components';
+  import VChart from 'vue-echarts';
+  import 'echarts-wordcloud';
+  import { wordCloudColor } from 'ele-admin-pro/es';
+  import { getWordCloudList } from '@/api/dashboard/analysis';
+  import useEcharts from '@/utils/use-echarts';
+
+  use([CanvasRenderer, LineChart, BarChart, GridComponent, TooltipComponent]);
+
+  //
+  const hotSearchChartRef = ref<InstanceType<typeof VChart> | null>(null);
+
+  useEcharts([hotSearchChartRef]);
+
+  // 词云图表配置
+  const hotSearchChartOption: EChartsCoreOption = reactive({});
+
+  /* 获取词云数据 */
+  const getWordCloudData = () => {
+    getWordCloudList()
+      .then((data) => {
+        Object.assign(hotSearchChartOption, {
+          tooltip: {
+            show: true,
+            confine: true,
+            borderWidth: 1
+          },
+          series: [
+            {
+              type: 'wordCloud',
+              width: '100%',
+              height: '100%',
+              sizeRange: [12, 24],
+              gridSize: 6,
+              textStyle: {
+                color: wordCloudColor
+              },
+              emphasis: {
+                textStyle: {
+                  shadowBlur: 8,
+                  shadowColor: 'rgba(0, 0, 0, .15)'
+                }
+              },
+              data: data
+            }
+          ]
+        });
+      })
+      .catch((e) => {
+        message.error(e.message);
+      });
+  };
+
+  getWordCloudData();
+</script>
diff --git a/src/views-demo/dashboard/analysis/components/sale-card.vue b/src/views-demo/dashboard/analysis/components/sale-card.vue
new file mode 100644
index 0000000..45d6c62
--- /dev/null
+++ b/src/views-demo/dashboard/analysis/components/sale-card.vue
@@ -0,0 +1,248 @@
+<template>
+  <a-card :bordered="false" :body-style="{ padding: 0 }">
+    <a-tabs
+      size="large"
+      v-model:activeKey="saleSearch.type"
+      class="monitor-card-tabs"
+      @change="onSaleTypeChange"
+    >
+      <a-tab-pane tab="销售额" key="saleroom" />
+      <a-tab-pane tab="访问量" key="visits" />
+      <template #rightExtra>
+        <a-space
+          size="middle"
+          :class="[
+            'analysis-tabs-extra',
+            { 'hidden-lg-and-down': styleResponsive }
+          ]"
+        >
+          <a-radio-group v-model:value="saleSearch.dateType">
+            <a-radio-button value="1">今天</a-radio-button>
+            <a-radio-button value="2">本周</a-radio-button>
+            <a-radio-button value="3">本月</a-radio-button>
+            <a-radio-button value="4">本年</a-radio-button>
+          </a-radio-group>
+          <div style="width: 300px">
+            <a-range-picker
+              value-format="YYYY-MM-DD"
+              v-model:value="saleSearch.datetime"
+            />
+          </div>
+        </a-space>
+      </template>
+    </a-tabs>
+    <div style="padding-bottom: 10px">
+      <a-row :gutter="16">
+        <a-col
+          v-bind="
+            styleResponsive ? { lg: 17, md: 15, sm: 24, xs: 24 } : { span: 17 }
+          "
+        >
+          <div v-if="saleSearch.type === 'saleroom'" class="demo-monitor-title">
+            销售量趋势
+          </div>
+          <div v-else class="demo-monitor-title">访问量趋势</div>
+          <v-chart
+            ref="saleChartRef"
+            :option="saleChartOption"
+            style="height: 320px"
+          />
+        </a-col>
+        <a-col
+          v-bind="
+            styleResponsive ? { lg: 7, md: 9, sm: 24, xs: 24 } : { span: 7 }
+          "
+        >
+          <div v-if="saleSearch.type === 'saleroom'">
+            <div class="demo-monitor-title">门店销售额排名</div>
+            <div
+              v-for="(item, index) in saleroomRankData"
+              :key="index"
+              class="demo-monitor-rank-item ele-cell"
+            >
+              <ele-tag
+                shape="circle"
+                :color="index < 3 ? '#314659' : ''"
+                style="border: none"
+              >
+                {{ index + 1 }}
+              </ele-tag>
+              <div class="ele-cell-content ele-elip">{{ item.name }}</div>
+              <div class="ele-text-secondary">{{ item.value }}</div>
+            </div>
+          </div>
+          <div v-else>
+            <div class="demo-monitor-title">门店访问量排名</div>
+            <div
+              v-for="(item, index) in visitsRankData"
+              :key="index"
+              class="demo-monitor-rank-item ele-cell"
+            >
+              <ele-tag
+                shape="circle"
+                :color="index < 3 ? '#314659' : ''"
+                style="border: none"
+              >
+                {{ index + 1 }}
+              </ele-tag>
+              <div class="ele-cell-content ele-elip">{{ item.name }}</div>
+              <div class="ele-text-secondary">{{ item.value }}</div>
+            </div>
+          </div>
+        </a-col>
+      </a-row>
+    </div>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import { use } from 'echarts/core';
+  import type { EChartsCoreOption } from 'echarts/core';
+  import { CanvasRenderer } from 'echarts/renderers';
+  import { BarChart } from 'echarts/charts';
+  import { GridComponent, TooltipComponent } from 'echarts/components';
+  import VChart from 'vue-echarts';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import { getSaleroomList } from '@/api/dashboard/analysis';
+  import type { SaleroomData } from '@/api/dashboard/analysis/model';
+  import useEcharts from '@/utils/use-echarts';
+
+  use([CanvasRenderer, BarChart, GridComponent, TooltipComponent]);
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  //
+  const saleChartRef = ref<InstanceType<typeof VChart> | null>(null);
+
+  useEcharts([saleChartRef]);
+
+  // 销售额柱状图配置
+  const saleChartOption: EChartsCoreOption = reactive({});
+
+  // 门店销售排名数据
+  const saleroomRankData = ref([
+    { name: '工专路 1 号店', value: '323,234' },
+    { name: '工专路 2 号店', value: '323,234' },
+    { name: '工专路 3 号店', value: '323,234' },
+    { name: '工专路 4 号店', value: '323,234' },
+    { name: '工专路 5 号店', value: '323,234' },
+    { name: '工专路 6 号店', value: '323,234' },
+    { name: '工专路 7 号店', value: '323,234' }
+  ]);
+
+  // 门店访问排名数据
+  const visitsRankData = ref([
+    { name: '工专路 1 号店', value: '323,234' },
+    { name: '工专路 2 号店', value: '323,234' },
+    { name: '工专路 3 号店', value: '323,234' },
+    { name: '工专路 4 号店', value: '323,234' },
+    { name: '工专路 5 号店', value: '323,234' },
+    { name: '工专路 6 号店', value: '323,234' },
+    { name: '工专路 7 号店', value: '323,234' }
+  ]);
+
+  // 销售量趋势数据
+  const saleroomData1 = ref<SaleroomData[]>([]);
+
+  // 访问量趋势数据
+  const saleroomData2 = ref<SaleroomData[]>([]);
+
+  interface SaleSearchType {
+    type: string;
+    dateType: string;
+    datetime: [string, string];
+  }
+
+  // 销售量搜索参数
+  const saleSearch = reactive<SaleSearchType>({
+    type: 'saleroom',
+    dateType: '1',
+    datetime: ['2022-01-08', '2022-02-12']
+  });
+
+  /* 获取销售量数据 */
+  const getSaleroomData = () => {
+    getSaleroomList()
+      .then((data) => {
+        saleroomData1.value = data.list1;
+        saleroomData2.value = data.list2;
+        onSaleTypeChange();
+      })
+      .catch((e) => {
+        message.error(e.message);
+      });
+  };
+
+  /* 销售量tab选择改变事件 */
+  const onSaleTypeChange = () => {
+    if (saleSearch.type === 'saleroom') {
+      Object.assign(saleChartOption, {
+        tooltip: {
+          trigger: 'axis'
+        },
+        xAxis: [
+          {
+            type: 'category',
+            data: saleroomData1.value.map((d) => d.month)
+          }
+        ],
+        yAxis: [
+          {
+            type: 'value'
+          }
+        ],
+        series: [
+          {
+            type: 'bar',
+            data: saleroomData1.value.map((d) => d.value)
+          }
+        ]
+      });
+    } else {
+      Object.assign(saleChartOption, {
+        tooltip: {
+          trigger: 'axis'
+        },
+        xAxis: [
+          {
+            type: 'category',
+            data: saleroomData2.value.map((d) => d.month)
+          }
+        ],
+        yAxis: [
+          {
+            type: 'value'
+          }
+        ],
+        series: [
+          {
+            type: 'bar',
+            data: saleroomData2.value.map((d) => d.value)
+          }
+        ]
+      });
+    }
+  };
+
+  getSaleroomData();
+</script>
+
+<style lang="less" scoped>
+  .monitor-card-tabs :deep(.ant-tabs-nav) {
+    padding: 0 16px;
+  }
+
+  .demo-monitor-title {
+    padding: 6px 20px;
+  }
+
+  .demo-monitor-rank-item {
+    padding: 0 20px;
+    margin-top: 18px;
+  }
+</style>
diff --git a/src/views-demo/dashboard/analysis/components/statistics-card.vue b/src/views-demo/dashboard/analysis/components/statistics-card.vue
new file mode 100644
index 0000000..f4fefd5
--- /dev/null
+++ b/src/views-demo/dashboard/analysis/components/statistics-card.vue
@@ -0,0 +1,246 @@
+<!-- 统计卡片 -->
+<template>
+  <a-row :gutter="16">
+    <a-col
+      v-bind="styleResponsive ? { lg: 6, md: 12, sm: 24, xs: 24 } : { span: 6 }"
+    >
+      <a-card class="analysis-chart-card" :bordered="false">
+        <div class="ele-text-secondary ele-cell">
+          <div class="ele-cell-content">总销售额</div>
+          <a-tooltip title="指标说明">
+            <question-circle-outlined />
+          </a-tooltip>
+        </div>
+        <h1 class="analysis-chart-card-num">¥ 126,560</h1>
+        <div class="analysis-chart-card-content" style="padding-top: 16px">
+          <a-space size="middle">
+            <span class="analysis-trend-text">
+              周同比12% <caret-up-outlined class="ele-text-danger" />
+            </span>
+            <span class="analysis-trend-text">
+              日同比11% <caret-down-outlined class="ele-text-success" />
+            </span>
+          </a-space>
+        </div>
+        <a-divider />
+        <div>日销售额 ¥12,423</div>
+      </a-card>
+    </a-col>
+    <a-col
+      v-bind="styleResponsive ? { lg: 6, md: 12, sm: 24, xs: 24 } : { span: 6 }"
+    >
+      <a-card class="analysis-chart-card" :bordered="false">
+        <div class="ele-text-secondary ele-cell">
+          <div class="ele-cell-content">访问量</div>
+          <ele-tag color="red">日</ele-tag>
+        </div>
+        <h1 class="analysis-chart-card-num">8,846</h1>
+        <v-chart
+          ref="visitChartRef"
+          :option="visitChartOption"
+          style="height: 40px"
+        />
+        <a-divider />
+        <div>日访问量 1,234</div>
+      </a-card>
+    </a-col>
+    <a-col
+      v-bind="styleResponsive ? { lg: 6, md: 12, sm: 24, xs: 24 } : { span: 6 }"
+    >
+      <a-card class="analysis-chart-card" :bordered="false">
+        <div class="ele-text-secondary ele-cell">
+          <div class="ele-cell-content">支付笔数</div>
+          <ele-tag color="blue">月</ele-tag>
+        </div>
+        <h1 class="analysis-chart-card-num">6,560</h1>
+        <v-chart
+          ref="payNumChartRef"
+          :option="payNumChartOption"
+          style="height: 40px"
+        />
+        <a-divider />
+        <div>转化率 60%</div>
+      </a-card>
+    </a-col>
+    <a-col
+      v-bind="styleResponsive ? { lg: 6, md: 12, sm: 24, xs: 24 } : { span: 6 }"
+    >
+      <a-card class="analysis-chart-card" :bordered="false">
+        <div class="ele-text-secondary ele-cell">
+          <div class="ele-cell-content">活动运营效果</div>
+          <ele-tag color="green">周</ele-tag>
+        </div>
+        <h1 class="analysis-chart-card-num">78%</h1>
+        <div class="analysis-chart-card-content" style="padding-top: 16px">
+          <a-progress
+            :percent="78"
+            :show-info="false"
+            stroke-color="#13c2c2"
+            status="active"
+          />
+        </div>
+        <a-divider />
+        <a-space size="middle">
+          <span class="analysis-trend-text">
+            周同比12% <caret-up-outlined class="ele-text-danger" />
+          </span>
+          <span class="analysis-trend-text">
+            日同比11% <caret-down-outlined class="ele-text-success" />
+          </span>
+        </a-space>
+      </a-card>
+    </a-col>
+  </a-row>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import {
+    QuestionCircleOutlined,
+    CaretUpOutlined,
+    CaretDownOutlined
+  } from '@ant-design/icons-vue';
+  import { use } from 'echarts/core';
+  import type { EChartsCoreOption } from 'echarts/core';
+  import { CanvasRenderer } from 'echarts/renderers';
+  import { LineChart, BarChart } from 'echarts/charts';
+  import { GridComponent, TooltipComponent } from 'echarts/components';
+  import VChart from 'vue-echarts';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import { getPayNumList } from '@/api/dashboard/analysis';
+  import useEcharts from '@/utils/use-echarts';
+
+  use([CanvasRenderer, LineChart, BarChart, GridComponent, TooltipComponent]);
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  //
+  const visitChartRef = ref<InstanceType<typeof VChart> | null>(null);
+  const payNumChartRef = ref<InstanceType<typeof VChart> | null>(null);
+
+  useEcharts([visitChartRef, payNumChartRef]);
+
+  // 访问量折线图配置
+  const visitChartOption: EChartsCoreOption = reactive({});
+
+  // 支付笔数柱状图配置
+  const payNumChartOption: EChartsCoreOption = reactive({});
+
+  /* 获取支付笔数数据 */
+  const getPayNumData = () => {
+    getPayNumList()
+      .then((data) => {
+        Object.assign(visitChartOption, {
+          color: '#975fe5',
+          tooltip: {
+            trigger: 'axis',
+            formatter:
+              '<i class="ele-chart-dot" style="background: #975fe5;"></i>{b0}: {c0}'
+          },
+          grid: {
+            top: 10,
+            bottom: 0,
+            left: 0,
+            right: 0
+          },
+          xAxis: [
+            {
+              show: false,
+              type: 'category',
+              boundaryGap: false,
+              data: data.map((d) => d.date)
+            }
+          ],
+          yAxis: [
+            {
+              show: false,
+              type: 'value',
+              splitLine: {
+                show: false
+              }
+            }
+          ],
+          series: [
+            {
+              type: 'line',
+              smooth: true,
+              symbol: 'none',
+              areaStyle: {
+                opacity: 0.5
+              },
+              data: data.map((d) => d.value)
+            }
+          ]
+        });
+
+        Object.assign(payNumChartOption, {
+          tooltip: {
+            trigger: 'axis',
+            formatter:
+              '<i class="ele-chart-dot" style="background: #5b8ff9;"></i>{b0}: {c0}'
+          },
+          grid: {
+            top: 10,
+            bottom: 0,
+            left: 0,
+            right: 0
+          },
+          xAxis: [
+            {
+              show: false,
+              type: 'category',
+              data: data.map((d) => d.date)
+            }
+          ],
+          yAxis: [
+            {
+              show: false,
+              type: 'value',
+              splitLine: {
+                show: false
+              }
+            }
+          ],
+          series: [
+            {
+              type: 'bar',
+              data: data.map((d) => d.value)
+            }
+          ]
+        });
+      })
+      .catch((e) => {
+        message.error(e.message);
+      });
+  };
+
+  getPayNumData();
+</script>
+
+<style lang="less" scoped>
+  .analysis-chart-card {
+    :deep(.ant-card-body) {
+      padding: 16px 22px 12px 22px;
+    }
+
+    :deep(.ant-divider) {
+      margin: 12px 0;
+    }
+  }
+
+  .analysis-chart-card-num {
+    font-size: 30px;
+  }
+
+  .analysis-chart-card-content {
+    height: 40px;
+  }
+
+  .analysis-trend-text {
+    white-space: nowrap;
+  }
+</style>
diff --git a/src/views-demo/dashboard/analysis/components/visit-hour.vue b/src/views-demo/dashboard/analysis/components/visit-hour.vue
new file mode 100644
index 0000000..ca371d3
--- /dev/null
+++ b/src/views-demo/dashboard/analysis/components/visit-hour.vue
@@ -0,0 +1,101 @@
+<template>
+  <a-card
+    :bordered="false"
+    title="最近1小时访问情况"
+    :body-style="{ padding: '16px 6px 0 0' }"
+  >
+    <v-chart
+      ref="visitHourChartRef"
+      :option="visitHourChartOption"
+      style="height: 362px"
+    />
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import { use } from 'echarts/core';
+  import type { EChartsCoreOption } from 'echarts/core';
+  import { CanvasRenderer } from 'echarts/renderers';
+  import { LineChart } from 'echarts/charts';
+  import {
+    GridComponent,
+    TooltipComponent,
+    LegendComponent
+  } from 'echarts/components';
+  import VChart from 'vue-echarts';
+  import { getVisitHourList } from '@/api/dashboard/analysis';
+  import useEcharts from '@/utils/use-echarts';
+
+  use([
+    CanvasRenderer,
+    LineChart,
+    GridComponent,
+    TooltipComponent,
+    LegendComponent
+  ]);
+
+  //
+  const visitHourChartRef = ref<InstanceType<typeof VChart> | null>(null);
+
+  useEcharts([visitHourChartRef]);
+
+  // 最近 1 小时访问情况折线图配置
+  const visitHourChartOption: EChartsCoreOption = reactive({});
+
+  /* 获取最近 1 小时访问情况数据 */
+  const getVisitHourData = () => {
+    getVisitHourList()
+      .then((data) => {
+        Object.assign(visitHourChartOption, {
+          tooltip: {
+            trigger: 'axis'
+          },
+          legend: {
+            data: ['浏览量', '访问量'],
+            right: 20
+          },
+          xAxis: [
+            {
+              type: 'category',
+              boundaryGap: false,
+              data: data.map((d) => d.time)
+            }
+          ],
+          yAxis: [
+            {
+              type: 'value'
+            }
+          ],
+          series: [
+            {
+              name: '浏览量',
+              type: 'line',
+              smooth: true,
+              symbol: 'none',
+              areaStyle: {
+                opacity: 0.5
+              },
+              data: data.map((d) => d.views)
+            },
+            {
+              name: '访问量',
+              type: 'line',
+              smooth: true,
+              symbol: 'none',
+              areaStyle: {
+                opacity: 0.5
+              },
+              data: data.map((d) => d.visits)
+            }
+          ]
+        });
+      })
+      .catch((e) => {
+        message.error(e.message);
+      });
+  };
+
+  getVisitHourData();
+</script>
diff --git a/src/views-demo/dashboard/analysis/index.vue b/src/views-demo/dashboard/analysis/index.vue
new file mode 100644
index 0000000..d0ede32
--- /dev/null
+++ b/src/views-demo/dashboard/analysis/index.vue
@@ -0,0 +1,41 @@
+<template>
+  <div class="ele-body ele-body-card">
+    <statistics-card />
+    <sale-card />
+    <a-row :gutter="16">
+      <a-col
+        v-bind="
+          styleResponsive ? { lg: 16, md: 14, sm: 24, xs: 24 } : { span: 16 }
+        "
+      >
+        <visit-hour />
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive ? { lg: 8, md: 10, sm: 24, xs: 24 } : { span: 8 }
+        "
+      >
+        <hot-search />
+      </a-col>
+    </a-row>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import StatisticsCard from './components/statistics-card.vue';
+  import SaleCard from './components/sale-card.vue';
+  import VisitHour from './components/visit-hour.vue';
+  import HotSearch from './components/hot-search.vue';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'DashboardAnalysis'
+  };
+</script>
diff --git a/src/views-demo/dashboard/monitor/components/browser-card.vue b/src/views-demo/dashboard/monitor/components/browser-card.vue
new file mode 100644
index 0000000..067abff
--- /dev/null
+++ b/src/views-demo/dashboard/monitor/components/browser-card.vue
@@ -0,0 +1,69 @@
+<template>
+  <a-card :bordered="false" title="浏览器分布" :body-style="{ padding: '0px' }">
+    <v-chart
+      ref="browserChartRef"
+      :option="browserChartOption"
+      style="height: 222px"
+    />
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import { use } from 'echarts/core';
+  import type { EChartsCoreOption } from 'echarts/core';
+  import { CanvasRenderer } from 'echarts/renderers';
+  import { PieChart } from 'echarts/charts';
+  import { TooltipComponent, LegendComponent } from 'echarts/components';
+  import VChart from 'vue-echarts';
+  import { getBrowserCountList } from '@/api/dashboard/monitor';
+  import useEcharts from '@/utils/use-echarts';
+
+  use([CanvasRenderer, PieChart, TooltipComponent, LegendComponent]);
+
+  //
+  const browserChartRef = ref<InstanceType<typeof VChart> | null>(null);
+
+  useEcharts([browserChartRef]);
+
+  // 浏览器分布饼图配置
+  const browserChartOption: EChartsCoreOption = reactive({});
+
+  /* 获取用户浏览器分布数据 */
+  const getBrowserCountData = () => {
+    getBrowserCountList()
+      .then((data) => {
+        Object.assign(browserChartOption, {
+          tooltip: {
+            trigger: 'item',
+            confine: true,
+            borderWidth: 1
+          },
+          legend: {
+            bottom: 5,
+            itemWidth: 10,
+            itemHeight: 10,
+            icon: 'circle',
+            data: data.map((d) => d.name)
+          },
+          series: [
+            {
+              type: 'pie',
+              radius: ['45%', '70%'],
+              center: ['50%', '43%'],
+              label: {
+                show: false
+              },
+              data: data
+            }
+          ]
+        });
+      })
+      .catch((e) => {
+        message.error(e.message);
+      });
+  };
+
+  getBrowserCountData();
+</script>
diff --git a/src/views-demo/dashboard/monitor/components/map-card.vue b/src/views-demo/dashboard/monitor/components/map-card.vue
new file mode 100644
index 0000000..87982f5
--- /dev/null
+++ b/src/views-demo/dashboard/monitor/components/map-card.vue
@@ -0,0 +1,147 @@
+<template>
+  <a-card :bordered="false" title="用户分布">
+    <a-row>
+      <a-col v-bind="styleResponsive ? { sm: 18, xs: 24 } : { span: 18 }">
+        <v-chart
+          ref="userCountMapChartRef"
+          :option="userCountMapOption"
+          style="height: 469px"
+        />
+      </a-col>
+      <a-col v-bind="styleResponsive ? { sm: 6, xs: 24 } : { span: 6 }">
+        <div
+          v-for="item in userCountDataRank"
+          :key="item.name"
+          class="monitor-user-count-item ele-cell"
+        >
+          <div>{{ item.name }}</div>
+          <div class="ele-cell-content">
+            <a-progress
+              status="normal"
+              :show-info="false"
+              :percent="item.percent"
+            />
+          </div>
+          <div>{{ item.value }}</div>
+        </div>
+      </a-col>
+    </a-row>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import { use, registerMap } from 'echarts/core';
+  import type { EChartsCoreOption } from 'echarts/core';
+  import { CanvasRenderer } from 'echarts/renderers';
+  import { MapChart } from 'echarts/charts';
+  import {
+    VisualMapComponent,
+    GeoComponent,
+    TooltipComponent
+  } from 'echarts/components';
+  import VChart from 'vue-echarts';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import { getChinaMapData, getUserCountList } from '@/api/dashboard/monitor';
+  import type { UserCount } from '@/api/dashboard/monitor/model';
+  import useEcharts from '@/utils/use-echarts';
+
+  use([
+    CanvasRenderer,
+    MapChart,
+    VisualMapComponent,
+    GeoComponent,
+    TooltipComponent
+  ]);
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  //
+  const userCountMapChartRef = ref<InstanceType<typeof VChart> | null>(null);
+
+  useEcharts([userCountMapChartRef]);
+
+  // 用户分布前 10 名
+  const userCountDataRank = ref<UserCount[]>([]);
+
+  // 用户分布地图配置
+  const userCountMapOption: EChartsCoreOption = reactive({});
+
+  /* 获取中国地图数据并注册地图 */
+  const registerChinaMap = () => {
+    getChinaMapData()
+      .then((data) => {
+        registerMap('china', data);
+        getUserCountData();
+      })
+      .catch((e) => {
+        message.error(e.message);
+      });
+  };
+
+  /* 获取用户分布数据 */
+  const getUserCountData = () => {
+    getUserCountList()
+      .then((data) => {
+        const temp = data.sort((a, b) => b.value - a.value);
+        const min = temp[temp.length - 1].value || 0;
+        const max = temp[0].value || 1;
+        //
+        const list = temp.length > 10 ? temp.slice(0, 15) : temp;
+        userCountDataRank.value = list.map((d) => {
+          return {
+            name: d.name,
+            value: d.value,
+            percent: (d.value / max) * 100
+          };
+        });
+        //
+        Object.assign(userCountMapOption, {
+          tooltip: {
+            trigger: 'item',
+            borderWidth: 1
+          },
+          visualMap: {
+            min: min,
+            max: max,
+            text: ['高', '低'],
+            calculable: true
+          },
+          series: [
+            {
+              name: '用户数',
+              label: {
+                show: true
+              },
+              type: 'map',
+              map: 'china',
+              data: data
+            }
+          ]
+        });
+      })
+      .catch((e) => {
+        message.error(e.message);
+      });
+  };
+
+  registerChinaMap();
+</script>
+
+<style lang="less" scoped>
+  .monitor-user-count-item {
+    margin-bottom: 8px;
+
+    :deep(.ant-progress-inner) {
+      background: none;
+    }
+
+    .ele-cell-content {
+      padding-right: 10px;
+    }
+  }
+</style>
diff --git a/src/views-demo/dashboard/monitor/components/online-num.vue b/src/views-demo/dashboard/monitor/components/online-num.vue
new file mode 100644
index 0000000..e8bad2f
--- /dev/null
+++ b/src/views-demo/dashboard/monitor/components/online-num.vue
@@ -0,0 +1,70 @@
+<template>
+  <a-card :bordered="false" title="在线人数">
+    <div class="monitor-online-num-card">
+      <div>{{ currentTime }}</div>
+      <div class="monitor-online-num-title">
+        <ele-count-up :end-val="onlineNum" />
+      </div>
+      <div class="monitor-online-num-text">在线总人数</div>
+      <a-badge status="processing" :text="updateTimeText" />
+    </div>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref, computed, onBeforeUnmount } from 'vue';
+  import { toDateString } from 'ele-admin-pro/es';
+  // 在线人数更新定时器
+  let onlineNumTimer: number | null = null;
+
+  // 在线总人数倒计时
+  const updateTime = ref(10);
+
+  // 当前时间
+  const currentTime = ref(toDateString(new Date(), 'HH:mm:ss'));
+
+  // 在线人数
+  const onlineNum = ref(228);
+
+  // 在线人数倒计时文字
+  const updateTimeText = computed(() => updateTime.value + ' 秒后更新');
+
+  /* 在线人数更新倒计时 */
+  const startUpdateOnlineNum = () => {
+    onlineNumTimer = window.setInterval(() => {
+      currentTime.value = toDateString(new Date(), 'HH:mm:ss');
+      if (updateTime.value === 1) {
+        updateTime.value = 10;
+        onlineNum.value = 100 + Math.round(Math.random() * 900);
+      } else {
+        updateTime.value--;
+      }
+    }, 1000);
+  };
+
+  onBeforeUnmount(() => {
+    // 销毁定时器
+    if (onlineNumTimer) {
+      clearInterval(onlineNumTimer);
+      onlineNumTimer = null;
+    }
+  });
+
+  startUpdateOnlineNum();
+</script>
+
+<style lang="less" scoped>
+  .monitor-online-num-card {
+    text-align: center;
+  }
+
+  .monitor-online-num-title {
+    line-height: 1;
+    font-size: 50px;
+    margin: 22px 0 14px;
+  }
+
+  .monitor-online-num-text {
+    margin-bottom: 22px;
+  }
+</style>
diff --git a/src/views-demo/dashboard/monitor/components/statistics-card.vue b/src/views-demo/dashboard/monitor/components/statistics-card.vue
new file mode 100644
index 0000000..8ff75b5
--- /dev/null
+++ b/src/views-demo/dashboard/monitor/components/statistics-card.vue
@@ -0,0 +1,166 @@
+<!-- 统计卡片 -->
+<template>
+  <a-row :gutter="16">
+    <a-col
+      v-bind="styleResponsive ? { lg: 6, md: 12, sm: 12, xs: 24 } : { span: 6 }"
+    >
+      <a-card :bordered="false" class="monitor-count-card">
+        <ele-tag color="blue" shape="circle" size="large">
+          <eye-filled />
+        </ele-tag>
+        <h1 class="monitor-count-card-num">21.2 k</h1>
+        <div class="monitor-count-card-text">总访问人数</div>
+        <ele-avatar-list :data="visitUsers" size="small" :max="4" />
+      </a-card>
+    </a-col>
+    <a-col
+      v-bind="styleResponsive ? { lg: 6, md: 12, sm: 12, xs: 24 } : { span: 6 }"
+    >
+      <a-card :bordered="false" class="monitor-count-card">
+        <ele-tag color="orange" shape="circle" size="large">
+          <fire-filled />
+        </ele-tag>
+        <h1 class="monitor-count-card-num">1.6 k</h1>
+        <div class="monitor-count-card-text">点击量(近30天)</div>
+        <div class="monitor-count-card-trend ele-text-success">
+          <up-outlined />
+          <span>110.5%</span>
+        </div>
+        <a-tooltip title="指标说明">
+          <question-circle-outlined class="monitor-count-card-tips" />
+        </a-tooltip>
+      </a-card>
+    </a-col>
+    <a-col
+      v-bind="styleResponsive ? { lg: 6, md: 12, sm: 12, xs: 24 } : { span: 6 }"
+    >
+      <a-card :bordered="false" class="monitor-count-card">
+        <ele-tag color="red" shape="circle" size="large">
+          <flag-filled />
+        </ele-tag>
+        <h1 class="monitor-count-card-num">826.0</h1>
+        <div class="monitor-count-card-text">到达量(近30天)</div>
+        <div class="monitor-count-card-trend ele-text-danger">
+          <down-outlined />
+          <span>15.5%</span>
+        </div>
+      </a-card>
+    </a-col>
+    <a-col
+      v-bind="styleResponsive ? { lg: 6, md: 12, sm: 12, xs: 24 } : { span: 6 }"
+    >
+      <a-card :bordered="false" class="monitor-count-card">
+        <ele-tag color="green" shape="circle" size="large">
+          <thunderbolt-filled />
+        </ele-tag>
+        <h1 class="monitor-count-card-num">28.8 %</h1>
+        <div class="monitor-count-card-text">转化率(近30天)</div>
+        <div class="monitor-count-card-trend ele-text-success">
+          <up-outlined />
+          <span>65.8%</span>
+        </div>
+        <a-tooltip title="指标说明">
+          <question-circle-outlined class="monitor-count-card-tips" />
+        </a-tooltip>
+      </a-card>
+    </a-col>
+  </a-row>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import {
+    QuestionCircleOutlined,
+    EyeFilled,
+    FireFilled,
+    FlagFilled,
+    ThunderboltFilled,
+    UpOutlined,
+    DownOutlined
+  } from '@ant-design/icons-vue';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+
+  interface VisitUserType {
+    key: string | number;
+    name: string;
+    avatar: string;
+  }
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  // 访问人数
+  const visitUsers = ref<VisitUserType[]>([
+    {
+      key: 1,
+      name: 'SunSmile',
+      avatar:
+        'https://cdn.eleadmin.com/20200609/c184eef391ae48dba87e3057e70238fb.jpg'
+    },
+    {
+      key: 2,
+      name: '你的名字很好听',
+      avatar:
+        'https://cdn.eleadmin.com/20200609/b6a811873e704db49db994053a5019b2.jpg'
+    },
+    {
+      key: 3,
+      name: '全村人的希望',
+      avatar:
+        'https://cdn.eleadmin.com/20200609/948344a2a77c47a7a7b332fe12ff749a.jpg'
+    },
+    {
+      key: 4,
+      name: 'Jasmine',
+      avatar:
+        'https://cdn.eleadmin.com/20200609/f6bc05af944a4f738b54128717952107.jpg'
+    },
+    {
+      key: 5,
+      name: '酷酷的大叔',
+      avatar:
+        'https://cdn.eleadmin.com/20200609/2d98970a51b34b6b859339c96b240dcd.jpg'
+    },
+    {
+      key: 6,
+      name: '管理员',
+      avatar: 'https://cdn.eleadmin.com/20200610/avatar.jpg'
+    }
+  ]);
+</script>
+
+<style lang="less" scoped>
+  .monitor-count-card {
+    text-align: center;
+
+    .monitor-count-card-num {
+      margin-top: 6px;
+      font-size: 32px;
+    }
+
+    .monitor-count-card-text {
+      font-size: 12px;
+      margin: 8px 0;
+      opacity: 0.8;
+    }
+
+    .monitor-count-card-trend {
+      font-weight: bold;
+      line-height: 26px;
+
+      & > .anticon {
+        margin-right: 6px;
+      }
+    }
+
+    .monitor-count-card-tips {
+      position: absolute;
+      top: 16px;
+      right: 16px;
+      cursor: pointer;
+      opacity: 0.6;
+    }
+  }
+</style>
diff --git a/src/views-demo/dashboard/monitor/components/user-liveness.vue b/src/views-demo/dashboard/monitor/components/user-liveness.vue
new file mode 100644
index 0000000..9eb2e71
--- /dev/null
+++ b/src/views-demo/dashboard/monitor/components/user-liveness.vue
@@ -0,0 +1,75 @@
+<template>
+  <a-card
+    :bordered="false"
+    title="用户活跃度"
+    :body-style="{ padding: '56px 0' }"
+  >
+    <div class="ele-cell">
+      <div class="ele-cell-content ele-text-center">
+        <div class="monitor-progress-group">
+          <a-progress
+            type="circle"
+            :percent="70"
+            stroke-color="#52c41a"
+            :show-info="false"
+            :width="161"
+          />
+          <a-progress
+            type="circle"
+            :percent="60"
+            stroke-color="#1890ff"
+            :show-info="false"
+            :width="121"
+            :stroke-width="5"
+          />
+          <a-progress
+            type="circle"
+            :percent="35"
+            stroke-color="#f5222d"
+            :show-info="false"
+            :width="91"
+            :stroke-width="4"
+          />
+        </div>
+      </div>
+      <div class="monitor-progress-legends">
+        <div class="ele-text-small ele-elip">
+          <a-badge color="green" text="活跃率: 70%" />
+        </div>
+        <div class="ele-text-small ele-elip">
+          <a-badge color="blue" text="留存率: 60%" />
+        </div>
+        <div class="ele-text-small ele-elip">
+          <a-badge color="red" text="跳出率: 35%" />
+        </div>
+      </div>
+    </div>
+  </a-card>
+</template>
+
+<style lang="less" scoped>
+  .monitor-progress-group {
+    position: relative;
+    display: inline-block;
+
+    .ant-progress:not(:first-child) {
+      position: absolute;
+      top: 50%;
+      left: 50%;
+      transform: translate(-50%, -50%);
+      margin-top: -1px;
+    }
+  }
+
+  .monitor-progress-legends {
+    padding-right: 24px;
+
+    :deep(.ant-badge-status-text) {
+      font-size: 12px;
+    }
+
+    & > div + div {
+      margin-top: 8px;
+    }
+  }
+</style>
diff --git a/src/views-demo/dashboard/monitor/components/user-rate.vue b/src/views-demo/dashboard/monitor/components/user-rate.vue
new file mode 100644
index 0000000..e88c449
--- /dev/null
+++ b/src/views-demo/dashboard/monitor/components/user-rate.vue
@@ -0,0 +1,86 @@
+<template>
+  <a-card :bordered="false" title="用户评价">
+    <div class="ele-cell ele-cell-align-bottom">
+      <div style="font-size: 51px; line-height: 1">4.5</div>
+      <div class="ele-cell-content">
+        <a-rate :value="userRate" disabled />
+        <span style="color: #fadb14; margin-left: 8px">很棒</span>
+      </div>
+    </div>
+    <div class="ele-cell" style="margin: 18px 0">
+      <div style="font-size: 28px; line-height: 1" class="ele-text-placeholder">
+        -0%
+      </div>
+      <div class="ele-cell-content ele-text-small ele-text-secondary">
+        当前没有评价波动
+      </div>
+    </div>
+    <div class="ele-cell">
+      <div class="ele-cell-content">
+        <a-progress :percent="60" stroke-color="#52c41a" :show-info="false" />
+      </div>
+      <div class="monitor-evaluate-text">
+        <star-filled />
+        <span>5 : 368 人</span>
+      </div>
+    </div>
+    <div class="ele-cell">
+      <div class="ele-cell-content">
+        <a-progress :percent="40" stroke-color="#1890ff" :show-info="false" />
+      </div>
+      <div class="monitor-evaluate-text">
+        <star-filled />
+        <span>4 : 256 人</span>
+      </div>
+    </div>
+    <div class="ele-cell">
+      <div class="ele-cell-content">
+        <a-progress :percent="20" stroke-color="#faad14" :show-info="false" />
+      </div>
+      <div class="monitor-evaluate-text">
+        <star-filled />
+        <span>3 : 49 人</span>
+      </div>
+    </div>
+    <div class="ele-cell">
+      <div class="ele-cell-content">
+        <a-progress :percent="10" stroke-color="#f5222d" :show-info="false" />
+      </div>
+      <div class="monitor-evaluate-text">
+        <star-filled />
+        <span>2 : 14 人</span>
+      </div>
+    </div>
+    <div class="ele-cell">
+      <div class="ele-cell-content">
+        <a-progress :percent="0" :show-info="false" />
+      </div>
+      <div class="monitor-evaluate-text">
+        <star-filled />
+        <span>1 : 0 人</span>
+      </div>
+    </div>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import { StarFilled } from '@ant-design/icons-vue';
+
+  // 用户评分
+  const userRate = ref(4.5);
+</script>
+
+<style lang="less" scoped>
+  .monitor-evaluate-text {
+    width: 90px;
+    flex-shrink: 0;
+    white-space: nowrap;
+    opacity: 0.8;
+
+    & > .anticon {
+      font-size: 12px;
+      margin: 0 6px 0 8px;
+    }
+  }
+</style>
diff --git a/src/views-demo/dashboard/monitor/components/user-satisfaction.vue b/src/views-demo/dashboard/monitor/components/user-satisfaction.vue
new file mode 100644
index 0000000..4933d1a
--- /dev/null
+++ b/src/views-demo/dashboard/monitor/components/user-satisfaction.vue
@@ -0,0 +1,79 @@
+<template>
+  <a-card :bordered="false" title="用户满意度">
+    <div class="ele-cell ele-text-center">
+      <div class="ele-cell-content" style="font-size: 24px">856</div>
+      <div class="ele-cell-content">
+        <div class="monitor-face-smile"><i></i></div>
+        <div class="ele-text-secondary ele-elip" style="margin-top: 8px">
+          正面评论
+        </div>
+      </div>
+      <h2 class="ele-cell-content ele-text-success">82%</h2>
+    </div>
+    <a-divider style="margin: 26px 0" />
+    <div class="ele-cell ele-text-center">
+      <div class="ele-cell-content" style="font-size: 24px">60</div>
+      <div class="ele-cell-content">
+        <div class="monitor-face-cry"><i></i></div>
+        <div class="ele-text-secondary ele-elip" style="margin-top: 8px">
+          负面评论
+        </div>
+      </div>
+      <h2 class="ele-cell-content ele-text-danger">9%</h2>
+    </div>
+  </a-card>
+</template>
+
+<style lang="less" scoped>
+  .monitor-face-smile,
+  .monitor-face-cry {
+    width: 50px;
+    height: 50px;
+    display: inline-block;
+    background: #fbd971;
+    border-radius: 50%;
+    position: relative;
+  }
+
+  .monitor-face-smile > i,
+  .monitor-face-smile:before,
+  .monitor-face-smile:after,
+  .monitor-face-cry > i,
+  .monitor-face-cry:before,
+  .monitor-face-cry:after {
+    width: 28px;
+    height: 28px;
+    border-radius: 50%;
+    transform: rotate(225deg);
+    border: 3px solid #f0c419;
+    border-right-color: transparent !important;
+    border-bottom-color: transparent !important;
+    position: absolute;
+    bottom: 8px;
+    left: 11px;
+  }
+
+  .monitor-face-smile:before,
+  .monitor-face-smile:after,
+  .monitor-face-cry:before,
+  .monitor-face-cry:after {
+    content: '';
+    width: 12px;
+    height: 12px;
+    left: 8px;
+    top: 14px;
+    border-color: #f29c1f;
+    transform: rotate(45deg);
+  }
+
+  .monitor-face-smile:after,
+  .monitor-face-cry:after {
+    left: auto;
+    right: 8px;
+  }
+
+  .monitor-face-cry > i {
+    transform: rotate(45deg);
+    bottom: -6px;
+  }
+</style>
diff --git a/src/views-demo/dashboard/monitor/index.vue b/src/views-demo/dashboard/monitor/index.vue
new file mode 100644
index 0000000..c9a72d8
--- /dev/null
+++ b/src/views-demo/dashboard/monitor/index.vue
@@ -0,0 +1,91 @@
+<template>
+  <div class="ele-body ele-body-card">
+    <statistics-card />
+    <a-row :gutter="16">
+      <a-col
+        v-bind="
+          styleResponsive ? { lg: 18, md: 24, sm: 24, xs: 24 } : { span: 18 }
+        "
+      >
+        <map-card />
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive ? { lg: 6, md: 24, sm: 24, xs: 24 } : { span: 6 }
+        "
+      >
+        <a-row :gutter="16">
+          <a-col
+            v-bind="
+              styleResponsive
+                ? { lg: 24, md: 12, sm: 12, xs: 24 }
+                : { span: 24 }
+            "
+          >
+            <online-num />
+          </a-col>
+          <a-col
+            v-bind="
+              styleResponsive
+                ? { lg: 24, md: 12, sm: 12, xs: 24 }
+                : { span: 24 }
+            "
+          >
+            <browser-card />
+          </a-col>
+        </a-row>
+      </a-col>
+    </a-row>
+    <a-row :gutter="16">
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 12, lg: 24, md: 24, sm: 24, xs: 24 }
+            : { span: 12 }
+        "
+      >
+        <user-rate />
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 12, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <user-satisfaction />
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 12, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <user-liveness />
+      </a-col>
+    </a-row>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import StatisticsCard from './components/statistics-card.vue';
+  import MapCard from './components/map-card.vue';
+  import OnlineNum from './components/online-num.vue';
+  import BrowserCard from './components/browser-card.vue';
+  import UserRate from './components/user-rate.vue';
+  import UserSatisfaction from './components/user-satisfaction.vue';
+  import UserLiveness from './components/user-liveness.vue';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'DashboardMonitor'
+  };
+</script>
diff --git a/src/views-demo/dashboard/workplace/components/activities-card.vue b/src/views-demo/dashboard/workplace/components/activities-card.vue
new file mode 100644
index 0000000..864f5d9
--- /dev/null
+++ b/src/views-demo/dashboard/workplace/components/activities-card.vue
@@ -0,0 +1,138 @@
+<!-- 最新动态 -->
+<template>
+  <a-card :title="title" :bordered="false" :body-style="{ padding: '6px 0' }">
+    <template #extra>
+      <more-icon @remove="onRemove" @edit="onEdit" />
+    </template>
+    <div
+      style="height: 346px; padding: 22px 20px 0 20px"
+      class="ele-scrollbar-hover"
+    >
+      <a-timeline>
+        <a-timeline-item
+          v-for="item in activities"
+          :key="item.id"
+          :color="item.color"
+        >
+          <em>{{ item.time }}</em>
+          <em>{{ item.title }}</em>
+        </a-timeline-item>
+      </a-timeline>
+    </div>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import MoreIcon from './more-icon.vue';
+
+  defineProps<{
+    title?: string;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'remove'): void;
+    (e: 'edit'): void;
+  }>();
+
+  interface Activitie {
+    id: number;
+    title: string;
+    time: string;
+    color?: string;
+  }
+
+  // 最新动态数据
+  const activities = ref<Activitie[]>([]);
+
+  /* 查询最新动态 */
+  const queryActivities = () => {
+    activities.value = [
+      {
+        id: 1,
+        title: 'SunSmile 解决了bug 登录提示操作失败',
+        time: '20:30',
+        color: 'gray'
+      },
+      {
+        id: 2,
+        title: 'Jasmine 解决了bug 按钮颜色与设计不符',
+        time: '19:30',
+        color: 'gray'
+      },
+      {
+        id: 3,
+        title: '项目经理 指派了任务 解决项目一的bug',
+        time: '18:30'
+      },
+      {
+        id: 4,
+        title: '项目经理 指派了任务 解决项目二的bug',
+        time: '17:30'
+      },
+      {
+        id: 5,
+        title: '项目经理 指派了任务 解决项目三的bug',
+        time: '16:30'
+      },
+      {
+        id: 6,
+        title: '项目经理 指派了任务 解决项目四的bug',
+        time: '15:30',
+        color: 'gray'
+      },
+      {
+        id: 7,
+        title: '项目经理 指派了任务 解决项目五的bug',
+        time: '14:30',
+        color: 'gray'
+      },
+      {
+        id: 8,
+        title: '项目经理 指派了任务 解决项目六的bug',
+        time: '12:30',
+        color: 'gray'
+      },
+      {
+        id: 9,
+        title: '项目经理 指派了任务 解决项目七的bug',
+        time: '11:30'
+      },
+      {
+        id: 10,
+        title: '项目经理 指派了任务 解决项目八的bug',
+        time: '10:30',
+        color: 'gray'
+      },
+      {
+        id: 11,
+        title: '项目经理 指派了任务 解决项目九的bug',
+        time: '09:30',
+        color: 'green'
+      },
+      {
+        id: 12,
+        title: '项目经理 指派了任务 解决项目十的bug',
+        time: '08:30',
+        color: 'red'
+      }
+    ];
+  };
+
+  const onRemove = () => {
+    emit('remove');
+  };
+
+  const onEdit = () => {
+    emit('edit');
+  };
+
+  queryActivities();
+</script>
+
+<style lang="less" scoped>
+  .ele-scrollbar-hover
+    :deep(.ant-timeline-item-last > .ant-timeline-item-content) {
+    min-height: auto;
+  }
+</style>
diff --git a/src/views-demo/dashboard/workplace/components/goal-card.vue b/src/views-demo/dashboard/workplace/components/goal-card.vue
new file mode 100644
index 0000000..b5ebf76
--- /dev/null
+++ b/src/views-demo/dashboard/workplace/components/goal-card.vue
@@ -0,0 +1,70 @@
+<!-- 本月目标 -->
+<template>
+  <a-card :title="title" :bordered="false">
+    <template #extra>
+      <more-icon @remove="onRemove" @edit="onEdit" />
+    </template>
+    <div class="workplace-goal-group">
+      <a-progress
+        :width="180"
+        :percent="80"
+        type="dashboard"
+        :stroke-width="4"
+        :show-info="false"
+      />
+      <div class="workplace-goal-content">
+        <ele-tag color="blue" size="large" shape="circle">
+          <trophy-outlined />
+        </ele-tag>
+        <div class="workplace-goal-num">285</div>
+      </div>
+      <div class="workplace-goal-text">恭喜, 本月目标已达标!</div>
+    </div>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { TrophyOutlined } from '@ant-design/icons-vue';
+  import MoreIcon from './more-icon.vue';
+
+  defineProps<{
+    title?: string;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'remove'): void;
+    (e: 'edit'): void;
+  }>();
+
+  const onRemove = () => {
+    emit('remove');
+  };
+
+  const onEdit = () => {
+    emit('edit');
+  };
+</script>
+
+<style lang="less" scoped>
+  .workplace-goal-group {
+    height: 310px;
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    align-items: center;
+    position: relative;
+
+    .workplace-goal-content {
+      position: absolute;
+      top: 50%;
+      left: 50%;
+      width: 180px;
+      margin: -50px 0 0 -90px;
+      text-align: center;
+    }
+
+    .workplace-goal-num {
+      font-size: 40px;
+    }
+  }
+</style>
diff --git a/src/views-demo/dashboard/workplace/components/link-card.vue b/src/views-demo/dashboard/workplace/components/link-card.vue
new file mode 100644
index 0000000..ae1352e
--- /dev/null
+++ b/src/views-demo/dashboard/workplace/components/link-card.vue
@@ -0,0 +1,187 @@
+<!-- 快捷方式 -->
+<template>
+  <a-row :gutter="16" ref="wrapRef">
+    <a-col
+      v-for="item in data"
+      :key="item.url"
+      v-bind="styleResponsive ? { lg: 3, md: 6, sm: 12, xs: 12 } : { span: 3 }"
+    >
+      <a-card :bordered="false" hoverable :body-style="{ padding: 0 }">
+        <router-link :to="item.url" class="app-link-block">
+          <component
+            :is="item.icon"
+            class="app-link-icon"
+            :style="{ color: item.color }"
+          />
+          <div class="app-link-title">{{ item.title }}</div>
+        </router-link>
+      </a-card>
+    </a-col>
+  </a-row>
+</template>
+
+<script lang="ts" setup>
+  import { ref, onMounted, onBeforeUnmount } from 'vue';
+  import SortableJs from 'sortablejs';
+  import type { Row as ARow } from 'ant-design-vue/es';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  const CACHE_KEY = 'workplace-links';
+
+  interface LinkItem {
+    icon: string;
+    title: string;
+    url: string;
+    color?: string;
+  }
+
+  // 默认顺序
+  const DEFAULT: LinkItem[] = [
+    {
+      icon: 'user-outlined',
+      title: '用户',
+      url: '/system/user'
+    },
+    {
+      icon: 'shopping-cart-outlined',
+      title: '分析',
+      url: '/dashboard/analysis',
+      color: '#95de64'
+    },
+    {
+      icon: 'fund-projection-screen-outlined',
+      title: '商品',
+      url: '/list/card/project',
+      color: '#ff9c6e'
+    },
+    {
+      icon: 'file-search-outlined',
+      title: '订单',
+      url: '/list/basic',
+      color: '#b37feb'
+    },
+    {
+      icon: 'credit-card-outlined',
+      title: '票据',
+      url: '/list/advanced',
+      color: '#ffd666'
+    },
+    {
+      icon: 'mail-outlined',
+      title: '消息',
+      url: '/user/message',
+      color: '#5cdbd3'
+    },
+    {
+      icon: 'tags-outlined',
+      title: '标签',
+      url: '/extension/tag',
+      color: '#ff85c0'
+    },
+    {
+      icon: 'control-outlined',
+      title: '配置',
+      url: '/user/profile',
+      color: '#ffc069'
+    }
+  ];
+
+  // 获取缓存的顺序
+  const cache = (() => {
+    const str = localStorage.getItem(CACHE_KEY);
+    try {
+      return str ? JSON.parse(str) : null;
+    } catch (e) {
+      return null;
+    }
+  })();
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const data = ref<LinkItem[]>([...(cache ?? DEFAULT)]);
+
+  const wrapRef = ref<InstanceType<typeof ARow> | null>(null);
+
+  let sortableIns: SortableJs | null = null;
+
+  /* 重置布局 */
+  const reset = () => {
+    data.value = [...DEFAULT];
+    cacheData();
+  };
+
+  /* 缓存布局 */
+  const cacheData = () => {
+    localStorage.setItem(CACHE_KEY, JSON.stringify(data.value));
+  };
+
+  onMounted(() => {
+    const isTouchDevice = 'ontouchstart' in document.documentElement;
+    if (isTouchDevice) {
+      return;
+    }
+    sortableIns = new SortableJs(wrapRef.value?.$el, {
+      animation: 300,
+      onUpdate: ({ oldIndex, newIndex }) => {
+        if (typeof oldIndex === 'number' && typeof newIndex === 'number') {
+          const temp = [...data.value];
+          temp.splice(newIndex, 0, temp.splice(oldIndex, 1)[0]);
+          data.value = temp;
+          cacheData();
+        }
+      },
+      setData: () => {}
+    });
+  });
+
+  onBeforeUnmount(() => {
+    if (sortableIns) {
+      sortableIns.destroy();
+    }
+  });
+
+  defineExpose({ reset });
+</script>
+
+<script lang="ts">
+  import {
+    UserOutlined,
+    ShoppingCartOutlined,
+    FundProjectionScreenOutlined,
+    FileSearchOutlined,
+    CreditCardOutlined,
+    MailOutlined,
+    TagsOutlined,
+    ControlOutlined
+  } from '@ant-design/icons-vue';
+
+  export default {
+    components: {
+      UserOutlined,
+      ShoppingCartOutlined,
+      FundProjectionScreenOutlined,
+      FileSearchOutlined,
+      CreditCardOutlined,
+      MailOutlined,
+      TagsOutlined,
+      ControlOutlined
+    }
+  };
+</script>
+
+<style lang="less" scoped>
+  .app-link-block {
+    padding: 12px;
+    text-align: center;
+    display: block;
+    color: inherit;
+
+    .app-link-icon {
+      color: #69c0ff;
+      font-size: 30px;
+      margin: 6px 0 10px 0;
+    }
+  }
+</style>
diff --git a/src/views-demo/dashboard/workplace/components/more-icon.vue b/src/views-demo/dashboard/workplace/components/more-icon.vue
new file mode 100644
index 0000000..2823738
--- /dev/null
+++ b/src/views-demo/dashboard/workplace/components/more-icon.vue
@@ -0,0 +1,38 @@
+<template>
+  <a-dropdown placement="bottomRight">
+    <more-outlined class="ele-text-secondary" style="font-size: 18px" />
+    <template #overlay>
+      <a-menu :selectable="false" @click="onClick">
+        <a-menu-item key="edit">
+          <div class="ele-cell">
+            <edit-outlined />
+            <div class="ele-cell-content">编辑</div>
+          </div>
+        </a-menu-item>
+        <a-menu-item key="remove">
+          <div class="ele-cell ele-text-danger">
+            <delete-outlined />
+            <div class="ele-cell-content">删除</div>
+          </div>
+        </a-menu-item>
+      </a-menu>
+    </template>
+  </a-dropdown>
+</template>
+
+<script lang="ts" setup>
+  import {
+    MoreOutlined,
+    EditOutlined,
+    DeleteOutlined
+  } from '@ant-design/icons-vue';
+
+  const emit = defineEmits<{
+    (e: 'edit'): void;
+    (e: 'remove'): void;
+  }>();
+
+  const onClick = ({ key }) => {
+    emit(key);
+  };
+</script>
diff --git a/src/views-demo/dashboard/workplace/components/profile-card.vue b/src/views-demo/dashboard/workplace/components/profile-card.vue
new file mode 100644
index 0000000..1007e4b
--- /dev/null
+++ b/src/views-demo/dashboard/workplace/components/profile-card.vue
@@ -0,0 +1,119 @@
+<!-- 用户信息 -->
+<template>
+  <a-card :bordered="false" :body-style="{ padding: '20px' }">
+    <div
+      :class="[
+        'ele-cell',
+        'workplace-user-card',
+        { 'workplace-user-responsive': styleResponsive }
+      ]"
+    >
+      <div class="ele-cell-content ele-cell">
+        <a-avatar :size="68" :src="loginUser.avatar" />
+        <div class="ele-cell-content">
+          <h4 class="ele-elip">
+            早安, {{ loginUser.nickname }}, 开始您一天的工作吧!
+          </h4>
+          <div class="ele-elip ele-text-secondary">
+            <cloud-outlined />
+            <em>今日多云转阴,18℃ - 22℃,出门记得穿外套哦~</em>
+          </div>
+        </div>
+      </div>
+      <div class="workplace-count-group">
+        <div class="workplace-count-item">
+          <div class="workplace-count-header">
+            <ele-tag color="blue" shape="circle" size="small">
+              <appstore-filled />
+            </ele-tag>
+            <span class="workplace-count-name">项目数</span>
+          </div>
+          <h2>3</h2>
+        </div>
+        <div class="workplace-count-item">
+          <div class="workplace-count-header">
+            <ele-tag color="orange" shape="circle" size="small">
+              <check-square-outlined />
+            </ele-tag>
+            <span class="workplace-count-name">待办项</span>
+          </div>
+          <h2>6 / 24</h2>
+        </div>
+        <div class="workplace-count-item">
+          <div class="workplace-count-header">
+            <ele-tag color="green" shape="circle" size="small">
+              <bell-filled />
+            </ele-tag>
+            <span class="workplace-count-name">消息</span>
+          </div>
+          <h2>1,689</h2>
+        </div>
+      </div>
+    </div>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { computed } from 'vue';
+  import {
+    CloudOutlined,
+    AppstoreFilled,
+    CheckSquareOutlined,
+    BellFilled
+  } from '@ant-design/icons-vue';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import { useUserStore } from '@/store/modules/user';
+
+  const userStore = useUserStore();
+
+  // 当前登录用户信息
+  const loginUser = computed(() => userStore.info ?? {});
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+</script>
+
+<style lang="less" scoped>
+  .workplace-user-card {
+    .ele-cell-content {
+      overflow: hidden;
+    }
+
+    h4 {
+      margin-bottom: 6px;
+    }
+  }
+
+  .workplace-count-group {
+    white-space: nowrap;
+    text-align: right;
+    flex-shrink: 0;
+  }
+
+  .workplace-count-item {
+    display: inline-block;
+    margin: 0 4px 0 24px;
+  }
+
+  .workplace-count-name {
+    margin-left: 8px;
+  }
+
+  @media screen and (max-width: 992px) {
+    .workplace-user-responsive .workplace-count-item {
+      margin: 0 2px 0 12px;
+    }
+  }
+
+  @media screen and (max-width: 768px) {
+    .workplace-user-responsive.workplace-user-card {
+      display: block;
+
+      .workplace-count-group {
+        margin-top: 8px;
+      }
+    }
+  }
+</style>
diff --git a/src/views-demo/dashboard/workplace/components/project-card.vue b/src/views-demo/dashboard/workplace/components/project-card.vue
new file mode 100644
index 0000000..14cb2a3
--- /dev/null
+++ b/src/views-demo/dashboard/workplace/components/project-card.vue
@@ -0,0 +1,179 @@
+<!-- 项目进度 -->
+<template>
+  <a-card
+    :title="title"
+    :bordered="false"
+    :body-style="{ padding: '14px', height: '358px' }"
+  >
+    <template #extra>
+      <more-icon @remove="onRemove" @edit="onEdit" />
+    </template>
+    <a-table
+      row-key="id"
+      size="middle"
+      :pagination="false"
+      :data-source="projectList"
+      :columns="projectColumns"
+      :scroll="{ x: 600 }"
+    >
+      <template #bodyCell="{ column, record }">
+        <template v-if="column.key === 'projectName'">
+          <a>{{ record.projectName }}</a>
+        </template>
+        <template v-else-if="column.key === 'status'">
+          <span v-if="record.status === 0" class="ele-text-success">
+            进行中
+          </span>
+          <span v-else-if="record.status === 1" class="ele-text-danger">
+            已延期
+          </span>
+          <span v-else-if="record.status === 2" class="ele-text-warning">
+            未开始
+          </span>
+          <span
+            v-else-if="record.status === 3"
+            class="ele-text-info ele-text-delete"
+          >
+            已结束
+          </span>
+        </template>
+        <template v-else-if="column.key === 'progress'">
+          <a-progress :percent="record.progress" size="small" />
+        </template>
+      </template>
+    </a-table>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import MoreIcon from './more-icon.vue';
+  import type { ColumnsType } from 'ant-design-vue/es/table';
+
+  defineProps<{
+    title?: string;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'remove'): void;
+    (e: 'edit'): void;
+  }>();
+
+  interface Project {
+    id: number;
+    projectName: string;
+    status: number;
+    startDate: string;
+    endDate: string;
+    progress: number;
+  }
+
+  const projectColumns = ref<ColumnsType>([
+    {
+      key: 'index',
+      align: 'center',
+      width: 38,
+      customRender: ({ index }) => index + 1,
+      fixed: 'left'
+    },
+    {
+      title: '项目名称',
+      key: 'projectName',
+      ellipsis: true,
+      minWidth: 120
+    },
+    {
+      title: '开始时间',
+      dataIndex: 'startDate',
+      align: 'center',
+      minWidth: 100,
+      ellipsis: true
+    },
+    {
+      title: '结束时间',
+      dataIndex: 'endDate',
+      align: 'center',
+      minWidth: 100,
+      ellipsis: true
+    },
+    {
+      title: '状态',
+      key: 'status',
+      align: 'center',
+      width: 90
+    },
+    {
+      title: '进度',
+      key: 'progress',
+      align: 'center',
+      width: 180
+    }
+  ]);
+
+  // 项目进度数据
+  const projectList = ref<Project[]>([]);
+
+  /* 查询项目进度 */
+  const queryProjectList = () => {
+    projectList.value = [
+      {
+        id: 1,
+        projectName: '项目0000001',
+        status: 0,
+        startDate: '2020-03-01',
+        endDate: '2020-06-01',
+        progress: 30
+      },
+      {
+        id: 2,
+        projectName: '项目0000002',
+        status: 0,
+        startDate: '2020-03-01',
+        endDate: '2020-08-01',
+        progress: 10
+      },
+      {
+        id: 3,
+        projectName: '项目0000003',
+        status: 1,
+        startDate: '2020-01-01',
+        endDate: '2020-05-01',
+        progress: 60
+      },
+      {
+        id: 4,
+        projectName: '项目0000004',
+        status: 1,
+        startDate: '2020-06-01',
+        endDate: '2020-10-01',
+        progress: 0
+      },
+      {
+        id: 5,
+        projectName: '项目0000005',
+        status: 2,
+        startDate: '2020-01-01',
+        endDate: '2020-03-01',
+        progress: 100
+      },
+      {
+        id: 6,
+        projectName: '项目0000006',
+        status: 3,
+        startDate: '2020-01-01',
+        endDate: '2020-03-01',
+        progress: 100
+      }
+    ];
+  };
+
+  const onRemove = () => {
+    emit('remove');
+  };
+
+  const onEdit = () => {
+    emit('edit');
+  };
+
+  queryProjectList();
+</script>
diff --git a/src/views-demo/dashboard/workplace/components/task-card.vue b/src/views-demo/dashboard/workplace/components/task-card.vue
new file mode 100644
index 0000000..c0a60ed
--- /dev/null
+++ b/src/views-demo/dashboard/workplace/components/task-card.vue
@@ -0,0 +1,157 @@
+<!-- 我的任务 -->
+<template>
+  <a-card
+    :title="title"
+    :bordered="false"
+    :body-style="{ padding: '10px', height: '358px' }"
+    class="workplace-table-card"
+  >
+    <template #extra>
+      <more-icon @remove="onRemove" @edit="onEdit" />
+    </template>
+    <div style="overflow: auto; position: relative">
+      <table class="ele-table" style="table-layout: fixed; min-width: 300px">
+        <colgroup>
+          <col width="38" />
+          <col width="65" />
+          <col />
+          <col width="70" />
+        </colgroup>
+        <thead>
+          <tr>
+            <th style="position: sticky; left: 0"></th>
+            <th style="text-align: center">优先级</th>
+            <th>任务名称</th>
+            <th style="text-align: center">状态</th>
+          </tr>
+        </thead>
+        <vue-draggable
+          tag="tbody"
+          item-key="id"
+          v-model="taskList"
+          handle=".sort-handle"
+          :animation="300"
+          :set-data="() => void 0"
+        >
+          <template #item="{ element }">
+            <tr>
+              <td style="text-align: center; position: sticky; left: 0">
+                <menu-outlined class="sort-handle ele-text-secondary" />
+              </td>
+              <td style="text-align: center">
+                <ele-tag
+                  :color="['red', 'orange', 'blue'][element.priority - 1]"
+                  shape="circle"
+                >
+                  {{ element.priority }}
+                </ele-tag>
+              </td>
+              <td class="ele-elip" :title="element.taskName">
+                <a>{{ element.taskName }}</a>
+              </td>
+              <td style="text-align: center">
+                <span v-if="element.status === 0" class="ele-text-warning">
+                  未开始
+                </span>
+                <span v-else-if="element.status === 1" class="ele-text-success">
+                  进行中
+                </span>
+                <span v-else-if="element.status === 2" class="ele-text-info">
+                  已完成
+                </span>
+              </td>
+            </tr>
+          </template>
+        </vue-draggable>
+      </table>
+    </div>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import VueDraggable from 'vuedraggable';
+  import { MenuOutlined } from '@ant-design/icons-vue';
+  import MoreIcon from './more-icon.vue';
+
+  defineProps<{
+    title?: string;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'remove'): void;
+    (e: 'edit'): void;
+  }>();
+
+  interface Task {
+    id: number;
+    priority: number;
+    taskName: string;
+    status: number;
+  }
+
+  // 我的任务数据
+  const taskList = ref<Task[]>([]);
+
+  /* 查询我的任务 */
+  const queryTaskList = () => {
+    taskList.value = [
+      {
+        id: 1,
+        priority: 1,
+        taskName: '解决项目一的bug',
+        status: 0
+      },
+      {
+        id: 2,
+        priority: 2,
+        taskName: '解决项目二的bug',
+        status: 0
+      },
+      {
+        id: 3,
+        priority: 2,
+        taskName: '解决项目三的bug',
+        status: 1
+      },
+      {
+        id: 4,
+        priority: 3,
+        taskName: '解决项目四的bug',
+        status: 1
+      },
+      {
+        id: 5,
+        priority: 3,
+        taskName: '解决项目五的bug',
+        status: 2
+      },
+      {
+        id: 6,
+        priority: 3,
+        taskName: '解决项目六的bug',
+        status: 2
+      }
+    ];
+  };
+
+  const onRemove = () => {
+    emit('remove');
+  };
+
+  const onEdit = () => {
+    emit('edit');
+  };
+
+  queryTaskList();
+</script>
+
+<style lang="less" scoped>
+  .ele-table tr.sortable-chosen {
+    background: hsla(0, 0%, 60%, 0.1);
+  }
+
+  .workplace-table-card .sort-handle {
+    cursor: move;
+  }
+</style>
diff --git a/src/views-demo/dashboard/workplace/components/user-list.vue b/src/views-demo/dashboard/workplace/components/user-list.vue
new file mode 100644
index 0000000..7ae8bc7
--- /dev/null
+++ b/src/views-demo/dashboard/workplace/components/user-list.vue
@@ -0,0 +1,123 @@
+<!-- 小组成员 -->
+<template>
+  <a-card :title="title" :bordered="false" :body-style="{ padding: '2px 0px' }">
+    <template #extra>
+      <more-icon @remove="onRemove" @edit="onEdit" />
+    </template>
+    <div
+      v-for="(item, index) in userList"
+      :key="index"
+      class="ele-cell user-list-item"
+    >
+      <div style="flex-shrink: 0">
+        <a-avatar :size="46" :src="item.avatar" />
+      </div>
+      <div class="ele-cell-content">
+        <div class="ele-cell-title ele-elip">{{ item.name }}</div>
+        <div class="ele-cell-desc ele-elip">{{ item.introduction }}</div>
+      </div>
+      <div style="flex-shrink: 0">
+        <a-tag :color="['green', 'red'][item.status]">
+          {{ ['在线', '离线'][item.status] }}
+        </a-tag>
+      </div>
+    </div>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import MoreIcon from './more-icon.vue';
+
+  defineProps<{
+    title?: string;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'remove'): void;
+    (e: 'edit'): void;
+  }>();
+
+  interface User {
+    name: string;
+    introduction: string;
+    status: number;
+    avatar: string;
+  }
+
+  // 小组成员数据
+  const userList = ref<User[]>([]);
+
+  /* 查询小组成员 */
+  const queryUserList = () => {
+    userList.value = [
+      {
+        name: 'SunSmile',
+        introduction: 'UI设计师、交互专家',
+        status: 0,
+        avatar:
+          'https://cdn.eleadmin.com/20200609/c184eef391ae48dba87e3057e70238fb.jpg'
+      },
+      {
+        name: '你的名字很好听',
+        introduction: '前端工程师',
+        status: 0,
+        avatar:
+          'https://cdn.eleadmin.com/20200609/b6a811873e704db49db994053a5019b2.jpg'
+      },
+      {
+        name: '全村人的希望',
+        introduction: '前端工程师',
+        status: 0,
+        avatar:
+          'https://cdn.eleadmin.com/20200609/948344a2a77c47a7a7b332fe12ff749a.jpg'
+      },
+      {
+        name: 'Jasmine',
+        introduction: '产品经理、项目经理',
+        status: 1,
+        avatar:
+          'https://cdn.eleadmin.com/20200609/f6bc05af944a4f738b54128717952107.jpg'
+      },
+      {
+        name: '酷酷的大叔',
+        introduction: '组长、后端工程师',
+        status: 1,
+        avatar:
+          'https://cdn.eleadmin.com/20200609/2d98970a51b34b6b859339c96b240dcd.jpg'
+      }
+    ];
+  };
+
+  const onRemove = () => {
+    emit('remove');
+  };
+
+  const onEdit = () => {
+    emit('edit');
+  };
+
+  queryUserList();
+</script>
+
+<style lang="less" scoped>
+  .user-list-item {
+    padding: 12px 18px;
+
+    & + .user-list-item {
+      border-top: 1px solid hsla(0, 0%, 60%, 0.15);
+    }
+
+    .ele-cell-content {
+      overflow: hidden;
+    }
+
+    .ele-cell-desc {
+      margin-top: 0;
+    }
+
+    .ant-tag {
+      margin: 0;
+    }
+  }
+</style>
diff --git a/src/views-demo/dashboard/workplace/index.vue b/src/views-demo/dashboard/workplace/index.vue
new file mode 100644
index 0000000..ff55d7c
--- /dev/null
+++ b/src/views-demo/dashboard/workplace/index.vue
@@ -0,0 +1,294 @@
+<template>
+  <div class="ele-body ele-body-card">
+    <profile-card />
+    <link-card ref="linkCardRef" />
+    <a-row :gutter="16" ref="wrapRef">
+      <a-col
+        v-for="(item, index) in data"
+        :key="item.name"
+        v-bind="
+          styleResponsive
+            ? { lg: item.lg, md: item.md, sm: item.sm, xs: item.xs }
+            : { span: item.lg }
+        "
+      >
+        <component
+          :is="item.name"
+          :title="item.title"
+          @remove="onRemove(index)"
+          @edit="onEdit(index)"
+        />
+      </a-col>
+    </a-row>
+    <a-card :bordered="false" :body-style="{ padding: 0 }">
+      <div class="ele-cell" style="line-height: 42px">
+        <div
+          class="ele-cell-content ele-text-primary workplace-bottom-btn"
+          @click="add"
+        >
+          <plus-circle-outlined /> 添加视图
+        </div>
+        <a-divider type="vertical" />
+        <div
+          class="ele-cell-content ele-text-primary workplace-bottom-btn"
+          @click="reset"
+        >
+          <undo-outlined /> 重置布局
+        </div>
+      </div>
+    </a-card>
+    <ele-modal
+      :width="680"
+      v-model:visible="visible"
+      title="未添加的视图"
+      :footer="null"
+    >
+      <a-row :gutter="16">
+        <a-col
+          v-for="item in notAddedData"
+          :key="item.name"
+          v-bind="styleResponsive ? { md: 8, sm: 12, xs: 24 } : { span: 8 }"
+        >
+          <div
+            class="workplace-card-item ele-border-split"
+            @click="addView(item)"
+          >
+            <div class="workplace-card-header ele-border-split">
+              {{ item.title }}
+            </div>
+            <div class="workplace-card-body ele-text-placeholder">
+              <plus-circle-outlined />
+            </div>
+          </div>
+        </a-col>
+      </a-row>
+      <a-empty v-if="!notAddedData.length" description="已添加所有视图" />
+    </ele-modal>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
+  import SortableJs from 'sortablejs';
+  import type { Row as ARow } from 'ant-design-vue/es';
+  import { message } from 'ant-design-vue/es';
+  import { PlusCircleOutlined, UndoOutlined } from '@ant-design/icons-vue';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import ProfileCard from './components/profile-card.vue';
+  import LinkCard from './components/link-card.vue';
+  const CACHE_KEY = 'workplace-layout';
+
+  interface ViewItem {
+    name: string;
+    title: string;
+    lg: number;
+    md: number;
+    sm: number;
+    xs: number;
+  }
+
+  // 默认布局
+  const DEFAULT: ViewItem[] = [
+    {
+      name: 'activities-card',
+      title: '最新动态',
+      lg: 8,
+      md: 24,
+      sm: 24,
+      xs: 24
+    },
+    {
+      name: 'task-card',
+      title: '我的任务',
+      lg: 8,
+      md: 24,
+      sm: 24,
+      xs: 24
+    },
+    {
+      name: 'goal-card',
+      title: '本月目标',
+      lg: 8,
+      md: 24,
+      sm: 24,
+      xs: 24
+    },
+    {
+      name: 'project-card',
+      title: '项目进度',
+      lg: 16,
+      md: 24,
+      sm: 24,
+      xs: 24
+    },
+    {
+      name: 'user-list',
+      title: '小组成员',
+      lg: 8,
+      md: 24,
+      sm: 24,
+      xs: 24
+    }
+  ];
+
+  // 获取缓存的顺序
+  const cache = (() => {
+    const str = localStorage.getItem(CACHE_KEY);
+    try {
+      return str ? JSON.parse(str) : null;
+    } catch (e) {
+      return null;
+    }
+  })();
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const data = ref<ViewItem[]>([...(cache ?? DEFAULT)]);
+
+  const visible = ref(false);
+
+  const linkCardRef = ref<InstanceType<typeof LinkCard> | null>(null);
+
+  const wrapRef = ref<InstanceType<typeof ARow> | null>(null);
+
+  let sortableIns: SortableJs | null = null;
+
+  // 未添加的数据
+  const notAddedData = computed(() => {
+    return DEFAULT.filter((d) => !data.value.some((t) => t.name === d.name));
+  });
+
+  /* 添加 */
+  const add = () => {
+    visible.value = true;
+  };
+
+  /* 重置布局 */
+  const reset = () => {
+    data.value = [...DEFAULT];
+    cacheData();
+    linkCardRef.value?.reset();
+    message.success('已重置');
+  };
+
+  /* 缓存布局 */
+  const cacheData = () => {
+    localStorage.setItem(CACHE_KEY, JSON.stringify(data.value));
+  };
+
+  /* 删除视图 */
+  const onRemove = (index: number) => {
+    data.value = data.value.filter((_d, i) => i !== index);
+    cacheData();
+  };
+
+  /* 编辑视图 */
+  const onEdit = (index: number) => {
+    console.log('index:', index);
+    message.info('点击了编辑');
+  };
+
+  /* 添加视图 */
+  const addView = (item) => {
+    data.value.push(item);
+    cacheData();
+    message.success('已添加');
+  };
+
+  onMounted(() => {
+    const isTouchDevice = 'ontouchstart' in document.documentElement;
+    if (isTouchDevice) {
+      return;
+    }
+    sortableIns = new SortableJs(wrapRef.value?.$el, {
+      handle: '.ant-card-head',
+      animation: 300,
+      onUpdate: ({ oldIndex, newIndex }) => {
+        if (typeof oldIndex === 'number' && typeof newIndex === 'number') {
+          const temp = [...data.value];
+          temp.splice(newIndex, 0, temp.splice(oldIndex, 1)[0]);
+          data.value = temp;
+          cacheData();
+        }
+      },
+      setData: () => {}
+    });
+  });
+
+  onBeforeUnmount(() => {
+    if (sortableIns) {
+      sortableIns.destroy();
+    }
+  });
+</script>
+
+<script lang="ts">
+  import ActivitiesCard from './components/activities-card.vue';
+  import TaskCard from './components/task-card.vue';
+  import GoalCard from './components/goal-card.vue';
+  import ProjectCard from './components/project-card.vue';
+  import UserList from './components/user-list.vue';
+
+  export default {
+    name: 'DashboardWorkplace',
+    components: {
+      ActivitiesCard,
+      TaskCard,
+      GoalCard,
+      ProjectCard,
+      UserList
+    }
+  };
+</script>
+
+<style lang="less" scoped>
+  .ele-body :deep(.ant-card-head) {
+    cursor: move;
+    position: relative;
+  }
+
+  .ele-body :deep(.ant-row > .ant-col.sortable-chosen > .ant-card) {
+    box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.2);
+  }
+
+  .workplace-bottom-btn {
+    text-align: center;
+    cursor: pointer;
+    transition: background-color 0.2s;
+  }
+
+  .workplace-bottom-btn:hover {
+    background: hsla(0, 0%, 60%, 0.05);
+  }
+
+  /* 添加弹窗 */
+  .workplace-card-item {
+    margin-bottom: 15px;
+    border-width: 1px;
+    border-style: solid;
+    border-radius: 4px;
+    position: relative;
+    cursor: pointer;
+    transition: box-shadow 0.2s, background-color 0.2s;
+  }
+
+  .workplace-card-item:hover {
+    box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.1);
+    background: hsla(0, 0%, 60%, 0.05);
+  }
+
+  .workplace-card-item .workplace-card-header {
+    border-bottom-width: 1px;
+    border-bottom-style: solid;
+    padding: 8px;
+  }
+
+  .workplace-card-body {
+    font-size: 26px;
+    padding: 24px 10px;
+    text-align: center;
+  }
+</style>
diff --git a/src/views-demo/example/choose/index.vue b/src/views-demo/example/choose/index.vue
new file mode 100644
index 0000000..956c9a7
--- /dev/null
+++ b/src/views-demo/example/choose/index.vue
@@ -0,0 +1,178 @@
+<template>
+  <div class="ele-body">
+    <a-card :bordered="false" :body-style="{ padding: '16px 16px' }">
+      <a-row :gutter="14">
+        <a-col
+          v-bind="
+            styleResponsive ? { lg: 12, md: 24, sm: 24, xs: 24 } : { span: 12 }
+          "
+        >
+          <!-- 未选择的班级数据表格 -->
+          <ele-pro-table
+            bordered
+            size="small"
+            :toolkit="[]"
+            :columns="columns"
+            row-key="classesId"
+            sub-title="未选班级:"
+            empty-text="已全部选择"
+            tools-theme="default"
+            :show-size-changer="false"
+            :datasource="unChooseClass"
+            :scroll="{ x: 400 }"
+          >
+            <template #toolkit>
+              <a-button type="dashed" class="ele-btn-icon" @click="addAll">
+                全部添加
+              </a-button>
+            </template>
+            <template #bodyCell="{ column, record }">
+              <template v-if="column.key === 'action'">
+                <a-button size="small" type="dashed" @click="addItem(record)">
+                  添加
+                </a-button>
+              </template>
+            </template>
+          </ele-pro-table>
+        </a-col>
+        <a-col
+          v-bind="
+            styleResponsive ? { lg: 12, md: 24, sm: 24, xs: 24 } : { span: 12 }
+          "
+        >
+          <!-- 已选择的班级数据表格 -->
+          <ele-pro-table
+            bordered
+            size="small"
+            :toolkit="[]"
+            :columns="columns"
+            row-key="classesId"
+            sub-title="已选班级:"
+            emptyText="未选择班级"
+            tools-theme="default"
+            :show-size-changer="false"
+            :datasource="chooseClasses"
+            :scroll="{ x: 400 }"
+          >
+            <template #toolkit>
+              <a-button
+                danger
+                type="dashed"
+                class="ele-btn-icon"
+                @click="removeAll"
+              >
+                全部移除
+              </a-button>
+            </template>
+            <template #bodyCell="{ column, record }">
+              <template v-if="column.key === 'action'">
+                <a-button
+                  danger
+                  size="small"
+                  type="dashed"
+                  @click="removeItem(record)"
+                >
+                  移除
+                </a-button>
+              </template>
+            </template>
+          </ele-pro-table>
+        </a-col>
+      </a-row>
+    </a-card>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref, computed } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import { getAllClasses } from '@/api/example/choose';
+  import type { Classes } from '@/api/example/choose/model';
+  import type { ColumnItem } from 'ele-admin-pro/es/ele-pro-table/types';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  // 全部班级
+  const classes = ref<Classes[]>([]);
+
+  // 表格列配置
+  const columns = ref<ColumnItem[]>([
+    {
+      width: 90,
+      title: '操作',
+      key: 'action',
+      align: 'center',
+      fixed: 'left'
+    },
+    {
+      title: '班级名称',
+      dataIndex: 'classesName',
+      ellipsis: true,
+      sorter: true
+    },
+    {
+      title: '专业',
+      dataIndex: 'major',
+      ellipsis: true,
+      sorter: true
+    },
+    {
+      title: '学院',
+      dataIndex: 'college',
+      ellipsis: true,
+      sorter: true
+    }
+  ]);
+
+  // 已选择的班级数据
+  const chooseClasses = ref<Classes[]>([]);
+
+  // 未选择的班级数据
+  const unChooseClass = computed(() =>
+    classes.value.filter((d) => chooseClasses.value.indexOf(d) === -1)
+  );
+
+  /* 获取全部班级 */
+  const query = () => {
+    getAllClasses()
+      .then((data) => {
+        classes.value = data;
+      })
+      .catch((e) => {
+        message.error(e.message);
+      });
+  };
+
+  query();
+
+  /* 添加 */
+  const addItem = (row: Classes) => {
+    chooseClasses.value = [...chooseClasses.value, row];
+  };
+
+  /* 移除 */
+  const removeItem = (row: Classes) => {
+    const index = chooseClasses.value.indexOf(row);
+    chooseClasses.value = chooseClasses.value.filter((_d, i) => i !== index);
+  };
+
+  /* 添加全部 */
+  const addAll = () => {
+    chooseClasses.value = [...classes.value];
+  };
+
+  /* 移除所有 */
+  const removeAll = () => {
+    chooseClasses.value = [];
+  };
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'ExampleChoose'
+  };
+</script>
diff --git a/src/views-demo/example/document/components/file-sort.vue b/src/views-demo/example/document/components/file-sort.vue
new file mode 100644
index 0000000..6560480
--- /dev/null
+++ b/src/views-demo/example/document/components/file-sort.vue
@@ -0,0 +1,340 @@
+<template>
+  <ele-modal
+    :width="1200"
+    :visible="visible"
+    title="卷内文件调整"
+    :body-style="{ padding: '16px 16px 0 16px' }"
+    @update:visible="updateVisible"
+    @cancel="close"
+    @ok="save"
+  >
+    <a-row :gutter="16">
+      <a-col
+        v-bind="
+          styleResponsive ? { lg: 8, md: 24, sm: 24, xs: 24 } : { span: 8 }
+        "
+      >
+        <!-- 表格 -->
+        <ele-pro-table
+          bordered
+          size="small"
+          :toolkit="[]"
+          height="360px"
+          :current="current"
+          :need-page="false"
+          row-key="piece_no"
+          sub-title="案卷列表"
+          :columns="columns1"
+          tools-theme="default"
+          :datasource="documents"
+          :scroll="{ x: 280 }"
+          selection-type="radio"
+          :row-selection="{ columnWidth: 38 }"
+          :tool-style="{ padding: '7px 14px' }"
+          class="demo-file-sort-table"
+          style="margin-bottom: 16px"
+          @update:current="updateCurrent"
+        />
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive ? { lg: 8, md: 24, sm: 24, xs: 24 } : { span: 8 }
+        "
+      >
+        <!-- 表格 -->
+        <ele-pro-table
+          bordered
+          size="small"
+          :toolkit="[]"
+          height="360px"
+          :need-page="false"
+          :loading="loading"
+          sub-title="卷内列表"
+          :datasource="data1"
+          :columns="columns2"
+          row-key="archive_no"
+          tools-theme="default"
+          :scroll="{ x: 280 }"
+          v-model:selection="selection1"
+          :row-selection="{ columnWidth: 38 }"
+          class="demo-file-sort-table"
+          style="margin-bottom: 16px"
+        >
+          <template #toolkit>
+            <a-space>
+              <a-button
+                ghost
+                type="primary"
+                class="ele-btn-icon"
+                @click="moveUp"
+              >
+                <span><arrow-up-outlined />上移</span>
+              </a-button>
+              <a-button
+                ghost
+                type="primary"
+                class="ele-btn-icon"
+                @click="moveDown"
+              >
+                <span><arrow-down-outlined />下移</span>
+              </a-button>
+              <a-button
+                ghost
+                type="primary"
+                class="ele-btn-icon"
+                @click="moveOut"
+              >
+                <span>调出<arrow-right-outlined /></span>
+              </a-button>
+            </a-space>
+          </template>
+        </ele-pro-table>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive ? { lg: 8, md: 24, sm: 24, xs: 24 } : { span: 8 }
+        "
+      >
+        <!-- 表格 -->
+        <ele-pro-table
+          bordered
+          size="small"
+          :toolkit="[]"
+          height="360px"
+          :need-page="false"
+          :loading="loading"
+          :datasource="data2"
+          :columns="columns2"
+          sub-title="未归档列表"
+          row-key="archive_no"
+          tools-theme="default"
+          :scroll="{ x: 280 }"
+          v-model:selection="selection2"
+          :row-selection="{ columnWidth: 38 }"
+          class="demo-file-sort-table"
+          style="margin-bottom: 16px"
+        >
+          <template #toolkit>
+            <a-button ghost type="primary" class="ele-btn-icon" @click="moveIn">
+              <span><arrow-left-outlined /> 调入</span>
+            </a-button>
+          </template>
+        </ele-pro-table>
+      </a-col>
+    </a-row>
+  </ele-modal>
+</template>
+
+<script lang="ts" setup>
+  import { ref, unref, computed, watch } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import {
+    ArrowUpOutlined,
+    ArrowDownOutlined,
+    ArrowLeftOutlined,
+    ArrowRightOutlined
+  } from '@ant-design/icons-vue';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import { getArchiveList } from '@/api/example/document';
+  import type { Piece, Archive } from '@/api/example/document/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const props = defineProps<{
+    // 弹窗是否打开
+    visible: boolean;
+    // 案卷列表
+    documents: Piece[];
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'update:visible', value: boolean): void;
+  }>();
+
+  // 案卷表格列配置
+  const columns1 = ref([
+    {
+      title: '案卷题名',
+      dataIndex: 'title',
+      ellipsis: true
+    },
+    {
+      title: '案卷档号',
+      dataIndex: 'piece_no',
+      ellipsis: true
+    }
+  ]);
+
+  // 卷内表格列配置
+  const columns2 = ref([
+    {
+      title: '文件题名',
+      dataIndex: 'title',
+      ellipsis: true
+    },
+    {
+      title: '文件档号',
+      dataIndex: 'archive_no',
+      ellipsis: true
+    }
+  ]);
+
+  // 所选案卷下的全部文件列表
+  const data = ref<Archive[]>([]);
+
+  // 选中案卷
+  const current = ref<Archive | null>(null);
+
+  // 加载loading
+  const loading = ref(true);
+
+  // 卷内列表选中数据
+  const selection1 = ref<Archive[]>([]);
+
+  // 未归档列表选中数据
+  const selection2 = ref<Archive[]>([]);
+
+  // 选中案卷的卷内文件
+  const data1 = computed(() =>
+    unref(current)
+      ? data.value.filter((d) => d.piece_no === unref(current)?.piece_no)
+      : []
+  );
+
+  // 未归档的卷内文件
+  const data2 = computed(() => data.value.filter((d) => !d.piece_no));
+
+  /* 上移 */
+  const moveUp = () => {
+    if (!selection1.value.length) {
+      message.error('请选择一条数据');
+      return;
+    }
+    if (selection1.value.length > 1) {
+      message.error('只能选择一条数据');
+      return;
+    }
+    if (data1.value.indexOf(selection1.value[0]) === 0) {
+      return;
+    }
+    const index = data.value.indexOf(selection1.value[0]);
+    const old = data.value[index - 1];
+    data.value[index - 1] = selection1.value[0];
+    data.value[index] = old;
+    selection1.value = [data.value[index - 1]];
+  };
+
+  /* 下移 */
+  const moveDown = () => {
+    if (!selection1.value.length) {
+      message.error('请选择一条数据');
+      return;
+    }
+    if (selection1.value.length > 1) {
+      message.error('只能选择一条数据');
+      return;
+    }
+    if (data1.value.indexOf(selection1.value[0]) === data1.value.length - 1) {
+      return;
+    }
+    const index = data.value.indexOf(selection1.value[0]);
+    const old = data.value[index + 1];
+    data.value[index + 1] = selection1.value[0];
+    data.value[index] = old;
+    selection1.value = [data.value[index + 1]];
+  };
+
+  /* 调出 */
+  const moveOut = () => {
+    if (!selection1.value.length) {
+      message.error('请至少选择一条数据');
+      return;
+    }
+    selection1.value.forEach((d) => {
+      d.piece_no = '';
+    });
+    selection1.value = [];
+  };
+
+  /* 调入 */
+  const moveIn = () => {
+    if (!unref(current)) {
+      return;
+    }
+    if (!selection2.value.length) {
+      message.error('请至少选择一条数据');
+      return;
+    }
+    selection2.value.forEach((d) => {
+      d.piece_no = unref(current)?.piece_no;
+    });
+    selection2.value = [];
+  };
+
+  /* 保存 */
+  const save = () => {
+    const result = data.value.map((d) => {
+      return {
+        archive_no: d.archive_no,
+        piece_no: d.piece_no
+      };
+    });
+    console.log(result);
+    message.success('调整成功');
+    close();
+  };
+
+  /* 关闭弹窗 */
+  const close = () => {
+    data.value = [];
+    selection1.value = [];
+    selection2.value = [];
+    updateVisible(false);
+  };
+
+  /* 更新visible */
+  const updateVisible = (value: boolean) => {
+    emit('update:visible', value);
+  };
+
+  /* 更新current */
+  const updateCurrent = (value: Archive) => {
+    current.value = value;
+    selection1.value = [];
+  };
+
+  /* 查询所选案卷的卷内文件 */
+  const query = () => {
+    loading.value = true;
+    getArchiveList({
+      piece_no_in: props.documents.map((d) => d.piece_no)
+    })
+      .then((list) => {
+        loading.value = false;
+        data.value = list;
+        current.value = props.documents[0];
+      })
+      .catch((e) => {
+        loading.value = false;
+        message.error(e.message);
+      });
+  };
+
+  watch(
+    () => props.documents,
+    (documents) => {
+      if (documents.length) {
+        query();
+      }
+    }
+  );
+</script>
+
+<style lang="less" scoped>
+  :deep(.demo-file-sort-table .ant-table-body) {
+    overflow: auto !important;
+  }
+</style>
diff --git a/src/views-demo/example/document/index.vue b/src/views-demo/example/document/index.vue
new file mode 100644
index 0000000..08ae034
--- /dev/null
+++ b/src/views-demo/example/document/index.vue
@@ -0,0 +1,176 @@
+<template>
+  <div class="ele-body">
+    <a-card :bordered="false" :body-style="{ padding: '10px 20px' }">
+      <!-- 表格 -->
+      <ele-pro-table
+        ref="tableRef"
+        row-key="piece_no"
+        :columns="columns"
+        :datasource="datasource"
+        v-model:selection="selection"
+        :scroll="{ x: 900 }"
+        :height="fixedHeight ? 'calc(100vh - 420px)' : void 0"
+        @done="onTableDone"
+      >
+        <template #toolbar>
+          <a-button type="primary" class="ele-btn-icon" @click="openFileSort">
+            卷内文件调整
+          </a-button>
+          <span>&emsp;高度铺满<s></s><s></s></span>
+          <a-switch v-model:checked="fixedHeight" size="small" />
+        </template>
+        <!-- 合计行 -->
+        <template #summary>
+          <a-table-summary fixed>
+            <a-table-summary-row>
+              <a-table-summary-cell />
+              <a-table-summary-cell />
+              <a-table-summary-cell>合计</a-table-summary-cell>
+              <a-table-summary-cell />
+              <a-table-summary-cell />
+              <a-table-summary-cell />
+              <a-table-summary-cell />
+              <a-table-summary-cell />
+              <a-table-summary-cell />
+              <a-table-summary-cell>{{ amountSummary }}</a-table-summary-cell>
+            </a-table-summary-row>
+          </a-table-summary>
+        </template>
+      </ele-pro-table>
+    </a-card>
+    <!-- 卷内文件调整弹窗 -->
+    <file-sort v-model:visible="showFileSort" :documents="fileSortChoose" />
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type { EleProTable } from 'ele-admin-pro/es';
+  import type {
+    DatasourceFunction,
+    ColumnItem,
+    EleProTableDone
+  } from 'ele-admin-pro/es/ele-pro-table/types';
+  import FileSort from './components/file-sort.vue';
+  import { getPieceList } from '@/api/example/document';
+  import type { Piece } from '@/api/example/document/model';
+
+  // 表格实例
+  const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
+
+  // 案卷数据
+  const data = ref<Piece[]>([]);
+
+  // 列表数据源
+  const datasource: DatasourceFunction = ({ page, limit }) => {
+    return getPieceList({ page, limit });
+  };
+
+  // 表格列配置
+  const columns = ref<ColumnItem[]>([
+    {
+      key: 'index',
+      width: 48,
+      align: 'center',
+      fixed: 'left',
+      hideInSetting: true,
+      customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
+    },
+    {
+      title: '案卷档号',
+      dataIndex: 'piece_no',
+      ellipsis: true
+    },
+    {
+      title: '案卷题名',
+      dataIndex: 'title',
+      ellipsis: true
+    },
+    {
+      title: '年度',
+      dataIndex: 'year',
+      width: 100,
+      ellipsis: true
+    },
+    {
+      title: '保管期限',
+      dataIndex: 'retention',
+      width: 120,
+      ellipsis: true
+    },
+    {
+      title: '密级',
+      dataIndex: 'secret',
+      width: 100,
+      ellipsis: true
+    },
+    {
+      title: '档案类别',
+      dataIndex: 'type',
+      ellipsis: true
+    },
+    {
+      title: '载体规格',
+      dataIndex: 'carrier',
+      width: 120,
+      ellipsis: true
+    },
+    {
+      title: '件数',
+      dataIndex: 'amount',
+      ellipsis: true
+    }
+  ]);
+
+  // 表格选中数据
+  const selection = ref<Piece[]>([]);
+
+  // 是否显示卷内文件调整弹窗
+  const showFileSort = ref(false);
+
+  // 选中的案卷
+  const fileSortChoose = ref<Piece[]>([]);
+
+  // 件数合计
+  const amountSummary = ref(0);
+
+  // 表格固定高度
+  const fixedHeight = ref(false);
+
+  /* 表格数据加载完成事件 */
+  const onTableDone: EleProTableDone<Piece> = (res) => {
+    data.value = res.data;
+    amountSummary.value = res.data
+      .map((item) => Number(item.amount))
+      .reduce((prev, curr) => {
+        const value = Number(curr);
+        if (!isNaN(value)) {
+          return prev + curr;
+        } else {
+          return prev;
+        }
+      }, 0);
+  };
+
+  /* 打开卷内文件调整弹窗 */
+  const openFileSort = () => {
+    if (selection.value.length < 2) {
+      message.error('请至少选择两条数据');
+      return;
+    }
+    // 实际项目用这一行
+    /* fileSortChoose.value = selection.value.map((d) => {
+      return { ...d };
+    }); */
+    // 演示强制选前三个演示
+    fileSortChoose.value = data.value.slice(0, 3);
+    showFileSort.value = true;
+  };
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'ExampleDocument'
+  };
+</script>
diff --git a/src/views-demo/example/menu-badge/index.vue b/src/views-demo/example/menu-badge/index.vue
new file mode 100644
index 0000000..6dd9dfd
--- /dev/null
+++ b/src/views-demo/example/menu-badge/index.vue
@@ -0,0 +1,134 @@
+<template>
+  <div class="ele-body ele-body-card">
+    <a-card title="修改菜单徽章数据" :bordered="false">
+      <a-form
+        :label-col="styleResponsive ? { sm: 6, xs: 24 } : { flex: '80px' }"
+        :wrapper-col="styleResponsive ? { sm: 18, xs: 24 } : { flex: '1' }"
+        style="max-width: 360px"
+      >
+        <a-form-item label="菜单">
+          <a-tree-select
+            :tree-data="treeData"
+            tree-default-expand-all
+            placeholder="请选择菜单"
+            v-model:value="path"
+          />
+        </a-form-item>
+        <a-form-item label="徽章值">
+          <a-input placeholder="请输入徽章值" v-model:value="badge" />
+        </a-form-item>
+        <a-form-item label="徽章颜色">
+          <ele-color-picker
+            size="large"
+            :show-alpha="true"
+            v-model:value="color"
+          />
+        </a-form-item>
+        <a-form-item :wrapper-col="{ sm: { offset: 6 } }">
+          <a-button type="primary" @click="setBadge">更新</a-button>
+        </a-form-item>
+      </a-form>
+    </a-card>
+    <a-card title="分组菜单" :bordered="false">
+      <div>
+        <a-button type="primary" @click="toMenuGroup1">
+          一级菜单变为分组形式
+        </a-button>
+      </div>
+      <div style="margin-top: 16px">
+        <a-button type="primary" @click="toMenuGroup2">
+          二级菜单变为分组形式
+        </a-button>
+      </div>
+      <div class="ele-text-secondary" style="margin-top: 6px">
+        二级菜单可查看列表页面/卡片列表的效果
+      </div>
+    </a-card>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref, computed } from 'vue';
+  import { useUserStore } from '@/store/modules/user';
+  import { message } from 'ant-design-vue/es';
+  import { formatTreeData } from 'ele-admin-pro/es';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const userStore = useUserStore();
+  const { menus } = storeToRefs(userStore);
+
+  const treeData = computed(() => {
+    return formatTreeData(menus.value, (m) => {
+      return {
+        ...m,
+        value: m.path,
+        title: m.meta.title
+      };
+    });
+  });
+
+  const path = ref<string>();
+
+  const badge = ref<string>();
+
+  const color = ref<string>();
+
+  const setBadge = () => {
+    if (!path.value) {
+      message.error('请选择菜单');
+      return;
+    }
+    userStore.setMenuBadge(path.value, badge.value, color.value);
+  };
+
+  //
+  const orgMenus = JSON.parse(JSON.stringify(menus.value));
+
+  /* 一级菜单变为分组形式 */
+  const toMenuGroup1 = () => {
+    userStore.setMenus(
+      orgMenus.map((m: any) => {
+        return {
+          ...m,
+          meta: {
+            ...m.meta,
+            group: true
+          }
+        };
+      })
+    );
+  };
+
+  /* 二级菜单变为分组形式 */
+  const toMenuGroup2 = () => {
+    userStore.setMenus(
+      orgMenus.map((m: any) => {
+        return {
+          ...m,
+          children: m.children
+            ? m.children.map((c: any) => {
+                return {
+                  ...c,
+                  meta: {
+                    ...c.meta,
+                    group: true
+                  }
+                };
+              })
+            : void 0
+        };
+      })
+    );
+  };
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'ExampleMenuBadge'
+  };
+</script>
diff --git a/src/views-demo/example/table/components/default-sorter.vue b/src/views-demo/example/table/components/default-sorter.vue
new file mode 100644
index 0000000..df2e975
--- /dev/null
+++ b/src/views-demo/example/table/components/default-sorter.vue
@@ -0,0 +1,90 @@
+<template>
+  <a-card :bordered="false" :body-style="{ padding: '10px 20px' }">
+    <ele-pro-table
+      ref="tableRef"
+      size="small"
+      title="设置默认排序和筛选"
+      row-key="userId"
+      :columns="columns"
+      :datasource="datasource"
+      :scroll="{ x: 800 }"
+    />
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import type { EleProTable } from 'ele-admin-pro/es';
+  import type {
+    DatasourceFunction,
+    ColumnItem
+  } from 'ele-admin-pro/es/ele-pro-table/types';
+  import { toDateString } from 'ele-admin-pro/es';
+  import { pageUsers } from '@/api/system/user';
+
+  // 表格实例
+  const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
+
+  // 表格列配置
+  const columns = ref<ColumnItem[]>([
+    {
+      key: 'index',
+      width: 48,
+      align: 'center',
+      fixed: 'left',
+      hideInSetting: true,
+      customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
+    },
+    {
+      title: '用户账号',
+      dataIndex: 'username',
+      sorter: true,
+      ellipsis: true,
+      defaultSortOrder: 'ascend'
+    },
+    {
+      title: '用户名',
+      dataIndex: 'nickname',
+      sorter: true,
+      ellipsis: true
+    },
+    {
+      title: '性别',
+      dataIndex: 'sexName',
+      width: 140,
+      align: 'center',
+      sorter: true,
+      filters: [
+        {
+          text: '男',
+          value: '男'
+        },
+        {
+          text: '女',
+          value: '女'
+        }
+      ],
+      filterMultiple: false,
+      defaultFilteredValue: ['男']
+    },
+    {
+      title: '手机号',
+      dataIndex: 'phone',
+      sorter: true,
+      ellipsis: true
+    },
+    {
+      title: '创建时间',
+      dataIndex: 'createTime',
+      sorter: true,
+      ellipsis: true,
+      customRender: ({ text }) => toDateString(text),
+      width: 180
+    }
+  ]);
+
+  // 表格数据源
+  const datasource: DatasourceFunction = ({ page, limit, orders, filters }) => {
+    return pageUsers({ ...orders, ...filters, page, limit });
+  };
+</script>
diff --git a/src/views-demo/example/table/components/lazy-tree-table.vue b/src/views-demo/example/table/components/lazy-tree-table.vue
new file mode 100644
index 0000000..3fe4f35
--- /dev/null
+++ b/src/views-demo/example/table/components/lazy-tree-table.vue
@@ -0,0 +1,74 @@
+<template>
+  <a-card :bordered="false" :body-style="{ padding: '10px 20px' }">
+    <!-- 表格 -->
+    <ele-pro-table
+      ref="tableRef"
+      size="small"
+      title="树形表格懒加载"
+      row-key="menuId"
+      :columns="columns"
+      :datasource="datasource"
+      :need-page="false"
+      :lazy-load="true"
+      :expand-icon-column-index="1"
+      :scroll="{ x: 600 }"
+    />
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import type { EleProTable } from 'ele-admin-pro/es';
+  import type {
+    DatasourceFunction,
+    ColumnItem
+  } from 'ele-admin-pro/es/ele-pro-table/types';
+  import { toDateString } from 'ele-admin-pro/es';
+  import { listMenus } from '@/api/system/menu';
+
+  // 表格实例
+  const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
+
+  // 表格列配置
+  const columns = ref<ColumnItem[]>([
+    {
+      key: 'index',
+      width: 48,
+      align: 'center',
+      fixed: 'left',
+      hideInSetting: true,
+      customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
+    },
+    {
+      title: '菜单名称',
+      dataIndex: 'title',
+      ellipsis: true
+    },
+    {
+      title: '路由地址',
+      dataIndex: 'path',
+      ellipsis: true
+    },
+    {
+      title: '组件路径',
+      dataIndex: 'component',
+      ellipsis: true
+    },
+    {
+      title: '排序',
+      dataIndex: 'sortNumber',
+      width: 60
+    },
+    {
+      title: '创建时间',
+      dataIndex: 'createTime',
+      ellipsis: true,
+      customRender: ({ text }) => toDateString(text)
+    }
+  ]);
+
+  // 表格数据源
+  const datasource: DatasourceFunction = ({ where, parent }) => {
+    return listMenus({ ...where, parentId: parent?.menuId || 0 });
+  };
+</script>
diff --git a/src/views-demo/example/table/components/merge-cell.vue b/src/views-demo/example/table/components/merge-cell.vue
new file mode 100644
index 0000000..f218abf
--- /dev/null
+++ b/src/views-demo/example/table/components/merge-cell.vue
@@ -0,0 +1,68 @@
+<template>
+  <a-card :bordered="false" :body-style="{ padding: '10px 20px' }">
+    <ele-pro-table
+      ref="tableRef"
+      size="small"
+      title="合并单元格"
+      row-key="id"
+      :columns="columns"
+      :datasource="datasource"
+      :scroll="{ x: 800 }"
+      :bordered="true"
+    >
+      <template #bodyCell="{ column, record }">
+        <template v-if="column.key === 'userName'">
+          {{ record.userName }}
+        </template>
+      </template>
+    </ele-pro-table>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import type { EleProTable } from 'ele-admin-pro/es';
+  import type {
+    DatasourceFunction,
+    ColumnItem
+  } from 'ele-admin-pro/es/ele-pro-table/types';
+  import { pageUserScores } from '@/api/example/table';
+  import type { UserScore } from '@/api/example/table/model';
+
+  // 表格实例
+  const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
+
+  // 表格列配置
+  const columns = ref<ColumnItem[]>([
+    {
+      key: 'index',
+      width: 48,
+      align: 'center',
+      hideInSetting: true,
+      fixed: 'left',
+      customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
+    },
+    {
+      title: '姓名',
+      key: 'userName',
+      customCell: (record) => {
+        return {
+          rowSpan: (record as UserScore).userNameRowSpan
+        };
+      }
+    },
+    {
+      title: '课程',
+      dataIndex: 'courseName'
+    },
+    {
+      title: '得分',
+      dataIndex: 'score'
+    }
+  ]);
+
+  // 表格数据源
+  const datasource: DatasourceFunction = () => {
+    return pageUserScores();
+  };
+</script>
diff --git a/src/views-demo/example/table/components/multiple-sorter.vue b/src/views-demo/example/table/components/multiple-sorter.vue
new file mode 100644
index 0000000..fa83c69
--- /dev/null
+++ b/src/views-demo/example/table/components/multiple-sorter.vue
@@ -0,0 +1,87 @@
+<template>
+  <a-card :bordered="false" :body-style="{ padding: '10px 20px' }">
+    <ele-pro-table
+      ref="tableRef"
+      size="small"
+      title="多列排序"
+      row-key="userId"
+      :columns="columns"
+      :datasource="datasource"
+      :scroll="{ x: 800 }"
+    />
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import type { EleProTable } from 'ele-admin-pro/es';
+  import type {
+    DatasourceFunction,
+    ColumnItem
+  } from 'ele-admin-pro/es/ele-pro-table/types';
+  import { toDateString } from 'ele-admin-pro/es';
+  import { pageUsers } from '@/api/system/user';
+
+  // 表格实例
+  const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
+
+  // 表格列配置
+  const columns = ref<ColumnItem[]>([
+    {
+      key: 'index',
+      width: 48,
+      align: 'center',
+      fixed: 'left',
+      hideInSetting: true,
+      customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
+    },
+    {
+      title: '用户账号',
+      dataIndex: 'username',
+      sorter: {
+        multiple: 1
+      },
+      ellipsis: true
+    },
+    {
+      title: '用户名',
+      dataIndex: 'nickname',
+      sorter: {
+        multiple: 1
+      },
+      ellipsis: true
+    },
+    {
+      title: '性别',
+      dataIndex: 'sexName',
+      width: 140,
+      align: 'center',
+      sorter: {
+        multiple: 2
+      }
+    },
+    {
+      title: '手机号',
+      dataIndex: 'phone',
+      sorter: {
+        multiple: 1
+      },
+      ellipsis: true
+    },
+    {
+      title: '创建时间',
+      dataIndex: 'createTime',
+      sorter: {
+        multiple: 1
+      },
+      ellipsis: true,
+      customRender: ({ text }) => toDateString(text),
+      width: 180
+    }
+  ]);
+
+  // 表格数据源
+  const datasource: DatasourceFunction = ({ page, limit, orders, filters }) => {
+    return pageUsers({ ...orders, ...filters, page, limit });
+  };
+</script>
diff --git a/src/views-demo/example/table/components/reset-sorter.vue b/src/views-demo/example/table/components/reset-sorter.vue
new file mode 100644
index 0000000..453b0f0
--- /dev/null
+++ b/src/views-demo/example/table/components/reset-sorter.vue
@@ -0,0 +1,171 @@
+<template>
+  <a-card :bordered="false" :body-style="{ padding: '10px 20px' }">
+    <ele-pro-table
+      ref="tableRef"
+      size="small"
+      title="可控的排序和筛选"
+      row-key="userId"
+      :columns="columns"
+      :datasource="datasource"
+      :scroll="{ x: 800 }"
+      @change="onChange"
+    >
+      <template #toolkit>
+        <a-space size="small" style="flex-wrap: wrap">
+          <a-button type="primary" class="ele-btn-icon" @click="setSorter">
+            设置用户名排序
+          </a-button>
+          <a-button type="primary" class="ele-btn-icon" @click="setFilter">
+            设置性别筛选
+          </a-button>
+          <a-button type="primary" class="ele-btn-icon" @click="resetAll">
+            重置排序和筛选
+          </a-button>
+          <a-divider type="vertical" />
+        </a-space>
+      </template>
+    </ele-pro-table>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref, computed, unref } from 'vue';
+  import type { EleProTable } from 'ele-admin-pro/es';
+  import type {
+    DatasourceFunction,
+    ColumnItem,
+    FilterType,
+    SorterType
+  } from 'ele-admin-pro/es/ele-pro-table/types';
+  import { toDateString } from 'ele-admin-pro/es';
+  import { pageUsers } from '@/api/system/user';
+  import type { TablePaginationConfig } from 'ant-design-vue/es/table/interface';
+
+  // 表格实例
+  const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
+
+  // 表格筛选值
+  const filteredInfo = ref<FilterType | null>(null);
+
+  // 表格排序值
+  const sortedInfo = ref<SorterType | null>(null);
+
+  // 表格列配置
+  const columns = computed<ColumnItem[]>(() => {
+    const sorted =
+      (Array.isArray(sortedInfo.value)
+        ? sortedInfo.value[0]
+        : sortedInfo.value) ?? {};
+    const filtered = unref(filteredInfo) ?? {};
+    const cols: ColumnItem[] = [
+      {
+        key: 'index',
+        width: 48,
+        align: 'center',
+        fixed: 'left',
+        hideInSetting: true,
+        customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
+      },
+      {
+        title: '用户账号',
+        dataIndex: 'username',
+        sorter: true,
+        ellipsis: true,
+        sortOrder: sorted.field === 'username' ? sorted.order : null
+      },
+      {
+        title: '用户名',
+        dataIndex: 'nickname',
+        sorter: true,
+        ellipsis: true,
+        sortOrder: sorted.field === 'nickname' ? sorted.order : null
+      },
+      {
+        title: '性别',
+        dataIndex: 'sexName',
+        width: 140,
+        align: 'center',
+        sorter: true,
+        filters: [
+          {
+            text: '男',
+            value: '男'
+          },
+          {
+            text: '女',
+            value: '女'
+          }
+        ],
+        filterMultiple: false,
+        sortOrder: sorted.field === 'sexName' ? sorted.order : null,
+        filteredValue: filtered.sexName || null
+      },
+      {
+        title: '手机号',
+        dataIndex: 'phone',
+        sorter: true,
+        ellipsis: true,
+        sortOrder: sorted.field === 'phone' ? sorted.order : null
+      },
+      {
+        title: '创建时间',
+        dataIndex: 'createTime',
+        sorter: true,
+        ellipsis: true,
+        customRender: ({ text }) => toDateString(text),
+        width: 180,
+        sortOrder: sorted.field === 'createTime' ? sorted.order : null
+      }
+    ];
+    return cols;
+  });
+
+  // 表格数据源
+  const datasource: DatasourceFunction = ({ page, limit, orders, filters }) => {
+    return pageUsers({ ...orders, ...filters, page, limit });
+  };
+
+  // 表格分页、排序、筛选改变事件
+  const onChange = (
+    _pagination: TablePaginationConfig,
+    filters: FilterType,
+    sorter: SorterType
+  ) => {
+    filteredInfo.value = filters;
+    sortedInfo.value = sorter;
+  };
+
+  // 重置排序和筛选
+  const resetAll = () => {
+    filteredInfo.value = {};
+    sortedInfo.value = {};
+    tableRef?.value?.reload({
+      page: 1,
+      sorter: sortedInfo.value,
+      filters: filteredInfo.value
+    });
+  };
+
+  // 设置排序
+  const setSorter = () => {
+    sortedInfo.value = {
+      order: 'descend',
+      field: 'nickname'
+    };
+    tableRef?.value?.reload({
+      page: 1,
+      sorter: sortedInfo.value
+    });
+  };
+
+  // 设置筛选
+  const setFilter = () => {
+    filteredInfo.value = {
+      sexName: ['女']
+    };
+    tableRef?.value?.reload({
+      page: 1,
+      filters: filteredInfo.value
+    });
+  };
+</script>
diff --git a/src/views-demo/example/table/index.vue b/src/views-demo/example/table/index.vue
new file mode 100644
index 0000000..5596311
--- /dev/null
+++ b/src/views-demo/example/table/index.vue
@@ -0,0 +1,23 @@
+<template>
+  <div class="ele-body ele-body-card">
+    <lazy-tree-table />
+    <default-sorter />
+    <reset-sorter />
+    <multiple-sorter />
+    <merge-cell />
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import LazyTreeTable from './components/lazy-tree-table.vue';
+  import DefaultSorter from './components/default-sorter.vue';
+  import ResetSorter from './components/reset-sorter.vue';
+  import MultipleSorter from './components/multiple-sorter.vue';
+  import MergeCell from './components/merge-cell.vue';
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'ExampleTable'
+  };
+</script>
diff --git a/src/views-demo/exception/403/index.vue b/src/views-demo/exception/403/index.vue
new file mode 100644
index 0000000..6b2bc27
--- /dev/null
+++ b/src/views-demo/exception/403/index.vue
@@ -0,0 +1,17 @@
+<template>
+  <div style="padding-top: 80px">
+    <a-result status="403" title="403" sub-title="抱歉, 你无权访问该页面.">
+      <template #extra>
+        <router-link to="/">
+          <a-button type="primary">返回首页</a-button>
+        </router-link>
+      </template>
+    </a-result>
+  </div>
+</template>
+
+<script lang="ts">
+  export default {
+    name: 'Exception403'
+  };
+</script>
diff --git a/src/views-demo/exception/404/index.vue b/src/views-demo/exception/404/index.vue
new file mode 100644
index 0000000..1c2b453
--- /dev/null
+++ b/src/views-demo/exception/404/index.vue
@@ -0,0 +1,17 @@
+<template>
+  <div style="padding-top: 80px">
+    <a-result status="404" title="404" sub-title="抱歉, 你访问的页面不存在.">
+      <template #extra>
+        <router-link to="/">
+          <a-button type="primary">返回首页</a-button>
+        </router-link>
+      </template>
+    </a-result>
+  </div>
+</template>
+
+<script lang="ts">
+  export default {
+    name: 'Exception404'
+  };
+</script>
diff --git a/src/views-demo/exception/500/index.vue b/src/views-demo/exception/500/index.vue
new file mode 100644
index 0000000..ff7c853
--- /dev/null
+++ b/src/views-demo/exception/500/index.vue
@@ -0,0 +1,17 @@
+<template>
+  <div style="padding-top: 80px">
+    <a-result status="500" title="500" sub-title="抱歉, 服务器出错了.">
+      <template #extra>
+        <router-link to="/">
+          <a-button type="primary">返回首页</a-button>
+        </router-link>
+      </template>
+    </a-result>
+  </div>
+</template>
+
+<script lang="ts">
+  export default {
+    name: 'Exception500'
+  };
+</script>
diff --git a/src/views-demo/extension/bar-code/index.vue b/src/views-demo/extension/bar-code/index.vue
new file mode 100644
index 0000000..a1af1a1
--- /dev/null
+++ b/src/views-demo/extension/bar-code/index.vue
@@ -0,0 +1,144 @@
+<template>
+  <div class="ele-body">
+    <a-card title="条形码" :bordered="false">
+      <div ref="printRef" class="demo-barcode-images ele-bg-white">
+        <ele-bar-code :value="text" :tag="tag" :options="options" />
+      </div>
+      <a-form
+        style="max-width: 340px"
+        :label-col="{ flex: '88px' }"
+        :wrapper-col="{ flex: '1' }"
+      >
+        <a-form-item label="条码类型" style="flex-wrap: nowrap">
+          <a-radio-group :value="options.format" @update:value="updateFormat">
+            <a-radio value="CODE128">CODE128</a-radio>
+            <a-radio value="EAN13">EAN13</a-radio>
+          </a-radio-group>
+        </a-form-item>
+        <a-form-item label="渲染方式" style="flex-wrap: nowrap">
+          <a-radio-group v-model:value="tag">
+            <a-radio value="svg">svg</a-radio>
+            <a-radio value="img">img</a-radio>
+            <a-radio value="canvas">canvas</a-radio>
+          </a-radio-group>
+        </a-form-item>
+        <a-form-item label="条码文本" style="flex-wrap: nowrap">
+          <a-select v-if="options.format === 'EAN13'" v-model:value="text">
+            <a-select-option value="1234567890128">
+              1234567890128
+            </a-select-option>
+            <a-select-option value="6971872201359">
+              6971872201359
+            </a-select-option>
+            <a-select-option value="6954531770199">
+              6954531770199
+            </a-select-option>
+            <a-select-option value="6923644240318">
+              6923644240318
+            </a-select-option>
+          </a-select>
+          <a-input v-else v-model:value="text" :maxlength="20" />
+        </a-form-item>
+        <a-form-item label="高度" style="flex-wrap: nowrap">
+          <a-slider
+            v-model:value="options.height"
+            :min="40"
+            :max="160"
+            :step="10"
+          />
+        </a-form-item>
+        <a-form-item label="宽度" style="flex-wrap: nowrap">
+          <a-slider v-model:value="options.width" :min="1" :max="6" />
+        </a-form-item>
+        <a-form-item label="间距" style="flex-wrap: nowrap">
+          <a-slider v-model:value="options.margin" :min="0" :max="40" />
+        </a-form-item>
+        <a-form-item label="显示文本" style="flex-wrap: nowrap">
+          <a-switch v-model:checked="options.displayValue" size="small" />
+        </a-form-item>
+        <a-form-item
+          v-if="options.displayValue"
+          label="文本大小"
+          style="flex-wrap: nowrap"
+        >
+          <a-slider
+            v-model:value="options.fontSize"
+            :min="12"
+            :max="36"
+            :step="2"
+          />
+        </a-form-item>
+        <a-form-item
+          v-if="options.displayValue && options.format === 'CODE128'"
+          label="文本位置"
+          style="flex-wrap: nowrap"
+        >
+          <a-radio-group v-model:value="options.textPosition">
+            <a-radio value="bottom">bottom</a-radio>
+            <a-radio value="top">top</a-radio>
+          </a-radio-group>
+        </a-form-item>
+        <a-form-item style="flex-wrap: nowrap">
+          <div style="padding-left: 88px">
+            <a-button type="primary" @click="print">打印</a-button>
+          </div>
+        </a-form-item>
+      </a-form>
+    </a-card>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive, nextTick } from 'vue';
+  import { printElement } from 'ele-admin-pro/es';
+  import type { Options } from 'jsbarcode';
+
+  const printRef = ref<HTMLElement | null>(null);
+
+  const text = ref('1234567890');
+
+  const tag = ref('svg');
+
+  const options = reactive<Options>({
+    height: 60,
+    width: 2,
+    margin: 2,
+    displayValue: true,
+    textPosition: 'bottom',
+    fontSize: 14,
+    format: 'CODE128'
+  });
+
+  const updateFormat = (value: string) => {
+    if (value === 'EAN13') {
+      text.value = '1234567890128';
+      nextTick(() => {
+        options.format = value;
+      });
+    } else {
+      options.format = value;
+    }
+  };
+
+  const print = () => {
+    printElement(printRef.value as HTMLElement);
+  };
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'ExtensionBarCode'
+  };
+</script>
+
+<style lang="less" scoped>
+  .demo-barcode-images {
+    padding-bottom: 16px;
+    margin-bottom: 4px;
+    position: sticky;
+    top: 0;
+    overflow: auto;
+    z-index: 1;
+    line-height: 0;
+  }
+</style>
diff --git a/src/views-demo/extension/color-picker/index.vue b/src/views-demo/extension/color-picker/index.vue
new file mode 100644
index 0000000..5c20ea1
--- /dev/null
+++ b/src/views-demo/extension/color-picker/index.vue
@@ -0,0 +1,62 @@
+<template>
+  <div class="ele-body ele-body-card">
+    <a-card title="颜色选择器" :bordered="false">
+      <a-space>
+        <ele-color-picker
+          size="large"
+          :show-alpha="true"
+          v-model:value="color"
+          :predefine="predefineColors"
+        />
+        <ele-color-picker
+          :show-alpha="true"
+          v-model:value="color2"
+          :predefine="predefineColors"
+        />
+        <ele-color-picker
+          size="small"
+          :show-alpha="true"
+          v-model:value="color3"
+          :predefine="predefineColors"
+        />
+      </a-space>
+    </a-card>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+
+  // 选中颜色
+  const color = ref('rgba(255, 69, 0, 0.68)');
+
+  // 选中颜色
+  const color2 = ref('rgba(255, 69, 0, 0.68)');
+
+  // 选中颜色
+  const color3 = ref('rgba(255, 69, 0, 0.68)');
+
+  // 预设颜色
+  const predefineColors = ref([
+    '#ff4500',
+    '#ff8c00',
+    '#ffd700',
+    '#90ee90',
+    '#00ced1',
+    '#1e90ff',
+    '#c71585',
+    'rgba(255, 69, 0, 0.68)',
+    'rgb(255, 120, 0)',
+    'hsv(51, 100, 98)',
+    'hsva(120, 40, 94, 0.5)',
+    'hsl(181, 100%, 37%)',
+    'hsla(209, 100%, 56%, 0.73)',
+    '#c7158577'
+  ]);
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'ExtensionColorPicker'
+  };
+</script>
diff --git a/src/views-demo/extension/count-up/index.vue b/src/views-demo/extension/count-up/index.vue
new file mode 100644
index 0000000..8313688
--- /dev/null
+++ b/src/views-demo/extension/count-up/index.vue
@@ -0,0 +1,63 @@
+<template>
+  <div class="ele-body ele-body-card">
+    <a-card title="滚动数字" :bordered="false">
+      <h1 style="padding-left: 10px; margin-bottom: 15px">
+        <ele-count-up
+          :delay="0"
+          :end-val="demoNum"
+          :options="option"
+          @ready="onReady"
+        />
+      </h1>
+      <a-space>
+        <a-button class="ele-btn-icon" @click="restart">重新开始</a-button>
+        <a-button class="ele-btn-icon" @click="update">更新数字</a-button>
+      </a-space>
+    </a-card>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive } from 'vue';
+  import type { CountUp } from 'countup.js';
+
+  // 值
+  const demoNum = ref(6317);
+
+  // 配置
+  const option = reactive({
+    useEasing: true,
+    useGrouping: true,
+    separator: ',',
+    decimal: '.',
+    prefix: '',
+    suffix: ''
+  });
+
+  let instance: CountUp;
+
+  /* 渲染完成 */
+  const onReady = (ins: CountUp) => {
+    instance = ins;
+  };
+
+  /* 重新开始 */
+  const restart = () => {
+    if (!instance) {
+      return;
+    }
+    instance.reset();
+    instance.start();
+  };
+
+  /* 更新 */
+  const update = () => {
+    demoNum.value += 100 + Math.round(Math.random() * 300);
+  };
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'ExtensionCountUp'
+  };
+</script>
diff --git a/src/views-demo/extension/dashboard/index.vue b/src/views-demo/extension/dashboard/index.vue
new file mode 100644
index 0000000..f20201a
--- /dev/null
+++ b/src/views-demo/extension/dashboard/index.vue
@@ -0,0 +1,41 @@
+<template>
+  <div class="ele-body ele-body-card">
+    <a-card title="基本用法" :bordered="false">
+      <ele-dashboard type="success" style="margin-right: 18px">
+        <div style="line-height: 1">
+          <span style="font-size: 48px">100</span>
+          <span style="font-size: 12px; margin-left: 4px">分</span>
+        </div>
+        <div style="margin-top: 4px">安全</div>
+      </ele-dashboard>
+      <ele-dashboard type="warning" style="margin-right: 18px">
+        <div style="line-height: 1">
+          <span style="font-size: 48px">70</span>
+          <span style="font-size: 12px; margin-left: 4px">分</span>
+        </div>
+        <div style="margin-top: 4px">待优化</div>
+      </ele-dashboard>
+      <ele-dashboard type="danger">
+        <div style="line-height: 1">
+          <span style="font-size: 48px">40</span>
+          <span style="font-size: 12px; margin-left: 4px">分</span>
+        </div>
+        <div style="margin-top: 4px">高风险</div>
+      </ele-dashboard>
+    </a-card>
+    <a-card title="自定义颜色和尺寸" :bordered="false">
+      <ele-dashboard color="#722ED1" style="margin-right: 18px">
+        <div style="font-size: 48px">100</div>
+      </ele-dashboard>
+      <ele-dashboard size="116px" space="12px">
+        <div style="font-size: 38px">100</div>
+      </ele-dashboard>
+    </a-card>
+  </div>
+</template>
+
+<script lang="ts">
+  export default {
+    name: 'ExtensionDashboard'
+  };
+</script>
diff --git a/src/views-demo/extension/dialog/components/demo-modal.vue b/src/views-demo/extension/dialog/components/demo-modal.vue
new file mode 100644
index 0000000..ec02398
--- /dev/null
+++ b/src/views-demo/extension/dialog/components/demo-modal.vue
@@ -0,0 +1,245 @@
+<template>
+  <a-card title="可拖拽、拉伸、全屏弹窗" :bordered="false">
+    <a-form
+      style="max-width: 360px"
+      :label-col="
+        styleResponsive ? { md: 10, sm: 8, xs: 24 } : { flex: '140px' }
+      "
+      :wrapper-col="
+        styleResponsive ? { md: 14, sm: 16, xs: 24 } : { flex: '1' }
+      "
+    >
+      <a-form-item label="是否可拖出边界">
+        <a-select v-model:value="moveOut">
+          <a-select-option :value="0">不可拖出边界</a-select-option>
+          <a-select-option :value="1">可以拖出边界</a-select-option>
+          <a-select-option :value="2">只可右下方向拖出边界</a-select-option>
+        </a-select>
+      </a-form-item>
+      <a-form-item label="是否可拉伸大小">
+        <a-select v-model:value="resizable">
+          <a-select-option value="false">不可拉伸大小</a-select-option>
+          <a-select-option value="true">可以拉伸大小</a-select-option>
+          <a-select-option value="horizontal">只可横向拉伸</a-select-option>
+          <a-select-option value="vertical">只可纵向拉伸</a-select-option>
+        </a-select>
+      </a-form-item>
+      <a-form-item label="最大化切换按钮">
+        <a-switch v-model:checked="maxable" size="small" />
+      </a-form-item>
+      <!-- <a-form-item label="是否垂直居中">
+        <a-switch v-model:checked="centered" size="small" />
+      </a-form-item> -->
+      <a-form-item label="关闭后重置位置">
+        <a-switch v-model:checked="resetOnClose" size="small" />
+      </a-form-item>
+      <a-form-item label="限制在主体区域">
+        <a-switch v-model:checked="inner" size="small" />
+      </a-form-item>
+      <a-form-item label="默认位置">
+        <a-select allow-clear v-model:value="position" placeholder="请选择">
+          <a-select-option value="top">顶部</a-select-option>
+          <a-select-option value="bottom">底部</a-select-option>
+          <a-select-option value="left">左边</a-select-option>
+          <a-select-option value="right">右边</a-select-option>
+          <a-select-option value="leftTop">左上角</a-select-option>
+          <a-select-option value="leftBottom">左下角</a-select-option>
+          <a-select-option value="rightTop">右上角</a-select-option>
+          <a-select-option value="rightBottom">右下角</a-select-option>
+          <a-select-option value="center">正中间</a-select-option>
+        </a-select>
+      </a-form-item>
+      <a-form-item
+        :wrapper-col="
+          styleResponsive
+            ? { md: { offset: 10 }, sm: { offset: 8 } }
+            : { offset: 9 }
+        "
+      >
+        <a-button type="primary" class="ele-btn-icon" @click="openDialog">
+          打开可拖拽弹窗
+        </a-button>
+      </a-form-item>
+    </a-form>
+  </a-card>
+  <ele-modal
+    :width="400"
+    title="拖拽弹窗"
+    v-model:visible="visible"
+    :move-out="moveOut > 0"
+    :move-out-positive="moveOut === 2"
+    :resizable="modalResizable"
+    :maxable="maxable"
+    :inner="inner"
+    :centered="centered"
+    :reset-on-close="resetOnClose"
+    :position="position"
+    :body-style="{ paddingBottom: '16px' }"
+    @cancel="cancel"
+    @ok="save"
+  >
+    <a-form
+      ref="formRef"
+      :model="form"
+      :rules="rules"
+      :label-col="{ flex: '70px' }"
+      :wrapper-col="{ flex: '1' }"
+    >
+      <a-form-item label="用户名" name="nickname" style="flex-wrap: nowrap">
+        <a-input
+          allow-clear
+          placeholder="请输入用户名"
+          v-model:value="form.nickname"
+        />
+      </a-form-item>
+      <a-form-item label="性别" name="sex">
+        <a-select allow-clear placeholder="请选择性别" v-model:value="form.sex">
+          <a-select-option value="男">男</a-select-option>
+          <a-select-option value="女">女</a-select-option>
+        </a-select>
+      </a-form-item>
+      <a-form-item label="手机号" name="phone" style="flex-wrap: nowrap">
+        <a-input
+          allow-clear
+          placeholder="请输入手机号"
+          v-model:value="form.phone"
+        />
+      </a-form-item>
+      <a-form-item label="邮箱" name="email" style="flex-wrap: nowrap">
+        <a-input
+          allow-clear
+          placeholder="请输入邮箱"
+          v-model:value="form.email"
+        />
+      </a-form-item>
+      <a-form-item label="个人简介" style="flex-wrap: nowrap">
+        <a-textarea
+          :rows="4"
+          placeholder="请输入个人简介"
+          v-model:value="form.introduction"
+        />
+      </a-form-item>
+    </a-form>
+  </ele-modal>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive, computed } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import type { PositionStringType } from 'ele-admin-pro/es/ele-modal/types';
+  import useFormData from '@/utils/use-form-data';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  // 弹窗是否打开
+  const visible = ref(false);
+
+  // 是否允许拖出边界
+  const moveOut = ref(0);
+
+  // 是否可拉伸
+  const resizable = ref<'false' | 'true' | 'horizontal' | 'vertical'>('false');
+
+  // 是否显示最大化切换按钮
+  const maxable = ref(true);
+
+  // 关闭后重置位置
+  const resetOnClose = ref(true);
+
+  // 限制在主体区域
+  const inner = ref(false);
+
+  // 垂直居中
+  const centered = ref(false);
+
+  // 默认位置
+  const position = ref<PositionStringType>();
+
+  //
+  const formRef = ref<FormInstance | null>(null);
+
+  //
+  const modalResizable = computed(() => {
+    return resizable.value === 'true'
+      ? true
+      : resizable.value === 'false'
+      ? false
+      : resizable.value;
+  });
+
+  // 表单数据
+  const { form, resetFields } = useFormData({
+    nickname: '',
+    sex: undefined,
+    phone: '',
+    email: '',
+    introduction: ''
+  });
+
+  // 表单验证规则
+  const rules = reactive<Record<string, Rule[]>>({
+    nickname: [
+      {
+        required: true,
+        message: '请输入用户名',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    sex: [
+      {
+        required: true,
+        message: '请选择性别',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    phone: [
+      {
+        required: true,
+        message: '请输入手机号',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    email: [
+      {
+        required: true,
+        message: '请输入邮箱',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ]
+  });
+
+  /* 打开弹窗 */
+  const openDialog = () => {
+    if (!visible.value) {
+      visible.value = true;
+    }
+  };
+
+  /* 弹窗关闭回调 */
+  const cancel = () => {
+    resetFields();
+    formRef.value?.clearValidate();
+  };
+
+  /* 保存编辑 */
+  const save = () => {
+    if (!formRef.value) {
+      return;
+    }
+    formRef.value
+      .validate()
+      .then(() => {
+        message.success('保存成功');
+      })
+      .catch(() => {});
+  };
+</script>
diff --git a/src/views-demo/extension/dialog/components/multiple-modal.vue b/src/views-demo/extension/dialog/components/multiple-modal.vue
new file mode 100644
index 0000000..37c5a46
--- /dev/null
+++ b/src/views-demo/extension/dialog/components/multiple-modal.vue
@@ -0,0 +1,78 @@
+<template>
+  <a-card title="同时打开多个弹窗" :bordered="false">
+    <a-space>
+      <a-button type="primary" class="ele-btn-icon" @click="openDialog1">
+        打开弹窗1
+      </a-button>
+      <a-button type="primary" class="ele-btn-icon" @click="openDialog2">
+        打开弹窗2
+      </a-button>
+      <a-button type="primary" class="ele-btn-icon" @click="openDialog3">
+        打开弹窗3
+      </a-button>
+    </a-space>
+    <p style="margin-top: 20px">同时打开多个弹窗时点击会自动置顶</p>
+  </a-card>
+  <ele-modal
+    :width="400"
+    title="弹窗1"
+    v-model:visible="visible1"
+    :resizable="true"
+    :maxable="true"
+    :multiple="true"
+    :destroy-on-close="false"
+    :move-out="true"
+    :move-out-positive="true"
+    position="center"
+  >
+    <div style="padding: 20px 0">弹窗1</div>
+  </ele-modal>
+  <ele-modal
+    :width="400"
+    title="弹窗2"
+    v-model:visible="visible2"
+    :resizable="true"
+    :maxable="true"
+    :multiple="true"
+    :destroy-on-close="false"
+    :move-out="true"
+    :move-out-positive="true"
+    position="rightTop"
+  >
+    <div style="padding: 20px 0">弹窗2</div>
+  </ele-modal>
+  <ele-modal
+    :width="400"
+    title="弹窗3"
+    v-model:visible="visible3"
+    :resizable="true"
+    :maxable="true"
+    :multiple="true"
+    :destroy-on-close="false"
+    :move-out="true"
+    :move-out-positive="true"
+    position="rightBottom"
+  >
+    <div style="padding: 20px 0">弹窗3</div>
+  </ele-modal>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+
+  // 弹窗是否打开
+  const visible1 = ref(false);
+  const visible2 = ref(false);
+  const visible3 = ref(false);
+
+  /* 打开弹窗 */
+  const openDialog1 = () => {
+    visible1.value = true;
+  };
+  const openDialog2 = () => {
+    visible2.value = true;
+  };
+  const openDialog3 = () => {
+    visible3.value = true;
+  };
+</script>
diff --git a/src/views-demo/extension/dialog/index.vue b/src/views-demo/extension/dialog/index.vue
new file mode 100644
index 0000000..c21b178
--- /dev/null
+++ b/src/views-demo/extension/dialog/index.vue
@@ -0,0 +1,17 @@
+<template>
+  <div class="ele-body ele-body-card">
+    <demo-modal />
+    <multiple-modal />
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import DemoModal from './components/demo-modal.vue';
+  import MultipleModal from './components/multiple-modal.vue';
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'ExtensionDialog'
+  };
+</script>
diff --git a/src/views-demo/extension/dragsort/components/demo-grid.vue b/src/views-demo/extension/dragsort/components/demo-grid.vue
new file mode 100644
index 0000000..e0d3f9e
--- /dev/null
+++ b/src/views-demo/extension/dragsort/components/demo-grid.vue
@@ -0,0 +1,154 @@
+<template>
+  <a-row :gutter="16">
+    <a-col
+      v-bind="styleResponsive ? { lg: 8, md: 24, sm: 24, xs: 24 } : { span: 8 }"
+    >
+      <a-card title="宫格拖拽排序" :bordered="false">
+        <div class="demo-drag-grid">
+          <vue-draggable
+            v-model="grid"
+            item-key="id"
+            :animation="300"
+            :set-data="() => void 0"
+          >
+            <template #item="{ element }">
+              <div class="demo-drag-grid-item">{{ element.name }}</div>
+            </template>
+          </vue-draggable>
+        </div>
+      </a-card>
+    </a-col>
+    <a-col
+      v-bind="
+        styleResponsive ? { lg: 16, md: 24, sm: 24, xs: 24 } : { span: 16 }
+      "
+    >
+      <a-card title="宫格相互拖拽" :bordered="false">
+        <a-row :gutter="16">
+          <a-col :span="12">
+            <div class="demo-drag-grid">
+              <vue-draggable
+                v-model="grid1"
+                item-key="id"
+                :animation="300"
+                group="demoDragGrid"
+                :set-data="() => void 0"
+              >
+                <template #item="{ element }">
+                  <div class="demo-drag-grid-item">{{ element.name }}</div>
+                </template>
+              </vue-draggable>
+            </div>
+          </a-col>
+          <a-col :span="12">
+            <div class="demo-drag-grid">
+              <vue-draggable
+                v-model="grid2"
+                item-key="id"
+                :animation="300"
+                group="demoDragGrid"
+                :set-data="() => void 0"
+              >
+                <template #item="{ element }">
+                  <div class="demo-drag-grid-item">{{ element.name }}</div>
+                </template>
+              </vue-draggable>
+            </div>
+          </a-col>
+        </a-row>
+      </a-card>
+    </a-col>
+  </a-row>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import VueDraggable from 'vuedraggable';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const grid = ref([
+    { id: 1, name: '001' },
+    { id: 2, name: '002' },
+    { id: 3, name: '003' },
+    { id: 4, name: '004' },
+    { id: 5, name: '005' },
+    { id: 6, name: '006' }
+  ]);
+
+  const grid1 = ref([
+    { id: 1, name: '001' },
+    { id: 2, name: '002' },
+    { id: 3, name: '003' },
+    { id: 4, name: '004' },
+    { id: 5, name: '005' },
+    { id: 6, name: '006' }
+  ]);
+
+  const grid2 = ref([
+    { id: 7, name: '007' },
+    { id: 8, name: '008' },
+    { id: 9, name: '009' },
+    { id: 10, name: '010' },
+    { id: 11, name: '011' },
+    { id: 12, name: '012' }
+  ]);
+</script>
+
+<style lang="less" scoped>
+  @import 'ant-design-vue/es/style/themes/default.less';
+
+  .demo-drag-grid {
+    position: relative;
+
+    & > div {
+      border: 1px solid @border-color-split;
+      border-right: none;
+      border-bottom: none;
+      display: grid;
+      grid-template-columns: repeat(3, 33.33%);
+      min-height: 201px;
+    }
+
+    &:before,
+    &:after {
+      content: '';
+      position: absolute;
+      background: @border-color-split;
+      bottom: 0;
+      right: 0;
+    }
+
+    &:before {
+      width: 1px;
+      top: 0;
+    }
+
+    &:after {
+      height: 1px;
+      left: 0;
+    }
+  }
+
+  .demo-drag-grid-item {
+    cursor: move;
+    border: 1px solid @border-color-split;
+    border-top: none;
+    border-left: none;
+    height: 100px;
+    line-height: 100px;
+    white-space: nowrap;
+    word-break: break-all;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    text-align: center;
+
+    &.sortable-chosen {
+      background: hsla(0, 0%, 60%, 0.1);
+    }
+  }
+</style>
diff --git a/src/views-demo/extension/dragsort/components/demo-list.vue b/src/views-demo/extension/dragsort/components/demo-list.vue
new file mode 100644
index 0000000..4475a12
--- /dev/null
+++ b/src/views-demo/extension/dragsort/components/demo-list.vue
@@ -0,0 +1,135 @@
+<template>
+  <a-row :gutter="16">
+    <a-col
+      v-bind="styleResponsive ? { lg: 8, md: 24, sm: 24, xs: 24 } : { span: 8 }"
+    >
+      <a-card title="列表拖拽排序" :bordered="false">
+        <div class="demo-drag-list">
+          <vue-draggable
+            v-model="list"
+            item-key="id"
+            :animation="300"
+            handle=".sort-handle"
+            :set-data="() => void 0"
+          >
+            <template #item="{ element }">
+              <div class="demo-drag-list-item ele-cell">
+                <drag-outlined class="sort-handle ele-text-secondary" />
+                <div class="ele-cell-content">{{ element.name }}</div>
+              </div>
+            </template>
+          </vue-draggable>
+        </div>
+      </a-card>
+    </a-col>
+    <a-col
+      v-bind="
+        styleResponsive ? { lg: 16, md: 24, sm: 24, xs: 24 } : { span: 16 }
+      "
+    >
+      <a-card title="列表相互拖拽" :bordered="false">
+        <a-row :gutter="16">
+          <a-col :span="12">
+            <div class="demo-drag-list">
+              <vue-draggable
+                v-model="list1"
+                item-key="id"
+                :animation="300"
+                handle=".sort-handle"
+                group="demoDragList"
+                :set-data="() => void 0"
+              >
+                <template #item="{ element }">
+                  <div class="demo-drag-list-item ele-cell">
+                    <drag-outlined class="sort-handle ele-text-secondary" />
+                    <div class="ele-cell-content">{{ element.name }}</div>
+                  </div>
+                </template>
+              </vue-draggable>
+            </div>
+          </a-col>
+          <a-col :span="12">
+            <div class="demo-drag-list">
+              <vue-draggable
+                v-model="list2"
+                item-key="id"
+                :animation="300"
+                handle=".sort-handle"
+                group="demoDragList"
+                :set-data="() => void 0"
+              >
+                <template #item="{ element }">
+                  <div class="demo-drag-list-item ele-cell">
+                    <drag-outlined class="sort-handle ele-text-secondary" />
+                    <div class="ele-cell-content">{{ element.name }}</div>
+                  </div>
+                </template>
+              </vue-draggable>
+            </div>
+          </a-col>
+        </a-row>
+      </a-card>
+    </a-col>
+  </a-row>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import { DragOutlined } from '@ant-design/icons-vue';
+  import VueDraggable from 'vuedraggable';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const list = ref([
+    { id: 1, name: '项目0000001' },
+    { id: 2, name: '项目0000002' },
+    { id: 3, name: '项目0000003' },
+    { id: 4, name: '项目0000004' },
+    { id: 5, name: '项目0000005' }
+  ]);
+
+  const list1 = ref([
+    { id: 1, name: '项目0000001' },
+    { id: 2, name: '项目0000002' },
+    { id: 3, name: '项目0000003' },
+    { id: 4, name: '项目0000004' },
+    { id: 5, name: '项目0000005' }
+  ]);
+
+  const list2 = ref([
+    { id: 6, name: '项目0000006' },
+    { id: 7, name: '项目0000007' },
+    { id: 8, name: '项目0000008' },
+    { id: 9, name: '项目0000009' },
+    { id: 10, name: '项目0000010' }
+  ]);
+</script>
+
+<style lang="less" scoped>
+  .demo-drag-list > div {
+    border: 1px solid hsla(0, 0%, 60%, 0.2);
+    min-height: 81px;
+  }
+
+  .demo-drag-list-item {
+    line-height: 1;
+    padding: 12px 16px;
+
+    & + .demo-drag-list-item {
+      border-top: 1px solid hsla(0, 0%, 60%, 0.2);
+    }
+
+    &.sortable-chosen {
+      background: hsla(0, 0%, 60%, 0.1);
+    }
+
+    .sort-handle {
+      cursor: move;
+      font-size: 16px;
+    }
+  }
+</style>
diff --git a/src/views-demo/extension/dragsort/components/demo-table.vue b/src/views-demo/extension/dragsort/components/demo-table.vue
new file mode 100644
index 0000000..86aeecf
--- /dev/null
+++ b/src/views-demo/extension/dragsort/components/demo-table.vue
@@ -0,0 +1,136 @@
+<template>
+  <a-card title="表格拖拽排序" :bordered="false">
+    <template #extra>
+      <a @click="viewData">查看数据</a>
+    </template>
+    <a-row :gutter="16">
+      <a-col
+        v-for="(item, index) in taskList"
+        :key="index"
+        v-bind="
+          styleResponsive ? { lg: 8, md: 24, sm: 24, xs: 24 } : { span: 8 }
+        "
+      >
+        <table class="ele-table ele-table-border ele-table-medium">
+          <colgroup>
+            <col width="40" />
+            <col />
+            <col width="80" />
+          </colgroup>
+          <thead>
+            <tr>
+              <th></th>
+              <th>任务名称</th>
+              <th style="text-align: center">状态</th>
+            </tr>
+          </thead>
+          <vue-draggable
+            tag="tbody"
+            item-key="id"
+            :animation="300"
+            :modelValue="item"
+            group="demoDragTable"
+            handle=".demo-table-drag-handle"
+            :set-data="() => void 0"
+            @update:modelValue="(value) => updateModelValue(value, index)"
+          >
+            <template #item="{ element }">
+              <tr>
+                <td style="text-align: center">
+                  <drag-outlined
+                    class="demo-table-drag-handle ele-text-secondary"
+                    style="cursor: move"
+                  />
+                </td>
+                <td>{{ element.taskName }}</td>
+                <td style="text-align: center">
+                  <span
+                    :class="
+                      ['ele-text-warning', 'ele-text-success', 'ele-text-info'][
+                        element.status
+                      ]
+                    "
+                  >
+                    {{ ['未开始', '进行中', '已完成'][element.status] }}
+                  </span>
+                </td>
+              </tr>
+            </template>
+            <template #footer v-if="!item.length">
+              <tr style="background: none">
+                <td colspan="3">
+                  <div class="ele-text-secondary ele-text-center">暂无数据</div>
+                </td>
+              </tr>
+            </template>
+          </vue-draggable>
+        </table>
+      </a-col>
+    </a-row>
+  </a-card>
+  <ele-modal v-model:visible="visible" title="拖拽后数据" :footer="null">
+    <div style="max-height: 240px; overflow: auto">
+      <pre>{{ result }}</pre>
+    </div>
+  </ele-modal>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import { DragOutlined } from '@ant-design/icons-vue';
+  import VueDraggable from 'vuedraggable';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+
+  interface Task {
+    id: number;
+    taskName: string;
+    status: number;
+  }
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  //
+  const taskList = ref<Task[][]>([]);
+
+  //
+  const result = ref('');
+
+  //
+  const visible = ref(false);
+
+  /* 更新数据 */
+  const updateModelValue = (value: Task[], index: number) => {
+    taskList.value[index] = value;
+  };
+
+  /* 查看数据 */
+  const viewData = () => {
+    result.value = JSON.stringify(taskList.value, null, 4);
+    visible.value = true;
+  };
+
+  // 处理数据
+  const temp: Task[][] = [];
+  for (let i = 0; i < 18; i++) {
+    const index = parseInt(String(i / 6));
+    if (temp[index] == null) {
+      temp[index] = [];
+    }
+    temp[index].push({
+      id: i,
+      taskName: '测试任务' + (i + 1),
+      status: 0
+    });
+  }
+  taskList.value = temp;
+</script>
+
+<style lang="less" scoped>
+  /* 表格行拖拽按下去样式 */
+  .ele-table tr.sortable-chosen {
+    background: hsla(0, 0%, 60%, 0.1);
+  }
+</style>
diff --git a/src/views-demo/extension/dragsort/index.vue b/src/views-demo/extension/dragsort/index.vue
new file mode 100644
index 0000000..4e0c3ea
--- /dev/null
+++ b/src/views-demo/extension/dragsort/index.vue
@@ -0,0 +1,19 @@
+<template>
+  <div class="ele-body ele-body-card">
+    <demo-list />
+    <demo-grid />
+    <demo-table />
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import DemoList from './components/demo-list.vue';
+  import DemoGrid from './components/demo-grid.vue';
+  import DemoTable from './components/demo-table.vue';
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'ExtensionDragsort'
+  };
+</script>
diff --git a/src/views-demo/extension/editor/index.vue b/src/views-demo/extension/editor/index.vue
new file mode 100644
index 0000000..3ad3825
--- /dev/null
+++ b/src/views-demo/extension/editor/index.vue
@@ -0,0 +1,125 @@
+<template>
+  <div class="ele-body">
+    <a-card :bordered="false">
+      <!-- 按钮 -->
+      <div style="margin-bottom: 16px">
+        <a-space>
+          <a-button type="primary" class="ele-btn-icon" @click="setContent">
+            修改内容
+          </a-button>
+          <a-button type="primary" class="ele-btn-icon" @click="showHtml">
+            获取html
+          </a-button>
+          <a-button type="primary" class="ele-btn-icon" @click="showText">
+            获取文本
+          </a-button>
+          <a-button
+            type="primary"
+            :danger="!disabled"
+            class="ele-btn-icon"
+            @click="toggleDisabled"
+          >
+            {{ disabled ? '启用' : '禁用' }}
+          </a-button>
+        </a-space>
+      </div>
+      <!-- 编辑器 -->
+      <tinymce-editor
+        ref="editorRef"
+        :init="config"
+        v-model:value="content"
+        :disabled="disabled"
+      />
+    </a-card>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import { Modal } from 'ant-design-vue/es';
+  import { htmlToText } from 'ele-admin-pro/es';
+  import TinymceEditor from '@/components/TinymceEditor/index.vue';
+
+  const editorRef = ref<InstanceType<typeof TinymceEditor> | null>(null);
+
+  const config = ref({
+    height: 520,
+    // 自定义文件上传(这里使用把选择的文件转成 blob 演示)
+    file_picker_callback: (callback: any, _value: any, meta: any) => {
+      const input = document.createElement('input');
+      input.setAttribute('type', 'file');
+      // 设定文件可选类型
+      if (meta.filetype === 'image') {
+        input.setAttribute('accept', 'image/*');
+      } else if (meta.filetype === 'media') {
+        input.setAttribute('accept', 'video/*');
+      }
+      input.onchange = () => {
+        const file = input.files?.[0];
+        if (!file) {
+          return;
+        }
+        if (meta.filetype === 'media') {
+          if (!file.type.startsWith('video/')) {
+            editorRef.value?.alert({ content: '只能选择视频文件' });
+            return;
+          }
+        }
+        if (file.size / 1024 / 1024 > 20) {
+          editorRef.value?.alert({ content: '大小不能超过 20MB' });
+          return;
+        }
+        const reader = new FileReader();
+        reader.onload = (e) => {
+          if (e.target?.result != null) {
+            const blob = new Blob([e.target.result], { type: file.type });
+            callback(URL.createObjectURL(blob));
+          }
+        };
+        reader.readAsArrayBuffer(file);
+      };
+      input.click();
+    }
+  });
+
+  const content = ref('');
+
+  const disabled = ref(false);
+
+  /* 获取编辑器内容 */
+  const showHtml = () => {
+    Modal.info({
+      maskClosable: true,
+      content: content.value
+    });
+  };
+
+  /* 获取编辑器纯文本内容 */
+  const showText = () => {
+    Modal.info({
+      maskClosable: true,
+      content: htmlToText(content.value)
+    });
+  };
+
+  /* 修改编辑器内容 */
+  const setContent = () => {
+    content.value = [
+      '<div style="text-align: center;color: #fff;background-image: linear-gradient(-90deg, rgb(62,119,255), rgb(159,98,212), rgb(255,78,170));padding: 32px 0;">',
+      '   <div style="font-size: 28px;margin-bottom: 16px;">EleAdminPro后台管理模板</div>',
+      '   <div style="font-size:18px">通用型后台管理模板,界面美观、开箱即用,拥有丰富的组件和模板</div>',
+      '</div><br/>'
+    ].join('');
+  };
+
+  /* 禁用启用切换 */
+  const toggleDisabled = () => {
+    disabled.value = !disabled.value;
+  };
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'ExtensionEditor'
+  };
+</script>
diff --git a/src/views-demo/extension/excel/components/excel-export.vue b/src/views-demo/extension/excel/components/excel-export.vue
new file mode 100644
index 0000000..4eff656
--- /dev/null
+++ b/src/views-demo/extension/excel/components/excel-export.vue
@@ -0,0 +1,266 @@
+<template>
+  <a-card title="导出 Excel" :bordered="false">
+    <!-- 表格 -->
+    <ele-pro-table
+      bordered
+      row-key="key"
+      :columns="columns"
+      :datasource="data"
+      :need-page="false"
+      tools-theme="default"
+      v-model:selection="selection"
+      :toolkit="['size', 'columns', 'fullscreen']"
+      :scroll="{ x: 800 }"
+    >
+      <template #toolbar>
+        <a-space>
+          <a-button type="primary" class="ele-btn-icon" @click="exportBas">
+            导出
+          </a-button>
+          <a-button type="primary" class="ele-btn-icon" @click="exportAdv">
+            导出带表头合并
+          </a-button>
+          <a-button type="primary" class="ele-btn-icon" @click="exportSel">
+            导出选中
+          </a-button>
+        </a-space>
+      </template>
+    </ele-pro-table>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import { utils, writeFile } from 'xlsx';
+  import { message } from 'ant-design-vue/es';
+  import type { ColumnItem } from 'ele-admin-pro/es/ele-pro-table/types';
+  //
+  interface UserType {
+    key: number;
+    username: string;
+    amount: number;
+    province: string;
+    city: string;
+    zone: string;
+    street: string;
+    address: string;
+  }
+
+  // 表格数据
+  const data = ref<UserType[]>([
+    {
+      key: 1,
+      username: '张小三',
+      amount: 18,
+      province: '浙江',
+      city: '杭州',
+      zone: '西湖区',
+      street: '西溪街道',
+      address: '西溪花园30栋1单元'
+    },
+    {
+      key: 2,
+      username: '李小四',
+      amount: 39,
+      province: '江苏',
+      city: '苏州',
+      zone: '姑苏区',
+      street: '丝绸路',
+      address: '天墅之城9幢2单元'
+    },
+    {
+      key: 3,
+      username: '王小五',
+      amount: 8,
+      province: '江西',
+      city: '南昌',
+      zone: '青山湖区',
+      street: '艾溪湖办事处',
+      address: '中兴和园1幢3单元'
+    },
+    {
+      key: 4,
+      username: '赵小六',
+      amount: 16,
+      province: '福建',
+      city: '泉州',
+      zone: '丰泽区',
+      street: '南洋街道',
+      address: '南洋村6幢1单元'
+    },
+    {
+      key: 5,
+      username: '孙小七',
+      amount: 12,
+      province: '湖北',
+      city: '武汉',
+      zone: '武昌区',
+      street: '武昌大道',
+      address: '两湖花园16幢2单元'
+    },
+    {
+      key: 6,
+      username: '周小八',
+      amount: 11,
+      province: '安徽',
+      city: '黄山',
+      zone: '黄山区',
+      street: '汤口镇',
+      address: '温泉村21号'
+    }
+  ]);
+
+  // 表格列配置
+  const columns = ref<ColumnItem[]>([
+    {
+      key: 'index',
+      width: 48,
+      align: 'center',
+      fixed: 'left',
+      hideInSetting: true,
+      customRender: ({ index }) => index + 1
+    },
+    {
+      title: '用户名',
+      dataIndex: 'username',
+      align: 'center'
+    },
+    {
+      title: '地址',
+      key: 'cityAddress',
+      children: [
+        {
+          title: '省',
+          dataIndex: 'province',
+          align: 'center'
+        },
+        {
+          title: '市',
+          dataIndex: 'city',
+          align: 'center'
+        },
+        {
+          title: '区',
+          dataIndex: 'zone',
+          align: 'center'
+        },
+        {
+          title: '街道',
+          dataIndex: 'street',
+          align: 'center'
+        },
+        {
+          title: '详细地址',
+          dataIndex: 'address',
+          align: 'center'
+        }
+      ]
+    },
+    {
+      title: '金额',
+      dataIndex: 'amount',
+      align: 'center'
+    }
+  ]);
+
+  // 选中数据
+  const selection = ref<UserType[]>([]);
+
+  /* 导出excel */
+  const exportBas = () => {
+    const array: (string | number)[][] = [
+      ['用户名', '省', '市', '区', '街道', '详细地址', '金额']
+    ];
+    data.value.forEach((d) => {
+      array.push([
+        d.username,
+        d.province,
+        d.city,
+        d.zone,
+        d.street,
+        d.address,
+        d.amount
+      ]);
+    });
+    const sheetName = 'Sheet1';
+    const workbook = {
+      SheetNames: [sheetName],
+      Sheets: {}
+    };
+    const sheet = utils.aoa_to_sheet(array);
+    workbook.Sheets[sheetName] = sheet;
+    // 设置列宽
+    sheet['!cols'] = [
+      { wch: 10 },
+      { wch: 10 },
+      { wch: 10 },
+      { wch: 10 },
+      { wch: 20 },
+      { wch: 40 },
+      { wch: 10 }
+    ];
+    writeFile(workbook, '用户数据.xlsx');
+  };
+
+  /* 导出带单元格合并 */
+  const exportAdv = () => {
+    const array: (string | number | null)[][] = [
+      ['用户名', '地址', null, null, null, null, '金额'],
+      [null, '省', '市', '区', '街道', '详细地址', null]
+    ];
+    data.value.forEach((d) => {
+      array.push([
+        d.username,
+        d.province,
+        d.city,
+        d.zone,
+        d.street,
+        d.address,
+        d.amount
+      ]);
+    });
+    const sheet = utils.aoa_to_sheet(array);
+    sheet['!merges'] = [
+      { s: { r: 0, c: 1 }, e: { r: 0, c: 5 } }, // 合并第 0 行第 1 列到第 0 行第5列
+      { s: { r: 0, c: 1 }, e: { r: 0, c: 5 } }, // 合并第 0 行第 1 列到第 0 行第 5 列
+      { s: { r: 0, c: 0 }, e: { r: 1, c: 0 } }, // 合并第 0 行第 0 列到第 1 行第 0 列
+      { s: { r: 0, c: 6 }, e: { r: 1, c: 6 } } // 合并第 0 行第 6 列到第 1 行第 6 列
+    ];
+    const sheetName = 'Sheet1';
+    const workbook = {
+      SheetNames: [sheetName],
+      Sheets: {}
+    };
+    workbook.Sheets[sheetName] = sheet;
+    writeFile(workbook, '用户数据.xlsx');
+  };
+
+  /* 导出选中数据 */
+  const exportSel = () => {
+    if (selection.value.length === 0) {
+      message.error('请至少选择一条数据');
+      return;
+    }
+    const array: (string | number)[][] = [
+      ['用户名', '省', '市', '区', '街道', '详细地址', '金额']
+    ];
+    selection.value.forEach((d) => {
+      array.push([
+        d.username,
+        d.province,
+        d.city,
+        d.zone,
+        d.street,
+        d.address,
+        d.amount
+      ]);
+    });
+    const sheetName = 'Sheet1';
+    const workbook = {
+      SheetNames: [sheetName],
+      Sheets: {}
+    };
+    workbook.Sheets[sheetName] = utils.aoa_to_sheet(array);
+    writeFile(workbook, '用户数据.xlsx');
+  };
+</script>
diff --git a/src/views-demo/extension/excel/components/excel-import.vue b/src/views-demo/extension/excel/components/excel-import.vue
new file mode 100644
index 0000000..f154db1
--- /dev/null
+++ b/src/views-demo/extension/excel/components/excel-import.vue
@@ -0,0 +1,316 @@
+<template>
+  <a-card title="导入 Excel" :bordered="false">
+    <!-- 操作按钮 -->
+    <ele-toolbar :tools="[]">
+      <a-space>
+        <a-upload
+          :before-upload="importFile"
+          :show-upload-list="false"
+          accept=".xls,.xlsx"
+        >
+          <a-button type="primary" class="ele-btn-icon">导入</a-button>
+        </a-upload>
+        <a-upload
+          :before-upload="importFile2"
+          :show-upload-list="false"
+          accept=".xls,.xlsx"
+        >
+          <a-button type="primary" class="ele-btn-icon">导入拆分合并</a-button>
+        </a-upload>
+        <a-upload
+          :before-upload="importFile3"
+          :show-upload-list="false"
+          accept=".xls,.xlsx"
+        >
+          <a-button type="primary" class="ele-btn-icon">导入保持合并</a-button>
+        </a-upload>
+      </a-space>
+    </ele-toolbar>
+    <div style="overflow: auto">
+      <table class="ele-table ele-table-border" style="min-width: max-content">
+        <thead>
+          <tr>
+            <th></th>
+            <th
+              v-for="item in importTitle"
+              :key="item"
+              style="text-align: center"
+            >
+              {{ item }}
+            </th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr v-for="(item, index) in importData" :key="index">
+            <td style="text-align: center">{{ index + 1 }}</td>
+            <template v-for="key in importTitle">
+              <td
+                v-if="
+                  item['__colspan__' + key] !== 0 &&
+                  item['__rowspan__' + key] !== 0
+                "
+                :key="key"
+                :colspan="item['__colspan__' + key]"
+                :rowspan="item['__rowspan__' + key]"
+                style="text-align: center"
+              >
+                {{ item[key] }}
+              </td>
+            </template>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+    <a-row :gutter="32">
+      <a-col
+        v-bind="styleResponsive ? { md: 12, sm: 24, xs: 24 } : { span: 12 }"
+      >
+        <div style="margin: 16px 0">二维数组格式数据:</div>
+        <pre style="max-height: 300px; padding: 16px; overflow: auto"
+          >{{ JSON.stringify(importDataAoa, null, 4) }}
+          </pre
+        >
+      </a-col>
+      <a-col
+        v-bind="styleResponsive ? { md: 12, sm: 24, xs: 24 } : { span: 12 }"
+      >
+        <div style="margin: 16px 0">JSON格式数据:</div>
+        <pre style="max-height: 300px; padding: 16px; overflow: auto"
+          >{{ JSON.stringify(importData, null, 4) }}
+          </pre
+        >
+      </a-col>
+    </a-row>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import { utils, read } from 'xlsx';
+  import { message } from 'ant-design-vue/es';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  // 导入数据的列
+  const importTitle = ref<string[]>(['A', 'B', 'C', 'D', 'E', 'F', 'G']);
+
+  // 导入的数据
+  const importData = ref<Record<string, any>[]>([]);
+
+  // 导入数据二维数组形式
+  const importDataAoa = ref<(string | number)[][]>([]);
+
+  /* 导入本地 excel 文件 */
+  const importFile = (file: File) => {
+    if (
+      ![
+        'application/vnd.ms-excel',
+        'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
+      ].includes(file.type)
+    ) {
+      message.error('只能选择 excel 文件');
+      return false;
+    }
+    if (file.size / 1024 / 1024 > 20) {
+      message.error('大小不能超过 20MB');
+      return false;
+    }
+    const reader = new FileReader();
+    reader.onload = (e) => {
+      const data = new Uint8Array(e.target?.result as any);
+      const workbook = read(data, { type: 'array' });
+      const sheetNames = workbook.SheetNames;
+      const worksheet = workbook.Sheets[sheetNames[0]];
+      // 解析成二维数组
+      const aoa = utils.sheet_to_json<string[]>(worksheet, { header: 1 });
+      // 生成表格需要的数据
+      let list: Record<string, any>[] = [];
+      let maxCols = 0;
+      let title: string[] = [];
+      aoa.forEach((d) => {
+        if (d.length > maxCols) {
+          maxCols = d.length;
+        }
+        const row = {};
+        for (let i = 0; i < d.length; i++) {
+          const key = getCharByIndex(i);
+          row[key] = d[i];
+          row['__colspan__' + key] = 1;
+          row['__rowspan__' + key] = 1;
+        }
+        list.push(row);
+      });
+      for (let i = 0; i < maxCols; i++) {
+        title.push(getCharByIndex(i));
+      }
+      importTitle.value = title;
+      importData.value = list;
+      importDataAoa.value = aoa;
+    };
+    reader.readAsArrayBuffer(file);
+    return false;
+  };
+
+  /* 导入 excel 拆分合并单元格 */
+  const importFile2 = (file: File) => {
+    if (
+      ![
+        'application/vnd.ms-excel',
+        'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
+      ].includes(file.type)
+    ) {
+      message.error('只能选择 excel 文件');
+      return false;
+    }
+    if (file.size / 1024 / 1024 > 20) {
+      message.error('大小不能超过 20MB');
+      return false;
+    }
+    const reader = new FileReader();
+    reader.onload = (e) => {
+      const data = new Uint8Array(e.target?.result as any);
+      const workbook = read(data, { type: 'array' });
+      const sheetNames = workbook.SheetNames;
+      const worksheet = workbook.Sheets[sheetNames[0]];
+      // 解析成二维数组
+      const aoa = utils.sheet_to_json<string[]>(worksheet, { header: 1 });
+      // 拆分合并单元格
+      if (worksheet['!merges']) {
+        worksheet['!merges'].forEach((m) => {
+          for (let r = m.s.r; r <= m.e.r; r++) {
+            for (let c = m.s.c; c <= m.e.c; c++) {
+              aoa[r][c] = aoa[m.s.r][m.s.c];
+            }
+          }
+        });
+      }
+      // 生成表格需要的数据
+      let list: Record<string, any>[] = [];
+      let maxCols = 0;
+      let title: string[] = [];
+      aoa.forEach((d) => {
+        if (d.length > maxCols) {
+          maxCols = d.length;
+        }
+        const row = {};
+        for (let i = 0; i < d.length; i++) {
+          row[getCharByIndex(i)] = d[i];
+        }
+        list.push(row);
+      });
+      for (let i = 0; i < maxCols; i++) {
+        title.push(getCharByIndex(i));
+      }
+      importTitle.value = title;
+      importData.value = list;
+      importDataAoa.value = aoa;
+    };
+    reader.readAsArrayBuffer(file);
+    return false;
+  };
+
+  /* 导入 excel 读取合并信息 */
+  const importFile3 = (file: File) => {
+    if (
+      ![
+        'application/vnd.ms-excel',
+        'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
+      ].includes(file.type)
+    ) {
+      message.error('只能选择 excel 文件');
+      return false;
+    }
+    if (file.size / 1024 / 1024 > 20) {
+      message.error('大小不能超过 20MB');
+      return false;
+    }
+    const reader = new FileReader();
+    reader.onload = (e) => {
+      const data = new Uint8Array(e.target?.result as any);
+      const workbook = read(data, { type: 'array' });
+      const sheetNames = workbook.SheetNames;
+      const worksheet = workbook.Sheets[sheetNames[0]];
+      // 解析成二维数组
+      const aoa = utils.sheet_to_json<string[]>(worksheet, { header: 1 });
+      // 生成表格需要的数据
+      let list: Record<string, any>[] = [];
+      let maxCols = 0;
+      let title: string[] = [];
+      aoa.forEach((d) => {
+        if (d.length > maxCols) {
+          maxCols = d.length;
+        }
+        const row = {};
+        for (let i = 0; i < d.length; i++) {
+          row[getCharByIndex(i)] = d[i];
+        }
+        list.push(row);
+      });
+      for (let i = 0; i < maxCols; i++) {
+        title.push(getCharByIndex(i));
+      }
+      // 记录合并单元格
+      if (worksheet['!merges']) {
+        worksheet['!merges'].forEach((m) => {
+          for (let r = m.s.r; r <= m.e.r; r++) {
+            for (let c = m.s.c; c <= m.e.c; c++) {
+              const cc = getCharByIndex(c);
+              list[r]['__colspan__' + cc] = 0;
+              list[r]['__rowspan__' + cc] = 0;
+            }
+          }
+          const char = getCharByIndex(m.s.c);
+          list[m.s.r]['__colspan__' + char] = m.e.c - m.s.c + 1;
+          list[m.s.r]['__rowspan__' + char] = m.e.r - m.s.r + 1;
+        });
+      }
+      importTitle.value = title;
+      importData.value = list;
+      importDataAoa.value = aoa;
+    };
+    reader.readAsArrayBuffer(file);
+    return false;
+  };
+
+  /* 生成Excel列字母序号 */
+  const getCharByIndex = (index: number) => {
+    const chars = [
+      'A',
+      'B',
+      'C',
+      'D',
+      'E',
+      'F',
+      'G',
+      'H',
+      'I',
+      'J',
+      'K',
+      'L',
+      'M',
+      'N',
+      'O',
+      'P',
+      'Q',
+      'R',
+      'S',
+      'T',
+      'U',
+      'V',
+      'W',
+      'X',
+      'Y',
+      'Z'
+    ];
+    if (index < chars.length) {
+      return chars[index];
+    }
+    const n = parseInt(String(index / chars.length));
+    const m = index % chars.length;
+    return chars[n] + chars[m];
+  };
+</script>
diff --git a/src/views-demo/extension/excel/index.vue b/src/views-demo/extension/excel/index.vue
new file mode 100644
index 0000000..1d5cd79
--- /dev/null
+++ b/src/views-demo/extension/excel/index.vue
@@ -0,0 +1,17 @@
+<template>
+  <div class="ele-body ele-body-card">
+    <excel-export />
+    <excel-import />
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import ExcelExport from './components/excel-export.vue';
+  import ExcelImport from './components/excel-import.vue';
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'ExtensionExcel'
+  };
+</script>
diff --git a/src/views-demo/extension/file/components/file-header.vue b/src/views-demo/extension/file/components/file-header.vue
new file mode 100644
index 0000000..61c6355
--- /dev/null
+++ b/src/views-demo/extension/file/components/file-header.vue
@@ -0,0 +1,119 @@
+<!-- 文件目录面包屑 -->
+<template>
+  <div class="ele-file-breadcrumb-group ele-cell">
+    <div class="ele-cell-content ele-cell">
+      <div
+        v-if="directorys.length"
+        class="ele-file-breadcrumb-back ele-text-primary"
+        @click="goBack"
+      >
+        返回上一级
+      </div>
+      <div class="ele-file-breadcrumb-list ele-cell-content ele-cell">
+        <div
+          :class="[
+            'ele-file-breadcrumb-item ele-cell',
+            { 'ele-text-primary': !!directorys.length }
+          ]"
+          @click="goRoot"
+        >
+          <div class="ele-file-breadcrumb-item-title">全部文件</div>
+          <right-outlined v-if="directorys.length" class="ele-text-secondary" />
+        </div>
+        <div
+          v-for="(item, i) in directorys"
+          :key="item.id"
+          :class="[
+            'ele-file-breadcrumb-item ele-cell',
+            { 'ele-text-primary': i !== directorys.length - 1 }
+          ]"
+          @click="goDirectory(i)"
+        >
+          <div class="ele-file-breadcrumb-item-title">{{ item.name }}</div>
+          <right-outlined
+            v-if="i !== directorys.length - 1"
+            class="ele-text-secondary"
+          />
+        </div>
+      </div>
+    </div>
+    <div :class="{ 'hidden-xs-only': styleResponsive }">
+      已全部加载,共 {{ total }} 个
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { RightOutlined } from '@ant-design/icons-vue';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import type { UserFile } from '@/api/system/user-file/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const props = defineProps<{
+    // 文件夹数据
+    directorys: UserFile[];
+    // 总文件数
+    total: number;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'update:directorys', value: UserFile[]): void;
+  }>();
+
+  /* 回到上级 */
+  const goBack = () => {
+    emit(
+      'update:directorys',
+      props.directorys.slice(0, props.directorys.length - 1)
+    );
+  };
+
+  /* 回到根目录 */
+  const goRoot = () => {
+    if (props.directorys.length) {
+      emit('update:directorys', []);
+    }
+  };
+
+  /* 回到指定目录 */
+  const goDirectory = (index: number) => {
+    if (index !== props.directorys.length - 1) {
+      emit('update:directorys', props.directorys.slice(0, index + 1));
+    }
+  };
+</script>
+
+<style lang="less" scoped>
+  /* 文件目录面包屑 */
+  .ele-file-breadcrumb-group {
+    line-height: 1;
+  }
+
+  .ele-file-breadcrumb-back {
+    padding-right: 12px;
+    border-right: 1px solid hsla(0, 0%, 60%, 0.3);
+  }
+
+  .ele-file-breadcrumb-back:hover,
+  .ele-file-breadcrumb-item.ele-text-primary:hover
+    > .ele-file-breadcrumb-item-title {
+    text-decoration: underline;
+    cursor: pointer;
+  }
+
+  .ele-file-breadcrumb-item .anticon {
+    margin: 0 4px;
+    font-size: 12px;
+  }
+
+  @media screen and (max-width: 768px) {
+    .ele-table-tool > .ele-table-tool-title + div,
+    .ele-file-breadcrumb-group > .ele-cell-content + div {
+      display: none;
+    }
+  }
+</style>
diff --git a/src/views-demo/extension/file/components/file-list.vue b/src/views-demo/extension/file/components/file-list.vue
new file mode 100644
index 0000000..f2df076
--- /dev/null
+++ b/src/views-demo/extension/file/components/file-list.vue
@@ -0,0 +1,243 @@
+<!-- 文件展示列表 -->
+<template>
+  <div class="demo-file-list-group">
+    <ele-file-list
+      :data="data"
+      :grid="grid"
+      :sort="sort"
+      :order="order"
+      :checked="checked"
+      :style="{ minHeight: '400px', minWidth: grid ? 'auto' : '456px' }"
+      @item-click="onItemClick"
+      @sort-change="onSortChange"
+      @update:checked="updateChecked"
+    >
+      <template #context-menu="{ item }">
+        <a-menu
+          :selectable="false"
+          @click="({ key }) => onCtxMenuClick(key, item)"
+        >
+          <a-menu-item key="open">打开</a-menu-item>
+          <a-menu-divider />
+          <a-menu-item key="download" v-if="!item.isDirectory">
+            <div class="ele-cell">
+              <download-outlined />
+              <div class="ele-cell-content">下载</div>
+            </div>
+          </a-menu-item>
+          <a-menu-item key="edit">
+            <div class="ele-cell">
+              <edit-outlined />
+              <div class="ele-cell-content">重命名</div>
+            </div>
+          </a-menu-item>
+          <a-menu-item key="move">
+            <div class="ele-cell">
+              <drag-outlined />
+              <div class="ele-cell-content">移动到</div>
+            </div>
+          </a-menu-item>
+          <a-menu-divider />
+          <a-menu-item key="remove">
+            <div class="ele-cell ele-text-danger">
+              <delete-outlined />
+              <div class="ele-cell-content">删除</div>
+            </div>
+          </a-menu-item>
+        </a-menu>
+      </template>
+    </ele-file-list>
+    <div v-if="!data.length" class="demo-file-list-empty">
+      <a-empty />
+    </div>
+  </div>
+  <!-- 用于图片预览 -->
+  <div style="display: none">
+    <AImagePreviewGroup v-if="previewOption.visible" :preview="previewOption">
+      <AImage
+        v-for="item in previewImages"
+        :key="String(item.id)"
+        :src="item.url"
+      />
+    </AImagePreviewGroup>
+  </div>
+  <!-- 文件重命名弹窗 -->
+  <name-edit
+    v-model:visible="nameEditVisible"
+    :data="nameEditData"
+    @done="onDone"
+  />
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive, createVNode } from 'vue';
+  import { message, Modal } from 'ant-design-vue/es';
+  import {
+    DownloadOutlined,
+    DragOutlined,
+    EditOutlined,
+    DeleteOutlined,
+    ExclamationCircleOutlined
+  } from '@ant-design/icons-vue';
+  import { messageLoading } from 'ele-admin-pro/es';
+  import type {
+    FileItem,
+    SortValue
+  } from 'ele-admin-pro/es/ele-file-list/types';
+  import { removeUserFile } from '@/api/system/user-file';
+  import type { UserFile } from '@/api/system/user-file/model';
+  import NameEdit from './name-edit.vue';
+
+  const props = defineProps<{
+    // 父级 id
+    parentId?: number;
+    // 文件列表数据
+    data: FileItem[];
+    // 排序字段
+    sort?: string;
+    // 排序方式
+    order?: string;
+    // 选中数据
+    checked: FileItem[];
+    // 是否网格展示
+    grid: boolean;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'sort-change', value: SortValue): void;
+    (e: 'update:checked', value: FileItem[]): void;
+    (e: 'go-directory', value: UserFile): void;
+    (e: 'done'): void;
+  }>();
+
+  // 图片预览配置
+  const previewOption = reactive({
+    current: 0,
+    visible: false,
+    onVisibleChange: (visible: boolean) => {
+      previewOption.visible = visible;
+    }
+  });
+
+  // 图片预览列表
+  const previewImages = ref<FileItem[]>([]);
+
+  // 文件重命名弹窗是否打开
+  const nameEditVisible = ref<boolean>(false);
+
+  // 文件重命名的数据
+  const nameEditData = ref<UserFile>();
+
+  /* 文件列表排序方式改变 */
+  const onSortChange = (option: SortValue) => {
+    emit('sort-change', option);
+  };
+
+  /* 更新选中数据 */
+  const updateChecked = (value: FileItem[]) => {
+    emit('update:checked', value);
+  };
+
+  /* item 点击事件 */
+  const onItemClick = (item: FileItem) => {
+    if (item.isDirectory) {
+      // 进入文件夹
+      emit('go-directory', item as unknown as UserFile);
+    } else if (isImageFile(item)) {
+      // 预览图片文件
+      previewItemImage(item);
+    } else {
+      // 选中或取消选中文件
+      updateChecked(
+        props.checked.includes(item)
+          ? props.checked.filter((d) => d !== item)
+          : [...props.checked, item]
+      );
+    }
+  };
+
+  /* 右键菜单点击事件 */
+  const onCtxMenuClick = (key: any, item: FileItem) => {
+    if (key === 'open') {
+      // 打开文件
+      if (item.isDirectory || isImageFile(item)) {
+        onItemClick(item);
+      } else {
+        window.open(item.url);
+      }
+    } else if (key === 'download') {
+      // 下载文件
+      if (typeof item.downloadUrl === 'string') {
+        window.open(item.downloadUrl);
+      }
+    } else if (key === 'edit') {
+      // 重命名
+      nameEditData.value = item as unknown as UserFile;
+      nameEditVisible.value = true;
+    } else if (key === 'remove') {
+      // 删除文件
+      removeItem(item);
+    }
+  };
+
+  /* 删除 */
+  const removeItem = (item: FileItem) => {
+    Modal.confirm({
+      title: '提示',
+      content: '确定要删除此文件吗?',
+      icon: createVNode(ExclamationCircleOutlined),
+      maskClosable: true,
+      onOk: () => {
+        const hide = messageLoading('请求中..', 0);
+        removeUserFile(item.id as number)
+          .then((msg) => {
+            hide();
+            message.success(msg);
+            onDone();
+          })
+          .catch((e) => {
+            hide();
+            message.error(e.message);
+          });
+      }
+    });
+  };
+
+  /* 完成刷新列表数据 */
+  const onDone = () => {
+    emit('done');
+  };
+
+  /* 判断是否是图片文件 */
+  const isImageFile = (item: FileItem) => {
+    return (
+      typeof item.contentType === 'string' &&
+      item.contentType.startsWith('image/') &&
+      item.url
+    );
+  };
+
+  /* 预览图片文件 */
+  const previewItemImage = (item: FileItem) => {
+    previewImages.value = props.data.filter((d) => isImageFile(d));
+    const index = previewImages.value.indexOf(item);
+    if (index !== -1) {
+      previewOption.current = index;
+      previewOption.visible = true;
+    }
+  };
+</script>
+
+<style lang="less" scoped>
+  .demo-file-list-group {
+    position: relative;
+    overflow-x: auto;
+
+    .demo-file-list-empty {
+      position: absolute;
+      top: 100px;
+      left: 50%;
+      transform: translateX(-50%);
+    }
+  }
+</style>
diff --git a/src/views-demo/extension/file/components/file-toolbar.vue b/src/views-demo/extension/file/components/file-toolbar.vue
new file mode 100644
index 0000000..1ee5f07
--- /dev/null
+++ b/src/views-demo/extension/file/components/file-toolbar.vue
@@ -0,0 +1,183 @@
+<template>
+  <ele-toolbar>
+    <a-space>
+      <a-upload :show-upload-list="false" :customRequest="onUpload">
+        <a-button type="primary" class="ele-btn-icon">
+          <template #icon>
+            <upload-outlined />
+          </template>
+          <span>上传</span>
+        </a-button>
+      </a-upload>
+      <a-button type="dashed" class="ele-btn-icon" @click="openFolderAdd">
+        <template #icon>
+          <folder-add-outlined />
+        </template>
+        <span>新建文件夹</span>
+      </a-button>
+      <a-button
+        danger
+        type="dashed"
+        :disabled="!checked.length"
+        :class="['ele-btn-icon', { 'hidden-xs-only': styleResponsive }]"
+        @click="removeBatch"
+      >
+        <template #icon>
+          <delete-outlined />
+        </template>
+        <span>删除</span>
+      </a-button>
+    </a-space>
+    <template #action>
+      <!-- 搜索框 -->
+      <div
+        style="max-width: 240px"
+        :class="{ 'hidden-sm-and-down': styleResponsive }"
+      >
+        <a-input-search v-model:value="search" placeholder="搜索您的文件" />
+      </div>
+      <!-- 显示方式切换 -->
+      <menu-outlined
+        v-if="grid"
+        class="ele-file-tool-btn"
+        @click="toggleShowType"
+      />
+      <appstore-outlined
+        v-else
+        class="ele-file-tool-btn"
+        @click="toggleShowType"
+      />
+    </template>
+  </ele-toolbar>
+  <!-- 新建文件夹弹窗 -->
+  <folder-add
+    v-model:visible="folderAddVisible"
+    :parent-id="parentId"
+    @done="onDone"
+  />
+</template>
+
+<script lang="ts" setup>
+  import { ref, createVNode } from 'vue';
+  import { message, Modal } from 'ant-design-vue/es';
+  import {
+    MenuOutlined,
+    AppstoreOutlined,
+    DeleteOutlined,
+    UploadOutlined,
+    FolderAddOutlined,
+    ExclamationCircleOutlined
+  } from '@ant-design/icons-vue';
+  import { messageLoading } from 'ele-admin-pro/es';
+  import type { FileItem } from 'ele-admin-pro/es/ele-file-list/types';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import { uploadFile } from '@/api/system/file';
+  import { addUserFile, removeUserFiles } from '@/api/system/user-file';
+  import FolderAdd from './folder-add.vue';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const props = defineProps<{
+    // 是否网格展示
+    grid: boolean;
+    // 选中数据
+    checked: FileItem[];
+    // 父级 id
+    parentId?: number;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'update:grid', value: boolean): void;
+    (e: 'done'): void;
+  }>();
+
+  // 搜索关键字
+  const search = ref<string>('');
+
+  // 新建文件夹弹窗是否打开
+  const folderAddVisible = ref<boolean>(false);
+
+  /* 上传 */
+  const onUpload = ({ file }) => {
+    if (file.size / 1024 / 1024 > 100) {
+      message.error('大小不能超过 100MB');
+      return false;
+    }
+    const hide = messageLoading('上传中..', 0);
+    uploadFile(file)
+      .then((data) => {
+        addUserFile({
+          name: data.name,
+          isDirectory: 0,
+          parentId: props.parentId,
+          path: data.path,
+          length: data.length,
+          contentType: data.contentType
+        })
+          .then(() => {
+            hide();
+            message.success('上传成功');
+            onDone();
+          })
+          .catch((e) => {
+            hide();
+            message.error(e.message);
+          });
+      })
+      .catch((e) => {
+        hide();
+        message.error(e.message);
+      });
+    return false;
+  };
+
+  /* 打开新建文件夹弹窗 */
+  const openFolderAdd = () => {
+    folderAddVisible.value = true;
+  };
+
+  /* 批量删除 */
+  const removeBatch = () => {
+    Modal.confirm({
+      title: '提示',
+      content: '确定要删除选中的文件吗?',
+      icon: createVNode(ExclamationCircleOutlined),
+      maskClosable: true,
+      onOk: () => {
+        const hide = messageLoading('请求中..', 0);
+        removeUserFiles(props.checked.map((d) => d.id as number))
+          .then((msg) => {
+            hide();
+            message.success(msg);
+            onDone();
+          })
+          .catch((e) => {
+            hide();
+            message.error(e.message);
+          });
+      }
+    });
+  };
+
+  /* 完成刷新列表数据 */
+  const onDone = () => {
+    emit('done');
+  };
+
+  /* 显示方式切换 */
+  const toggleShowType = () => {
+    emit('update:grid', !props.grid);
+  };
+</script>
+
+<style lang="less" scoped>
+  /* 图标按钮 */
+  .ele-file-tool-btn {
+    font-size: 20px;
+    margin-left: 16px;
+    cursor: pointer;
+  }
+</style>
diff --git a/src/views-demo/extension/file/components/folder-add.vue b/src/views-demo/extension/file/components/folder-add.vue
new file mode 100644
index 0000000..f638820
--- /dev/null
+++ b/src/views-demo/extension/file/components/folder-add.vue
@@ -0,0 +1,123 @@
+<template>
+  <ele-modal
+    :width="460"
+    :visible="visible"
+    :confirm-loading="loading"
+    title="新建文件夹"
+    :body-style="{ paddingBottom: '8px' }"
+    @update:visible="updateVisible"
+    @ok="save"
+  >
+    <a-form
+      ref="formRef"
+      :model="form"
+      :rules="rules"
+      :label-col="
+        styleResponsive ? { md: 6, sm: 6, xs: 24 } : { flex: '100px' }
+      "
+      :wrapper-col="
+        styleResponsive ? { md: 18, sm: 18, xs: 24 } : { flex: '1' }
+      "
+    >
+      <a-form-item label="文件夹名称" name="name">
+        <a-input
+          allow-clear
+          :maxlength="20"
+          placeholder="请输入文件夹名称"
+          v-model:value="form.name"
+        />
+      </a-form-item>
+    </a-form>
+  </ele-modal>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive, watch } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import { addUserFile } from '@/api/system/user-file';
+  import type { UserFile } from '@/api/system/user-file/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'done'): void;
+    (e: 'update:visible', visible: boolean): void;
+  }>();
+
+  const props = defineProps<{
+    // 弹窗是否打开
+    visible: boolean;
+    // 父级 id
+    parentId?: number;
+  }>();
+
+  //
+  const formRef = ref<FormInstance | null>(null);
+
+  // 提交状态
+  const loading = ref(false);
+
+  // 表单数据
+  const { form, resetFields } = useFormData<UserFile>({ name: '' });
+
+  // 表单验证规则
+  const rules = reactive<Record<string, Rule[]>>({
+    name: [
+      {
+        required: true,
+        message: '请输入文件夹名称',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ]
+  });
+
+  /* 保存编辑 */
+  const save = () => {
+    if (!formRef.value) {
+      return;
+    }
+    formRef.value
+      .validate()
+      .then(() => {
+        loading.value = true;
+        addUserFile({
+          ...form,
+          parentId: props.parentId,
+          isDirectory: 1
+        })
+          .then((msg) => {
+            loading.value = false;
+            message.success(msg);
+            updateVisible(false);
+            emit('done');
+          })
+          .catch((e) => {
+            loading.value = false;
+            message.error(e.message);
+          });
+      })
+      .catch(() => {});
+  };
+
+  /* 更新 visible */
+  const updateVisible = (value: boolean) => {
+    emit('update:visible', value);
+  };
+
+  watch(
+    () => props.visible,
+    (visible) => {
+      if (!visible) {
+        resetFields();
+        formRef.value?.clearValidate();
+      }
+    }
+  );
+</script>
diff --git a/src/views-demo/extension/file/components/name-edit.vue b/src/views-demo/extension/file/components/name-edit.vue
new file mode 100644
index 0000000..3c3beff
--- /dev/null
+++ b/src/views-demo/extension/file/components/name-edit.vue
@@ -0,0 +1,121 @@
+<template>
+  <ele-modal
+    :width="460"
+    :visible="visible"
+    :confirm-loading="loading"
+    title="重命名"
+    :body-style="{ paddingBottom: '8px' }"
+    @update:visible="updateVisible"
+    @ok="save"
+  >
+    <a-form
+      ref="formRef"
+      :model="form"
+      :rules="rules"
+      :label-col="
+        styleResponsive ? { md: 6, sm: 6, xs: 24 } : { flex: '100px' }
+      "
+      :wrapper-col="
+        styleResponsive ? { md: 18, sm: 18, xs: 24 } : { flex: '1' }
+      "
+    >
+      <a-form-item label="文件/夹名称" name="name">
+        <a-input
+          allow-clear
+          :maxlength="20"
+          placeholder="请输入文件/夹名称"
+          v-model:value="form.name"
+        />
+      </a-form-item>
+    </a-form>
+  </ele-modal>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive, watch } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import { updateUserFile } from '@/api/system/user-file';
+  import type { UserFile } from '@/api/system/user-file/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'done'): void;
+    (e: 'update:visible', visible: boolean): void;
+  }>();
+
+  const props = defineProps<{
+    // 弹窗是否打开
+    visible: boolean;
+    // 文件数据
+    data?: UserFile;
+  }>();
+
+  //
+  const formRef = ref<FormInstance | null>(null);
+
+  // 提交状态
+  const loading = ref(false);
+
+  // 表单数据
+  const { form, resetFields } = useFormData<UserFile>({ name: '' });
+
+  // 表单验证规则
+  const rules = reactive<Record<string, Rule[]>>({
+    name: [
+      {
+        required: true,
+        message: '请输入文件/夹名称',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ]
+  });
+
+  /* 保存编辑 */
+  const save = () => {
+    if (!formRef.value) {
+      return;
+    }
+    formRef.value
+      .validate()
+      .then(() => {
+        loading.value = true;
+        updateUserFile({ ...form, id: props.data?.id })
+          .then((msg) => {
+            loading.value = false;
+            message.success(msg);
+            updateVisible(false);
+            emit('done');
+          })
+          .catch((e) => {
+            loading.value = false;
+            message.error(e.message);
+          });
+      })
+      .catch(() => {});
+  };
+
+  /* 更新 visible */
+  const updateVisible = (value: boolean) => {
+    emit('update:visible', value);
+  };
+
+  watch(
+    () => props.visible,
+    (visible) => {
+      if (!visible) {
+        resetFields();
+        formRef.value?.clearValidate();
+      } else if (props.data) {
+        form.name = props.data.name;
+      }
+    }
+  );
+</script>
diff --git a/src/views-demo/extension/file/index.vue b/src/views-demo/extension/file/index.vue
new file mode 100644
index 0000000..2b2a244
--- /dev/null
+++ b/src/views-demo/extension/file/index.vue
@@ -0,0 +1,147 @@
+<template>
+  <div class="ele-body">
+    <a-card :bordered="false" :body-style="{ padding: 0 }">
+      <div style="padding: 16px 16px 12px 16px">
+        <file-toolbar
+          v-model:grid="grid"
+          :checked="checked"
+          :parentId="parentId"
+          @done="onDone"
+        />
+        <file-header
+          :total="total"
+          :directorys="directorys"
+          @update:directorys="updateDirectorys"
+        />
+      </div>
+      <a-spin :spinning="loading">
+        <file-list
+          :grid="grid"
+          :data="data"
+          :sort="sort"
+          :order="order"
+          :parentId="parentId"
+          v-model:checked="checked"
+          @sort-change="onSortChange"
+          @go-directory="onGoDirectory"
+          @done="onDone"
+        />
+      </a-spin>
+    </a-card>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type {
+    FileItem,
+    SortValue
+  } from 'ele-admin-pro/es/ele-file-list/types';
+  import FileToolbar from './components/file-toolbar.vue';
+  import FileHeader from './components/file-header.vue';
+  import FileList from './components/file-list.vue';
+  import { listUserFiles } from '@/api/system/user-file';
+  import type { UserFile } from '@/api/system/user-file/model';
+
+  // 加载状态
+  const loading = ref(true);
+
+  // 文件列表数据
+  const data = ref<FileItem[]>([]);
+
+  // 排序字段
+  const sort = ref<string>('');
+
+  // 排序方式
+  const order = ref<string>('');
+
+  // 选中数据
+  const checked = ref<FileItem[]>([]);
+
+  // 文件夹数据
+  const directorys = ref<UserFile[]>([]);
+
+  // 总文件数
+  const total = ref<number>(0);
+
+  // 是否网格展示
+  const grid = ref(true);
+
+  // 父级 id
+  const parentId = ref<number>(0);
+
+  /* 查询文件列表 */
+  const query = () => {
+    data.value = [];
+    checked.value = [];
+    loading.value = true;
+    listUserFiles({
+      sort: order.value ? sort.value : '',
+      order: order.value,
+      parentId: parentId.value
+    })
+      .then((list) => {
+        loading.value = false;
+        data.value = list.map((d) => {
+          return Object.assign({ name: d.name }, d, {
+            isDirectory: d.isDirectory === 1 ? true : false,
+            length: formatLength(d.length)
+          });
+        });
+        total.value = list.length;
+      })
+      .catch((e) => {
+        loading.value = false;
+        message.error(e.message);
+      });
+  };
+
+  /* 刷新列表数据 */
+  const onDone = () => {
+    query();
+  };
+
+  /* 文件列表排序方式改变 */
+  const onSortChange = (option: SortValue) => {
+    order.value = option.order;
+    sort.value = option.sort;
+    query();
+  };
+
+  /* 进入文件夹 */
+  const onGoDirectory = (item: UserFile) => {
+    updateDirectorys([...directorys.value, item]);
+  };
+
+  /* 更新文件夹数据 */
+  const updateDirectorys = (values: UserFile[]) => {
+    directorys.value = values;
+    parentId.value = directorys.value[directorys.value.length - 1]?.id ?? 0;
+    query();
+  };
+
+  /* 格式化文件大小 */
+  const formatLength = (length?: number) => {
+    if (length == null) {
+      return '-';
+    }
+    if (length < 1024) {
+      return length + 'B';
+    } else if (length < 1024 * 1024) {
+      return (length / 1024).toFixed(1) + 'KB';
+    } else if (length < 1024 * 1024 * 1024) {
+      return (length / 1024 / 1024).toFixed(1) + 'M';
+    } else {
+      return (length / 1024 / 1024 / 1024).toFixed(1) + 'G';
+    }
+  };
+
+  query();
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'ExtensionFile'
+  };
+</script>
diff --git a/src/views-demo/extension/map/components/demo-map.vue b/src/views-demo/extension/map/components/demo-map.vue
new file mode 100644
index 0000000..5c446a5
--- /dev/null
+++ b/src/views-demo/extension/map/components/demo-map.vue
@@ -0,0 +1,98 @@
+<template>
+  <a-card title="官网底部地图" :bordered="false">
+    <div ref="locationMapRef" style="height: 360px; max-width: 800px"></div>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref, watch, onMounted, onUnmounted } from 'vue';
+  import AMapLoader from '@amap/amap-jsapi-loader';
+  import { useThemeStore } from '@/store/modules/theme';
+  import { storeToRefs } from 'pinia';
+  import { MAP_KEY } from '@/config/setting';
+
+  const themeStore = useThemeStore();
+  const { darkMode } = storeToRefs(themeStore);
+
+  //
+  const locationMapRef = ref<HTMLElement | null>(null);
+
+  // 官网底部地图的实例
+  let mapInsLocation: any;
+
+  /* 渲染官网底部地图 */
+  const renderLocationMap = () => {
+    AMapLoader.load({
+      key: MAP_KEY,
+      version: '2.0',
+      plugins: ['AMap.InfoWindow', 'AMap.Marker']
+    })
+      .then((AMap) => {
+        // 渲染地图
+        const option = {
+          zoom: 13, // 初缩放级别
+          center: [114.346084, 30.511215 + 0.005], // 初始中心点
+          mapStyle: darkMode.value ? 'amap://styles/dark' : void 0
+        };
+        mapInsLocation = new AMap.Map(locationMapRef.value, option);
+        // 创建信息窗体
+        const infoWindow = new AMap.InfoWindow({
+          content: `
+            <div style="color: #333;">
+              <div style="padding: 5px;font-size: 16px;">武汉易云智科技有限公司</div>
+              <div style="padding: 0 5px;">地址: 湖北省武汉市洪山区雄楚大道222号</div>
+              <div style="padding: 0 5px;">电话: 020-123456789</div>
+            </div>
+            <a
+              class="ele-text-primary"
+              style="padding: 8px 5px 0;text-decoration: none;display: inline-block;"
+              href="//uri.amap.com/marker?position=114.346084,30.511215&name=武汉易云智科技有限公司"
+              target="_blank">到这里去→
+            </a>
+          `
+        });
+        infoWindow.open(mapInsLocation, [114.346084, 30.511215]);
+        const icon = new AMap.Icon({
+          size: new AMap.Size(25, 34),
+          image:
+            '//a.amap.com/jsapi_demos/static/demo-center/icons/poi-marker-red.png',
+          imageSize: new AMap.Size(25, 34)
+        });
+        const marker = new AMap.Marker({
+          icon: icon,
+          position: [114.346084, 30.511215],
+          offset: new AMap.Pixel(-12, -28)
+        });
+        marker.setMap(mapInsLocation);
+        marker.on('click', () => {
+          infoWindow.open(mapInsLocation);
+        });
+      })
+      .catch((e) => {
+        console.error(e);
+      });
+  };
+
+  /* 渲染地图 */
+  onMounted(() => {
+    renderLocationMap();
+  });
+
+  /* 销毁地图 */
+  onUnmounted(() => {
+    if (mapInsLocation) {
+      mapInsLocation.destroy();
+    }
+  });
+
+  /* 同步框架暗黑模式切换 */
+  watch(darkMode, (value) => {
+    if (mapInsLocation) {
+      if (value) {
+        mapInsLocation.setMapStyle('amap://styles/dark');
+      } else {
+        mapInsLocation.setMapStyle('amap://styles/normal');
+      }
+    }
+  });
+</script>
diff --git a/src/views-demo/extension/map/components/demo-picker.vue b/src/views-demo/extension/map/components/demo-picker.vue
new file mode 100644
index 0000000..9e651e1
--- /dev/null
+++ b/src/views-demo/extension/map/components/demo-picker.vue
@@ -0,0 +1,63 @@
+<template>
+  <a-card title="地图位置选择器" :bordered="false">
+    <a-space>
+      <div style="width: 140px">
+        <a-select v-model:value="searchType" class="ele-fluid">
+          <a-select-option :value="0">POI检索模式</a-select-option>
+          <a-select-option :value="1">关键字检索模式</a-select-option>
+        </a-select>
+      </div>
+      <a-button class="ele-btn-icon" @click="openMapPicker">
+        打开地图位置选择器
+      </a-button>
+    </a-space>
+    <div style="margin-top: 12px">选择位置: {{ result.location }}</div>
+    <div style="margin-top: 12px">详细地址: {{ result.address }}</div>
+    <div style="margin-top: 12px">经 纬 度 : {{ result.lngAndLat }}</div>
+  </a-card>
+  <!-- 地图位置选择弹窗 -->
+  <ele-map-picker
+    :need-city="true"
+    :dark-mode="darkMode"
+    v-model:visible="visible"
+    :search-type="searchType"
+    @done="onChoose"
+  />
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive } from 'vue';
+  import type { CenterPoint } from 'ele-admin-pro/es/ele-map-picker/types';
+  import { useThemeStore } from '@/store/modules/theme';
+  import { storeToRefs } from 'pinia';
+
+  const themeStore = useThemeStore();
+  const { darkMode } = storeToRefs(themeStore);
+
+  // 是否显示地图选择弹窗
+  const visible = ref(false);
+
+  // 地点检索类型
+  const searchType = ref(0);
+
+  // 选择结果
+  const result = reactive({
+    location: '',
+    address: '',
+    lngAndLat: ''
+  });
+
+  /* 打开位置选择 */
+  const openMapPicker = () => {
+    visible.value = true;
+  };
+
+  /* 地图选择后回调 */
+  const onChoose = (location: CenterPoint) => {
+    console.log(location);
+    result.location = `${location.city?.province}/${location.city?.city}/${location.city?.district}`;
+    result.address = `${location.name} ${location.address}`;
+    result.lngAndLat = `${location.lng},${location.lat}`;
+    visible.value = false;
+  };
+</script>
diff --git a/src/views-demo/extension/map/components/demo-track.vue b/src/views-demo/extension/map/components/demo-track.vue
new file mode 100644
index 0000000..661288f
--- /dev/null
+++ b/src/views-demo/extension/map/components/demo-track.vue
@@ -0,0 +1,164 @@
+<template>
+  <a-card title="轨迹展示及轨迹回放" :bordered="false">
+    <div
+      ref="trackMapRef"
+      style="height: 360px; max-width: 800px; margin-bottom: 16px"
+    >
+    </div>
+    <a-space>
+      <a-button type="primary" class="ele-btn-icon" @click="startTrackAnim">
+        开始移动
+      </a-button>
+      <a-button type="primary" class="ele-btn-icon" @click="pauseTrackAnim">
+        暂停移动
+      </a-button>
+      <a-button type="primary" class="ele-btn-icon" @click="resumeTrackAnim">
+        继续移动
+      </a-button>
+    </a-space>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref, watch, onMounted, onUnmounted } from 'vue';
+  import AMapLoader from '@amap/amap-jsapi-loader';
+  import { useThemeStore } from '@/store/modules/theme';
+  import { storeToRefs } from 'pinia';
+  import { MAP_KEY } from '@/config/setting';
+
+  const themeStore = useThemeStore();
+  const { darkMode } = storeToRefs(themeStore);
+
+  //
+  const trackMapRef = ref<HTMLElement | null>(null);
+
+  // 小车轨迹地图的实例
+  let mapInsTrack: any;
+
+  // 小车的 marker
+  let carMarker: any;
+
+  // 轨迹路线
+  const lineData = [
+    [116.478935, 39.997761],
+    [116.478939, 39.997825],
+    [116.478912, 39.998549],
+    [116.478912, 39.998549],
+    [116.478998, 39.998555],
+    [116.478998, 39.998555],
+    [116.479282, 39.99856],
+    [116.479658, 39.998528],
+    [116.480151, 39.998453],
+    [116.480784, 39.998302],
+    [116.480784, 39.998302],
+    [116.481149, 39.998184],
+    [116.481573, 39.997997],
+    [116.481863, 39.997846],
+    [116.482072, 39.997718],
+    [116.482362, 39.997718],
+    [116.483633, 39.998935],
+    [116.48367, 39.998968],
+    [116.484648, 39.999861]
+  ];
+
+  /* 渲染轨迹回放地图 */
+  const renderTrackMap = () => {
+    AMapLoader.load({
+      key: MAP_KEY,
+      version: '2.0',
+      plugins: ['AMap.MoveAnimation', 'AMap.Marker', 'AMap.Polyline']
+    })
+      .then((AMap) => {
+        // 渲染地图
+        const option = {
+          zoom: 17,
+          center: [116.478935, 39.997761],
+
+          mapStyle: darkMode.value ? 'amap://styles/dark' : void 0
+        };
+        mapInsTrack = new AMap.Map(trackMapRef.value, option);
+        // 创建小车 marker
+        carMarker = new AMap.Marker({
+          map: mapInsTrack,
+          position: [116.478935, 39.997761],
+          icon: 'https://a.amap.com/jsapi_demos/static/demo-center-v2/car.png',
+          offset: new AMap.Pixel(-13, -26)
+        });
+        // 绘制轨迹
+        new AMap.Polyline({
+          map: mapInsTrack,
+          path: lineData,
+          showDir: true,
+          strokeColor: '#2288FF', // 线颜色
+          strokeOpacity: 1, // 线透明度
+          strokeWeight: 6 // 线宽
+          //strokeStyle: 'solid'  // 线样式
+        });
+        // 通过的轨迹
+        const passedPolyline = new AMap.Polyline({
+          map: mapInsTrack,
+          showDir: true,
+          strokeColor: '#44BB55', // 线颜色
+          strokeOpacity: 1, // 线透明度
+          strokeWeight: 6 // 线宽
+        });
+        // 小车移动回调
+        carMarker.on('moving', (e) => {
+          passedPolyline.setPath(e.passedPath);
+        });
+        // 地图自适应
+        mapInsTrack.setFitView();
+      })
+      .catch((e) => {
+        console.error(e);
+      });
+  };
+
+  /* 开始轨迹回放动画 */
+  const startTrackAnim = () => {
+    if (carMarker) {
+      carMarker.stopMove();
+      carMarker.moveAlong(lineData, {
+        duration: 200,
+        autoRotation: true
+      });
+    }
+  };
+
+  /* 暂停轨迹回放动画 */
+  const pauseTrackAnim = () => {
+    if (carMarker) {
+      carMarker.pauseMove();
+    }
+  };
+
+  /* 继续开始轨迹回放动画 */
+  const resumeTrackAnim = () => {
+    if (carMarker) {
+      carMarker.resumeMove();
+    }
+  };
+
+  /* 渲染地图 */
+  onMounted(() => {
+    renderTrackMap();
+  });
+
+  /* 销毁地图 */
+  onUnmounted(() => {
+    if (mapInsTrack) {
+      mapInsTrack.destroy();
+    }
+  });
+
+  /* 同步框架暗黑模式切换 */
+  watch(darkMode, () => {
+    if (mapInsTrack) {
+      if (darkMode.value) {
+        mapInsTrack.setMapStyle('amap://styles/dark');
+      } else {
+        mapInsTrack.setMapStyle('amap://styles/normal');
+      }
+    }
+  });
+</script>
diff --git a/src/views-demo/extension/map/index.vue b/src/views-demo/extension/map/index.vue
new file mode 100644
index 0000000..e640c53
--- /dev/null
+++ b/src/views-demo/extension/map/index.vue
@@ -0,0 +1,19 @@
+<template>
+  <div class="ele-body ele-body-card">
+    <demo-picker />
+    <demo-map />
+    <demo-track />
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import DemoPicker from './components/demo-picker.vue';
+  import DemoMap from './components/demo-map.vue';
+  import DemoTrack from './components/demo-track.vue';
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'ExtensionMap'
+  };
+</script>
diff --git a/src/views-demo/extension/markdown/index.vue b/src/views-demo/extension/markdown/index.vue
new file mode 100644
index 0000000..3f319c3
--- /dev/null
+++ b/src/views-demo/extension/markdown/index.vue
@@ -0,0 +1,68 @@
+<template>
+  <div class="ele-body">
+    <a-card :bordered="false">
+      <!-- 按钮 -->
+      <div style="margin-bottom: 16px">
+        <a-space>
+          <a-button type="primary" class="ele-btn-icon" @click="setContent">
+            修改内容
+          </a-button>
+          <a-button type="primary" class="ele-btn-icon" @click="showText">
+            获取内容
+          </a-button>
+        </a-space>
+      </div>
+      <!-- 编辑器 -->
+      <byte-md-editor
+        v-model:value="content"
+        :locale="zh_Hans"
+        :plugins="plugins"
+        height="600px"
+        :editorConfig="{ lineNumbers: true }"
+      />
+    </a-card>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import { Modal } from 'ant-design-vue/es';
+  import ByteMdEditor from '@/components/ByteMdEditor/index.vue';
+  // 中文语言文件
+  import zh_Hans from 'bytemd/locales/zh_Hans.json';
+  // 链接、删除线、复选框、表格等的插件
+  import gfm from '@bytemd/plugin-gfm';
+  // 插件的中文语言文件
+  import zh_HansGfm from '@bytemd/plugin-gfm/locales/zh_Hans.json';
+  // 预览界面的样式,这里用的 github 的 markdown 主题
+  import 'github-markdown-css/github-markdown-light.css';
+
+  // 编辑器内容,双向绑定
+  const content = ref('');
+
+  // 插件
+  const plugins = ref([
+    gfm({
+      locale: zh_HansGfm
+    })
+  ]);
+
+  /* 获取编辑器内容 */
+  const showText = () => {
+    Modal.info({
+      maskClosable: true,
+      content: content.value
+    });
+  };
+
+  /* 修改编辑器内容 */
+  const setContent = () => {
+    content.value = '> **EleAdminPro**后台管理模板';
+  };
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'ExtensionMarkdown'
+  };
+</script>
diff --git a/src/views-demo/extension/player/index.vue b/src/views-demo/extension/player/index.vue
new file mode 100644
index 0000000..22e786b
--- /dev/null
+++ b/src/views-demo/extension/player/index.vue
@@ -0,0 +1,116 @@
+<template>
+  <div class="ele-body">
+    <a-card title="基础演示" :bordered="false">
+      <!-- 操作按钮 -->
+      <a-space style="margin-bottom: 16px">
+        <a-button
+          type="primary"
+          :disabled="!ready"
+          class="ele-btn-icon"
+          @click="play"
+        >
+          播放
+        </a-button>
+        <a-button
+          type="primary"
+          :disabled="!ready"
+          class="ele-btn-icon"
+          @click="pause"
+        >
+          暂停
+        </a-button>
+        <a-button
+          type="primary"
+          :disabled="!ready"
+          class="ele-btn-icon"
+          @click="replay"
+        >
+          重新播放
+        </a-button>
+        <a-button
+          type="primary"
+          :disabled="!ready"
+          class="ele-btn-icon"
+          @click="changeSrc"
+        >
+          切换视频源
+        </a-button>
+      </a-space>
+      <!-- 播放器 -->
+      <ele-xg-player :config="config" @player="onPlayer" />
+    </a-card>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive } from 'vue';
+  import type Player from 'xgplayer';
+
+  // 视频播放器配置
+  const config = reactive({
+    id: 'demoPlayer1',
+    lang: 'zh-cn',
+    fluid: true,
+    // 视频地址
+    url: 'https://s1.pstatp.com/cdn/expire-1-M/byted-player-videos/1.0.0/xgplayer-demo.mp4',
+    // 封面
+    poster:
+      'https://imgcache.qq.com/open_proj/proj_qcloud_v2/gateway/solution/general-video/css/img/scene/1.png',
+    // 开启倍速播放
+    playbackRate: [0.5, 1, 1.5, 2],
+    // 开启画中画
+    pip: true
+  });
+
+  // 视频播放器是否实例化完成
+  const ready = ref(false);
+
+  // 视频播放器实例
+  let player: Player;
+
+  /* 播放器渲染完成 */
+  const onPlayer = (e: Player) => {
+    player = e;
+    player.on('play', () => {
+      ready.value = true;
+    });
+  };
+
+  /* 播放 */
+  const play = () => {
+    if (player && player.paused) {
+      player.play();
+    }
+  };
+
+  /* 暂停 */
+  const pause = () => {
+    if (player) {
+      player.pause();
+    }
+  };
+
+  /* 重新播放 */
+  const replay = () => {
+    if (player) {
+      player.replay();
+    }
+  };
+
+  /* 切换视频源 */
+  const changeSrc = () => {
+    if (player) {
+      player.src =
+        'https://blz-videos.nosdn.127.net/1/OverWatch/AnimatedShots/Overwatch_TheatricalTeaser_WeAreOverwatch_zhCN.mp4';
+      if (player.paused) {
+        player.play();
+      }
+    }
+  };
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'ExtensionPlayer'
+  };
+</script>
diff --git a/src/views-demo/extension/printer/components/print-advanced.vue b/src/views-demo/extension/printer/components/print-advanced.vue
new file mode 100644
index 0000000..d859358
--- /dev/null
+++ b/src/views-demo/extension/printer/components/print-advanced.vue
@@ -0,0 +1,282 @@
+<template>
+  <a-card title="进阶示例" :bordered="false">
+    <a-space style="flex-wrap: wrap">
+      <a-button class="ele-btn-icon" @click="printDataTable">
+        打印数据表格
+      </a-button>
+      <a-tooltip title="对于复杂的打印需求,可以后端生成pdf给前端打印">
+        <a-button class="ele-btn-icon" @click="printPdfUrl">打印pdf</a-button>
+      </a-tooltip>
+      <a-button class="ele-btn-icon" @click="printQrCode">打印条码</a-button>
+      <a-button class="ele-btn-icon" @click="printAnyTable">
+        打印自定义表格
+      </a-button>
+    </a-space>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import { printHtml, printPdf, makeTable } from 'ele-admin-pro/es';
+
+  interface UserType {
+    key: number;
+    username: string;
+    amount: number;
+    province: string;
+    city: string;
+    zone: string;
+    street: string;
+    address: string;
+  }
+
+  const users = ref<UserType[]>([
+    {
+      key: 1,
+      username: '张小三',
+      amount: 18,
+      province: '浙江',
+      city: '杭州',
+      zone: '西湖区',
+      street: '西溪街道',
+      address: '西溪花园30栋1单元'
+    },
+    {
+      key: 2,
+      username: '李小四',
+      amount: 39,
+      province: '江苏',
+      city: '苏州',
+      zone: '姑苏区',
+      street: '丝绸路',
+      address: '天墅之城9幢2单元'
+    },
+    {
+      key: 3,
+      username: '王小五',
+      amount: 8,
+      province: '江西',
+      city: '南昌',
+      zone: '青山湖区',
+      street: '艾溪湖办事处',
+      address: '中兴和园1幢3单元'
+    },
+    {
+      key: 4,
+      username: '赵小六',
+      amount: 16,
+      province: '福建',
+      city: '泉州',
+      zone: '丰泽区',
+      street: '南洋街道',
+      address: '南洋村6幢1单元'
+    },
+    {
+      key: 5,
+      username: '孙小七',
+      amount: 12,
+      province: '湖北',
+      city: '武汉',
+      zone: '武昌区',
+      street: '武昌大道',
+      address: '两湖花园16幢2单元'
+    },
+    {
+      key: 6,
+      username: '周小八',
+      amount: 11,
+      province: '安徽',
+      city: '黄山',
+      zone: '黄山区',
+      street: '汤口镇',
+      address: '温泉村21号'
+    }
+  ]);
+
+  /* 打印数据表格 */
+  const printDataTable = () => {
+    const html = makeTable(users.value, [
+      [
+        {
+          field: 'username',
+          width: 150,
+          rowspan: 2,
+          title: '联系人'
+        },
+        {
+          align: 'center',
+          colspan: 3,
+          title: '地址'
+        },
+        {
+          field: 'amount',
+          width: 120,
+          rowspan: 2,
+          title: '金额',
+          align: 'center'
+        }
+      ],
+      [
+        {
+          field: 'province',
+          width: 120,
+          title: '省'
+        },
+        {
+          field: 'city',
+          width: 120,
+          title: '市'
+        },
+        {
+          width: 200,
+          title: '区',
+          templet: (d) => {
+            return `<span style="color: red;">${d.zone}</span>`;
+          }
+        }
+      ]
+    ]);
+    printHtml({
+      html: '<p>提供数据和cols配置自动生成复杂表格,非常的方便</p>' + html,
+      loading: false
+    });
+  };
+
+  /* 打印 pdf */
+  const printPdfUrl = () => {
+    printPdf({
+      url: 'https://cdn.eleadmin.com/20200610/20200708224450.pdf'
+    });
+  };
+
+  /* 打印条码 */
+  const printQrCode = () => {
+    const html = `
+      <div class="code-group">
+        <div class="code-group-title">EasyWeb授权凭证</div>
+        <div class="code-group-body">
+          <p>手机扫描右侧二维码,或登录</p>
+          <p>网站https://easyweb.vip</p>
+          <p>查询产品真伪</p>
+          <img src="https://cdn.eleadmin.com/20200610/20200708230820.png" width="70px" height="70px"/>
+          <span>515AE3X1</span>
+        </div>
+      </div>
+      <style>
+        .code-group {
+          display: inline-block;
+          border: 1px solid #ccc;
+          border-radius: 5px;
+          background-color: #fff;
+        }
+        .code-group-title {
+          border-bottom: 1px solid #ccc;
+          padding: 10px 15px;
+          text-align: center;
+          font-size: 18px;
+        }
+        .code-group-body {
+          text-align: center;
+          position: relative;
+          padding: 15px 115px 0 25px;
+          min-height: 90px;
+        }
+        .code-group-body > p {
+          margin: 0 0 13px 0;
+          font-size: 15px;
+          font-family: 幼圆;
+          color: #333;
+          font-weight: 600;
+        }
+        .code-group-body > img, .code-group-body > span {
+          position: absolute;
+          right: 25px;
+          top: 15px;
+        }
+        .code-group-body > span {
+          top: 90px;
+        }
+      </style>
+    `;
+    printHtml({
+      html: html,
+      loading: false
+    });
+  };
+
+  /* 打印自定义表格 */
+  const printAnyTable = () => {
+    const html = `
+      <h2 style="text-align: center;color: #333;">XxxXx班课程表</h2>
+      <table class="ele-printer-table">
+        <colgroup>
+          <col width="130px"/>
+        </colgroup>
+        <tr>
+          <th style="position: relative;">
+            <div style="position: absolute;right: 20px;top: 10px;line-height: normal;">星期</div>
+            <div style="position: absolute;left: 20px;bottom: 10px;line-height: normal;">时间</div>
+            <div
+              style="border-top: 1px solid #000;width:141px;height: 0;position: absolute;left: 0;top: 0;transform: rotate(22deg);transform-origin: 0 0;">
+            </div>
+          </th>
+          <th>周一</th>
+          <th>周二</th>
+          <th>周三</th>
+          <th>周四</th>
+          <th>周五</th>
+        </tr>
+        <tr>
+          <td>8:00-10:00</td>
+          <td>HTML5网页设计<br/>曲丽丽 - 441教室</td>
+          <td>数据库原理及应用<br/>严良 - 716机房</td>
+          <td>JavaSE初级程序设计<br/>肖萧 - 715机房</td>
+          <td></td>
+          <td>JavaScript程序设计<br/>董娜 - 733机房</td>
+        </tr>
+        <tr>
+          <td>10:30-12:30</td>
+          <td></td>
+          <td>JavaScript程序设计<br/>董娜 - 733机房</td>
+          <td></td>
+          <td>锋利的jQuery<br/>程咏 - 303教室</td>
+          <td>JavaEE应用开发<br/>周星 - 303教室</td>
+        </tr>
+        <tr>
+          <td colspan="6" style="height: auto;">午休</td>
+        </tr>
+        <tr>
+          <td>13:30-15:30</td>
+          <td>JavaSE初级程序设计<br/>肖萧 - 715机房</td>
+          <td></td>
+          <td>HTML5网页设计<br/>曲丽丽 - 441教室</td>
+          <td></td>
+          <td></td>
+        </tr>
+        <tr>
+          <td>16:00-18:00</td>
+          <td></td>
+          <td>JavaEE应用开发<br/>周星 - 303教室</td>
+          <td></td>
+          <td>数据库原理及应用<br/>严良 - 716机房</td>
+          <td></td>
+        </tr>
+      </table>
+      <style>
+        th, td {
+          text-align: center;
+          line-height: 35px;
+        }
+        td {
+          height: 100px;
+        }
+      </style>
+    `;
+    printHtml({
+      html: html,
+      horizontal: true,
+      title: '.',
+      loading: false
+    });
+  };
+</script>
diff --git a/src/views-demo/extension/printer/components/print-div.vue b/src/views-demo/extension/printer/components/print-div.vue
new file mode 100644
index 0000000..b70cdfa
--- /dev/null
+++ b/src/views-demo/extension/printer/components/print-div.vue
@@ -0,0 +1,64 @@
+<template>
+  <a-card title="打印指定区域" :bordered="false">
+    <div ref="printRef" class="demo-print-group">
+      <div class="demo-print-div">示例示例示例示例示例</div>
+      <div class="demo-print-right">
+        <div>
+          <ele-tag size="mini" color="blue">示例</ele-tag>
+          <ele-tag size="mini" color="green">示例</ele-tag>
+          <ele-tag size="mini" color="orange">示例</ele-tag>
+        </div>
+        <div style="margin-top: 12px">
+          <a-input v-model:value="value" />
+        </div>
+      </div>
+    </div>
+    <div style="margin-top: 20px">
+      <a-button @click="print">打印</a-button>
+    </div>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import { printElement } from 'ele-admin-pro/es';
+
+  const printRef = ref<HTMLElement | null>(null);
+
+  const print = () => {
+    printElement(printRef.value as HTMLElement);
+  };
+
+  const value = ref('示例示例示例');
+</script>
+
+<style lang="less" scoped>
+  .demo-print-group {
+    display: flex;
+    align-items: center;
+  }
+
+  .demo-print-div {
+    background: #096dd9;
+    color: #fff;
+    font-size: 18px;
+    text-align: center;
+    padding: 40px 0;
+    flex: 1;
+    border: 2px solid #096dd9;
+    height: 120px;
+    box-sizing: border-box;
+    border-top-left-radius: 8px;
+    border-bottom-left-radius: 8px;
+  }
+
+  .demo-print-right {
+    flex: 1;
+    padding: 20px;
+    border: 2px solid #096dd9;
+    height: 120px;
+    box-sizing: border-box;
+    border-top-right-radius: 8px;
+    border-bottom-right-radius: 8px;
+  }
+</style>
diff --git a/src/views-demo/extension/printer/components/print-html.vue b/src/views-demo/extension/printer/components/print-html.vue
new file mode 100644
index 0000000..d901d89
--- /dev/null
+++ b/src/views-demo/extension/printer/components/print-html.vue
@@ -0,0 +1,81 @@
+<template>
+  <a-card title="打印任意内容" :bordered="false">
+    <a-form
+      :label-col="{ span: 6 }"
+      :wrapper-col="{ span: 18 }"
+      style="max-width: 320px"
+    >
+      <a-form-item label="loading">
+        <a-radio-group v-model:value="option.loading">
+          <a-radio :value="false">不显示</a-radio>
+          <a-radio :value="true">显示</a-radio>
+        </a-radio-group>
+      </a-form-item>
+    </a-form>
+    <a-space style="flex-wrap: wrap">
+      <a-button class="ele-btn-icon" @click="printAnyHtml">
+        打印任意内容
+      </a-button>
+      <a-button class="ele-btn-icon" @click="printAddHeader">
+        设置页眉页脚
+      </a-button>
+      <a-button class="ele-btn-icon" @click="printImage">打印图片</a-button>
+    </a-space>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { reactive } from 'vue';
+  import { printHtml } from 'ele-admin-pro/es';
+
+  // 打印任意内容参数
+  const option = reactive({
+    loading: false
+  });
+
+  /* 打印任意内容 */
+  const printAnyHtml = () => {
+    printHtml({
+      ...option,
+      html: [
+        '<h1 style="color: #1890ff;">EleAdmin 后台管理模板</h1>',
+        '<div style="color: #F51D2C;">通用型后台管理模板,界面美观、开箱即用</div>'
+      ].join('')
+    });
+  };
+
+  /* 打印设置页眉页脚 */
+  const printAddHeader = () => {
+    printHtml({
+      ...option,
+      margin: 0,
+      html: [
+        '<div style="padding: 0 60px;">',
+        Array(38).join('<h3>EleAdmin 后台管理模板</h3>'),
+        '</div>'
+      ].join(''),
+      header: `
+        <div style="display: flex;font-size: 12px;padding: 15px 30px 25px;">
+          <div>我是页眉左侧</div>
+          <div style="flex: 1;text-align: center;">我是页眉</div>
+          <div>我是页眉右侧</div>
+        </div>`,
+      footer: `
+        <div style="display: flex;font-size: 12px;padding: 15px 30px 25px;">
+          <div>我是页脚左侧</div>
+          <div style="flex: 1;text-align: center;">我是页脚</div>
+          <div>我是页脚右侧</div>
+        </div>`,
+      style: '<style> h3 { color: red; } </style>'
+    });
+  };
+
+  /* 打印图片 */
+  const printImage = () => {
+    printHtml({
+      ...option,
+      margin: 0,
+      html: '<img src="https://cdn.eleadmin.com/20200610/LrCTN2j94lo9N7wEql7cBr1Ux4rHMvmZ.jpg" style="width: 100%;"/>'
+    });
+  };
+</script>
diff --git a/src/views-demo/extension/printer/components/print-page.vue b/src/views-demo/extension/printer/components/print-page.vue
new file mode 100644
index 0000000..612b5ea
--- /dev/null
+++ b/src/views-demo/extension/printer/components/print-page.vue
@@ -0,0 +1,58 @@
+<template>
+  <a-card title="分页打印" :bordered="false">
+    <a-space>
+      <a-button class="ele-btn-icon" @click="printAnyPage">分页打印</a-button>
+      <a-button class="ele-btn-icon" @click="printPageAddHeader">
+        分页打印设置页眉页脚
+      </a-button>
+    </a-space>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { printPage } from 'ele-admin-pro/es';
+
+  /* 分页打印 */
+  const printAnyPage = () => {
+    printPage({
+      pages: [
+        '<h3>我是第一页</h3>',
+        '<h3>我是第二页</h3>',
+        '<h3>我是第三页</h3>',
+        '<h3>我是第四页</h3>',
+        '<h3>我是第五页</h3>'
+      ],
+      style: '<style> h3 { color: red; } </style>',
+      loading: false
+    });
+  };
+
+  /* 分页打印设置页眉页脚 */
+  const printPageAddHeader = () => {
+    printPage({
+      pages: [
+        '<h3>我是第一页</h3>',
+        '<h3>我是第二页</h3>',
+        '<h3>我是第三页</h3>',
+        '<h3>我是第四页</h3>',
+        '<h3>我是第五页</h3>'
+      ],
+      margin: 0,
+      padding: '20px 60px',
+      header: `
+        <div style="display: flex;font-size: 12px;padding: 15px 30px;">
+          <div>我是页眉左侧</div>
+          <div style="flex: 1;text-align: center;">我是页眉</div>
+          <div>我是页眉右侧</div>
+        </div>`,
+      footer: `
+        <div style="display: flex;font-size: 12px;padding: 15px 30px;">
+          <div>我是页脚左侧</div>
+          <div style="flex: 1;text-align: center;">我是页脚</div>
+          <div>我是页脚右侧</div>
+        </div>`,
+      style: '<style> h3 { color: red; } </style>',
+      loading: false
+    });
+  };
+</script>
diff --git a/src/views-demo/extension/printer/components/print-this.vue b/src/views-demo/extension/printer/components/print-this.vue
new file mode 100644
index 0000000..9d0fc37
--- /dev/null
+++ b/src/views-demo/extension/printer/components/print-this.vue
@@ -0,0 +1,89 @@
+<template>
+  <a-card title="打印当前页面" :bordered="false">
+    <a-form
+      :label-col="styleResponsive ? { span: 6 } : { flex: '80px' }"
+      :wrapper-col="styleResponsive ? { span: 18 } : { flex: '1' }"
+      style="max-width: 320px"
+    >
+      <a-form-item label="纸张方向">
+        <a-select
+          allow-clear
+          :value="{ true: 1, false: 0 }[String(option.horizontal)]"
+          placeholder="不设置"
+          @update:value="updateHorizontal"
+        >
+          <a-select-option :value="1">横向</a-select-option>
+          <a-select-option :value="0">纵向</a-select-option>
+        </a-select>
+      </a-form-item>
+      <a-form-item label="页面间距">
+        <a-select
+          allow-clear
+          v-model:value="option.margin"
+          placeholder="不设置"
+        >
+          <a-select-option value="0px">0px</a-select-option>
+          <a-select-option value="50px">50px</a-select-option>
+          <a-select-option value="100px">100px</a-select-option>
+        </a-select>
+      </a-form-item>
+      <a-form-item label="页面标题">
+        <a-input
+          allow-clear
+          v-model:value="option.title"
+          placeholder="不设置"
+        />
+      </a-form-item>
+    </a-form>
+    <a-space>
+      <a-button @click="print">打印</a-button>
+      <a-button class="ele-btn-icon" @click="printHide">
+        打印隐藏指定内容
+      </a-button>
+    </a-space>
+    <div style="margin-top: 16px">
+      <span class="ele-text-primary ele-printer-hide">
+        此段内容在所有打印时隐藏, 打印完复原。
+      </span>
+      <span class="ele-text-danger demo-hide-1">
+        此段内容在指定打印时才隐藏。
+      </span>
+    </div>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { reactive } from 'vue';
+  import { printThis } from 'ele-admin-pro/es';
+  import type { PrintHtmlOption } from 'ele-admin-pro/es';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  // 打印当前页面参数
+  const option: PrintHtmlOption = reactive({
+    horizontal: undefined,
+    margin: undefined,
+    title: ''
+  });
+
+  /* 打印当前页面 */
+  const print = () => {
+    printThis(option);
+  };
+
+  /* 打印隐藏指定内容 */
+  const printHide = () => {
+    printThis({
+      ...option,
+      hide: ['.demo-hide-1']
+    });
+  };
+
+  const updateHorizontal = (value?: number) => {
+    option.horizontal = { '1': true, '0': false }[String(value)];
+  };
+</script>
diff --git a/src/views-demo/extension/printer/index.vue b/src/views-demo/extension/printer/index.vue
new file mode 100644
index 0000000..5fa07ba
--- /dev/null
+++ b/src/views-demo/extension/printer/index.vue
@@ -0,0 +1,23 @@
+<template>
+  <div class="ele-body ele-body-card">
+    <print-this />
+    <print-div />
+    <print-html />
+    <print-page />
+    <print-advanced />
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import PrintThis from './components/print-this.vue';
+  import PrintDiv from './components/print-div.vue';
+  import PrintHtml from './components/print-html.vue';
+  import PrintPage from './components/print-page.vue';
+  import PrintAdvanced from './components/print-advanced.vue';
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'ExtensionPrinter'
+  };
+</script>
diff --git a/src/views-demo/extension/qr-code/index.vue b/src/views-demo/extension/qr-code/index.vue
new file mode 100644
index 0000000..1ae7062
--- /dev/null
+++ b/src/views-demo/extension/qr-code/index.vue
@@ -0,0 +1,211 @@
+<template>
+  <div class="ele-body">
+    <a-card title="二维码" :bordered="false">
+      <div ref="printRef" class="demo-qrcode-images ele-bg-white">
+        <div class="demo-qrcode-image-item">
+          <div class="demo-qr-code-title">canvas 渲染</div>
+          <ele-qr-code
+            :value="text"
+            :size="size"
+            :level="level"
+            :margin="margin"
+            :image-settings="image"
+          />
+        </div>
+        <div class="demo-qrcode-image-item">
+          <div class="demo-qr-code-title">svg 渲染</div>
+          <ele-qr-code-svg
+            :value="text"
+            :size="size"
+            :level="level"
+            :margin="margin"
+            :image-settings="image"
+          />
+        </div>
+      </div>
+      <a-form
+        style="max-width: 340px"
+        :label-col="{ flex: '88px' }"
+        :wrapper-col="{ flex: '1' }"
+      >
+        <a-form-item label="二维码内容" style="flex-wrap: nowrap">
+          <a-input v-model:value="text" :maxlength="150" />
+        </a-form-item>
+        <a-form-item label="容错等级" style="flex-wrap: nowrap">
+          <a-select v-model:value="level">
+            <a-select-option value="L">L</a-select-option>
+            <a-select-option value="M">M</a-select-option>
+            <a-select-option value="Q">Q</a-select-option>
+            <a-select-option value="H">H</a-select-option>
+          </a-select>
+        </a-form-item>
+        <a-form-item label="尺寸" style="flex-wrap: nowrap">
+          <a-slider v-model:value="size" :min="80" :max="280" :step="40" />
+        </a-form-item>
+        <a-form-item label="间距" style="flex-wrap: nowrap">
+          <a-slider v-model:value="margin" :min="0" :max="20" />
+        </a-form-item>
+        <a-form-item label="自定义图片" style="flex-wrap: nowrap">
+          <a-switch
+            v-model:checked="showImage"
+            size="small"
+            @change="onShowImageChange"
+          />
+        </a-form-item>
+        <template v-if="showImage">
+          <a-form-item label="图片地址" style="flex-wrap: nowrap">
+            <a-input v-model:value="image.src" :maxlength="400" />
+          </a-form-item>
+          <a-form-item label="图片宽高" style="flex-wrap: nowrap">
+            <div class="ele-cell">
+              <div style="width: 80px; margin-right: 20px">
+                <a-input-number
+                  v-model:value="image.width"
+                  size="small"
+                  :min="0"
+                  :max="size"
+                  class="ele-fluid"
+                />
+              </div>
+              <div style="width: 80px">
+                <a-input-number
+                  v-model:value="image.height"
+                  size="small"
+                  :min="0"
+                  :max="size"
+                  class="ele-fluid"
+                />
+              </div>
+            </div>
+          </a-form-item>
+          <a-form-item label="位置居中" style="flex-wrap: nowrap">
+            <div class="ele-cell">
+              <a-switch
+                v-model:checked="center"
+                size="small"
+                @change="onCenterChange"
+              />
+              <template v-if="!center">
+                <div style="padding: 0 10px">x</div>
+                <div style="width: 60px">
+                  <a-input-number
+                    v-model:value="image.x"
+                    size="small"
+                    :min="0"
+                    :max="size"
+                    class="ele-fluid"
+                  />
+                </div>
+                <div style="padding: 0 10px">y</div>
+                <div style="width: 60px">
+                  <a-input-number
+                    v-model:value="image.y"
+                    size="small"
+                    :min="0"
+                    :max="size"
+                    class="ele-fluid"
+                  />
+                </div>
+              </template>
+            </div>
+          </a-form-item>
+          <a-form-item label="背景擦除" style="flex-wrap: nowrap">
+            <a-switch v-model:checked="image.excavate" size="small" />
+          </a-form-item>
+          <a-form-item style="flex-wrap: nowrap">
+            <div style="padding-left: 88px">
+              <a-button type="primary" @click="print">打印</a-button>
+            </div>
+          </a-form-item>
+        </template>
+      </a-form>
+    </a-card>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive } from 'vue';
+  import { printElement } from 'ele-admin-pro/es';
+  import type {
+    LevelType,
+    ImageSettings
+  } from 'ele-admin-pro/es/ele-qr-code/types';
+  const IMAGE_SRC = 'https://cdn.eleadmin.com/20200610/logo-radius.png';
+
+  const text = ref('https://eleadmin.com');
+
+  const level = ref<LevelType>('L');
+
+  const size = ref(120);
+
+  const margin = ref(0);
+
+  const showImage = ref(true);
+
+  const image = reactive<ImageSettings>({
+    src: IMAGE_SRC,
+    width: 28,
+    height: 28,
+    x: undefined,
+    y: undefined,
+    excavate: false
+  });
+
+  const center = ref(true);
+
+  const printRef = ref<HTMLElement | null>(null);
+
+  const onShowImageChange = (checked: boolean) => {
+    if (checked) {
+      image.src = IMAGE_SRC;
+    } else {
+      image.src = undefined;
+    }
+  };
+
+  const onCenterChange = (checked: boolean) => {
+    if (checked) {
+      image.x = undefined;
+      image.y = undefined;
+    } else {
+      image.x = 0;
+      image.y = 0;
+    }
+  };
+
+  const print = () => {
+    printElement(printRef.value as HTMLElement);
+  };
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'ExtensionQrCode'
+  };
+</script>
+
+<style lang="less" scoped>
+  .demo-qrcode-images {
+    display: flex;
+    padding-bottom: 16px;
+    margin-bottom: 4px;
+    position: sticky;
+    top: 0;
+    overflow: auto;
+    z-index: 1;
+
+    .demo-qrcode-image-item {
+      padding: 0 20px;
+
+      .demo-qr-code-title {
+        margin-bottom: 6px;
+      }
+    }
+  }
+
+  @media screen and (max-width: 768px) {
+    .demo-qrcode-images .demo-qrcode-image-item {
+      padding: 0 10px;
+    }
+  }
+</style>
diff --git a/src/views-demo/extension/regions/index.vue b/src/views-demo/extension/regions/index.vue
new file mode 100644
index 0000000..977d8db
--- /dev/null
+++ b/src/views-demo/extension/regions/index.vue
@@ -0,0 +1,53 @@
+<template>
+  <div class="ele-body ele-body-card">
+    <a-card title="省市区级联选择" :bordered="false">
+      <div style="max-width: 280px">
+        <regions-select
+          v-model:value="city"
+          placeholder="请选择省市区"
+          class="ele-fluid"
+        />
+      </div>
+    </a-card>
+    <a-card title="省市级联选择" :bordered="false">
+      <div style="max-width: 280px">
+        <regions-select
+          v-model:value="provinceCity"
+          placeholder="请选择省市"
+          type="provinceCity"
+          class="ele-fluid"
+        />
+      </div>
+    </a-card>
+    <a-card title="省选择" :bordered="false">
+      <div style="max-width: 280px">
+        <regions-select
+          v-model:value="province"
+          placeholder="请选择省"
+          type="province"
+          class="ele-fluid"
+        />
+      </div>
+    </a-card>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import RegionsSelect from '@/components/RegionsSelect/index.vue';
+
+  // 选中的省市区
+  const city = ref<string[]>([]);
+
+  // 选中的省市
+  const provinceCity = ref<string[]>([]);
+
+  // 选中的省
+  const province = ref<string[]>([]);
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'ExtensionRegions'
+  };
+</script>
diff --git a/src/views-demo/extension/split/index.vue b/src/views-demo/extension/split/index.vue
new file mode 100644
index 0000000..5d81261
--- /dev/null
+++ b/src/views-demo/extension/split/index.vue
@@ -0,0 +1,195 @@
+<template>
+  <div class="ele-body ele-body-card">
+    <a-card title="基本用法" :bordered="false">
+      <div class="option-item">
+        <div>显示折叠按钮:</div>
+        <div class="option-item-body">
+          <a-radio-group
+            :options="[
+              { label: '是', value: true },
+              { label: '否', value: false }
+            ]"
+            v-model:value="allowCollapse"
+          />
+        </div>
+      </div>
+      <div class="option-item">
+        <div>支持自由拉伸:</div>
+        <div class="option-item-body">
+          <a-radio-group
+            :options="[
+              { label: '是', value: true },
+              { label: '否', value: false }
+            ]"
+            v-model:value="resizable"
+          />
+        </div>
+      </div>
+      <div class="option-item">
+        <div>上下布局模式:</div>
+        <div class="option-item-body">
+          <a-radio-group
+            :options="[
+              { label: '是', value: true },
+              { label: '否', value: false }
+            ]"
+            v-model:value="vertical"
+          />
+        </div>
+      </div>
+      <div class="option-item">
+        <div>反转布局方向:</div>
+        <div class="option-item-body">
+          <a-radio-group
+            :options="[
+              { label: '是', value: true },
+              { label: '否', value: false }
+            ]"
+            v-model:value="reverse"
+          />
+        </div>
+      </div>
+      <ele-split-layout
+        space="0px"
+        :allow-collapse="allowCollapse"
+        :resizable="resizable"
+        :vertical="vertical"
+        :reverse="reverse"
+        :min-size="40"
+        :left-style="{
+          background: 'rgba(185, 182, 229, .4)',
+          overflow: 'hidden'
+        }"
+        :right-style="{
+          background: 'rgba(125, 226, 252, .4)',
+          overflow: 'hidden'
+        }"
+        style="height: 480px; margin-top: 12px"
+      >
+        <div>边栏</div>
+        <template #content>
+          <div>内容</div>
+        </template>
+      </ele-split-layout>
+    </a-card>
+    <a-card title="组合使用" :bordered="false">
+      <div style="margin: 0 0 8px 0">先左右再上下</div>
+      <ele-split-layout
+        space="0px"
+        :resizable="true"
+        :min-size="40"
+        :max-size="-40"
+        :left-style="{
+          background: 'rgba(185, 182, 229, .4)',
+          overflow: 'hidden'
+        }"
+        :right-style="{ overflow: 'hidden' }"
+        :responsive="false"
+        style="height: 400px"
+      >
+        <div>边栏</div>
+        <template #content>
+          <ele-split-layout
+            space="0px"
+            width="240px"
+            :min-size="40"
+            :max-size="-40"
+            :resizable="true"
+            :vertical="true"
+            :left-style="{
+              background: 'rgba(171, 199, 255, .5)',
+              overflow: 'hidden'
+            }"
+            :right-style="{
+              background: 'rgba(125, 226, 252, .4)',
+              overflow: 'hidden'
+            }"
+            :responsive="false"
+            style="height: 400px"
+          >
+            <div>内容一</div>
+            <template #content>
+              <div>内容二</div>
+            </template>
+          </ele-split-layout>
+        </template>
+      </ele-split-layout>
+      <div style="margin: 16px 0 8px 0">先上下再左右</div>
+      <ele-split-layout
+        space="0px"
+        width="120px"
+        :min-size="40"
+        :max-size="-40"
+        :vertical="true"
+        :resizable="true"
+        :left-style="{
+          background: 'rgba(185, 182, 229, .4)',
+          overflow: 'hidden'
+        }"
+        :right-style="{ overflow: 'hidden' }"
+        :responsive="false"
+        style="height: 400px"
+      >
+        <div>顶栏</div>
+        <template #content>
+          <ele-split-layout
+            space="0px"
+            :min-size="40"
+            :max-size="-40"
+            :resizable="true"
+            :left-style="{
+              background: 'rgba(171, 199, 255, .5)',
+              overflow: 'hidden'
+            }"
+            :right-style="{
+              background: 'rgba(125, 226, 252, .4)',
+              overflow: 'hidden'
+            }"
+            :responsive="false"
+            style="height: 100%"
+          >
+            <div>边栏</div>
+            <template #content>
+              <div>内容</div>
+            </template>
+          </ele-split-layout>
+        </template>
+      </ele-split-layout>
+    </a-card>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+
+  const allowCollapse = ref(true);
+
+  const resizable = ref(false);
+
+  const vertical = ref(false);
+
+  const reverse = ref(false);
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'ExtensionSplit'
+  };
+</script>
+
+<style lang="less" scoped>
+  .option-item {
+    display: flex;
+    align-items: center;
+
+    .option-item-body {
+      flex: 1;
+      padding-left: 12px;
+      display: flex;
+    }
+
+    & + .option-item {
+      margin-top: 6px;
+    }
+  }
+</style>
diff --git a/src/views-demo/extension/table-select/components/demo-advanced-search.vue b/src/views-demo/extension/table-select/components/demo-advanced-search.vue
new file mode 100644
index 0000000..1ca1b72
--- /dev/null
+++ b/src/views-demo/extension/table-select/components/demo-advanced-search.vue
@@ -0,0 +1,35 @@
+<template>
+  <div style="max-width: 160px">
+    <a-input
+      allow-clear
+      size="small"
+      v-model:value="where.keywords"
+      placeholder="输入关键字搜索"
+      @change="search"
+    >
+      <template #prefix>
+        <search-outlined class="ele-text-placeholder" />
+      </template>
+    </a-input>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { reactive } from 'vue';
+  import { SearchOutlined } from '@ant-design/icons-vue';
+  import type { WhereType } from '../types';
+
+  const emit = defineEmits<{
+    (e: 'search', where: WhereType): void;
+  }>();
+
+  // 搜索表单
+  const where = reactive<WhereType>({
+    keywords: ''
+  });
+
+  /* 搜索 */
+  const search = () => {
+    emit('search', where);
+  };
+</script>
diff --git a/src/views-demo/extension/table-select/components/demo-advanced.vue b/src/views-demo/extension/table-select/components/demo-advanced.vue
new file mode 100644
index 0000000..e7e3de1
--- /dev/null
+++ b/src/views-demo/extension/table-select/components/demo-advanced.vue
@@ -0,0 +1,128 @@
+<template>
+  <a-card title="可搜索" :bordered="false">
+    <div style="max-width: 260px">
+      <ele-table-select
+        ref="selectRef"
+        :multiple="true"
+        :allow-clear="true"
+        placeholder="请选择"
+        value-key="userId"
+        label-key="nickname"
+        v-model:value="selectedValue"
+        :table-config="tableConfig"
+        :overlay-style="{ width: '520px', maxWidth: '80%' }"
+        :init-value="initValue"
+      >
+        <!-- 角色列 -->
+        <template #bodyCell="{ column, record }">
+          <template v-if="column.key === 'roles'">
+            <a-tag v-for="item in record.roles" :key="item.roleId" color="blue">
+              {{ item.roleName }}
+            </a-tag>
+          </template>
+        </template>
+        <!-- 表头工具栏 -->
+        <template #toolbar>
+          <demo-advanced-search @search="search" />
+        </template>
+      </ele-table-select>
+    </div>
+    <div style="margin-top: 12px">
+      <a-button type="primary" @click="setInitValue">回显数据</a-button>
+    </div>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive } from 'vue';
+  import DemoAdvancedSearch from './demo-advanced-search.vue';
+  import { pageUsers } from '@/api/system/user';
+  import type { EleTableSelect } from 'ele-admin-pro/es';
+  import type { ProTableProps } from 'ele-admin-pro/es/ele-pro-table/types';
+  import type { User } from '@/api/system/user/model';
+  import type { WhereType } from '../types';
+
+  const selectedValue = ref<number[]>([]);
+
+  // 选择框实例
+  const selectRef = ref<InstanceType<typeof EleTableSelect> | null>(null);
+
+  // 表格配置
+  const tableConfig = reactive<ProTableProps>({
+    datasource: ({ page, limit, where, orders }) => {
+      return pageUsers({ ...where, ...orders, page, limit });
+    },
+    columns: [
+      {
+        title: '用户账号',
+        dataIndex: 'username',
+        sorter: true,
+        showSorterTooltip: false
+      },
+      {
+        title: '用户名',
+        key: 'nickname',
+        dataIndex: 'nickname',
+        sorter: true,
+        showSorterTooltip: false
+      },
+      {
+        title: '性别',
+        dataIndex: 'sexName',
+        width: 80,
+        align: 'center',
+        sorter: true,
+        showSorterTooltip: false
+      },
+      {
+        title: '角色',
+        key: 'roles'
+      }
+    ],
+    toolkit: ['reload', 'columns'],
+    pageSize: 5,
+    pageSizeOptions: ['5', '10', '15', '20'],
+    size: 'small',
+    rowSelection: {
+      columnWidth: 38,
+      preserveSelectedRowKeys: true,
+      fixed: 'left'
+    },
+    toolsTheme: 'default',
+    bordered: true,
+    toolStyle: {
+      padding: '0 8px'
+    },
+    scroll: { x: 480 }
+  });
+
+  // 回显值
+  const initValue = ref<User[]>();
+
+  /* 回显数据 */
+  const setInitValue = () => {
+    //selectedValue.value = [14, 18, 19];
+    initValue.value = [
+      {
+        userId: 14,
+        nickname: '管理员'
+      },
+      {
+        userId: 18,
+        nickname: '用户四'
+      },
+      {
+        userId: 19,
+        nickname: '用户五'
+      }
+    ];
+  };
+
+  // 搜索
+  const search = (where: WhereType) => {
+    selectRef.value?.reload({
+      where,
+      page: 1
+    });
+  };
+</script>
diff --git a/src/views-demo/extension/table-select/components/demo-basic-page.vue b/src/views-demo/extension/table-select/components/demo-basic-page.vue
new file mode 100644
index 0000000..2646d28
--- /dev/null
+++ b/src/views-demo/extension/table-select/components/demo-basic-page.vue
@@ -0,0 +1,110 @@
+<template>
+  <a-card title="表格后端分页" :bordered="false">
+    <div style="max-width: 260px">
+      <ele-table-select
+        :allow-clear="true"
+        placeholder="请选择"
+        value-key="userId"
+        label-key="nickname"
+        v-model:value="selectedValue"
+        :table-config="tableConfig"
+        :overlay-style="{ width: '520px', maxWidth: '80%' }"
+        :disabled="disabled"
+        :init-value="initValue"
+        @select="onSelect"
+      >
+        <!-- 角色列 -->
+        <template #bodyCell="{ column, record }">
+          <template v-if="column.key === 'roles'">
+            <a-tag v-for="item in record.roles" :key="item.roleId" color="blue">
+              {{ item.roleName }}
+            </a-tag>
+          </template>
+        </template>
+      </ele-table-select>
+    </div>
+    <div class="ele-cell" style="margin-top: 15px">
+      <div style="line-height: 22px">&nbsp;禁用:</div>
+      <div class="ele-cell-content">
+        <a-radio-group v-model:value="disabled" name="disabled">
+          <a-radio :value="false">否</a-radio>
+          <a-radio :value="true">是</a-radio>
+        </a-radio-group>
+      </div>
+    </div>
+    <div style="margin-top: 12px">
+      <a-button type="primary" @click="setInitValue">回显数据</a-button>
+    </div>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive } from 'vue';
+  import { pageUsers } from '@/api/system/user';
+  import type { ProTableProps } from 'ele-admin-pro/es/ele-pro-table/types';
+  import type { User } from '@/api/system/user/model';
+
+  const selectedValue = ref<number>();
+
+  // 表格配置
+  const tableConfig = reactive<ProTableProps>({
+    datasource: ({ page, limit, where, orders }) => {
+      return pageUsers({ ...where, ...orders, page, limit });
+    },
+    columns: [
+      {
+        title: '用户账号',
+        dataIndex: 'username',
+        sorter: true,
+        showSorterTooltip: false
+      },
+      {
+        title: '用户名',
+        key: 'nickname',
+        dataIndex: 'nickname',
+        sorter: true,
+        showSorterTooltip: false
+      },
+      {
+        title: '性别',
+        dataIndex: 'sexName',
+        width: 80,
+        sorter: true,
+        showSorterTooltip: false
+      },
+      {
+        title: '角色',
+        key: 'roles'
+      }
+    ],
+    toolbar: false,
+    pageSize: 5,
+    pageSizeOptions: ['5', '10', '15', '20'],
+    size: 'small',
+    rowSelection: {
+      columnWidth: 38,
+      fixed: 'left'
+    },
+    scroll: { x: 480 }
+  });
+
+  // 禁用
+  const disabled = ref(false);
+
+  // 回显值
+  const initValue = ref<User>();
+
+  /* 回显数据 */
+  const setInitValue = () => {
+    //selectedValue.value = 14;
+    initValue.value = {
+      userId: 14,
+      nickname: '管理员'
+    };
+  };
+
+  /* 选中事件 */
+  const onSelect = (item: User) => {
+    console.log('item:', item);
+  };
+</script>
diff --git a/src/views-demo/extension/table-select/components/demo-basic.vue b/src/views-demo/extension/table-select/components/demo-basic.vue
new file mode 100644
index 0000000..1b15597
--- /dev/null
+++ b/src/views-demo/extension/table-select/components/demo-basic.vue
@@ -0,0 +1,84 @@
+<template>
+  <a-card title="基础用法" :bordered="false">
+    <div style="max-width: 260px">
+      <ele-table-select
+        :allow-clear="true"
+        placeholder="请选择"
+        value-key="userId"
+        label-key="nickname"
+        v-model:value="selectedValue"
+        :table-config="tableConfig"
+        :overlay-style="{ width: '520px', maxWidth: '80%' }"
+      >
+        <!-- 角色列 -->
+        <template #bodyCell="{ column, record }">
+          <template v-if="column.key === 'roles'">
+            <a-tag v-for="item in record.roles" :key="item.roleId" color="blue">
+              {{ item.roleName }}
+            </a-tag>
+          </template>
+        </template>
+      </ele-table-select>
+    </div>
+    <div style="margin-top: 12px">
+      <a-button type="primary" @click="setInitValue">回显数据</a-button>
+    </div>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive } from 'vue';
+  import { listUsers } from '@/api/system/user';
+  import type { ProTableProps } from 'ele-admin-pro/es/ele-pro-table/types';
+
+  const selectedValue = ref<number>();
+
+  // 表格配置
+  const tableConfig = reactive<ProTableProps>({
+    datasource: [],
+    columns: [
+      {
+        title: '用户账号',
+        dataIndex: 'username',
+        sorter: true,
+        showSorterTooltip: false
+      },
+      {
+        title: '用户名',
+        key: 'nickname',
+        dataIndex: 'nickname',
+        sorter: true,
+        showSorterTooltip: false
+      },
+      {
+        title: '性别',
+        dataIndex: 'sexName',
+        width: 80,
+        sorter: true,
+        showSorterTooltip: false
+      },
+      {
+        title: '角色',
+        key: 'roles'
+      }
+    ],
+    toolbar: false,
+    pageSize: 5,
+    pageSizeOptions: ['5', '10', '15', '20'],
+    size: 'small',
+    rowSelection: {
+      columnWidth: 38,
+      fixed: 'left'
+    },
+    scroll: { x: 480 }
+  });
+
+  listUsers().then((data) => {
+    tableConfig.datasource = data;
+  });
+
+  /* 回显数据 */
+  const setInitValue = () => {
+    selectedValue.value = 14;
+  };
+</script>
diff --git a/src/views-demo/extension/table-select/components/demo-multiple.vue b/src/views-demo/extension/table-select/components/demo-multiple.vue
new file mode 100644
index 0000000..0c5c600
--- /dev/null
+++ b/src/views-demo/extension/table-select/components/demo-multiple.vue
@@ -0,0 +1,102 @@
+<template>
+  <a-card title="多选" :bordered="false">
+    <div style="max-width: 260px">
+      <ele-table-select
+        :multiple="true"
+        :allow-clear="true"
+        placeholder="请选择"
+        value-key="userId"
+        label-key="nickname"
+        v-model:value="selectedValue"
+        :table-config="tableConfig"
+        :overlay-style="{ width: '520px', maxWidth: '80%' }"
+        :disabled="disabled"
+        :max-tag-text-length="3"
+        :max-tag-count="5"
+      >
+        <!-- 角色列 -->
+        <template #bodyCell="{ column, record }">
+          <template v-if="column.key === 'roles'">
+            <a-tag v-for="item in record.roles" :key="item.roleId" color="blue">
+              {{ item.roleName }}
+            </a-tag>
+          </template>
+        </template>
+      </ele-table-select>
+    </div>
+    <div class="ele-cell" style="margin-top: 15px">
+      <div style="line-height: 22px">&nbsp;禁用:</div>
+      <div class="ele-cell-content">
+        <a-radio-group v-model:value="disabled" name="disabled">
+          <a-radio :value="false">否</a-radio>
+          <a-radio :value="true">是</a-radio>
+        </a-radio-group>
+      </div>
+    </div>
+    <div style="margin-top: 12px">
+      <a-button type="primary" @click="setInitValue">回显数据</a-button>
+    </div>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive } from 'vue';
+  import { listUsers } from '@/api/system/user';
+  import type { ProTableProps } from 'ele-admin-pro/es/ele-pro-table/types';
+
+  const selectedValue = ref<number[]>([]);
+
+  // 表格配置
+  const tableConfig = reactive<ProTableProps>({
+    datasource: [],
+    columns: [
+      {
+        title: '用户账号',
+        dataIndex: 'username',
+        sorter: true,
+        showSorterTooltip: false
+      },
+      {
+        title: '用户名',
+        key: 'nickname',
+        dataIndex: 'nickname',
+        sorter: true,
+        showSorterTooltip: false
+      },
+      {
+        title: '性别',
+        dataIndex: 'sexName',
+        width: 80,
+        align: 'center',
+        sorter: true,
+        showSorterTooltip: false
+      },
+      {
+        title: '角色',
+        key: 'roles'
+      }
+    ],
+    toolbar: false,
+    pageSize: 5,
+    pageSizeOptions: ['5', '10', '15', '20'],
+    size: 'small',
+    rowSelection: {
+      columnWidth: 38,
+      preserveSelectedRowKeys: true,
+      fixed: 'left'
+    },
+    scroll: { x: 480 }
+  });
+
+  // 禁用
+  const disabled = ref(false);
+
+  listUsers().then((data) => {
+    tableConfig.datasource = data;
+  });
+
+  /* 回显数据 */
+  const setInitValue = () => {
+    selectedValue.value = [14, 18, 19];
+  };
+</script>
diff --git a/src/views-demo/extension/table-select/index.vue b/src/views-demo/extension/table-select/index.vue
new file mode 100644
index 0000000..1857669
--- /dev/null
+++ b/src/views-demo/extension/table-select/index.vue
@@ -0,0 +1,21 @@
+<template>
+  <div class="ele-body ele-body-card">
+    <demo-basic />
+    <demo-basic-page />
+    <demo-multiple />
+    <demo-advanced />
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import DemoBasic from './components/demo-basic.vue';
+  import DemoBasicPage from './components/demo-basic-page.vue';
+  import DemoMultiple from './components/demo-multiple.vue';
+  import DemoAdvanced from './components/demo-advanced.vue';
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'ExtensionTableSelect'
+  };
+</script>
diff --git a/src/views-demo/extension/table-select/types/index.ts b/src/views-demo/extension/table-select/types/index.ts
new file mode 100644
index 0000000..fe57925
--- /dev/null
+++ b/src/views-demo/extension/table-select/types/index.ts
@@ -0,0 +1,6 @@
+/**
+ * 搜索表单类型
+ */
+export interface WhereType {
+  keywords?: string;
+}
diff --git a/src/views-demo/extension/tag/index.vue b/src/views-demo/extension/tag/index.vue
new file mode 100644
index 0000000..8a3b99e
--- /dev/null
+++ b/src/views-demo/extension/tag/index.vue
@@ -0,0 +1,131 @@
+<template>
+  <div class="ele-body ele-body-card">
+    <a-card title="标签组件" :bordered="false">
+      <div class="ele-cell">
+        <div>预设颜色:</div>
+        <div class="ele-cell-content">
+          <ele-tag
+            v-for="(item, index) in list"
+            :key="item"
+            :size="size"
+            :color="colors[type][index]"
+          >
+            标签{{ item }}
+          </ele-tag>
+        </div>
+      </div>
+      <div class="ele-cell">
+        <div>圆角样式:</div>
+        <div class="ele-cell-content">
+          <ele-tag
+            v-for="(item, index) in list"
+            :key="item"
+            :size="size"
+            shape="round"
+            :color="colors[type][index]"
+          >
+            标签{{ item }}
+          </ele-tag>
+        </div>
+      </div>
+      <div class="ele-cell">
+        <div>圆形样式:</div>
+        <div class="ele-cell-content">
+          <ele-tag
+            v-for="(item, index) in list"
+            :key="item"
+            :size="size"
+            shape="circle"
+            :color="colors[type][index]"
+          >
+            {{ index + 1 }}
+          </ele-tag>
+        </div>
+      </div>
+      <div class="ele-cell">
+        <div>尺寸选择:</div>
+        <div class="ele-cell-content">
+          <a-radio-group :options="sizes" v-model:value="size" />
+        </div>
+      </div>
+      <div class="ele-cell">
+        <div>主题选择:</div>
+        <div class="ele-cell-content">
+          <a-radio-group :options="types" v-model:value="type" />
+        </div>
+      </div>
+    </a-card>
+    <a-card title="标签输入" :bordered="false">
+      <ele-edit-tag v-model:data="tags" :size="size" :color="colors[type][0]" />
+      <div style="padding: 8px 0">{{ JSON.stringify(tags) }}</div>
+      <div style="padding: 8px 0">自定义异步验证:</div>
+      <ele-edit-tag
+        v-model:data="tags2"
+        :size="size"
+        :color="colors[type][0]"
+        :validator="validator"
+      />
+    </a-card>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import type { SizeType } from 'ele-admin-pro/es/ele-tag/types';
+
+  // 尺寸
+  const sizes = ref([
+    { label: 'mini', value: 'mini' },
+    { label: 'small', value: 'small' },
+    { label: 'middle', value: 'middle' },
+    { label: 'large', value: 'large' }
+  ]);
+
+  // 选中尺寸
+  const size = ref<SizeType>('mini');
+
+  // 颜色
+  const colors = ref([
+    ['', 'blue', 'green', 'orange', 'red'],
+    ['#909399', '#1890ff', '#52c41a', '#fa8c16', '#f5222d']
+  ]);
+
+  // 主题
+  const types = ref([
+    { label: 'presets', value: 0 },
+    { label: 'custom', value: 1 }
+  ]);
+
+  // 选中主题
+  const type = ref(0);
+
+  // 标签输入
+  const tags = ref(['标签一', '标签二', '标签三']);
+
+  //
+  const list = ['一', '二', '三', '四', '五'];
+
+  // 标签输入
+  const tags2 = ref(['标签一', '标签二', '标签三']);
+
+  //
+  const validator = (value: string) => {
+    return new Promise<void>((_resolve, reject) => {
+      setTimeout(() => {
+        reject(new Error(value + '不合法, 请重新输入'));
+      }, 1000);
+    });
+  };
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'ExtensionTag'
+  };
+</script>
+
+<style lang="less" scoped>
+  .ele-cell + .ele-cell {
+    margin-top: 16px;
+  }
+</style>
diff --git a/src/views-demo/extension/tour/index.vue b/src/views-demo/extension/tour/index.vue
new file mode 100644
index 0000000..e36c89d
--- /dev/null
+++ b/src/views-demo/extension/tour/index.vue
@@ -0,0 +1,172 @@
+<template>
+  <div class="ele-body ele-body-card">
+    <a-card title="基本用法" :bordered="false">
+      <div>
+        <a-button type="primary" @click="onStart1">开始引导</a-button>
+      </div>
+      <div style="margin-top: 20px">
+        <a-space size="large">
+          <a-button ref="uploadRef1">Upload</a-button>
+          <a-button ref="saveRef1" type="primary">Save</a-button>
+          <a-button ref="moreRef1">More</a-button>
+        </a-space>
+      </div>
+      <ele-tour v-model="current1" :steps="steps1" />
+    </a-card>
+    <a-card title="不带遮罩层" :bordered="false">
+      <div>
+        <a-button type="primary" @click="onStart2">开始引导</a-button>
+      </div>
+      <div style="margin-top: 20px">
+        <a-space size="large">
+          <a-button ref="uploadRef2">Upload</a-button>
+          <a-button ref="saveRef2" type="primary">Save</a-button>
+          <a-button ref="moreRef2">More</a-button>
+        </a-space>
+      </div>
+      <ele-tour v-model="current2" :steps="steps2" :mask="false" />
+    </a-card>
+    <a-card title="混合弹窗等多种形式" :bordered="false">
+      <div>
+        <a-button type="primary" @click="onStart3">开始引导</a-button>
+      </div>
+      <div style="margin-top: 20px">
+        <a-space size="large">
+          <a-button ref="uploadRef3">Upload</a-button>
+          <a-button ref="saveRef3" type="primary">Save</a-button>
+          <a-button ref="moreRef3">More</a-button>
+        </a-space>
+      </div>
+      <ele-tour v-model="current3" :steps="steps3">
+        <template #text="{ step, current }">
+          <template v-if="current === 0">
+            <div style="margin-bottom: 10px">
+              <img
+                src="https://gw.alipayobjects.com/mdn/rms_08e378/afts/img/A*P0S-QIRUbsUAAAAAAAAAAABkARQnAQ"
+                style="height: 184px; width: 100%; object-fit: cover"
+              />
+            </div>
+            <div>{{ step.description }}</div>
+          </template>
+        </template>
+      </ele-tour>
+    </a-card>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import type { Button as AButton } from 'ant-design-vue/es';
+  import type { TourStep } from 'ele-admin-pro/es/ele-tour/types';
+
+  // 当前步骤
+  const current1 = ref<number | null>(null);
+
+  // 按钮
+  const uploadRef1 = ref<InstanceType<typeof AButton> | null>(null);
+  const saveRef1 = ref<InstanceType<typeof AButton> | null>(null);
+  const moreRef1 = ref<InstanceType<typeof AButton> | null>(null);
+
+  // 步骤
+  const steps1 = ref<TourStep[]>([
+    {
+      target: () => uploadRef1.value?.$el,
+      title: '如何进行文件上传',
+      description: '点击这个按钮在弹出框中选择想要上传的文件即可.'
+    },
+    {
+      target: () => saveRef1.value?.$el,
+      title: '如何提交数据',
+      description: '数据录入完成后点击这个按钮即可提交数据到后台.'
+    },
+    {
+      target: () => moreRef1.value?.$el,
+      title: '如何进行更多的操作',
+      description: '鼠标移入到此按钮上即可展示出更多的操作功能.'
+    }
+  ]);
+
+  /* 开始引导 */
+  const onStart1 = () => {
+    current1.value = 0;
+  };
+
+  // 当前步骤
+  const current2 = ref<number | null>(null);
+
+  // 按钮
+  const uploadRef2 = ref<InstanceType<typeof AButton> | null>(null);
+  const saveRef2 = ref<InstanceType<typeof AButton> | null>(null);
+  const moreRef2 = ref<InstanceType<typeof AButton> | null>(null);
+
+  // 步骤
+  const steps2 = ref<TourStep[]>([
+    {
+      target: () => uploadRef2.value?.$el,
+      title: '如何进行文件上传',
+      description: '点击这个按钮在弹出框中选择想要上传的文件即可.',
+      popoverProps: { placement: 'topLeft' }
+    },
+    {
+      target: () => saveRef2.value?.$el,
+      title: '如何提交数据',
+      description: '数据录入完成后点击这个按钮即可提交数据到后台.',
+      popoverProps: { placement: 'bottom' }
+    },
+    {
+      target: () => moreRef2.value?.$el,
+      title: '如何进行更多的操作',
+      description: '鼠标移入到此按钮上即可展示出更多的操作功能.',
+      popoverProps: { placement: 'topRight' }
+    }
+  ]);
+
+  /* 开始引导 */
+  const onStart2 = () => {
+    current2.value = 0;
+  };
+
+  // 当前步骤
+  const current3 = ref<number | null>(null);
+
+  // 按钮
+  const uploadRef3 = ref<InstanceType<typeof AButton> | null>(null);
+  const saveRef3 = ref<InstanceType<typeof AButton> | null>(null);
+  const moreRef3 = ref<InstanceType<typeof AButton> | null>(null);
+
+  // 步骤
+  const steps3 = ref<TourStep[]>([
+    {
+      title: '欢迎使用 EleAdminPro 系统',
+      description:
+        '下面将为您介绍一些常用功能的操作说明, 如果之前已经为您介绍过, 您可以直接点击跳过结束指引.'
+    },
+    {
+      target: () => uploadRef3.value?.$el,
+      title: '如何进行文件上传',
+      description: '点击这个按钮在弹出框中选择想要上传的文件即可.'
+    },
+    {
+      target: () => saveRef3.value?.$el,
+      title: '如何提交数据',
+      description: '数据录入完成后点击这个按钮即可提交数据到后台.',
+      mask: false
+    },
+    {
+      target: () => moreRef3.value?.$el,
+      title: '如何进行更多的操作',
+      description: '鼠标移入到此按钮上即可展示出更多的操作功能.'
+    }
+  ]);
+
+  /* 开始引导 */
+  const onStart3 = () => {
+    current3.value = 0;
+  };
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'ExtensionTour'
+  };
+</script>
diff --git a/src/views-demo/extension/upload/components/demo-advanced.vue b/src/views-demo/extension/upload/components/demo-advanced.vue
new file mode 100644
index 0000000..e932fc7
--- /dev/null
+++ b/src/views-demo/extension/upload/components/demo-advanced.vue
@@ -0,0 +1,114 @@
+<template>
+  <a-card title="手动上传" :bordered="false">
+    <ele-image-upload
+      v-model:value="images"
+      :auto-upload="false"
+      :before-remove="onBeforeRemove"
+    />
+    <div class="ele-cell">
+      <a-button type="primary" :loading="loading" @click="onSubmit">
+        提交
+      </a-button>
+    </div>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref, createVNode } from 'vue';
+  import { message, Modal } from 'ant-design-vue/es';
+  import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
+  import type {
+    ItemType,
+    BeforeRemoveType
+  } from 'ele-admin-pro/es/ele-image-upload/types';
+
+  const images = ref<ItemType[]>([
+    {
+      uid: 1,
+      url: 'https://cdn.eleadmin.com/20200609/c184eef391ae48dba87e3057e70238fb.jpg',
+      status: 'done'
+    },
+    {
+      uid: 2,
+      url: 'https://cdn.eleadmin.com/20200610/WLXm7gp1EbLDtvVQgkeQeyq5OtDm00Jd.jpg',
+      status: 'done'
+    },
+    {
+      uid: 3,
+      url: 'https://cdn.eleadmin.com/20200609/f6bc05af944a4f738b54128717952107.jpg',
+      status: 'done'
+    }
+  ]);
+
+  const loading = ref(false);
+
+  /* 手动上传 */
+  const onSubmit = () => {
+    if (checkDone()) {
+      submitForm();
+      return;
+    }
+    loading.value = true;
+    images.value.forEach((item) => {
+      if (!item.status) {
+        uploadItem(item);
+      }
+    });
+  };
+
+  /* 上传图片 */
+  const uploadItem = (item: ItemType) => {
+    // 模拟上传
+    if (item.progress == null) {
+      item.progress = 20;
+    } else {
+      item.progress += 20;
+    }
+    item.status = 'uploading';
+    const timer = setInterval(() => {
+      if (item.progress == null) {
+        item.progress = 20;
+      } else {
+        item.progress += 20;
+      }
+      if (item.progress === 100) {
+        item.status = 'done';
+        clearInterval(timer);
+        // 每个图片上传完成后都检查是否全部上传完成
+        if (checkDone()) {
+          submitForm();
+        }
+      }
+    }, Math.round(Math.random() * 2500) + 500);
+  };
+
+  /* 检查是否全部上传完毕 */
+  const checkDone = () => {
+    return !images.value.some((d) => d.status !== 'done');
+  };
+
+  /* 全部上传完毕后与其它表单数据一起提交 */
+  const submitForm = () => {
+    message.success('已全部上传完毕');
+    console.log('data:', images.value);
+    loading.value = false;
+  };
+
+  /* 删除增加确认弹窗 */
+  const onBeforeRemove: BeforeRemoveType = () => {
+    return new Promise<void>((resolve, reject) => {
+      Modal.confirm({
+        title: '提示',
+        content: '确定要删除吗?',
+        icon: createVNode(ExclamationCircleOutlined),
+        maskClosable: true,
+        onOk: () => {
+          resolve();
+        },
+        onCancel: () => {
+          reject();
+        }
+      });
+    });
+  };
+</script>
diff --git a/src/views-demo/extension/upload/components/demo-basic.vue b/src/views-demo/extension/upload/components/demo-basic.vue
new file mode 100644
index 0000000..0f40c59
--- /dev/null
+++ b/src/views-demo/extension/upload/components/demo-basic.vue
@@ -0,0 +1,110 @@
+<template>
+  <a-card title="基础示例" :bordered="false">
+    <ele-image-upload
+      v-model:value="images"
+      :limit="8"
+      :disabled="disabled"
+      :before-upload="onBeforeUpload"
+      :drag="true"
+      @upload="onUpload"
+      @item-click="onItemClick"
+    />
+    <div class="ele-cell">
+      <a-button type="primary" @click="getData">获取数据</a-button>
+      <div style="line-height: 22px"><em></em>禁用:</div>
+      <div class="ele-cell-content">
+        <a-radio-group v-model:value="disabled">
+          <a-radio :value="false">否</a-radio>
+          <a-radio :value="true">是</a-radio>
+        </a-radio-group>
+      </div>
+    </div>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type {
+    ItemType,
+    BeforeUploadType
+  } from 'ele-admin-pro/es/ele-image-upload/types';
+
+  // 已上传数据
+  const images = ref<ItemType[]>([
+    {
+      uid: 1,
+      url: 'https://cdn.eleadmin.com/20200609/c184eef391ae48dba87e3057e70238fb.jpg',
+      status: 'done'
+    },
+    {
+      uid: 2,
+      url: 'https://cdn.eleadmin.com/20200610/WLXm7gp1EbLDtvVQgkeQeyq5OtDm00Jd.jpg',
+      status: 'done'
+    },
+    {
+      uid: 3,
+      url: 'https://cdn.eleadmin.com/20200609/f6bc05af944a4f738b54128717952107.jpg',
+      status: 'done'
+    }
+  ]);
+
+  // 禁用
+  const disabled = ref(false);
+
+  /* 获取当前数据 */
+  const getData = () => {
+    console.log('data:', images.value);
+    message.success('数据已打印在控制台');
+  };
+
+  /* 上传事件 */
+  const onUpload = (d: ItemType) => {
+    const item = images.value.find((t) => t.uid === d.uid) ?? d;
+    console.log('item:', item);
+    // 模拟上传
+    if (images.value.length !== 5) {
+      item.status = 'uploading';
+      item.progress = 20;
+      const timer = setInterval(() => {
+        if (item.progress == null) {
+          item.progress = 20;
+        } else {
+          item.progress += 20;
+        }
+        if (item.progress === 100) {
+          item.status = 'done';
+          clearInterval(timer);
+        }
+      }, 1000);
+    } else {
+      item.status = 'uploading';
+      if (item.progress == null) {
+        item.progress = 20;
+      } else if (item.progress < 80) {
+        item.progress += 20;
+      }
+      setTimeout(() => {
+        item.status = 'exception';
+        message.error('上传失败, 服务器繁忙');
+      }, 2000);
+    }
+  };
+
+  /* 上传前钩子 */
+  const onBeforeUpload: BeforeUploadType = (file: File) => {
+    if (!file.type.startsWith('image')) {
+      message.error('只能选择图片');
+      return false;
+    }
+    if (file.size / 1024 / 1024 > 2) {
+      message.error('大小不能超过 2MB');
+      return false;
+    }
+  };
+
+  /* item 点击事件 */
+  const onItemClick = (item: ItemType) => {
+    console.log('item-click:', item);
+  };
+</script>
diff --git a/src/views-demo/extension/upload/components/demo-multiple.vue b/src/views-demo/extension/upload/components/demo-multiple.vue
new file mode 100644
index 0000000..4ac06d3
--- /dev/null
+++ b/src/views-demo/extension/upload/components/demo-multiple.vue
@@ -0,0 +1,86 @@
+<template>
+  <a-card title="支持多选" :bordered="false">
+    <ele-image-upload
+      v-model:value="images"
+      :limit="8"
+      :drag="true"
+      :multiple="true"
+      :upload-handler="uploadHandler"
+      @upload="onUpload"
+    />
+    <a-button type="primary" @click="getData">获取数据</a-button>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type { ItemType } from 'ele-admin-pro/es/ele-image-upload/types';
+
+  // 已上传数据
+  const images = ref<ItemType[]>([
+    {
+      uid: 1,
+      url: 'https://cdn.eleadmin.com/20200609/c184eef391ae48dba87e3057e70238fb.jpg',
+      status: 'done'
+    },
+    {
+      uid: 2,
+      url: 'https://cdn.eleadmin.com/20200610/WLXm7gp1EbLDtvVQgkeQeyq5OtDm00Jd.jpg',
+      status: 'done'
+    },
+    {
+      uid: 3,
+      url: 'https://cdn.eleadmin.com/20200609/f6bc05af944a4f738b54128717952107.jpg',
+      status: 'done'
+    }
+  ]);
+
+  /* 获取当前数据 */
+  const getData = () => {
+    console.log('data:', images.value);
+    message.success('数据已打印在控制台');
+  };
+
+  /* 上传事件 */
+  const uploadHandler = (file: File) => {
+    const item: ItemType = {
+      file,
+      uid: (file as any).uid,
+      name: file.name,
+      progress: 0,
+      status: undefined
+    };
+    if (!file.type.startsWith('image')) {
+      message.error('只能选择图片');
+      return;
+    }
+    if (file.size / 1024 / 1024 > 2) {
+      message.error('大小不能超过 2MB');
+      return;
+    }
+    item.url = window.URL.createObjectURL(file);
+    images.value.push(item);
+    onUpload(item);
+  };
+
+  /* 上传 item */
+  const onUpload = (d: ItemType) => {
+    const item = images.value.find((t) => t.uid === d.uid) ?? d;
+    console.log('item:', item);
+    // 模拟上传
+    item.status = 'uploading';
+    item.progress = 20;
+    const timer = setInterval(() => {
+      if (item.progress == null) {
+        item.progress = 20;
+      } else {
+        item.progress += 20;
+      }
+      if (item.progress === 100) {
+        item.status = 'done';
+        clearInterval(timer);
+      }
+    }, 1000);
+  };
+</script>
diff --git a/src/views-demo/extension/upload/index.vue b/src/views-demo/extension/upload/index.vue
new file mode 100644
index 0000000..9dd67c3
--- /dev/null
+++ b/src/views-demo/extension/upload/index.vue
@@ -0,0 +1,19 @@
+<template>
+  <div class="ele-body ele-body-card">
+    <demo-basic />
+    <demo-advanced />
+    <demo-multiple />
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import DemoBasic from './components/demo-basic.vue';
+  import DemoAdvanced from './components/demo-advanced.vue';
+  import DemoMultiple from './components/demo-multiple.vue';
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'ExtensionUpload'
+  };
+</script>
diff --git a/src/views-demo/extension/watermark/index.vue b/src/views-demo/extension/watermark/index.vue
new file mode 100644
index 0000000..897a888
--- /dev/null
+++ b/src/views-demo/extension/watermark/index.vue
@@ -0,0 +1,33 @@
+<template>
+  <div class="ele-body ele-body-card">
+    <a-card title="基本用法" :bordered="false">
+      <ele-watermark content="Ele Admin Pro" :gap="[60, 40]">
+        <div style="height: 200px"></div>
+      </ele-watermark>
+    </a-card>
+    <a-card title="多行水印" :bordered="false">
+      <ele-watermark
+        :content="['Ele Admin Pro', 'Happy Working']"
+        :gap="[60, 40]"
+      >
+        <div style="height: 200px"></div>
+      </ele-watermark>
+    </a-card>
+    <a-card title="图片水印" :bordered="false">
+      <ele-watermark
+        :height="30"
+        :width="130"
+        image="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*lkAoRbywo0oAAAAAAAAAAAAADrJ8AQ/original"
+        :gap="[60, 40]"
+      >
+        <div style="height: 200px"></div>
+      </ele-watermark>
+    </a-card>
+  </div>
+</template>
+
+<script lang="ts">
+  export default {
+    name: 'ExtensionWatermark'
+  };
+</script>
diff --git a/src/views-demo/forget/index.vue b/src/views-demo/forget/index.vue
new file mode 100644
index 0000000..13d655b
--- /dev/null
+++ b/src/views-demo/forget/index.vue
@@ -0,0 +1,407 @@
+<template>
+  <div class="login-wrapper">
+    <a-form
+      ref="formRef"
+      :model="form"
+      :rules="rules"
+      class="login-form ele-bg-white"
+    >
+      <h4>忘记密码</h4>
+      <a-form-item name="phone">
+        <a-input
+          placeholder="请输入绑定手机号"
+          v-model:value="form.phone"
+          allow-clear
+          size="large"
+        >
+          <template #prefix>
+            <mobile-outlined />
+          </template>
+        </a-input>
+      </a-form-item>
+      <a-form-item name="password">
+        <a-input-password
+          placeholder="请输入新的登录密码"
+          v-model:value="form.password"
+          size="large"
+        >
+          <template #prefix>
+            <lock-outlined />
+          </template>
+        </a-input-password>
+      </a-form-item>
+      <a-form-item name="password2">
+        <a-input-password
+          placeholder="请再次输入登录密码"
+          v-model:value="form.password2"
+          size="large"
+        >
+          <template #prefix>
+            <key-outlined />
+          </template>
+        </a-input-password>
+      </a-form-item>
+      <a-form-item name="code">
+        <div class="login-input-group">
+          <a-input
+            placeholder="请输入验证码"
+            v-model:value="form.code"
+            allow-clear
+            size="large"
+          >
+            <template #prefix>
+              <safety-certificate-outlined />
+            </template>
+          </a-input>
+          <a-button
+            class="login-captcha"
+            :disabled="!!countdownTime"
+            @click="openImgCodeModal"
+          >
+            <span v-if="!countdownTime">发送验证码</span>
+            <span v-else>已发送 {{ countdownTime }} s</span>
+          </a-button>
+        </div>
+      </a-form-item>
+      <a-form-item>
+        <router-link
+          to="/login"
+          class="ele-pull-right"
+          style="line-height: 22px"
+        >
+          返回登录
+        </router-link>
+      </a-form-item>
+      <a-form-item>
+        <a-button
+          block
+          size="large"
+          type="primary"
+          :loading="loading"
+          @click="submit"
+        >
+          修改密码
+        </a-button>
+      </a-form-item>
+    </a-form>
+    <div class="login-copyright">
+      copyright © 2022 eleadmin.com all rights reserved.
+    </div>
+  </div>
+  <!-- 编辑弹窗 -->
+  <a-modal
+    :width="340"
+    :footer="null"
+    title="发送验证码"
+    v-model:visible="visible"
+  >
+    <div class="login-input-group" style="margin-bottom: 16px">
+      <a-input
+        v-model:value="imgCode"
+        placeholder="请输入图形验证码"
+        allow-clear
+        size="large"
+      />
+      <a-button class="login-captcha">
+        <img alt="" :src="captcha" @click="changeImgCode" />
+      </a-button>
+    </div>
+    <a-button
+      block
+      size="large"
+      type="primary"
+      :loading="codeLoading"
+      @click="sendCode"
+    >
+      立即发送
+    </a-button>
+  </a-modal>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive, onBeforeUnmount } from 'vue';
+  import { useRouter } from 'vue-router';
+  import { message } from 'ant-design-vue/es';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import {
+    MobileOutlined,
+    LockOutlined,
+    KeyOutlined,
+    SafetyCertificateOutlined
+  } from '@ant-design/icons-vue';
+
+  const { push } = useRouter();
+
+  //
+  const formRef = ref<FormInstance | null>(null);
+
+  // 加载状态
+  const loading = ref(false);
+
+  // 表单数据
+  const form = reactive({
+    phone: '1234567890',
+    password: '',
+    password2: '',
+    code: ''
+  });
+
+  // 表单验证规则
+  const rules = reactive<Record<string, Rule[]>>({
+    phone: [
+      {
+        required: true,
+        message: '请输入绑定手机号',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    password: [
+      {
+        required: true,
+        message: '请输入新的登录密码',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    password2: [
+      {
+        required: true,
+        type: 'string',
+        validator: async (_rule: Rule, value: string) => {
+          if (!value) {
+            return Promise.reject('请再次输入新密码');
+          }
+          if (value !== form.password) {
+            return Promise.reject('两次输入密码不一致');
+          }
+          return Promise.resolve();
+        },
+        trigger: 'blur'
+      }
+    ],
+    code: [
+      {
+        required: true,
+        message: '请输入验证码',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ]
+  });
+
+  // 是否显示图形验证码弹窗
+  const visible = ref(false);
+
+  // 图形验证码
+  const imgCode = ref('');
+
+  // 发送验证码按钮loading
+  const codeLoading = ref(false);
+
+  // 验证码倒计时时间
+  const countdownTime = ref(0);
+
+  // 图形验证码地址
+  const captcha = ref('https://eleadmin.com/assets/captcha?v=');
+
+  // 验证码倒计时定时器
+  let countdownTimer: number | null = null;
+
+  /* 提交 */
+  const submit = () => {
+    if (!formRef.value) {
+      return;
+    }
+    formRef.value
+      .validate()
+      .then(() => {
+        loading.value = true;
+        setTimeout(() => {
+          message.success('密码修改成功');
+          push('/login');
+        }, 1000);
+      })
+      .catch(() => {});
+  };
+
+  /* 更换图形验证码 */
+  const changeImgCode = () => {
+    // 这里演示的验证码是后端地址直接是图片的形式, 如果后端返回base64格式请参考登录页面
+    captcha.value = captcha.value.replace(/v=.*/, 'v=' + new Date().getTime());
+  };
+
+  /* 显示发送短信验证码弹窗 */
+  const openImgCodeModal = () => {
+    if (!form.phone) {
+      message.error('请输入手机号码');
+      return;
+    }
+    imgCode.value = '';
+    changeImgCode();
+    visible.value = true;
+  };
+
+  /* 发送短信验证码 */
+  const sendCode = () => {
+    if (!imgCode.value) {
+      message.error('请输入图形验证码');
+      return;
+    }
+    codeLoading.value = true;
+    setTimeout(() => {
+      message.success('短信验证码发送成功, 请注意查收!');
+      visible.value = false;
+      codeLoading.value = false;
+      countdownTime.value = 30;
+      // 开始对按钮进行倒计时
+      countdownTimer = window.setInterval(() => {
+        if (countdownTime.value <= 1) {
+          countdownTimer && clearInterval(countdownTimer);
+          countdownTimer = null;
+        }
+        countdownTime.value--;
+      }, 1000);
+    }, 1000);
+  };
+
+  onBeforeUnmount(() => {
+    countdownTimer && clearInterval(countdownTimer);
+  });
+</script>
+
+<style lang="less" scoped>
+  /* 背景 */
+  .login-wrapper {
+    padding: 48px 16px 0 16px;
+    position: relative;
+    box-sizing: border-box;
+    background-image: url('@/assets/bg-login.jpg');
+    background-repeat: no-repeat;
+    background-size: cover;
+    min-height: 100vh;
+
+    &:before {
+      content: '';
+      position: absolute;
+      top: 0;
+      left: 0;
+      right: 0;
+      bottom: 0;
+      background: rgba(0, 0, 0, 0.2);
+    }
+  }
+
+  /* 卡片 */
+  .login-form {
+    width: 360px;
+    margin: 0 auto;
+    max-width: 100%;
+    padding: 0 28px 16px 28px;
+    box-sizing: border-box;
+    box-shadow: 0 3px 6px rgba(0, 0, 0, 0.15);
+    border-radius: 2px;
+    position: relative;
+    z-index: 2;
+
+    h4 {
+      padding: 22px 0;
+      text-align: center;
+    }
+  }
+
+  .login-form-right .login-form {
+    margin: 0 15% 0 auto;
+  }
+
+  .login-form-left .login-form {
+    margin: 0 auto 0 15%;
+  }
+
+  /* 验证码 */
+  .login-input-group {
+    display: flex;
+    align-items: center;
+
+    :deep(.ant-input-affix-wrapper) {
+      flex: 1;
+    }
+
+    .login-captcha {
+      width: 102px;
+      height: 40px;
+      margin-left: 10px;
+      padding: 0;
+
+      & > img {
+        width: 100%;
+        height: 100%;
+      }
+    }
+  }
+
+  /* 第三方登录图标 */
+  .login-oauth-icon {
+    color: #fff;
+    padding: 5px;
+    margin: 0 12px;
+    font-size: 18px;
+    border-radius: 50%;
+    cursor: pointer;
+  }
+
+  /* 底部版权 */
+  .login-copyright {
+    color: #eee;
+    text-align: center;
+    padding: 48px 0 22px 0;
+    position: relative;
+    z-index: 1;
+  }
+
+  /* 响应式 */
+  @media screen and (min-height: 640px) {
+    .login-wrapper {
+      padding-top: 0;
+    }
+
+    .login-form {
+      position: absolute;
+      top: 50%;
+      left: 50%;
+      transform: translateX(-50%);
+      margin-top: -230px;
+    }
+
+    .login-form-right .login-form,
+    .login-form-left .login-form {
+      left: auto;
+      right: 15%;
+      transform: translateX(0);
+      margin: -230px auto auto auto;
+    }
+
+    .login-form-left .login-form {
+      right: auto;
+      left: 15%;
+    }
+
+    .login-copyright {
+      position: absolute;
+      left: 0;
+      right: 0;
+      bottom: 0;
+    }
+  }
+
+  @media screen and (max-width: 768px) {
+    .login-form-right .login-form,
+    .login-form-left .login-form {
+      left: 50%;
+      right: auto;
+      margin-left: 0;
+      margin-right: auto;
+      transform: translateX(-50%);
+    }
+  }
+</style>
diff --git a/src/views-demo/form/advanced/components/user-select.vue b/src/views-demo/form/advanced/components/user-select.vue
new file mode 100644
index 0000000..4bceaa5
--- /dev/null
+++ b/src/views-demo/form/advanced/components/user-select.vue
@@ -0,0 +1,148 @@
+<template>
+  <a-card :bordered="false" title="选择成员">
+    <a-table
+      size="middle"
+      row-key="key"
+      :pagination="false"
+      :data-source="users"
+      :columns="columns"
+      :scroll="{ x: 900 }"
+    >
+      <template #bodyCell="{ column, record, index }">
+        <template v-if="column.key === 'name'">
+          <a-input
+            v-if="record.isEdit"
+            v-model:value="record.name"
+            placeholder="请输入用户名"
+          />
+          <div v-else>{{ record.name }}</div>
+        </template>
+        <template v-else-if="column.key === 'number'">
+          <a-input
+            v-if="record.isEdit"
+            v-model:value="record.number"
+            placeholder="请输入工号"
+          />
+          <div v-else>{{ record.number }}</div>
+        </template>
+        <template v-else-if="column.key === 'department'">
+          <a-select
+            v-if="record.isEdit"
+            v-model:value="record.department"
+            placeholder="请选择部门"
+            class="ele-fluid"
+          >
+            <a-select-option value="研发部">研发部</a-select-option>
+            <a-select-option value="测试部">测试部</a-select-option>
+            <a-select-option value="产品部">产品部</a-select-option>
+          </a-select>
+          <div v-else>{{ record.department }}</div>
+        </template>
+        <template v-else-if="column.key === 'action'">
+          <a-space>
+            <a v-if="record.isEdit" @click="done(record, index)">完成</a>
+            <a v-else @click="edit(record, index)">修改</a>
+            <a-divider type="vertical" />
+            <a-popconfirm
+              title="确定要删除此用户吗?"
+              @confirm="remove(record, index)"
+            >
+              <a class="ele-text-danger">删除</a>
+            </a-popconfirm>
+          </a-space>
+        </template>
+      </template>
+    </a-table>
+    <a-button block type="dashed" style="margin-top: 16px" @click="add">
+      <template #icon>
+        <plus-outlined />
+      </template>
+      <span>新增成员</span>
+    </a-button>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import { PlusOutlined } from '@ant-design/icons-vue';
+  import { uuid } from 'ele-admin-pro/es';
+  import { queryList } from '@/api/form/advanced';
+  import type { UserItem } from '@/api/form/advanced/model';
+  import type { ColumnsType } from 'ant-design-vue/es/table';
+
+  // 已添加成员
+  const users = ref<UserItem[]>([]);
+
+  // 列
+  const columns = reactive<ColumnsType>([
+    {
+      key: 'index',
+      align: 'center',
+      width: 48,
+      customRender: ({ index }) => index + 1,
+      fixed: 'left'
+    },
+    {
+      title: '用户名',
+      key: 'name',
+      width: 200
+    },
+    {
+      title: '工号',
+      key: 'number',
+      width: 200
+    },
+    {
+      title: '所属部门',
+      key: 'department',
+      width: 200
+    },
+    {
+      title: '操作',
+      key: 'action',
+      align: 'center',
+      width: 160
+    }
+  ]);
+
+  /* 添加 */
+  const add = () => {
+    users.value.push({
+      key: uuid(8),
+      isEdit: true,
+      number: '00001',
+      name: 'John Brown',
+      department: '研发部'
+    });
+  };
+
+  /* 编辑 */
+  const edit = (_row: UserItem, index: number) => {
+    users.value[index].isEdit = true;
+  };
+
+  /* 完成编辑 */
+  const done = (_row: UserItem, index: number) => {
+    users.value[index].isEdit = false;
+  };
+
+  /* 删除 */
+  const remove = (_row: UserItem, index: number) => {
+    users.value.splice(index, 1);
+  };
+
+  /* 查询已添加 */
+  queryList()
+    .then((data) => {
+      users.value = data.map((d) => {
+        return {
+          ...d,
+          isEdit: false
+        };
+      });
+    })
+    .catch((e) => {
+      message.error(e.message);
+    });
+</script>
diff --git a/src/views-demo/form/advanced/index.vue b/src/views-demo/form/advanced/index.vue
new file mode 100644
index 0000000..9a3dbb2
--- /dev/null
+++ b/src/views-demo/form/advanced/index.vue
@@ -0,0 +1,447 @@
+<template>
+  <div>
+    <a-page-header :ghost="false" title="复杂表单">
+      <div class="ele-text-secondary">
+        复杂表单常见于一次性输入和提交大批量数据的场景。
+      </div>
+    </a-page-header>
+    <div class="ele-body ele-body-card" style="padding-bottom: 48px">
+      <a-form
+        ref="formRef"
+        :model="form"
+        :rules="rules"
+        :label-col="
+          styleResponsive
+            ? {
+                xxl: 6,
+                xl: 8,
+                lg: 6,
+                md: 8,
+                sm: 5,
+                xs: 24
+              }
+            : { flex: '90px' }
+        "
+        :wrapper-col="
+          styleResponsive
+            ? {
+                xxl: 18,
+                xl: 16,
+                lg: 18,
+                md: 16,
+                sm: 19,
+                xs: 24
+              }
+            : { flex: '1' }
+        "
+      >
+        <a-card :bordered="false" title="仓库信息">
+          <a-row :gutter="16">
+            <a-col
+              v-bind="
+                styleResponsive
+                  ? { xl: 8, lg: 12, md: 12, sm: 24, xs: 24 }
+                  : { span: 8 }
+              "
+            >
+              <a-form-item label="仓库名" name="name">
+                <a-input
+                  allow-clear
+                  v-model:value="form.name"
+                  placeholder="请输入仓库名"
+                />
+              </a-form-item>
+            </a-col>
+            <a-col
+              v-bind="
+                styleResponsive
+                  ? { xl: 8, lg: 12, md: 12, sm: 24, xs: 24 }
+                  : { span: 8 }
+              "
+            >
+              <a-form-item label="仓库域名" name="url">
+                <a-input
+                  allow-clear
+                  addon-after=".com"
+                  addon-before="https://"
+                  v-model:value="form.url"
+                  placeholder="请输入"
+                />
+              </a-form-item>
+            </a-col>
+            <a-col
+              v-bind="
+                styleResponsive
+                  ? { xl: 8, lg: 12, md: 12, sm: 24, xs: 24 }
+                  : { span: 8 }
+              "
+            >
+              <a-form-item label="仓库管理员" name="administrator">
+                <a-select
+                  allow-clear
+                  v-model:value="form.administrator"
+                  placeholder="请选择仓库管理员"
+                >
+                  <a-select-option value="1">SunSmile</a-select-option>
+                  <a-select-option value="2">Jasmine</a-select-option>
+                </a-select>
+              </a-form-item>
+            </a-col>
+            <a-col
+              v-bind="
+                styleResponsive
+                  ? { xl: 8, lg: 12, md: 12, sm: 24, xs: 24 }
+                  : { span: 8 }
+              "
+            >
+              <a-form-item label="审批人" name="approver">
+                <a-select
+                  allow-clear
+                  v-model:value="form.approver"
+                  placeholder="请选择审批人"
+                >
+                  <a-select-option value="1">SunSmile</a-select-option>
+                  <a-select-option value="2">Jasmine</a-select-option>
+                </a-select>
+              </a-form-item>
+            </a-col>
+            <a-col
+              v-bind="
+                styleResponsive
+                  ? { xl: 8, lg: 12, md: 12, sm: 24, xs: 24 }
+                  : { span: 8 }
+              "
+            >
+              <a-form-item label="生效日期" name="datetime">
+                <a-range-picker
+                  class="ele-fluid"
+                  value-format="YYYY-MM-DD"
+                  v-model:value="form.datetime"
+                />
+              </a-form-item>
+            </a-col>
+            <a-col
+              v-bind="
+                styleResponsive
+                  ? { xl: 8, lg: 12, md: 12, sm: 24, xs: 24 }
+                  : { span: 8 }
+              "
+            >
+              <a-form-item label="仓库类型" name="type">
+                <a-select
+                  allow-clear
+                  v-model:value="form.type"
+                  placeholder="请选择仓库类型"
+                >
+                  <a-select-option value="private">私密</a-select-option>
+                  <a-select-option value="public">公开</a-select-option>
+                </a-select>
+              </a-form-item>
+            </a-col>
+          </a-row>
+        </a-card>
+        <a-card :bordered="false" title="任务信息">
+          <a-row :gutter="16">
+            <a-col
+              v-bind="
+                styleResponsive
+                  ? { xl: 8, lg: 12, md: 12, sm: 24, xs: 24 }
+                  : { span: 8 }
+              "
+            >
+              <a-form-item label="任务名" name="task">
+                <a-input
+                  allow-clear
+                  v-model:value="form.task"
+                  placeholder="请输入任务名"
+                />
+              </a-form-item>
+            </a-col>
+            <a-col
+              v-bind="
+                styleResponsive
+                  ? { xl: 8, lg: 12, md: 12, sm: 24, xs: 24 }
+                  : { span: 8 }
+              "
+            >
+              <a-form-item label="任务表述" name="description">
+                <a-input
+                  allow-clear
+                  v-model:value="form.description"
+                  placeholder="请输入任务表述"
+                />
+              </a-form-item>
+            </a-col>
+            <a-col
+              v-bind="
+                styleResponsive
+                  ? { xl: 8, lg: 12, md: 12, sm: 24, xs: 24 }
+                  : { span: 8 }
+              "
+            >
+              <a-form-item label="执行人" name="executor">
+                <a-select
+                  allow-clear
+                  v-model:value="form.executor"
+                  placeholder="请选择执行人"
+                >
+                  <a-select-option value="1">SunSmile</a-select-option>
+                  <a-select-option value="2">Jasmine</a-select-option>
+                </a-select>
+              </a-form-item>
+            </a-col>
+            <a-col
+              v-bind="
+                styleResponsive
+                  ? { xl: 8, lg: 12, md: 12, sm: 24, xs: 24 }
+                  : { span: 8 }
+              "
+            >
+              <a-form-item label="责任人" name="officer">
+                <a-select
+                  allow-clear
+                  v-model:value="form.officer"
+                  placeholder="请选择责任人"
+                >
+                  <a-select-option value="1">SunSmile</a-select-option>
+                  <a-select-option value="2">Jasmine</a-select-option>
+                </a-select>
+              </a-form-item>
+            </a-col>
+            <a-col
+              v-bind="
+                styleResponsive
+                  ? { xl: 8, lg: 12, md: 12, sm: 24, xs: 24 }
+                  : { span: 8 }
+              "
+            >
+              <a-form-item label="提醒时间" name="reminder">
+                <a-time-picker
+                  class="ele-fluid"
+                  value-format="HH:mm:ss"
+                  v-model:value="form.reminder"
+                  placeholder="请选择提醒时间"
+                />
+              </a-form-item>
+            </a-col>
+            <a-col
+              v-bind="
+                styleResponsive
+                  ? { xl: 8, lg: 12, md: 12, sm: 24, xs: 24 }
+                  : { span: 8 }
+              "
+            >
+              <a-form-item label="任务类型" name="taskType">
+                <a-select
+                  allow-clear
+                  v-model:value="form.taskType"
+                  placeholder="请选择任务类型"
+                >
+                  <a-select-option value="1">私密</a-select-option>
+                  <a-select-option value="2">公开</a-select-option>
+                </a-select>
+              </a-form-item>
+            </a-col>
+          </a-row>
+        </a-card>
+        <user-select />
+        <!-- 底部工具栏 -->
+        <div class="ele-bottom-tool">
+          <div v-if="validMsg" class="ele-text-danger">
+            <close-circle-outlined />
+            <span>{{ validMsg }}</span>
+          </div>
+          <div class="ele-bottom-tool-actions">
+            <a-button type="primary" :loading="loading" @click="submit">
+              提交
+            </a-button>
+          </div>
+        </div>
+      </a-form>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import { CloseCircleOutlined } from '@ant-design/icons-vue';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import UserSelect from './components/user-select.vue';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  //
+  const formRef = ref<FormInstance | null>(null);
+
+  // 加载状态
+  const loading = ref(false);
+
+  //
+  interface FormType {
+    name?: string;
+    url?: string;
+    administrator?: string;
+    approver?: string;
+    datetime?: [string, string];
+    type?: string;
+    task?: string;
+    description?: string;
+    executor?: string;
+    officer?: string;
+    reminder?: string;
+    taskType?: string;
+  }
+
+  // 表单数据
+  const { form, resetFields } = useFormData<FormType>({
+    name: '',
+    url: '',
+    administrator: undefined,
+    approver: undefined,
+    datetime: ['', ''],
+    type: undefined,
+    task: '',
+    description: '',
+    executor: undefined,
+    officer: undefined,
+    reminder: undefined,
+    taskType: undefined
+  });
+
+  // 表单验证规则
+  const rules = reactive<Record<string, Rule[]>>({
+    name: [
+      {
+        required: true,
+        message: '请输入仓库名',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    url: [
+      {
+        required: true,
+        message: '请输入仓库域名',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    administrator: [
+      {
+        required: true,
+        message: '请选择仓库管理员',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    approver: [
+      {
+        required: true,
+        message: '请选择审批人',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    datetime: [
+      {
+        required: true,
+        message: '请选择生效日期',
+        type: 'array',
+        trigger: 'blur'
+      }
+    ],
+    type: [
+      {
+        required: true,
+        message: '请选择仓库类型',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    task: [
+      {
+        required: true,
+        message: '请输入任务名',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    description: [
+      {
+        required: true,
+        message: '请输入任务表述',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    executor: [
+      {
+        required: true,
+        message: '请选择执行人',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    officer: [
+      {
+        required: true,
+        message: '请选择责任人',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    reminder: [
+      {
+        required: true,
+        message: '请选择提醒时间',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    taskType: [
+      {
+        required: true,
+        message: '请选择任务类型',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ]
+  });
+
+  // 表单验证失败提示信息
+  const validMsg = ref('');
+
+  /* 表单提交 */
+  const submit = () => {
+    if (!formRef.value) {
+      return;
+    }
+    formRef.value
+      .validate()
+      .then(() => {
+        validMsg.value = '';
+        loading.value = true;
+        setTimeout(() => {
+          loading.value = false;
+          message.success('提交成功');
+          resetFields();
+          formRef.value?.clearValidate();
+        }, 1000);
+      })
+      .catch((e: any) => {
+        validMsg.value = ` 共有 ${e.errorFields.length} 项校验不通过`;
+      });
+  };
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'FormAdvanced'
+  };
+</script>
diff --git a/src/views-demo/form/basic/index.vue b/src/views-demo/form/basic/index.vue
new file mode 100644
index 0000000..2f0ed69
--- /dev/null
+++ b/src/views-demo/form/basic/index.vue
@@ -0,0 +1,230 @@
+<template>
+  <div>
+    <a-page-header :ghost="false" title="基础表单">
+      <div class="ele-text-secondary">
+        表单页用于向用户收集或验证信息,基础表单常见于数据项较少的表单场景。
+      </div>
+    </a-page-header>
+    <div class="ele-body">
+      <a-card :bordered="false">
+        <a-form
+          ref="formRef"
+          :model="form"
+          :rules="rules"
+          :label-col="styleResponsive ? { sm: 4, xs: 24 } : { flex: '100px' }"
+          :wrapper-col="styleResponsive ? { sm: 20, xs: 24 } : { flex: '1' }"
+          style="max-width: 800px; margin: 0 auto"
+        >
+          <a-form-item label="标题" name="title">
+            <a-input
+              allow-clear
+              placeholder="请输入标题"
+              v-model:value="form.title"
+            />
+          </a-form-item>
+          <a-form-item label="起止日期" name="datetime">
+            <a-range-picker
+              class="ele-fluid"
+              value-format="YYYY-MM-DD"
+              v-model:value="form.datetime"
+            />
+          </a-form-item>
+          <a-form-item label="目标描述" name="goal">
+            <a-textarea
+              :rows="4"
+              v-model:value="form.goal"
+              placeholder="请输入目标描述"
+            />
+          </a-form-item>
+          <a-form-item label="衡量标准" name="standard">
+            <a-textarea
+              :rows="4"
+              v-model:value="form.standard"
+              placeholder="请输入衡量标准"
+            />
+          </a-form-item>
+          <a-form-item label="地点" name="address">
+            <a-select
+              allow-clear
+              v-model:value="form.address"
+              placeholder="请选择地点"
+            >
+              <a-select-option value="1">地点一</a-select-option>
+              <a-select-option value="2">地点二</a-select-option>
+              <a-select-option value="3">地点三</a-select-option>
+            </a-select>
+          </a-form-item>
+          <a-form-item label="邀评人">
+            <a-select
+              allow-clear
+              mode="multiple"
+              v-model:value="form.invites"
+              placeholder="请选择邀评人"
+            >
+              <a-select-option
+                v-for="item in users"
+                :key="item.id"
+                :value="item.name"
+              >
+                {{ item.name }}
+              </a-select-option>
+            </a-select>
+          </a-form-item>
+          <a-form-item label="权重">
+            <a-space>
+              <a-input-number :min="0" :max="100" v-model:value="form.weight" />
+              <span>%</span>
+            </a-space>
+          </a-form-item>
+          <a-form-item label="目标公开">
+            <a-radio-group name="publicType" v-model:value="form.publicType">
+              <a-radio :value="1">公开</a-radio>
+              <a-radio :value="2">部分公开</a-radio>
+              <a-radio :value="3">不公开</a-radio>
+            </a-radio-group>
+            <div style="margin-top: 12px">
+              <a-input v-if="form.publicType === 2" placeholder="公开给" />
+            </div>
+            <div class="ele-text-secondary" style="margin-top: 12px">
+              客户、邀评人默认被分享
+            </div>
+          </a-form-item>
+          <a-form-item
+            :wrapper-col="
+              styleResponsive ? { sm: { offset: 4 } } : { offset: 3 }
+            "
+          >
+            <a-space size="middle">
+              <a-button @click="finishPageTab()">关闭</a-button>
+              <a-button type="primary" :loading="loading" @click="submit">
+                提交
+              </a-button>
+            </a-space>
+          </a-form-item>
+        </a-form>
+      </a-card>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import { finishPageTab } from '@/utils/page-tab-util';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  //
+  const formRef = ref<FormInstance | null>(null);
+
+  // 加载状态
+  const loading = ref(false);
+
+  //
+  interface FormType {
+    title?: string;
+    datetime?: [string, string];
+    goal?: string;
+    standard?: string;
+    address?: string;
+    invites?: [];
+    weight?: number;
+    publicType?: number;
+  }
+
+  // 表单数据
+  const { form, resetFields } = useFormData<FormType>({
+    title: '',
+    datetime: ['', ''],
+    goal: '',
+    standard: '',
+    address: undefined,
+    invites: [],
+    weight: 0,
+    publicType: 1
+  });
+
+  // 表单验证规则
+  const rules = reactive<Record<string, Rule[]>>({
+    title: [
+      {
+        required: true,
+        message: '请输入标题',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    datetime: [
+      {
+        required: true,
+        message: '请选择起止日期',
+        type: 'array',
+        trigger: 'blur'
+      }
+    ],
+    goal: [
+      {
+        required: true,
+        message: '请输入目标描述',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    standard: [
+      {
+        required: true,
+        message: '请输入衡量标准',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    address: [
+      {
+        required: true,
+        message: '请选择地点',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ]
+  });
+
+  // 邀评人下拉列表数据
+  const users = ref([
+    { id: 1, name: 'SunSmile' },
+    { id: 2, name: '你的名字很好听' },
+    { id: 3, name: '全村人的希望' },
+    { id: 4, name: 'Jasmine' },
+    { id: 5, name: '酷酷的大叔' }
+  ]);
+
+  /* 提交 */
+  const submit = () => {
+    if (!formRef.value) {
+      return;
+    }
+    formRef.value
+      .validate()
+      .then(() => {
+        loading.value = true;
+        setTimeout(() => {
+          loading.value = false;
+          resetFields();
+          formRef.value?.clearValidate();
+          message.success('提交成功');
+        }, 1500);
+      })
+      .catch(() => {});
+  };
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'FormBasic'
+  };
+</script>
diff --git a/src/views-demo/form/step/components/step-confirm.vue b/src/views-demo/form/step/components/step-confirm.vue
new file mode 100644
index 0000000..9a8c743
--- /dev/null
+++ b/src/views-demo/form/step/components/step-confirm.vue
@@ -0,0 +1,112 @@
+<template>
+  <a-form
+    ref="formRef"
+    :model="form"
+    :rules="rules"
+    class="ele-form-detail"
+    :label-col="styleResponsive ? { sm: 5, xs: 24 } : { flex: '130px' }"
+    :wrapper-col="styleResponsive ? { sm: 19, xs: 24 } : { flex: '1' }"
+  >
+    <a-alert
+      closable
+      show-icon
+      type="info"
+      message="确认转账后,资金将直接打入对方账户,无法退回。"
+    />
+    <a-form-item label="付款账户" style="margin-top: 24px">
+      {{ data.account }}
+    </a-form-item>
+    <a-form-item label="收款账户">{{ data.receiver }}</a-form-item>
+    <a-form-item label="收款人姓名">{{ data.name }}</a-form-item>
+    <a-form-item label="转账金额">
+      <span style="font-size: 24px; line-height: 1">
+        {{ data.amount }}
+      </span>
+      元
+    </a-form-item>
+    <a-divider style="margin: 20px 0 30px 0" />
+    <a-form-item label="支付密码" name="password">
+      <div style="max-width: 220px">
+        <a-input-password
+          v-model:value="form.password"
+          placeholder="请输入支付密码"
+        />
+      </div>
+    </a-form-item>
+    <a-form-item
+      :wrapper-col="styleResponsive ? { sm: { offset: 5 } } : { offset: 4 }"
+      style="margin-top: 24px"
+    >
+      <a-space size="middle">
+        <a-button type="primary" :loading="loading" @click="submit">
+          下一步
+        </a-button>
+        <a-button @click="back">上一步</a-button>
+      </a-space>
+    </a-form-item>
+  </a-form>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive } from 'vue';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import type { StepForm } from '../model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  defineProps<{
+    data: StepForm;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'done'): void;
+    (e: 'back'): void;
+  }>();
+
+  //
+  const formRef = ref<FormInstance | null>(null);
+
+  // 提交状态
+  const loading = ref(false);
+
+  // 表单数据
+  const form = reactive({
+    password: '123456'
+  });
+
+  // 表单验证规则
+  const rules = reactive<Record<string, Rule[]>>({
+    password: [
+      {
+        required: true,
+        message: '请输入支付密码',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ]
+  });
+
+  const submit = () => {
+    if (!formRef.value) {
+      return;
+    }
+    formRef.value
+      ?.validate()
+      .then(() => {
+        loading.value = true;
+        setTimeout(() => {
+          loading.value = false;
+          emit('done');
+        }, 300);
+      })
+      .catch(() => {});
+  };
+
+  const back = () => {
+    emit('back');
+  };
+</script>
diff --git a/src/views-demo/form/step/components/step-edit.vue b/src/views-demo/form/step/components/step-edit.vue
new file mode 100644
index 0000000..d017270
--- /dev/null
+++ b/src/views-demo/form/step/components/step-edit.vue
@@ -0,0 +1,144 @@
+<template>
+  <a-form
+    ref="formRef"
+    :model="form"
+    :rules="rules"
+    :label-col="styleResponsive ? { sm: 5, xs: 24 } : { flex: '130px' }"
+    :wrapper-col="styleResponsive ? { sm: 19, xs: 24 } : { flex: '1' }"
+  >
+    <a-form-item label="付款账户" name="account">
+      <a-select
+        allow-clear
+        v-model:value="form.account"
+        placeholder="请选择付款账户"
+      >
+        <a-select-option value="eleadmin@eclouds.com">
+          eleadmin@eclouds.com
+        </a-select-option>
+      </a-select>
+    </a-form-item>
+    <a-form-item label="收款账户" name="receiver">
+      <a-input
+        allow-clear
+        v-model:value="form.receiver"
+        placeholder="请输入收款账户"
+      >
+        <template #addonBefore>
+          <a-select
+            v-model:value="form.pay"
+            style="width: 100px; margin: -5px -12px"
+          >
+            <a-select-option value="alipay">支付宝</a-select-option>
+            <a-select-option value="wxpay">微信</a-select-option>
+          </a-select>
+        </template>
+      </a-input>
+    </a-form-item>
+    <a-form-item label="收款人姓名" name="name">
+      <a-input
+        allow-clear
+        v-model:value="form.name"
+        placeholder="请输入收款人姓名"
+      />
+    </a-form-item>
+    <a-form-item label="转账金额" name="amount">
+      <a-input
+        prefix="¥"
+        allow-clear
+        v-model:value.number="form.amount"
+        placeholder="请输入转账金额"
+      />
+    </a-form-item>
+    <a-form-item
+      :wrapper-col="styleResponsive ? { sm: { offset: 5 } } : { offset: 4 }"
+    >
+      <a-button type="primary" :loading="loading" @click="submit">
+        下一步
+      </a-button>
+    </a-form-item>
+  </a-form>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive } from 'vue';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import type { StepForm } from '../model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'done', data: StepForm): void;
+  }>();
+
+  //
+  const formRef = ref<FormInstance | null>(null);
+
+  // 提交状态
+  const loading = ref(false);
+
+  // 表单数据
+  const form = reactive<StepForm>({
+    account: 'eleadmin@eclouds.com',
+    receiver: 'test@example.com',
+    pay: 'alipay',
+    name: 'Alex',
+    amount: 500
+  });
+
+  // 表单验证规则
+  const rules = reactive<Record<string, Rule[]>>({
+    account: [
+      {
+        required: true,
+        message: '请选择付款账户',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    receiver: [
+      {
+        required: true,
+        message: '请输入收款账户',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    name: [
+      {
+        required: true,
+        message: '请输入收款人姓名',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    amount: [
+      {
+        required: true,
+        message: '请输入合法金额数字',
+        type: 'number',
+        trigger: 'blur'
+      }
+    ]
+  });
+
+  /* 步骤一提交 */
+  const submit = () => {
+    if (!formRef.value) {
+      return;
+    }
+    formRef.value
+      .validate()
+      .then(() => {
+        loading.value = true;
+        setTimeout(() => {
+          loading.value = false;
+          emit('done', form);
+        }, 300);
+      })
+      .catch(() => {});
+  };
+</script>
diff --git a/src/views-demo/form/step/components/step-success.vue b/src/views-demo/form/step/components/step-success.vue
new file mode 100644
index 0000000..427070f
--- /dev/null
+++ b/src/views-demo/form/step/components/step-success.vue
@@ -0,0 +1,49 @@
+<template>
+  <div>
+    <a-result title="操作成功" status="success" sub-title="预计两小时内到账">
+      <template #extra>
+        <a-space size="middle">
+          <a-button type="primary" @click="back"> 再转一笔 </a-button>
+          <a-button>查看账单</a-button>
+        </a-space>
+      </template>
+      <a-form
+        class="ele-form-detail"
+        :label-col="styleResponsive ? { sm: 5, xs: 24 } : { flex: '100px' }"
+        :wrapper-col="styleResponsive ? { sm: 19, xs: 24 } : { flex: '1' }"
+      >
+        <a-form-item label="付款账户">{{ data.account }}</a-form-item>
+        <a-form-item label="收款账户">{{ data.receiver }}</a-form-item>
+        <a-form-item label="收款人姓名">{{ data.name }}</a-form-item>
+        <a-form-item label="转账金额">
+          <span style="font-size: 24px; line-height: 1">
+            {{ data.amount }}
+          </span>
+          元
+        </a-form-item>
+      </a-form>
+    </a-result>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import type { StepForm } from '../model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  defineProps<{
+    data: StepForm;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'back'): void;
+  }>();
+
+  const back = () => {
+    emit('back');
+  };
+</script>
diff --git a/src/views-demo/form/step/index.vue b/src/views-demo/form/step/index.vue
new file mode 100644
index 0000000..d1a5408
--- /dev/null
+++ b/src/views-demo/form/step/index.vue
@@ -0,0 +1,94 @@
+<template>
+  <div>
+    <a-page-header :ghost="false" title="分步表单">
+      <div class="ele-text-secondary">
+        将一个冗长或用户不熟悉的表单任务分成多个步骤,指导用户完成。
+      </div>
+    </a-page-header>
+    <div class="ele-body">
+      <a-card :bordered="false">
+        <div style="max-width: 800px; margin: 0 auto">
+          <div style="margin: 10px 0 30px 0">
+            <a-steps
+              :current="active"
+              direction="horizontal"
+              :responsive="styleResponsive"
+            >
+              <a-step title="第一步" description="填写转账信息" />
+              <a-step title="第二步" description="确认转账信息" />
+              <a-step title="第三步" description="转账成功" />
+            </a-steps>
+          </div>
+          <step-edit v-if="active === 0" @done="onDone" />
+          <step-confirm
+            v-if="active === 1"
+            :data="form"
+            @done="onNext"
+            @back="onBack"
+          />
+          <step-success v-if="active === 2" :data="form" @back="onBack" />
+        </div>
+        <div v-if="active === 0">
+          <a-divider style="margin: 35px 0 25px 0" />
+          <a-alert type="info">
+            <template #description>
+              <h6 style="margin: 5px 0 15px 0">说明</h6>
+              <h6 style="margin-bottom: 10px">转账到支付宝</h6>
+              <p style="margin-bottom: 15px">
+                如果需要,这里可以放一些关于产品的常见问题说明。如果需要,
+                这里可以放一些关于产品的常见问题说明。如果需要,这里可以放一些关于产品的常见问题说明。
+              </p>
+              <h6 style="margin-bottom: 10px">转账到微信</h6>
+              <p style="margin-bottom: 15px">
+                如果需要,这里可以放一些关于产品的常见问题说明。如果需要,
+                这里可以放一些关于产品的常见问题说明。如果需要,这里可以放一些关于产品的常见问题说明。
+              </p>
+            </template>
+          </a-alert>
+        </div>
+      </a-card>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive } from 'vue';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import StepEdit from './components/step-edit.vue';
+  import StepConfirm from './components/step-confirm.vue';
+  import StepSuccess from './components/step-success.vue';
+  import type { StepForm } from './model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  // 选中步骤
+  const active = ref(0);
+
+  //
+  const form = reactive<StepForm>({});
+
+  //
+  const onDone = (data: StepForm) => {
+    Object.assign(form, data);
+    active.value = 1;
+  };
+
+  //
+  const onNext = () => {
+    active.value = 2;
+  };
+
+  //
+  const onBack = () => {
+    active.value = 0;
+  };
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'FormStep'
+  };
+</script>
diff --git a/src/views-demo/form/step/model/index.ts b/src/views-demo/form/step/model/index.ts
new file mode 100644
index 0000000..725d712
--- /dev/null
+++ b/src/views-demo/form/step/model/index.ts
@@ -0,0 +1,7 @@
+export interface StepForm {
+  account?: string;
+  receiver?: string;
+  pay?: string;
+  name?: string;
+  amount?: number;
+}
diff --git a/src/views-demo/list/advanced/index.vue b/src/views-demo/list/advanced/index.vue
new file mode 100644
index 0000000..ce2e6d2
--- /dev/null
+++ b/src/views-demo/list/advanced/index.vue
@@ -0,0 +1,488 @@
+<template>
+  <div
+    :class="[
+      'ele-body ele-body-card',
+      { 'list-adv-responsive': styleResponsive }
+    ]"
+  >
+    <a-card :bordered="false">
+      <a-row>
+        <a-col
+          v-bind="styleResponsive ? { md: 8, sm: 24, xs: 24 } : { span: 8 }"
+        >
+          <div class="ele-text-center">
+            <div style="margin-bottom: 8px">进行中的任务</div>
+            <h2>10 个任务</h2>
+          </div>
+        </a-col>
+        <a-col
+          v-bind="styleResponsive ? { md: 8, sm: 24, xs: 24 } : { span: 8 }"
+        >
+          <div class="ele-text-center">
+            <div style="margin-bottom: 8px">剩余任务</div>
+            <h2>3 个任务</h2>
+          </div>
+        </a-col>
+        <a-col
+          v-bind="styleResponsive ? { md: 8, sm: 24, xs: 24 } : { span: 8 }"
+        >
+          <div class="ele-text-center">
+            <div style="margin-bottom: 8px">任务总耗时</div>
+            <h2>120 个小时</h2>
+          </div>
+        </a-col>
+      </a-row>
+    </a-card>
+    <a-card :bordered="false">
+      <!-- 头部工具栏 -->
+      <ele-toolbar title="复杂列表">
+        <template #action>
+          <a-space size="middle">
+            <a-radio-group v-model:value="where.state" @change="query">
+              <a-radio-button value="0">全部</a-radio-button>
+              <a-radio-button value="1">进行中</a-radio-button>
+              <a-radio-button value="2">已完成</a-radio-button>
+            </a-radio-group>
+            <div
+              style="width: 200px"
+              :class="{ 'hidden-sm-and-down': styleResponsive }"
+            >
+              <a-input-search
+                v-model:value="where.keyword"
+                placeholder="请输入"
+                @search="query"
+              />
+            </div>
+          </a-space>
+        </template>
+      </ele-toolbar>
+      <a-button block type="dashed" @click="openEdit()">
+        <template #icon>
+          <plus-outlined />
+        </template>
+        <span>添加</span>
+      </a-button>
+      <!-- 数据列表 -->
+      <a-spin :spinning="loading">
+        <div style="min-height: 100px">
+          <div v-for="item in data" :key="item.id">
+            <div class="basic-list-item">
+              <div class="ele-cell">
+                <a-avatar :size="60" shape="square" :src="item.cover" />
+                <div class="ele-cell-content">
+                  <div class="ele-cell-title">{{ item.title }}</div>
+                  <div class="ele-cell-desc">{{ item.content }}</div>
+                </div>
+              </div>
+              <div class="basic-list-item-owner">
+                <div class="ele-text-heading">发布人</div>
+                <div class="ele-text-secondary">{{ item.user }}</div>
+              </div>
+              <div class="basic-list-item-time">
+                <div class="ele-text-heading">开始时间</div>
+                <div class="ele-text-secondary">{{ item.time }}</div>
+              </div>
+              <div class="basic-list-item-progress">
+                <a-progress :status="item.status" :percent="item.progress" />
+              </div>
+              <div class="basic-list-item-tool">
+                <a-space>
+                  <a @click="openEdit(item)">编辑</a>
+                  <a-divider type="vertical" />
+                  <a-dropdown>
+                    <a>更多<down-outlined class="ele-text-small" /></a>
+                    <template #overlay>
+                      <a-menu @click="(obj: any) => dropClick(obj.key, item)">
+                        <a-menu-item key="share">分享</a-menu-item>
+                        <a-menu-item key="remove">删除</a-menu-item>
+                      </a-menu>
+                    </template>
+                  </a-dropdown>
+                </a-space>
+              </div>
+            </div>
+            <a-divider />
+          </div>
+        </div>
+        <div class="ele-text-center" style="margin-top: 18px">
+          <a-pagination
+            :total="count"
+            v-model:page-size="limit"
+            show-quick-jumper
+            v-model:current="page"
+            @change="query"
+          />
+        </div>
+      </a-spin>
+    </a-card>
+    <!-- 编辑弹窗 -->
+    <ele-modal
+      :width="460"
+      v-model:visible="visible"
+      :confirm-loading="submitLoading"
+      :title="form.id ? '任务编辑' : '任务添加'"
+      :body-style="{ paddingBottom: '8px' }"
+      @ok="submit"
+    >
+      <a-form
+        ref="formRef"
+        :model="form"
+        :rules="rules"
+        :label-col="
+          styleResponsive ? { md: 5, sm: 5, xs: 24 } : { flex: '90px' }
+        "
+        :wrapper-col="
+          styleResponsive ? { md: 19, sm: 19, xs: 24 } : { flex: '1' }
+        "
+      >
+        <a-form-item label="任务名称:" name="title">
+          <a-input
+            allow-clear
+            v-model:value="form.title"
+            placeholder="请输入任务名称"
+          />
+        </a-form-item>
+        <a-form-item label="开始时间:" name="time">
+          <a-date-picker
+            show-time
+            class="ele-fluid"
+            v-model:value="form.time"
+            placeholder="请选择开始时间"
+            value-format="YYYY-MM-DD HH:mm:ss"
+          />
+        </a-form-item>
+        <a-form-item label="负责人:" name="user">
+          <a-select
+            allow-clear
+            v-model:value="form.user"
+            placeholder="请选择负责人"
+          >
+            <a-select-option value="SunSmile">SunSmile</a-select-option>
+            <a-select-option value="Pojin">Pojin</a-select-option>
+            <a-select-option value="SuperWill">SuperWill</a-select-option>
+            <a-select-option value="Jasmine">Jasmine</a-select-option>
+            <a-select-option value="Vast">Vast</a-select-option>
+          </a-select>
+        </a-form-item>
+        <a-form-item label="任务描述:">
+          <a-textarea
+            :rows="4"
+            v-model:value="form.content"
+            placeholder="请输入任务描述"
+          />
+        </a-form-item>
+      </a-form>
+    </ele-modal>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive, createVNode } from 'vue';
+  import { message, Modal } from 'ant-design-vue/es';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import {
+    PlusOutlined,
+    DownOutlined,
+    ExclamationCircleOutlined
+  } from '@ant-design/icons-vue';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+
+  interface ListItem {
+    id?: number;
+    title?: string;
+    time?: string;
+    user?: string;
+    progress?: number;
+    content?: string;
+    cover?: string;
+    status?: 'normal' | 'active' | 'success' | 'exception';
+  }
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  //
+  const formRef = ref<FormInstance | null>(null);
+
+  // 列表加载状态
+  const loading = ref(false);
+
+  // 列表数据
+  const data = ref<ListItem[]>([]);
+
+  // 搜索表单
+  const where = reactive({
+    state: '0',
+    keyword: ''
+  });
+
+  // 第几页
+  const page = ref(1);
+
+  // 每页多少条
+  const limit = ref(5);
+
+  // 总数量
+  const count = ref(0);
+
+  // 编辑弹窗是否显示
+  const visible = ref(false);
+
+  // 编辑弹窗表单数据
+  const { form, resetFields, assignFields } = useFormData<ListItem>({
+    id: undefined,
+    title: 'Vue Router',
+    time: undefined,
+    user: '',
+    content: ''
+  });
+
+  // 编辑弹窗表单验证规则
+  const rules = reactive<Record<string, Rule[]>>({
+    title: [
+      {
+        required: true,
+        message: '请输入任务名称',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    time: [
+      {
+        required: true,
+        message: '请选择开始时间',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    user: [
+      {
+        required: true,
+        message: '请选择负责人',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ]
+  });
+
+  // 编辑表单提交状态
+  const submitLoading = ref(false);
+
+  /* 查询数据 */
+  const query = () => {
+    loading.value = true;
+    setTimeout(() => {
+      loading.value = false;
+      count.value = 25;
+      data.value = [
+        {
+          id: 1,
+          title: 'ElementUI',
+          time: '2020-06-13 08:33:12',
+          user: 'SunSmile',
+          progress: 87,
+          content:
+            'Element,一套为开发者、设计师和产品经理准备的基于 Vue 2.0 的组件库,提供了配套设计资源,帮助你的网站快速成型。',
+          cover:
+            'https://cdn.eleadmin.com/20200609/c184eef391ae48dba87e3057e70238fb.jpg'
+        },
+        {
+          id: 2,
+          title: 'Vue.js',
+          time: '2020-06-13 06:40:13',
+          user: 'Pojin',
+          progress: 100,
+          content:
+            'Vue 是一套用于构建用户界面的渐进式框架。与其它大型框架不同的是,Vue 被设计为可以自底向上逐层应用。',
+          cover:
+            'https://cdn.eleadmin.com/20200609/b6a811873e704db49db994053a5019b2.jpg'
+        },
+        {
+          id: 3,
+          title: 'Vuex',
+          time: '2020-06-13 04:40:20',
+          user: 'SuperWill',
+          progress: 75,
+          content:
+            'Vuex 是一个专为 Vue.js 应用程序开发的状态管理模式。它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化。',
+          cover:
+            'https://cdn.eleadmin.com/20200609/948344a2a77c47a7a7b332fe12ff749a.jpg'
+        },
+        {
+          id: 4,
+          title: 'Vue Router',
+          time: '2020-06-13 02:40:05',
+          user: 'Jasmine',
+          progress: 65,
+          content:
+            'Vue Router 是 Vue.js 官方的路由管理器。它和 Vue.js 的核心深度集成,让构建单页面应用变得易如反掌。',
+          cover:
+            'https://cdn.eleadmin.com/20200609/f6bc05af944a4f738b54128717952107.jpg'
+        },
+        {
+          id: 5,
+          title: 'Sass',
+          time: '2020-06-13 00:40:58',
+          user: 'Vast',
+          progress: 45,
+          status: 'exception',
+          content: 'Sass 是世界上最成熟、稳定、强大的专业级 CSS 扩展语言。',
+          cover:
+            'https://cdn.eleadmin.com/20200609/2d98970a51b34b6b859339c96b240dcd.jpg'
+        }
+      ];
+    }, 300);
+  };
+
+  /* 显示编辑弹窗 */
+  const openEdit = (row?: ListItem) => {
+    visible.value = true;
+    resetFields();
+    formRef.value?.clearValidate();
+    if (row) {
+      assignFields(row);
+    }
+  };
+
+  /* 保存编辑 */
+  const submit = () => {
+    if (!formRef.value) {
+      return;
+    }
+    formRef.value
+      .validate()
+      .then(() => {
+        submitLoading.value = true;
+        setTimeout(() => {
+          submitLoading.value = false;
+          visible.value = false;
+          message.success('保存成功');
+          if (form.id) {
+            // 保存修改
+            Object.assign(
+              data.value[data.value.findIndex((d) => d.id === form.id)],
+              form
+            );
+          } else {
+            // 保存添加
+            data.value.push({
+              ...form,
+              id: new Date().getTime(),
+              cover:
+                'https://cdn.eleadmin.com/20200610/RZ8FQmZfHkcffMlTBCJllBFjEhEsObVo.jpg'
+            });
+          }
+        }, 300);
+      })
+      .catch(() => {});
+  };
+
+  /* 下拉菜单点击事件 */
+  const dropClick = (key: string, item: ListItem) => {
+    console.log(item);
+    if (key === 'remove') {
+      // 删除
+      Modal.confirm({
+        title: '提示',
+        content: '确定删除该任务吗?',
+        icon: createVNode(ExclamationCircleOutlined),
+        maskClosable: true,
+        onOk: () => {
+          message.success('删除成功');
+        }
+      });
+    } else if (key === 'share') {
+      message.success('点击了分享');
+    }
+  };
+
+  query();
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'ListAdvanced'
+  };
+</script>
+
+<style lang="less" scoped>
+  /* 列表样式 */
+  .basic-list-item {
+    display: flex;
+    align-items: center;
+    padding: 16px 8px;
+
+    & > .ele-cell {
+      flex: 1;
+    }
+
+    & > div + div {
+      margin-left: 20px;
+      flex-shrink: 0;
+    }
+
+    .basic-list-item-owner {
+      width: 80px;
+    }
+
+    .basic-list-item-time {
+      width: 160px;
+    }
+
+    .basic-list-item-progress {
+      width: 180px;
+    }
+
+    .ele-text-heading + .ele-text-secondary {
+      margin-top: 8px;
+    }
+  }
+
+  /* 响应式 */
+  @media screen and (max-width: 1340px) {
+    .basic-list-item {
+      & > div + div {
+        margin-left: 10px;
+      }
+
+      .basic-list-item-owner {
+        width: 70px;
+      }
+
+      .basic-list-item-time {
+        width: 140px;
+      }
+
+      .basic-list-item-progress {
+        width: 100px;
+      }
+    }
+  }
+
+  @media screen and (max-width: 1100px) {
+    .list-adv-responsive .basic-list-item {
+      display: block;
+
+      .basic-list-item-owner,
+      .basic-list-item-time,
+      .basic-list-item-progress {
+        width: auto;
+        margin: 8px 0 0 0;
+        display: flex;
+        align-items: center;
+      }
+
+      .basic-list-item-tool {
+        margin-top: 8px;
+        text-align: right;
+      }
+
+      .ele-text-heading + .ele-text-secondary {
+        margin: 0 0 0 16px;
+      }
+    }
+  }
+</style>
diff --git a/src/views-demo/list/basic/add/index.vue b/src/views-demo/list/basic/add/index.vue
new file mode 100644
index 0000000..147393f
--- /dev/null
+++ b/src/views-demo/list/basic/add/index.vue
@@ -0,0 +1,17 @@
+<template>
+  <div class="ele-body">
+    <a-card :bordered="false">
+      <edit-form />
+    </a-card>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import EditForm from '../components/edit-form.vue';
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'ListBasicAdd'
+  };
+</script>
diff --git a/src/views-demo/list/basic/components/edit-form.vue b/src/views-demo/list/basic/components/edit-form.vue
new file mode 100644
index 0000000..8a6837d
--- /dev/null
+++ b/src/views-demo/list/basic/components/edit-form.vue
@@ -0,0 +1,264 @@
+<template>
+  <a-form
+    ref="formRef"
+    :model="form"
+    :rules="rules"
+    :label-col="styleResponsive ? { sm: 4, xs: 24 } : { flex: '100px' }"
+    :wrapper-col="styleResponsive ? { sm: 20, xs: 24 } : { flex: '1' }"
+    style="max-width: 600px; margin: 0 auto"
+  >
+    <a-form-item label="用户账号" name="username">
+      <a-input
+        allow-clear
+        :maxlength="20"
+        placeholder="请输入用户账号"
+        v-model:value="form.username"
+      />
+    </a-form-item>
+    <a-form-item label="用户名" name="nickname">
+      <a-input
+        allow-clear
+        :maxlength="20"
+        placeholder="请输入用户名"
+        v-model:value="form.nickname"
+      />
+    </a-form-item>
+    <a-form-item label="性别" name="sex">
+      <sex-select v-model:value="form.sex" />
+    </a-form-item>
+    <a-form-item label="角色" name="roles">
+      <role-select v-model:value="form.roles" />
+    </a-form-item>
+    <a-form-item label="邮箱" name="email">
+      <a-input
+        allow-clear
+        :maxlength="100"
+        placeholder="请输入邮箱"
+        v-model:value="form.email"
+      />
+    </a-form-item>
+    <a-form-item label="手机号" name="phone">
+      <a-input
+        allow-clear
+        :maxlength="11"
+        placeholder="请输入手机号"
+        v-model:value="form.phone"
+      />
+    </a-form-item>
+    <a-form-item label="出生日期">
+      <a-date-picker
+        class="ele-fluid"
+        placeholder="请选择出生日期"
+        value-format="YYYY-MM-DD"
+        v-model:value="form.birthday"
+      />
+    </a-form-item>
+    <a-form-item v-if="!isUpdate" label="登录密码" name="password">
+      <a-input-password
+        :maxlength="20"
+        v-model:value="form.password"
+        placeholder="请输入登录密码"
+      />
+    </a-form-item>
+    <a-form-item label="个人简介">
+      <a-textarea
+        :rows="4"
+        :maxlength="200"
+        placeholder="请输入个人简介"
+        v-model:value="form.introduction"
+      />
+    </a-form-item>
+    <a-form-item
+      :wrapper-col="styleResponsive ? { sm: { offset: 4 } } : { offset: 4 }"
+    >
+      <a-space size="middle">
+        <a-button @click="onClose">关闭</a-button>
+        <a-button type="primary" :loading="loading" @click="save">
+          保存
+        </a-button>
+      </a-space>
+    </a-form-item>
+  </a-form>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive, watch, unref } from 'vue';
+  import { useRouter } from 'vue-router';
+  import { message } from 'ant-design-vue/es';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import { emailReg, phoneReg } from 'ele-admin-pro/es';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import RoleSelect from '@/views/system/user/components/role-select.vue';
+  import SexSelect from '@/views/system/user/components/sex-select.vue';
+  import { addUser, updateUser, checkExistence } from '@/api/system/user';
+  import type { User } from '@/api/system/user/model';
+  import { removePageTab, reloadPageTab } from '@/utils/page-tab-util';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const props = defineProps<{
+    // 修改回显的数据
+    data?: User | null;
+  }>();
+
+  const { currentRoute, push } = useRouter();
+
+  //
+  const formRef = ref<FormInstance | null>(null);
+
+  // 是否是修改
+  const isUpdate = ref(false);
+
+  // 提交状态
+  const loading = ref(false);
+
+  // 表单数据
+  const { form, resetFields, assignFields } = useFormData<User>({
+    userId: undefined,
+    username: '',
+    nickname: '',
+    sex: undefined,
+    roles: [],
+    email: '',
+    phone: '',
+    password: '',
+    introduction: '',
+    birthday: ''
+  });
+
+  // 表单验证规则
+  const rules = reactive<Record<string, Rule[]>>({
+    username: [
+      {
+        required: true,
+        type: 'string',
+        validator: (_rule: Rule, value: string) => {
+          return new Promise<void>((resolve, reject) => {
+            if (!value) {
+              return reject('请输入用户账号');
+            }
+            checkExistence('username', value, form.userId)
+              .then(() => {
+                reject('账号已经存在');
+              })
+              .catch(() => {
+                resolve();
+              });
+          });
+        },
+        trigger: 'blur'
+      }
+    ],
+    nickname: [
+      {
+        required: true,
+        message: '请输入用户名',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    sex: [
+      {
+        required: true,
+        message: '请选择性别',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    roles: [
+      {
+        required: true,
+        message: '请选择角色',
+        type: 'array',
+        trigger: 'blur'
+      }
+    ],
+    email: [
+      {
+        pattern: emailReg,
+        message: '邮箱格式不正确',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    password: [
+      {
+        required: true,
+        type: 'string',
+        validator: async (_rule: Rule, value: string) => {
+          if (isUpdate.value || /^[\S]{5,18}$/.test(value)) {
+            return Promise.resolve();
+          }
+          return Promise.reject('密码必须为5-18位非空白字符');
+        },
+        trigger: 'blur'
+      }
+    ],
+    phone: [
+      {
+        pattern: phoneReg,
+        message: '手机号格式不正确',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ]
+  });
+
+  /* 保存编辑 */
+  const save = () => {
+    if (!formRef.value) {
+      return;
+    }
+    formRef.value
+      .validate()
+      .then(() => {
+        loading.value = true;
+        const saveOrUpdate = isUpdate.value ? updateUser : addUser;
+        saveOrUpdate(form)
+          .then((msg) => {
+            loading.value = false;
+            message.success(msg);
+            onDone();
+          })
+          .catch((e) => {
+            loading.value = false;
+            message.error(e.message);
+          });
+      })
+      .catch(() => {});
+  };
+
+  /* 关闭当前页面并跳转到列表页面 */
+  const onClose = () => {
+    removePageTab({ key: unref(currentRoute).path });
+    push('/list/basic');
+  };
+
+  /* 关闭当前页面并刷新列表页面 */
+  const onDone = () => {
+    removePageTab({ key: unref(currentRoute).path });
+    reloadPageTab({ fullPath: '/list/basic' });
+  };
+
+  watch(
+    () => props.data,
+    () => {
+      if (props.data) {
+        assignFields({
+          ...props.data,
+          password: ''
+        });
+        isUpdate.value = true;
+      } else {
+        isUpdate.value = false;
+        resetFields();
+        formRef.value?.clearValidate();
+      }
+    },
+    { immediate: true }
+  );
+</script>
diff --git a/src/views-demo/list/basic/components/nickname-filter.vue b/src/views-demo/list/basic/components/nickname-filter.vue
new file mode 100644
index 0000000..2aedab1
--- /dev/null
+++ b/src/views-demo/list/basic/components/nickname-filter.vue
@@ -0,0 +1,57 @@
+<!-- 自定义表格筛选dropdown的内容 -->
+<template>
+  <div style="padding: 8px">
+    <div style="margin-bottom: 8px">
+      <a-input
+        placeholder="请输入关键字"
+        v-model:value="nickname"
+        @pressEnter="search"
+      />
+    </div>
+    <a-space>
+      <a-button size="small" type="primary" @click="search">
+        <template #icon>
+          <search-outlined />
+        </template>
+        <span>搜索</span>
+      </a-button>
+      <a-button size="small" style="min-width: 66px" @click="reset">
+        重置
+      </a-button>
+    </a-space>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import { SearchOutlined } from '@ant-design/icons-vue';
+
+  const emit = defineEmits<{
+    (e: 'search', nickname: string): void;
+  }>();
+
+  const props = defineProps<{
+    // 设置筛选选中的方法
+    setSelectedKeys: (value: any[]) => void;
+    // 筛选确认的方法
+    confirm: () => void;
+    // 清除筛选的方法
+    clearFilters: () => void;
+  }>();
+
+  const nickname = ref('');
+
+  /* 搜索 */
+  const search = () => {
+    props.setSelectedKeys(nickname.value ? [nickname.value] : []);
+    props.confirm();
+    emit('search', nickname.value);
+  };
+
+  /*  重置 */
+  const reset = () => {
+    nickname.value = '';
+    props.clearFilters();
+    search();
+  };
+</script>
diff --git a/src/views-demo/list/basic/components/search-form.vue b/src/views-demo/list/basic/components/search-form.vue
new file mode 100644
index 0000000..3555196
--- /dev/null
+++ b/src/views-demo/list/basic/components/search-form.vue
@@ -0,0 +1,165 @@
+<!-- 搜索表单 -->
+<template>
+  <a-card :bordered="false" :body-style="{ paddingBottom: 0 }">
+    <a-form
+      :label-col="
+        styleResponsive ? { xl: 7, lg: 5, md: 7, sm: 4 } : { flex: '90px' }
+      "
+      :wrapper-col="
+        styleResponsive ? { xl: 17, lg: 19, md: 17, sm: 20 } : { flex: '1' }
+      "
+    >
+      <a-row :gutter="8">
+        <a-col
+          v-bind="
+            styleResponsive
+              ? { xl: 8, lg: 12, md: 12, sm: 24, xs: 24 }
+              : { span: 8 }
+          "
+        >
+          <a-form-item label="用户账号">
+            <a-input
+              v-model:value.trim="form.username"
+              placeholder="请输入"
+              allow-clear
+            />
+          </a-form-item>
+        </a-col>
+        <a-col
+          v-bind="
+            styleResponsive
+              ? { xl: 8, lg: 12, md: 12, sm: 24, xs: 24 }
+              : { span: 8 }
+          "
+        >
+          <a-form-item label="性别">
+            <a-select v-model:value="form.sex" placeholder="请选择" allow-clear>
+              <a-select-option value="1">男</a-select-option>
+              <a-select-option value="2">女</a-select-option>
+            </a-select>
+          </a-form-item>
+        </a-col>
+        <a-col
+          v-if="searchExpand"
+          v-bind="
+            styleResponsive
+              ? { xl: 8, lg: 12, md: 12, sm: 24, xs: 24 }
+              : { span: 8 }
+          "
+        >
+          <a-form-item label="用户名">
+            <a-input
+              v-model:value.trim="form.nickname"
+              placeholder="请输入"
+              allow-clear
+            />
+          </a-form-item>
+        </a-col>
+        <a-col
+          v-if="searchExpand"
+          v-bind="
+            styleResponsive
+              ? { xl: 8, lg: 12, md: 12, sm: 24, xs: 24 }
+              : { span: 8 }
+          "
+        >
+          <a-form-item label="手机号">
+            <a-input
+              v-model:value.trim="form.phone"
+              placeholder="请输入"
+              allow-clear
+            />
+          </a-form-item>
+        </a-col>
+        <a-col
+          v-if="searchExpand"
+          v-bind="
+            styleResponsive
+              ? { xl: 8, lg: 12, md: 12, sm: 24, xs: 24 }
+              : { span: 8 }
+          "
+        >
+          <a-form-item label="状态">
+            <a-select
+              v-model:value="form.status"
+              placeholder="请选择"
+              allow-clear
+            >
+              <a-select-option :value="0">正常</a-select-option>
+              <a-select-option :value="1">冻结</a-select-option>
+            </a-select>
+          </a-form-item>
+        </a-col>
+        <a-col
+          v-bind="
+            styleResponsive
+              ? { xl: 8, lg: 12, md: 12, sm: 24, xs: 24 }
+              : { span: 8 }
+          "
+        >
+          <a-form-item class="ele-text-right" :wrapper-col="{ span: 24 }">
+            <a-space>
+              <a-button type="primary" @click="search">查询</a-button>
+              <a-button @click="reset">重置</a-button>
+              <a @click="toggleExpand">
+                <span v-if="searchExpand">
+                  收起 <up-outlined class="ele-text-small" />
+                </span>
+                <span v-else>
+                  展开 <down-outlined class="ele-text-small" />
+                </span>
+              </a>
+            </a-space>
+          </a-form-item>
+        </a-col>
+      </a-row>
+    </a-form>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import { DownOutlined, UpOutlined } from '@ant-design/icons-vue';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import type { UserParam } from '@/api/system/user/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'search', where?: UserParam): void;
+    (e: 'expand-change', expand: boolean): void;
+  }>();
+
+  // 表单数据
+  const { form, resetFields } = useFormData<UserParam>({
+    username: '',
+    nickname: '',
+    sex: undefined,
+    phone: '',
+    status: undefined
+  });
+
+  // 搜索表单是否展开
+  const searchExpand = ref(false);
+
+  /* 搜索 */
+  const search = () => {
+    emit('search', form);
+  };
+
+  /*  重置 */
+  const reset = () => {
+    resetFields();
+    search();
+  };
+
+  /* 搜索展开/收起 */
+  const toggleExpand = () => {
+    searchExpand.value = !searchExpand.value;
+    emit('expand-change', searchExpand.value);
+  };
+</script>
diff --git a/src/views-demo/list/basic/details/index.vue b/src/views-demo/list/basic/details/index.vue
new file mode 100644
index 0000000..da49e55
--- /dev/null
+++ b/src/views-demo/list/basic/details/index.vue
@@ -0,0 +1,121 @@
+<template>
+  <div class="ele-body">
+    <a-card title="基本信息" :bordered="false">
+      <a-form
+        class="ele-form-detail"
+        :label-col="
+          styleResponsive ? { md: 2, sm: 4, xs: 6 } : { flex: '90px' }
+        "
+        :wrapper-col="
+          styleResponsive ? { md: 22, sm: 20, xs: 18 } : { flex: '1' }
+        "
+      >
+        <a-form-item label="账号">
+          <div class="ele-text-secondary">{{ form.username }}</div>
+        </a-form-item>
+        <a-form-item label="用户名">
+          <div class="ele-text-secondary">{{ form.nickname }}</div>
+        </a-form-item>
+        <a-form-item label="性别">
+          <div class="ele-text-secondary">{{ form.sexName }}</div>
+        </a-form-item>
+        <a-form-item label="手机号">
+          <div class="ele-text-secondary">{{ form.phone }}</div>
+        </a-form-item>
+        <a-form-item label="角色">
+          <a-tag v-for="item in form.roles" :key="item.roleId" color="blue">
+            {{ item.roleName }}
+          </a-tag>
+        </a-form-item>
+        <a-form-item label="创建时间">
+          <div class="ele-text-secondary">{{ form.createTime }}</div>
+        </a-form-item>
+        <a-form-item label="状态">
+          <a-badge
+            v-if="typeof form.status === 'number'"
+            :status="(['processing', 'error'][form.status] as any)"
+            :text="['正常', '冻结'][form.status]"
+          />
+        </a-form-item>
+      </a-form>
+    </a-card>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref, watch, unref } from 'vue';
+  import { useRouter } from 'vue-router';
+  import { message } from 'ant-design-vue/es';
+  import { toDateString } from 'ele-admin-pro/es';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import { setPageTabTitle } from '@/utils/page-tab-util';
+  import { getUser } from '@/api/system/user';
+  import type { User } from '@/api/system/user/model';
+  const ROUTE_PATH = '/list/basic/details';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const { currentRoute } = useRouter();
+
+  // 用户信息
+  const { form, assignFields } = useFormData<User>({
+    userId: undefined,
+    username: '',
+    nickname: '',
+    sexName: '',
+    phone: '',
+    roles: [],
+    createTime: undefined,
+    status: undefined
+  });
+
+  // 请求状态
+  const loading = ref(true);
+
+  const query = () => {
+    const { params } = unref(currentRoute);
+    const id = params.id;
+    if (!id || form.userId === Number(id)) {
+      return;
+    }
+    loading.value = true;
+    getUser(Number(id))
+      .then((data) => {
+        loading.value = false;
+        assignFields({
+          ...data,
+          createTime: toDateString(data.createTime)
+        });
+        // 修改页签标题
+        if (unref(currentRoute).path.startsWith(ROUTE_PATH)) {
+          setPageTabTitle(data.nickname + '的信息');
+        }
+      })
+      .catch((e) => {
+        loading.value = false;
+        message.error(e.message);
+      });
+  };
+
+  watch(
+    currentRoute,
+    (route) => {
+      const { fullPath } = unref(route);
+      if (!fullPath.startsWith(ROUTE_PATH)) {
+        return;
+      }
+      query();
+    },
+    { immediate: true }
+  );
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'ListBasicDetails'
+  };
+</script>
diff --git a/src/views-demo/list/basic/edit/index.vue b/src/views-demo/list/basic/edit/index.vue
new file mode 100644
index 0000000..edd1940
--- /dev/null
+++ b/src/views-demo/list/basic/edit/index.vue
@@ -0,0 +1,49 @@
+<template>
+  <div class="ele-body">
+    <a-card :bordered="false">
+      <a-spin :spinning="loading">
+        <edit-form :data="user" />
+      </a-spin>
+    </a-card>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref, unref } from 'vue';
+  import { useRouter } from 'vue-router';
+  import { message } from 'ant-design-vue/es';
+  import EditForm from '../components/edit-form.vue';
+  import { getUser } from '@/api/system/user';
+  import type { User } from '@/api/system/user/model';
+
+  const { currentRoute } = useRouter();
+
+  // 查询状态
+  const loading = ref(true);
+
+  // 用户信息
+  const user = ref<User>();
+
+  /* 查询用户信息 */
+  const query = () => {
+    const { query } = unref(currentRoute);
+    if (query.id) {
+      getUser(Number(query.id))
+        .then((data) => {
+          loading.value = false;
+          user.value = data;
+        })
+        .catch((e) => {
+          message.error(e.message);
+        });
+    }
+  };
+
+  query();
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'ListBasicEdit'
+  };
+</script>
diff --git a/src/views-demo/list/basic/index.vue b/src/views-demo/list/basic/index.vue
new file mode 100644
index 0000000..e544a6b
--- /dev/null
+++ b/src/views-demo/list/basic/index.vue
@@ -0,0 +1,433 @@
+<template>
+  <div class="ele-body ele-body-card">
+    <!-- 搜索表单 -->
+    <search-form @search="reload" @expand-change="onExpandChange" />
+    <a-card :bordered="false">
+      <!-- 提示信息 -->
+      <a-alert type="info" show-icon style="margin-bottom: 16px">
+        <template #message>
+          <span>
+            已选择
+            <b class="ele-text-primary">{{ selection.length }}</b>
+            项数据<em></em>
+          </span>
+          <span>
+            其中冻结状态的用户有
+            <b>{{ selection.filter((d) => d.status === 1).length }} 个</b>
+            <em></em><em></em>
+          </span>
+          <a @click="clearChoose">清空</a>
+        </template>
+      </a-alert>
+      <!-- 表格 -->
+      <ele-pro-table
+        ref="tableRef"
+        row-key="userId"
+        title="基础列表"
+        :resizable="true"
+        :bordered="bordered"
+        :striped="striped"
+        :tools-theme="toolDefault ? 'default' : 'none'"
+        :height="tableHeight"
+        :full-height="fixedHeight ? 'calc(100vh - 168px)' : void 0"
+        :columns="columns"
+        :datasource="datasource"
+        v-model:selection="selection"
+        :custom-row="customRow"
+        :scroll="{ x: 1000 }"
+        :row-selection="{ columnWidth: 38 }"
+        cache-key="proListBasicTable"
+        @done="onDone"
+      >
+        <!-- 表头工具按钮 -->
+        <template #toolkit>
+          <a-space size="middle" style="flex-wrap: wrap">
+            <div class="list-tool-item">
+              <span>边框</span>
+              <a-switch v-model:checked="bordered" size="small" />
+            </div>
+            <a-divider type="vertical" />
+            <div class="list-tool-item">
+              <span>斑马线</span>
+              <a-switch v-model:checked="striped" size="small" />
+            </div>
+            <a-divider type="vertical" />
+            <div class="list-tool-item">
+              <span>表头背景</span>
+              <a-switch v-model:checked="toolDefault" size="small" />
+            </div>
+            <a-divider type="vertical" />
+            <div class="list-tool-item">
+              <span>高度铺满</span>
+              <a-switch v-model:checked="fixedHeight" size="small" />
+            </div>
+            <a-divider type="vertical" />
+            <a-button type="primary" class="ele-btn-icon" @click="openEdit()">
+              <template #icon>
+                <plus-outlined />
+              </template>
+              <span>新建</span>
+            </a-button>
+            <a-dropdown :disabled="!selection.length">
+              <a-button class="ele-btn-icon">
+                <span>批量操作 <down-outlined class="ele-text-small" /></span>
+              </a-button>
+              <template #overlay>
+                <a-menu @click="onDropClick">
+                  <a-menu-item key="del">批量删除</a-menu-item>
+                  <a-menu-item key="edit">批量修改</a-menu-item>
+                </a-menu>
+              </template>
+            </a-dropdown>
+            <a-divider type="vertical" />
+          </a-space>
+        </template>
+        <!-- 自定义列 -->
+        <template #bodyCell="{ column, record }">
+          <!-- 头像列 -->
+          <template v-if="column.key === 'avatar'">
+            <a-avatar
+              v-if="record.avatar"
+              :src="record.avatar"
+              :size="32"
+              @click.stop=""
+            />
+            <a-avatar v-else class="ele-bg-primary" :size="32" @click.stop="">
+              {{
+                record.nickname && record.nickname.length > 2
+                  ? record.nickname.substring(record.nickname.length - 2)
+                  : record.nickname
+              }}
+            </a-avatar>
+          </template>
+          <!-- 用户名列 -->
+          <template v-else-if="column.key === 'nickname'">
+            <router-link
+              :to="'/list/basic/details/' + record.userId"
+              @click.stop=""
+            >
+              {{ record.nickname }}
+            </router-link>
+          </template>
+          <!-- 状态列 -->
+          <template v-else-if="column.key === 'status'">
+            <a-badge
+              :status="(['processing', 'error'][record.status] as any)"
+              :text="['正常', '冻结'][record.status]"
+            />
+          </template>
+          <!-- 操作列 -->
+          <template v-else-if="column.key === 'action'">
+            <a-space>
+              <a @click.stop="openEdit(record)">修改</a>
+              <a-divider type="vertical" />
+              <a class="ele-text-danger" @click.stop="remove(record)">删除</a>
+            </a-space>
+          </template>
+        </template>
+        <!-- 自定义筛选dropdown -->
+        <template
+          #customFilterDropdown="{
+            column,
+            setSelectedKeys,
+            confirm,
+            clearFilters
+          }"
+        >
+          <!-- 用户名 -->
+          <template v-if="column.key === 'nickname'">
+            <nickname-filter
+              :setSelectedKeys="setSelectedKeys"
+              :confirm="confirm"
+              :clearFilters="clearFilters"
+            />
+          </template>
+        </template>
+      </ele-pro-table>
+    </a-card>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref, computed, nextTick } from 'vue';
+  import { useRouter } from 'vue-router';
+  import { message } from 'ant-design-vue/es';
+  import { DownOutlined, PlusOutlined } from '@ant-design/icons-vue';
+  import type { EleProTable } from 'ele-admin-pro/es';
+  import type {
+    DatasourceFunction,
+    ColumnItem,
+    EleProTableDone
+  } from 'ele-admin-pro/es/ele-pro-table/types';
+  import { messageLoading, toDateString } from 'ele-admin-pro/es';
+  import SearchForm from './components/search-form.vue';
+  import NicknameFilter from './components/nickname-filter.vue';
+  import { pageUsers } from '@/api/system/user';
+  import type { User, UserParam } from '@/api/system/user/model';
+  import { removePageTab } from '@/utils/page-tab-util';
+  import { useI18n } from 'vue-i18n';
+
+  const { t } = useI18n();
+
+  const { push } = useRouter();
+
+  // 表格实例
+  const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
+
+  // 表格列配置
+  const columns = computed<ColumnItem[]>(() => {
+    return [
+      {
+        key: 'index',
+        width: 52,
+        align: 'center',
+        fixed: 'left',
+        hideInSetting: true,
+        customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
+      },
+      {
+        width: 80,
+        title: t('list.basic.table.avatar'),
+        key: 'avatar',
+        dataIndex: 'avatar',
+        ellipsis: true,
+        align: 'center'
+      },
+      {
+        title: t('list.basic.table.username'),
+        dataIndex: 'username',
+        sorter: true,
+        showSorterTooltip: false,
+        ellipsis: true,
+        width: 160,
+        minWidth: 100,
+        resizable: true
+      },
+      {
+        title: t('list.basic.table.nickname'),
+        key: 'nickname',
+        dataIndex: 'nickname',
+        sorter: true,
+        showSorterTooltip: false,
+        customFilterDropdown: true,
+        ellipsis: true,
+        width: 160,
+        minWidth: 100,
+        resizable: true
+      },
+      {
+        title: t('list.basic.table.organizationName'),
+        dataIndex: 'organizationName',
+        sorter: true,
+        showSorterTooltip: false,
+        hideInTable: true,
+        ellipsis: true,
+        width: 160,
+        minWidth: 100,
+        resizable: true
+      },
+      {
+        title: t('list.basic.table.phone'),
+        dataIndex: 'phone',
+        sorter: true,
+        showSorterTooltip: false,
+        ellipsis: true,
+        width: 160,
+        minWidth: 100,
+        resizable: true
+      },
+      {
+        title: t('list.basic.table.sexName'),
+        dataIndex: 'sexName',
+        width: 80,
+        align: 'center',
+        sorter: true,
+        showSorterTooltip: false,
+        filters: [
+          {
+            text: '男',
+            value: '男'
+          },
+          {
+            text: '女',
+            value: '女'
+          }
+        ],
+        filterMultiple: false,
+        ellipsis: true
+      },
+      {
+        title: t('list.basic.table.createTime'),
+        dataIndex: 'createTime',
+        sorter: true,
+        showSorterTooltip: false,
+        ellipsis: true,
+        customRender: ({ text }) => toDateString(text),
+        customCell: (record: User) => {
+          return {
+            onClick: (e: MouseEvent) => {
+              e.stopPropagation();
+              message.info('点击了创建时间: ' + record.createTime);
+            }
+          };
+        },
+        defaultSortOrder: 'ascend',
+        width: 160,
+        minWidth: 100,
+        resizable: true
+      },
+      {
+        title: t('list.basic.table.status'),
+        key: 'status',
+        dataIndex: 'status',
+        sorter: true,
+        showSorterTooltip: false,
+        width: 90,
+        align: 'center',
+        ellipsis: true
+      },
+      {
+        title: t('list.basic.table.action'),
+        key: 'action',
+        width: 110,
+        align: 'center',
+        hideInSetting: true,
+        fixed: 'right'
+      }
+    ];
+  });
+
+  // 表格选中数据
+  const selection = ref<User[]>([]);
+
+  // 表格是否显示边框
+  const bordered = ref(false);
+
+  // 表格是否斑马纹
+  const striped = ref(false);
+
+  // 表头工具栏风格
+  const toolDefault = ref(false);
+
+  // 表格固定高度
+  const fixedHeight = ref(false);
+
+  // 搜索是否展开
+  const searchExpand = ref(false);
+
+  // 表格高度
+  const tableHeight = computed(() => {
+    return fixedHeight.value
+      ? searchExpand.value
+        ? 'calc(100vh - 618px)'
+        : 'calc(100vh - 562px)'
+      : void 0;
+  });
+
+  // 表格数据源
+  const datasource: DatasourceFunction = ({
+    page,
+    limit,
+    where,
+    orders,
+    filters
+  }) => {
+    return pageUsers({
+      ...where,
+      ...orders,
+      ...filters,
+      page,
+      limit
+    });
+  };
+
+  /* 表格数据请求完成事件 */
+  const onDone: EleProTableDone<User> = ({ data }) => {
+    // 回显 id 为 19、22、21 的数据的复选框
+    const ids = [19, 22, 21];
+    selection.value = data.filter((d) => d.userId && ids.includes(d.userId));
+  };
+
+  /* 自定义行属性 */
+  const customRow = (record: User) => {
+    return {
+      // 行点击事件
+      onClick: () => {
+        if (selection.value.some((d) => d.userId === record.userId)) {
+          selection.value = selection.value.filter(
+            (d) => d.userId !== record.userId
+          );
+        } else {
+          selection.value = selection.value.concat([record]);
+        }
+      }
+    };
+  };
+
+  /* 刷新表格 */
+  const reload = (where?: UserParam) => {
+    selection.value = [];
+    tableRef?.value?.reload({ page: 1, where });
+  };
+
+  /* 清空选择 */
+  const clearChoose = () => {
+    selection.value = [];
+  };
+
+  /* 编辑 */
+  const openEdit = (row?: User) => {
+    const path = row ? '/list/basic/edit' : '/list/basic/add';
+    removePageTab({ key: path });
+    nextTick(() => {
+      push({
+        path,
+        query: row ? { id: row.userId } : undefined
+      });
+    });
+  };
+
+  /* 删除 */
+  const remove = (row: User) => {
+    console.log(row);
+    const hide = messageLoading({
+      content: '请求中...',
+      duration: 0,
+      mask: true
+    });
+    setTimeout(() => {
+      hide();
+      message.info('点击了删除');
+    }, 1500);
+  };
+
+  /* 下拉按钮点击 */
+  const onDropClick = ({ key }) => {
+    if (key === 'del') {
+      message.info('点击了批量删除');
+    } else if (key === 'edit') {
+      message.info('点击了批量修改');
+    }
+  };
+
+  /* 搜索展开改变事件 */
+  const onExpandChange = (value: boolean) => {
+    searchExpand.value = value;
+  };
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'ListBasic'
+  };
+</script>
+
+<style lang="less" scoped>
+  .list-tool-item {
+    & > span {
+      vertical-align: middle;
+      margin-right: 6px;
+      opacity: 0.9;
+    }
+  }
+</style>
diff --git a/src/views-demo/list/card/application/index.vue b/src/views-demo/list/card/application/index.vue
new file mode 100644
index 0000000..83ed6e5
--- /dev/null
+++ b/src/views-demo/list/card/application/index.vue
@@ -0,0 +1,149 @@
+<template>
+  <div class="ele-body">
+    <a-card :bordered="false" :body-style="{ padding: '44px 16px' }">
+      <div style="max-width: 500px; margin: 0 auto">
+        <a-input-search
+          size="large"
+          enter-button="搜索"
+          placeholder="请输入内容"
+          v-model:value="keyword"
+        />
+      </div>
+    </a-card>
+    <a-row :gutter="16">
+      <a-col
+        v-for="(item, index) in data"
+        :key="index"
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 8, md: 12, sm: 12, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-card :bordered="false" hoverable style="margin-top: 16px">
+          <div class="ele-cell" style="margin-bottom: 16px">
+            <a-avatar size="large" :src="item.cover" />
+            <h6 class="ele-cell-content ele-elip">{{ item.title }}</h6>
+          </div>
+          <div class="ele-elip" style="margin-bottom: 6px">
+            网址: {{ item.url }}
+          </div>
+          <div class="ele-elip">最后更新时间: {{ item.time }}</div>
+          <template #actions>
+            <a-tooltip title="下载">
+              <download-outlined />
+            </a-tooltip>
+            <a-tooltip title="编辑">
+              <edit-outlined />
+            </a-tooltip>
+            <a-tooltip title="分享">
+              <share-alt-outlined />
+            </a-tooltip>
+            <a-dropdown placement="bottom">
+              <ellipsis-outlined />
+              <template #overlay>
+                <a-menu>
+                  <a-menu-item>1st menu item</a-menu-item>
+                  <a-menu-item>2nd menu item</a-menu-item>
+                  <a-menu-item>3rd menu item</a-menu-item>
+                </a-menu>
+              </template>
+            </a-dropdown>
+          </template>
+        </a-card>
+      </a-col>
+    </a-row>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import {
+    DownloadOutlined,
+    EditOutlined,
+    ShareAltOutlined,
+    EllipsisOutlined
+  } from '@ant-design/icons-vue';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+
+  interface DataType {
+    title: string;
+    url: string;
+    time: string;
+    cover: string;
+  }
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const data = ref<DataType[]>([]);
+
+  data.value = [
+    {
+      title: 'ElementUI',
+      url: 'https://element.eleme.cn',
+      time: '2 小时前',
+      cover:
+        'https://cdn.eleadmin.com/20200609/c184eef391ae48dba87e3057e70238fb.jpg'
+    },
+    {
+      title: 'Vue.js',
+      url: 'https://cn.vuejs.org',
+      time: '4 小时前',
+      cover:
+        'https://cdn.eleadmin.com/20200609/b6a811873e704db49db994053a5019b2.jpg'
+    },
+    {
+      title: 'Vuex',
+      url: 'https://vuex.vuejs.org',
+      time: '12 小时前',
+      cover:
+        'https://cdn.eleadmin.com/20200609/948344a2a77c47a7a7b332fe12ff749a.jpg'
+    },
+    {
+      title: 'Vue Router',
+      url: 'https://vuex.vuejs.org',
+      time: '14 小时前',
+      cover:
+        'https://cdn.eleadmin.com/20200609/f6bc05af944a4f738b54128717952107.jpg'
+    },
+    {
+      title: 'Sass',
+      url: 'https://www.sass.hk',
+      time: '10 小时前',
+      cover:
+        'https://cdn.eleadmin.com/20200609/2d98970a51b34b6b859339c96b240dcd.jpg'
+    },
+    {
+      title: 'Axios',
+      url: 'http://www.axios-js.com',
+      time: '16 小时前',
+      cover:
+        'https://cdn.eleadmin.com/20200609/faa0202700ee455b90fe77d8bef98bc0.jpg'
+    },
+    {
+      title: 'Webpack',
+      url: 'https://www.webpackjs.com',
+      time: '6 小时前',
+      cover:
+        'https://cdn.eleadmin.com/20200609/d3519518b00d42d3936b2ab5ce3a4cc3.jpg'
+    },
+    {
+      title: 'Node.js',
+      url: 'http://nodejs.cn',
+      time: '8 小时前',
+      cover:
+        'https://cdn.eleadmin.com/20200609/fe9196dd091e438fba115205c1003ee7.jpg'
+    }
+  ];
+
+  const keyword = ref('');
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'ListCardApplication'
+  };
+</script>
diff --git a/src/views-demo/list/card/article/index.vue b/src/views-demo/list/card/article/index.vue
new file mode 100644
index 0000000..e3261c9
--- /dev/null
+++ b/src/views-demo/list/card/article/index.vue
@@ -0,0 +1,268 @@
+<template>
+  <div :class="['ele-body', { 'list-article-responsive': styleResponsive }]">
+    <a-card
+      :bordered="false"
+      :body-style="{ padding: '44px 16px' }"
+      style="margin-bottom: 16px"
+    >
+      <div style="max-width: 500px; margin: 0 auto">
+        <a-input-search
+          size="large"
+          enter-button="搜索"
+          placeholder="请输入内容"
+          v-model:value="keyword"
+        />
+      </div>
+    </a-card>
+    <a-card :bordered="false" :body-style="{ padding: '16px 8px' }">
+      <a-image-preview-group>
+        <a-list
+          :data-source="data"
+          :loading="loading && page === 1"
+          item-layout="vertical"
+          size="large"
+        >
+          <template #renderItem="{ item, index }">
+            <a-list-item :key="index">
+              <a-list-item-meta :title="item.title">
+                <template #description>
+                  <a-tag v-for="(tag, i) in item.tags" :key="i">
+                    {{ tag }}
+                  </a-tag>
+                </template>
+              </a-list-item-meta>
+              <div class="ele-text-heading">
+                {{ item.content }}
+              </div>
+              <div class="ele-cell" style="margin-top: 16px">
+                <a-avatar :src="item.avatar" size="small" />
+                <div class="ele-cell-content">
+                  {{ item.user }} 发表于 {{ item.time }}
+                </div>
+              </div>
+              <template #extra>
+                <div class="list-image-wrap">
+                  <a-image width="100%" :src="item.cover" />
+                </div>
+              </template>
+              <template #actions>
+                <span>
+                  <like-outlined />
+                  <span><s></s>{{ item.likes }}</span>
+                </span>
+                <span>
+                  <star-outlined />
+                  <span><s></s>{{ item.favorites }}</span>
+                </span>
+                <span>
+                  <message-outlined />
+                  <span><s></s>{{ item.comments }}</span>
+                </span>
+              </template>
+            </a-list-item>
+          </template>
+          <template #loadMore>
+            <div class="ele-text-center" style="margin-top: 16px">
+              <a-button v-if="page !== 1" :loading="loading" @click="query">
+                {{ loading ? '加载中..' : '加载更多' }}
+              </a-button>
+            </div>
+          </template>
+        </a-list>
+      </a-image-preview-group>
+    </a-card>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import {
+    LikeOutlined,
+    StarOutlined,
+    MessageOutlined
+  } from '@ant-design/icons-vue';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+
+  interface DataType {
+    title: string;
+    content: string;
+    time: string;
+    cover: string;
+    tags: string[];
+    user: string;
+    avatar: string;
+    favorites: number;
+    likes: number;
+    comments: number;
+  }
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const data = ref<DataType[]>([]);
+
+  const loading = ref(false);
+
+  const page = ref(2);
+
+  const query = () => {
+    loading.value = true;
+    setTimeout(() => {
+      loading.value = false;
+      page.value++;
+      data.value = data.value.concat(data.value.slice(0, 3));
+    }, 1000);
+  };
+
+  data.value = [
+    {
+      title: 'ElementUI',
+      content:
+        'Element,一套为开发者、设计师和产品经理准备的基于 Vue 2.0 的组件库,提供了配套设计资源,帮助你的网站快速成型。',
+      time: '2 小时前',
+      cover:
+        'https://cdn.eleadmin.com/20200610/RZ8FQmZfHkcffMlTBCJllBFjEhEsObVo.jpg',
+      tags: ['EleAdminPro', 'UI框架', '设计语言'],
+      user: 'SunSmile',
+      avatar:
+        'https://cdn.eleadmin.com/20200609/c184eef391ae48dba87e3057e70238fb.jpg',
+      favorites: 104,
+      likes: 189,
+      comments: 15
+    },
+    {
+      title: 'Vue.js',
+      content:
+        'Vue 是一套用于构建用户界面的渐进式框架。与其它大型框架不同的是,Vue 被设计为可以自底向上逐层应用。',
+      time: '4 小时前',
+      cover:
+        'https://cdn.eleadmin.com/20200610/WLXm7gp1EbLDtvVQgkeQeyq5OtDm00Jd.jpg',
+      tags: ['EleAdminPro', 'UI框架', '设计语言'],
+      user: '你的名字很好听',
+      avatar:
+        'https://cdn.eleadmin.com/20200609/b6a811873e704db49db994053a5019b2.jpg',
+      favorites: 104,
+      likes: 189,
+      comments: 15
+    },
+    {
+      title: 'Vuex',
+      content:
+        'Vuex 是一个专为 Vue.js 应用程序开发的状态管理模式。它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化。',
+      time: '12 小时前',
+      cover:
+        'https://cdn.eleadmin.com/20200610/4Z0QR2L0J1XStxBh99jVJ8qLfsGsOgjU.jpg',
+      tags: ['EleAdminPro', 'UI框架', '设计语言'],
+      user: '全村人的希望',
+      avatar:
+        'https://cdn.eleadmin.com/20200609/948344a2a77c47a7a7b332fe12ff749a.jpg',
+      favorites: 104,
+      likes: 189,
+      comments: 15
+    },
+    {
+      title: 'Vue Router',
+      content:
+        'Vue Router 是 Vue.js 官方的路由管理器。它和 Vue.js 的核心深度集成,让构建单页面应用变得易如反掌。',
+      time: '14 小时前',
+      cover:
+        'https://cdn.eleadmin.com/20200610/ttkIjNPlVDuv4lUTvRX8GIlM2QqSe0jg.jpg',
+      tags: ['EleAdminPro', 'UI框架', '设计语言'],
+      user: 'Jasmine',
+      avatar:
+        'https://cdn.eleadmin.com/20200609/f6bc05af944a4f738b54128717952107.jpg',
+      favorites: 104,
+      likes: 189,
+      comments: 15
+    },
+    {
+      title: 'Sass',
+      content: 'Sass 是世界上最成熟、稳定、强大的专业级 CSS 扩展语言。',
+      time: '10 小时前',
+      cover:
+        'https://cdn.eleadmin.com/20200610/fAenQ8nvRjL7x0i0jEfuDBZHvJfHf3v6.jpg',
+      tags: ['EleAdminPro', 'UI框架', '设计语言'],
+      user: '酷酷的大叔',
+      avatar:
+        'https://cdn.eleadmin.com/20200609/2d98970a51b34b6b859339c96b240dcd.jpg',
+      favorites: 104,
+      likes: 189,
+      comments: 15
+    },
+    {
+      title: 'Axios',
+      content:
+        'Axios 是一个基于 promise 的 HTTP 库,可以用在浏览器和 node.js 中。',
+      time: '16 小时前',
+      cover:
+        'https://cdn.eleadmin.com/20200610/LrCTN2j94lo9N7wEql7cBr1Ux4rHMvmZ.jpg',
+      tags: ['EleAdminPro', 'UI框架', '设计语言'],
+      user: 'SunSmile',
+      avatar:
+        'https://cdn.eleadmin.com/20200609/c184eef391ae48dba87e3057e70238fb.jpg',
+      favorites: 104,
+      likes: 189,
+      comments: 15
+    },
+    {
+      title: 'Webpack',
+      content:
+        'webpack 是一个模块打包器。webpack 的主要目标是将 JavaScript 文件打包在一起,打包后的文件用于在浏览器中使用。',
+      time: '6 小时前',
+      cover:
+        'https://cdn.eleadmin.com/20200610/yeKvhT20lMU0f1T3Y743UlGEOLLnZSnp.jpg',
+      tags: ['EleAdminPro', 'UI框架', '设计语言'],
+      user: '全村人的希望',
+      avatar:
+        'https://cdn.eleadmin.com/20200609/948344a2a77c47a7a7b332fe12ff749a.jpg',
+      favorites: 104,
+      likes: 189,
+      comments: 15
+    },
+    {
+      title: 'Node.js',
+      content:
+        'Node.js 是一个基于 Chrome V8 引擎的 JavaScript 运行环境。Node.js 使用了一个事件驱动、非阻塞式 I/O 的模型,使其轻量又高效。',
+      time: '8 小时前',
+      cover:
+        'https://cdn.eleadmin.com/20200610/CyrCNmTJfv7D6GFAg39bjT3eRkkRm5dI.jpg',
+      tags: ['EleAdminPro', 'UI框架', '设计语言'],
+      user: 'Jasmine',
+      avatar:
+        'https://cdn.eleadmin.com/20200609/f6bc05af944a4f738b54128717952107.jpg',
+      favorites: 104,
+      likes: 189,
+      comments: 15
+    }
+  ];
+
+  const keyword = ref('');
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'ListCardArticle'
+  };
+</script>
+
+<style lang="less" scoped>
+  .list-image-wrap {
+    width: 280px;
+    border-radius: 6px;
+    overflow: hidden;
+  }
+
+  @media screen and (max-width: 880px) {
+    .list-article-responsive .list-image-wrap {
+      width: 200px;
+    }
+  }
+
+  @media screen and (max-width: 576px) {
+    .list-article-responsive .list-image-wrap {
+      width: 100%;
+    }
+  }
+</style>
diff --git a/src/views-demo/list/card/project/index.vue b/src/views-demo/list/card/project/index.vue
new file mode 100644
index 0000000..5eddc66
--- /dev/null
+++ b/src/views-demo/list/card/project/index.vue
@@ -0,0 +1,314 @@
+<template>
+  <div class="ele-body">
+    <a-card :bordered="false" :body-style="{ padding: '44px 16px' }">
+      <div style="max-width: 500px; margin: 0 auto">
+        <a-input-search
+          size="large"
+          enter-button="搜索"
+          placeholder="请输入内容"
+          v-model:value="keyword"
+        />
+      </div>
+    </a-card>
+    <a-row :gutter="16">
+      <a-col
+        v-for="(item, index) in data"
+        :key="index"
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 8, md: 12, sm: 12, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-card :bordered="false" hoverable style="margin-top: 16px">
+          <template #cover>
+            <img :src="item.cover" alt="" />
+          </template>
+          <a-card-meta :title="item.title">
+            <template #description>
+              <div class="project-list-desc" :title="item.content">
+                {{ item.content }}
+              </div>
+            </template>
+          </a-card-meta>
+          <div class="ele-cell">
+            <div class="ele-cell-content ele-text-secondary">
+              {{ item.time }}
+            </div>
+            <ele-avatar-list :data="item.users" size="small" />
+          </div>
+        </a-card>
+      </a-col>
+    </a-row>
+    <div class="ele-text-center" style="margin-top: 18px">
+      <a-pagination
+        :total="count"
+        v-model:current="page"
+        v-model:page-size="limit"
+        @change="query"
+      />
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+
+  interface UserType {
+    name: string;
+    avatar: string;
+  }
+
+  interface DataType {
+    title: string;
+    content: string;
+    time: string;
+    cover: string;
+    users: UserType[];
+  }
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const data = ref<DataType[]>([]);
+
+  const keyword = ref('');
+
+  // 第几页
+  const page = ref(1);
+
+  // 每页多少条
+  const limit = ref(8);
+
+  // 总数量
+  const count = ref(0);
+
+  /* 查询数据 */
+  const query = () => {
+    count.value = 40;
+    data.value = [
+      {
+        title: 'ElementUI',
+        content:
+          'Element,一套为开发者、设计师和产品经理准备的基于 Vue 2.0 的组件库,提供了配套设计资源,帮助你的网站快速成型。',
+        time: '2 小时前',
+        cover:
+          'https://cdn.eleadmin.com/20200610/RZ8FQmZfHkcffMlTBCJllBFjEhEsObVo.jpg',
+        users: [
+          {
+            name: 'SunSmile',
+            avatar:
+              'https://cdn.eleadmin.com/20200609/c184eef391ae48dba87e3057e70238fb.jpg'
+          },
+          {
+            name: '酷酷的大叔',
+            avatar:
+              'https://cdn.eleadmin.com/20200609/2d98970a51b34b6b859339c96b240dcd.jpg'
+          },
+          {
+            name: 'Jasmine',
+            avatar:
+              'https://cdn.eleadmin.com/20200609/948344a2a77c47a7a7b332fe12ff749a.jpg'
+          }
+        ]
+      },
+      {
+        title: 'Vue.js',
+        content:
+          'Vue 是一套用于构建用户界面的渐进式框架。与其它大型框架不同的是,Vue 被设计为可以自底向上逐层应用。',
+        time: '4 小时前',
+        cover:
+          'https://cdn.eleadmin.com/20200610/WLXm7gp1EbLDtvVQgkeQeyq5OtDm00Jd.jpg',
+        users: [
+          {
+            name: 'SunSmile',
+            avatar:
+              'https://cdn.eleadmin.com/20200609/c184eef391ae48dba87e3057e70238fb.jpg'
+          },
+          {
+            name: '酷酷的大叔',
+            avatar:
+              'https://cdn.eleadmin.com/20200609/2d98970a51b34b6b859339c96b240dcd.jpg'
+          },
+          {
+            name: 'Jasmine',
+            avatar:
+              'https://cdn.eleadmin.com/20200609/948344a2a77c47a7a7b332fe12ff749a.jpg'
+          }
+        ]
+      },
+      {
+        title: 'Vuex',
+        content:
+          'Vuex 是一个专为 Vue.js 应用程序开发的状态管理模式。它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化。',
+        time: '12 小时前',
+        cover:
+          'https://cdn.eleadmin.com/20200610/4Z0QR2L0J1XStxBh99jVJ8qLfsGsOgjU.jpg',
+        users: [
+          {
+            name: 'SunSmile',
+            avatar:
+              'https://cdn.eleadmin.com/20200609/c184eef391ae48dba87e3057e70238fb.jpg'
+          },
+          {
+            name: '酷酷的大叔',
+            avatar:
+              'https://cdn.eleadmin.com/20200609/2d98970a51b34b6b859339c96b240dcd.jpg'
+          },
+          {
+            name: 'Jasmine',
+            avatar:
+              'https://cdn.eleadmin.com/20200609/948344a2a77c47a7a7b332fe12ff749a.jpg'
+          }
+        ]
+      },
+      {
+        title: 'Vue Router',
+        content:
+          'Vue Router 是 Vue.js 官方的路由管理器。它和 Vue.js 的核心深度集成,让构建单页面应用变得易如反掌。',
+        time: '14 小时前',
+        cover:
+          'https://cdn.eleadmin.com/20200610/ttkIjNPlVDuv4lUTvRX8GIlM2QqSe0jg.jpg',
+        users: [
+          {
+            name: 'SunSmile',
+            avatar:
+              'https://cdn.eleadmin.com/20200609/c184eef391ae48dba87e3057e70238fb.jpg'
+          },
+          {
+            name: '酷酷的大叔',
+            avatar:
+              'https://cdn.eleadmin.com/20200609/2d98970a51b34b6b859339c96b240dcd.jpg'
+          },
+          {
+            name: 'Jasmine',
+            avatar:
+              'https://cdn.eleadmin.com/20200609/948344a2a77c47a7a7b332fe12ff749a.jpg'
+          }
+        ]
+      },
+      {
+        title: 'Sass',
+        content: 'Sass 是世界上最成熟、稳定、强大的专业级 CSS 扩展语言。',
+        time: '10 小时前',
+        cover:
+          'https://cdn.eleadmin.com/20200610/fAenQ8nvRjL7x0i0jEfuDBZHvJfHf3v6.jpg',
+        users: [
+          {
+            name: 'SunSmile',
+            avatar:
+              'https://cdn.eleadmin.com/20200609/c184eef391ae48dba87e3057e70238fb.jpg'
+          },
+          {
+            name: '酷酷的大叔',
+            avatar:
+              'https://cdn.eleadmin.com/20200609/2d98970a51b34b6b859339c96b240dcd.jpg'
+          },
+          {
+            name: 'Jasmine',
+            avatar:
+              'https://cdn.eleadmin.com/20200609/948344a2a77c47a7a7b332fe12ff749a.jpg'
+          }
+        ]
+      },
+      {
+        title: 'Axios',
+        content:
+          'Axios 是一个基于 promise 的 HTTP 库,可以用在浏览器和 node.js 中。',
+        time: '16 小时前',
+        cover:
+          'https://cdn.eleadmin.com/20200610/LrCTN2j94lo9N7wEql7cBr1Ux4rHMvmZ.jpg',
+        users: [
+          {
+            name: 'SunSmile',
+            avatar:
+              'https://cdn.eleadmin.com/20200609/c184eef391ae48dba87e3057e70238fb.jpg'
+          },
+          {
+            name: '酷酷的大叔',
+            avatar:
+              'https://cdn.eleadmin.com/20200609/2d98970a51b34b6b859339c96b240dcd.jpg'
+          },
+          {
+            name: 'Jasmine',
+            avatar:
+              'https://cdn.eleadmin.com/20200609/948344a2a77c47a7a7b332fe12ff749a.jpg'
+          }
+        ]
+      },
+      {
+        title: 'Webpack',
+        content:
+          'webpack 是一个模块打包器。webpack 的主要目标是将 JavaScript 文件打包在一起,打包后的文件用于在浏览器中使用。',
+        time: '6 小时前',
+        cover:
+          'https://cdn.eleadmin.com/20200610/yeKvhT20lMU0f1T3Y743UlGEOLLnZSnp.jpg',
+        users: [
+          {
+            name: 'SunSmile',
+            avatar:
+              'https://cdn.eleadmin.com/20200609/c184eef391ae48dba87e3057e70238fb.jpg'
+          },
+          {
+            name: '酷酷的大叔',
+            avatar:
+              'https://cdn.eleadmin.com/20200609/2d98970a51b34b6b859339c96b240dcd.jpg'
+          },
+          {
+            name: 'Jasmine',
+            avatar:
+              'https://cdn.eleadmin.com/20200609/948344a2a77c47a7a7b332fe12ff749a.jpg'
+          }
+        ]
+      },
+      {
+        title: 'Node.js',
+        content:
+          'Node.js 是一个基于 Chrome V8 引擎的 JavaScript 运行环境。Node.js 使用了一个事件驱动、非阻塞式 I/O 的模型,使其轻量又高效。',
+        time: '8 小时前',
+        cover:
+          'https://cdn.eleadmin.com/20200610/CyrCNmTJfv7D6GFAg39bjT3eRkkRm5dI.jpg',
+        users: [
+          {
+            name: 'SunSmile',
+            avatar:
+              'https://cdn.eleadmin.com/20200609/c184eef391ae48dba87e3057e70238fb.jpg'
+          },
+          {
+            name: '酷酷的大叔',
+            avatar:
+              'https://cdn.eleadmin.com/20200609/2d98970a51b34b6b859339c96b240dcd.jpg'
+          },
+          {
+            name: 'Jasmine',
+            avatar:
+              'https://cdn.eleadmin.com/20200609/948344a2a77c47a7a7b332fe12ff749a.jpg'
+          }
+        ]
+      }
+    ];
+  };
+
+  query();
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'ListCardProject'
+  };
+</script>
+
+<style lang="less" scoped>
+  .project-list-desc {
+    height: 44px;
+    line-height: 22px;
+    margin-bottom: 20px;
+    overflow: hidden;
+    display: -webkit-box;
+    -webkit-line-clamp: 2;
+    -webkit-box-orient: vertical;
+  }
+</style>
diff --git a/src/views-demo/login/index.vue b/src/views-demo/login/index.vue
new file mode 100644
index 0000000..9197c22
--- /dev/null
+++ b/src/views-demo/login/index.vue
@@ -0,0 +1,371 @@
+<template>
+  <div
+    :class="[
+      'login-wrapper',
+      ['', 'login-form-right', 'login-form-left'][direction]
+    ]"
+  >
+    <a-form
+      ref="formRef"
+      :model="form"
+      :rules="rules"
+      class="login-form ele-bg-white"
+    >
+      <h4>{{ t('login.title') }}</h4>
+      <a-form-item name="username">
+        <a-input
+          allow-clear
+          size="large"
+          v-model:value="form.username"
+          :placeholder="t('login.username')"
+        >
+          <template #prefix>
+            <user-outlined />
+          </template>
+        </a-input>
+      </a-form-item>
+      <a-form-item name="password">
+        <a-input-password
+          size="large"
+          v-model:value="form.password"
+          :placeholder="t('login.password')"
+        >
+          <template #prefix>
+            <lock-outlined />
+          </template>
+        </a-input-password>
+      </a-form-item>
+      <a-form-item name="code">
+        <div class="login-input-group">
+          <a-input
+            allow-clear
+            size="large"
+            v-model:value="form.code"
+            :placeholder="t('login.code')"
+          >
+            <template #prefix>
+              <safety-certificate-outlined />
+            </template>
+          </a-input>
+          <a-button class="login-captcha" @click="changeCaptcha">
+            <img v-if="captcha" :src="captcha" alt="" />
+          </a-button>
+        </div>
+      </a-form-item>
+      <a-form-item>
+        <a-checkbox v-model:checked="form.remember">
+          {{ t('login.remember') }}
+        </a-checkbox>
+        <router-link
+          to="/forget"
+          class="ele-pull-right"
+          style="line-height: 22px"
+        >
+          {{ t('login.forget') }}
+        </router-link>
+      </a-form-item>
+      <a-form-item>
+        <a-button
+          block
+          size="large"
+          type="primary"
+          :loading="loading"
+          @click="submit"
+        >
+          {{ loading ? t('login.loading') : t('login.login') }}
+        </a-button>
+      </a-form-item>
+      <div class="ele-text-center" style="padding-bottom: 32px">
+        <qq-outlined class="login-oauth-icon" style="background: #3492ed" />
+        <wechat-outlined class="login-oauth-icon" style="background: #4daf29" />
+        <weibo-outlined class="login-oauth-icon" style="background: #cf1900" />
+      </div>
+    </a-form>
+    <div class="login-copyright">
+      copyright © 2022 eleadmin.com all rights reserved.
+    </div>
+    <!-- 多语言切换 -->
+    <div style="position: absolute; right: 30px; top: 20px; z-index: 999">
+      <i18n-icon
+        placement="bottomLeft"
+        :style="{ fontSize: '18px', color: '#fff' }"
+      />
+    </div>
+    <!-- 实际项目去掉这段 -->
+    <div style="position: absolute; left: 30px; top: 20px; z-index: 999">
+      <a-radio-group v-model:value="direction" size="small">
+        <a-radio-button :value="2">居左</a-radio-button>
+        <a-radio-button :value="0">居中</a-radio-button>
+        <a-radio-button :value="1">居右</a-radio-button>
+      </a-radio-group>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive, computed, unref } from 'vue';
+  import { useI18n } from 'vue-i18n';
+  import { useRouter } from 'vue-router';
+  import { message } from 'ant-design-vue/es';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import {
+    UserOutlined,
+    LockOutlined,
+    SafetyCertificateOutlined,
+    QqOutlined,
+    WechatOutlined,
+    WeiboOutlined
+  } from '@ant-design/icons-vue';
+  import I18nIcon from '@/layout/components/i18n-icon.vue';
+  import { getToken } from '@/utils/token-util';
+  import { goHomeRoute, cleanPageTabs } from '@/utils/page-tab-util';
+  import { login, getCaptcha } from '@/api/login';
+
+  const { currentRoute } = useRouter();
+  const { t } = useI18n();
+
+  // 登录框方向, 0 居中, 1 居右, 2 居左
+  const direction = ref(0);
+
+  //
+  const formRef = ref<FormInstance | null>(null);
+
+  // 加载状态
+  const loading = ref(false);
+
+  // 表单数据
+  const form = reactive({
+    username: 'admin',
+    password: 'admin',
+    code: '',
+    remember: true
+  });
+
+  // 验证码 base64 数据
+  const captcha = ref('');
+
+  // 验证码内容, 实际项目去掉
+  const text = ref('');
+
+  // 表单验证规则
+  const rules = computed<Record<string, Rule[]>>(() => {
+    return {
+      username: [
+        {
+          required: true,
+          message: t('login.username'),
+          type: 'string',
+          trigger: 'blur'
+        }
+      ],
+      password: [
+        {
+          required: true,
+          message: t('login.password'),
+          type: 'string',
+          trigger: 'blur'
+        }
+      ],
+      code: [
+        {
+          required: true,
+          message: t('login.code'),
+          type: 'string',
+          trigger: 'blur'
+        }
+      ]
+    };
+  });
+
+  /* 跳转到首页 */
+  const goHome = () => {
+    const { query } = unref(currentRoute);
+    goHomeRoute(query.from as string);
+  };
+
+  /* 提交 */
+  const submit = () => {
+    if (!formRef.value) {
+      return;
+    }
+    formRef.value
+      .validate()
+      .then(() => {
+        if (form.code.toLowerCase() !== text.value) {
+          message.error('验证码错误');
+          return;
+        }
+        loading.value = true;
+        login(form)
+          .then((msg) => {
+            message.success(msg);
+            cleanPageTabs();
+            goHome();
+          })
+          .catch((e: Error) => {
+            message.error(e.message);
+            loading.value = false;
+          });
+      })
+      .catch(() => {});
+  };
+
+  /* 获取图形验证码 */
+  const changeCaptcha = () => {
+    // 这里演示的验证码是后端返回base64格式的形式, 如果后端地址直接是图片请参考忘记密码页面
+    getCaptcha()
+      .then((data) => {
+        captcha.value = data.base64;
+        // 实际项目后端一般会返回验证码的key而不是直接返回验证码的内容, 登录用key去验证, 你可以根据自己后端接口修改
+        text.value = data.text;
+        // 自动回填验证码, 实际项目去掉这个
+        form.code = data.text;
+        formRef.value?.clearValidate();
+      })
+      .catch((e) => {
+        message.error(e.message);
+      });
+  };
+
+  if (getToken()) {
+    goHome();
+  } else {
+    changeCaptcha();
+  }
+</script>
+
+<style lang="less" scoped>
+  /* 背景 */
+  .login-wrapper {
+    padding: 48px 16px 0 16px;
+    position: relative;
+    box-sizing: border-box;
+    background-image: url('@/assets/bg-login.jpg');
+    background-repeat: no-repeat;
+    background-size: cover;
+    min-height: 100vh;
+
+    &:before {
+      content: '';
+      position: absolute;
+      top: 0;
+      left: 0;
+      right: 0;
+      bottom: 0;
+      background: rgba(0, 0, 0, 0.2);
+    }
+  }
+
+  /* 卡片 */
+  .login-form {
+    width: 360px;
+    margin: 0 auto;
+    max-width: 100%;
+    padding: 0 28px;
+    box-sizing: border-box;
+    box-shadow: 0 3px 6px rgba(0, 0, 0, 0.15);
+    border-radius: 2px;
+    position: relative;
+    z-index: 2;
+
+    h4 {
+      padding: 22px 0;
+      text-align: center;
+    }
+  }
+
+  .login-form-right .login-form {
+    margin: 0 15% 0 auto;
+  }
+
+  .login-form-left .login-form {
+    margin: 0 auto 0 15%;
+  }
+
+  /* 验证码 */
+  .login-input-group {
+    display: flex;
+    align-items: center;
+
+    :deep(.ant-input-affix-wrapper) {
+      flex: 1;
+    }
+
+    .login-captcha {
+      width: 102px;
+      height: 40px;
+      margin-left: 10px;
+      padding: 0;
+
+      & > img {
+        width: 100%;
+        height: 100%;
+      }
+    }
+  }
+
+  /* 第三方登录图标 */
+  .login-oauth-icon {
+    color: #fff;
+    padding: 5px;
+    margin: 0 12px;
+    font-size: 18px;
+    border-radius: 50%;
+    cursor: pointer;
+  }
+
+  /* 底部版权 */
+  .login-copyright {
+    color: #eee;
+    text-align: center;
+    padding: 48px 0 22px 0;
+    position: relative;
+    z-index: 1;
+  }
+
+  /* 响应式 */
+  @media screen and (min-height: 640px) {
+    .login-wrapper {
+      padding-top: 0;
+    }
+
+    .login-form {
+      position: absolute;
+      top: 50%;
+      left: 50%;
+      transform: translateX(-50%);
+      margin-top: -230px;
+    }
+
+    .login-form-right .login-form,
+    .login-form-left .login-form {
+      left: auto;
+      right: 15%;
+      transform: translateX(0);
+      margin: -230px auto auto auto;
+    }
+
+    .login-form-left .login-form {
+      right: auto;
+      left: 15%;
+    }
+
+    .login-copyright {
+      position: absolute;
+      left: 0;
+      right: 0;
+      bottom: 0;
+    }
+  }
+
+  @media screen and (max-width: 768px) {
+    .login-form-right .login-form,
+    .login-form-left .login-form {
+      left: 50%;
+      right: auto;
+      margin-left: 0;
+      margin-right: auto;
+      transform: translateX(-50%);
+    }
+  }
+</style>
diff --git a/src/views-demo/result/fail/index.vue b/src/views-demo/result/fail/index.vue
new file mode 100644
index 0000000..c3881e9
--- /dev/null
+++ b/src/views-demo/result/fail/index.vue
@@ -0,0 +1,57 @@
+<template>
+  <div class="ele-body">
+    <a-card :bordered="false">
+      <div style="max-width: 960px; margin: 0 auto">
+        <a-result
+          status="error"
+          title="提交失败"
+          sub-title="请核对并修改以下信息后,再重新提交。"
+        >
+          <div>您提交的内容有如下错误:</div>
+          <div class="error-tips-item">
+            <close-circle-outlined class="ele-text-danger" />
+            <div>您的账户已被冻结</div>
+            <a>立即解冻&gt;</a>
+          </div>
+          <div class="error-tips-item">
+            <close-circle-outlined class="ele-text-danger" />
+            <div>您的账户还不具备申请资格</div>
+            <a>立即升级&gt;</a>
+          </div>
+          <template #extra>
+            <a-space size="middle">
+              <a-button type="primary">返回修改</a-button>
+              <a-button>重新提交</a-button>
+            </a-space>
+          </template>
+        </a-result>
+      </div>
+    </a-card>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { CloseCircleOutlined } from '@ant-design/icons-vue';
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'ResultFail'
+  };
+</script>
+
+<style lang="less" scoped>
+  .error-tips-item {
+    display: flex;
+    align-items: center;
+    margin-top: 16px;
+
+    & > div {
+      margin: 0 10px;
+    }
+
+    a {
+      white-space: nowrap;
+    }
+  }
+</style>
diff --git a/src/views-demo/result/success/index.vue b/src/views-demo/result/success/index.vue
new file mode 100644
index 0000000..9f80288
--- /dev/null
+++ b/src/views-demo/result/success/index.vue
@@ -0,0 +1,28 @@
+<template>
+  <div class="ele-body">
+    <a-card :bordered="false">
+      <div style="max-width: 960px; margin: 0 auto">
+        <a-result
+          status="success"
+          title="提交成功"
+          sub-title="提交结果页用于反馈一系列操作任务的处理结果,如果仅是简单操作,使用 Message 全局提示反馈即可。灰色区域可以显示一些补充的信息。"
+        >
+          <div>已提交申请,等待部门审核。</div>
+          <template #extra>
+            <a-space size="middle">
+              <a-button type="primary">返回列表</a-button>
+              <a-button>查看项目</a-button>
+              <a-button>打印</a-button>
+            </a-space>
+          </template>
+        </a-result>
+      </div>
+    </a-card>
+  </div>
+</template>
+
+<script lang="ts">
+  export default {
+    name: 'ResultSuccess'
+  };
+</script>
diff --git a/src/views-demo/system/dictionary/components/dict-data-edit.vue b/src/views-demo/system/dictionary/components/dict-data-edit.vue
new file mode 100644
index 0000000..8a7cb77
--- /dev/null
+++ b/src/views-demo/system/dictionary/components/dict-data-edit.vue
@@ -0,0 +1,186 @@
+<!-- 字典项编辑弹窗 -->
+<template>
+  <ele-modal
+    :width="460"
+    :visible="visible"
+    :confirm-loading="loading"
+    :body-style="{ paddingBottom: '8px' }"
+    :title="isUpdate ? '修改字典项' : '添加字典项'"
+    @update:visible="updateVisible"
+    @ok="save"
+  >
+    <a-form
+      ref="formRef"
+      :model="form"
+      :rules="rules"
+      :label-col="styleResponsive ? { md: 6, sm: 6, xs: 24 } : { flex: '98px' }"
+      :wrapper-col="
+        styleResponsive ? { md: 18, sm: 18, xs: 24 } : { flex: '1' }
+      "
+    >
+      <a-form-item label="字典项名称" name="dictDataName">
+        <a-input
+          allow-clear
+          :maxlength="20"
+          placeholder="请输入字典项名称"
+          v-model:value="form.dictDataName"
+        />
+      </a-form-item>
+      <a-form-item label="字典项值" name="dictDataCode">
+        <a-input
+          allow-clear
+          :maxlength="20"
+          placeholder="请输入字典项值"
+          v-model:value="form.dictDataCode"
+        />
+      </a-form-item>
+      <a-form-item label="排序号" name="sortNumber">
+        <a-input-number
+          :min="0"
+          :max="9999"
+          class="ele-fluid"
+          placeholder="请输入排序号"
+          v-model:value="form.sortNumber"
+        />
+      </a-form-item>
+      <a-form-item label="备注">
+        <a-textarea
+          :rows="4"
+          :maxlength="200"
+          placeholder="请输入备注"
+          v-model:value="form.comments"
+        />
+      </a-form-item>
+    </a-form>
+  </ele-modal>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive, watch } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import {
+    addDictionaryData,
+    updateDictionaryData
+  } from '@/api/system/dictionary-data';
+  import type { DictionaryData } from '@/api/system/dictionary-data/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'done'): void;
+    (e: 'update:visible', visible: boolean): void;
+  }>();
+
+  const props = defineProps<{
+    // 弹窗是否打开
+    visible: boolean;
+    // 修改回显的数据
+    data?: DictionaryData | null;
+    // 字典id
+    dictId: number;
+  }>();
+
+  //
+  const formRef = ref<FormInstance | null>(null);
+
+  // 是否是修改
+  const isUpdate = ref(false);
+
+  // 提交状态
+  const loading = ref(false);
+
+  // 表单数据
+  const { form, resetFields, assignFields } = useFormData<DictionaryData>({
+    dictDataId: undefined,
+    dictDataName: '',
+    dictDataCode: '',
+    sortNumber: '',
+    comments: ''
+  });
+
+  // 表单验证规则
+  const rules = reactive<Record<string, Rule[]>>({
+    dictDataName: [
+      {
+        required: true,
+        message: '请输入字典项名称',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    dictDataCode: [
+      {
+        required: true,
+        message: '请输入字典项值',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    sortNumber: [
+      {
+        required: true,
+        message: '请输入排序号',
+        type: 'number',
+        trigger: 'blur'
+      }
+    ]
+  });
+
+  /* 保存编辑 */
+  const save = () => {
+    if (!formRef.value) {
+      return;
+    }
+    formRef.value
+      .validate()
+      .then(() => {
+        loading.value = true;
+        const saveOrUpdate = isUpdate.value
+          ? updateDictionaryData
+          : addDictionaryData;
+        saveOrUpdate({
+          ...form,
+          dictId: props.dictId
+        })
+          .then((msg) => {
+            loading.value = false;
+            message.success(msg);
+            updateVisible(false);
+            emit('done');
+          })
+          .catch((e) => {
+            loading.value = false;
+            message.error(e.message);
+          });
+      })
+      .catch(() => {});
+  };
+
+  /* 更新visible */
+  const updateVisible = (value: boolean) => {
+    emit('update:visible', value);
+  };
+
+  watch(
+    () => props.visible,
+    (visible) => {
+      if (visible) {
+        if (props.data) {
+          assignFields(props.data);
+          isUpdate.value = true;
+        } else {
+          isUpdate.value = false;
+        }
+      } else {
+        resetFields();
+        formRef.value?.clearValidate();
+      }
+    }
+  );
+</script>
diff --git a/src/views-demo/system/dictionary/components/dict-data-search.vue b/src/views-demo/system/dictionary/components/dict-data-search.vue
new file mode 100644
index 0000000..8dfafa3
--- /dev/null
+++ b/src/views-demo/system/dictionary/components/dict-data-search.vue
@@ -0,0 +1,86 @@
+<!-- 搜索表单 -->
+<template>
+  <a-row :gutter="16">
+    <a-col
+      v-bind="
+        styleResponsive ? { xl: 6, lg: 8, md: 11, sm: 24, xs: 24 } : { span: 6 }
+      "
+    >
+      <a-input
+        v-model:value.trim="form.keywords"
+        placeholder="输入关键字搜索"
+        allow-clear
+      />
+    </a-col>
+    <a-col
+      v-bind="
+        styleResponsive
+          ? { xl: 18, lg: 16, md: 13, sm: 24, xs: 24 }
+          : { span: 18 }
+      "
+    >
+      <a-space :size="10" style="flex-wrap: wrap">
+        <a-button type="primary" class="ele-btn-icon" @click="search">
+          <template #icon>
+            <search-outlined />
+          </template>
+          <span>查询</span>
+        </a-button>
+        <a-button type="primary" class="ele-btn-icon" @click="add">
+          <template #icon>
+            <plus-outlined />
+          </template>
+          <span>新建</span>
+        </a-button>
+        <a-button danger type="primary" class="ele-btn-icon" @click="remove">
+          <template #icon>
+            <delete-outlined />
+          </template>
+          <span>删除</span>
+        </a-button>
+      </a-space>
+    </a-col>
+  </a-row>
+</template>
+
+<script lang="ts" setup>
+  import {
+    PlusOutlined,
+    DeleteOutlined,
+    SearchOutlined
+  } from '@ant-design/icons-vue';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import type { DictionaryDataParam } from '@/api/system/dictionary-data/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'search', where?: DictionaryDataParam): void;
+    (e: 'add'): void;
+    (e: 'remove'): void;
+  }>();
+
+  // 表单数据
+  const { form } = useFormData<DictionaryDataParam>({
+    keywords: ''
+  });
+
+  /* 搜索 */
+  const search = () => {
+    emit('search', form);
+  };
+
+  /* 添加 */
+  const add = () => {
+    emit('add');
+  };
+
+  /* 删除 */
+  const remove = () => {
+    emit('remove');
+  };
+</script>
diff --git a/src/views-demo/system/dictionary/components/dict-data.vue b/src/views-demo/system/dictionary/components/dict-data.vue
new file mode 100644
index 0000000..eb1821b
--- /dev/null
+++ b/src/views-demo/system/dictionary/components/dict-data.vue
@@ -0,0 +1,212 @@
+<template>
+  <!-- 表格 -->
+  <ele-pro-table
+    ref="tableRef"
+    row-key="dictDataId"
+    :columns="columns"
+    :datasource="datasource"
+    tool-class="ele-toolbar-form"
+    v-model:selection="selection"
+    :row-selection="{ columnWidth: 48 }"
+    :scroll="{ x: 800 }"
+    height="calc(100vh - 290px)"
+    tools-theme="default"
+    bordered
+    cache-key="proSystemDictDataTable"
+    class="sys-dict-data-table"
+  >
+    <template #toolbar>
+      <dict-data-search
+        @search="reload"
+        @add="openEdit()"
+        @remove="removeBatch"
+      />
+    </template>
+    <template #bodyCell="{ column, record }">
+      <template v-if="column.key === 'action'">
+        <a-space>
+          <a @click="openEdit(record)">修改</a>
+          <a-divider type="vertical" />
+          <a-popconfirm
+            placement="topRight"
+            title="确定要删除此字典项吗?"
+            @confirm="remove(record)"
+          >
+            <a class="ele-text-danger">删除</a>
+          </a-popconfirm>
+        </a-space>
+      </template>
+    </template>
+  </ele-pro-table>
+  <!-- 编辑弹窗 -->
+  <dict-data-edit
+    v-model:visible="showEdit"
+    :data="current"
+    :dict-id="dictId"
+    @done="reload"
+  />
+</template>
+
+<script lang="ts" setup>
+  import { createVNode, ref, watch } from 'vue';
+  import { message, Modal } from 'ant-design-vue/es';
+  import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
+  import type { EleProTable } from 'ele-admin-pro/es';
+  import type {
+    DatasourceFunction,
+    ColumnItem
+  } from 'ele-admin-pro/es/ele-pro-table/types';
+  import { messageLoading, toDateString } from 'ele-admin-pro/es';
+  import DictDataSearch from './dict-data-search.vue';
+  import DictDataEdit from './dict-data-edit.vue';
+  import {
+    pageDictionaryData,
+    removeDictionaryData,
+    removeDictionaryDataBatch
+  } from '@/api/system/dictionary-data';
+  import type {
+    DictionaryData,
+    DictionaryDataParam
+  } from '@/api/system/dictionary-data/model';
+
+  const props = defineProps<{
+    // 字典id
+    dictId: number;
+  }>();
+
+  // 表格实例
+  const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
+
+  // 表格列配置
+  const columns = ref<ColumnItem[]>([
+    {
+      title: '字典项名称',
+      dataIndex: 'dictDataName',
+      ellipsis: true,
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '字典项值',
+      dataIndex: 'dictDataCode',
+      ellipsis: true,
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '排序号',
+      dataIndex: 'sortNumber',
+      sorter: true,
+      showSorterTooltip: false,
+      width: 120,
+      align: 'center'
+    },
+    {
+      title: '创建时间',
+      dataIndex: 'createTime',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true,
+      customRender: ({ text }) => toDateString(text)
+    },
+    {
+      title: '操作',
+      key: 'action',
+      width: 130,
+      align: 'center'
+    }
+  ]);
+
+  // 表格选中数据
+  const selection = ref<DictionaryData[]>([]);
+
+  // 当前编辑数据
+  const current = ref<DictionaryData | null>(null);
+
+  // 是否显示编辑弹窗
+  const showEdit = ref(false);
+
+  // 表格数据源
+  const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
+    return pageDictionaryData({
+      ...where,
+      ...orders,
+      page,
+      limit,
+      dictId: props.dictId
+    });
+  };
+
+  /* 刷新表格 */
+  const reload = (where?: DictionaryDataParam) => {
+    tableRef?.value?.reload({ page: 1, where });
+  };
+
+  /* 打开编辑弹窗 */
+  const openEdit = (row?: DictionaryData) => {
+    current.value = row ?? null;
+    showEdit.value = true;
+  };
+
+  /* 删除单个 */
+  const remove = (row: DictionaryData) => {
+    const hide = messageLoading('请求中..', 0);
+    removeDictionaryData(row.dictDataId)
+      .then((msg) => {
+        hide();
+        message.success(msg);
+        reload();
+      })
+      .catch((e) => {
+        hide();
+        message.error(e.message);
+      });
+  };
+
+  /* 批量删除 */
+  const removeBatch = () => {
+    if (!selection.value.length) {
+      message.error('请至少选择一条数据');
+      return;
+    }
+    Modal.confirm({
+      title: '提示',
+      content: '确定要删除选中的字典项吗?',
+      icon: createVNode(ExclamationCircleOutlined),
+      maskClosable: true,
+      onOk: () => {
+        const hide = messageLoading('请求中..', 0);
+        removeDictionaryDataBatch(selection.value.map((d) => d.dictDataId))
+          .then((msg) => {
+            hide();
+            message.success(msg);
+            reload();
+          })
+          .catch((e) => {
+            hide();
+            message.error(e.message);
+          });
+      }
+    });
+  };
+
+  // 监听字典id变化
+  watch(
+    () => props.dictId,
+    () => {
+      reload();
+    }
+  );
+</script>
+
+<style lang="less" scoped>
+  .sys-dict-data-table :deep(.ant-table-body) {
+    overflow: auto !important;
+    overflow: overlay !important;
+  }
+
+  .sys-dict-data-table :deep(.ant-table-pagination.ant-pagination) {
+    padding: 0 4px;
+    margin-bottom: 0;
+  }
+</style>
diff --git a/src/views-demo/system/dictionary/components/dict-edit.vue b/src/views-demo/system/dictionary/components/dict-edit.vue
new file mode 100644
index 0000000..b2de3db
--- /dev/null
+++ b/src/views-demo/system/dictionary/components/dict-edit.vue
@@ -0,0 +1,176 @@
+<!-- 字典编辑弹窗 -->
+<template>
+  <ele-modal
+    :width="460"
+    :visible="visible"
+    :confirm-loading="loading"
+    :title="isUpdate ? '修改字典' : '添加字典'"
+    :body-style="{ paddingBottom: '8px' }"
+    @update:visible="updateVisible"
+    @ok="save"
+  >
+    <a-form
+      ref="formRef"
+      :model="form"
+      :rules="rules"
+      :label-col="styleResponsive ? { md: 5, sm: 5, xs: 24 } : { flex: '90px' }"
+      :wrapper-col="
+        styleResponsive ? { md: 19, sm: 19, xs: 24 } : { flex: '1' }
+      "
+    >
+      <a-form-item label="字典名称" name="dictName">
+        <a-input
+          allow-clear
+          :maxlength="20"
+          placeholder="请输入字典名称"
+          v-model:value="form.dictName"
+        />
+      </a-form-item>
+      <a-form-item label="字典值" name="dictCode">
+        <a-input
+          allow-clear
+          :maxlength="20"
+          placeholder="请输入字典值"
+          v-model:value="form.dictCode"
+        />
+      </a-form-item>
+      <a-form-item label="排序号" name="sortNumber">
+        <a-input-number
+          :min="0"
+          :max="9999"
+          class="ele-fluid"
+          placeholder="请输入排序号"
+          v-model:value="form.sortNumber"
+        />
+      </a-form-item>
+      <a-form-item label="备注">
+        <a-textarea
+          :rows="4"
+          :maxlength="200"
+          placeholder="请输入备注"
+          v-model:value="form.comments"
+        />
+      </a-form-item>
+    </a-form>
+  </ele-modal>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive, watch } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import { addDictionary, updateDictionary } from '@/api/system/dictionary';
+  import type { Dictionary } from '@/api/system/dictionary/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'done'): void;
+    (e: 'update:visible', visible: boolean): void;
+  }>();
+
+  const props = defineProps<{
+    // 弹窗是否打开
+    visible: boolean;
+    // 修改回显的数据
+    data?: Dictionary | null;
+  }>();
+
+  //
+  const formRef = ref<FormInstance | null>(null);
+
+  // 是否是修改
+  const isUpdate = ref(false);
+
+  // 提交状态
+  const loading = ref(false);
+
+  // 表单数据
+  const { form, resetFields, assignFields } = useFormData<Dictionary>({
+    dictId: undefined,
+    dictName: '',
+    dictCode: '',
+    sortNumber: undefined,
+    comments: ''
+  });
+
+  // 表单验证规则
+  const rules = reactive<Record<string, Rule[]>>({
+    dictName: [
+      {
+        required: true,
+        message: '请输入字典名称',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    dictCode: [
+      {
+        required: true,
+        message: '请输入字典值',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    sortNumber: [
+      {
+        required: true,
+        message: '请输入排序号',
+        type: 'number',
+        trigger: 'blur'
+      }
+    ]
+  });
+
+  /* 保存编辑 */
+  const save = () => {
+    if (!formRef.value) {
+      return;
+    }
+    formRef.value
+      .validate()
+      .then(() => {
+        loading.value = true;
+        const saveOrUpdate = isUpdate.value ? updateDictionary : addDictionary;
+        saveOrUpdate(form)
+          .then((msg) => {
+            loading.value = false;
+            message.success(msg);
+            updateVisible(false);
+            emit('done');
+          })
+          .catch((e) => {
+            loading.value = false;
+            message.error(e.message);
+          });
+      })
+      .catch(() => {});
+  };
+
+  /* 更新visible */
+  const updateVisible = (value: boolean) => {
+    emit('update:visible', value);
+  };
+
+  watch(
+    () => props.visible,
+    (visible) => {
+      if (visible) {
+        if (props.data) {
+          assignFields(props.data);
+          isUpdate.value = true;
+        } else {
+          isUpdate.value = false;
+        }
+      } else {
+        resetFields();
+        formRef.value?.clearValidate();
+      }
+    }
+  );
+</script>
diff --git a/src/views-demo/system/dictionary/index.vue b/src/views-demo/system/dictionary/index.vue
new file mode 100644
index 0000000..be8be9c
--- /dev/null
+++ b/src/views-demo/system/dictionary/index.vue
@@ -0,0 +1,197 @@
+<template>
+  <div class="ele-body">
+    <a-card :bordered="false" :body-style="{ padding: '16px' }">
+      <ele-split-layout
+        width="266px"
+        allow-collapse
+        :right-style="{ overflow: 'hidden' }"
+        :style="{ minHeight: 'calc(100vh - 152px)' }"
+      >
+        <!-- 表格 -->
+        <ele-pro-table
+          ref="tableRef"
+          row-key="dictId"
+          :columns="columns"
+          :datasource="datasource"
+          v-model:current="current"
+          selection-type="radio"
+          :row-selection="{ columnWidth: 32 }"
+          :need-page="false"
+          :toolkit="[]"
+          height="calc(100vh - 290px)"
+          tools-theme="default"
+          bordered
+          :custom-row="customRow"
+          class="sys-dict-table"
+          @done="done"
+        >
+          <template #toolbar>
+            <a-space :size="10">
+              <a-button type="primary" class="ele-btn-icon" @click="openEdit()">
+                <template #icon>
+                  <plus-outlined />
+                </template>
+                <span>新建</span>
+              </a-button>
+              <a-button
+                type="primary"
+                :disabled="!current"
+                class="ele-btn-icon"
+                @click="openEdit(current)"
+              >
+                <template #icon>
+                  <edit-outlined />
+                </template>
+                <span>修改</span>
+              </a-button>
+              <a-button
+                danger
+                type="primary"
+                :disabled="!current"
+                class="ele-btn-icon"
+                @click="remove"
+              >
+                <template #icon>
+                  <delete-outlined />
+                </template>
+                <span>删除</span>
+              </a-button>
+            </a-space>
+          </template>
+        </ele-pro-table>
+        <template #content>
+          <dict-data
+            v-if="current && current.dictId"
+            :dict-id="current.dictId"
+          />
+        </template>
+      </ele-split-layout>
+    </a-card>
+    <!-- 编辑弹窗 -->
+    <dict-edit v-model:visible="showEdit" :data="editData" @done="reload" />
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { createVNode, ref } from 'vue';
+  import { message, Modal } from 'ant-design-vue/es';
+  import {
+    PlusOutlined,
+    EditOutlined,
+    DeleteOutlined,
+    ExclamationCircleOutlined
+  } from '@ant-design/icons-vue';
+  import { messageLoading } from 'ele-admin-pro/es';
+  import type { EleProTable } from 'ele-admin-pro/es';
+  import type {
+    DatasourceFunction,
+    ColumnItem,
+    EleProTableDone
+  } from 'ele-admin-pro/es/ele-pro-table/types';
+  import DictData from './components/dict-data.vue';
+  import DictEdit from './components/dict-edit.vue';
+  import { listDictionaries, removeDictionary } from '@/api/system/dictionary';
+  import type { Dictionary } from '@/api/system/dictionary/model';
+
+  // 表格实例
+  const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
+
+  // 表格列配置
+  const columns = ref<ColumnItem[]>([
+    {
+      key: 'index',
+      width: 32,
+      align: 'center',
+      fixed: 'left',
+      hideInSetting: true,
+      customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
+    },
+    {
+      title: '字典名称',
+      dataIndex: 'dictName'
+    }
+  ]);
+
+  // 表格选中数据
+  const current = ref<Dictionary | null>(null);
+
+  // 是否显示编辑弹窗
+  const showEdit = ref(false);
+
+  // 编辑回显数据
+  const editData = ref<Dictionary | null>(null);
+
+  // 表格数据源
+  const datasource: DatasourceFunction = () => {
+    return listDictionaries();
+  };
+
+  /* 表格渲染完成回调 */
+  const done: EleProTableDone<Dictionary> = (res) => {
+    if (res.data?.length) {
+      current.value = res.data[0];
+    }
+  };
+
+  /* 刷新表格 */
+  const reload = () => {
+    tableRef?.value?.reload();
+  };
+
+  /* 打开编辑弹窗 */
+  const openEdit = (row?: Dictionary | null) => {
+    editData.value = row ?? null;
+    showEdit.value = true;
+  };
+
+  /* 删除 */
+  const remove = () => {
+    Modal.confirm({
+      title: '提示',
+      content: '确定要删除选中的字典吗?',
+      icon: createVNode(ExclamationCircleOutlined),
+      maskClosable: true,
+      onOk: () => {
+        const hide = messageLoading('请求中..', 0);
+        removeDictionary(current.value?.dictId)
+          .then((msg) => {
+            hide();
+            message.success(msg);
+            reload();
+          })
+          .catch((e) => {
+            hide();
+            message.error(e.message);
+          });
+      }
+    });
+  };
+
+  /* 行点击事件 */
+  const customRow = (record: Dictionary) => {
+    return {
+      onClick: () => {
+        current.value = record;
+      }
+    };
+  };
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'SystemDictionary'
+  };
+</script>
+
+<style lang="less" scoped>
+  .sys-dict-table {
+    :deep(.ant-table-body) {
+      overflow: auto !important;
+      overflow: overlay !important;
+    }
+
+    :deep(.ant-table-row) {
+      cursor: pointer;
+    }
+  }
+</style>
diff --git a/src/views-demo/system/file/components/file-search.vue b/src/views-demo/system/file/components/file-search.vue
new file mode 100644
index 0000000..bd9ded8
--- /dev/null
+++ b/src/views-demo/system/file/components/file-search.vue
@@ -0,0 +1,106 @@
+<!-- 搜索表单 -->
+<template>
+  <a-form
+    :label-col="
+      styleResponsive ? { xl: 7, lg: 5, md: 7, sm: 4 } : { flex: '90px' }
+    "
+    :wrapper-col="
+      styleResponsive ? { xl: 17, lg: 19, md: 17, sm: 20 } : { flex: '1' }
+    "
+  >
+    <a-row :gutter="8">
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="文件名称">
+          <a-input
+            v-model:value.trim="form.name"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="文件路径">
+          <a-input
+            v-model:value.trim="form.path"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="上传人">
+          <a-input
+            v-model:value.trim="form.createNickname"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item class="ele-text-right" :wrapper-col="{ span: 24 }">
+          <a-space>
+            <a-button type="primary" @click="search">查询</a-button>
+            <a-button @click="reset">重置</a-button>
+          </a-space>
+        </a-form-item>
+      </a-col>
+    </a-row>
+  </a-form>
+</template>
+
+<script lang="ts" setup>
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import type { FileRecordParam } from '@/api/system/file/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'search', where?: FileRecordParam): void;
+  }>();
+
+  // 表单数据
+  const { form, resetFields } = useFormData<FileRecordParam>({
+    name: '',
+    path: '',
+    createNickname: ''
+  });
+
+  /* 搜索 */
+  const search = () => {
+    emit('search', form);
+  };
+
+  /*  重置 */
+  const reset = () => {
+    resetFields();
+    search();
+  };
+</script>
diff --git a/src/views-demo/system/file/index.vue b/src/views-demo/system/file/index.vue
new file mode 100644
index 0000000..e2e5cb4
--- /dev/null
+++ b/src/views-demo/system/file/index.vue
@@ -0,0 +1,244 @@
+<template>
+  <div class="ele-body">
+    <a-card :bordered="false">
+      <!-- 搜索表单 -->
+      <file-search @search="reload" />
+      <!-- 表格 -->
+      <ele-pro-table
+        ref="tableRef"
+        row-key="id"
+        :columns="columns"
+        :datasource="datasource"
+        v-model:selection="selection"
+        :scroll="{ x: 800 }"
+        cache-key="proSystemFileTable"
+      >
+        <template #toolbar>
+          <a-space>
+            <a-upload :show-upload-list="false" :customRequest="onUpload">
+              <a-button type="primary" class="ele-btn-icon">
+                <template #icon>
+                  <upload-outlined />
+                </template>
+                <span>上传</span>
+              </a-button>
+            </a-upload>
+            <a-button
+              danger
+              type="primary"
+              class="ele-btn-icon"
+              @click="removeBatch"
+            >
+              <template #icon>
+                <delete-outlined />
+              </template>
+              <span>删除</span>
+            </a-button>
+          </a-space>
+        </template>
+        <template #bodyCell="{ column, record }">
+          <template v-if="column.key === 'path'">
+            <a :href="record.url" target="_blank">
+              {{ record.path }}
+            </a>
+          </template>
+          <template v-else-if="column.key === 'action'">
+            <a-space>
+              <a :href="record.downloadUrl" target="_blank">下载</a>
+              <a-divider type="vertical" />
+              <a-popconfirm
+                placement="topRight"
+                title="确定要删除此文件吗?"
+                @confirm="remove(record)"
+              >
+                <a class="ele-text-danger">删除</a>
+              </a-popconfirm>
+            </a-space>
+          </template>
+        </template>
+      </ele-pro-table>
+    </a-card>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { createVNode, ref } from 'vue';
+  import { message, Modal } from 'ant-design-vue/es';
+  import {
+    UploadOutlined,
+    DeleteOutlined,
+    ExclamationCircleOutlined
+  } from '@ant-design/icons-vue';
+  import type { EleProTable } from 'ele-admin-pro/es';
+  import type {
+    DatasourceFunction,
+    ColumnItem
+  } from 'ele-admin-pro/es/ele-pro-table/types';
+  import { messageLoading, toDateString } from 'ele-admin-pro/es';
+  import FileSearch from './components/file-search.vue';
+  import {
+    pageFiles,
+    removeFile,
+    removeFiles,
+    uploadFile
+  } from '@/api/system/file';
+  import type { FileRecord, FileRecordParam } from '@/api/system/file/model';
+
+  // 表格实例
+  const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
+
+  // 表格列配置
+  const columns = ref<ColumnItem[]>([
+    {
+      key: 'index',
+      width: 48,
+      align: 'center',
+      fixed: 'left',
+      hideInSetting: true,
+      customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
+    },
+    {
+      title: '文件名称',
+      dataIndex: 'name',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true
+    },
+    {
+      title: '文件路径',
+      key: 'path',
+      dataIndex: 'path',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true
+    },
+    {
+      title: '文件大小',
+      dataIndex: 'length',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true,
+      customRender: ({ text }) => {
+        if (text < 1024) {
+          return text + 'B';
+        } else if (text < 1024 * 1024) {
+          return (text / 1024).toFixed(1) + 'KB';
+        } else if (text < 1024 * 1024 * 1024) {
+          return (text / 1024 / 1024).toFixed(1) + 'M';
+        } else {
+          return (text / 1024 / 1024 / 1024).toFixed(1) + 'G';
+        }
+      },
+      width: 120
+    },
+    {
+      title: '上传人',
+      dataIndex: 'createNickname',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true,
+      width: 120
+    },
+    {
+      title: '上传时间',
+      dataIndex: 'createTime',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true,
+      customRender: ({ text }) => toDateString(text),
+      width: 160
+    },
+    {
+      title: '操作',
+      key: 'action',
+      width: 120,
+      align: 'center'
+    }
+  ]);
+
+  // 表格选中数据
+  const selection = ref<FileRecord[]>([]);
+
+  // 表格数据源
+  const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
+    return pageFiles({ ...where, ...orders, page, limit });
+  };
+
+  /* 搜索 */
+  const reload = (where?: FileRecordParam) => {
+    selection.value = [];
+    tableRef?.value?.reload({ page: 1, where });
+  };
+
+  /* 删除单个 */
+  const remove = (row: FileRecord) => {
+    const hide = messageLoading('请求中..', 0);
+    removeFile(row.id)
+      .then((msg) => {
+        hide();
+        message.success(msg);
+        reload();
+      })
+      .catch((e) => {
+        hide();
+        message.error(e.message);
+      });
+  };
+
+  /* 批量删除 */
+  const removeBatch = () => {
+    if (!selection.value.length) {
+      message.error('请至少选择一条数据');
+      return;
+    }
+    Modal.confirm({
+      title: '提示',
+      content: '确定要删除选中的文件吗?',
+      icon: createVNode(ExclamationCircleOutlined),
+      maskClosable: true,
+      onOk: () => {
+        const hide = messageLoading('请求中..', 0);
+        removeFiles(selection.value.map((d) => d.id))
+          .then((msg) => {
+            hide();
+            message.success(msg);
+            reload();
+          })
+          .catch((e) => {
+            hide();
+            message.error(e.message);
+          });
+      }
+    });
+  };
+
+  /* 上传 */
+  const onUpload = ({ file }) => {
+    if (file.size / 1024 / 1024 > 100) {
+      message.error('大小不能超过 100MB');
+      return false;
+    }
+    const hide = messageLoading({
+      content: '上传中..',
+      duration: 0,
+      mask: true
+    });
+    uploadFile(file)
+      .then(() => {
+        hide();
+        message.success('上传成功');
+        reload();
+      })
+      .catch((e) => {
+        hide();
+        message.error(e.message);
+      });
+    return false;
+  };
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'SystemFile'
+  };
+</script>
diff --git a/src/views-demo/system/login-record/components/login-record-search.vue b/src/views-demo/system/login-record/components/login-record-search.vue
new file mode 100644
index 0000000..c1c480b
--- /dev/null
+++ b/src/views-demo/system/login-record/components/login-record-search.vue
@@ -0,0 +1,115 @@
+<!-- 搜索表单 -->
+<template>
+  <a-form
+    :label-col="
+      styleResponsive ? { xl: 7, lg: 5, md: 7, sm: 4 } : { flex: '90px' }
+    "
+    :wrapper-col="
+      styleResponsive ? { xl: 17, lg: 19, md: 17, sm: 20 } : { flex: '1' }
+    "
+  >
+    <a-row :gutter="8">
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="用户账号">
+          <a-input
+            v-model:value.trim="form.username"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="用户名">
+          <a-input
+            v-model:value.trim="form.nickname"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="登录时间">
+          <a-range-picker
+            v-model:value="dateRange"
+            value-format="YYYY-MM-DD"
+            class="ele-fluid"
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item class="ele-text-right" :wrapper-col="{ span: 24 }">
+          <a-space>
+            <a-button type="primary" @click="search">查询</a-button>
+            <a-button @click="reset">重置</a-button>
+          </a-space>
+        </a-form-item>
+      </a-col>
+    </a-row>
+  </a-form>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import type { LoginRecordParam } from '@/api/system/login-record/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'search', where?: LoginRecordParam): void;
+  }>();
+
+  // 表单数据
+  const { form, resetFields } = useFormData<LoginRecordParam>({
+    username: '',
+    nickname: ''
+  });
+
+  // 日期范围选择
+  const dateRange = ref<[string, string]>(['', '']);
+
+  /* 搜索 */
+  const search = () => {
+    const [d1, d2] = dateRange.value ?? [];
+    emit('search', {
+      ...form,
+      createTimeStart: d1 ? d1 + ' 00:00:00' : '',
+      createTimeEnd: d2 ? d2 + ' 23:59:59' : ''
+    });
+  };
+
+  /*  重置 */
+  const reset = () => {
+    resetFields();
+    dateRange.value = ['', ''];
+    search();
+  };
+</script>
diff --git a/src/views-demo/system/login-record/index.vue b/src/views-demo/system/login-record/index.vue
new file mode 100644
index 0000000..2f62337
--- /dev/null
+++ b/src/views-demo/system/login-record/index.vue
@@ -0,0 +1,235 @@
+<template>
+  <div class="ele-body">
+    <a-card :bordered="false">
+      <!-- 搜索表单 -->
+      <login-record-search @search="reload" />
+      <!-- 表格 -->
+      <ele-pro-table
+        ref="tableRef"
+        row-key="id"
+        :columns="columns"
+        :datasource="datasource"
+        :scroll="{ x: 900 }"
+        cache-key="proSystemLoginRecordTable"
+      >
+        <template #toolbar>
+          <a-space>
+            <a-button type="primary" class="ele-btn-icon" @click="exportData">
+              <template #icon>
+                <download-outlined />
+              </template>
+              <span>导出</span>
+            </a-button>
+          </a-space>
+        </template>
+        <template #bodyCell="{ column, record }">
+          <template v-if="column.key === 'loginType'">
+            <a-tag v-if="record.loginType === 0" color="green">登录成功</a-tag>
+            <a-tag v-else-if="record.loginType === 1" color="red">
+              登录失败
+            </a-tag>
+            <a-tag v-else-if="record.loginType === 2">退出登录</a-tag>
+            <a-tag v-else-if="record.loginType === 3" color="orange">
+              刷新TOKEN
+            </a-tag>
+          </template>
+        </template>
+      </ele-pro-table>
+    </a-card>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import { utils, writeFile } from 'xlsx';
+  import { DownloadOutlined } from '@ant-design/icons-vue';
+  import type { EleProTable } from 'ele-admin-pro/es';
+  import type {
+    DatasourceFunction,
+    ColumnItem
+  } from 'ele-admin-pro/es/ele-pro-table/types';
+  import { messageLoading, toDateString } from 'ele-admin-pro/es';
+  import LoginRecordSearch from './components/login-record-search.vue';
+  import {
+    pageLoginRecords,
+    listLoginRecords
+  } from '@/api/system/login-record';
+  import type { LoginRecordParam } from '@/api/system/login-record/model';
+
+  // 表格实例
+  const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
+
+  // 表格列配置
+  const columns = ref<ColumnItem[]>([
+    {
+      key: 'index',
+      width: 48,
+      align: 'center',
+      fixed: 'left',
+      hideInSetting: true,
+      customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
+    },
+    {
+      title: '账号',
+      dataIndex: 'username',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '用户名',
+      dataIndex: 'nickname',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: 'IP地址',
+      dataIndex: 'ip',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true
+    },
+    {
+      title: '设备型号',
+      dataIndex: 'device',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true
+    },
+    {
+      title: '操作系统',
+      dataIndex: 'os',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true
+    },
+    {
+      title: '浏览器',
+      dataIndex: 'browser',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true
+    },
+    {
+      title: '操作类型',
+      key: 'loginType',
+      dataIndex: 'loginType',
+      sorter: true,
+      showSorterTooltip: false,
+      width: 120,
+      filters: [
+        {
+          text: '登录成功',
+          value: 0
+        },
+        {
+          text: '登录失败',
+          value: 1
+        },
+        {
+          text: '退出登录',
+          value: 2
+        },
+        {
+          text: '刷新TOKEN',
+          value: 3
+        }
+      ],
+      filterMultiple: false
+    },
+    {
+      title: '备注',
+      dataIndex: 'comments',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true
+    },
+    {
+      title: '登录时间',
+      dataIndex: 'createTime',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true,
+      customRender: ({ text }) => toDateString(text)
+    }
+  ]);
+
+  // 表格数据源
+  const datasource: DatasourceFunction = ({
+    page,
+    limit,
+    where,
+    orders,
+    filters
+  }) => {
+    return pageLoginRecords({
+      ...where,
+      ...orders,
+      ...filters,
+      page,
+      limit
+    });
+  };
+
+  /* 刷新表格 */
+  const reload = (where?: LoginRecordParam) => {
+    tableRef?.value?.reload({ page: 1, where });
+  };
+
+  /* 导出数据 */
+  const exportData = () => {
+    const array = [
+      [
+        '账号',
+        '用户名',
+        'IP地址',
+        '设备型号',
+        '操作系统',
+        '浏览器',
+        '操作类型',
+        '备注',
+        '登录时间'
+      ]
+    ];
+    // 请求查询全部接口
+    const hide = messageLoading('请求中..', 0);
+    tableRef.value?.doRequest(({ where, orders, filters }) => {
+      listLoginRecords({ ...where, ...orders, ...filters })
+        .then((data) => {
+          hide();
+          data.forEach((d) => {
+            array.push([
+              d.username,
+              d.nickname,
+              d.ip,
+              d.device,
+              d.os,
+              d.browser,
+              ['登录成功', '登录失败', '退出登录', '刷新TOKEN'][d.loginType],
+              d.comments,
+              toDateString(d.createTime)
+            ]);
+          });
+          writeFile(
+            {
+              SheetNames: ['Sheet1'],
+              Sheets: {
+                Sheet1: utils.aoa_to_sheet(array)
+              }
+            },
+            '登录日志.xlsx'
+          );
+        })
+        .catch((e) => {
+          hide();
+          message.error(e.message);
+        });
+    });
+  };
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'SystemLoginRecord'
+  };
+</script>
diff --git a/src/views-demo/system/menu/components/menu-edit.vue b/src/views-demo/system/menu/components/menu-edit.vue
new file mode 100644
index 0000000..f336a0d
--- /dev/null
+++ b/src/views-demo/system/menu/components/menu-edit.vue
@@ -0,0 +1,414 @@
+<!-- 编辑弹窗 -->
+<template>
+  <ele-modal
+    :width="740"
+    :visible="visible"
+    :confirm-loading="loading"
+    :title="isUpdate ? '修改菜单' : '新建菜单'"
+    :body-style="{ paddingBottom: '8px' }"
+    @update:visible="updateVisible"
+    @ok="save"
+  >
+    <a-form
+      ref="formRef"
+      :model="form"
+      :rules="rules"
+      :label-col="styleResponsive ? { md: 6, sm: 4, xs: 24 } : { flex: '90px' }"
+      :wrapper-col="
+        styleResponsive ? { md: 18, sm: 20, xs: 24 } : { flex: '1' }
+      "
+    >
+      <a-row :gutter="16">
+        <a-col
+          v-bind="styleResponsive ? { md: 12, sm: 24, xs: 24 } : { span: 12 }"
+        >
+          <a-form-item label="上级菜单" name="parentId">
+            <a-tree-select
+              allow-clear
+              :tree-data="menuList"
+              tree-default-expand-all
+              placeholder="请选择上级菜单"
+              :value="form.parentId || undefined"
+              :dropdown-style="{ maxHeight: '360px', overflow: 'auto' }"
+              @update:value="(value?: number) => (form.parentId = value)"
+            />
+          </a-form-item>
+          <a-form-item label="菜单名称" name="title">
+            <a-input
+              allow-clear
+              placeholder="请输入菜单名称"
+              v-model:value="form.title"
+            />
+          </a-form-item>
+        </a-col>
+        <a-col
+          v-bind="styleResponsive ? { md: 12, sm: 24, xs: 24 } : { span: 12 }"
+        >
+          <a-form-item label="菜单类型" name="menuType">
+            <a-radio-group
+              v-model:value="form.menuType"
+              @change="onMenuTypeChange"
+            >
+              <a-radio :value="0">目录</a-radio>
+              <a-radio :value="1">菜单</a-radio>
+              <a-radio :value="2">按钮</a-radio>
+            </a-radio-group>
+          </a-form-item>
+          <a-form-item label="打开方式">
+            <a-radio-group
+              v-model:value="form.openType"
+              :disabled="form.menuType === 0 || form.menuType === 2"
+              @change="onOpenTypeChange"
+            >
+              <a-radio :value="0">组件</a-radio>
+              <a-radio :value="1">内链</a-radio>
+              <a-radio :value="2">外链</a-radio>
+            </a-radio-group>
+          </a-form-item>
+        </a-col>
+      </a-row>
+      <div style="margin-bottom: 22px">
+        <a-divider />
+      </div>
+      <a-row :gutter="16">
+        <a-col
+          v-bind="styleResponsive ? { md: 12, sm: 24, xs: 24 } : { span: 12 }"
+        >
+          <a-form-item label="菜单图标" name="icon">
+            <ele-icon-picker
+              :data="iconData"
+              :allow-search="false"
+              v-model:value="form.icon"
+              placeholder="请选择菜单图标"
+              :disabled="form.menuType === 2"
+            >
+              <template #icon="{ icon }">
+                <component :is="icon" />
+              </template>
+            </ele-icon-picker>
+          </a-form-item>
+          <a-form-item name="path">
+            <template #label>
+              <a-tooltip
+                v-if="form.openType === 2"
+                title="需要以`http://`、`https://`、`//`开头"
+              >
+                <question-circle-outlined
+                  style="vertical-align: -2px; margin-right: 4px"
+                />
+              </a-tooltip>
+              <span>{{ form.openType === 2 ? '外链地址' : '路由地址' }}</span>
+            </template>
+            <a-input
+              allow-clear
+              v-model:value="form.path"
+              :disabled="form.menuType === 2"
+              :placeholder="
+                form.openType === 2 ? '请输入外链地址' : '请输入路由地址'
+              "
+            />
+          </a-form-item>
+          <a-form-item name="component">
+            <template #label>
+              <a-tooltip
+                v-if="form.openType === 1"
+                title="需要以`http://`、`https://`、`//`开头"
+              >
+                <question-circle-outlined
+                  style="vertical-align: -2px; margin-right: 4px"
+                />
+              </a-tooltip>
+              <span>{{ form.openType === 1 ? '内链地址' : '组件路径' }}</span>
+            </template>
+            <a-input
+              allow-clear
+              v-model:value="form.component"
+              :disabled="
+                form.menuType === 0 ||
+                form.menuType === 2 ||
+                form.openType === 2
+              "
+              :placeholder="
+                form.openType === 1 ? '请输入内链地址' : '请输入组件路径'
+              "
+            />
+          </a-form-item>
+        </a-col>
+        <a-col
+          v-bind="styleResponsive ? { md: 12, sm: 24, xs: 24 } : { span: 12 }"
+        >
+          <a-form-item label="权限标识" name="authority">
+            <a-input
+              allow-clear
+              placeholder="请输入权限标识"
+              v-model:value="form.authority"
+              :disabled="
+                form.menuType === 0 ||
+                (form.menuType === 1 && form.openType === 2)
+              "
+            />
+          </a-form-item>
+          <a-form-item label="排序号" name="sortNumber">
+            <a-input-number
+              :min="0"
+              :max="99999"
+              class="ele-fluid"
+              placeholder="请输入排序号"
+              v-model:value="form.sortNumber"
+            />
+          </a-form-item>
+          <a-form-item label="是否展示">
+            <a-switch
+              checked-children="是"
+              un-checked-children="否"
+              :checked="form.hide === 0"
+              :disabled="form.menuType === 2"
+              @update:checked="updateHideValue"
+            />
+            <a-tooltip
+              title="选择不展示只注册路由不展示在侧边栏, 比如添加页面应该选择不展示"
+            >
+              <question-circle-outlined
+                style="vertical-align: -4px; margin-left: 16px"
+              />
+            </a-tooltip>
+          </a-form-item>
+        </a-col>
+      </a-row>
+      <a-form-item
+        label="路由元数据"
+        name="meta"
+        :label-col="
+          styleResponsive ? { md: 3, sm: 4, xs: 24 } : { flex: '90px' }
+        "
+        :wrapper-col="
+          styleResponsive ? { md: 21, sm: 20, xs: 24 } : { flex: '1' }
+        "
+      >
+        <a-textarea
+          :rows="4"
+          :maxlength="200"
+          placeholder="请输入JSON格式的路由元数据"
+          v-model:value="form.meta"
+        />
+      </a-form-item>
+    </a-form>
+  </ele-modal>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive, watch } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import { QuestionCircleOutlined } from '@ant-design/icons-vue';
+  import { isExternalLink } from 'ele-admin-pro/es';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import { addMenu, updateMenu } from '@/api/system/menu';
+  import type { Menu } from '@/api/system/menu/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'done'): void;
+    (e: 'update:visible', visible: boolean): void;
+  }>();
+
+  const props = defineProps<{
+    // 弹窗是否打开
+    visible: boolean;
+    // 修改回显的数据
+    data?: Menu | null;
+    // 上级菜单id
+    parentId?: number;
+    // 全部菜单数据
+    menuList: Menu[];
+  }>();
+
+  //
+  const formRef = ref<FormInstance | null>(null);
+
+  // 是否是修改
+  const isUpdate = ref(false);
+
+  // 提交状态
+  const loading = ref(false);
+
+  // 表单数据
+  const { form, resetFields, assignFields } = useFormData<Menu>({
+    menuId: undefined,
+    parentId: undefined,
+    title: '',
+    menuType: 0,
+    openType: 0,
+    icon: '',
+    path: '',
+    component: '',
+    authority: '',
+    sortNumber: undefined,
+    hide: 0,
+    meta: ''
+  });
+
+  // 表单验证规则
+  const rules = reactive<Record<string, Rule[]>>({
+    title: [
+      {
+        required: true,
+        message: '请输入菜单名称',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    sortNumber: [
+      {
+        required: true,
+        message: '请输入排序号',
+        type: 'number',
+        trigger: 'blur'
+      }
+    ],
+    meta: [
+      {
+        type: 'string',
+        validator: async (_rule: Rule, value: string) => {
+          if (value) {
+            const msg = '请输入正确的JSON格式';
+            try {
+              const obj = JSON.parse(value);
+              if (typeof obj !== 'object' || obj === null) {
+                return Promise.reject(msg);
+              }
+            } catch (_e) {
+              return Promise.reject(msg);
+            }
+          }
+          return Promise.resolve();
+        },
+        trigger: 'blur'
+      }
+    ]
+  });
+
+  /* 保存编辑 */
+  const save = () => {
+    if (!formRef.value) {
+      return;
+    }
+    formRef.value
+      .validate()
+      .then(() => {
+        loading.value = true;
+        const menuForm = {
+          ...form,
+          // menuType 对应的值与后端不一致在前端处理
+          menuType: form.menuType === 2 ? 1 : 0,
+          parentId: form.parentId || 0
+        };
+        const saveOrUpdate = isUpdate.value ? updateMenu : addMenu;
+        saveOrUpdate(menuForm)
+          .then((msg) => {
+            loading.value = false;
+            message.success(msg);
+            updateVisible(false);
+            emit('done');
+          })
+          .catch((e) => {
+            loading.value = false;
+            message.error(e.message);
+          });
+      })
+      .catch(() => {});
+  };
+
+  /* 更新visible */
+  const updateVisible = (value: boolean) => {
+    emit('update:visible', value);
+  };
+
+  /* menuType选择改变 */
+  const onMenuTypeChange = () => {
+    if (form.menuType === 0) {
+      form.authority = '';
+      form.openType = 0;
+      form.component = '';
+    } else if (form.menuType === 1) {
+      if (form.openType === 2) {
+        form.authority = '';
+      }
+    } else {
+      form.openType = 0;
+      form.icon = '';
+      form.path = '';
+      form.component = '';
+      form.hide = 0;
+    }
+  };
+
+  /* openType选择改变 */
+  const onOpenTypeChange = () => {
+    if (form.openType === 2) {
+      form.component = '';
+      form.authority = '';
+    }
+  };
+
+  const updateHideValue = (value: boolean) => {
+    form.hide = value ? 0 : 1;
+  };
+
+  /* 判断是否是目录 */
+  const isDirectory = (d: Menu) => {
+    return !!d.children?.length && !d.component;
+  };
+
+  watch(
+    () => props.visible,
+    (visible) => {
+      if (visible) {
+        if (props.data) {
+          const isExternal = isExternalLink(props.data.path);
+          const isInner = isExternalLink(props.data.component);
+          // menuType 对应的值与后端不一致在前端处理
+          const menuType =
+            props.data.menuType === 1 ? 2 : isDirectory(props.data) ? 0 : 1;
+          assignFields({
+            ...props.data,
+            menuType,
+            openType: isExternal ? 2 : isInner ? 1 : 0,
+            parentId:
+              props.data.parentId === 0 ? undefined : props.data.parentId
+          });
+          isUpdate.value = true;
+        } else {
+          form.parentId = props.parentId;
+          isUpdate.value = false;
+        }
+      } else {
+        resetFields();
+        formRef.value?.clearValidate();
+      }
+    }
+  );
+</script>
+
+<script lang="ts">
+  import * as icons from '@/layout/menu-icons';
+
+  export default {
+    components: icons,
+    data() {
+      return {
+        iconData: [
+          {
+            title: '已引入的图标',
+            icons: Object.keys(icons)
+          }
+        ]
+      };
+    }
+  };
+</script>
diff --git a/src/views-demo/system/menu/components/menu-search.vue b/src/views-demo/system/menu/components/menu-search.vue
new file mode 100644
index 0000000..81afbf5
--- /dev/null
+++ b/src/views-demo/system/menu/components/menu-search.vue
@@ -0,0 +1,106 @@
+<!-- 搜索表单 -->
+<template>
+  <a-form
+    :label-col="
+      styleResponsive ? { xl: 7, lg: 5, md: 7, sm: 4 } : { flex: '90px' }
+    "
+    :wrapper-col="
+      styleResponsive ? { xl: 17, lg: 19, md: 17, sm: 20 } : { flex: '1' }
+    "
+  >
+    <a-row :gutter="8">
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="菜单名称">
+          <a-input
+            v-model:value.trim="form.title"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="菜单地址">
+          <a-input
+            v-model:value.trim="form.path"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="权限标识">
+          <a-input
+            v-model:value.trim="form.authority"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item class="ele-text-right" :wrapper-col="{ span: 24 }">
+          <a-space>
+            <a-button type="primary" @click="search">查询</a-button>
+            <a-button @click="reset">重置</a-button>
+          </a-space>
+        </a-form-item>
+      </a-col>
+    </a-row>
+  </a-form>
+</template>
+
+<script lang="ts" setup>
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import type { MenuParam } from '@/api/system/menu/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'search', where?: MenuParam): void;
+  }>();
+
+  // 表单数据
+  const { form, resetFields } = useFormData<MenuParam>({
+    title: '',
+    path: '',
+    authority: ''
+  });
+
+  /* 搜索 */
+  const search = () => {
+    emit('search', form);
+  };
+
+  /*  重置 */
+  const reset = () => {
+    resetFields();
+    search();
+  };
+</script>
diff --git a/src/views-demo/system/menu/index.vue b/src/views-demo/system/menu/index.vue
new file mode 100644
index 0000000..24a24e1
--- /dev/null
+++ b/src/views-demo/system/menu/index.vue
@@ -0,0 +1,291 @@
+<template>
+  <div class="ele-body">
+    <a-card :bordered="false">
+      <!-- 搜索表单 -->
+      <menu-search @search="reload" />
+      <!-- 表格 -->
+      <ele-pro-table
+        ref="tableRef"
+        row-key="menuId"
+        :columns="columns"
+        :datasource="datasource"
+        :parse-data="parseData"
+        :need-page="false"
+        :expand-icon-column-index="1"
+        :expanded-row-keys="expandedRowKeys"
+        :scroll="{ x: 1200 }"
+        cache-key="proSystemMenuTable"
+        @done="onDone"
+        @expand="onExpand"
+      >
+        <template #toolbar>
+          <a-space>
+            <a-button type="primary" class="ele-btn-icon" @click="openEdit()">
+              <template #icon>
+                <plus-outlined />
+              </template>
+              <span>新建</span>
+            </a-button>
+            <a-button type="dashed" class="ele-btn-icon" @click="expandAll">
+              展开全部
+            </a-button>
+            <a-button type="dashed" class="ele-btn-icon" @click="foldAll">
+              折叠全部
+            </a-button>
+          </a-space>
+        </template>
+        <template #bodyCell="{ column, record }">
+          <template v-if="column.key === 'menuType'">
+            <a-tag v-if="isExternalLink(record.path)" color="red">外链</a-tag>
+            <a-tag v-else-if="isExternalLink(record.component)" color="orange">
+              内链
+            </a-tag>
+            <a-tag v-else-if="isDirectory(record)" color="blue">目录</a-tag>
+            <a-tag v-else-if="record.menuType === 0" color="green">菜单</a-tag>
+            <a-tag v-else-if="record.menuType === 1">按钮</a-tag>
+          </template>
+          <template v-else-if="column.key === 'title'">
+            <component v-if="record.icon" :is="record.icon" />
+            <span style="padding-left: 8px">{{ record.title }}</span>
+          </template>
+          <template v-else-if="column.key === 'action'">
+            <a-space>
+              <a @click="openEdit(null, record.menuId)">添加</a>
+              <a-divider type="vertical" />
+              <a @click="openEdit(record)">修改</a>
+              <a-divider type="vertical" />
+              <a-popconfirm
+                placement="topRight"
+                title="确定要删除此菜单吗?"
+                @confirm="remove(record)"
+              >
+                <a class="ele-text-danger">删除</a>
+              </a-popconfirm>
+            </a-space>
+          </template>
+        </template>
+      </ele-pro-table>
+    </a-card>
+    <!-- 编辑弹窗 -->
+    <menu-edit
+      v-model:visible="showEdit"
+      :data="current"
+      :parent-id="parentId"
+      :menu-list="menuData"
+      @done="reload"
+    />
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import { PlusOutlined } from '@ant-design/icons-vue';
+  import type {
+    DatasourceFunction,
+    ColumnItem,
+    EleProTableDone
+  } from 'ele-admin-pro/es/ele-pro-table/types';
+  import MenuSearch from './components/menu-search.vue';
+  import {
+    messageLoading,
+    toDateString,
+    isExternalLink,
+    toTreeData,
+    eachTreeData
+  } from 'ele-admin-pro/es';
+  import type { EleProTable } from 'ele-admin-pro/es';
+  import MenuEdit from './components/menu-edit.vue';
+  import { listMenus, removeMenu } from '@/api/system/menu';
+  import type { Menu, MenuParam } from '@/api/system/menu/model';
+
+  // 表格实例
+  const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
+
+  // 表格列配置
+  const columns = ref<ColumnItem[]>([
+    {
+      key: 'index',
+      width: 48,
+      align: 'center',
+      fixed: 'left',
+      hideInSetting: true,
+      customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
+    },
+    {
+      title: '菜单名称',
+      key: 'title',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true
+    },
+    {
+      title: '路由地址',
+      dataIndex: 'path',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true
+    },
+    {
+      title: '组件路径',
+      dataIndex: 'component',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true
+    },
+    {
+      title: '权限标识',
+      dataIndex: 'authority',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true
+    },
+    {
+      title: '排序',
+      dataIndex: 'sortNumber',
+      sorter: true,
+      showSorterTooltip: false,
+      width: 90
+    },
+    {
+      title: '可见',
+      dataIndex: 'hide',
+      sorter: true,
+      showSorterTooltip: false,
+      customRender: ({ text }) => ['是', '否'][text],
+      width: 90
+    },
+    {
+      title: '类型',
+      key: 'menuType',
+      sorter: true,
+      showSorterTooltip: false,
+      width: 90
+    },
+    {
+      title: '创建时间',
+      dataIndex: 'createTime',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true,
+      customRender: ({ text }) => toDateString(text)
+    },
+    {
+      title: '操作',
+      key: 'action',
+      width: 200,
+      align: 'center'
+    }
+  ]);
+
+  // 当前编辑数据
+  const current = ref<Menu | null>(null);
+
+  // 是否显示编辑弹窗
+  const showEdit = ref(false);
+
+  // 上级菜单id
+  const parentId = ref<number>();
+
+  // 菜单数据
+  const menuData = ref<Menu[]>([]);
+
+  // 表格展开的行
+  const expandedRowKeys = ref<number[]>([]);
+
+  // 表格数据源
+  const datasource: DatasourceFunction = ({ where }) => {
+    return listMenus({ ...where });
+  };
+
+  /* 数据转为树形结构 */
+  const parseData = (data: Menu[]) => {
+    return toTreeData({
+      data: data.map((d) => {
+        return { ...d, key: d.menuId, value: d.menuId };
+      }),
+      idField: 'menuId',
+      parentIdField: 'parentId'
+    });
+  };
+
+  /* 表格渲染完成回调 */
+  const onDone: EleProTableDone<Menu> = ({ data }) => {
+    menuData.value = data;
+  };
+
+  /* 刷新表格 */
+  const reload = (where?: MenuParam) => {
+    tableRef?.value?.reload({ where });
+  };
+
+  /* 打开编辑弹窗 */
+  const openEdit = (row?: Menu | null, id?: number) => {
+    current.value = row ?? null;
+    parentId.value = id;
+    showEdit.value = true;
+  };
+
+  /* 删除单个 */
+  const remove = (row: Menu) => {
+    if (row.children?.length) {
+      message.error('请先删除子节点');
+      return;
+    }
+    const hide = messageLoading('请求中..', 0);
+    removeMenu(row.menuId)
+      .then((msg) => {
+        hide();
+        message.success(msg);
+        reload();
+      })
+      .catch((e) => {
+        hide();
+        message.error(e.message);
+      });
+  };
+
+  /* 展开全部 */
+  const expandAll = () => {
+    let keys: number[] = [];
+    eachTreeData(menuData.value, (d) => {
+      if (d.children && d.children.length && d.menuId) {
+        keys.push(d.menuId);
+      }
+    });
+    expandedRowKeys.value = keys;
+  };
+
+  /* 折叠全部 */
+  const foldAll = () => {
+    expandedRowKeys.value = [];
+  };
+
+  /* 点击展开图标时触发 */
+  const onExpand = (expanded: boolean, record: Menu) => {
+    if (expanded) {
+      expandedRowKeys.value = [
+        ...expandedRowKeys.value,
+        record.menuId as number
+      ];
+    } else {
+      expandedRowKeys.value = expandedRowKeys.value.filter(
+        (d) => d !== record.menuId
+      );
+    }
+  };
+
+  /* 判断是否是目录 */
+  const isDirectory = (d: Menu) => {
+    return !!d.children?.length && !d.component;
+  };
+</script>
+
+<script lang="ts">
+  import * as MenuIcons from '@/layout/menu-icons';
+
+  export default {
+    name: 'SystemMenu',
+    components: MenuIcons
+  };
+</script>
diff --git a/src/views-demo/system/operation-record/components/operation-record-detail.vue b/src/views-demo/system/operation-record/components/operation-record-detail.vue
new file mode 100644
index 0000000..deb5740
--- /dev/null
+++ b/src/views-demo/system/operation-record/components/operation-record-detail.vue
@@ -0,0 +1,131 @@
+<!-- 详情弹窗 -->
+<template>
+  <ele-modal
+    title="详情"
+    :width="640"
+    :footer="null"
+    :visible="visible"
+    @update:visible="updateVisible"
+  >
+    <a-form
+      class="ele-form-detail"
+      :label-col="{ sm: { span: 8 }, xs: { span: 6 } }"
+      :wrapper-col="{ sm: { span: 16 }, xs: { span: 18 } }"
+    >
+      <a-row :gutter="16">
+        <a-col :sm="12" :xs="24">
+          <a-form-item label="操作人">
+            <div class="ele-text-secondary">
+              {{ data.nickname }}({{ data.username }})
+            </div>
+          </a-form-item>
+          <a-form-item label="操作模块">
+            <div class="ele-text-secondary">
+              {{ data.module }}
+            </div>
+          </a-form-item>
+          <a-form-item label="操作时间">
+            <div class="ele-text-secondary">
+              {{ toDateString(data.createTime) }}
+            </div>
+          </a-form-item>
+          <a-form-item label="请求方式">
+            <div class="ele-text-secondary">
+              {{ data.requestMethod }}
+            </div>
+          </a-form-item>
+        </a-col>
+        <a-col :sm="12" :xs="24">
+          <a-form-item label="IP地址">
+            <div class="ele-text-secondary">
+              {{ data.ip }}
+            </div>
+          </a-form-item>
+          <a-form-item label="操作功能">
+            <div class="ele-text-secondary">
+              {{ data.description }}
+            </div>
+          </a-form-item>
+          <a-form-item label="请求耗时">
+            <div v-if="!isNaN(data.spendTime)" class="ele-text-secondary">
+              {{ data.spendTime / 1000 }}s
+            </div>
+          </a-form-item>
+          <a-form-item label="请求状态">
+            <a-tag :color="['green', 'red'][data.status]">
+              {{ ['正常', '异常'][data.status] }}
+            </a-tag>
+          </a-form-item>
+        </a-col>
+      </a-row>
+      <div style="margin: 12px 0">
+        <a-divider />
+      </div>
+      <a-form-item
+        label="请求地址"
+        :label-col="{ sm: { span: 4 }, xs: { span: 6 } }"
+        :wrapper-col="{ sm: { span: 20 }, xs: { span: 18 } }"
+      >
+        <div class="ele-text-secondary">
+          {{ data.url }}
+        </div>
+      </a-form-item>
+      <a-form-item
+        label="调用方法"
+        :label-col="{ sm: { span: 4 }, xs: { span: 6 } }"
+        :wrapper-col="{ sm: { span: 20 }, xs: { span: 18 } }"
+      >
+        <div class="ele-text-secondary">
+          {{ data.method }}
+        </div>
+      </a-form-item>
+      <a-form-item
+        label="请求参数"
+        :label-col="{ sm: { span: 4 }, xs: { span: 6 } }"
+        :wrapper-col="{ sm: { span: 20 }, xs: { span: 18 } }"
+      >
+        <div class="ele-text-secondary">
+          {{ data.params }}
+        </div>
+      </a-form-item>
+      <a-form-item
+        v-if="data.status === 0"
+        label="返回结果"
+        :label-col="{ sm: { span: 4 }, xs: { span: 6 } }"
+        :wrapper-col="{ sm: { span: 20 }, xs: { span: 18 } }"
+      >
+        <text-ellipsis :content="data.result" class="ele-text-secondary" />
+      </a-form-item>
+      <a-form-item
+        v-else
+        label="异常信息"
+        :label-col="{ sm: { span: 4 }, xs: { span: 6 } }"
+        :wrapper-col="{ sm: { span: 20 }, xs: { span: 18 } }"
+      >
+        <text-ellipsis :content="data.error" class="ele-text-secondary" />
+      </a-form-item>
+    </a-form>
+  </ele-modal>
+</template>
+
+<script lang="ts" setup>
+  import { toDateString } from 'ele-admin-pro/es';
+  import type { OperationRecord } from '@/api/system/operation-record/model';
+  import TextEllipsis from './text-ellipsis.vue';
+
+  const emit = defineEmits<{
+    (e: 'update:visible', visible: boolean): void;
+  }>();
+
+  defineProps<{
+    // 弹窗是否打开
+    visible?: boolean;
+    // 修改回显的数据
+    data: OperationRecord;
+  }>();
+
+  /* 更新visible */
+  const updateVisible = (value: boolean) => {
+    emit('update:visible', value);
+  };
+</script>
diff --git a/src/views-demo/system/operation-record/components/operation-record-search.vue b/src/views-demo/system/operation-record/components/operation-record-search.vue
new file mode 100644
index 0000000..d6e4309
--- /dev/null
+++ b/src/views-demo/system/operation-record/components/operation-record-search.vue
@@ -0,0 +1,112 @@
+<!-- 搜索表单 -->
+<template>
+  <a-form
+    :label-col="
+      styleResponsive ? { xl: 7, lg: 5, md: 7, sm: 4 } : { flex: '90px' }
+    "
+    :wrapper-col="
+      styleResponsive ? { xl: 17, lg: 19, md: 17, sm: 20 } : { flex: '1' }
+    "
+  >
+    <a-row :gutter="8">
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="用户账号">
+          <a-input
+            v-model:value.trim="form.username"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="操作模块">
+          <a-input
+            v-model:value.trim="form.module"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="操作时间">
+          <a-range-picker
+            v-model:value="dateRange"
+            :show-time="true"
+            value-format="YYYY-MM-DD HH:mm:ss"
+            class="ele-fluid"
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item class="ele-text-right" :wrapper-col="{ span: 24 }">
+          <a-space>
+            <a-button type="primary" @click="search">查询</a-button>
+            <a-button @click="reset">重置</a-button>
+          </a-space>
+        </a-form-item>
+      </a-col>
+    </a-row>
+  </a-form>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import type { OperationRecordParam } from '@/api/system/operation-record/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'search', where?: OperationRecordParam): void;
+  }>();
+
+  // 表单数据
+  const { form, resetFields } = useFormData<OperationRecordParam>({
+    username: '',
+    module: ''
+  });
+
+  // 日期范围选择
+  const dateRange = ref<[string, string]>(['', '']);
+
+  /* 搜索 */
+  const search = () => {
+    const [createTimeStart, createTimeEnd] = dateRange.value;
+    emit('search', { ...form, createTimeStart, createTimeEnd });
+  };
+
+  /*  重置 */
+  const reset = () => {
+    resetFields();
+    dateRange.value = ['', ''];
+    search();
+  };
+</script>
diff --git a/src/views-demo/system/operation-record/components/text-ellipsis.vue b/src/views-demo/system/operation-record/components/text-ellipsis.vue
new file mode 100644
index 0000000..05b204a
--- /dev/null
+++ b/src/views-demo/system/operation-record/components/text-ellipsis.vue
@@ -0,0 +1,59 @@
+<!-- 文本超出隐藏 -->
+<template>
+  <div
+    :class="[
+      'demo-text-ellipsis ele-bg-white ele-border-split',
+      { expanded: expanded }
+    ]"
+  >
+    <div>{{ content }}</div>
+    <div
+      class="demo-text-ellipsis-footer ele-border-split ele-bg-white"
+      @click="expanded = !expanded"
+    >
+      <up-outlined v-if="expanded" />
+      <down-outlined v-else />
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import { DownOutlined, UpOutlined } from '@ant-design/icons-vue';
+
+  defineProps<{
+    content?: string;
+  }>();
+
+  const expanded = ref(false);
+</script>
+
+<style lang="less" scoped>
+  .demo-text-ellipsis {
+    border-radius: 4px;
+    padding: 6px 12px 20px 12px;
+    position: relative;
+    border-width: 1px;
+    border-style: solid;
+    word-break: break-all;
+
+    &:not(.expanded) {
+      max-height: 192px;
+      overflow: hidden;
+    }
+  }
+
+  .demo-text-ellipsis-footer {
+    border-top-width: 1px;
+    border-top-style: solid;
+    position: absolute;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    text-align: center;
+    font-size: 12px;
+    cursor: pointer;
+    border-bottom-left-radius: 4px;
+    border-bottom-right-radius: 4px;
+  }
+</style>
diff --git a/src/views-demo/system/operation-record/index.vue b/src/views-demo/system/operation-record/index.vue
new file mode 100644
index 0000000..760464e
--- /dev/null
+++ b/src/views-demo/system/operation-record/index.vue
@@ -0,0 +1,273 @@
+<template>
+  <div class="ele-body">
+    <a-card :bordered="false">
+      <!-- 搜索表单 -->
+      <operation-record-search @search="reload" />
+      <!-- 表格 -->
+      <ele-pro-table
+        ref="tableRef"
+        row-key="id"
+        :columns="columns"
+        :datasource="datasource"
+        :scroll="{ x: 1000 }"
+        cache-key="proSystemOperationRecordTable"
+      >
+        <template #toolbar>
+          <a-space>
+            <a-button type="primary" class="ele-btn-icon" @click="exportData">
+              <template #icon>
+                <download-outlined />
+              </template>
+              <span>导出</span>
+            </a-button>
+          </a-space>
+        </template>
+        <template #bodyCell="{ column, record }">
+          <template v-if="column.key === 'status'">
+            <a-tag v-if="record.status === 0" color="green">正常</a-tag>
+            <a-tag v-else-if="record.status === 1" color="red">异常</a-tag>
+          </template>
+          <template v-else-if="column.key === 'action'">
+            <a @click="openDetail(record)">详情</a>
+          </template>
+        </template>
+      </ele-pro-table>
+    </a-card>
+    <!-- 详情弹窗 -->
+    <operation-record-detail v-model:visible="showInfo" :data="current" />
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import { DownloadOutlined } from '@ant-design/icons-vue';
+  import type { EleProTable } from 'ele-admin-pro/es';
+  import type {
+    DatasourceFunction,
+    ColumnItem
+  } from 'ele-admin-pro/es/ele-pro-table/types';
+  import { utils, writeFile } from 'xlsx';
+  import { messageLoading, toDateString } from 'ele-admin-pro/es';
+  import OperationRecordSearch from './components/operation-record-search.vue';
+  import OperationRecordDetail from './components/operation-record-detail.vue';
+  import {
+    pageOperationRecords,
+    listOperationRecords
+  } from '@/api/system/operation-record';
+  import type {
+    OperationRecord,
+    OperationRecordParam
+  } from '@/api/system/operation-record/model';
+
+  // 表格实例
+  const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
+
+  // 表格列配置
+  const columns = ref<ColumnItem[]>([
+    {
+      key: 'index',
+      width: 48,
+      align: 'center',
+      fixed: 'left',
+      hideInSetting: true,
+      customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
+    },
+    {
+      title: '账号',
+      dataIndex: 'username',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true
+    },
+    {
+      title: '用户名',
+      dataIndex: 'nickname',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true
+    },
+    {
+      title: '操作模块',
+      dataIndex: 'module',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true
+    },
+    {
+      title: '操作功能',
+      dataIndex: 'description',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true
+    },
+    {
+      title: '请求地址',
+      dataIndex: 'url',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true
+    },
+    {
+      title: '请求方式',
+      dataIndex: 'requestMethod',
+      sorter: true,
+      showSorterTooltip: false,
+      width: 100,
+      align: 'center'
+    },
+    {
+      title: '状态',
+      key: 'status',
+      dataIndex: 'status',
+      sorter: true,
+      showSorterTooltip: false,
+      width: 100,
+      filters: [
+        {
+          text: '正常',
+          value: 0
+        },
+        {
+          text: '异常',
+          value: 1
+        }
+      ],
+      filterMultiple: false,
+      align: 'center'
+    },
+    {
+      title: '耗时',
+      dataIndex: 'spendTime',
+      sorter: true,
+      showSorterTooltip: false,
+      width: 100,
+      customRender: ({ text }) => text / 1000 + 's'
+    },
+    {
+      title: '操作时间',
+      dataIndex: 'createTime',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true,
+      customRender: ({ text }) => toDateString(text),
+      align: 'center'
+    },
+    {
+      title: '操作',
+      key: 'action',
+      width: 90,
+      align: 'center',
+      fixed: 'right'
+    }
+  ]);
+
+  // 当前选中数据
+  const current = ref<OperationRecord>({
+    module: '',
+    description: '',
+    url: '',
+    requestMethod: '',
+    method: '',
+    params: '',
+    result: '',
+    error: '',
+    spendTime: 0,
+    os: '',
+    device: '',
+    browser: '',
+    ip: '',
+    status: 0,
+    createTime: '',
+    nickname: '',
+    username: ''
+  });
+
+  // 是否显示查看弹窗
+  const showInfo = ref(false);
+
+  // 表格数据源
+  const datasource: DatasourceFunction = ({
+    page,
+    limit,
+    where,
+    orders,
+    filters
+  }) => {
+    return pageOperationRecords({
+      ...where,
+      ...orders,
+      ...filters,
+      page,
+      limit
+    });
+  };
+
+  /* 刷新表格 */
+  const reload = (where?: OperationRecordParam) => {
+    tableRef?.value?.reload({ page: 1, where });
+  };
+
+  /* 详情 */
+  const openDetail = (row: OperationRecord) => {
+    current.value = row;
+    showInfo.value = true;
+  };
+
+  /* 导出数据 */
+  const exportData = () => {
+    const array = [
+      [
+        '账号',
+        '用户名',
+        '操作模块',
+        '操作功能',
+        '请求地址',
+        '请求方式',
+        '状态',
+        '耗时',
+        '操作时间'
+      ]
+    ];
+    // 请求查询全部(不分页)的接口
+    const hide = messageLoading('请求中..', 0);
+    tableRef.value?.doRequest(({ where, orders, filters }) => {
+      listOperationRecords({ ...where, ...orders, ...filters })
+        .then((data) => {
+          hide();
+          data.forEach((d) => {
+            array.push([
+              d.username,
+              d.nickname,
+              d.module,
+              d.description,
+              d.url,
+              d.requestMethod,
+              ['正常', '异常'][d.status],
+              d.spendTime / 1000 + 's',
+              toDateString(d.createTime)
+            ]);
+          });
+          writeFile(
+            {
+              SheetNames: ['Sheet1'],
+              Sheets: {
+                Sheet1: utils.aoa_to_sheet(array)
+              }
+            },
+            '操作日志.xlsx'
+          );
+        })
+        .catch((e) => {
+          hide();
+          message.error(e.message);
+        });
+    });
+  };
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'SystemOperationRecord'
+  };
+</script>
diff --git a/src/views-demo/system/organization/components/org-edit.vue b/src/views-demo/system/organization/components/org-edit.vue
new file mode 100644
index 0000000..0c1b910
--- /dev/null
+++ b/src/views-demo/system/organization/components/org-edit.vue
@@ -0,0 +1,229 @@
+<!-- 机构编辑弹窗 -->
+<template>
+  <ele-modal
+    :width="620"
+    :visible="visible"
+    :confirm-loading="loading"
+    :title="isUpdate ? '修改机构' : '添加机构'"
+    :body-style="{ paddingBottom: '8px' }"
+    @update:visible="updateVisible"
+    @ok="save"
+  >
+    <a-form
+      ref="formRef"
+      :model="form"
+      :rules="rules"
+      :label-col="styleResponsive ? { md: 7, sm: 24 } : { flex: '90px' }"
+      :wrapper-col="styleResponsive ? { md: 17, sm: 24 } : { flex: '1' }"
+    >
+      <a-row :gutter="16">
+        <a-col
+          v-bind="styleResponsive ? { md: 12, sm: 24, xs: 24 } : { span: 12 }"
+        >
+          <a-form-item label="上级机构" name="parentId">
+            <org-select
+              :data="organizationList"
+              placeholder="请选择上级机构"
+              v-model:value="form.parentId"
+            />
+          </a-form-item>
+          <a-form-item label="机构名称" name="organizationName">
+            <a-input
+              allow-clear
+              :maxlength="20"
+              placeholder="请输入机构名称"
+              v-model:value="form.organizationName"
+            />
+          </a-form-item>
+          <a-form-item label="机构全称" name="organizationFullName">
+            <a-input
+              allow-clear
+              :maxlength="100"
+              placeholder="请输入机构全称"
+              v-model:value="form.organizationFullName"
+            />
+          </a-form-item>
+          <a-form-item label="机构代码">
+            <a-input
+              allow-clear
+              :maxlength="20"
+              placeholder="请输入机构代码"
+              v-model:value="form.organizationCode"
+            />
+          </a-form-item>
+        </a-col>
+        <a-col
+          v-bind="styleResponsive ? { md: 12, sm: 24, xs: 24 } : { span: 12 }"
+        >
+          <a-form-item label="机构类型" name="organizationType">
+            <org-type-select v-model:value="form.organizationType" />
+          </a-form-item>
+          <a-form-item label="排序号" name="sortNumber">
+            <a-input-number
+              :min="0"
+              :max="99999"
+              class="ele-fluid"
+              placeholder="请输入排序号"
+              v-model:value="form.sortNumber"
+            />
+          </a-form-item>
+          <a-form-item label="备注">
+            <a-textarea
+              :rows="4"
+              :maxlength="200"
+              placeholder="请输入备注"
+              v-model:value="form.comments"
+            />
+          </a-form-item>
+        </a-col>
+      </a-row>
+    </a-form>
+  </ele-modal>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive, watch } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import OrgSelect from './org-select.vue';
+  import OrgTypeSelect from './org-type-select.vue';
+  import {
+    addOrganization,
+    updateOrganization
+  } from '@/api/system/organization';
+  import type { Organization } from '@/api/system/organization/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'done'): void;
+    (e: 'update:visible', visible: boolean): void;
+  }>();
+
+  const props = defineProps<{
+    // 弹窗是否打开
+    visible: boolean;
+    // 修改回显的数据
+    data?: Organization | null;
+    // 机构id
+    organizationId?: number;
+    // 全部机构
+    organizationList: Organization[];
+  }>();
+
+  //
+  const formRef = ref<FormInstance | null>(null);
+
+  // 是否是修改
+  const isUpdate = ref(false);
+
+  // 提交状态
+  const loading = ref(false);
+
+  // 表单数据
+  const { form, resetFields, assignFields } = useFormData<Organization>({
+    organizationId: undefined,
+    parentId: undefined,
+    organizationName: '',
+    organizationFullName: '',
+    organizationCode: '',
+    organizationType: undefined,
+    sortNumber: undefined,
+    comments: ''
+  });
+
+  // 表单验证规则
+  const rules = reactive<Record<string, Rule[]>>({
+    organizationName: [
+      {
+        required: true,
+        message: '请输入机构名称',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    organizationFullName: [
+      {
+        required: true,
+        message: '请输入机构全称',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    organizationType: [
+      {
+        required: true,
+        message: '请选择机构类型',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    sortNumber: [
+      {
+        required: true,
+        message: '请输入排序号',
+        type: 'number',
+        trigger: 'blur'
+      }
+    ]
+  });
+
+  /* 保存编辑 */
+  const save = () => {
+    if (!formRef.value) {
+      return;
+    }
+    formRef.value
+      .validate()
+      .then(() => {
+        loading.value = true;
+        const orgData = {
+          ...form,
+          parentId: form.parentId || 0
+        };
+        const saveOrUpdate = isUpdate.value
+          ? updateOrganization
+          : addOrganization;
+        saveOrUpdate(orgData)
+          .then((msg) => {
+            loading.value = false;
+            message.success(msg);
+            updateVisible(false);
+            emit('done');
+          })
+          .catch((e) => {
+            loading.value = false;
+            message.error(e.message);
+          });
+      })
+      .catch(() => {});
+  };
+
+  /* 更新visible */
+  const updateVisible = (value: boolean) => {
+    emit('update:visible', value);
+  };
+
+  watch(
+    () => props.visible,
+    (visible) => {
+      if (visible) {
+        if (props.data) {
+          assignFields(props.data);
+          isUpdate.value = true;
+        } else {
+          form.parentId = props.organizationId;
+          isUpdate.value = false;
+        }
+      } else {
+        resetFields();
+        formRef.value?.clearValidate();
+      }
+    }
+  );
+</script>
diff --git a/src/views-demo/system/organization/components/org-select.vue b/src/views-demo/system/organization/components/org-select.vue
new file mode 100644
index 0000000..587424f
--- /dev/null
+++ b/src/views-demo/system/organization/components/org-select.vue
@@ -0,0 +1,39 @@
+<!-- 机构选择下拉框 -->
+<template>
+  <a-tree-select
+    allow-clear
+    tree-default-expand-all
+    :placeholder="placeholder"
+    :value="value || undefined"
+    :tree-data="data"
+    :dropdown-style="{ maxHeight: '360px', overflow: 'auto' }"
+    @update:value="updateValue"
+  />
+</template>
+
+<script lang="ts" setup>
+  import type { Organization } from '@/api/system/organization/model';
+
+  const emit = defineEmits<{
+    (e: 'update:value', value?: number): void;
+  }>();
+
+  withDefaults(
+    defineProps<{
+      // 选中的数据(v-modal)
+      value?: number;
+      // 提示信息
+      placeholder?: string;
+      // 机构数据
+      data: Organization[];
+    }>(),
+    {
+      placeholder: '请选择角色'
+    }
+  );
+
+  /* 更新选中数据 */
+  const updateValue = (value?: number) => {
+    emit('update:value', value);
+  };
+</script>
diff --git a/src/views-demo/system/organization/components/org-type-select.vue b/src/views-demo/system/organization/components/org-type-select.vue
new file mode 100644
index 0000000..b45f351
--- /dev/null
+++ b/src/views-demo/system/organization/components/org-type-select.vue
@@ -0,0 +1,57 @@
+<!-- 机构类型选择下拉框 -->
+<template>
+  <a-select
+    allow-clear
+    :value="value"
+    :placeholder="placeholder"
+    @update:value="updateValue"
+  >
+    <a-select-option
+      v-for="item in data"
+      :key="item.dictDataCode"
+      :value="item.dictDataCode"
+    >
+      {{ item.dictDataName }}
+    </a-select-option>
+  </a-select>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import { listDictionaryData } from '@/api/system/dictionary-data';
+  import type { DictionaryData } from '@/api/system/dictionary-data/model';
+
+  const emit = defineEmits<{
+    (e: 'update:value', value: string): void;
+  }>();
+
+  withDefaults(
+    defineProps<{
+      value?: string;
+      placeholder?: string;
+    }>(),
+    {
+      placeholder: '请选择机构类型'
+    }
+  );
+
+  // 机构类型数据
+  const data = ref<DictionaryData[]>([]);
+
+  /* 更新选中数据 */
+  const updateValue = (value: string) => {
+    emit('update:value', value);
+  };
+
+  /* 获取机构类型数据 */
+  listDictionaryData({
+    dictCode: 'organization_type'
+  })
+    .then((list) => {
+      data.value = list;
+    })
+    .catch((e) => {
+      message.error(e.message);
+    });
+</script>
diff --git a/src/views-demo/system/organization/components/org-user-edit.vue b/src/views-demo/system/organization/components/org-user-edit.vue
new file mode 100644
index 0000000..dbfb2f1
--- /dev/null
+++ b/src/views-demo/system/organization/components/org-user-edit.vue
@@ -0,0 +1,276 @@
+<!-- 用户编辑弹窗 -->
+<template>
+  <ele-modal
+    :width="680"
+    :visible="visible"
+    :confirm-loading="loading"
+    :title="isUpdate ? '修改用户' : '新建用户'"
+    :body-style="{ paddingBottom: '8px' }"
+    @update:visible="updateVisible"
+    @ok="save"
+  >
+    <a-form
+      ref="formRef"
+      :model="form"
+      :rules="rules"
+      :label-col="styleResponsive ? { md: 7, sm: 24 } : { flex: '90px' }"
+      :wrapper-col="styleResponsive ? { md: 17, sm: 24 } : { flex: '1' }"
+    >
+      <a-row :gutter="16">
+        <a-col
+          v-bind="styleResponsive ? { md: 12, sm: 24, xs: 24 } : { span: 12 }"
+        >
+          <a-form-item label="所属机构">
+            <org-select
+              :data="organizationList"
+              placeholder="请选择所属机构"
+              v-model:value="form.organizationId"
+            />
+          </a-form-item>
+          <a-form-item label="用户账号" name="username">
+            <a-input
+              allow-clear
+              :maxlength="20"
+              placeholder="请输入用户账号"
+              v-model:value="form.username"
+            />
+          </a-form-item>
+          <a-form-item label="用户名" name="nickname">
+            <a-input
+              allow-clear
+              :maxlength="20"
+              placeholder="请输入用户名"
+              v-model:value="form.nickname"
+            />
+          </a-form-item>
+          <a-form-item label="性别" name="sex">
+            <sex-select v-model:value="form.sex" />
+          </a-form-item>
+          <a-form-item label="角色" name="roleIds">
+            <role-select v-model:value="form.roles" />
+          </a-form-item>
+        </a-col>
+        <a-col
+          v-bind="styleResponsive ? { md: 12, sm: 24, xs: 24 } : { span: 12 }"
+        >
+          <a-form-item label="手机号" name="phone">
+            <a-input
+              allow-clear
+              :maxlength="11"
+              placeholder="请输入手机号"
+              v-model:value="form.phone"
+            />
+          </a-form-item>
+          <a-form-item label="邮箱" name="email">
+            <a-input
+              allow-clear
+              :maxlength="100"
+              placeholder="请输入邮箱"
+              v-model:value="form.email"
+            />
+          </a-form-item>
+          <a-form-item v-if="!isUpdate" label="登录密码" name="password">
+            <a-input-password
+              :maxlength="20"
+              v-model:value="form.password"
+              placeholder="请输入登录密码"
+            />
+          </a-form-item>
+          <a-form-item label="个人简介">
+            <a-textarea
+              :rows="4"
+              :maxlength="200"
+              placeholder="请输入个人简介"
+              v-model:value="form.introduction"
+            />
+          </a-form-item>
+        </a-col>
+      </a-row>
+    </a-form>
+  </ele-modal>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive, watch } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import { emailReg, phoneReg } from 'ele-admin-pro/es';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import OrgSelect from './org-select.vue';
+  import RoleSelect from '../../user/components/role-select.vue';
+  import SexSelect from '../../user/components/sex-select.vue';
+  import { addUser, updateUser, checkExistence } from '@/api/system/user';
+  import type { User } from '@/api/system/user/model';
+  import type { Organization } from '@/api/system/organization/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'done'): void;
+    (e: 'update:visible', visible: boolean): void;
+  }>();
+
+  const props = defineProps<{
+    // 弹窗是否打开
+    visible: boolean;
+    // 修改回显的数据
+    data?: User | null;
+    // 全部机构
+    organizationList: Organization[];
+    // 机构id
+    organizationId?: number;
+  }>();
+
+  //
+  const formRef = ref<FormInstance | null>(null);
+
+  // 是否是修改
+  const isUpdate = ref(false);
+
+  // 提交状态
+  const loading = ref(false);
+
+  // 表单数据
+  const { form, resetFields, assignFields } = useFormData<User>({
+    userId: undefined,
+    organizationId: undefined,
+    username: '',
+    nickname: '',
+    sex: undefined,
+    roles: [],
+    email: '',
+    phone: '',
+    password: '',
+    introduction: ''
+  });
+
+  // 表单验证规则
+  const rules = reactive<Record<string, Rule[]>>({
+    username: [
+      {
+        required: true,
+        type: 'string',
+        validator: (_rule: Rule, value: string) => {
+          return new Promise<void>((resolve, reject) => {
+            if (!value) {
+              return reject('请输入用户账号');
+            }
+            checkExistence('username', value, props.data?.userId)
+              .then(() => {
+                reject('账号已经存在');
+              })
+              .catch(() => {
+                resolve();
+              });
+          });
+        },
+        trigger: 'blur'
+      }
+    ],
+    nickname: [
+      {
+        required: true,
+        message: '请输入用户名',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    sex: [
+      {
+        required: true,
+        message: '请选择性别',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    roleIds: [
+      {
+        required: true,
+        message: '请选择角色',
+        type: 'array',
+        trigger: 'blur'
+      }
+    ],
+    email: [
+      {
+        pattern: emailReg,
+        message: '邮箱格式不正确',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    password: [
+      {
+        required: true,
+        type: 'string',
+        validator: async (_rule: Rule, value: string) => {
+          if (isUpdate.value || /^[\S]{5,18}$/.test(value)) {
+            return Promise.resolve();
+          }
+          return Promise.reject('密码必须为5-18位非空白字符');
+        },
+        trigger: 'blur'
+      }
+    ],
+    phone: [
+      {
+        pattern: phoneReg,
+        message: '手机号格式不正确',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ]
+  });
+
+  /* 保存编辑 */
+  const save = () => {
+    if (!formRef.value) {
+      return;
+    }
+    formRef.value
+      .validate()
+      .then(() => {
+        loading.value = true;
+        const saveOrUpdate = isUpdate.value ? updateUser : addUser;
+        saveOrUpdate(form)
+          .then((msg) => {
+            loading.value = false;
+            message.success(msg);
+            updateVisible(false);
+            emit('done');
+          })
+          .catch((e) => {
+            loading.value = false;
+            message.error(e.message);
+          });
+      })
+      .catch(() => {});
+  };
+
+  /* 更新visible */
+  const updateVisible = (value: boolean) => {
+    emit('update:visible', value);
+  };
+
+  watch(
+    () => props.visible,
+    (visible) => {
+      if (visible) {
+        if (props.data) {
+          assignFields(props.data);
+          isUpdate.value = true;
+        } else {
+          form.organizationId = props.organizationId;
+          isUpdate.value = false;
+        }
+      } else {
+        resetFields();
+        formRef.value?.clearValidate();
+      }
+    }
+  );
+</script>
diff --git a/src/views-demo/system/organization/components/org-user-list.vue b/src/views-demo/system/organization/components/org-user-list.vue
new file mode 100644
index 0000000..fdb0936
--- /dev/null
+++ b/src/views-demo/system/organization/components/org-user-list.vue
@@ -0,0 +1,214 @@
+<template>
+  <!-- 表格 -->
+  <ele-pro-table
+    ref="tableRef"
+    row-key="userId"
+    :columns="columns"
+    :datasource="datasource"
+    height="calc(100vh - 290px)"
+    tool-class="ele-toolbar-form"
+    :scroll="{ x: 800 }"
+    tools-theme="default"
+    bordered
+    cache-key="proSystemOrgUserTable"
+    class="sys-org-table"
+  >
+    <template #toolbar>
+      <org-user-search @search="reload" @add="openEdit()" />
+    </template>
+    <template #bodyCell="{ column, record }">
+      <template v-if="column.key === 'roles'">
+        <a-tag v-for="item in record.roles" :key="item.roleId" color="blue">
+          {{ item.roleName }}
+        </a-tag>
+      </template>
+      <template v-else-if="column.key === 'status'">
+        <a-switch
+          :checked="record.status === 0"
+          @change="(checked: boolean) => editStatus(checked, record)"
+        />
+      </template>
+      <template v-else-if="column.key === 'action'">
+        <a-space>
+          <a @click="openEdit(record)">修改</a>
+          <a-divider type="vertical" />
+          <a-popconfirm
+            placement="topRight"
+            title="确定要删除此用户吗?"
+            @confirm="remove(record)"
+          >
+            <a class="ele-text-danger">删除</a>
+          </a-popconfirm>
+        </a-space>
+      </template>
+    </template>
+  </ele-pro-table>
+  <!-- 编辑弹窗 -->
+  <org-user-edit
+    :data="current"
+    v-model:visible="showEdit"
+    :organization-list="organizationList"
+    :organization-id="organizationId"
+    @done="reload"
+  />
+</template>
+
+<script lang="ts" setup>
+  import { ref, watch } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type { EleProTable } from 'ele-admin-pro/es';
+  import type {
+    DatasourceFunction,
+    ColumnItem
+  } from 'ele-admin-pro/es/ele-pro-table/types';
+  import { messageLoading, toDateString } from 'ele-admin-pro/es';
+  import OrgUserSearch from './org-user-search.vue';
+  import OrgUserEdit from './org-user-edit.vue';
+  import { pageUsers, removeUser, updateUserStatus } from '@/api/system/user';
+  import type { User, UserParam } from '@/api/system/user/model';
+  import type { Organization } from '@/api/system/organization/model';
+
+  const props = defineProps<{
+    // 机构 id
+    organizationId?: number;
+    // 全部机构
+    organizationList: Organization[];
+  }>();
+
+  // 表格实例
+  const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
+
+  // 表格列配置
+  const columns = ref<ColumnItem[]>([
+    {
+      key: 'index',
+      width: 48,
+      align: 'center',
+      fixed: 'left',
+      hideInSetting: true,
+      customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
+    },
+    {
+      title: '用户账号',
+      dataIndex: 'username',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '用户名',
+      dataIndex: 'nickname',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '性别',
+      dataIndex: 'sexName',
+      width: 80,
+      align: 'center',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '角色',
+      key: 'roles'
+    },
+    {
+      title: '创建时间',
+      dataIndex: 'createTime',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true,
+      customRender: ({ text }) => toDateString(text)
+    },
+    {
+      title: '状态',
+      key: 'status',
+      sorter: true,
+      showSorterTooltip: false,
+      width: 80,
+      align: 'center'
+    },
+    {
+      title: '操作',
+      key: 'action',
+      width: 100,
+      align: 'center'
+    }
+  ]);
+
+  // 当前编辑数据
+  const current = ref<User | null>(null);
+
+  // 是否显示编辑弹窗
+  const showEdit = ref(false);
+
+  // 表格数据源
+  const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
+    return pageUsers({
+      ...where,
+      ...orders,
+      page,
+      limit,
+      organizationId: props.organizationId
+    });
+  };
+
+  /* 搜索 */
+  const reload = (where?: UserParam) => {
+    tableRef?.value?.reload({ page: 1, where });
+  };
+
+  /* 打开编辑弹窗 */
+  const openEdit = (row?: User) => {
+    current.value = row ?? null;
+    showEdit.value = true;
+  };
+
+  /* 删除单个 */
+  const remove = (row: User) => {
+    const hide = messageLoading('请求中..', 0);
+    removeUser(row.userId)
+      .then((msg) => {
+        hide();
+        message.success(msg);
+        reload();
+      })
+      .catch((e) => {
+        hide();
+        message.error(e.message);
+      });
+  };
+
+  /* 修改用户状态 */
+  const editStatus = (checked: boolean, row: User) => {
+    const status = checked ? 0 : 1;
+    updateUserStatus(row.userId, status)
+      .then((msg) => {
+        row.status = status;
+        message.success(msg);
+      })
+      .catch((e) => {
+        message.error(e.message);
+      });
+  };
+
+  // 监听机构 id 变化
+  watch(
+    () => props.organizationId,
+    () => {
+      reload();
+    }
+  );
+</script>
+
+<style lang="less" scoped>
+  .sys-org-table :deep(.ant-table-body) {
+    overflow: auto !important;
+    overflow: overlay !important;
+  }
+
+  .sys-org-table :deep(.ant-table-pagination.ant-pagination) {
+    padding: 0 4px;
+    margin-bottom: 0;
+  }
+</style>
diff --git a/src/views-demo/system/organization/components/org-user-search.vue b/src/views-demo/system/organization/components/org-user-search.vue
new file mode 100644
index 0000000..cf68c22
--- /dev/null
+++ b/src/views-demo/system/organization/components/org-user-search.vue
@@ -0,0 +1,82 @@
+<!-- 搜索表单 -->
+<template>
+  <a-row :gutter="16">
+    <a-col
+      v-bind="
+        styleResponsive ? { xl: 6, lg: 8, md: 12, sm: 24, xs: 24 } : { span: 6 }
+      "
+    >
+      <a-input
+        v-model:value.trim="form.username"
+        placeholder="请输入用户账号"
+        allow-clear
+      />
+    </a-col>
+    <a-col
+      v-bind="
+        styleResponsive ? { xl: 6, lg: 8, md: 12, sm: 24, xs: 24 } : { span: 6 }
+      "
+    >
+      <a-input
+        v-model:value.trim="form.nickname"
+        placeholder="请输入用户名"
+        allow-clear
+      />
+    </a-col>
+    <a-col
+      v-bind="
+        styleResponsive
+          ? { xl: 12, lg: 8, md: 24, sm: 24, xs: 24 }
+          : { span: 12 }
+      "
+    >
+      <a-space :size="10" style="flex-wrap: wrap">
+        <a-button type="primary" class="ele-btn-icon" @click="search">
+          <template #icon>
+            <search-outlined />
+          </template>
+          <span>查询</span>
+        </a-button>
+        <a-button type="primary" class="ele-btn-icon" @click="add">
+          <template #icon>
+            <plus-outlined />
+          </template>
+          <span>新建</span>
+        </a-button>
+      </a-space>
+    </a-col>
+  </a-row>
+</template>
+
+<script lang="ts" setup>
+  import { PlusOutlined, SearchOutlined } from '@ant-design/icons-vue';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import type { UserParam } from '@/api/system/user/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'search', where?: UserParam): void;
+    (e: 'add'): void;
+  }>();
+
+  // 表单数据
+  const { form } = useFormData<UserParam>({
+    username: '',
+    nickname: ''
+  });
+
+  /* 搜索 */
+  const search = () => {
+    emit('search', form);
+  };
+
+  /*  添加 */
+  const add = () => {
+    emit('add');
+  };
+</script>
diff --git a/src/views-demo/system/organization/index.vue b/src/views-demo/system/organization/index.vue
new file mode 100644
index 0000000..cc576f1
--- /dev/null
+++ b/src/views-demo/system/organization/index.vue
@@ -0,0 +1,205 @@
+<template>
+  <div class="ele-body">
+    <a-card :bordered="false" :body-style="{ padding: '16px' }">
+      <ele-split-layout
+        width="266px"
+        allow-collapse
+        :right-style="{ overflow: 'hidden' }"
+        :style="{ minHeight: 'calc(100vh - 152px)' }"
+      >
+        <div>
+          <ele-toolbar theme="default">
+            <a-space :size="10">
+              <a-button type="primary" class="ele-btn-icon" @click="openEdit()">
+                <template #icon>
+                  <plus-outlined />
+                </template>
+                <span>新建</span>
+              </a-button>
+              <a-button
+                type="primary"
+                :disabled="!current"
+                class="ele-btn-icon"
+                @click="openEdit(current)"
+              >
+                <template #icon>
+                  <edit-outlined />
+                </template>
+                <span>修改</span>
+              </a-button>
+              <a-button
+                danger
+                type="primary"
+                :disabled="!current"
+                class="ele-btn-icon"
+                @click="remove"
+              >
+                <template #icon>
+                  <delete-outlined />
+                </template>
+                <span>删除</span>
+              </a-button>
+            </a-space>
+          </ele-toolbar>
+          <div class="ele-border-split sys-organization-list">
+            <a-tree
+              :tree-data="(data as any)"
+              v-model:expanded-keys="expandedRowKeys"
+              v-model:selected-keys="selectedRowKeys"
+              @select="onTreeSelect"
+            />
+          </div>
+        </div>
+        <template #content>
+          <org-user-list
+            v-if="current"
+            :organization-list="data"
+            :organization-id="current.organizationId"
+          />
+        </template>
+      </ele-split-layout>
+    </a-card>
+    <!-- 编辑弹窗 -->
+    <org-edit
+      v-model:visible="showEdit"
+      :data="editData"
+      :organization-list="data"
+      :organization-id="current?.organizationId"
+      @done="query"
+    />
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { createVNode, ref } from 'vue';
+  import { message, Modal } from 'ant-design-vue/es';
+  import {
+    PlusOutlined,
+    EditOutlined,
+    DeleteOutlined,
+    ExclamationCircleOutlined
+  } from '@ant-design/icons-vue';
+  import { messageLoading, toTreeData, eachTreeData } from 'ele-admin-pro/es';
+  import OrgUserList from './components/org-user-list.vue';
+  import OrgEdit from './components/org-edit.vue';
+  import {
+    listOrganizations,
+    removeOrganization
+  } from '@/api/system/organization';
+  import type { Organization } from '@/api/system/organization/model';
+
+  // 加载状态
+  const loading = ref(true);
+
+  // 树形数据
+  const data = ref<Organization[]>([]);
+
+  // 树展开的key
+  const expandedRowKeys = ref<number[]>([]);
+
+  // 树选中的key
+  const selectedRowKeys = ref<number[]>([]);
+
+  // 选中数据
+  const current = ref<Organization | null>(null);
+
+  // 是否显示表单弹窗
+  const showEdit = ref(false);
+
+  // 编辑回显数据
+  const editData = ref<Organization | null>(null);
+
+  /* 查询 */
+  const query = () => {
+    loading.value = true;
+    listOrganizations()
+      .then((list) => {
+        loading.value = false;
+        const eks: number[] = [];
+        list.forEach((d) => {
+          d.key = d.organizationId;
+          d.value = d.organizationId;
+          d.title = d.organizationName;
+          if (typeof d.key === 'number') {
+            eks.push(d.key);
+          }
+        });
+        expandedRowKeys.value = eks;
+        data.value = toTreeData({
+          data: list,
+          idField: 'organizationId',
+          parentIdField: 'parentId'
+        });
+        if (list.length) {
+          if (typeof list[0].key === 'number') {
+            selectedRowKeys.value = [list[0].key];
+          }
+          current.value = list[0];
+        } else {
+          selectedRowKeys.value = [];
+          current.value = null;
+        }
+      })
+      .catch((e) => {
+        loading.value = false;
+        message.error(e.message);
+      });
+  };
+
+  /* 选择数据 */
+  const onTreeSelect = () => {
+    eachTreeData(data.value, (d) => {
+      if (typeof d.key === 'number' && selectedRowKeys.value.includes(d.key)) {
+        current.value = d;
+        return false;
+      }
+    });
+  };
+
+  /* 打开编辑弹窗 */
+  const openEdit = (item?: Organization | null) => {
+    editData.value = item ?? null;
+    showEdit.value = true;
+  };
+
+  /* 删除 */
+  const remove = () => {
+    Modal.confirm({
+      title: '提示',
+      content: '确定要删除选中的机构吗?',
+      icon: createVNode(ExclamationCircleOutlined),
+      maskClosable: true,
+      onOk: () => {
+        const hide = messageLoading('请求中..', 0);
+        removeOrganization(current.value?.organizationId)
+          .then((msg) => {
+            hide();
+            message.success(msg);
+            query();
+          })
+          .catch((e) => {
+            hide();
+            message.error(e.message);
+          });
+      }
+    });
+  };
+
+  query();
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'SystemOrganization'
+  };
+</script>
+
+<style lang="less" scoped>
+  .sys-organization-list {
+    padding: 12px 6px;
+    height: calc(100vh - 242px);
+    border-width: 1px;
+    border-style: solid;
+    overflow: auto;
+  }
+</style>
diff --git a/src/views-demo/system/role/components/role-auth.vue b/src/views-demo/system/role/components/role-auth.vue
new file mode 100644
index 0000000..ae3ff0b
--- /dev/null
+++ b/src/views-demo/system/role/components/role-auth.vue
@@ -0,0 +1,159 @@
+<!-- 角色权限分配弹窗 -->
+<template>
+  <ele-modal
+    :width="460"
+    title="分配权限"
+    :visible="visible"
+    :confirm-loading="loading"
+    @update:visible="updateVisible"
+    @ok="save"
+  >
+    <a-spin :spinning="authLoading">
+      <div style="height: 60vh" class="ele-scrollbar-hover">
+        <a-tree
+          :checkable="true"
+          :show-icon="true"
+          :tree-data="(authData as any)"
+          v-model:expandedKeys="expandKeys"
+          v-model:checkedKeys="checkedKeys"
+        >
+          <template #icon="{ menuIcon }">
+            <component v-if="menuIcon" :is="menuIcon" />
+          </template>
+        </a-tree>
+      </div>
+    </a-spin>
+  </ele-modal>
+</template>
+
+<script lang="ts" setup>
+  import { ref, watch, nextTick } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import { toTreeData, eachTreeData } from 'ele-admin-pro/es';
+  import { listRoleMenus, updateRoleMenus } from '@/api/system/role';
+  import type { Role } from '@/api/system/role/model';
+  import type { Menu } from '@/api/system/menu/model';
+
+  const emit = defineEmits<{
+    (e: 'update:visible', visible: boolean): void;
+  }>();
+
+  const props = defineProps<{
+    // 弹窗是否打开
+    visible: boolean;
+    // 当前角色数据
+    data?: Role | null;
+  }>();
+
+  // 权限数据
+  const authData = ref<Menu[]>([]);
+
+  // 权限数据请求状态
+  const authLoading = ref(false);
+
+  // 提交状态
+  const loading = ref(false);
+
+  // 角色权限展开的keys
+  const expandKeys = ref<number[]>([]);
+
+  // 角色权限选中的keys
+  const checkedKeys = ref<number[]>([]);
+
+  /* 查询权限数据 */
+  const query = () => {
+    authData.value = [];
+    expandKeys.value = [];
+    checkedKeys.value = [];
+    if (!props.data) {
+      return;
+    }
+    authLoading.value = true;
+    listRoleMenus(props.data.roleId)
+      .then((data) => {
+        authLoading.value = false;
+        // 转成树形结构的数据
+        authData.value = toTreeData({
+          data: data?.map((d) => ({
+            ...d,
+            key: d.menuId,
+            icon: undefined,
+            menuIcon: d.icon
+          })),
+          idField: 'menuId',
+          parentIdField: 'parentId',
+          addParentIds: true,
+          parentIds: []
+        });
+        // 全部默认展开以及回显选中的数据
+        nextTick(() => {
+          const eks: number[] = [];
+          const cks: number[] = [];
+          eachTreeData(authData.value, (d) => {
+            if (d.key) {
+              if (d.children?.length) {
+                eks.push(d.key);
+              } else if (d.checked) {
+                cks.push(d.key);
+              }
+            }
+          });
+          expandKeys.value = eks;
+          checkedKeys.value = cks;
+        });
+      })
+      .catch((e) => {
+        authLoading.value = false;
+        message.error(e.message);
+      });
+  };
+
+  /* 保存权限分配 */
+  const save = () => {
+    loading.value = true;
+    // 获取选中的id,包含所有半选的父级的id
+    const ids = new Set<number>();
+    eachTreeData(authData.value, (d) => {
+      if (d.key && checkedKeys.value.some((c) => c === d.key)) {
+        ids.add(d.key);
+        if (d.parentIds) {
+          d.parentIds.forEach((id: number) => {
+            ids.add(id);
+          });
+        }
+      }
+    });
+    updateRoleMenus(props.data?.roleId, Array.from(ids))
+      .then((msg) => {
+        loading.value = false;
+        message.success(msg);
+        updateVisible(false);
+      })
+      .catch((e) => {
+        loading.value = false;
+        message.error(e.message);
+      });
+  };
+
+  /* 更新visible */
+  const updateVisible = (value: boolean) => {
+    emit('update:visible', value);
+  };
+
+  watch(
+    () => props.visible,
+    (visible) => {
+      if (visible) {
+        query();
+      }
+    }
+  );
+</script>
+
+<script lang="ts">
+  import * as MenuIcons from '@/layout/menu-icons';
+
+  export default {
+    components: MenuIcons
+  };
+</script>
diff --git a/src/views-demo/system/role/components/role-edit.vue b/src/views-demo/system/role/components/role-edit.vue
new file mode 100644
index 0000000..b3a5f39
--- /dev/null
+++ b/src/views-demo/system/role/components/role-edit.vue
@@ -0,0 +1,158 @@
+<!-- 角色编辑弹窗 -->
+<template>
+  <ele-modal
+    :width="460"
+    :visible="visible"
+    :confirm-loading="loading"
+    :title="isUpdate ? '修改角色' : '添加角色'"
+    :body-style="{ paddingBottom: '8px' }"
+    @update:visible="updateVisible"
+    @ok="save"
+  >
+    <a-form
+      ref="formRef"
+      :model="form"
+      :rules="rules"
+      :label-col="styleResponsive ? { md: 5, sm: 5, xs: 24 } : { flex: '90px' }"
+      :wrapper-col="
+        styleResponsive ? { md: 19, sm: 19, xs: 24 } : { flex: '1' }
+      "
+    >
+      <a-form-item label="角色名称" name="roleName">
+        <a-input
+          allow-clear
+          :maxlength="20"
+          placeholder="请输入角色名称"
+          v-model:value="form.roleName"
+        />
+      </a-form-item>
+      <a-form-item label="角色标识" name="roleCode">
+        <a-input
+          allow-clear
+          :maxlength="20"
+          placeholder="请输入角色标识"
+          v-model:value="form.roleCode"
+        />
+      </a-form-item>
+      <a-form-item label="备注">
+        <a-textarea
+          :rows="4"
+          :maxlength="200"
+          placeholder="请输入备注"
+          v-model:value="form.comments"
+        />
+      </a-form-item>
+    </a-form>
+  </ele-modal>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive, watch } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import { addRole, updateRole } from '@/api/system/role';
+  import type { Role } from '@/api/system/role/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'done'): void;
+    (e: 'update:visible', visible: boolean): void;
+  }>();
+
+  const props = defineProps<{
+    // 弹窗是否打开
+    visible: boolean;
+    // 修改回显的数据
+    data?: Role | null;
+  }>();
+
+  //
+  const formRef = ref<FormInstance | null>(null);
+
+  // 是否是修改
+  const isUpdate = ref(false);
+
+  // 提交状态
+  const loading = ref(false);
+
+  // 表单数据
+  const { form, resetFields, assignFields } = useFormData<Role>({
+    roleId: undefined,
+    roleName: '',
+    roleCode: '',
+    comments: ''
+  });
+
+  // 表单验证规则
+  const rules = reactive<Record<string, Rule[]>>({
+    roleName: [
+      {
+        required: true,
+        message: '请输入角色名称',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    roleCode: [
+      {
+        required: true,
+        message: '请输入角色标识',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ]
+  });
+
+  /* 保存编辑 */
+  const save = () => {
+    if (!formRef.value) {
+      return;
+    }
+    formRef.value
+      .validate()
+      .then(() => {
+        loading.value = true;
+        const saveOrUpdate = isUpdate.value ? updateRole : addRole;
+        saveOrUpdate(form)
+          .then((msg) => {
+            loading.value = false;
+            message.success(msg);
+            updateVisible(false);
+            emit('done');
+          })
+          .catch((e) => {
+            loading.value = false;
+            message.error(e.message);
+          });
+      })
+      .catch(() => {});
+  };
+
+  /* 更新visible */
+  const updateVisible = (value: boolean) => {
+    emit('update:visible', value);
+  };
+
+  watch(
+    () => props.visible,
+    (visible) => {
+      if (visible) {
+        if (props.data) {
+          assignFields(props.data);
+          isUpdate.value = true;
+        } else {
+          isUpdate.value = false;
+        }
+      } else {
+        resetFields();
+        formRef.value?.clearValidate();
+      }
+    }
+  );
+</script>
diff --git a/src/views-demo/system/role/components/role-search.vue b/src/views-demo/system/role/components/role-search.vue
new file mode 100644
index 0000000..2169dca
--- /dev/null
+++ b/src/views-demo/system/role/components/role-search.vue
@@ -0,0 +1,106 @@
+<!-- 搜索表单 -->
+<template>
+  <a-form
+    :label-col="
+      styleResponsive ? { xl: 7, lg: 5, md: 7, sm: 4 } : { flex: '90px' }
+    "
+    :wrapper-col="
+      styleResponsive ? { xl: 17, lg: 19, md: 17, sm: 20 } : { flex: '1' }
+    "
+  >
+    <a-row :gutter="8">
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="角色名称">
+          <a-input
+            v-model:value.trim="form.roleName"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="角色标识">
+          <a-input
+            v-model:value.trim="form.roleCode"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="备注">
+          <a-input
+            v-model:value.trim="form.comments"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item class="ele-text-right" :wrapper-col="{ span: 24 }">
+          <a-space>
+            <a-button type="primary" @click="search">查询</a-button>
+            <a-button @click="reset">重置</a-button>
+          </a-space>
+        </a-form-item>
+      </a-col>
+    </a-row>
+  </a-form>
+</template>
+
+<script lang="ts" setup>
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import type { RoleParam } from '@/api/system/role/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'search', where?: RoleParam): void;
+  }>();
+
+  // 表单数据
+  const { form, resetFields } = useFormData<RoleParam>({
+    roleName: '',
+    roleCode: '',
+    comments: ''
+  });
+
+  /* 搜索 */
+  const search = () => {
+    emit('search', form);
+  };
+
+  /*  重置 */
+  const reset = () => {
+    resetFields();
+    search();
+  };
+</script>
diff --git a/src/views-demo/system/role/index.vue b/src/views-demo/system/role/index.vue
new file mode 100644
index 0000000..497d2fb
--- /dev/null
+++ b/src/views-demo/system/role/index.vue
@@ -0,0 +1,212 @@
+<template>
+  <div class="ele-body">
+    <a-card :bordered="false">
+      <!-- 搜索表单 -->
+      <role-search @search="reload" />
+      <!-- 表格 -->
+      <ele-pro-table
+        ref="tableRef"
+        row-key="roleId"
+        :columns="columns"
+        :datasource="datasource"
+        v-model:selection="selection"
+        :scroll="{ x: 800 }"
+        cache-key="proSystemRoleTable"
+      >
+        <template #toolbar>
+          <a-space>
+            <a-button type="primary" class="ele-btn-icon" @click="openEdit()">
+              <template #icon>
+                <plus-outlined />
+              </template>
+              <span>新建</span>
+            </a-button>
+            <a-button
+              danger
+              type="primary"
+              class="ele-btn-icon"
+              @click="removeBatch"
+            >
+              <template #icon>
+                <delete-outlined />
+              </template>
+              <span>删除</span>
+            </a-button>
+          </a-space>
+        </template>
+        <template #bodyCell="{ column, record }">
+          <template v-if="column.key === 'action'">
+            <a-space>
+              <a @click="openEdit(record)">修改</a>
+              <a-divider type="vertical" />
+              <a @click="openAuth(record)">分配权限</a>
+              <a-divider type="vertical" />
+              <a-popconfirm
+                placement="topRight"
+                title="确定要删除此角色吗?"
+                @confirm="remove(record)"
+              >
+                <a class="ele-text-danger">删除</a>
+              </a-popconfirm>
+            </a-space>
+          </template>
+        </template>
+      </ele-pro-table>
+    </a-card>
+    <!-- 编辑弹窗 -->
+    <role-edit v-model:visible="showEdit" :data="current" @done="reload" />
+    <!-- 权限分配弹窗 -->
+    <role-auth v-model:visible="showAuth" :data="current" />
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { createVNode, ref } from 'vue';
+  import { message, Modal } from 'ant-design-vue/es';
+  import {
+    PlusOutlined,
+    DeleteOutlined,
+    ExclamationCircleOutlined
+  } from '@ant-design/icons-vue';
+  import type { EleProTable } from 'ele-admin-pro/es';
+  import type {
+    DatasourceFunction,
+    ColumnItem
+  } from 'ele-admin-pro/es/ele-pro-table/types';
+  import { messageLoading, toDateString } from 'ele-admin-pro/es';
+  import RoleSearch from './components/role-search.vue';
+  import RoleEdit from './components/role-edit.vue';
+  import RoleAuth from './components/role-auth.vue';
+  import { pageRoles, removeRole, removeRoles } from '@/api/system/role';
+  import type { Role, RoleParam } from '@/api/system/role/model';
+
+  // 表格实例
+  const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
+
+  // 表格列配置
+  const columns = ref<ColumnItem[]>([
+    {
+      key: 'index',
+      width: 48,
+      align: 'center',
+      fixed: 'left',
+      hideInSetting: true,
+      customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
+    },
+    {
+      title: '角色名称',
+      dataIndex: 'roleName',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '角色标识',
+      dataIndex: 'roleCode',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '备注',
+      dataIndex: 'comments',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '创建时间',
+      dataIndex: 'createTime',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true,
+      customRender: ({ text }) => toDateString(text)
+    },
+    {
+      title: '操作',
+      key: 'action',
+      width: 200,
+      align: 'center'
+    }
+  ]);
+
+  // 表格选中数据
+  const selection = ref<Role[]>([]);
+
+  // 当前编辑数据
+  const current = ref<Role | null>(null);
+
+  // 是否显示编辑弹窗
+  const showEdit = ref(false);
+
+  // 是否显示权限分配弹窗
+  const showAuth = ref(false);
+
+  // 表格数据源
+  const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
+    return pageRoles({ ...where, ...orders, page, limit });
+  };
+
+  /* 搜索 */
+  const reload = (where?: RoleParam) => {
+    selection.value = [];
+    tableRef?.value?.reload({ page: 1, where });
+  };
+
+  /* 打开编辑弹窗 */
+  const openEdit = (row?: Role) => {
+    current.value = row ?? null;
+    showEdit.value = true;
+  };
+
+  /* 打开权限分配弹窗 */
+  const openAuth = (row?: Role) => {
+    current.value = row ?? null;
+    showAuth.value = true;
+  };
+
+  /* 删除单个 */
+  const remove = (row: Role) => {
+    const hide = messageLoading('请求中..', 0);
+    removeRole(row.roleId)
+      .then((msg) => {
+        hide();
+        message.success(msg);
+        reload();
+      })
+      .catch((e) => {
+        hide();
+        message.error(e.message);
+      });
+  };
+
+  /* 批量删除 */
+  const removeBatch = () => {
+    if (!selection.value.length) {
+      message.error('请至少选择一条数据');
+      return;
+    }
+    Modal.confirm({
+      title: '提示',
+      content: '确定要删除选中的角色吗?',
+      icon: createVNode(ExclamationCircleOutlined),
+      maskClosable: true,
+      onOk: () => {
+        const hide = messageLoading('请求中..', 0);
+        removeRoles(selection.value.map((d) => d.roleId))
+          .then((msg) => {
+            hide();
+            message.success(msg);
+            reload();
+          })
+          .catch((e) => {
+            hide();
+            message.error(e.message);
+          });
+      }
+    });
+  };
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'SystemRole'
+  };
+</script>
diff --git a/src/views-demo/system/user/components/role-select.vue b/src/views-demo/system/user/components/role-select.vue
new file mode 100644
index 0000000..04534b9
--- /dev/null
+++ b/src/views-demo/system/user/components/role-select.vue
@@ -0,0 +1,71 @@
+<!-- 角色选择下拉框 -->
+<template>
+  <a-select
+    allow-clear
+    mode="multiple"
+    :value="roleIds"
+    :placeholder="placeholder"
+    @update:value="updateValue"
+    @blur="onBlur"
+  >
+    <a-select-option
+      v-for="item in data"
+      :key="item.roleId"
+      :value="item.roleId"
+    >
+      {{ item.roleName }}
+    </a-select-option>
+  </a-select>
+</template>
+
+<script lang="ts" setup>
+  import { ref, computed } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import { listRoles } from '@/api/system/role';
+  import type { Role } from '@/api/system/role/model';
+
+  const emit = defineEmits<{
+    (e: 'update:value', value: Role[]): void;
+    (e: 'blur'): void;
+  }>();
+
+  const props = withDefaults(
+    defineProps<{
+      // 选中的角色
+      value?: Role[];
+      //
+      placeholder?: string;
+    }>(),
+    {
+      placeholder: '请选择角色'
+    }
+  );
+
+  // 选中的角色id
+  const roleIds = computed(() => props.value?.map((d) => d.roleId as number));
+
+  // 角色数据
+  const data = ref<Role[]>([]);
+
+  /* 更新选中数据 */
+  const updateValue = (value: number[]) => {
+    emit(
+      'update:value',
+      value.map((v) => ({ roleId: v }))
+    );
+  };
+
+  /* 获取角色数据 */
+  listRoles()
+    .then((list) => {
+      data.value = list;
+    })
+    .catch((e) => {
+      message.error(e.message);
+    });
+
+  /* 失去焦点 */
+  const onBlur = () => {
+    emit('blur');
+  };
+</script>
diff --git a/src/views-demo/system/user/components/sex-select.vue b/src/views-demo/system/user/components/sex-select.vue
new file mode 100644
index 0000000..a1f6020
--- /dev/null
+++ b/src/views-demo/system/user/components/sex-select.vue
@@ -0,0 +1,64 @@
+<!-- 性别选择下拉框 -->
+<template>
+  <a-select
+    show-search
+    option-filter-prop="label"
+    :options="data"
+    allow-clear
+    :value="value"
+    :placeholder="placeholder"
+    @update:value="updateValue"
+    @blur="onBlur"
+  />
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import { listDictionaryData } from '@/api/system/dictionary-data';
+  import type { SelectProps } from 'ant-design-vue/es';
+
+  const emit = defineEmits<{
+    (e: 'update:value', value: string): void;
+    (e: 'blur'): void;
+  }>();
+
+  withDefaults(
+    defineProps<{
+      value?: string;
+      placeholder?: string;
+    }>(),
+    {
+      placeholder: '请选择性别'
+    }
+  );
+
+  // 字典数据
+  const data = ref<SelectProps['options']>([]);
+
+  /* 更新选中数据 */
+  const updateValue = (value: string) => {
+    emit('update:value', value);
+  };
+
+  /* 获取字典数据 */
+  listDictionaryData({
+    dictCode: 'sex'
+  })
+    .then((list) => {
+      data.value = list.map((d) => {
+        return {
+          value: d.dictDataCode,
+          label: d.dictDataName
+        };
+      });
+    })
+    .catch((e) => {
+      message.error(e.message);
+    });
+
+  /* 失去焦点 */
+  const onBlur = () => {
+    emit('blur');
+  };
+</script>
diff --git a/src/views-demo/system/user/components/user-edit.vue b/src/views-demo/system/user/components/user-edit.vue
new file mode 100644
index 0000000..80972f5
--- /dev/null
+++ b/src/views-demo/system/user/components/user-edit.vue
@@ -0,0 +1,275 @@
+<!-- 用户编辑弹窗 -->
+<template>
+  <ele-modal
+    :width="680"
+    :visible="visible"
+    :confirm-loading="loading"
+    :title="isUpdate ? '修改用户' : '新建用户'"
+    :body-style="{ paddingBottom: '8px' }"
+    @update:visible="updateVisible"
+    @ok="save"
+  >
+    <a-form
+      ref="formRef"
+      :model="form"
+      :rules="rules"
+      :label-col="styleResponsive ? { md: 7, sm: 4, xs: 24 } : { flex: '90px' }"
+      :wrapper-col="
+        styleResponsive ? { md: 17, sm: 20, xs: 24 } : { flex: '1' }
+      "
+    >
+      <a-row :gutter="16">
+        <a-col
+          v-bind="styleResponsive ? { md: 12, sm: 24, xs: 24 } : { span: 12 }"
+        >
+          <a-form-item label="用户账号" name="username">
+            <a-input
+              allow-clear
+              :maxlength="20"
+              placeholder="请输入用户账号"
+              v-model:value="form.username"
+            />
+          </a-form-item>
+          <a-form-item label="用户名" name="nickname">
+            <a-input
+              allow-clear
+              :maxlength="20"
+              placeholder="请输入用户名"
+              v-model:value="form.nickname"
+            />
+          </a-form-item>
+          <a-form-item label="性别" name="sex">
+            <sex-select v-model:value="form.sex" />
+          </a-form-item>
+          <a-form-item label="角色" name="roles">
+            <role-select v-model:value="form.roles" />
+          </a-form-item>
+          <a-form-item label="邮箱" name="email">
+            <a-input
+              allow-clear
+              :maxlength="100"
+              placeholder="请输入邮箱"
+              v-model:value="form.email"
+            />
+          </a-form-item>
+        </a-col>
+        <a-col
+          v-bind="styleResponsive ? { md: 12, sm: 24, xs: 24 } : { span: 12 }"
+        >
+          <a-form-item label="手机号" name="phone">
+            <a-input
+              allow-clear
+              :maxlength="11"
+              placeholder="请输入手机号"
+              v-model:value="form.phone"
+            />
+          </a-form-item>
+          <a-form-item label="出生日期">
+            <a-date-picker
+              class="ele-fluid"
+              value-format="YYYY-MM-DD"
+              placeholder="请选择出生日期"
+              v-model:value="form.birthday"
+            />
+          </a-form-item>
+          <a-form-item v-if="!isUpdate" label="登录密码" name="password">
+            <a-input-password
+              :maxlength="20"
+              v-model:value="form.password"
+              placeholder="请输入登录密码"
+            />
+          </a-form-item>
+          <a-form-item label="个人简介">
+            <a-textarea
+              :rows="4"
+              :maxlength="200"
+              placeholder="请输入个人简介"
+              v-model:value="form.introduction"
+            />
+          </a-form-item>
+        </a-col>
+      </a-row>
+    </a-form>
+  </ele-modal>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive, watch } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import { emailReg, phoneReg } from 'ele-admin-pro/es';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import RoleSelect from './role-select.vue';
+  import SexSelect from './sex-select.vue';
+  import { addUser, updateUser, checkExistence } from '@/api/system/user';
+  import type { User } from '@/api/system/user/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'done'): void;
+    (e: 'update:visible', visible: boolean): void;
+  }>();
+
+  const props = defineProps<{
+    // 弹窗是否打开
+    visible: boolean;
+    // 修改回显的数据
+    data?: User | null;
+  }>();
+
+  //
+  const formRef = ref<FormInstance | null>(null);
+
+  // 是否是修改
+  const isUpdate = ref(false);
+
+  // 提交状态
+  const loading = ref(false);
+
+  // 表单数据
+  const { form, resetFields, assignFields } = useFormData<User>({
+    userId: undefined,
+    username: '',
+    nickname: '',
+    sex: undefined,
+    roles: [],
+    email: '',
+    phone: '',
+    password: '',
+    introduction: '',
+    birthday: ''
+  });
+
+  // 表单验证规则
+  const rules = reactive<Record<string, Rule[]>>({
+    username: [
+      {
+        required: true,
+        type: 'string',
+        validator: (_rule: Rule, value: string) => {
+          return new Promise<void>((resolve, reject) => {
+            if (!value) {
+              return reject('请输入用户账号');
+            }
+            checkExistence('username', value, props.data?.userId)
+              .then(() => {
+                reject('账号已经存在');
+              })
+              .catch(() => {
+                resolve();
+              });
+          });
+        },
+        trigger: 'blur'
+      }
+    ],
+    nickname: [
+      {
+        required: true,
+        message: '请输入用户名',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    sex: [
+      {
+        required: true,
+        message: '请选择性别',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    roles: [
+      {
+        required: true,
+        message: '请选择角色',
+        type: 'array',
+        trigger: 'blur'
+      }
+    ],
+    email: [
+      {
+        pattern: emailReg,
+        message: '邮箱格式不正确',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    password: [
+      {
+        required: true,
+        type: 'string',
+        validator: async (_rule: Rule, value: string) => {
+          if (isUpdate.value || /^[\S]{5,18}$/.test(value)) {
+            return Promise.resolve();
+          }
+          return Promise.reject('密码必须为5-18位非空白字符');
+        },
+        trigger: 'blur'
+      }
+    ],
+    phone: [
+      {
+        pattern: phoneReg,
+        message: '手机号格式不正确',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ]
+  });
+
+  /* 保存编辑 */
+  const save = () => {
+    if (!formRef.value) {
+      return;
+    }
+    formRef.value
+      .validate()
+      .then(() => {
+        loading.value = true;
+        const saveOrUpdate = isUpdate.value ? updateUser : addUser;
+        saveOrUpdate(form)
+          .then((msg) => {
+            loading.value = false;
+            message.success(msg);
+            updateVisible(false);
+            emit('done');
+          })
+          .catch((e) => {
+            loading.value = false;
+            message.error(e.message);
+          });
+      })
+      .catch(() => {});
+  };
+
+  /* 更新visible */
+  const updateVisible = (value: boolean) => {
+    emit('update:visible', value);
+  };
+
+  watch(
+    () => props.visible,
+    (visible) => {
+      if (visible) {
+        if (props.data) {
+          assignFields({
+            ...props.data,
+            password: ''
+          });
+          isUpdate.value = true;
+        } else {
+          isUpdate.value = false;
+        }
+      } else {
+        resetFields();
+        formRef.value?.clearValidate();
+      }
+    }
+  );
+</script>
diff --git a/src/views-demo/system/user/components/user-import.vue b/src/views-demo/system/user/components/user-import.vue
new file mode 100644
index 0000000..0d8cbcb
--- /dev/null
+++ b/src/views-demo/system/user/components/user-import.vue
@@ -0,0 +1,88 @@
+<!-- 用户导入弹窗 -->
+<template>
+  <ele-modal
+    :width="520"
+    :footer="null"
+    title="导入用户"
+    :visible="visible"
+    @update:visible="updateVisible"
+  >
+    <a-spin :spinning="loading">
+      <a-upload-dragger
+        accept=".xls,.xlsx"
+        :show-upload-list="false"
+        :customRequest="doUpload"
+        style="padding: 24px 0; margin-bottom: 16px"
+      >
+        <p class="ant-upload-drag-icon">
+          <cloud-upload-outlined />
+        </p>
+        <p class="ant-upload-hint">将文件拖到此处,或点击上传</p>
+      </a-upload-dragger>
+    </a-spin>
+    <div class="ele-text-center">
+      <span>只能上传xls、xlsx文件,</span>
+      <a
+        href="https://cdn.eleadmin.com/20200610/用户导入模板.xlsx"
+        download="用户导入模板.xlsx"
+      >
+        下载模板
+      </a>
+    </div>
+  </ele-modal>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import { CloudUploadOutlined } from '@ant-design/icons-vue';
+  import { importUsers } from '@/api/system/user';
+
+  const emit = defineEmits<{
+    (e: 'done'): void;
+    (e: 'update:visible', visible: boolean): void;
+  }>();
+
+  defineProps<{
+    // 是否打开弹窗
+    visible: boolean;
+  }>();
+
+  // 导入请求状态
+  const loading = ref(false);
+
+  /* 上传 */
+  const doUpload = ({ file }) => {
+    if (
+      ![
+        'application/vnd.ms-excel',
+        'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
+      ].includes(file.type)
+    ) {
+      message.error('只能选择 excel 文件');
+      return false;
+    }
+    if (file.size / 1024 / 1024 > 10) {
+      message.error('大小不能超过 10MB');
+      return false;
+    }
+    loading.value = true;
+    importUsers(file)
+      .then((msg) => {
+        loading.value = false;
+        message.success(msg);
+        updateVisible(false);
+        emit('done');
+      })
+      .catch((e) => {
+        loading.value = false;
+        message.error(e.message);
+      });
+    return false;
+  };
+
+  /* 更新 visible */
+  const updateVisible = (value: boolean) => {
+    emit('update:visible', value);
+  };
+</script>
diff --git a/src/views-demo/system/user/components/user-search.vue b/src/views-demo/system/user/components/user-search.vue
new file mode 100644
index 0000000..3e6e866
--- /dev/null
+++ b/src/views-demo/system/user/components/user-search.vue
@@ -0,0 +1,111 @@
+<!-- 搜索表单 -->
+<template>
+  <a-form
+    :label-col="
+      styleResponsive ? { xl: 7, lg: 5, md: 7, sm: 4 } : { flex: '90px' }
+    "
+    :wrapper-col="
+      styleResponsive ? { xl: 17, lg: 19, md: 17, sm: 20 } : { flex: '1' }
+    "
+  >
+    <a-row :gutter="8">
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="用户账号">
+          <a-input
+            v-model:value.trim="form.username"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="用户名">
+          <a-input
+            v-model:value.trim="form.nickname"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="性别">
+          <a-select v-model:value="form.sex" placeholder="请选择" allow-clear>
+            <a-select-option value="1">男</a-select-option>
+            <a-select-option value="2">女</a-select-option>
+          </a-select>
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item class="ele-text-right" :wrapper-col="{ span: 24 }">
+          <a-space>
+            <a-button type="primary" @click="search">查询</a-button>
+            <a-button @click="reset">重置</a-button>
+          </a-space>
+        </a-form-item>
+      </a-col>
+    </a-row>
+  </a-form>
+</template>
+
+<script lang="ts" setup>
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import type { UserParam } from '@/api/system/user/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const props = defineProps<{
+    // 默认搜索条件
+    where?: UserParam;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'search', where?: UserParam): void;
+  }>();
+
+  // 表单数据
+  const { form, resetFields } = useFormData<UserParam>({
+    username: '',
+    nickname: '',
+    sex: undefined,
+    ...props.where
+  });
+
+  /* 搜索 */
+  const search = () => {
+    emit('search', form);
+  };
+
+  /*  重置 */
+  const reset = () => {
+    resetFields();
+    search();
+  };
+</script>
diff --git a/src/views-demo/system/user/details/index.vue b/src/views-demo/system/user/details/index.vue
new file mode 100644
index 0000000..177bb22
--- /dev/null
+++ b/src/views-demo/system/user/details/index.vue
@@ -0,0 +1,122 @@
+<template>
+  <div class="ele-body">
+    <a-card title="基本信息" :bordered="false">
+      <a-form
+        class="ele-form-detail"
+        :label-col="
+          styleResponsive ? { md: 2, sm: 4, xs: 6 } : { flex: '90px' }
+        "
+        :wrapper-col="
+          styleResponsive ? { md: 22, sm: 20, xs: 18 } : { flex: '1' }
+        "
+      >
+        <a-form-item label="账号">
+          <div class="ele-text-secondary">{{ form.username }}</div>
+        </a-form-item>
+        <a-form-item label="用户名">
+          <div class="ele-text-secondary">{{ form.nickname }}</div>
+        </a-form-item>
+        <a-form-item label="性别">
+          <div class="ele-text-secondary">{{ form.sexName }}</div>
+        </a-form-item>
+        <a-form-item label="手机号">
+          <div class="ele-text-secondary">{{ form.phone }}</div>
+        </a-form-item>
+        <a-form-item label="角色">
+          <a-tag v-for="item in form.roles" :key="item.roleId" color="blue">
+            {{ item.roleName }}
+          </a-tag>
+        </a-form-item>
+        <a-form-item label="创建时间">
+          <div class="ele-text-secondary">{{ form.createTime }}</div>
+        </a-form-item>
+        <a-form-item label="状态">
+          <a-badge
+            v-if="typeof form.status === 'number'"
+            :status="(['processing', 'error'][form.status] as any)"
+            :text="['正常', '冻结'][form.status]"
+          />
+        </a-form-item>
+      </a-form>
+    </a-card>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref, watch, unref } from 'vue';
+  import { useRouter } from 'vue-router';
+  import { message } from 'ant-design-vue/es';
+  import { toDateString } from 'ele-admin-pro/es';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import { setPageTabTitle } from '@/utils/page-tab-util';
+  import { getUser } from '@/api/system/user';
+  import type { User } from '@/api/system/user/model';
+  const ROUTE_PATH = '/system/user/details';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const { currentRoute } = useRouter();
+
+  // 用户信息
+  const { form, assignFields } = useFormData<User>({
+    userId: undefined,
+    username: '',
+    nickname: '',
+    sexName: '',
+    phone: '',
+    roles: [],
+    createTime: undefined,
+    status: undefined
+  });
+
+  // 请求状态
+  const loading = ref(true);
+
+  /*  */
+  const query = () => {
+    const { query } = unref(currentRoute);
+    const id = query.id;
+    if (!id || form.userId === Number(id)) {
+      return;
+    }
+    loading.value = true;
+    getUser(Number(id))
+      .then((data) => {
+        loading.value = false;
+        assignFields({
+          ...data,
+          createTime: toDateString(data.createTime)
+        });
+        // 修改页签标题
+        if (unref(currentRoute).path === ROUTE_PATH) {
+          setPageTabTitle(data.nickname + '的信息');
+        }
+      })
+      .catch((e) => {
+        loading.value = false;
+        message.error(e.message);
+      });
+  };
+
+  watch(
+    currentRoute,
+    (route) => {
+      const { path } = unref(route);
+      if (path !== ROUTE_PATH) {
+        return;
+      }
+      query();
+    },
+    { immediate: true }
+  );
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'SystemUserDetails'
+  };
+</script>
diff --git a/src/views-demo/system/user/index.vue b/src/views-demo/system/user/index.vue
new file mode 100644
index 0000000..13730cf
--- /dev/null
+++ b/src/views-demo/system/user/index.vue
@@ -0,0 +1,304 @@
+<template>
+  <div class="ele-body">
+    <a-card :bordered="false">
+      <!-- 搜索表单 -->
+      <user-search :where="defaultWhere" @search="reload" />
+      <!-- 表格 -->
+      <ele-pro-table
+        ref="tableRef"
+        row-key="userId"
+        :columns="columns"
+        :datasource="datasource"
+        v-model:selection="selection"
+        :scroll="{ x: 1000 }"
+        :where="defaultWhere"
+        cache-key="proSystemUserTable"
+      >
+        <template #toolbar>
+          <a-space>
+            <a-button type="primary" class="ele-btn-icon" @click="openEdit()">
+              <template #icon>
+                <plus-outlined />
+              </template>
+              <span>新建</span>
+            </a-button>
+            <a-button
+              danger
+              type="primary"
+              class="ele-btn-icon"
+              @click="removeBatch"
+            >
+              <template #icon>
+                <delete-outlined />
+              </template>
+              <span>删除</span>
+            </a-button>
+            <a-button type="dashed" class="ele-btn-icon" @click="openImport">
+              <template #icon>
+                <upload-outlined />
+              </template>
+              <span>导入</span>
+            </a-button>
+          </a-space>
+        </template>
+        <template #bodyCell="{ column, record }">
+          <template v-if="column.key === 'nickname'">
+            <router-link :to="'/system/user/details?id=' + record.userId">
+              {{ record.nickname }}
+            </router-link>
+          </template>
+          <template v-else-if="column.key === 'roles'">
+            <a-tag v-for="item in record.roles" :key="item.roleId" color="blue">
+              {{ item.roleName }}
+            </a-tag>
+          </template>
+          <template v-else-if="column.key === 'status'">
+            <a-switch
+              :checked="record.status === 0"
+              @change="(checked: boolean) => editStatus(checked, record)"
+            />
+          </template>
+          <template v-else-if="column.key === 'action'">
+            <a-space>
+              <a @click="openEdit(record)">修改</a>
+              <a-divider type="vertical" />
+              <a @click="resetPsw(record)">重置密码</a>
+              <a-divider type="vertical" />
+              <a-popconfirm
+                placement="topRight"
+                title="确定要删除此用户吗?"
+                @confirm="remove(record)"
+              >
+                <a class="ele-text-danger">删除</a>
+              </a-popconfirm>
+            </a-space>
+          </template>
+        </template>
+      </ele-pro-table>
+    </a-card>
+    <!-- 编辑弹窗 -->
+    <user-edit v-model:visible="showEdit" :data="current" @done="reload" />
+    <!-- 导入弹窗 -->
+    <user-import v-model:visible="showImport" @done="reload" />
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { createVNode, ref, reactive } from 'vue';
+  import { message, Modal } from 'ant-design-vue/es';
+  import {
+    PlusOutlined,
+    DeleteOutlined,
+    UploadOutlined,
+    ExclamationCircleOutlined
+  } from '@ant-design/icons-vue';
+  import type { EleProTable } from 'ele-admin-pro/es';
+  import type {
+    DatasourceFunction,
+    ColumnItem
+  } from 'ele-admin-pro/es/ele-pro-table/types';
+  import { toDateString, messageLoading } from 'ele-admin-pro/es';
+  import UserSearch from './components/user-search.vue';
+  import UserEdit from './components/user-edit.vue';
+  import UserImport from './components/user-import.vue';
+  import {
+    pageUsers,
+    removeUser,
+    removeUsers,
+    updateUserStatus,
+    updateUserPassword
+  } from '@/api/system/user';
+  import type { User, UserParam } from '@/api/system/user/model';
+
+  // 表格实例
+  const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
+
+  // 表格列配置
+  const columns = ref<ColumnItem[]>([
+    {
+      key: 'index',
+      width: 48,
+      align: 'center',
+      fixed: 'left',
+      hideInSetting: true,
+      customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
+    },
+    {
+      title: '用户账号',
+      dataIndex: 'username',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '用户名',
+      key: 'nickname',
+      dataIndex: 'nickname',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '性别',
+      dataIndex: 'sexName',
+      width: 80,
+      align: 'center',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '手机号',
+      dataIndex: 'phone',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '角色',
+      key: 'roles'
+    },
+    {
+      title: '创建时间',
+      dataIndex: 'createTime',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true,
+      customRender: ({ text }) => toDateString(text)
+    },
+    {
+      title: '状态',
+      key: 'status',
+      dataIndex: 'status',
+      sorter: true,
+      showSorterTooltip: false,
+      width: 90,
+      align: 'center'
+    },
+    {
+      title: '操作',
+      key: 'action',
+      width: 200,
+      align: 'center'
+    }
+  ]);
+
+  // 表格选中数据
+  const selection = ref<User[]>([]);
+
+  // 当前编辑数据
+  const current = ref<User | null>(null);
+
+  // 是否显示编辑弹窗
+  const showEdit = ref(false);
+
+  // 是否显示用户导入弹窗
+  const showImport = ref(false);
+
+  // 默认搜索条件
+  const defaultWhere = reactive({
+    username: '',
+    nickname: ''
+  });
+
+  // 表格数据源
+  const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
+    return pageUsers({ ...where, ...orders, page, limit });
+  };
+
+  /* 搜索 */
+  const reload = (where?: UserParam) => {
+    selection.value = [];
+    tableRef?.value?.reload({ page: 1, where });
+  };
+
+  /* 打开编辑弹窗 */
+  const openEdit = (row?: User) => {
+    current.value = row ?? null;
+    showEdit.value = true;
+  };
+
+  /* 打开编辑弹窗 */
+  const openImport = () => {
+    showImport.value = true;
+  };
+
+  /* 删除单个 */
+  const remove = (row: User) => {
+    const hide = messageLoading('请求中..', 0);
+    removeUser(row.userId)
+      .then((msg) => {
+        hide();
+        message.success(msg);
+        reload();
+      })
+      .catch((e) => {
+        hide();
+        message.error(e.message);
+      });
+  };
+
+  /* 批量删除 */
+  const removeBatch = () => {
+    if (!selection.value.length) {
+      message.error('请至少选择一条数据');
+      return;
+    }
+    Modal.confirm({
+      title: '提示',
+      content: '确定要删除选中的用户吗?',
+      icon: createVNode(ExclamationCircleOutlined),
+      maskClosable: true,
+      onOk: () => {
+        const hide = messageLoading('请求中..', 0);
+        removeUsers(selection.value.map((d) => d.userId))
+          .then((msg) => {
+            hide();
+            message.success(msg);
+            reload();
+          })
+          .catch((e) => {
+            hide();
+            message.error(e.message);
+          });
+      }
+    });
+  };
+
+  /* 重置用户密码 */
+  const resetPsw = (row: User) => {
+    Modal.confirm({
+      title: '提示',
+      content: '确定要重置此用户的密码为"123456"吗?',
+      icon: createVNode(ExclamationCircleOutlined),
+      maskClosable: true,
+      onOk: () => {
+        const hide = messageLoading('请求中..', 0);
+        updateUserPassword(row.userId)
+          .then((msg) => {
+            hide();
+            message.success(msg);
+          })
+          .catch((e) => {
+            hide();
+            message.error(e.message);
+          });
+      }
+    });
+  };
+
+  /* 修改用户状态 */
+  const editStatus = (checked: boolean, row: User) => {
+    const status = checked ? 0 : 1;
+    updateUserStatus(row.userId, status)
+      .then((msg) => {
+        row.status = status;
+        message.success(msg);
+      })
+      .catch((e) => {
+        message.error(e.message);
+      });
+  };
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'SystemUser'
+  };
+</script>
diff --git a/src/views-demo/user/message/components/message-letter.vue b/src/views-demo/user/message/components/message-letter.vue
new file mode 100644
index 0000000..e3313cc
--- /dev/null
+++ b/src/views-demo/user/message/components/message-letter.vue
@@ -0,0 +1,152 @@
+<template>
+  <div>
+    <ele-pro-table
+      ref="tableRef"
+      row-key="id"
+      :columns="columns"
+      :datasource="datasource"
+      v-model:selection="selection"
+      :scroll="{ x: 600 }"
+    >
+      <template #toolbar>
+        <a-space>
+          <a-button type="primary" class="ele-btn-icon" @click="read">
+            标记已读
+          </a-button>
+          <a-button
+            danger
+            type="primary"
+            class="ele-btn-icon"
+            @click="removeBatch"
+          >
+            删除消息
+          </a-button>
+        </a-space>
+      </template>
+      <template #bodyCell="{ column, record }">
+        <template v-if="column.key === 'status'">
+          <span :class="['ele-text-warning', 'ele-text-info'][record.status]">
+            {{ ['未读', '已读'][record.status] }}
+          </span>
+        </template>
+        <template v-else-if="column.key === 'action'">
+          <a-space>
+            <a @click="reply(record)">回复</a>
+            <a-divider type="vertical" />
+            <a-popconfirm
+              placement="topRight"
+              title="确定要删除此消息吗?"
+              @confirm="remove(record)"
+            >
+              <a class="ele-text-danger">删除</a>
+            </a-popconfirm>
+          </a-space>
+        </template>
+      </template>
+    </ele-pro-table>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type { EleProTable } from 'ele-admin-pro/es';
+  import { pageLetters } from '@/api/user/message';
+  import type { Message } from '@/api/user/message/model';
+  import type {
+    DatasourceFunction,
+    ColumnItem
+  } from 'ele-admin-pro/es/ele-pro-table/types';
+
+  const emit = defineEmits<{
+    (e: 'update-data'): void;
+  }>();
+
+  // 表格实例
+  const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
+
+  // 表格列配置
+  const columns = ref<ColumnItem[]>([
+    {
+      key: 'index',
+      width: 48,
+      align: 'center',
+      fixed: 'left',
+      hideInSetting: true,
+      customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
+    },
+    {
+      title: '私信内容',
+      dataIndex: 'title',
+      ellipsis: true
+    },
+    {
+      title: '发送时间',
+      dataIndex: 'time',
+      ellipsis: true,
+      width: 140,
+      align: 'center'
+    },
+    {
+      title: '状态',
+      key: 'status',
+      width: 90,
+      align: 'center'
+    },
+    {
+      title: '操作',
+      key: 'action',
+      width: 120,
+      align: 'center',
+      hideInSetting: true
+    }
+  ]);
+
+  // 列表选中数据
+  const selection = ref<Message[]>([]);
+
+  // 表格数据源
+  const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
+    return pageLetters({ ...where, ...orders, page, limit });
+  };
+
+  /* 回复 */
+  const reply = (row: Message) => {
+    console.log(row);
+    message.info('点击了回复');
+  };
+
+  /* 删除单个 */
+  const remove = (row: Message) => {
+    console.log(row);
+    message.info('点击了删除');
+    updateUnReadNum();
+  };
+
+  /* 批量删除 */
+  const removeBatch = () => {
+    if (!selection.value.length) {
+      message.error('请至少选择一条数据');
+      return;
+    }
+    message.info('点击了删除');
+    updateUnReadNum();
+  };
+
+  /* 标记已读 */
+  const read = () => {
+    if (!selection.value.length) {
+      message.error('请至少选择一条数据');
+      return;
+    }
+    selection.value.forEach((d) => {
+      d.status = 1;
+    });
+    updateUnReadNum();
+  };
+
+  /* 触发更新未读数量事件 */
+  const updateUnReadNum = () => {
+    emit('update-data');
+  };
+</script>
diff --git a/src/views-demo/user/message/components/message-notice.vue b/src/views-demo/user/message/components/message-notice.vue
new file mode 100644
index 0000000..3a3548a
--- /dev/null
+++ b/src/views-demo/user/message/components/message-notice.vue
@@ -0,0 +1,152 @@
+<template>
+  <div>
+    <ele-pro-table
+      ref="tableRef"
+      row-key="id"
+      :columns="columns"
+      :datasource="datasource"
+      v-model:selection="selection"
+      :scroll="{ x: 600 }"
+    >
+      <template #toolbar>
+        <a-space>
+          <a-button type="primary" class="ele-btn-icon" @click="confirmBatch">
+            批量确认
+          </a-button>
+          <a-button
+            danger
+            type="primary"
+            class="ele-btn-icon"
+            @click="removeBatch"
+          >
+            删除通知
+          </a-button>
+        </a-space>
+      </template>
+      <template #bodyCell="{ column, record }">
+        <template v-if="column.key === 'status'">
+          <span :class="['ele-text-warning', 'ele-text-info'][record.status]">
+            {{ ['未确认', '已确认'][record.status] }}
+          </span>
+        </template>
+        <template v-else-if="column.key === 'action'">
+          <a-space>
+            <a @click="confirm(record)">确认</a>
+            <a-divider type="vertical" />
+            <a-popconfirm
+              placement="topRight"
+              title="确定要删除此通知吗"
+              @confirm="remove(record)"
+            >
+              <a class="ele-text-danger">删除</a>
+            </a-popconfirm>
+          </a-space>
+        </template>
+      </template>
+    </ele-pro-table>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type { EleProTable } from 'ele-admin-pro/es';
+  import { pageNotices } from '@/api/user/message';
+  import type { Message } from '@/api/user/message/model';
+  import type {
+    DatasourceFunction,
+    ColumnItem
+  } from 'ele-admin-pro/es/ele-pro-table/types';
+
+  const emit = defineEmits<{
+    (e: 'update-data'): void;
+  }>();
+
+  // 表格实例
+  const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
+
+  // 表格列配置
+  const columns = ref<ColumnItem[]>([
+    {
+      key: 'index',
+      width: 48,
+      align: 'center',
+      fixed: 'left',
+      hideInSetting: true,
+      customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
+    },
+    {
+      title: '通知标题',
+      dataIndex: 'title',
+      ellipsis: true
+    },
+    {
+      title: '通知时间',
+      dataIndex: 'time',
+      ellipsis: true,
+      width: 140,
+      align: 'center'
+    },
+    {
+      title: '状态',
+      key: 'status',
+      width: 90,
+      align: 'center'
+    },
+    {
+      title: '操作',
+      key: 'action',
+      width: 120,
+      align: 'center',
+      hideInSetting: true
+    }
+  ]);
+
+  // 列表选中数据
+  const selection = ref<Message[]>([]);
+
+  // 表格数据源
+  const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
+    return pageNotices({ ...where, ...orders, page, limit });
+  };
+
+  /* 确认 */
+  const confirm = (row: Message) => {
+    console.log(row);
+    message.info('点击了确认');
+  };
+
+  /* 删除单个 */
+  const remove = (row: Message) => {
+    console.log(row);
+    message.info('点击了删除');
+    updateUnReadNum();
+  };
+
+  /* 批量删除 */
+  const removeBatch = () => {
+    if (!selection.value.length) {
+      message.error('请至少选择一条数据');
+      return;
+    }
+    message.info('点击了删除');
+    updateUnReadNum();
+  };
+
+  /* 批量确认 */
+  const confirmBatch = () => {
+    if (!selection.value.length) {
+      message.error('请至少选择一条数据');
+      return;
+    }
+    selection.value.forEach((d) => {
+      d.status = 1;
+    });
+    updateUnReadNum();
+  };
+
+  /* 触发更新未读数量事件 */
+  const updateUnReadNum = () => {
+    emit('update-data');
+  };
+</script>
diff --git a/src/views-demo/user/message/components/message-todo.vue b/src/views-demo/user/message/components/message-todo.vue
new file mode 100644
index 0000000..e392a27
--- /dev/null
+++ b/src/views-demo/user/message/components/message-todo.vue
@@ -0,0 +1,152 @@
+<template>
+  <div>
+    <ele-pro-table
+      ref="tableRef"
+      row-key="id"
+      :columns="columns"
+      :datasource="datasource"
+      v-model:selection="selection"
+      :scroll="{ x: 600 }"
+    >
+      <template #toolbar>
+        <a-space>
+          <a-button type="primary" class="ele-btn-icon" @click="okBatch">
+            批量完成
+          </a-button>
+          <a-button
+            danger
+            type="primary"
+            class="ele-btn-icon"
+            @click="removeBatch"
+          >
+            删除待办
+          </a-button>
+        </a-space>
+      </template>
+      <template #bodyCell="{ column, record }">
+        <template v-if="column.key === 'status'">
+          <span :class="['ele-text-warning', 'ele-text-info'][record.status]">
+            {{ ['未完成', '已完成'][record.status] }}
+          </span>
+        </template>
+        <template v-else-if="column.key === 'action'">
+          <a-space>
+            <a @click="ok(record)">完成</a>
+            <a-divider type="vertical" />
+            <a-popconfirm
+              placement="topRight"
+              title="确定要删除此消息吗?"
+              @confirm="remove(record)"
+            >
+              <a class="ele-text-danger">删除</a>
+            </a-popconfirm>
+          </a-space>
+        </template>
+      </template>
+    </ele-pro-table>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type { EleProTable } from 'ele-admin-pro/es';
+  import { pageTodos } from '@/api/user/message';
+  import type { Message } from '@/api/user/message/model';
+  import type {
+    DatasourceFunction,
+    ColumnItem
+  } from 'ele-admin-pro/es/ele-pro-table/types';
+
+  const emit = defineEmits<{
+    (e: 'update-data'): void;
+  }>();
+
+  // 表格实例
+  const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
+
+  // 表格列配置
+  const columns = ref<ColumnItem[]>([
+    {
+      key: 'index',
+      width: 48,
+      align: 'center',
+      fixed: 'left',
+      hideInSetting: true,
+      customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
+    },
+    {
+      title: '待办内容',
+      dataIndex: 'title',
+      ellipsis: true
+    },
+    {
+      title: '结束时间',
+      dataIndex: 'time',
+      ellipsis: true,
+      width: 140,
+      align: 'center'
+    },
+    {
+      title: '状态',
+      key: 'status',
+      width: 90,
+      align: 'center'
+    },
+    {
+      title: '操作',
+      key: 'action',
+      width: 120,
+      align: 'center',
+      hideInSetting: true
+    }
+  ]);
+
+  // 列表选中数据
+  const selection = ref<Message[]>([]);
+
+  // 表格数据源
+  const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
+    return pageTodos({ ...where, ...orders, page, limit });
+  };
+
+  /* 完成 */
+  const ok = (row: Message) => {
+    console.log(row);
+    message.info('点击了完成');
+  };
+
+  /* 删除单个 */
+  const remove = (row: Message) => {
+    console.log(row);
+    message.info('点击了删除');
+    updateUnReadNum();
+  };
+
+  /* 批量删除 */
+  const removeBatch = () => {
+    if (!selection.value.length) {
+      message.error('请至少选择一条数据');
+      return;
+    }
+    message.info('点击了删除');
+    updateUnReadNum();
+  };
+
+  /* 批量完成 */
+  const okBatch = () => {
+    if (!selection.value.length) {
+      message.error('请至少选择一条数据');
+      return;
+    }
+    selection.value.forEach((d) => {
+      d.status = 1;
+    });
+    updateUnReadNum();
+  };
+
+  /* 触发更新未读数量事件 */
+  const updateUnReadNum = () => {
+    emit('update-data');
+  };
+</script>
diff --git a/src/views-demo/user/message/index.vue b/src/views-demo/user/message/index.vue
new file mode 100644
index 0000000..777cb3d
--- /dev/null
+++ b/src/views-demo/user/message/index.vue
@@ -0,0 +1,180 @@
+<template>
+  <div :class="['ele-body', { 'demo-message-responsive': styleResponsive }]">
+    <a-card :bordered="false" :body-style="{ padding: '0px' }">
+      <div class="ele-cell ele-cell-align-top ele-user-message">
+        <div class="message-menu-wrap">
+          <a-menu :selected-keys="active" :mode="mode">
+            <a-menu-item key="notice">
+              <router-link to="/user/message?type=notice">
+                <a-badge v-if="unReadNotice" :count="unReadNotice" />
+                <span>系统通知</span>
+              </router-link>
+            </a-menu-item>
+            <a-menu-item key="letter">
+              <router-link to="/user/message?type=letter">
+                <a-badge v-if="unReadLetter" :count="unReadLetter" />
+                <span>用户私信</span>
+              </router-link>
+            </a-menu-item>
+            <a-menu-item key="todo">
+              <router-link to="/user/message?type=todo">
+                <a-badge v-if="unReadTodo" :count="unReadTodo" />
+                <span>代办事项</span>
+              </router-link>
+            </a-menu-item>
+          </a-menu>
+        </div>
+        <div class="ele-cell-content" style="overflow-x: hidden">
+          <transition name="slide-right" mode="out-in">
+            <message-notice
+              v-if="active.includes('notice')"
+              @update-data="queryUnReadNum"
+            />
+            <message-letter
+              v-else-if="active.includes('letter')"
+              @update-data="queryUnReadNum"
+            />
+            <message-todo v-else @update-data="queryUnReadNum" />
+          </transition>
+        </div>
+      </div>
+    </a-card>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref, watch, unref, computed } from 'vue';
+  import { useRouter } from 'vue-router';
+  import { storeToRefs } from 'pinia';
+  import { message } from 'ant-design-vue/es';
+  import { useThemeStore } from '@/store/modules/theme';
+  import MessageNotice from './components/message-notice.vue';
+  import MessageLetter from './components/message-letter.vue';
+  import MessageTodo from './components/message-todo.vue';
+  import { getUnReadNum } from '@/api/user/message';
+
+  const { currentRoute } = useRouter();
+  const themeStore = useThemeStore();
+  const { screenWidth, styleResponsive } = storeToRefs(themeStore);
+
+  // 导航选中
+  const active = ref<string[]>([]);
+
+  // 通知未读数量
+  const unReadNotice = ref(0);
+
+  // 私信未读数量
+  const unReadLetter = ref(0);
+
+  // 代办未读数量
+  const unReadTodo = ref(0);
+
+  // 导航模式
+  const mode = computed(() => {
+    return styleResponsive.value && screenWidth.value < 768
+      ? 'horizontal'
+      : 'inline';
+  });
+
+  watch(
+    currentRoute,
+    (route) => {
+      const { path, query } = unref(route);
+      if (path === '/user/message') {
+        const defaultType = 'notice';
+        if (!query.type) {
+          active.value = [defaultType];
+        } else if (typeof query.type === 'string') {
+          active.value = [query.type || defaultType];
+        } else if (query.type.length && query.type[0]) {
+          active.value = [query.type[0]];
+        } else {
+          active.value = [defaultType];
+        }
+      }
+    },
+    {
+      immediate: true
+    }
+  );
+
+  /* 查询未读数量 */
+  const queryUnReadNum = () => {
+    getUnReadNum()
+      .then((result) => {
+        unReadNotice.value = result.notice;
+        unReadLetter.value = result.letter;
+        unReadTodo.value = result.todo;
+      })
+      .catch((e) => {
+        message.error(e.message);
+      });
+  };
+
+  queryUnReadNum();
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'UserMessage'
+  };
+</script>
+
+<style lang="less" scoped>
+  .message-menu-wrap {
+    width: 150px;
+    display: flex;
+
+    :deep(.ant-menu) {
+      padding-top: 16px;
+
+      .ant-badge {
+        vertical-align: -2px;
+        margin-right: 10px;
+      }
+
+      .ant-badge-count {
+        height: 16px;
+        line-height: 16px;
+        border-radius: 8px;
+        box-shadow: none;
+        min-width: 16px;
+        padding: 0 2px;
+      }
+
+      .ant-scroll-number-only {
+        height: 16px;
+
+        & > p.ant-scroll-number-only-unit {
+          height: 16px;
+        }
+      }
+    }
+
+    & + .ele-cell-content {
+      padding: 16px 24px;
+      overflow: auto;
+    }
+  }
+
+  @media screen and (max-width: 768px) {
+    .demo-message-responsive {
+      .ele-user-message {
+        display: block;
+
+        & > .ele-cell-content {
+          padding: 16px 16px;
+        }
+      }
+
+      .message-menu-wrap {
+        width: auto;
+        display: block;
+
+        :deep(.ant-menu) {
+          padding-top: 0;
+        }
+      }
+    }
+  }
+</style>
diff --git a/src/views-demo/user/profile/index.vue b/src/views-demo/user/profile/index.vue
new file mode 100644
index 0000000..1d7970f
--- /dev/null
+++ b/src/views-demo/user/profile/index.vue
@@ -0,0 +1,426 @@
+<template>
+  <div class="ele-body ele-body-card">
+    <a-row :gutter="16">
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xxl: 6, xl: 7, lg: 9, md: 10, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-card :bordered="false">
+          <div class="ele-text-center">
+            <div class="user-info-avatar-group" @click="openCropper">
+              <a-avatar :size="110" :src="form.avatar" />
+              <upload-outlined class="user-info-avatar-icon" />
+            </div>
+            <h1>{{ loginUser.nickname }}</h1>
+            <div>{{ loginUser.introduction }}</div>
+          </div>
+          <div class="user-info-list">
+            <div class="ele-cell">
+              <user-outlined />
+              <div class="ele-cell-content">资深前端工程师</div>
+            </div>
+            <div class="ele-cell">
+              <home-outlined />
+              <div class="ele-cell-content">某某公司 - 研发部 - 某某组</div>
+            </div>
+            <div class="ele-cell">
+              <environment-outlined />
+              <div class="ele-cell-content">中国 • 浙江省 • 杭州市</div>
+            </div>
+            <div class="ele-cell">
+              <tag-outlined />
+              <div class="ele-cell-content">JavaScript、HTML、CSS</div>
+            </div>
+          </div>
+          <a-divider dashed />
+          <h6>标签</h6>
+          <div class="user-info-tags">
+            <a-tag>很有想法的</a-tag>
+            <a-tag>专注设计</a-tag>
+            <a-tag>辣~</a-tag>
+            <a-tag>大长腿</a-tag>
+            <a-tag>川妹子</a-tag>
+            <a-tag>海纳百川</a-tag>
+          </div>
+        </a-card>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xxl: 18, xl: 17, lg: 15, md: 14, sm: 24, xs: 24 }
+            : { span: 18 }
+        "
+      >
+        <a-card
+          :bordered="false"
+          :body-style="{ paddingTop: '0px', minHeight: '600px' }"
+        >
+          <a-tabs v-model:active-key="active" size="large">
+            <a-tab-pane tab="基本信息" key="info">
+              <a-form
+                ref="formRef"
+                :model="form"
+                :rules="rules"
+                :label-col="
+                  styleResponsive
+                    ? { lg: 4, md: 6, sm: 4, xs: 24 }
+                    : { flex: '100px' }
+                "
+                :wrapper-col="
+                  styleResponsive
+                    ? { lg: 20, md: 18, sm: 20, xs: 24 }
+                    : { flex: '1' }
+                "
+                style="max-width: 580px; margin-top: 20px"
+              >
+                <a-form-item label="昵称" name="nickname">
+                  <a-input
+                    v-model:value="form.nickname"
+                    placeholder="请输入昵称"
+                    allow-clear
+                  />
+                </a-form-item>
+                <a-form-item label="性别" name="sex">
+                  <a-select
+                    v-model:value="form.sex"
+                    placeholder="请选择性别"
+                    allow-clear
+                  >
+                    <a-select-option value="保密">保密</a-select-option>
+                    <a-select-option value="男">男</a-select-option>
+                    <a-select-option value="女">女</a-select-option>
+                  </a-select>
+                </a-form-item>
+                <a-form-item label="邮箱" name="email">
+                  <a-input
+                    v-model:value="form.email"
+                    placeholder="请输入邮箱"
+                    allow-clear
+                  />
+                </a-form-item>
+                <a-form-item label="个人简介">
+                  <a-textarea
+                    v-model:value="form.introduction"
+                    placeholder="请输入个人简介"
+                    :rows="4"
+                  />
+                </a-form-item>
+                <a-form-item label="街道地址">
+                  <a-input
+                    v-model:value="form.address"
+                    placeholder="请输入街道地址"
+                    allow-clear
+                  />
+                </a-form-item>
+                <a-form-item label="联系电话:">
+                  <div class="ele-cell">
+                    <a-input v-model:value="form.tellPre" style="width: 65px" />
+                    <div class="ele-cell-content">
+                      <a-input
+                        v-model:value="form.tell"
+                        placeholder="请输入联系电话"
+                        allow-clear
+                      />
+                    </div>
+                  </div>
+                </a-form-item>
+                <a-form-item
+                  :wrapper-col="
+                    styleResponsive
+                      ? {
+                          lg: { offset: 4 },
+                          md: { offset: 6 },
+                          sm: { offset: 4 }
+                        }
+                      : { offset: 4 }
+                  "
+                >
+                  <a-button type="primary" :loading="loading" @click="save">
+                    {{ loading ? '保存中..' : '保存更改' }}
+                  </a-button>
+                </a-form-item>
+              </a-form>
+            </a-tab-pane>
+            <a-tab-pane tab="账号绑定" key="account">
+              <div class="user-account-list">
+                <div class="ele-cell">
+                  <div class="ele-cell-content">
+                    <div class="ele-cell-title">密保手机</div>
+                    <div class="ele-cell-desc">已绑定手机: 138****8293</div>
+                  </div>
+                  <a>去修改</a>
+                </div>
+                <a-divider />
+                <div class="ele-cell">
+                  <div class="ele-cell-content">
+                    <div class="ele-cell-title">密保邮箱</div>
+                    <div class="ele-cell-desc">
+                      已绑定邮箱: eleadmin@eclouds.com
+                    </div>
+                  </div>
+                  <a>去修改</a>
+                </div>
+                <a-divider />
+                <div class="ele-cell">
+                  <div class="ele-cell-content">
+                    <div class="ele-cell-title">密保问题</div>
+                    <div class="ele-cell-desc">未设置密保问题</div>
+                  </div>
+                  <a>去设置</a>
+                </div>
+                <a-divider />
+                <div class="ele-cell">
+                  <qq-outlined class="user-account-icon" />
+                  <div class="ele-cell-content">
+                    <div class="ele-cell-title">绑定QQ</div>
+                    <div class="ele-cell-desc">当前未绑定QQ账号</div>
+                  </div>
+                  <a>去绑定</a>
+                </div>
+                <a-divider />
+                <div class="ele-cell">
+                  <wechat-outlined class="user-account-icon" />
+                  <div class="ele-cell-content">
+                    <div class="ele-cell-title">绑定微信</div>
+                    <div class="ele-cell-desc">当前未绑定绑定微信账号</div>
+                  </div>
+                  <a>去绑定</a>
+                </div>
+                <a-divider />
+                <div class="ele-cell">
+                  <alipay-outlined class="user-account-icon" />
+                  <div class="ele-cell-content">
+                    <div class="ele-cell-title">绑定支付宝</div>
+                    <div class="ele-cell-desc">当前未绑定绑定支付宝账号</div>
+                  </div>
+                  <a>去绑定</a>
+                </div>
+              </div>
+            </a-tab-pane>
+          </a-tabs>
+        </a-card>
+      </a-col>
+    </a-row>
+    <!-- 头像裁剪弹窗 -->
+    <ele-cropper-modal
+      :src="form.avatar"
+      v-model:visible="visible"
+      :options="{ autoCropArea: 1, viewMode: 1, dragMode: 'move' }"
+      @done="onCrop"
+    />
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive, computed } from 'vue';
+  import {
+    UploadOutlined,
+    UserOutlined,
+    HomeOutlined,
+    EnvironmentOutlined,
+    TagOutlined,
+    QqOutlined,
+    WechatOutlined,
+    AlipayOutlined
+  } from '@ant-design/icons-vue';
+  import { message } from 'ant-design-vue/es';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import { useUserStore } from '@/store/modules/user';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const userStore = useUserStore();
+
+  //
+  const formRef = ref<FormInstance | null>(null);
+
+  // tab 页选中
+  const active = ref('info');
+
+  // 保存按钮 loading
+  const loading = ref(false);
+
+  // 是否显示裁剪弹窗
+  const visible = ref(false);
+
+  // 登录用户信息
+  const loginUser = computed(() => userStore.info ?? {});
+
+  // 表单数据
+  const form = reactive({
+    nickname: loginUser.value.nickname,
+    sex: '保密',
+    email: 'eleadmin@eclouds.com',
+    introduction: loginUser.value.introduction,
+    address: '',
+    tellPre: '0752',
+    tell: '',
+    avatar: 'https://cdn.eleadmin.com/20200610/avatar.jpg'
+  });
+
+  // 表单验证规则
+  const rules = reactive<Record<string, Rule[]>>({
+    nickname: [
+      {
+        required: true,
+        message: '请输入昵称',
+        type: 'string'
+      }
+    ],
+    sex: [
+      {
+        required: true,
+        message: '请选择性别',
+        type: 'string'
+      }
+    ],
+    email: [
+      {
+        required: true,
+        message: '请输入邮箱',
+        type: 'string'
+      }
+    ]
+  });
+
+  /* 修改登录用户 */
+  const updateLoginUser = (obj: Record<string, any>) => {
+    userStore.setInfo({ ...loginUser.value, ...obj });
+  };
+
+  /* 保存更改 */
+  const save = () => {
+    if (!formRef.value) {
+      return;
+    }
+    formRef.value
+      .validate()
+      .then(() => {
+        loading.value = true;
+        setTimeout(() => {
+          loading.value = false;
+          message.success('保存成功');
+          updateLoginUser(form);
+        }, 800);
+      })
+      .catch(() => {});
+  };
+
+  /* 头像裁剪完成回调 */
+  const onCrop = (result: string) => {
+    form.avatar = result;
+    visible.value = false;
+    updateLoginUser(form);
+  };
+
+  /* 打开图片裁剪 */
+  const openCropper = () => {
+    visible.value = true;
+  };
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'UserProfile'
+  };
+</script>
+
+<style lang="less" scoped>
+  /* 用户资料卡片 */
+  .user-info-avatar-group {
+    margin: 16px 0;
+    display: inline-block;
+    position: relative;
+    cursor: pointer;
+
+    .user-info-avatar-icon {
+      position: absolute;
+      top: 50%;
+      left: 50%;
+      transform: translate(-50%, -50%);
+      color: #fff;
+      font-size: 30px;
+      display: none;
+      z-index: 2;
+    }
+
+    &:hover .user-info-avatar-icon {
+      display: block;
+    }
+
+    &:after {
+      content: '';
+      position: absolute;
+      top: 0;
+      left: 0;
+      width: 100%;
+      height: 100%;
+      border-radius: 50%;
+      background-color: transparent;
+      transition: background-color 0.3s;
+    }
+
+    &:hover:after {
+      background-color: rgba(0, 0, 0, 0.4);
+    }
+
+    & + h1 {
+      margin-bottom: 8px;
+    }
+  }
+
+  /* 用户信息列表 */
+  .user-info-list {
+    margin: 47px 0 32px 0;
+
+    .ele-cell + .ele-cell {
+      margin-top: 16px;
+    }
+
+    & + .ant-divider {
+      margin-bottom: 16px;
+    }
+  }
+
+  /* 用户标签 */
+  .user-info-tags {
+    margin: 16px 0 4px 0;
+
+    .ant-tag {
+      margin: 0 12px 8px 0;
+    }
+  }
+
+  /* 用户账号绑定列表 */
+  .user-account-list {
+    & > .ele-cell {
+      padding: 16px 8px;
+    }
+
+    .user-account-icon {
+      color: #fff;
+      padding: 8px;
+      font-size: 26px;
+      border-radius: 50%;
+
+      &.anticon-qq {
+        background: #3492ed;
+      }
+
+      &.anticon-wechat {
+        background: #4daf29;
+      }
+
+      &.anticon-alipay {
+        background: #1476fe;
+      }
+    }
+  }
+</style>
diff --git a/src/views/content/category/components/cate-article-edit.vue b/src/views/content/category/components/cate-article-edit.vue
new file mode 100644
index 0000000..d9b506a
--- /dev/null
+++ b/src/views/content/category/components/cate-article-edit.vue
@@ -0,0 +1,443 @@
+<!-- 文章编辑弹窗 -->
+<template>
+  <ele-modal
+    :width="680"
+    :visible="visible"
+    :confirm-loading="loading"
+    :title="isUpdate ? '修改内容' : '新建内容'"
+    :body-style="{ paddingBottom: '8px' }"
+    @update:visible="updateVisible"
+    @ok="save"
+  >
+    <a-form
+      ref="formRef"
+      :model="form"
+      :rules="rules"
+      :label-col="styleResponsive ? { md: 7, sm: 24 } : { flex: '90px' }"
+      :wrapper-col="styleResponsive ? { md: 17, sm: 24 } : { flex: '1' }"
+    >
+      <a-row :gutter="16">
+        <a-col :span="24">
+          <a-form-item
+            label="文章标题"
+            name="title"
+            :label-col="{ flex: '90px' }"
+            :wrapper-col="{ flex: 1 }"
+          >
+            <a-input
+              allow-clear
+              placeholder="请输入文章标题"
+              v-model:value="form.title"
+            />
+          </a-form-item>
+        </a-col>
+      </a-row>
+      <a-row :gutter="16">
+        <a-col
+          v-bind="styleResponsive ? { md: 12, sm: 24, xs: 24 } : { span: 12 }"
+        >
+          <a-form-item label="所属栏目">
+            <cate-select
+              :data="cateList"
+              placeholder="请选择所属栏目"
+              v-model:value="form.cateId"
+            />
+          </a-form-item>
+          <a-form-item v-if="form.type === 1" label="内容来源" name="origin">
+            <a-input
+              allow-clear
+              placeholder="请输入内容来源"
+              v-model:value="form.origin"
+            />
+          </a-form-item>
+          <a-form-item v-if="form.type !== 1" label="发布者" name="user">
+            <a-input
+              allow-clear
+              :maxlength="20"
+              placeholder="请输入发布者"
+              v-model:value="form.user"
+            />
+          </a-form-item>
+        </a-col>
+        <a-col
+          v-bind="styleResponsive ? { md: 12, sm: 24, xs: 24 } : { span: 12 }"
+        >
+          <a-form-item
+            label="文章模板"
+            :label-col="{ flex: '90px' }"
+            :wrapper-col="{ flex: 1 }"
+          >
+            <a-input
+              allow-clear
+              :disabled="form.type !== 0"
+              placeholder="请输入模板路径"
+              v-model:value="form.template"
+            />
+          </a-form-item>
+
+          <a-form-item label="文章类型" name="type">
+            <a-radio-group v-model:value="form.type" @change="onTypeChange">
+              <a-radio :value="0">文章</a-radio>
+              <a-radio :value="1">外链</a-radio>
+              <a-radio :value="2">图集</a-radio>
+            </a-radio-group>
+          </a-form-item>
+        </a-col>
+      </a-row>
+      <a-row :gutter="16">
+        <a-col :span="12">
+          <a-form-item label="关键字" name="keywords">
+            <a-input
+              allow-clear
+              placeholder="请输入关键字"
+              v-model:value="form.keywords"
+            />
+          </a-form-item>
+        </a-col>
+        <a-col :span="12">
+          <a-form-item label="发布时间" name="createTime">
+            <a-date-picker
+              show-time
+              format="YYYY-MM-DD HH:mm:ss"
+              placeholder="请选择发布时间"
+              v-model:value="form.createTime"
+            />
+          </a-form-item>
+        </a-col>
+      </a-row>
+      <a-row>
+        <a-col :span="24">
+          <a-form-item
+            label="图片"
+            name="image"
+            :label-col="{ flex: '90px' }"
+            :wrapper-col="{ flex: '1' }"
+          >
+            <ele-image-upload
+              v-model:value="images"
+              :limit="12"
+              :drag="true"
+              :multiple="true"
+              :upload-handler="uploadHandler"
+              :remove-handler="removeHandler"
+              @upload="onUpload"
+            />
+          </a-form-item>
+        </a-col>
+      </a-row>
+      <a-row :gutter="16" v-if="form.type === 1">
+        <a-col :span="24">
+          <a-form-item
+            label="外链地址"
+            :label-col="{ flex: '90px' }"
+            :wrapper-col="{ flex: 1 }"
+          >
+            <a-input
+              allow-clear
+              :disabled="form.type !== 1"
+              placeholder="请输入外链地址"
+              v-model:value="form.link"
+            />
+          </a-form-item>
+        </a-col>
+      </a-row>
+      <a-row :gutter="16">
+        <a-col :span="24">
+          <a-form-item
+            label="文章描述"
+            :label-col="{ flex: '90px' }"
+            :wrapper-col="{ flex: '1' }"
+          >
+            <a-textarea
+              :rows="4"
+              :maxlength="200"
+              placeholder="请输入文章描述"
+              v-model:value="form.summary"
+            />
+          </a-form-item>
+        </a-col>
+      </a-row>
+      <a-row :gutter="16" v-if="form.type === 0">
+        <a-col :span="24">
+          <a-form-item
+            label="内容"
+            :label-col="{ flex: '90px' }"
+            :wrapper-col="{ flex: '1' }"
+            name="review"
+          >
+            <tinymce-editor v-model:value="form.content" :init="config" />
+          </a-form-item>
+        </a-col>
+      </a-row>
+    </a-form>
+  </ele-modal>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive, watch } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import type { ItemType } from 'ele-admin-pro/es/ele-image-upload/types';
+  import CateSelect from './cate-select.vue';
+  import type { Article } from '@/api/content/article/model';
+  import { addArticle, updateArticle } from '@/api/content/article';
+  import request from '@/utils/request';
+  import type { Category } from '@/api/content/category/model';
+  import TinymceEditor from '@/components/TinymceEditor/index.vue';
+  import dayjs from 'dayjs';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'done'): void;
+    (e: 'update:visible', visible: boolean): void;
+  }>();
+
+  const props = defineProps<{
+    // 弹窗是否打开
+    visible: boolean;
+    // 修改回显的数据
+    data?: Article | null;
+    // 全部栏目
+    cateList: Category[];
+    // 栏目id
+    cateId?: number;
+  }>();
+
+  //
+  const formRef = ref<FormInstance | null>(null);
+  const editorRef = ref<InstanceType<typeof TinymceEditor> | null>(null);
+  // 是否是修改
+  const isUpdate = ref(false);
+
+  // 提交状态
+  const loading = ref(false);
+
+  // 标题图
+  const images = ref<ItemType[]>([]);
+
+  const config = ref({
+    height: 280,
+    menubar: false,
+    images_upload_handler: (blobInfo, success, error) => {
+      const file = blobInfo.blob();
+      // 使用 axios 上传,实际开发这段建议写在 api 中再调用 api
+      const formData = new FormData();
+      formData.append('file', file, file.name);
+      request
+        .post('/file/upload', formData)
+        .then((res) => {
+          if (res.data.code === 0) {
+            success(res.data.data);
+          } else {
+            error(res.data.message);
+          }
+        })
+        .catch((e) => {
+          console.error(e);
+          error(e.message);
+        });
+    },
+    // 自定义文件上传(这里使用把选择的文件转成 blob 演示)
+    file_picker_callback: (callback: any, _value: any, meta: any) => {
+      const input = document.createElement('input');
+      input.setAttribute('type', 'file');
+      // 设定文件可选类型
+      if (meta.filetype === 'image') {
+        input.setAttribute('accept', 'image/*');
+      } else if (meta.filetype === 'media') {
+        input.setAttribute('accept', 'video/*');
+      }
+      input.onchange = () => {
+        const file = input.files?.[0];
+        if (!file) {
+          return;
+        }
+        if (meta.filetype === 'media') {
+          if (!file.type.startsWith('video/')) {
+            editorRef.value?.alert({ content: '只能选择视频文件' });
+            return;
+          }
+        }
+        if (file.size / 1024 / 1024 > 20) {
+          editorRef.value?.alert({ content: '大小不能超过 20MB' });
+          return;
+        }
+        const reader = new FileReader();
+        reader.onload = (e) => {
+          if (e.target?.result != null) {
+            const blob = new Blob([e.target.result], { type: file.type });
+            callback(URL.createObjectURL(blob));
+          }
+        };
+        reader.readAsArrayBuffer(file);
+      };
+      input.click();
+    }
+  });
+  //单选改变事件
+  const onTypeChange = () => {
+    if (form.type === 0) {
+      form.link = '';
+    }
+  };
+  // 表单数据
+  const { form, resetFields, assignFields } = useFormData<Article>({
+    id: undefined,
+    cateId: undefined,
+    title: '',
+    user: '',
+    origin: '',
+    type: 0,
+    link: '',
+    image: '',
+    keywords: '',
+    template: '',
+    summary: '',
+    content: '',
+    createTime: ''
+  });
+
+  // 表单验证规则
+  const rules = reactive<Record<string, Rule[]>>({
+    title: [
+      {
+        required: true,
+        message: '请输入标题',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ]
+  });
+  /* 上传事件 */
+  const uploadHandler = (file: File) => {
+    const item: ItemType = {
+      file,
+      uid: (file as any).uid,
+      name: file.name,
+      progress: 0,
+      status: undefined
+    };
+    if (!file.type.startsWith('image')) {
+      message.error('只能选择图片');
+      return;
+    }
+    if (file.size / 1024 / 1024 > 2) {
+      message.error('大小不能超过 2MB');
+      return;
+    }
+    item.url = window.URL.createObjectURL(file);
+    images.value.push(item);
+    onUpload(item);
+  };
+
+  const removeHandler = (item) => {
+    images.value = images.value.filter((d) => d !== item);
+  };
+
+  /* 上传 item */
+  const onUpload = (d: ItemType) => {
+    const item: any = images.value.find((t) => t.uid === d.uid) ?? d;
+    // 上传
+    item.status = 'uploading';
+    const formData = new FormData();
+    formData.append('file', item.file);
+    request({
+      url: '/file/upload',
+      method: 'post',
+      data: formData,
+      onUploadProgress: (e: any) => {
+        // 文件上传进度回调
+        if (e.lengthComputable) {
+          item.progress = (e.loaded / e.total) * 100;
+        }
+      }
+    })
+      .then((res) => {
+        if (res.data.code === 0) {
+          item.status = 'done';
+          item.url = res.data.data;
+        }
+      })
+      .catch((e) => {
+        message.error(e);
+        item.status = 'exception';
+      });
+  };
+  /* 保存编辑 */
+  const save = () => {
+    if (!formRef.value) {
+      return;
+    }
+    formRef.value
+      .validate()
+      .then(() => {
+        loading.value = true;
+        let img_arr = [];
+        if (images.value.length > 0) {
+          images.value.forEach((item) => {
+            img_arr.push(item.url);
+          });
+        }
+        const articleForm = {
+          ...form,
+          createTime: dayjs(form.createTime).format('YY-MM-DD HH:mm:ss'),
+          image: JSON.stringify(img_arr)
+        };
+        const saveOrUpdate = isUpdate.value ? updateArticle : addArticle;
+        saveOrUpdate(articleForm)
+          .then((msg) => {
+            loading.value = false;
+            message.success(msg);
+            updateVisible(false);
+            emit('done');
+          })
+          .catch((e) => {
+            loading.value = false;
+            message.error(e.message);
+          });
+      })
+      .catch(() => {});
+  };
+
+  /* 更新visible */
+  const updateVisible = (value: boolean) => {
+    emit('update:visible', value);
+  };
+
+  watch(
+    () => props.visible,
+    (visible) => {
+      if (visible) {
+        if (props.data) {
+          if (props.data.image != undefined) {
+            let img_arr = JSON.parse(props.data.image);
+            img_arr.map((item, uid) => {
+              images.value.push({
+                uid: uid,
+                url: item
+              });
+            });
+          }
+          assignFields({
+            ...props.data,
+            createTime: dayjs(props.data.createTime, 'YY-MM-DD HH:mm:ss')
+          });
+          isUpdate.value = true;
+        } else {
+          form.cateId = props.cateId;
+          isUpdate.value = false;
+        }
+      } else {
+        resetFields();
+        images.value = [];
+        formRef.value?.clearValidate();
+      }
+    }
+  );
+</script>
diff --git a/src/views/content/category/components/cate-article-list.vue b/src/views/content/category/components/cate-article-list.vue
new file mode 100644
index 0000000..9e326a1
--- /dev/null
+++ b/src/views/content/category/components/cate-article-list.vue
@@ -0,0 +1,222 @@
+<template>
+  <!-- 表格 -->
+  <ele-pro-table
+    ref="tableRef"
+    row-key="id"
+    :columns="columns"
+    :datasource="datasource"
+    height="calc(100vh - 290px)"
+    tool-class="ele-toolbar-form"
+    :scroll="{ x: 800 }"
+    tools-theme="default"
+    bordered
+    cache-key="contentCategpryArticle"
+    class="content-article-table"
+  >
+    <template #toolbar>
+      <cate-article-search @search="reload" @add="openEdit()" />
+    </template>
+    <template #bodyCell="{ column, record }">
+      <template v-if="column.key === 'type'">
+        <a-tag color="blue" v-if="record.type === 0">文章</a-tag>
+        <a-tag color="red" v-if="record.type === 1">外链</a-tag>
+        <a-tag color="green" v-if="record.type === 2">图集</a-tag>
+      </template>
+      <template v-else-if="column.key === 'status'">
+        <a-switch
+          :checked="record.status === 0"
+          @change="(checked: boolean) => editStatus(checked, record)"
+        />
+      </template>
+      <template v-else-if="column.key === 'action'">
+        <a-space>
+          <a @click="openEdit(record)">修改</a>
+          <a-divider type="vertical" />
+          <a-popconfirm
+            placement="topRight"
+            title="确定要删除此内容吗?"
+            @confirm="remove(record)"
+          >
+            <a class="ele-text-danger">删除</a>
+          </a-popconfirm>
+        </a-space>
+      </template>
+    </template>
+  </ele-pro-table>
+  <!-- 编辑弹窗 -->
+  <cate-article-edit
+    :data="current"
+    v-model:visible="showEdit"
+    :cate-list="cateList"
+    :cate-id="cateId"
+    @done="reload"
+  />
+</template>
+
+<script lang="ts" setup>
+  import { ref, watch } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type { EleProTable } from 'ele-admin-pro/es';
+  import type {
+    DatasourceFunction,
+    ColumnItem
+  } from 'ele-admin-pro/es/ele-pro-table/types';
+  import { messageLoading, toDateString } from 'ele-admin-pro/es';
+  import CateArticleSearch from './cate-article-search.vue';
+  import CateArticleEdit from './cate-article-edit.vue';
+  import type { Category } from '@/api/content/category/model';
+  import { Article, ArticleParam } from '@/api/content/article/model';
+  import {
+    pageArticles,
+    removeArticle,
+    updateArticleStatus
+  } from '@/api/content/article';
+
+  const props = defineProps<{
+    // 栏目 id
+    cateId?: number;
+    // 全部栏目
+    cateList: Category[];
+  }>();
+
+  // 表格实例
+  const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
+
+  // 表格列配置
+  const columns = ref<ColumnItem[]>([
+    {
+      key: 'index',
+      width: 48,
+      align: 'center',
+      fixed: 'left',
+      hideInSetting: true,
+      customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
+    },
+    {
+      title: 'ID',
+      width: 60,
+      align: 'center',
+      dataIndex: 'id',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '标题',
+      dataIndex: 'title',
+      sorter: true,
+      ellipsis: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '作者',
+      dataIndex: 'user',
+      width: 80,
+      align: 'center',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '类型',
+      width: 80,
+      dataIndex: 'type',
+      key: 'type'
+    },
+    {
+      title: '创建时间',
+      dataIndex: 'createTime',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true,
+      customRender: ({ text }) => toDateString(text)
+    },
+    {
+      title: '状态',
+      key: 'status',
+      showSorterTooltip: false,
+      width: 80,
+      align: 'center'
+    },
+    {
+      title: '操作',
+      key: 'action',
+      width: 100,
+      align: 'center'
+    }
+  ]);
+
+  // 当前编辑数据
+  const current = ref<Article | null>(null);
+
+  // 是否显示编辑弹窗
+  const showEdit = ref(false);
+
+  // 表格数据源
+  const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
+    return pageArticles({
+      ...where,
+      ...orders,
+      page,
+      limit,
+      cateId: props.cateId
+    });
+  };
+
+  /* 搜索 */
+  const reload = (where?: ArticleParam) => {
+    tableRef?.value?.reload({ page: 1, where });
+  };
+
+  /* 打开编辑弹窗 */
+  const openEdit = (row?: Article) => {
+    current.value = row ?? null;
+    showEdit.value = true;
+  };
+
+  /* 删除单个 */
+  const remove = (row: Article) => {
+    const hide = messageLoading('请求中..', 0);
+    removeArticle(row.id)
+      .then((msg) => {
+        hide();
+        message.success(msg);
+        reload();
+      })
+      .catch((e) => {
+        hide();
+        message.error(e.message);
+      });
+  };
+
+  /* 修改用户状态 */
+  const editStatus = (checked: boolean, row: Article) => {
+    const status = checked ? 0 : 1;
+    updateArticleStatus(row.id, status)
+      .then((msg) => {
+        row.status = status;
+        message.success(msg);
+      })
+      .catch((e) => {
+        message.error(e.message);
+      });
+  };
+
+  // 监听 id 变化
+  watch(
+    () => props.cateId,
+    () => {
+      reload();
+    }
+  );
+</script>
+
+<style lang="less" scoped>
+  .content-article-table :deep(.ant-table-body) {
+    overflow: auto !important;
+    overflow: overlay !important;
+  }
+
+  .content-article-table :deep(.ant-table-pagination.ant-pagination) {
+    padding: 0 4px;
+    margin-bottom: 0;
+  }
+</style>
diff --git a/src/views/content/category/components/cate-article-search.vue b/src/views/content/category/components/cate-article-search.vue
new file mode 100644
index 0000000..995a92b
--- /dev/null
+++ b/src/views/content/category/components/cate-article-search.vue
@@ -0,0 +1,82 @@
+<!-- 搜索表单 -->
+<template>
+  <a-row :gutter="16">
+    <a-col
+      v-bind="
+        styleResponsive ? { xl: 6, lg: 8, md: 12, sm: 24, xs: 24 } : { span: 6 }
+      "
+    >
+      <a-input
+        v-model:value.trim="form.title"
+        placeholder="请输入标题"
+        allow-clear
+      />
+    </a-col>
+    <a-col
+      v-bind="
+        styleResponsive ? { xl: 6, lg: 8, md: 12, sm: 24, xs: 24 } : { span: 6 }
+      "
+    >
+      <a-input
+        v-model:value.trim="form.user"
+        placeholder="请输入发布者"
+        allow-clear
+      />
+    </a-col>
+    <a-col
+      v-bind="
+        styleResponsive
+          ? { xl: 12, lg: 8, md: 24, sm: 24, xs: 24 }
+          : { span: 12 }
+      "
+    >
+      <a-space :size="10" style="flex-wrap: wrap">
+        <a-button type="primary" class="ele-btn-icon" @click="search">
+          <template #icon>
+            <search-outlined />
+          </template>
+          <span>查询</span>
+        </a-button>
+        <a-button type="primary" class="ele-btn-icon" @click="add">
+          <template #icon>
+            <plus-outlined />
+          </template>
+          <span>新建</span>
+        </a-button>
+      </a-space>
+    </a-col>
+  </a-row>
+</template>
+
+<script lang="ts" setup>
+  import { PlusOutlined, SearchOutlined } from '@ant-design/icons-vue';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import { ArticleParam } from '@/api/content/article/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'search', where?: ArticleParam): void;
+    (e: 'add'): void;
+  }>();
+
+  // 表单数据
+  const { form } = useFormData<ArticleParam>({
+    title: '',
+    user: ''
+  });
+
+  /* 搜索 */
+  const search = () => {
+    emit('search', form);
+  };
+
+  /*  添加 */
+  const add = () => {
+    emit('add');
+  };
+</script>
diff --git a/src/views/content/category/components/cate-edit.vue b/src/views/content/category/components/cate-edit.vue
new file mode 100644
index 0000000..145203a
--- /dev/null
+++ b/src/views/content/category/components/cate-edit.vue
@@ -0,0 +1,328 @@
+<!-- 编辑弹窗 -->
+<template>
+  <ele-modal
+    :width="860"
+    :visible="visible"
+    :confirm-loading="loading"
+    :title="isUpdate ? '修改栏目' : '添加栏目'"
+    :body-style="{ paddingBottom: '8px' }"
+    @update:visible="updateVisible"
+    @ok="save"
+  >
+    <a-form
+      ref="formRef"
+      :model="form"
+      :rules="rules"
+      :label-col="styleResponsive ? { md: 7, sm: 24 } : { flex: '90px' }"
+      :wrapper-col="styleResponsive ? { md: 17, sm: 24 } : { flex: '1' }"
+    >
+      <a-row :gutter="16">
+        <a-col
+          v-bind="styleResponsive ? { md: 12, sm: 24, xs: 24 } : { span: 12 }"
+        >
+          <a-form-item label="上级栏目" name="parentId">
+            <cate-select
+              :data="cateList"
+              placeholder="请选择上级栏目"
+              v-model:value="form.parentId"
+            />
+          </a-form-item>
+          <a-form-item label="栏目名称" name="cateName">
+            <a-input
+              allow-clear
+              :maxlength="20"
+              placeholder="请输入栏目名称"
+              v-model:value="form.cateName"
+            />
+          </a-form-item>
+          <a-form-item label="主题颜色">
+            <ele-color-picker
+              :show-alpha="true"
+              v-model:value="form.color"
+              :predefine="predefineColors"
+            />
+          </a-form-item>
+          <a-form-item label="栏目图片">
+            <ele-image-upload
+              v-model:value="images"
+              :limit="1"
+              :drag="true"
+              :upload-handler="uploadHandler"
+              :remove-handler="removeHandler"
+              @upload="onUpload"
+            />
+          </a-form-item>
+        </a-col>
+        <a-col
+          v-bind="styleResponsive ? { md: 12, sm: 24, xs: 24 } : { span: 12 }"
+        >
+          <a-form-item label="栏目类型" name="menuType">
+            <a-radio-group
+              v-model:value="form.menuType"
+              @change="onMenuTypeChange"
+            >
+              <a-radio :value="0">目录</a-radio>
+              <a-radio :value="1">菜单</a-radio>
+              <a-radio :value="2">外链</a-radio>
+            </a-radio-group>
+          </a-form-item>
+          <a-form-item label="外链地址" name="url" v-if="form.menuType === 2">
+            <a-input
+              allow-clear
+              placeholder="请输入外链地址"
+              v-model:value="form.url"
+            />
+          </a-form-item>
+          <a-form-item
+            label="栏目模板"
+            name="template"
+            v-if="form.menuType !== 2"
+          >
+            <a-input
+              allow-clear
+              placeholder="请输入栏目模板"
+              :disabled="form.menuType === 0"
+              v-model:value="form.template"
+            />
+          </a-form-item>
+          <a-form-item label="排序号" name="sortNumber">
+            <a-input-number
+              :min="0"
+              :max="99999"
+              class="ele-fluid"
+              placeholder="请输入排序号"
+              v-model:value="form.sortNumber"
+            />
+          </a-form-item>
+          <a-form-item label="描述">
+            <a-textarea
+              :rows="4"
+              :maxlength="200"
+              placeholder="请输入描述"
+              v-model:value="form.introduction"
+            />
+          </a-form-item>
+        </a-col>
+      </a-row>
+    </a-form>
+  </ele-modal>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive, watch } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import CateSelect from './cate-select.vue';
+  import type { ItemType } from 'ele-admin-pro/es/ele-image-upload/types';
+  import type { Category } from '@/api/content/category/model';
+  import { addCategory, updateCategory } from '@/api/content/category';
+  import request from '@/utils/request';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'done'): void;
+    (e: 'update:visible', visible: boolean): void;
+  }>();
+
+  const props = defineProps<{
+    // 弹窗是否打开
+    visible: boolean;
+    // 修改回显的数据
+    data?: Category | null;
+    // 栏目id
+    cateId?: number;
+    // 全部栏目
+    cateList: Category[];
+  }>();
+
+  //
+  const formRef = ref<FormInstance | null>(null);
+  const images = ref<ItemType[]>([]);
+  // 是否是修改
+  const isUpdate = ref(false);
+
+  // 提交状态
+  const loading = ref(false);
+
+  // 表单数据
+  const { form, resetFields, assignFields } = useFormData<Category>({
+    cateId: undefined,
+    parentId: undefined,
+    cateName: '',
+    menuType: 0,
+    url: '',
+    template: '',
+    color: 'rgba(255, 69, 0, 0.68)',
+    image: '',
+    sortNumber: undefined,
+    introduction: ''
+  });
+  // 预设颜色
+  const predefineColors = ref([
+    '#ff4500',
+    '#ff8c00',
+    '#ffd700',
+    '#90ee90',
+    '#00ced1',
+    '#1e90ff',
+    '#c71585',
+    'rgba(255, 69, 0, 0.68)',
+    'rgb(255, 120, 0)',
+    'hsv(51, 100, 98)',
+    'hsva(120, 40, 94, 0.5)',
+    'hsl(181, 100%, 37%)',
+    'hsla(209, 100%, 56%, 0.73)',
+    '#c7158577'
+  ]);
+  // 表单验证规则
+  const rules = reactive<Record<string, Rule[]>>({
+    cateName: [
+      {
+        required: true,
+        message: '请输入栏目名称',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    sortNumber: [
+      {
+        required: true,
+        message: '请输入排序号',
+        type: 'number',
+        trigger: 'blur'
+      }
+    ]
+  });
+  /* menuType选择改变 */
+  const onMenuTypeChange = () => {
+    if (form.menuType === 0) {
+      form.url = '';
+      form.template = '';
+    } else if (form.menuType === 1) {
+      form.url = '';
+    } else {
+      form.template = '';
+    }
+  };
+  /* 上传事件 */
+  const uploadHandler = (file: File) => {
+    const item: ItemType = {
+      file,
+      uid: (file as any).uid,
+      name: file.name,
+      progress: 0,
+      status: undefined
+    };
+    if (!file.type.startsWith('image')) {
+      message.error('只能选择图片');
+      return;
+    }
+    if (file.size / 1024 / 1024 > 2) {
+      message.error('大小不能超过 2MB');
+      return;
+    }
+    item.url = window.URL.createObjectURL(file);
+    images.value.push(item);
+    onUpload(item);
+  };
+
+  const removeHandler = (item) => {
+    images.value = images.value.filter((d) => d !== item);
+  };
+
+  /* 上传 item */
+  const onUpload = (d: ItemType) => {
+    const item: any = images.value.find((t) => t.uid === d.uid) ?? d;
+    // 上传
+    item.status = 'uploading';
+    const formData = new FormData();
+    formData.append('file', item.file);
+    request({
+      url: '/file/upload',
+      method: 'post',
+      data: formData,
+      onUploadProgress: (e: any) => {
+        // 文件上传进度回调
+        if (e.lengthComputable) {
+          item.progress = (e.loaded / e.total) * 100;
+        }
+      }
+    })
+      .then((res) => {
+        if (res.data.code === 0) {
+          item.status = 'done';
+          item.url = res.data.data;
+        }
+      })
+      .catch((e) => {
+        message.error(e);
+        item.status = 'exception';
+      });
+  };
+
+  /* 保存编辑 */
+  const save = () => {
+    if (!formRef.value) {
+      return;
+    }
+    formRef.value
+      .validate()
+      .then(() => {
+        loading.value = true;
+        const cateData = {
+          ...form,
+          parentId: form.parentId || 0,
+          image: images.value.length !== 0 ? images.value[0].url : undefined
+        };
+        const saveOrUpdate = isUpdate.value ? updateCategory : addCategory;
+        saveOrUpdate(cateData)
+          .then((msg) => {
+            loading.value = false;
+            message.success(msg);
+            updateVisible(false);
+            emit('done');
+          })
+          .catch((e) => {
+            loading.value = false;
+            message.error(e.message);
+          });
+      })
+      .catch(() => {});
+  };
+
+  /* 更新visible */
+  const updateVisible = (value: boolean) => {
+    emit('update:visible', value);
+  };
+
+  watch(
+    () => props.visible,
+    (visible) => {
+      if (visible) {
+        if (props.data) {
+          if (props.data.image != undefined) {
+            images.value.push({
+              url: props.data.image,
+              uid: ''
+            });
+          }
+          assignFields(props.data);
+          isUpdate.value = true;
+        } else {
+          form.parentId = props.cateId;
+          isUpdate.value = false;
+        }
+      } else {
+        resetFields();
+        images.value = [];
+        formRef.value?.clearValidate();
+      }
+    }
+  );
+</script>
diff --git a/src/views/content/category/components/cate-outlink-edit.vue b/src/views/content/category/components/cate-outlink-edit.vue
new file mode 100644
index 0000000..b2abaef
--- /dev/null
+++ b/src/views/content/category/components/cate-outlink-edit.vue
@@ -0,0 +1,296 @@
+<template>
+  <a-card title="修改配置">
+    <a-form
+      ref="formRef"
+      :model="form"
+      :rules="rules"
+      :label-col="styleResponsive ? { md: 7, sm: 24 } : { flex: '90px' }"
+      :wrapper-col="styleResponsive ? { md: 17, sm: 24 } : { flex: '1' }"
+    >
+      <a-row :gutter="16">
+        <a-col
+          v-bind="styleResponsive ? { md: 12, sm: 24, xs: 24 } : { span: 12 }"
+        >
+          <a-form-item label="上级栏目" name="parentId">
+            <cate-select
+              :data="cateList"
+              placeholder="请选择上级栏目"
+              v-model:value="form.parentId"
+            />
+          </a-form-item>
+          <a-form-item label="栏目名称" name="cateName">
+            <a-input
+              allow-clear
+              :maxlength="20"
+              placeholder="请输入栏目名称"
+              v-model:value="form.cateName"
+            />
+          </a-form-item>
+          <a-form-item label="主题颜色">
+            <ele-color-picker
+              :show-alpha="true"
+              v-model:value="form.color"
+              :predefine="predefineColors"
+            />
+          </a-form-item>
+          <a-form-item label="栏目图片">
+            <ele-image-upload
+              v-model:value="images"
+              :limit="1"
+              :drag="true"
+              :upload-handler="uploadHandler"
+              :remove-handler="removeHandler"
+              @upload="onUpload"
+            />
+          </a-form-item>
+        </a-col>
+        <a-col
+          v-bind="styleResponsive ? { md: 12, sm: 24, xs: 24 } : { span: 12 }"
+        >
+          <a-form-item label="栏目类型" name="menuType">
+            <a-radio-group
+              v-model:value="form.menuType"
+              @change="onMenuTypeChange"
+            >
+              <a-radio :value="0">目录</a-radio>
+              <a-radio :value="1">菜单</a-radio>
+              <a-radio :value="2">外链</a-radio>
+            </a-radio-group>
+          </a-form-item>
+          <a-form-item label="外链地址" name="url" v-if="form.menuType === 2">
+            <a-input
+              allow-clear
+              placeholder="请输入外链地址"
+              v-model:value="form.url"
+            />
+          </a-form-item>
+          <a-form-item
+            label="栏目模板"
+            name="template"
+            v-if="form.menuType !== 2"
+          >
+            <a-input
+              allow-clear
+              placeholder="请输入栏目模板"
+              :disabled="form.menuType === 0"
+              v-model:value="form.template"
+            />
+          </a-form-item>
+          <a-form-item label="排序号" name="sortNumber">
+            <a-input-number
+              :min="0"
+              :max="99999"
+              class="ele-fluid"
+              placeholder="请输入排序号"
+              v-model:value="form.sortNumber"
+            />
+          </a-form-item>
+          <a-form-item label="描述">
+            <a-textarea
+              :rows="4"
+              :maxlength="200"
+              placeholder="请输入描述"
+              v-model:value="form.introduction"
+            />
+          </a-form-item>
+        </a-col>
+      </a-row>
+      <a-row justify="end">
+        <a-col :span="3">
+          <a-form-item :wrapper-col="{ flex: 1 }" justify="end">
+            <a-button type="primary" @click="save">提交</a-button>
+          </a-form-item>
+        </a-col>
+      </a-row>
+    </a-form>
+  </a-card>
+</template>
+<script lang="ts" setup>
+  import { Category } from '@/api/content/category/model';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import { message } from 'ant-design-vue/es';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import { ItemType } from 'ele-admin-pro/es/ele-image-upload/types';
+  import { storeToRefs } from 'pinia';
+  import CateSelect from './cate-select.vue';
+  import { reactive, ref, watch } from 'vue';
+  import request from '@/utils/request';
+  import { getCategory, updateCategory } from '@/api/content/category';
+
+  const props = defineProps<{
+    // 栏目 id
+    cateId?: number;
+    // 全部栏目
+    cateList: Category[];
+  }>();
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+  const formRef = ref<FormInstance | null>(null);
+  const images = ref<ItemType[]>([]);
+  // 提交状态
+  const loading = ref(false);
+  // 表单数据
+  const { form } = useFormData<Category>({
+    cateId: undefined,
+    parentId: undefined,
+    cateName: '',
+    menuType: 0,
+    url: '',
+    template: '',
+    color: 'rgba(255, 69, 0, 0.68)',
+    image: '',
+    sortNumber: undefined,
+    introduction: ''
+  });
+  // 预设颜色
+  const predefineColors = ref([
+    '#ff4500',
+    '#ff8c00',
+    '#ffd700',
+    '#90ee90',
+    '#00ced1',
+    '#1e90ff',
+    '#c71585',
+    'rgba(255, 69, 0, 0.68)',
+    'rgb(255, 120, 0)',
+    'hsv(51, 100, 98)',
+    'hsva(120, 40, 94, 0.5)',
+    'hsl(181, 100%, 37%)',
+    'hsla(209, 100%, 56%, 0.73)',
+    '#c7158577'
+  ]);
+  // 表单验证规则
+  const rules = reactive<Record<string, Rule[]>>({
+    cateName: [
+      {
+        required: true,
+        message: '请输入栏目名称',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    sortNumber: [
+      {
+        required: true,
+        message: '请输入排序号',
+        type: 'number',
+        trigger: 'blur'
+      }
+    ]
+  });
+  /* menuType选择改变 */
+  const onMenuTypeChange = () => {
+    if (form.menuType === 0) {
+      form.url = '';
+      form.template = '';
+    } else if (form.menuType === 1) {
+      form.url = '';
+    } else {
+      form.template = '';
+    }
+  };
+  /* 上传事件 */
+  const uploadHandler = (file: File) => {
+    const item: ItemType = {
+      file,
+      uid: (file as any).uid,
+      name: file.name,
+      progress: 0,
+      status: undefined
+    };
+    if (!file.type.startsWith('image')) {
+      message.error('只能选择图片');
+      return;
+    }
+    if (file.size / 1024 / 1024 > 2) {
+      message.error('大小不能超过 2MB');
+      return;
+    }
+    item.url = window.URL.createObjectURL(file);
+    images.value.push(item);
+    onUpload(item);
+  };
+
+  const removeHandler = (item) => {
+    images.value = images.value.filter((d) => d !== item);
+  };
+
+  /* 上传 item */
+  const onUpload = (d: ItemType) => {
+    const item: any = images.value.find((t) => t.uid === d.uid) ?? d;
+    // 上传
+    item.status = 'uploading';
+    const formData = new FormData();
+    formData.append('file', item.file);
+    request({
+      url: '/file/upload',
+      method: 'post',
+      data: formData,
+      onUploadProgress: (e: any) => {
+        // 文件上传进度回调
+        if (e.lengthComputable) {
+          item.progress = (e.loaded / e.total) * 100;
+        }
+      }
+    })
+      .then((res) => {
+        if (res.data.code === 0) {
+          item.status = 'done';
+          item.url = res.data.data;
+        }
+      })
+      .catch((e) => {
+        message.error(e);
+        item.status = 'exception';
+      });
+  };
+  /* 保存编辑 */
+  const save = () => {
+    if (!formRef.value) {
+      return;
+    }
+    formRef.value
+      .validate()
+      .then(() => {
+        loading.value = true;
+        const cateData = {
+          ...form,
+          parentId: form.parentId || 0,
+          image: images.value.length !== 0 ? images.value[0].url : undefined
+        };
+        updateCategory(cateData)
+          .then((msg) => {
+            loading.value = false;
+            message.success(msg);
+          })
+          .catch((e) => {
+            loading.value = false;
+            message.error(e.message);
+          });
+      })
+      .catch(() => {});
+  };
+  const getCateDetail = (cateId) => {
+    images.value = [];
+    getCategory(cateId).then((res: any) => {
+      Object.assign(form, res);
+      if (res.image && res.image != undefined) {
+        images.value.push({
+          url: res.image,
+          uid: ''
+        });
+      }
+    });
+  };
+  getCateDetail(props.cateId);
+  watch(
+    () => props.cateId,
+    (value) => {
+      if (value) {
+        getCateDetail(value);
+      }
+    }
+  );
+</script>
+<style lang="scss" scoped></style>
diff --git a/src/views/content/category/components/cate-select.vue b/src/views/content/category/components/cate-select.vue
new file mode 100644
index 0000000..c6d7dc0
--- /dev/null
+++ b/src/views/content/category/components/cate-select.vue
@@ -0,0 +1,36 @@
+<!-- 选择下拉框 -->
+<template>
+  <a-tree-select
+    allow-clear
+    tree-default-expand-all
+    :placeholder="placeholder"
+    :value="value || undefined"
+    :tree-data="data"
+    :dropdown-style="{ maxHeight: '360px', overflow: 'auto' }"
+    @update:value="updateValue"
+  />
+</template>
+
+<script lang="ts" setup>
+  import { Category } from '@/api/content/category/model';
+
+  const emit = defineEmits<{
+    (e: 'update:value', value?: number): void;
+  }>();
+
+  withDefaults(
+    defineProps<{
+      value?: number;
+      placeholder?: string;
+      data: Category[];
+    }>(),
+    {
+      placeholder: '请选择分类'
+    }
+  );
+
+  /* 更新选中数据 */
+  const updateValue = (value?: number) => {
+    emit('update:value', value);
+  };
+</script>
diff --git a/src/views/content/category/index.vue b/src/views/content/category/index.vue
new file mode 100644
index 0000000..eb80a79
--- /dev/null
+++ b/src/views/content/category/index.vue
@@ -0,0 +1,209 @@
+<template>
+  <div class="ele-body">
+    <a-card :bordered="false" :body-style="{ padding: '16px' }">
+      <ele-split-layout
+        width="266px"
+        allow-collapse
+        :right-style="{ overflow: 'hidden' }"
+        :style="{ minHeight: 'calc(100vh - 152px)' }"
+      >
+        <div>
+          <ele-toolbar theme="default">
+            <a-space :size="10">
+              <a-button type="primary" class="ele-btn-icon" @click="openEdit()">
+                <template #icon>
+                  <plus-outlined />
+                </template>
+                <span>新建</span>
+              </a-button>
+              <a-button
+                type="primary"
+                :disabled="!current"
+                class="ele-btn-icon"
+                @click="openEdit(current)"
+              >
+                <template #icon>
+                  <edit-outlined />
+                </template>
+                <span>修改</span>
+              </a-button>
+              <a-button
+                danger
+                type="primary"
+                :disabled="!current"
+                class="ele-btn-icon"
+                @click="remove"
+              >
+                <template #icon>
+                  <delete-outlined />
+                </template>
+                <span>删除</span>
+              </a-button>
+            </a-space>
+          </ele-toolbar>
+          <div class="ele-border-split content-category-list">
+            <a-tree
+              :tree-data="(data as any)"
+              v-model:expanded-keys="expandedRowKeys"
+              v-model:selected-keys="selectedRowKeys"
+              @select="onTreeSelect"
+            />
+          </div>
+        </div>
+        <template #content>
+          <cate-outlink-edit
+            v-if="current && current.menuType === 2"
+            :cate-list="data"
+            :cate-id="current.cateId"
+          />
+          <cate-article-list
+            v-if="current && current.menuType === 1"
+            :cate-list="data"
+            :cate-id="current.cateId"
+          />
+        </template>
+      </ele-split-layout>
+    </a-card>
+    <!-- 编辑弹窗 -->
+    <cate-edit
+      v-model:visible="showEdit"
+      :data="editData"
+      :cate-list="data"
+      :cate-id="current?.cateId"
+      @done="query"
+    />
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { createVNode, ref } from 'vue';
+  import { message, Modal } from 'ant-design-vue/es';
+  import {
+    PlusOutlined,
+    EditOutlined,
+    DeleteOutlined,
+    ExclamationCircleOutlined
+  } from '@ant-design/icons-vue';
+  import { messageLoading, toTreeData, eachTreeData } from 'ele-admin-pro/es';
+  import CateArticleList from './components/cate-article-list.vue';
+  import cateOutlinkEdit from './components/cate-outlink-edit.vue';
+  import CateEdit from './components/cate-edit.vue';
+  import { listCategories, removeCategory } from '@/api/content/category';
+  import type { Category } from '@/api/content/category/model';
+
+  // 加载状态
+  const loading = ref(true);
+
+  // 树形数据
+  const data = ref<Category[]>([]);
+
+  // 树展开的key
+  const expandedRowKeys = ref<number[]>([]);
+
+  // 树选中的key
+  const selectedRowKeys = ref<number[]>([]);
+
+  // 选中数据
+  const current = ref<Category | null>(null);
+
+  // 是否显示表单弹窗
+  const showEdit = ref(false);
+
+  // 编辑回显数据
+  const editData = ref<Category | null>(null);
+
+  /* 查询 */
+  const query = () => {
+    loading.value = true;
+    listCategories()
+      .then((list) => {
+        loading.value = false;
+        const eks: number[] = [];
+        list.forEach((d) => {
+          d.key = d.cateId;
+          d.value = d.cateId;
+          d.title = d.cateName;
+          d.disabled = Boolean(d.disabled);
+          if (typeof d.key === 'number') {
+            eks.push(d.key);
+          }
+        });
+        expandedRowKeys.value = eks;
+        data.value = toTreeData({
+          data: list,
+          idField: 'cateId',
+          parentIdField: 'parentId'
+        });
+        if (list.length) {
+          if (typeof list[0].key === 'number') {
+            selectedRowKeys.value = [list[0].key];
+          }
+          current.value = list[0];
+        } else {
+          selectedRowKeys.value = [];
+          current.value = null;
+        }
+      })
+      .catch((e) => {
+        loading.value = false;
+        message.error(e.message);
+      });
+  };
+
+  /* 选择数据 */
+  const onTreeSelect = () => {
+    eachTreeData(data.value, (d) => {
+      if (typeof d.key === 'number' && selectedRowKeys.value.includes(d.key)) {
+        current.value = d;
+        return false;
+      }
+    });
+  };
+
+  /* 打开编辑弹窗 */
+  const openEdit = (item?: Category | null) => {
+    editData.value = item ?? null;
+    showEdit.value = true;
+  };
+
+  /* 删除 */
+  const remove = () => {
+    Modal.confirm({
+      title: '提示',
+      content: '确定要删除选中的栏目吗?',
+      icon: createVNode(ExclamationCircleOutlined),
+      maskClosable: true,
+      onOk: () => {
+        const hide = messageLoading('请求中..', 0);
+        removeCategory(current.value?.cateId)
+          .then((msg) => {
+            hide();
+            message.success(msg);
+            query();
+          })
+          .catch((e) => {
+            hide();
+            message.error(e.message);
+          });
+      }
+    });
+  };
+
+  query();
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'ArticleCategory'
+  };
+</script>
+
+<style lang="less" scoped>
+  .content-category-list {
+    padding: 12px 6px;
+    height: calc(100vh - 242px);
+    border-width: 1px;
+    border-style: solid;
+    overflow: auto;
+  }
+</style>
diff --git a/src/views/dashboard/analysis/components/hot-search.vue b/src/views/dashboard/analysis/components/hot-search.vue
new file mode 100644
index 0000000..c1a5531
--- /dev/null
+++ b/src/views/dashboard/analysis/components/hot-search.vue
@@ -0,0 +1,72 @@
+<template>
+  <a-card :bordered="false" title="热门搜索">
+    <v-chart
+      ref="hotSearchChartRef"
+      :option="hotSearchChartOption"
+      style="height: 330px"
+    />
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import { use } from 'echarts/core';
+  import type { EChartsCoreOption } from 'echarts/core';
+  import { CanvasRenderer } from 'echarts/renderers';
+  import { LineChart, BarChart } from 'echarts/charts';
+  import { GridComponent, TooltipComponent } from 'echarts/components';
+  import VChart from 'vue-echarts';
+  import 'echarts-wordcloud';
+  import { wordCloudColor } from 'ele-admin-pro/es';
+  import { getWordCloudList } from '@/api/dashboard/analysis';
+  import useEcharts from '@/utils/use-echarts';
+
+  use([CanvasRenderer, LineChart, BarChart, GridComponent, TooltipComponent]);
+
+  //
+  const hotSearchChartRef = ref<InstanceType<typeof VChart> | null>(null);
+
+  useEcharts([hotSearchChartRef]);
+
+  // 词云图表配置
+  const hotSearchChartOption: EChartsCoreOption = reactive({});
+
+  /* 获取词云数据 */
+  const getWordCloudData = () => {
+    getWordCloudList()
+      .then((data) => {
+        Object.assign(hotSearchChartOption, {
+          tooltip: {
+            show: true,
+            confine: true,
+            borderWidth: 1
+          },
+          series: [
+            {
+              type: 'wordCloud',
+              width: '100%',
+              height: '100%',
+              sizeRange: [12, 24],
+              gridSize: 6,
+              textStyle: {
+                color: wordCloudColor
+              },
+              emphasis: {
+                textStyle: {
+                  shadowBlur: 8,
+                  shadowColor: 'rgba(0, 0, 0, .15)'
+                }
+              },
+              data: data
+            }
+          ]
+        });
+      })
+      .catch((e) => {
+        message.error(e.message);
+      });
+  };
+
+  getWordCloudData();
+</script>
diff --git a/src/views/dashboard/analysis/components/sale-card.vue b/src/views/dashboard/analysis/components/sale-card.vue
new file mode 100644
index 0000000..45d6c62
--- /dev/null
+++ b/src/views/dashboard/analysis/components/sale-card.vue
@@ -0,0 +1,248 @@
+<template>
+  <a-card :bordered="false" :body-style="{ padding: 0 }">
+    <a-tabs
+      size="large"
+      v-model:activeKey="saleSearch.type"
+      class="monitor-card-tabs"
+      @change="onSaleTypeChange"
+    >
+      <a-tab-pane tab="销售额" key="saleroom" />
+      <a-tab-pane tab="访问量" key="visits" />
+      <template #rightExtra>
+        <a-space
+          size="middle"
+          :class="[
+            'analysis-tabs-extra',
+            { 'hidden-lg-and-down': styleResponsive }
+          ]"
+        >
+          <a-radio-group v-model:value="saleSearch.dateType">
+            <a-radio-button value="1">今天</a-radio-button>
+            <a-radio-button value="2">本周</a-radio-button>
+            <a-radio-button value="3">本月</a-radio-button>
+            <a-radio-button value="4">本年</a-radio-button>
+          </a-radio-group>
+          <div style="width: 300px">
+            <a-range-picker
+              value-format="YYYY-MM-DD"
+              v-model:value="saleSearch.datetime"
+            />
+          </div>
+        </a-space>
+      </template>
+    </a-tabs>
+    <div style="padding-bottom: 10px">
+      <a-row :gutter="16">
+        <a-col
+          v-bind="
+            styleResponsive ? { lg: 17, md: 15, sm: 24, xs: 24 } : { span: 17 }
+          "
+        >
+          <div v-if="saleSearch.type === 'saleroom'" class="demo-monitor-title">
+            销售量趋势
+          </div>
+          <div v-else class="demo-monitor-title">访问量趋势</div>
+          <v-chart
+            ref="saleChartRef"
+            :option="saleChartOption"
+            style="height: 320px"
+          />
+        </a-col>
+        <a-col
+          v-bind="
+            styleResponsive ? { lg: 7, md: 9, sm: 24, xs: 24 } : { span: 7 }
+          "
+        >
+          <div v-if="saleSearch.type === 'saleroom'">
+            <div class="demo-monitor-title">门店销售额排名</div>
+            <div
+              v-for="(item, index) in saleroomRankData"
+              :key="index"
+              class="demo-monitor-rank-item ele-cell"
+            >
+              <ele-tag
+                shape="circle"
+                :color="index < 3 ? '#314659' : ''"
+                style="border: none"
+              >
+                {{ index + 1 }}
+              </ele-tag>
+              <div class="ele-cell-content ele-elip">{{ item.name }}</div>
+              <div class="ele-text-secondary">{{ item.value }}</div>
+            </div>
+          </div>
+          <div v-else>
+            <div class="demo-monitor-title">门店访问量排名</div>
+            <div
+              v-for="(item, index) in visitsRankData"
+              :key="index"
+              class="demo-monitor-rank-item ele-cell"
+            >
+              <ele-tag
+                shape="circle"
+                :color="index < 3 ? '#314659' : ''"
+                style="border: none"
+              >
+                {{ index + 1 }}
+              </ele-tag>
+              <div class="ele-cell-content ele-elip">{{ item.name }}</div>
+              <div class="ele-text-secondary">{{ item.value }}</div>
+            </div>
+          </div>
+        </a-col>
+      </a-row>
+    </div>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import { use } from 'echarts/core';
+  import type { EChartsCoreOption } from 'echarts/core';
+  import { CanvasRenderer } from 'echarts/renderers';
+  import { BarChart } from 'echarts/charts';
+  import { GridComponent, TooltipComponent } from 'echarts/components';
+  import VChart from 'vue-echarts';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import { getSaleroomList } from '@/api/dashboard/analysis';
+  import type { SaleroomData } from '@/api/dashboard/analysis/model';
+  import useEcharts from '@/utils/use-echarts';
+
+  use([CanvasRenderer, BarChart, GridComponent, TooltipComponent]);
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  //
+  const saleChartRef = ref<InstanceType<typeof VChart> | null>(null);
+
+  useEcharts([saleChartRef]);
+
+  // 销售额柱状图配置
+  const saleChartOption: EChartsCoreOption = reactive({});
+
+  // 门店销售排名数据
+  const saleroomRankData = ref([
+    { name: '工专路 1 号店', value: '323,234' },
+    { name: '工专路 2 号店', value: '323,234' },
+    { name: '工专路 3 号店', value: '323,234' },
+    { name: '工专路 4 号店', value: '323,234' },
+    { name: '工专路 5 号店', value: '323,234' },
+    { name: '工专路 6 号店', value: '323,234' },
+    { name: '工专路 7 号店', value: '323,234' }
+  ]);
+
+  // 门店访问排名数据
+  const visitsRankData = ref([
+    { name: '工专路 1 号店', value: '323,234' },
+    { name: '工专路 2 号店', value: '323,234' },
+    { name: '工专路 3 号店', value: '323,234' },
+    { name: '工专路 4 号店', value: '323,234' },
+    { name: '工专路 5 号店', value: '323,234' },
+    { name: '工专路 6 号店', value: '323,234' },
+    { name: '工专路 7 号店', value: '323,234' }
+  ]);
+
+  // 销售量趋势数据
+  const saleroomData1 = ref<SaleroomData[]>([]);
+
+  // 访问量趋势数据
+  const saleroomData2 = ref<SaleroomData[]>([]);
+
+  interface SaleSearchType {
+    type: string;
+    dateType: string;
+    datetime: [string, string];
+  }
+
+  // 销售量搜索参数
+  const saleSearch = reactive<SaleSearchType>({
+    type: 'saleroom',
+    dateType: '1',
+    datetime: ['2022-01-08', '2022-02-12']
+  });
+
+  /* 获取销售量数据 */
+  const getSaleroomData = () => {
+    getSaleroomList()
+      .then((data) => {
+        saleroomData1.value = data.list1;
+        saleroomData2.value = data.list2;
+        onSaleTypeChange();
+      })
+      .catch((e) => {
+        message.error(e.message);
+      });
+  };
+
+  /* 销售量tab选择改变事件 */
+  const onSaleTypeChange = () => {
+    if (saleSearch.type === 'saleroom') {
+      Object.assign(saleChartOption, {
+        tooltip: {
+          trigger: 'axis'
+        },
+        xAxis: [
+          {
+            type: 'category',
+            data: saleroomData1.value.map((d) => d.month)
+          }
+        ],
+        yAxis: [
+          {
+            type: 'value'
+          }
+        ],
+        series: [
+          {
+            type: 'bar',
+            data: saleroomData1.value.map((d) => d.value)
+          }
+        ]
+      });
+    } else {
+      Object.assign(saleChartOption, {
+        tooltip: {
+          trigger: 'axis'
+        },
+        xAxis: [
+          {
+            type: 'category',
+            data: saleroomData2.value.map((d) => d.month)
+          }
+        ],
+        yAxis: [
+          {
+            type: 'value'
+          }
+        ],
+        series: [
+          {
+            type: 'bar',
+            data: saleroomData2.value.map((d) => d.value)
+          }
+        ]
+      });
+    }
+  };
+
+  getSaleroomData();
+</script>
+
+<style lang="less" scoped>
+  .monitor-card-tabs :deep(.ant-tabs-nav) {
+    padding: 0 16px;
+  }
+
+  .demo-monitor-title {
+    padding: 6px 20px;
+  }
+
+  .demo-monitor-rank-item {
+    padding: 0 20px;
+    margin-top: 18px;
+  }
+</style>
diff --git a/src/views/dashboard/analysis/components/statistics-card.vue b/src/views/dashboard/analysis/components/statistics-card.vue
new file mode 100644
index 0000000..f4fefd5
--- /dev/null
+++ b/src/views/dashboard/analysis/components/statistics-card.vue
@@ -0,0 +1,246 @@
+<!-- 统计卡片 -->
+<template>
+  <a-row :gutter="16">
+    <a-col
+      v-bind="styleResponsive ? { lg: 6, md: 12, sm: 24, xs: 24 } : { span: 6 }"
+    >
+      <a-card class="analysis-chart-card" :bordered="false">
+        <div class="ele-text-secondary ele-cell">
+          <div class="ele-cell-content">总销售额</div>
+          <a-tooltip title="指标说明">
+            <question-circle-outlined />
+          </a-tooltip>
+        </div>
+        <h1 class="analysis-chart-card-num">¥ 126,560</h1>
+        <div class="analysis-chart-card-content" style="padding-top: 16px">
+          <a-space size="middle">
+            <span class="analysis-trend-text">
+              周同比12% <caret-up-outlined class="ele-text-danger" />
+            </span>
+            <span class="analysis-trend-text">
+              日同比11% <caret-down-outlined class="ele-text-success" />
+            </span>
+          </a-space>
+        </div>
+        <a-divider />
+        <div>日销售额 ¥12,423</div>
+      </a-card>
+    </a-col>
+    <a-col
+      v-bind="styleResponsive ? { lg: 6, md: 12, sm: 24, xs: 24 } : { span: 6 }"
+    >
+      <a-card class="analysis-chart-card" :bordered="false">
+        <div class="ele-text-secondary ele-cell">
+          <div class="ele-cell-content">访问量</div>
+          <ele-tag color="red">日</ele-tag>
+        </div>
+        <h1 class="analysis-chart-card-num">8,846</h1>
+        <v-chart
+          ref="visitChartRef"
+          :option="visitChartOption"
+          style="height: 40px"
+        />
+        <a-divider />
+        <div>日访问量 1,234</div>
+      </a-card>
+    </a-col>
+    <a-col
+      v-bind="styleResponsive ? { lg: 6, md: 12, sm: 24, xs: 24 } : { span: 6 }"
+    >
+      <a-card class="analysis-chart-card" :bordered="false">
+        <div class="ele-text-secondary ele-cell">
+          <div class="ele-cell-content">支付笔数</div>
+          <ele-tag color="blue">月</ele-tag>
+        </div>
+        <h1 class="analysis-chart-card-num">6,560</h1>
+        <v-chart
+          ref="payNumChartRef"
+          :option="payNumChartOption"
+          style="height: 40px"
+        />
+        <a-divider />
+        <div>转化率 60%</div>
+      </a-card>
+    </a-col>
+    <a-col
+      v-bind="styleResponsive ? { lg: 6, md: 12, sm: 24, xs: 24 } : { span: 6 }"
+    >
+      <a-card class="analysis-chart-card" :bordered="false">
+        <div class="ele-text-secondary ele-cell">
+          <div class="ele-cell-content">活动运营效果</div>
+          <ele-tag color="green">周</ele-tag>
+        </div>
+        <h1 class="analysis-chart-card-num">78%</h1>
+        <div class="analysis-chart-card-content" style="padding-top: 16px">
+          <a-progress
+            :percent="78"
+            :show-info="false"
+            stroke-color="#13c2c2"
+            status="active"
+          />
+        </div>
+        <a-divider />
+        <a-space size="middle">
+          <span class="analysis-trend-text">
+            周同比12% <caret-up-outlined class="ele-text-danger" />
+          </span>
+          <span class="analysis-trend-text">
+            日同比11% <caret-down-outlined class="ele-text-success" />
+          </span>
+        </a-space>
+      </a-card>
+    </a-col>
+  </a-row>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import {
+    QuestionCircleOutlined,
+    CaretUpOutlined,
+    CaretDownOutlined
+  } from '@ant-design/icons-vue';
+  import { use } from 'echarts/core';
+  import type { EChartsCoreOption } from 'echarts/core';
+  import { CanvasRenderer } from 'echarts/renderers';
+  import { LineChart, BarChart } from 'echarts/charts';
+  import { GridComponent, TooltipComponent } from 'echarts/components';
+  import VChart from 'vue-echarts';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import { getPayNumList } from '@/api/dashboard/analysis';
+  import useEcharts from '@/utils/use-echarts';
+
+  use([CanvasRenderer, LineChart, BarChart, GridComponent, TooltipComponent]);
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  //
+  const visitChartRef = ref<InstanceType<typeof VChart> | null>(null);
+  const payNumChartRef = ref<InstanceType<typeof VChart> | null>(null);
+
+  useEcharts([visitChartRef, payNumChartRef]);
+
+  // 访问量折线图配置
+  const visitChartOption: EChartsCoreOption = reactive({});
+
+  // 支付笔数柱状图配置
+  const payNumChartOption: EChartsCoreOption = reactive({});
+
+  /* 获取支付笔数数据 */
+  const getPayNumData = () => {
+    getPayNumList()
+      .then((data) => {
+        Object.assign(visitChartOption, {
+          color: '#975fe5',
+          tooltip: {
+            trigger: 'axis',
+            formatter:
+              '<i class="ele-chart-dot" style="background: #975fe5;"></i>{b0}: {c0}'
+          },
+          grid: {
+            top: 10,
+            bottom: 0,
+            left: 0,
+            right: 0
+          },
+          xAxis: [
+            {
+              show: false,
+              type: 'category',
+              boundaryGap: false,
+              data: data.map((d) => d.date)
+            }
+          ],
+          yAxis: [
+            {
+              show: false,
+              type: 'value',
+              splitLine: {
+                show: false
+              }
+            }
+          ],
+          series: [
+            {
+              type: 'line',
+              smooth: true,
+              symbol: 'none',
+              areaStyle: {
+                opacity: 0.5
+              },
+              data: data.map((d) => d.value)
+            }
+          ]
+        });
+
+        Object.assign(payNumChartOption, {
+          tooltip: {
+            trigger: 'axis',
+            formatter:
+              '<i class="ele-chart-dot" style="background: #5b8ff9;"></i>{b0}: {c0}'
+          },
+          grid: {
+            top: 10,
+            bottom: 0,
+            left: 0,
+            right: 0
+          },
+          xAxis: [
+            {
+              show: false,
+              type: 'category',
+              data: data.map((d) => d.date)
+            }
+          ],
+          yAxis: [
+            {
+              show: false,
+              type: 'value',
+              splitLine: {
+                show: false
+              }
+            }
+          ],
+          series: [
+            {
+              type: 'bar',
+              data: data.map((d) => d.value)
+            }
+          ]
+        });
+      })
+      .catch((e) => {
+        message.error(e.message);
+      });
+  };
+
+  getPayNumData();
+</script>
+
+<style lang="less" scoped>
+  .analysis-chart-card {
+    :deep(.ant-card-body) {
+      padding: 16px 22px 12px 22px;
+    }
+
+    :deep(.ant-divider) {
+      margin: 12px 0;
+    }
+  }
+
+  .analysis-chart-card-num {
+    font-size: 30px;
+  }
+
+  .analysis-chart-card-content {
+    height: 40px;
+  }
+
+  .analysis-trend-text {
+    white-space: nowrap;
+  }
+</style>
diff --git a/src/views/dashboard/analysis/components/visit-hour.vue b/src/views/dashboard/analysis/components/visit-hour.vue
new file mode 100644
index 0000000..ca371d3
--- /dev/null
+++ b/src/views/dashboard/analysis/components/visit-hour.vue
@@ -0,0 +1,101 @@
+<template>
+  <a-card
+    :bordered="false"
+    title="最近1小时访问情况"
+    :body-style="{ padding: '16px 6px 0 0' }"
+  >
+    <v-chart
+      ref="visitHourChartRef"
+      :option="visitHourChartOption"
+      style="height: 362px"
+    />
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import { use } from 'echarts/core';
+  import type { EChartsCoreOption } from 'echarts/core';
+  import { CanvasRenderer } from 'echarts/renderers';
+  import { LineChart } from 'echarts/charts';
+  import {
+    GridComponent,
+    TooltipComponent,
+    LegendComponent
+  } from 'echarts/components';
+  import VChart from 'vue-echarts';
+  import { getVisitHourList } from '@/api/dashboard/analysis';
+  import useEcharts from '@/utils/use-echarts';
+
+  use([
+    CanvasRenderer,
+    LineChart,
+    GridComponent,
+    TooltipComponent,
+    LegendComponent
+  ]);
+
+  //
+  const visitHourChartRef = ref<InstanceType<typeof VChart> | null>(null);
+
+  useEcharts([visitHourChartRef]);
+
+  // 最近 1 小时访问情况折线图配置
+  const visitHourChartOption: EChartsCoreOption = reactive({});
+
+  /* 获取最近 1 小时访问情况数据 */
+  const getVisitHourData = () => {
+    getVisitHourList()
+      .then((data) => {
+        Object.assign(visitHourChartOption, {
+          tooltip: {
+            trigger: 'axis'
+          },
+          legend: {
+            data: ['浏览量', '访问量'],
+            right: 20
+          },
+          xAxis: [
+            {
+              type: 'category',
+              boundaryGap: false,
+              data: data.map((d) => d.time)
+            }
+          ],
+          yAxis: [
+            {
+              type: 'value'
+            }
+          ],
+          series: [
+            {
+              name: '浏览量',
+              type: 'line',
+              smooth: true,
+              symbol: 'none',
+              areaStyle: {
+                opacity: 0.5
+              },
+              data: data.map((d) => d.views)
+            },
+            {
+              name: '访问量',
+              type: 'line',
+              smooth: true,
+              symbol: 'none',
+              areaStyle: {
+                opacity: 0.5
+              },
+              data: data.map((d) => d.visits)
+            }
+          ]
+        });
+      })
+      .catch((e) => {
+        message.error(e.message);
+      });
+  };
+
+  getVisitHourData();
+</script>
diff --git a/src/views/dashboard/analysis/index.vue b/src/views/dashboard/analysis/index.vue
new file mode 100644
index 0000000..d0ede32
--- /dev/null
+++ b/src/views/dashboard/analysis/index.vue
@@ -0,0 +1,41 @@
+<template>
+  <div class="ele-body ele-body-card">
+    <statistics-card />
+    <sale-card />
+    <a-row :gutter="16">
+      <a-col
+        v-bind="
+          styleResponsive ? { lg: 16, md: 14, sm: 24, xs: 24 } : { span: 16 }
+        "
+      >
+        <visit-hour />
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive ? { lg: 8, md: 10, sm: 24, xs: 24 } : { span: 8 }
+        "
+      >
+        <hot-search />
+      </a-col>
+    </a-row>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import StatisticsCard from './components/statistics-card.vue';
+  import SaleCard from './components/sale-card.vue';
+  import VisitHour from './components/visit-hour.vue';
+  import HotSearch from './components/hot-search.vue';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'DashboardAnalysis'
+  };
+</script>
diff --git a/src/views/dashboard/monitor/components/browser-card.vue b/src/views/dashboard/monitor/components/browser-card.vue
new file mode 100644
index 0000000..067abff
--- /dev/null
+++ b/src/views/dashboard/monitor/components/browser-card.vue
@@ -0,0 +1,69 @@
+<template>
+  <a-card :bordered="false" title="浏览器分布" :body-style="{ padding: '0px' }">
+    <v-chart
+      ref="browserChartRef"
+      :option="browserChartOption"
+      style="height: 222px"
+    />
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import { use } from 'echarts/core';
+  import type { EChartsCoreOption } from 'echarts/core';
+  import { CanvasRenderer } from 'echarts/renderers';
+  import { PieChart } from 'echarts/charts';
+  import { TooltipComponent, LegendComponent } from 'echarts/components';
+  import VChart from 'vue-echarts';
+  import { getBrowserCountList } from '@/api/dashboard/monitor';
+  import useEcharts from '@/utils/use-echarts';
+
+  use([CanvasRenderer, PieChart, TooltipComponent, LegendComponent]);
+
+  //
+  const browserChartRef = ref<InstanceType<typeof VChart> | null>(null);
+
+  useEcharts([browserChartRef]);
+
+  // 浏览器分布饼图配置
+  const browserChartOption: EChartsCoreOption = reactive({});
+
+  /* 获取用户浏览器分布数据 */
+  const getBrowserCountData = () => {
+    getBrowserCountList()
+      .then((data) => {
+        Object.assign(browserChartOption, {
+          tooltip: {
+            trigger: 'item',
+            confine: true,
+            borderWidth: 1
+          },
+          legend: {
+            bottom: 5,
+            itemWidth: 10,
+            itemHeight: 10,
+            icon: 'circle',
+            data: data.map((d) => d.name)
+          },
+          series: [
+            {
+              type: 'pie',
+              radius: ['45%', '70%'],
+              center: ['50%', '43%'],
+              label: {
+                show: false
+              },
+              data: data
+            }
+          ]
+        });
+      })
+      .catch((e) => {
+        message.error(e.message);
+      });
+  };
+
+  getBrowserCountData();
+</script>
diff --git a/src/views/dashboard/monitor/components/map-card.vue b/src/views/dashboard/monitor/components/map-card.vue
new file mode 100644
index 0000000..87982f5
--- /dev/null
+++ b/src/views/dashboard/monitor/components/map-card.vue
@@ -0,0 +1,147 @@
+<template>
+  <a-card :bordered="false" title="用户分布">
+    <a-row>
+      <a-col v-bind="styleResponsive ? { sm: 18, xs: 24 } : { span: 18 }">
+        <v-chart
+          ref="userCountMapChartRef"
+          :option="userCountMapOption"
+          style="height: 469px"
+        />
+      </a-col>
+      <a-col v-bind="styleResponsive ? { sm: 6, xs: 24 } : { span: 6 }">
+        <div
+          v-for="item in userCountDataRank"
+          :key="item.name"
+          class="monitor-user-count-item ele-cell"
+        >
+          <div>{{ item.name }}</div>
+          <div class="ele-cell-content">
+            <a-progress
+              status="normal"
+              :show-info="false"
+              :percent="item.percent"
+            />
+          </div>
+          <div>{{ item.value }}</div>
+        </div>
+      </a-col>
+    </a-row>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import { use, registerMap } from 'echarts/core';
+  import type { EChartsCoreOption } from 'echarts/core';
+  import { CanvasRenderer } from 'echarts/renderers';
+  import { MapChart } from 'echarts/charts';
+  import {
+    VisualMapComponent,
+    GeoComponent,
+    TooltipComponent
+  } from 'echarts/components';
+  import VChart from 'vue-echarts';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import { getChinaMapData, getUserCountList } from '@/api/dashboard/monitor';
+  import type { UserCount } from '@/api/dashboard/monitor/model';
+  import useEcharts from '@/utils/use-echarts';
+
+  use([
+    CanvasRenderer,
+    MapChart,
+    VisualMapComponent,
+    GeoComponent,
+    TooltipComponent
+  ]);
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  //
+  const userCountMapChartRef = ref<InstanceType<typeof VChart> | null>(null);
+
+  useEcharts([userCountMapChartRef]);
+
+  // 用户分布前 10 名
+  const userCountDataRank = ref<UserCount[]>([]);
+
+  // 用户分布地图配置
+  const userCountMapOption: EChartsCoreOption = reactive({});
+
+  /* 获取中国地图数据并注册地图 */
+  const registerChinaMap = () => {
+    getChinaMapData()
+      .then((data) => {
+        registerMap('china', data);
+        getUserCountData();
+      })
+      .catch((e) => {
+        message.error(e.message);
+      });
+  };
+
+  /* 获取用户分布数据 */
+  const getUserCountData = () => {
+    getUserCountList()
+      .then((data) => {
+        const temp = data.sort((a, b) => b.value - a.value);
+        const min = temp[temp.length - 1].value || 0;
+        const max = temp[0].value || 1;
+        //
+        const list = temp.length > 10 ? temp.slice(0, 15) : temp;
+        userCountDataRank.value = list.map((d) => {
+          return {
+            name: d.name,
+            value: d.value,
+            percent: (d.value / max) * 100
+          };
+        });
+        //
+        Object.assign(userCountMapOption, {
+          tooltip: {
+            trigger: 'item',
+            borderWidth: 1
+          },
+          visualMap: {
+            min: min,
+            max: max,
+            text: ['高', '低'],
+            calculable: true
+          },
+          series: [
+            {
+              name: '用户数',
+              label: {
+                show: true
+              },
+              type: 'map',
+              map: 'china',
+              data: data
+            }
+          ]
+        });
+      })
+      .catch((e) => {
+        message.error(e.message);
+      });
+  };
+
+  registerChinaMap();
+</script>
+
+<style lang="less" scoped>
+  .monitor-user-count-item {
+    margin-bottom: 8px;
+
+    :deep(.ant-progress-inner) {
+      background: none;
+    }
+
+    .ele-cell-content {
+      padding-right: 10px;
+    }
+  }
+</style>
diff --git a/src/views/dashboard/monitor/components/online-num.vue b/src/views/dashboard/monitor/components/online-num.vue
new file mode 100644
index 0000000..e8bad2f
--- /dev/null
+++ b/src/views/dashboard/monitor/components/online-num.vue
@@ -0,0 +1,70 @@
+<template>
+  <a-card :bordered="false" title="在线人数">
+    <div class="monitor-online-num-card">
+      <div>{{ currentTime }}</div>
+      <div class="monitor-online-num-title">
+        <ele-count-up :end-val="onlineNum" />
+      </div>
+      <div class="monitor-online-num-text">在线总人数</div>
+      <a-badge status="processing" :text="updateTimeText" />
+    </div>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref, computed, onBeforeUnmount } from 'vue';
+  import { toDateString } from 'ele-admin-pro/es';
+  // 在线人数更新定时器
+  let onlineNumTimer: number | null = null;
+
+  // 在线总人数倒计时
+  const updateTime = ref(10);
+
+  // 当前时间
+  const currentTime = ref(toDateString(new Date(), 'HH:mm:ss'));
+
+  // 在线人数
+  const onlineNum = ref(228);
+
+  // 在线人数倒计时文字
+  const updateTimeText = computed(() => updateTime.value + ' 秒后更新');
+
+  /* 在线人数更新倒计时 */
+  const startUpdateOnlineNum = () => {
+    onlineNumTimer = window.setInterval(() => {
+      currentTime.value = toDateString(new Date(), 'HH:mm:ss');
+      if (updateTime.value === 1) {
+        updateTime.value = 10;
+        onlineNum.value = 100 + Math.round(Math.random() * 900);
+      } else {
+        updateTime.value--;
+      }
+    }, 1000);
+  };
+
+  onBeforeUnmount(() => {
+    // 销毁定时器
+    if (onlineNumTimer) {
+      clearInterval(onlineNumTimer);
+      onlineNumTimer = null;
+    }
+  });
+
+  startUpdateOnlineNum();
+</script>
+
+<style lang="less" scoped>
+  .monitor-online-num-card {
+    text-align: center;
+  }
+
+  .monitor-online-num-title {
+    line-height: 1;
+    font-size: 50px;
+    margin: 22px 0 14px;
+  }
+
+  .monitor-online-num-text {
+    margin-bottom: 22px;
+  }
+</style>
diff --git a/src/views/dashboard/monitor/components/statistics-card.vue b/src/views/dashboard/monitor/components/statistics-card.vue
new file mode 100644
index 0000000..8ff75b5
--- /dev/null
+++ b/src/views/dashboard/monitor/components/statistics-card.vue
@@ -0,0 +1,166 @@
+<!-- 统计卡片 -->
+<template>
+  <a-row :gutter="16">
+    <a-col
+      v-bind="styleResponsive ? { lg: 6, md: 12, sm: 12, xs: 24 } : { span: 6 }"
+    >
+      <a-card :bordered="false" class="monitor-count-card">
+        <ele-tag color="blue" shape="circle" size="large">
+          <eye-filled />
+        </ele-tag>
+        <h1 class="monitor-count-card-num">21.2 k</h1>
+        <div class="monitor-count-card-text">总访问人数</div>
+        <ele-avatar-list :data="visitUsers" size="small" :max="4" />
+      </a-card>
+    </a-col>
+    <a-col
+      v-bind="styleResponsive ? { lg: 6, md: 12, sm: 12, xs: 24 } : { span: 6 }"
+    >
+      <a-card :bordered="false" class="monitor-count-card">
+        <ele-tag color="orange" shape="circle" size="large">
+          <fire-filled />
+        </ele-tag>
+        <h1 class="monitor-count-card-num">1.6 k</h1>
+        <div class="monitor-count-card-text">点击量(近30天)</div>
+        <div class="monitor-count-card-trend ele-text-success">
+          <up-outlined />
+          <span>110.5%</span>
+        </div>
+        <a-tooltip title="指标说明">
+          <question-circle-outlined class="monitor-count-card-tips" />
+        </a-tooltip>
+      </a-card>
+    </a-col>
+    <a-col
+      v-bind="styleResponsive ? { lg: 6, md: 12, sm: 12, xs: 24 } : { span: 6 }"
+    >
+      <a-card :bordered="false" class="monitor-count-card">
+        <ele-tag color="red" shape="circle" size="large">
+          <flag-filled />
+        </ele-tag>
+        <h1 class="monitor-count-card-num">826.0</h1>
+        <div class="monitor-count-card-text">到达量(近30天)</div>
+        <div class="monitor-count-card-trend ele-text-danger">
+          <down-outlined />
+          <span>15.5%</span>
+        </div>
+      </a-card>
+    </a-col>
+    <a-col
+      v-bind="styleResponsive ? { lg: 6, md: 12, sm: 12, xs: 24 } : { span: 6 }"
+    >
+      <a-card :bordered="false" class="monitor-count-card">
+        <ele-tag color="green" shape="circle" size="large">
+          <thunderbolt-filled />
+        </ele-tag>
+        <h1 class="monitor-count-card-num">28.8 %</h1>
+        <div class="monitor-count-card-text">转化率(近30天)</div>
+        <div class="monitor-count-card-trend ele-text-success">
+          <up-outlined />
+          <span>65.8%</span>
+        </div>
+        <a-tooltip title="指标说明">
+          <question-circle-outlined class="monitor-count-card-tips" />
+        </a-tooltip>
+      </a-card>
+    </a-col>
+  </a-row>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import {
+    QuestionCircleOutlined,
+    EyeFilled,
+    FireFilled,
+    FlagFilled,
+    ThunderboltFilled,
+    UpOutlined,
+    DownOutlined
+  } from '@ant-design/icons-vue';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+
+  interface VisitUserType {
+    key: string | number;
+    name: string;
+    avatar: string;
+  }
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  // 访问人数
+  const visitUsers = ref<VisitUserType[]>([
+    {
+      key: 1,
+      name: 'SunSmile',
+      avatar:
+        'https://cdn.eleadmin.com/20200609/c184eef391ae48dba87e3057e70238fb.jpg'
+    },
+    {
+      key: 2,
+      name: '你的名字很好听',
+      avatar:
+        'https://cdn.eleadmin.com/20200609/b6a811873e704db49db994053a5019b2.jpg'
+    },
+    {
+      key: 3,
+      name: '全村人的希望',
+      avatar:
+        'https://cdn.eleadmin.com/20200609/948344a2a77c47a7a7b332fe12ff749a.jpg'
+    },
+    {
+      key: 4,
+      name: 'Jasmine',
+      avatar:
+        'https://cdn.eleadmin.com/20200609/f6bc05af944a4f738b54128717952107.jpg'
+    },
+    {
+      key: 5,
+      name: '酷酷的大叔',
+      avatar:
+        'https://cdn.eleadmin.com/20200609/2d98970a51b34b6b859339c96b240dcd.jpg'
+    },
+    {
+      key: 6,
+      name: '管理员',
+      avatar: 'https://cdn.eleadmin.com/20200610/avatar.jpg'
+    }
+  ]);
+</script>
+
+<style lang="less" scoped>
+  .monitor-count-card {
+    text-align: center;
+
+    .monitor-count-card-num {
+      margin-top: 6px;
+      font-size: 32px;
+    }
+
+    .monitor-count-card-text {
+      font-size: 12px;
+      margin: 8px 0;
+      opacity: 0.8;
+    }
+
+    .monitor-count-card-trend {
+      font-weight: bold;
+      line-height: 26px;
+
+      & > .anticon {
+        margin-right: 6px;
+      }
+    }
+
+    .monitor-count-card-tips {
+      position: absolute;
+      top: 16px;
+      right: 16px;
+      cursor: pointer;
+      opacity: 0.6;
+    }
+  }
+</style>
diff --git a/src/views/dashboard/monitor/components/user-liveness.vue b/src/views/dashboard/monitor/components/user-liveness.vue
new file mode 100644
index 0000000..9eb2e71
--- /dev/null
+++ b/src/views/dashboard/monitor/components/user-liveness.vue
@@ -0,0 +1,75 @@
+<template>
+  <a-card
+    :bordered="false"
+    title="用户活跃度"
+    :body-style="{ padding: '56px 0' }"
+  >
+    <div class="ele-cell">
+      <div class="ele-cell-content ele-text-center">
+        <div class="monitor-progress-group">
+          <a-progress
+            type="circle"
+            :percent="70"
+            stroke-color="#52c41a"
+            :show-info="false"
+            :width="161"
+          />
+          <a-progress
+            type="circle"
+            :percent="60"
+            stroke-color="#1890ff"
+            :show-info="false"
+            :width="121"
+            :stroke-width="5"
+          />
+          <a-progress
+            type="circle"
+            :percent="35"
+            stroke-color="#f5222d"
+            :show-info="false"
+            :width="91"
+            :stroke-width="4"
+          />
+        </div>
+      </div>
+      <div class="monitor-progress-legends">
+        <div class="ele-text-small ele-elip">
+          <a-badge color="green" text="活跃率: 70%" />
+        </div>
+        <div class="ele-text-small ele-elip">
+          <a-badge color="blue" text="留存率: 60%" />
+        </div>
+        <div class="ele-text-small ele-elip">
+          <a-badge color="red" text="跳出率: 35%" />
+        </div>
+      </div>
+    </div>
+  </a-card>
+</template>
+
+<style lang="less" scoped>
+  .monitor-progress-group {
+    position: relative;
+    display: inline-block;
+
+    .ant-progress:not(:first-child) {
+      position: absolute;
+      top: 50%;
+      left: 50%;
+      transform: translate(-50%, -50%);
+      margin-top: -1px;
+    }
+  }
+
+  .monitor-progress-legends {
+    padding-right: 24px;
+
+    :deep(.ant-badge-status-text) {
+      font-size: 12px;
+    }
+
+    & > div + div {
+      margin-top: 8px;
+    }
+  }
+</style>
diff --git a/src/views/dashboard/monitor/components/user-rate.vue b/src/views/dashboard/monitor/components/user-rate.vue
new file mode 100644
index 0000000..e88c449
--- /dev/null
+++ b/src/views/dashboard/monitor/components/user-rate.vue
@@ -0,0 +1,86 @@
+<template>
+  <a-card :bordered="false" title="用户评价">
+    <div class="ele-cell ele-cell-align-bottom">
+      <div style="font-size: 51px; line-height: 1">4.5</div>
+      <div class="ele-cell-content">
+        <a-rate :value="userRate" disabled />
+        <span style="color: #fadb14; margin-left: 8px">很棒</span>
+      </div>
+    </div>
+    <div class="ele-cell" style="margin: 18px 0">
+      <div style="font-size: 28px; line-height: 1" class="ele-text-placeholder">
+        -0%
+      </div>
+      <div class="ele-cell-content ele-text-small ele-text-secondary">
+        当前没有评价波动
+      </div>
+    </div>
+    <div class="ele-cell">
+      <div class="ele-cell-content">
+        <a-progress :percent="60" stroke-color="#52c41a" :show-info="false" />
+      </div>
+      <div class="monitor-evaluate-text">
+        <star-filled />
+        <span>5 : 368 人</span>
+      </div>
+    </div>
+    <div class="ele-cell">
+      <div class="ele-cell-content">
+        <a-progress :percent="40" stroke-color="#1890ff" :show-info="false" />
+      </div>
+      <div class="monitor-evaluate-text">
+        <star-filled />
+        <span>4 : 256 人</span>
+      </div>
+    </div>
+    <div class="ele-cell">
+      <div class="ele-cell-content">
+        <a-progress :percent="20" stroke-color="#faad14" :show-info="false" />
+      </div>
+      <div class="monitor-evaluate-text">
+        <star-filled />
+        <span>3 : 49 人</span>
+      </div>
+    </div>
+    <div class="ele-cell">
+      <div class="ele-cell-content">
+        <a-progress :percent="10" stroke-color="#f5222d" :show-info="false" />
+      </div>
+      <div class="monitor-evaluate-text">
+        <star-filled />
+        <span>2 : 14 人</span>
+      </div>
+    </div>
+    <div class="ele-cell">
+      <div class="ele-cell-content">
+        <a-progress :percent="0" :show-info="false" />
+      </div>
+      <div class="monitor-evaluate-text">
+        <star-filled />
+        <span>1 : 0 人</span>
+      </div>
+    </div>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import { StarFilled } from '@ant-design/icons-vue';
+
+  // 用户评分
+  const userRate = ref(4.5);
+</script>
+
+<style lang="less" scoped>
+  .monitor-evaluate-text {
+    width: 90px;
+    flex-shrink: 0;
+    white-space: nowrap;
+    opacity: 0.8;
+
+    & > .anticon {
+      font-size: 12px;
+      margin: 0 6px 0 8px;
+    }
+  }
+</style>
diff --git a/src/views/dashboard/monitor/components/user-satisfaction.vue b/src/views/dashboard/monitor/components/user-satisfaction.vue
new file mode 100644
index 0000000..4933d1a
--- /dev/null
+++ b/src/views/dashboard/monitor/components/user-satisfaction.vue
@@ -0,0 +1,79 @@
+<template>
+  <a-card :bordered="false" title="用户满意度">
+    <div class="ele-cell ele-text-center">
+      <div class="ele-cell-content" style="font-size: 24px">856</div>
+      <div class="ele-cell-content">
+        <div class="monitor-face-smile"><i></i></div>
+        <div class="ele-text-secondary ele-elip" style="margin-top: 8px">
+          正面评论
+        </div>
+      </div>
+      <h2 class="ele-cell-content ele-text-success">82%</h2>
+    </div>
+    <a-divider style="margin: 26px 0" />
+    <div class="ele-cell ele-text-center">
+      <div class="ele-cell-content" style="font-size: 24px">60</div>
+      <div class="ele-cell-content">
+        <div class="monitor-face-cry"><i></i></div>
+        <div class="ele-text-secondary ele-elip" style="margin-top: 8px">
+          负面评论
+        </div>
+      </div>
+      <h2 class="ele-cell-content ele-text-danger">9%</h2>
+    </div>
+  </a-card>
+</template>
+
+<style lang="less" scoped>
+  .monitor-face-smile,
+  .monitor-face-cry {
+    width: 50px;
+    height: 50px;
+    display: inline-block;
+    background: #fbd971;
+    border-radius: 50%;
+    position: relative;
+  }
+
+  .monitor-face-smile > i,
+  .monitor-face-smile:before,
+  .monitor-face-smile:after,
+  .monitor-face-cry > i,
+  .monitor-face-cry:before,
+  .monitor-face-cry:after {
+    width: 28px;
+    height: 28px;
+    border-radius: 50%;
+    transform: rotate(225deg);
+    border: 3px solid #f0c419;
+    border-right-color: transparent !important;
+    border-bottom-color: transparent !important;
+    position: absolute;
+    bottom: 8px;
+    left: 11px;
+  }
+
+  .monitor-face-smile:before,
+  .monitor-face-smile:after,
+  .monitor-face-cry:before,
+  .monitor-face-cry:after {
+    content: '';
+    width: 12px;
+    height: 12px;
+    left: 8px;
+    top: 14px;
+    border-color: #f29c1f;
+    transform: rotate(45deg);
+  }
+
+  .monitor-face-smile:after,
+  .monitor-face-cry:after {
+    left: auto;
+    right: 8px;
+  }
+
+  .monitor-face-cry > i {
+    transform: rotate(45deg);
+    bottom: -6px;
+  }
+</style>
diff --git a/src/views/dashboard/monitor/index.vue b/src/views/dashboard/monitor/index.vue
new file mode 100644
index 0000000..c9a72d8
--- /dev/null
+++ b/src/views/dashboard/monitor/index.vue
@@ -0,0 +1,91 @@
+<template>
+  <div class="ele-body ele-body-card">
+    <statistics-card />
+    <a-row :gutter="16">
+      <a-col
+        v-bind="
+          styleResponsive ? { lg: 18, md: 24, sm: 24, xs: 24 } : { span: 18 }
+        "
+      >
+        <map-card />
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive ? { lg: 6, md: 24, sm: 24, xs: 24 } : { span: 6 }
+        "
+      >
+        <a-row :gutter="16">
+          <a-col
+            v-bind="
+              styleResponsive
+                ? { lg: 24, md: 12, sm: 12, xs: 24 }
+                : { span: 24 }
+            "
+          >
+            <online-num />
+          </a-col>
+          <a-col
+            v-bind="
+              styleResponsive
+                ? { lg: 24, md: 12, sm: 12, xs: 24 }
+                : { span: 24 }
+            "
+          >
+            <browser-card />
+          </a-col>
+        </a-row>
+      </a-col>
+    </a-row>
+    <a-row :gutter="16">
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 12, lg: 24, md: 24, sm: 24, xs: 24 }
+            : { span: 12 }
+        "
+      >
+        <user-rate />
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 12, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <user-satisfaction />
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 12, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <user-liveness />
+      </a-col>
+    </a-row>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import StatisticsCard from './components/statistics-card.vue';
+  import MapCard from './components/map-card.vue';
+  import OnlineNum from './components/online-num.vue';
+  import BrowserCard from './components/browser-card.vue';
+  import UserRate from './components/user-rate.vue';
+  import UserSatisfaction from './components/user-satisfaction.vue';
+  import UserLiveness from './components/user-liveness.vue';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'DashboardMonitor'
+  };
+</script>
diff --git a/src/views/dashboard/workplace/components/activities-card.vue b/src/views/dashboard/workplace/components/activities-card.vue
new file mode 100644
index 0000000..864f5d9
--- /dev/null
+++ b/src/views/dashboard/workplace/components/activities-card.vue
@@ -0,0 +1,138 @@
+<!-- 最新动态 -->
+<template>
+  <a-card :title="title" :bordered="false" :body-style="{ padding: '6px 0' }">
+    <template #extra>
+      <more-icon @remove="onRemove" @edit="onEdit" />
+    </template>
+    <div
+      style="height: 346px; padding: 22px 20px 0 20px"
+      class="ele-scrollbar-hover"
+    >
+      <a-timeline>
+        <a-timeline-item
+          v-for="item in activities"
+          :key="item.id"
+          :color="item.color"
+        >
+          <em>{{ item.time }}</em>
+          <em>{{ item.title }}</em>
+        </a-timeline-item>
+      </a-timeline>
+    </div>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import MoreIcon from './more-icon.vue';
+
+  defineProps<{
+    title?: string;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'remove'): void;
+    (e: 'edit'): void;
+  }>();
+
+  interface Activitie {
+    id: number;
+    title: string;
+    time: string;
+    color?: string;
+  }
+
+  // 最新动态数据
+  const activities = ref<Activitie[]>([]);
+
+  /* 查询最新动态 */
+  const queryActivities = () => {
+    activities.value = [
+      {
+        id: 1,
+        title: 'SunSmile 解决了bug 登录提示操作失败',
+        time: '20:30',
+        color: 'gray'
+      },
+      {
+        id: 2,
+        title: 'Jasmine 解决了bug 按钮颜色与设计不符',
+        time: '19:30',
+        color: 'gray'
+      },
+      {
+        id: 3,
+        title: '项目经理 指派了任务 解决项目一的bug',
+        time: '18:30'
+      },
+      {
+        id: 4,
+        title: '项目经理 指派了任务 解决项目二的bug',
+        time: '17:30'
+      },
+      {
+        id: 5,
+        title: '项目经理 指派了任务 解决项目三的bug',
+        time: '16:30'
+      },
+      {
+        id: 6,
+        title: '项目经理 指派了任务 解决项目四的bug',
+        time: '15:30',
+        color: 'gray'
+      },
+      {
+        id: 7,
+        title: '项目经理 指派了任务 解决项目五的bug',
+        time: '14:30',
+        color: 'gray'
+      },
+      {
+        id: 8,
+        title: '项目经理 指派了任务 解决项目六的bug',
+        time: '12:30',
+        color: 'gray'
+      },
+      {
+        id: 9,
+        title: '项目经理 指派了任务 解决项目七的bug',
+        time: '11:30'
+      },
+      {
+        id: 10,
+        title: '项目经理 指派了任务 解决项目八的bug',
+        time: '10:30',
+        color: 'gray'
+      },
+      {
+        id: 11,
+        title: '项目经理 指派了任务 解决项目九的bug',
+        time: '09:30',
+        color: 'green'
+      },
+      {
+        id: 12,
+        title: '项目经理 指派了任务 解决项目十的bug',
+        time: '08:30',
+        color: 'red'
+      }
+    ];
+  };
+
+  const onRemove = () => {
+    emit('remove');
+  };
+
+  const onEdit = () => {
+    emit('edit');
+  };
+
+  queryActivities();
+</script>
+
+<style lang="less" scoped>
+  .ele-scrollbar-hover
+    :deep(.ant-timeline-item-last > .ant-timeline-item-content) {
+    min-height: auto;
+  }
+</style>
diff --git a/src/views/dashboard/workplace/components/goal-card.vue b/src/views/dashboard/workplace/components/goal-card.vue
new file mode 100644
index 0000000..b5ebf76
--- /dev/null
+++ b/src/views/dashboard/workplace/components/goal-card.vue
@@ -0,0 +1,70 @@
+<!-- 本月目标 -->
+<template>
+  <a-card :title="title" :bordered="false">
+    <template #extra>
+      <more-icon @remove="onRemove" @edit="onEdit" />
+    </template>
+    <div class="workplace-goal-group">
+      <a-progress
+        :width="180"
+        :percent="80"
+        type="dashboard"
+        :stroke-width="4"
+        :show-info="false"
+      />
+      <div class="workplace-goal-content">
+        <ele-tag color="blue" size="large" shape="circle">
+          <trophy-outlined />
+        </ele-tag>
+        <div class="workplace-goal-num">285</div>
+      </div>
+      <div class="workplace-goal-text">恭喜, 本月目标已达标!</div>
+    </div>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { TrophyOutlined } from '@ant-design/icons-vue';
+  import MoreIcon from './more-icon.vue';
+
+  defineProps<{
+    title?: string;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'remove'): void;
+    (e: 'edit'): void;
+  }>();
+
+  const onRemove = () => {
+    emit('remove');
+  };
+
+  const onEdit = () => {
+    emit('edit');
+  };
+</script>
+
+<style lang="less" scoped>
+  .workplace-goal-group {
+    height: 310px;
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    align-items: center;
+    position: relative;
+
+    .workplace-goal-content {
+      position: absolute;
+      top: 50%;
+      left: 50%;
+      width: 180px;
+      margin: -50px 0 0 -90px;
+      text-align: center;
+    }
+
+    .workplace-goal-num {
+      font-size: 40px;
+    }
+  }
+</style>
diff --git a/src/views/dashboard/workplace/components/link-card.vue b/src/views/dashboard/workplace/components/link-card.vue
new file mode 100644
index 0000000..ae1352e
--- /dev/null
+++ b/src/views/dashboard/workplace/components/link-card.vue
@@ -0,0 +1,187 @@
+<!-- 快捷方式 -->
+<template>
+  <a-row :gutter="16" ref="wrapRef">
+    <a-col
+      v-for="item in data"
+      :key="item.url"
+      v-bind="styleResponsive ? { lg: 3, md: 6, sm: 12, xs: 12 } : { span: 3 }"
+    >
+      <a-card :bordered="false" hoverable :body-style="{ padding: 0 }">
+        <router-link :to="item.url" class="app-link-block">
+          <component
+            :is="item.icon"
+            class="app-link-icon"
+            :style="{ color: item.color }"
+          />
+          <div class="app-link-title">{{ item.title }}</div>
+        </router-link>
+      </a-card>
+    </a-col>
+  </a-row>
+</template>
+
+<script lang="ts" setup>
+  import { ref, onMounted, onBeforeUnmount } from 'vue';
+  import SortableJs from 'sortablejs';
+  import type { Row as ARow } from 'ant-design-vue/es';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  const CACHE_KEY = 'workplace-links';
+
+  interface LinkItem {
+    icon: string;
+    title: string;
+    url: string;
+    color?: string;
+  }
+
+  // 默认顺序
+  const DEFAULT: LinkItem[] = [
+    {
+      icon: 'user-outlined',
+      title: '用户',
+      url: '/system/user'
+    },
+    {
+      icon: 'shopping-cart-outlined',
+      title: '分析',
+      url: '/dashboard/analysis',
+      color: '#95de64'
+    },
+    {
+      icon: 'fund-projection-screen-outlined',
+      title: '商品',
+      url: '/list/card/project',
+      color: '#ff9c6e'
+    },
+    {
+      icon: 'file-search-outlined',
+      title: '订单',
+      url: '/list/basic',
+      color: '#b37feb'
+    },
+    {
+      icon: 'credit-card-outlined',
+      title: '票据',
+      url: '/list/advanced',
+      color: '#ffd666'
+    },
+    {
+      icon: 'mail-outlined',
+      title: '消息',
+      url: '/user/message',
+      color: '#5cdbd3'
+    },
+    {
+      icon: 'tags-outlined',
+      title: '标签',
+      url: '/extension/tag',
+      color: '#ff85c0'
+    },
+    {
+      icon: 'control-outlined',
+      title: '配置',
+      url: '/user/profile',
+      color: '#ffc069'
+    }
+  ];
+
+  // 获取缓存的顺序
+  const cache = (() => {
+    const str = localStorage.getItem(CACHE_KEY);
+    try {
+      return str ? JSON.parse(str) : null;
+    } catch (e) {
+      return null;
+    }
+  })();
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const data = ref<LinkItem[]>([...(cache ?? DEFAULT)]);
+
+  const wrapRef = ref<InstanceType<typeof ARow> | null>(null);
+
+  let sortableIns: SortableJs | null = null;
+
+  /* 重置布局 */
+  const reset = () => {
+    data.value = [...DEFAULT];
+    cacheData();
+  };
+
+  /* 缓存布局 */
+  const cacheData = () => {
+    localStorage.setItem(CACHE_KEY, JSON.stringify(data.value));
+  };
+
+  onMounted(() => {
+    const isTouchDevice = 'ontouchstart' in document.documentElement;
+    if (isTouchDevice) {
+      return;
+    }
+    sortableIns = new SortableJs(wrapRef.value?.$el, {
+      animation: 300,
+      onUpdate: ({ oldIndex, newIndex }) => {
+        if (typeof oldIndex === 'number' && typeof newIndex === 'number') {
+          const temp = [...data.value];
+          temp.splice(newIndex, 0, temp.splice(oldIndex, 1)[0]);
+          data.value = temp;
+          cacheData();
+        }
+      },
+      setData: () => {}
+    });
+  });
+
+  onBeforeUnmount(() => {
+    if (sortableIns) {
+      sortableIns.destroy();
+    }
+  });
+
+  defineExpose({ reset });
+</script>
+
+<script lang="ts">
+  import {
+    UserOutlined,
+    ShoppingCartOutlined,
+    FundProjectionScreenOutlined,
+    FileSearchOutlined,
+    CreditCardOutlined,
+    MailOutlined,
+    TagsOutlined,
+    ControlOutlined
+  } from '@ant-design/icons-vue';
+
+  export default {
+    components: {
+      UserOutlined,
+      ShoppingCartOutlined,
+      FundProjectionScreenOutlined,
+      FileSearchOutlined,
+      CreditCardOutlined,
+      MailOutlined,
+      TagsOutlined,
+      ControlOutlined
+    }
+  };
+</script>
+
+<style lang="less" scoped>
+  .app-link-block {
+    padding: 12px;
+    text-align: center;
+    display: block;
+    color: inherit;
+
+    .app-link-icon {
+      color: #69c0ff;
+      font-size: 30px;
+      margin: 6px 0 10px 0;
+    }
+  }
+</style>
diff --git a/src/views/dashboard/workplace/components/more-icon.vue b/src/views/dashboard/workplace/components/more-icon.vue
new file mode 100644
index 0000000..2823738
--- /dev/null
+++ b/src/views/dashboard/workplace/components/more-icon.vue
@@ -0,0 +1,38 @@
+<template>
+  <a-dropdown placement="bottomRight">
+    <more-outlined class="ele-text-secondary" style="font-size: 18px" />
+    <template #overlay>
+      <a-menu :selectable="false" @click="onClick">
+        <a-menu-item key="edit">
+          <div class="ele-cell">
+            <edit-outlined />
+            <div class="ele-cell-content">编辑</div>
+          </div>
+        </a-menu-item>
+        <a-menu-item key="remove">
+          <div class="ele-cell ele-text-danger">
+            <delete-outlined />
+            <div class="ele-cell-content">删除</div>
+          </div>
+        </a-menu-item>
+      </a-menu>
+    </template>
+  </a-dropdown>
+</template>
+
+<script lang="ts" setup>
+  import {
+    MoreOutlined,
+    EditOutlined,
+    DeleteOutlined
+  } from '@ant-design/icons-vue';
+
+  const emit = defineEmits<{
+    (e: 'edit'): void;
+    (e: 'remove'): void;
+  }>();
+
+  const onClick = ({ key }) => {
+    emit(key);
+  };
+</script>
diff --git a/src/views/dashboard/workplace/components/profile-card.vue b/src/views/dashboard/workplace/components/profile-card.vue
new file mode 100644
index 0000000..1007e4b
--- /dev/null
+++ b/src/views/dashboard/workplace/components/profile-card.vue
@@ -0,0 +1,119 @@
+<!-- 用户信息 -->
+<template>
+  <a-card :bordered="false" :body-style="{ padding: '20px' }">
+    <div
+      :class="[
+        'ele-cell',
+        'workplace-user-card',
+        { 'workplace-user-responsive': styleResponsive }
+      ]"
+    >
+      <div class="ele-cell-content ele-cell">
+        <a-avatar :size="68" :src="loginUser.avatar" />
+        <div class="ele-cell-content">
+          <h4 class="ele-elip">
+            早安, {{ loginUser.nickname }}, 开始您一天的工作吧!
+          </h4>
+          <div class="ele-elip ele-text-secondary">
+            <cloud-outlined />
+            <em>今日多云转阴,18℃ - 22℃,出门记得穿外套哦~</em>
+          </div>
+        </div>
+      </div>
+      <div class="workplace-count-group">
+        <div class="workplace-count-item">
+          <div class="workplace-count-header">
+            <ele-tag color="blue" shape="circle" size="small">
+              <appstore-filled />
+            </ele-tag>
+            <span class="workplace-count-name">项目数</span>
+          </div>
+          <h2>3</h2>
+        </div>
+        <div class="workplace-count-item">
+          <div class="workplace-count-header">
+            <ele-tag color="orange" shape="circle" size="small">
+              <check-square-outlined />
+            </ele-tag>
+            <span class="workplace-count-name">待办项</span>
+          </div>
+          <h2>6 / 24</h2>
+        </div>
+        <div class="workplace-count-item">
+          <div class="workplace-count-header">
+            <ele-tag color="green" shape="circle" size="small">
+              <bell-filled />
+            </ele-tag>
+            <span class="workplace-count-name">消息</span>
+          </div>
+          <h2>1,689</h2>
+        </div>
+      </div>
+    </div>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { computed } from 'vue';
+  import {
+    CloudOutlined,
+    AppstoreFilled,
+    CheckSquareOutlined,
+    BellFilled
+  } from '@ant-design/icons-vue';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import { useUserStore } from '@/store/modules/user';
+
+  const userStore = useUserStore();
+
+  // 当前登录用户信息
+  const loginUser = computed(() => userStore.info ?? {});
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+</script>
+
+<style lang="less" scoped>
+  .workplace-user-card {
+    .ele-cell-content {
+      overflow: hidden;
+    }
+
+    h4 {
+      margin-bottom: 6px;
+    }
+  }
+
+  .workplace-count-group {
+    white-space: nowrap;
+    text-align: right;
+    flex-shrink: 0;
+  }
+
+  .workplace-count-item {
+    display: inline-block;
+    margin: 0 4px 0 24px;
+  }
+
+  .workplace-count-name {
+    margin-left: 8px;
+  }
+
+  @media screen and (max-width: 992px) {
+    .workplace-user-responsive .workplace-count-item {
+      margin: 0 2px 0 12px;
+    }
+  }
+
+  @media screen and (max-width: 768px) {
+    .workplace-user-responsive.workplace-user-card {
+      display: block;
+
+      .workplace-count-group {
+        margin-top: 8px;
+      }
+    }
+  }
+</style>
diff --git a/src/views/dashboard/workplace/components/project-card.vue b/src/views/dashboard/workplace/components/project-card.vue
new file mode 100644
index 0000000..14cb2a3
--- /dev/null
+++ b/src/views/dashboard/workplace/components/project-card.vue
@@ -0,0 +1,179 @@
+<!-- 项目进度 -->
+<template>
+  <a-card
+    :title="title"
+    :bordered="false"
+    :body-style="{ padding: '14px', height: '358px' }"
+  >
+    <template #extra>
+      <more-icon @remove="onRemove" @edit="onEdit" />
+    </template>
+    <a-table
+      row-key="id"
+      size="middle"
+      :pagination="false"
+      :data-source="projectList"
+      :columns="projectColumns"
+      :scroll="{ x: 600 }"
+    >
+      <template #bodyCell="{ column, record }">
+        <template v-if="column.key === 'projectName'">
+          <a>{{ record.projectName }}</a>
+        </template>
+        <template v-else-if="column.key === 'status'">
+          <span v-if="record.status === 0" class="ele-text-success">
+            进行中
+          </span>
+          <span v-else-if="record.status === 1" class="ele-text-danger">
+            已延期
+          </span>
+          <span v-else-if="record.status === 2" class="ele-text-warning">
+            未开始
+          </span>
+          <span
+            v-else-if="record.status === 3"
+            class="ele-text-info ele-text-delete"
+          >
+            已结束
+          </span>
+        </template>
+        <template v-else-if="column.key === 'progress'">
+          <a-progress :percent="record.progress" size="small" />
+        </template>
+      </template>
+    </a-table>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import MoreIcon from './more-icon.vue';
+  import type { ColumnsType } from 'ant-design-vue/es/table';
+
+  defineProps<{
+    title?: string;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'remove'): void;
+    (e: 'edit'): void;
+  }>();
+
+  interface Project {
+    id: number;
+    projectName: string;
+    status: number;
+    startDate: string;
+    endDate: string;
+    progress: number;
+  }
+
+  const projectColumns = ref<ColumnsType>([
+    {
+      key: 'index',
+      align: 'center',
+      width: 38,
+      customRender: ({ index }) => index + 1,
+      fixed: 'left'
+    },
+    {
+      title: '项目名称',
+      key: 'projectName',
+      ellipsis: true,
+      minWidth: 120
+    },
+    {
+      title: '开始时间',
+      dataIndex: 'startDate',
+      align: 'center',
+      minWidth: 100,
+      ellipsis: true
+    },
+    {
+      title: '结束时间',
+      dataIndex: 'endDate',
+      align: 'center',
+      minWidth: 100,
+      ellipsis: true
+    },
+    {
+      title: '状态',
+      key: 'status',
+      align: 'center',
+      width: 90
+    },
+    {
+      title: '进度',
+      key: 'progress',
+      align: 'center',
+      width: 180
+    }
+  ]);
+
+  // 项目进度数据
+  const projectList = ref<Project[]>([]);
+
+  /* 查询项目进度 */
+  const queryProjectList = () => {
+    projectList.value = [
+      {
+        id: 1,
+        projectName: '项目0000001',
+        status: 0,
+        startDate: '2020-03-01',
+        endDate: '2020-06-01',
+        progress: 30
+      },
+      {
+        id: 2,
+        projectName: '项目0000002',
+        status: 0,
+        startDate: '2020-03-01',
+        endDate: '2020-08-01',
+        progress: 10
+      },
+      {
+        id: 3,
+        projectName: '项目0000003',
+        status: 1,
+        startDate: '2020-01-01',
+        endDate: '2020-05-01',
+        progress: 60
+      },
+      {
+        id: 4,
+        projectName: '项目0000004',
+        status: 1,
+        startDate: '2020-06-01',
+        endDate: '2020-10-01',
+        progress: 0
+      },
+      {
+        id: 5,
+        projectName: '项目0000005',
+        status: 2,
+        startDate: '2020-01-01',
+        endDate: '2020-03-01',
+        progress: 100
+      },
+      {
+        id: 6,
+        projectName: '项目0000006',
+        status: 3,
+        startDate: '2020-01-01',
+        endDate: '2020-03-01',
+        progress: 100
+      }
+    ];
+  };
+
+  const onRemove = () => {
+    emit('remove');
+  };
+
+  const onEdit = () => {
+    emit('edit');
+  };
+
+  queryProjectList();
+</script>
diff --git a/src/views/dashboard/workplace/components/task-card.vue b/src/views/dashboard/workplace/components/task-card.vue
new file mode 100644
index 0000000..c0a60ed
--- /dev/null
+++ b/src/views/dashboard/workplace/components/task-card.vue
@@ -0,0 +1,157 @@
+<!-- 我的任务 -->
+<template>
+  <a-card
+    :title="title"
+    :bordered="false"
+    :body-style="{ padding: '10px', height: '358px' }"
+    class="workplace-table-card"
+  >
+    <template #extra>
+      <more-icon @remove="onRemove" @edit="onEdit" />
+    </template>
+    <div style="overflow: auto; position: relative">
+      <table class="ele-table" style="table-layout: fixed; min-width: 300px">
+        <colgroup>
+          <col width="38" />
+          <col width="65" />
+          <col />
+          <col width="70" />
+        </colgroup>
+        <thead>
+          <tr>
+            <th style="position: sticky; left: 0"></th>
+            <th style="text-align: center">优先级</th>
+            <th>任务名称</th>
+            <th style="text-align: center">状态</th>
+          </tr>
+        </thead>
+        <vue-draggable
+          tag="tbody"
+          item-key="id"
+          v-model="taskList"
+          handle=".sort-handle"
+          :animation="300"
+          :set-data="() => void 0"
+        >
+          <template #item="{ element }">
+            <tr>
+              <td style="text-align: center; position: sticky; left: 0">
+                <menu-outlined class="sort-handle ele-text-secondary" />
+              </td>
+              <td style="text-align: center">
+                <ele-tag
+                  :color="['red', 'orange', 'blue'][element.priority - 1]"
+                  shape="circle"
+                >
+                  {{ element.priority }}
+                </ele-tag>
+              </td>
+              <td class="ele-elip" :title="element.taskName">
+                <a>{{ element.taskName }}</a>
+              </td>
+              <td style="text-align: center">
+                <span v-if="element.status === 0" class="ele-text-warning">
+                  未开始
+                </span>
+                <span v-else-if="element.status === 1" class="ele-text-success">
+                  进行中
+                </span>
+                <span v-else-if="element.status === 2" class="ele-text-info">
+                  已完成
+                </span>
+              </td>
+            </tr>
+          </template>
+        </vue-draggable>
+      </table>
+    </div>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import VueDraggable from 'vuedraggable';
+  import { MenuOutlined } from '@ant-design/icons-vue';
+  import MoreIcon from './more-icon.vue';
+
+  defineProps<{
+    title?: string;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'remove'): void;
+    (e: 'edit'): void;
+  }>();
+
+  interface Task {
+    id: number;
+    priority: number;
+    taskName: string;
+    status: number;
+  }
+
+  // 我的任务数据
+  const taskList = ref<Task[]>([]);
+
+  /* 查询我的任务 */
+  const queryTaskList = () => {
+    taskList.value = [
+      {
+        id: 1,
+        priority: 1,
+        taskName: '解决项目一的bug',
+        status: 0
+      },
+      {
+        id: 2,
+        priority: 2,
+        taskName: '解决项目二的bug',
+        status: 0
+      },
+      {
+        id: 3,
+        priority: 2,
+        taskName: '解决项目三的bug',
+        status: 1
+      },
+      {
+        id: 4,
+        priority: 3,
+        taskName: '解决项目四的bug',
+        status: 1
+      },
+      {
+        id: 5,
+        priority: 3,
+        taskName: '解决项目五的bug',
+        status: 2
+      },
+      {
+        id: 6,
+        priority: 3,
+        taskName: '解决项目六的bug',
+        status: 2
+      }
+    ];
+  };
+
+  const onRemove = () => {
+    emit('remove');
+  };
+
+  const onEdit = () => {
+    emit('edit');
+  };
+
+  queryTaskList();
+</script>
+
+<style lang="less" scoped>
+  .ele-table tr.sortable-chosen {
+    background: hsla(0, 0%, 60%, 0.1);
+  }
+
+  .workplace-table-card .sort-handle {
+    cursor: move;
+  }
+</style>
diff --git a/src/views/dashboard/workplace/components/user-list.vue b/src/views/dashboard/workplace/components/user-list.vue
new file mode 100644
index 0000000..7ae8bc7
--- /dev/null
+++ b/src/views/dashboard/workplace/components/user-list.vue
@@ -0,0 +1,123 @@
+<!-- 小组成员 -->
+<template>
+  <a-card :title="title" :bordered="false" :body-style="{ padding: '2px 0px' }">
+    <template #extra>
+      <more-icon @remove="onRemove" @edit="onEdit" />
+    </template>
+    <div
+      v-for="(item, index) in userList"
+      :key="index"
+      class="ele-cell user-list-item"
+    >
+      <div style="flex-shrink: 0">
+        <a-avatar :size="46" :src="item.avatar" />
+      </div>
+      <div class="ele-cell-content">
+        <div class="ele-cell-title ele-elip">{{ item.name }}</div>
+        <div class="ele-cell-desc ele-elip">{{ item.introduction }}</div>
+      </div>
+      <div style="flex-shrink: 0">
+        <a-tag :color="['green', 'red'][item.status]">
+          {{ ['在线', '离线'][item.status] }}
+        </a-tag>
+      </div>
+    </div>
+  </a-card>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import MoreIcon from './more-icon.vue';
+
+  defineProps<{
+    title?: string;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'remove'): void;
+    (e: 'edit'): void;
+  }>();
+
+  interface User {
+    name: string;
+    introduction: string;
+    status: number;
+    avatar: string;
+  }
+
+  // 小组成员数据
+  const userList = ref<User[]>([]);
+
+  /* 查询小组成员 */
+  const queryUserList = () => {
+    userList.value = [
+      {
+        name: 'SunSmile',
+        introduction: 'UI设计师、交互专家',
+        status: 0,
+        avatar:
+          'https://cdn.eleadmin.com/20200609/c184eef391ae48dba87e3057e70238fb.jpg'
+      },
+      {
+        name: '你的名字很好听',
+        introduction: '前端工程师',
+        status: 0,
+        avatar:
+          'https://cdn.eleadmin.com/20200609/b6a811873e704db49db994053a5019b2.jpg'
+      },
+      {
+        name: '全村人的希望',
+        introduction: '前端工程师',
+        status: 0,
+        avatar:
+          'https://cdn.eleadmin.com/20200609/948344a2a77c47a7a7b332fe12ff749a.jpg'
+      },
+      {
+        name: 'Jasmine',
+        introduction: '产品经理、项目经理',
+        status: 1,
+        avatar:
+          'https://cdn.eleadmin.com/20200609/f6bc05af944a4f738b54128717952107.jpg'
+      },
+      {
+        name: '酷酷的大叔',
+        introduction: '组长、后端工程师',
+        status: 1,
+        avatar:
+          'https://cdn.eleadmin.com/20200609/2d98970a51b34b6b859339c96b240dcd.jpg'
+      }
+    ];
+  };
+
+  const onRemove = () => {
+    emit('remove');
+  };
+
+  const onEdit = () => {
+    emit('edit');
+  };
+
+  queryUserList();
+</script>
+
+<style lang="less" scoped>
+  .user-list-item {
+    padding: 12px 18px;
+
+    & + .user-list-item {
+      border-top: 1px solid hsla(0, 0%, 60%, 0.15);
+    }
+
+    .ele-cell-content {
+      overflow: hidden;
+    }
+
+    .ele-cell-desc {
+      margin-top: 0;
+    }
+
+    .ant-tag {
+      margin: 0;
+    }
+  }
+</style>
diff --git a/src/views/dashboard/workplace/index.vue b/src/views/dashboard/workplace/index.vue
new file mode 100644
index 0000000..ff55d7c
--- /dev/null
+++ b/src/views/dashboard/workplace/index.vue
@@ -0,0 +1,294 @@
+<template>
+  <div class="ele-body ele-body-card">
+    <profile-card />
+    <link-card ref="linkCardRef" />
+    <a-row :gutter="16" ref="wrapRef">
+      <a-col
+        v-for="(item, index) in data"
+        :key="item.name"
+        v-bind="
+          styleResponsive
+            ? { lg: item.lg, md: item.md, sm: item.sm, xs: item.xs }
+            : { span: item.lg }
+        "
+      >
+        <component
+          :is="item.name"
+          :title="item.title"
+          @remove="onRemove(index)"
+          @edit="onEdit(index)"
+        />
+      </a-col>
+    </a-row>
+    <a-card :bordered="false" :body-style="{ padding: 0 }">
+      <div class="ele-cell" style="line-height: 42px">
+        <div
+          class="ele-cell-content ele-text-primary workplace-bottom-btn"
+          @click="add"
+        >
+          <plus-circle-outlined /> 添加视图
+        </div>
+        <a-divider type="vertical" />
+        <div
+          class="ele-cell-content ele-text-primary workplace-bottom-btn"
+          @click="reset"
+        >
+          <undo-outlined /> 重置布局
+        </div>
+      </div>
+    </a-card>
+    <ele-modal
+      :width="680"
+      v-model:visible="visible"
+      title="未添加的视图"
+      :footer="null"
+    >
+      <a-row :gutter="16">
+        <a-col
+          v-for="item in notAddedData"
+          :key="item.name"
+          v-bind="styleResponsive ? { md: 8, sm: 12, xs: 24 } : { span: 8 }"
+        >
+          <div
+            class="workplace-card-item ele-border-split"
+            @click="addView(item)"
+          >
+            <div class="workplace-card-header ele-border-split">
+              {{ item.title }}
+            </div>
+            <div class="workplace-card-body ele-text-placeholder">
+              <plus-circle-outlined />
+            </div>
+          </div>
+        </a-col>
+      </a-row>
+      <a-empty v-if="!notAddedData.length" description="已添加所有视图" />
+    </ele-modal>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
+  import SortableJs from 'sortablejs';
+  import type { Row as ARow } from 'ant-design-vue/es';
+  import { message } from 'ant-design-vue/es';
+  import { PlusCircleOutlined, UndoOutlined } from '@ant-design/icons-vue';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import ProfileCard from './components/profile-card.vue';
+  import LinkCard from './components/link-card.vue';
+  const CACHE_KEY = 'workplace-layout';
+
+  interface ViewItem {
+    name: string;
+    title: string;
+    lg: number;
+    md: number;
+    sm: number;
+    xs: number;
+  }
+
+  // 默认布局
+  const DEFAULT: ViewItem[] = [
+    {
+      name: 'activities-card',
+      title: '最新动态',
+      lg: 8,
+      md: 24,
+      sm: 24,
+      xs: 24
+    },
+    {
+      name: 'task-card',
+      title: '我的任务',
+      lg: 8,
+      md: 24,
+      sm: 24,
+      xs: 24
+    },
+    {
+      name: 'goal-card',
+      title: '本月目标',
+      lg: 8,
+      md: 24,
+      sm: 24,
+      xs: 24
+    },
+    {
+      name: 'project-card',
+      title: '项目进度',
+      lg: 16,
+      md: 24,
+      sm: 24,
+      xs: 24
+    },
+    {
+      name: 'user-list',
+      title: '小组成员',
+      lg: 8,
+      md: 24,
+      sm: 24,
+      xs: 24
+    }
+  ];
+
+  // 获取缓存的顺序
+  const cache = (() => {
+    const str = localStorage.getItem(CACHE_KEY);
+    try {
+      return str ? JSON.parse(str) : null;
+    } catch (e) {
+      return null;
+    }
+  })();
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const data = ref<ViewItem[]>([...(cache ?? DEFAULT)]);
+
+  const visible = ref(false);
+
+  const linkCardRef = ref<InstanceType<typeof LinkCard> | null>(null);
+
+  const wrapRef = ref<InstanceType<typeof ARow> | null>(null);
+
+  let sortableIns: SortableJs | null = null;
+
+  // 未添加的数据
+  const notAddedData = computed(() => {
+    return DEFAULT.filter((d) => !data.value.some((t) => t.name === d.name));
+  });
+
+  /* 添加 */
+  const add = () => {
+    visible.value = true;
+  };
+
+  /* 重置布局 */
+  const reset = () => {
+    data.value = [...DEFAULT];
+    cacheData();
+    linkCardRef.value?.reset();
+    message.success('已重置');
+  };
+
+  /* 缓存布局 */
+  const cacheData = () => {
+    localStorage.setItem(CACHE_KEY, JSON.stringify(data.value));
+  };
+
+  /* 删除视图 */
+  const onRemove = (index: number) => {
+    data.value = data.value.filter((_d, i) => i !== index);
+    cacheData();
+  };
+
+  /* 编辑视图 */
+  const onEdit = (index: number) => {
+    console.log('index:', index);
+    message.info('点击了编辑');
+  };
+
+  /* 添加视图 */
+  const addView = (item) => {
+    data.value.push(item);
+    cacheData();
+    message.success('已添加');
+  };
+
+  onMounted(() => {
+    const isTouchDevice = 'ontouchstart' in document.documentElement;
+    if (isTouchDevice) {
+      return;
+    }
+    sortableIns = new SortableJs(wrapRef.value?.$el, {
+      handle: '.ant-card-head',
+      animation: 300,
+      onUpdate: ({ oldIndex, newIndex }) => {
+        if (typeof oldIndex === 'number' && typeof newIndex === 'number') {
+          const temp = [...data.value];
+          temp.splice(newIndex, 0, temp.splice(oldIndex, 1)[0]);
+          data.value = temp;
+          cacheData();
+        }
+      },
+      setData: () => {}
+    });
+  });
+
+  onBeforeUnmount(() => {
+    if (sortableIns) {
+      sortableIns.destroy();
+    }
+  });
+</script>
+
+<script lang="ts">
+  import ActivitiesCard from './components/activities-card.vue';
+  import TaskCard from './components/task-card.vue';
+  import GoalCard from './components/goal-card.vue';
+  import ProjectCard from './components/project-card.vue';
+  import UserList from './components/user-list.vue';
+
+  export default {
+    name: 'DashboardWorkplace',
+    components: {
+      ActivitiesCard,
+      TaskCard,
+      GoalCard,
+      ProjectCard,
+      UserList
+    }
+  };
+</script>
+
+<style lang="less" scoped>
+  .ele-body :deep(.ant-card-head) {
+    cursor: move;
+    position: relative;
+  }
+
+  .ele-body :deep(.ant-row > .ant-col.sortable-chosen > .ant-card) {
+    box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.2);
+  }
+
+  .workplace-bottom-btn {
+    text-align: center;
+    cursor: pointer;
+    transition: background-color 0.2s;
+  }
+
+  .workplace-bottom-btn:hover {
+    background: hsla(0, 0%, 60%, 0.05);
+  }
+
+  /* 添加弹窗 */
+  .workplace-card-item {
+    margin-bottom: 15px;
+    border-width: 1px;
+    border-style: solid;
+    border-radius: 4px;
+    position: relative;
+    cursor: pointer;
+    transition: box-shadow 0.2s, background-color 0.2s;
+  }
+
+  .workplace-card-item:hover {
+    box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.1);
+    background: hsla(0, 0%, 60%, 0.05);
+  }
+
+  .workplace-card-item .workplace-card-header {
+    border-bottom-width: 1px;
+    border-bottom-style: solid;
+    padding: 8px;
+  }
+
+  .workplace-card-body {
+    font-size: 26px;
+    padding: 24px 10px;
+    text-align: center;
+  }
+</style>
diff --git a/src/views/employ/category/components/cate-edit.vue b/src/views/employ/category/components/cate-edit.vue
new file mode 100644
index 0000000..62b96d3
--- /dev/null
+++ b/src/views/employ/category/components/cate-edit.vue
@@ -0,0 +1,141 @@
+<!-- 编辑弹窗 -->
+<template>
+  <ele-modal
+    :width="460"
+    :visible="visible"
+    :confirm-loading="loading"
+    :title="isUpdate ? '修改职位分类' : '添加职位分类'"
+    :body-style="{ paddingBottom: '8px' }"
+    @update:visible="updateVisible"
+    @ok="save"
+  >
+    <a-form
+      ref="formRef"
+      :model="form"
+      :rules="rules"
+      :label-col="styleResponsive ? { md: 5, sm: 5, xs: 24 } : { flex: '90px' }"
+      :wrapper-col="
+        styleResponsive ? { md: 19, sm: 19, xs: 24 } : { flex: '1' }
+      "
+    >
+      <a-form-item label="分类名称" name="name">
+        <a-input
+          allow-clear
+          :maxlength="20"
+          placeholder="请输入分类名称"
+          v-model:value="form.name"
+        />
+      </a-form-item>
+      <a-form-item label="备注">
+        <a-textarea
+          :rows="4"
+          :maxlength="200"
+          placeholder="请输入备注"
+          v-model:value="form.comments"
+        />
+      </a-form-item>
+    </a-form>
+  </ele-modal>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive, watch } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import { Category } from '@/api/employ/category/model';
+  import { addCategory, updateCategory } from '@/api/employ/category';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'done'): void;
+    (e: 'update:visible', visible: boolean): void;
+  }>();
+
+  const props = defineProps<{
+    // 弹窗是否打开
+    visible: boolean;
+    // 修改回显的数据
+    data?: Category | null;
+  }>();
+
+  //
+  const formRef = ref<FormInstance | null>(null);
+
+  // 是否是修改
+  const isUpdate = ref(false);
+
+  // 提交状态
+  const loading = ref(false);
+
+  // 表单数据
+  const { form, resetFields, assignFields } = useFormData<Category>({
+    categoryId: undefined,
+    name: '',
+    comments: ''
+  });
+
+  // 表单验证规则
+  const rules = reactive<Record<string, Rule[]>>({
+    name: [
+      {
+        required: true,
+        message: '请输入分类名称',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ]
+  });
+
+  /* 保存编辑 */
+  const save = () => {
+    if (!formRef.value) {
+      return;
+    }
+    formRef.value
+      .validate()
+      .then(() => {
+        loading.value = true;
+        const saveOrUpdate = isUpdate.value ? updateCategory : addCategory;
+        saveOrUpdate(form)
+          .then((msg) => {
+            loading.value = false;
+            message.success(msg);
+            updateVisible(false);
+            emit('done');
+          })
+          .catch((e) => {
+            loading.value = false;
+            message.error(e.message);
+          });
+      })
+      .catch(() => {});
+  };
+
+  /* 更新visible */
+  const updateVisible = (value: boolean) => {
+    emit('update:visible', value);
+  };
+
+  watch(
+    () => props.visible,
+    (visible) => {
+      if (visible) {
+        if (props.data) {
+          assignFields(props.data);
+          isUpdate.value = true;
+        } else {
+          isUpdate.value = false;
+        }
+      } else {
+        resetFields();
+        formRef.value?.clearValidate();
+      }
+    }
+  );
+</script>
diff --git a/src/views/employ/category/components/cate-search.vue b/src/views/employ/category/components/cate-search.vue
new file mode 100644
index 0000000..5601faa
--- /dev/null
+++ b/src/views/employ/category/components/cate-search.vue
@@ -0,0 +1,90 @@
+<!-- 搜索表单 -->
+<template>
+  <a-form
+    :label-col="
+      styleResponsive ? { xl: 7, lg: 5, md: 7, sm: 4 } : { flex: '90px' }
+    "
+    :wrapper-col="
+      styleResponsive ? { xl: 17, lg: 19, md: 17, sm: 20 } : { flex: '1' }
+    "
+  >
+    <a-row :gutter="8">
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 8, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 8 }
+        "
+      >
+        <a-form-item label="分类名称">
+          <a-input
+            v-model:value.trim="form.name"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 8, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 8 }
+        "
+      >
+        <a-form-item label="备注">
+          <a-input
+            v-model:value.trim="form.comments"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 8, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 8 }
+        "
+      >
+        <a-form-item class="ele-text-right" :wrapper-col="{ span: 24 }">
+          <a-space>
+            <a-button type="primary" @click="search">查询</a-button>
+            <a-button @click="reset">重置</a-button>
+          </a-space>
+        </a-form-item>
+      </a-col>
+    </a-row>
+  </a-form>
+</template>
+
+<script lang="ts" setup>
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import type { CategoryParam } from '@/api/employ/category/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'search', where?: CategoryParam): void;
+  }>();
+
+  // 表单数据
+  const { form, resetFields } = useFormData<CategoryParam>({
+    name: '',
+    comments: ''
+  });
+
+  /* 搜索 */
+  const search = () => {
+    emit('search', form);
+  };
+
+  /*  重置 */
+  const reset = () => {
+    resetFields();
+    search();
+  };
+</script>
diff --git a/src/views/employ/category/index.vue b/src/views/employ/category/index.vue
new file mode 100644
index 0000000..09b0440
--- /dev/null
+++ b/src/views/employ/category/index.vue
@@ -0,0 +1,202 @@
+<template>
+  <div class="ele-body">
+    <a-card :bordered="false">
+      <!-- 搜索表单 -->
+      <cate-search @search="reload" />
+      <!-- 表格 -->
+      <ele-pro-table
+        ref="tableRef"
+        row-key="categoryId"
+        :columns="columns"
+        :datasource="datasource"
+        v-model:selection="selection"
+        :scroll="{ x: 800 }"
+        cache-key="proEmployCategoryTable"
+      >
+        <template #toolbar>
+          <a-space>
+            <a-button type="primary" class="ele-btn-icon" @click="openEdit()">
+              <template #icon>
+                <plus-outlined />
+              </template>
+              <span>新建</span>
+            </a-button>
+            <a-button
+              danger
+              type="primary"
+              class="ele-btn-icon"
+              @click="removeBatch"
+            >
+              <template #icon>
+                <delete-outlined />
+              </template>
+              <span>删除</span>
+            </a-button>
+          </a-space>
+        </template>
+        <template #bodyCell="{ column, record }">
+          <template v-if="column.key === 'action'">
+            <a-space>
+              <a @click="openEdit(record)">修改</a>
+              <a-divider type="vertical" />
+              <a-popconfirm
+                placement="topRight"
+                title="确定要删除此角色吗?"
+                @confirm="remove(record)"
+              >
+                <a class="ele-text-danger">删除</a>
+              </a-popconfirm>
+            </a-space>
+          </template>
+        </template>
+      </ele-pro-table>
+    </a-card>
+    <!-- 编辑弹窗 -->
+    <cate-edit v-model:visible="showEdit" :data="current" @done="reload" />
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { createVNode, ref } from 'vue';
+  import { message, Modal } from 'ant-design-vue/es';
+  import {
+    PlusOutlined,
+    DeleteOutlined,
+    ExclamationCircleOutlined
+  } from '@ant-design/icons-vue';
+  import type { EleProTable } from 'ele-admin-pro/es';
+  import type {
+    DatasourceFunction,
+    ColumnItem
+  } from 'ele-admin-pro/es/ele-pro-table/types';
+  import { messageLoading, toDateString } from 'ele-admin-pro/es';
+  import CateSearch from './components/cate-search.vue';
+  import CateEdit from './components/cate-edit.vue';
+  import type { Category, CategoryParam } from '@/api/employ/category/model';
+  import {
+    pageCategory,
+    removeCategory,
+    removeCategoryBatch
+  } from '@/api/employ/category';
+
+  // 表格实例
+  const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
+
+  // 表格列配置
+  const columns = ref<ColumnItem[]>([
+    {
+      key: 'index',
+      width: 48,
+      align: 'center',
+      fixed: 'left',
+      hideInSetting: true,
+      customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
+    },
+    {
+      title: '分类ID',
+      dataIndex: 'categoryId',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '分类名称',
+      dataIndex: 'name',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '备注',
+      dataIndex: 'comments',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '创建时间',
+      dataIndex: 'createTime',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true,
+      customRender: ({ text }) => toDateString(text)
+    },
+    {
+      title: '操作',
+      key: 'action',
+      width: 200,
+      align: 'center'
+    }
+  ]);
+
+  // 表格选中数据
+  const selection = ref<Category[]>([]);
+
+  // 当前编辑数据
+  const current = ref<Category | null>(null);
+
+  // 是否显示编辑弹窗
+  const showEdit = ref(false);
+
+  // 表格数据源
+  const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
+    return pageCategory({ ...where, ...orders, page, limit });
+  };
+
+  /* 搜索 */
+  const reload = (where?: CategoryParam) => {
+    selection.value = [];
+    tableRef?.value?.reload({ page: 1, where });
+  };
+
+  /* 打开编辑弹窗 */
+  const openEdit = (row?: Category) => {
+    current.value = row ?? null;
+    showEdit.value = true;
+  };
+
+  /* 删除单个 */
+  const remove = (row: Category) => {
+    const hide = messageLoading('请求中..', 0);
+    removeCategory(row.categoryId)
+      .then((msg) => {
+        hide();
+        message.success(msg);
+        reload();
+      })
+      .catch((e) => {
+        hide();
+        message.error(e.message);
+      });
+  };
+
+  /* 批量删除 */
+  const removeBatch = () => {
+    if (!selection.value.length) {
+      message.error('请至少选择一条数据');
+      return;
+    }
+    Modal.confirm({
+      title: '提示',
+      content: '确定要删除选中的角色吗?',
+      icon: createVNode(ExclamationCircleOutlined),
+      maskClosable: true,
+      onOk: () => {
+        const hide = messageLoading('请求中..', 0);
+        removeCategoryBatch(selection.value.map((d) => d.categoryId))
+          .then((msg) => {
+            hide();
+            message.success(msg);
+            reload();
+          })
+          .catch((e) => {
+            hide();
+            message.error(e.message);
+          });
+      }
+    });
+  };
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'EmployCate'
+  };
+</script>
diff --git a/src/views/employ/company/components/company-edit.vue b/src/views/employ/company/components/company-edit.vue
new file mode 100644
index 0000000..155ba08
--- /dev/null
+++ b/src/views/employ/company/components/company-edit.vue
@@ -0,0 +1,420 @@
+<!-- 用户编辑弹窗 -->
+<template>
+  <ele-modal
+    :width="680"
+    :visible="visible"
+    :confirm-loading="loading"
+    :title="isUpdate ? '修改企业' : '新建企业'"
+    :body-style="{ paddingBottom: '8px' }"
+    @update:visible="updateVisible"
+    @ok="save"
+  >
+    <a-form
+      ref="formRef"
+      :model="form"
+      :rules="rules"
+      :label-col="styleResponsive ? { md: 3, sm: 4, xs: 24 } : { flex: '80px' }"
+      :wrapper-col="
+        styleResponsive ? { md: 21, sm: 20, xs: 24 } : { flex: '1' }
+      "
+    >
+      <a-row>
+        <a-col :span="24">
+          <a-form-item label="企业名称" name="name">
+            <a-input
+              allow-clear
+              placeholder="请输入企业名称"
+              v-model:value="form.name"
+            />
+          </a-form-item>
+        </a-col>
+      </a-row>
+
+      <a-row>
+        <a-col :span="24">
+          <a-form-item label="企业地址" name="address">
+            <a-input
+              allow-clear
+              placeholder="请输入企业地址"
+              v-model:value="form.address"
+            />
+          </a-form-item>
+        </a-col>
+      </a-row>
+
+      <a-row :gutttr="24">
+        <a-col
+          v-bind="styleResponsive ? { md: 12, sm: 24, xs: 24 } : { span: 12 }"
+        >
+          <a-form-item :label-col="{ span: 6 }" label="联系人" name="hr">
+            <a-input
+              allow-clear
+              placeholder="请输入企业联系人"
+              v-model:value="form.hr"
+            />
+          </a-form-item>
+          <a-form-item :label-col="{ span: 6 }" label="联系电话" name="phone">
+            <a-input
+              allow-clear
+              placeholder="请输入联系电话"
+              v-model:value="form.phone"
+            />
+          </a-form-item>
+          <a-form-item :label-col="{ span: 6 }" label="联系邮箱" name="email">
+            <a-input
+              allow-clear
+              placeholder="请输入联系邮箱"
+              v-model:value="form.email"
+            />
+          </a-form-item>
+        </a-col>
+        <a-col :span="12">
+          <a-form-item :label-col="{ span: 8 }" label="企业logo" name="logo">
+            <ele-image-upload
+              v-model:value="images"
+              :limit="1"
+              :before-upload="onBeforeUpload"
+              :remove-handler="removeHandler"
+              :item-style="{ width: '152px', height: '90px' }"
+              :button-style="{ width: '152px', height: '90px' }"
+              @upload="onUpload"
+            />
+          </a-form-item>
+        </a-col>
+      </a-row>
+      <a-row :gutttr="24">
+        <a-col :span="12">
+          <a-form-item
+            :label-col="{ span: 6 }"
+            label="企业位置"
+            name="location"
+          >
+            <a-input
+              allow-clear
+              placeholder="请输入地图"
+              v-model:value="result.lngAndLat"
+            />
+          </a-form-item>
+        </a-col>
+        <a-col :span="12">
+          <a-button @click="showMap">选择地址</a-button>
+        </a-col>
+      </a-row>
+      <a-row>
+        <a-col :span="24">
+          <a-form-item
+            label="文章内容"
+            :label-col="
+              styleResponsive ? { md: 3, sm: 4, xs: 24 } : { flex: '90px' }
+            "
+            :wrapper-col="
+              styleResponsive ? { md: 21, sm: 20, xs: 24 } : { flex: '1' }
+            "
+          >
+            <tinymce-editor v-model:value="form.comment" :init="config" />
+          </a-form-item>
+        </a-col>
+      </a-row>
+      <ele-map-picker
+        :need-city="true"
+        :dark-mode="darkMode"
+        v-model:visible="mapVisible"
+        @done="onChoose"
+      />
+    </a-form>
+  </ele-modal>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive, watch } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import { emailReg, phoneReg } from 'ele-admin-pro/es';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import TinymceEditor from '@/components/TinymceEditor/index.vue';
+  import type { CenterPoint } from 'ele-admin-pro/es/ele-map-picker/types';
+  import { Company } from '@/api/employ/company/model';
+  import request from '@/utils/request';
+  import {
+    addCompany,
+    checkExistence,
+    updateCompany
+  } from '@/api/employ/company';
+  import type {
+    BeforeUploadType,
+    ItemType
+  } from 'ele-admin-pro/es/ele-image-upload/types';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'done'): void;
+    (e: 'update:visible', visible: boolean): void;
+  }>();
+
+  const props = defineProps<{
+    // 弹窗是否打开
+    visible: boolean;
+    // 修改回显的数据
+    data?: Company | null;
+  }>();
+  const mapVisible = ref(false);
+  const { darkMode } = storeToRefs(themeStore);
+  //
+  const formRef = ref<FormInstance | null>(null);
+
+  // 是否是修改
+  const isUpdate = ref(false);
+
+  // 提交状态
+  const loading = ref(false);
+  // 选择结果
+  const result = reactive({
+    location: '',
+    address: '',
+    lngAndLat: ''
+  });
+  const images = ref<any>([]);
+  const img_obj = reactive<any>({
+    url: ''
+  });
+  // 表单数据
+  const { form, resetFields, assignFields } = useFormData<Company>({
+    companyId: undefined,
+    name: '',
+    address: '',
+    hr: '',
+    email: '',
+    phone: '',
+    location: '',
+    comment: ''
+  });
+
+  // 表单验证规则
+  const rules = reactive<Record<string, Rule[]>>({
+    name: [
+      {
+        required: true,
+        type: 'string',
+        validator: (_rule: Rule, value: string) => {
+          return new Promise<void>((resolve, reject) => {
+            if (!value) {
+              return reject('请输入企业名称');
+            }
+            checkExistence('name', value, props.data?.companyId)
+              .then(() => {
+                reject('该企业已经存在');
+              })
+              .catch(() => {
+                resolve();
+              });
+          });
+        },
+        trigger: 'blur'
+      }
+    ],
+    email: [
+      {
+        pattern: emailReg,
+        message: '邮箱格式不正确',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    phone: [
+      {
+        pattern: phoneReg,
+        message: '手机号格式不正确',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ]
+  });
+  const showMap = () => {
+    mapVisible.value = true;
+  };
+  const onUpload = (d: ItemType) => {
+    const item = images.value.find((t: any) => t.uid === d.uid) ?? d;
+    // item 包含的字段参考前面说明
+    item.status = 'uploading';
+    const formData = new FormData();
+    formData.append('file', item.file);
+    request({
+      url: '/file/upload',
+      method: 'post',
+      data: formData,
+      onUploadProgress: (e: any) => {
+        // 文件上传进度回调
+        if (e.lengthComputable) {
+          item.progress = (e.loaded / e.total) * 100;
+        }
+      }
+    })
+      .then((res) => {
+        if (res.data.code === 0) {
+          item.status = 'done';
+          item.url = res.data.data;
+          form.logo = res.data.data;
+        }
+      })
+      .catch((e: Error) => {
+        message.warning(e.message);
+        item.status = 'exception';
+      });
+  };
+  const onBeforeUpload: BeforeUploadType = (file: File) => {
+    // file 即选择后的文件
+    if (!file.type.startsWith('image')) {
+      message.error('只能选择图片');
+      return false;
+    }
+    if (file.size / 1024 / 1024 > 2) {
+      message.error('大小不能超过 2MB');
+      return false;
+    }
+  };
+  const removeHandler = (item) => {
+    images.value.forEach((d: any) => {
+      if (d.uid === item.uid) {
+        images.value = [];
+        form.logo = '';
+        d.deleted = 1;
+      }
+    });
+  };
+  //编辑器配置
+  const config = ref({
+    height: 300,
+    external_plugins: {
+      editor135: 'http://cdn.cqtlcm.com/plugin/plugin.js'
+    },
+    menubar: false,
+    toolbar:
+      'removeformat forecolor backcolor bold italic underline strikethrough | alignleft aligncenter alignright alignjustify outdent indent | formatselect fontselect editor135',
+    plugins:
+      'editor135 media image link table code preview fullscreen wordcount',
+    automatic_uploads: true,
+    paste_data_images: true, // 设置为允许粘帖图片
+    images_upload_handler: (blobInfo, success, error) => {
+      const file = blobInfo.blob();
+      // 使用 axios 上传,实际开发这段建议写在 api 中再调用 api
+      const formData = new FormData();
+      formData.append('file', file, file.name);
+      request({
+        url: '/file/upload',
+        method: 'post',
+        data: formData
+      })
+        .then((res) => {
+          if (res.data.data) {
+            success(res.data.data);
+          } else {
+            error(res.data.message);
+          }
+        })
+        .catch((e) => {
+          error(e.message);
+        });
+    },
+    file_picker_callback: (callback, value, meta) => {
+      const input = document.createElement('input');
+      input.setAttribute('type', 'file');
+      // 设定文件可选类型
+      if (meta.filetype === 'image') {
+        input.setAttribute('accept', 'image/*');
+      } else if (meta.filetype === 'media') {
+        input.setAttribute('accept', 'video/*');
+        //input.setAttribute('accept', 'audio/*');
+      }
+      input.onchange = () => {
+        const file = input.files[0];
+        // 使用 axios 上传,实际开发这段建议写在 api 中再调用 api
+        const formData = new FormData();
+        formData.append('file', file, file.name);
+        request({
+          url: '/file/upload',
+          method: 'post',
+          data: formData
+        })
+          .then((res) => {
+            if (res.data.data) {
+              callback(res.data.data);
+            } else {
+              message.error(res.data.message);
+            }
+          })
+          .catch((e) => {
+            message.error(e.message);
+          });
+      };
+      input.click();
+    }
+  });
+  /* 地图选择后回调 */
+  const onChoose = (location: CenterPoint) => {
+    result.location = `${location.city?.province}/${location.city?.city}/${location.city?.district}`;
+    result.address = `${location.name} ${location.address}`;
+    result.lngAndLat = `${location.lng},${location.lat}`;
+    mapVisible.value = false;
+  };
+  /* 保存编辑 */
+  const save = () => {
+    if (!formRef.value) {
+      return;
+    }
+    formRef.value
+      .validate()
+      .then(() => {
+        loading.value = true;
+        const saveOrUpdate = isUpdate.value ? updateCompany : addCompany;
+        form.location = result.lngAndLat;
+        saveOrUpdate(form)
+          .then((msg) => {
+            loading.value = false;
+            message.success(msg);
+            updateVisible(false);
+            emit('done');
+          })
+          .catch((e) => {
+            loading.value = false;
+            message.error(e.message);
+          });
+      })
+      .catch(() => {});
+  };
+
+  /* 更新visible */
+  const updateVisible = (value: boolean) => {
+    emit('update:visible', value);
+  };
+
+  watch(
+    () => props.visible,
+    (visible) => {
+      if (visible) {
+        if (props.data) {
+          assignFields(props.data);
+          if (props.data.location) {
+            result.lngAndLat = props.data.location;
+          }
+          if (props.data.logo != null && props.data.logo != '') {
+            img_obj.url = props.data.logo;
+            images.value.push(img_obj);
+          }
+          isUpdate.value = true;
+        } else {
+          isUpdate.value = false;
+        }
+      } else {
+        resetFields();
+        images.value = [];
+        result.lngAndLat = '';
+        formRef.value?.clearValidate();
+      }
+    }
+  );
+</script>
diff --git a/src/views/employ/company/components/company-search.vue b/src/views/employ/company/components/company-search.vue
new file mode 100644
index 0000000..a90b528
--- /dev/null
+++ b/src/views/employ/company/components/company-search.vue
@@ -0,0 +1,115 @@
+<!-- 搜索表单 -->
+<template>
+  <a-form
+    :label-col="
+      styleResponsive ? { xl: 7, lg: 5, md: 7, sm: 4 } : { flex: '90px' }
+    "
+    :wrapper-col="
+      styleResponsive ? { xl: 17, lg: 19, md: 17, sm: 20 } : { flex: '1' }
+    "
+  >
+    <a-row :gutter="8">
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="企业名称">
+          <a-input
+            v-model:value.trim="form.name"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="企业地址">
+          <a-input
+            v-model:value.trim="form.address"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="状态">
+          <a-select
+            v-model:value="form.status"
+            placeholder="请选择"
+            allow-clear
+          >
+            <a-select-option value="0">正常</a-select-option>
+            <a-select-option value="1">禁用</a-select-option>
+          </a-select>
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item class="ele-text-right" :wrapper-col="{ span: 24 }">
+          <a-space>
+            <a-button type="primary" @click="search">查询</a-button>
+            <a-button @click="reset">重置</a-button>
+          </a-space>
+        </a-form-item>
+      </a-col>
+    </a-row>
+  </a-form>
+</template>
+
+<script lang="ts" setup>
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import type { CompanyParam } from '@/api/employ/company/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const props = defineProps<{
+    // 默认搜索条件
+    where?: CompanyParam;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'search', where?: CompanyParam): void;
+  }>();
+
+  // 表单数据
+  const { form, resetFields } = useFormData<CompanyParam>({
+    name: '',
+    address: '',
+    status: undefined,
+    ...props.where
+  });
+
+  /* 搜索 */
+  const search = () => {
+    emit('search', form);
+  };
+
+  /*  重置 */
+  const reset = () => {
+    resetFields();
+    search();
+  };
+</script>
diff --git a/src/views/employ/company/index.vue b/src/views/employ/company/index.vue
new file mode 100644
index 0000000..5eb86b7
--- /dev/null
+++ b/src/views/employ/company/index.vue
@@ -0,0 +1,242 @@
+<template>
+  <div class="ele-body">
+    <a-card :bordered="false">
+      <!-- 搜索表单 -->
+      <company-search :where="defaultWhere" @search="reload" />
+      <!-- 表格 -->
+      <ele-pro-table
+        ref="tableRef"
+        row-key="companyId"
+        :columns="columns"
+        :datasource="datasource"
+        v-model:selection="selection"
+        :scroll="{ x: 1000 }"
+        :where="defaultWhere"
+        cache-key="proEmployCompanyTable"
+      >
+        <template #toolbar>
+          <a-space>
+            <a-button type="primary" class="ele-btn-icon" @click="openEdit()">
+              <template #icon>
+                <plus-outlined />
+              </template>
+              <span>新建</span>
+            </a-button>
+            <a-button
+              danger
+              type="primary"
+              class="ele-btn-icon"
+              @click="removeBatch"
+            >
+              <template #icon>
+                <delete-outlined />
+              </template>
+              <span>删除</span>
+            </a-button>
+          </a-space>
+        </template>
+        <template #bodyCell="{ column, record }">
+          <template v-if="column.key === 'logo'">
+            <a-avatar
+              v-if="record.logo"
+              :size="60"
+              shape="square"
+              :src="record.logo"
+            />
+            <a-avatar v-else :size="60" shape="square" :src="noImage" />
+          </template>
+          <template v-else-if="column.key === 'status'">
+            <a-switch
+              :checked="record.status === 0"
+              @change="(checked: boolean) => editStatus(checked, record)"
+            />
+          </template>
+          <template v-else-if="column.key === 'action'">
+            <a-space>
+              <a @click="openEdit(record)">修改</a>
+              <a-divider type="vertical" />
+              <a-popconfirm
+                placement="topRight"
+                title="确定要删除此公司吗?"
+                @confirm="remove(record)"
+              >
+                <a class="ele-text-danger">删除</a>
+              </a-popconfirm>
+            </a-space>
+          </template>
+        </template>
+      </ele-pro-table>
+    </a-card>
+    <!-- 编辑弹窗 -->
+    <company-edit v-model:visible="showEdit" :data="current" @done="reload" />
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { createVNode, ref, reactive } from 'vue';
+  import { message, Modal } from 'ant-design-vue/es';
+  import {
+    PlusOutlined,
+    DeleteOutlined,
+    ExclamationCircleOutlined
+  } from '@ant-design/icons-vue';
+  import type { EleProTable } from 'ele-admin-pro/es';
+  import type {
+    DatasourceFunction,
+    ColumnItem
+  } from 'ele-admin-pro/es/ele-pro-table/types';
+  import { toDateString, messageLoading } from 'ele-admin-pro/es';
+  import CompanySearch from './components/company-search.vue';
+  import CompanyEdit from './components/company-edit.vue';
+  import type { Company, CompanyParam } from '@/api/employ/company/model';
+  import {
+    pageCompany,
+    removeCompany,
+    removeCompanyBatch,
+    updateCompanyStatus
+  } from '@/api/employ/company';
+  import noImage from '@/assets/noimage.png';
+
+  // 表格实例
+  const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
+
+  // 表格列配置
+  const columns = ref<ColumnItem[]>([
+    {
+      key: 'index',
+      width: 48,
+      align: 'center',
+      fixed: 'left',
+      hideInSetting: true,
+      customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
+    },
+    {
+      title: '企业名称',
+      dataIndex: 'name',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '企业地址',
+      dataIndex: 'address',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '创建时间',
+      dataIndex: 'createTime',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true,
+      customRender: ({ text }) => toDateString(text)
+    },
+    {
+      title: '状态',
+      key: 'status',
+      dataIndex: 'status',
+      sorter: true,
+      showSorterTooltip: false,
+      width: 90,
+      align: 'center'
+    },
+    {
+      title: '操作',
+      key: 'action',
+      width: 200,
+      align: 'center'
+    }
+  ]);
+
+  // 表格选中数据
+  const selection = ref<Company[]>([]);
+
+  // 当前编辑数据
+  const current = ref<Company | null>(null);
+
+  // 是否显示编辑弹窗
+  const showEdit = ref(false);
+
+  // 默认搜索条件
+  const defaultWhere = reactive({
+    name: '',
+    address: ''
+  });
+
+  // 表格数据源
+  const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
+    return pageCompany({ ...where, ...orders, page, limit });
+  };
+
+  /* 搜索 */
+  const reload = (where?: CompanyParam) => {
+    selection.value = [];
+    tableRef?.value?.reload({ page: 1, where });
+  };
+
+  /* 打开编辑弹窗 */
+  const openEdit = (row?: Company) => {
+    current.value = row ?? null;
+    showEdit.value = true;
+  };
+
+  /* 删除单个 */
+  const remove = (row: Company) => {
+    const hide = messageLoading('请求中..', 0);
+    removeCompany(row.companyId)
+      .then((msg) => {
+        hide();
+        message.success(msg);
+        reload();
+      })
+      .catch((e) => {
+        hide();
+        message.error(e.message);
+      });
+  };
+
+  /* 批量删除 */
+  const removeBatch = () => {
+    if (!selection.value.length) {
+      message.error('请至少选择一条数据');
+      return;
+    }
+    Modal.confirm({
+      title: '提示',
+      content: '确定要删除选中的企业吗?',
+      icon: createVNode(ExclamationCircleOutlined),
+      maskClosable: true,
+      onOk: () => {
+        const hide = messageLoading('请求中..', 0);
+        removeCompanyBatch(selection.value.map((d) => d.companyId))
+          .then((msg) => {
+            hide();
+            message.success(msg);
+            reload();
+          })
+          .catch((e) => {
+            hide();
+            message.error(e.message);
+          });
+      }
+    });
+  };
+
+  /* 修改公司状态 */
+  const editStatus = (checked: boolean, row: Company) => {
+    const status = checked ? 0 : 1;
+    updateCompanyStatus(row.companyId, status)
+      .then((msg) => {
+        row.status = status;
+        message.success(msg);
+      })
+      .catch((e) => {
+        message.error(e.message);
+      });
+  };
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'EmployCompany'
+  };
+</script>
diff --git a/src/views/employ/job/components/category-select.vue b/src/views/employ/job/components/category-select.vue
new file mode 100644
index 0000000..d528ce2
--- /dev/null
+++ b/src/views/employ/job/components/category-select.vue
@@ -0,0 +1,66 @@
+<!-- 角色选择下拉框 -->
+<template>
+  <a-select
+    allow-clear
+    :value="cateId"
+    :placeholder="placeholder"
+    @update:value="updateValue"
+    @blur="onBlur"
+  >
+    <a-select-option
+      v-for="item in data"
+      :key="item.categoryId"
+      :value="item.categoryId"
+    >
+      {{ item.name }}
+    </a-select-option>
+  </a-select>
+</template>
+
+<script lang="ts" setup>
+  import { computed, ref } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type { Category } from '@/api/employ/category/model';
+  import { listCategory } from '@/api/employ/category';
+
+  const emit = defineEmits<{
+    (e: 'update:value', value: Category[]): void;
+    (e: 'blur'): void;
+  }>();
+
+  const props = withDefaults(
+    defineProps<{
+      // 选中的企业
+      value?: Category[];
+      //
+      placeholder?: string;
+    }>(),
+    {
+      placeholder: '请选择岗位类型'
+    }
+  );
+
+  // 数据
+  const data = ref<Category[]>([]);
+  const cateId = computed(() =>
+    props.value?.map((d) => d.categoryId as number)
+  );
+  /* 更新选中数据 */
+  const updateValue = (value: number) => {
+    emit('update:value', [{ categoryId: value }]);
+  };
+
+  /* 获取企业数据 */
+  listCategory()
+    .then((list) => {
+      data.value = list;
+    })
+    .catch((e) => {
+      message.error(e.message);
+    });
+
+  /* 失去焦点 */
+  const onBlur = () => {
+    emit('blur');
+  };
+</script>
diff --git a/src/views/employ/job/components/company-select.vue b/src/views/employ/job/components/company-select.vue
new file mode 100644
index 0000000..edc2148
--- /dev/null
+++ b/src/views/employ/job/components/company-select.vue
@@ -0,0 +1,67 @@
+<!-- 角色选择下拉框 -->
+<template>
+  <a-select
+    allow-clear
+    :value="company"
+    :placeholder="placeholder"
+    @update:value="updateValue"
+    @blur="onBlur"
+  >
+    <a-select-option
+      v-for="item in data"
+      :key="item.companyId"
+      :value="item.companyId"
+    >
+      {{ item.name }}
+    </a-select-option>
+  </a-select>
+</template>
+
+<script lang="ts" setup>
+  import { computed, ref } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type { Company } from '@/api/employ/company/model';
+  import { listCompany } from '@/api/employ/company';
+
+  const emit = defineEmits<{
+    (e: 'update:value', value: Company[]): void;
+    (e: 'blur'): void;
+  }>();
+
+  const props = withDefaults(
+    defineProps<{
+      // 选中的企业
+      value?: Company[];
+      //
+      placeholder?: string;
+    }>(),
+    {
+      placeholder: '请选择企业'
+    }
+  );
+
+  // 企业数据
+  const data = ref<Company[]>([]);
+
+  const company = computed(() =>
+    props.value?.map((d) => d.companyId as number)
+  );
+  /* 更新选中数据 */
+  const updateValue = (value: number) => {
+    emit('update:value', [{ companyId: value }]);
+  };
+
+  /* 获取企业数据 */
+  listCompany()
+    .then((list) => {
+      data.value = list;
+    })
+    .catch((e) => {
+      message.error(e.message);
+    });
+
+  /* 失去焦点 */
+  const onBlur = () => {
+    emit('blur');
+  };
+</script>
diff --git a/src/views/employ/job/components/job-edit.vue b/src/views/employ/job/components/job-edit.vue
new file mode 100644
index 0000000..bbbc097
--- /dev/null
+++ b/src/views/employ/job/components/job-edit.vue
@@ -0,0 +1,305 @@
+<!-- 用户编辑弹窗 -->
+<template>
+  <ele-modal
+    :width="780"
+    :visible="visible"
+    :confirm-loading="loading"
+    :title="isUpdate ? '修改职位' : '新建职位'"
+    :body-style="{ paddingBottom: '8px' }"
+    @update:visible="updateVisible"
+    @ok="save"
+  >
+    <a-form
+      ref="formRef"
+      :model="form"
+      :rules="rules"
+      :label-col="styleResponsive ? { md: 3, sm: 4, xs: 24 } : { flex: '90px' }"
+      :wrapper-col="
+        styleResponsive ? { md: 21, sm: 20, xs: 24 } : { flex: '1' }
+      "
+    >
+      <a-row>
+        <a-col :span="24">
+          <a-form-item label="岗位名称" name="title">
+            <a-input
+              allow-clear
+              placeholder="请输入岗位名称"
+              v-model:value="form.title"
+            />
+          </a-form-item>
+        </a-col>
+      </a-row>
+      <a-row :gutter="24">
+        <a-col :span="12">
+          <a-form-item :label-col="{ span: 6 }" label="所属企业" name="company">
+            <company-select v-model:value="company" />
+          </a-form-item>
+        </a-col>
+        <a-col :span="12">
+          <a-form-item
+            :label-col="{ span: 6 }"
+            label="所属分类"
+            name="category"
+          >
+            <cate-select v-model:value="category" />
+          </a-form-item>
+        </a-col>
+      </a-row>
+      <a-row :gutter="24">
+        <a-col :span="12">
+          <a-form-item :label-col="{ span: 6 }" label="招聘人数" name="needs">
+            <a-input
+              allow-clear
+              placeholder="请输入招聘人数"
+              v-model:value="form.needs"
+            />
+          </a-form-item>
+        </a-col>
+        <a-col :span="12">
+          <a-form-item :label-col="{ span: 6 }" label="学历要求" name="degree">
+            <a-input
+              allow-clear
+              placeholder="请输入学历要求"
+              v-model:value="form.degree"
+            />
+          </a-form-item>
+        </a-col>
+      </a-row>
+      <a-row :gutter="24">
+        <a-col :span="12">
+          <a-form-item
+            :label-col="{ span: 6 }"
+            label="工作经历"
+            name="seniority"
+          >
+            <a-input
+              allow-clear
+              placeholder="请输入工作经历"
+              v-model:value="form.seniority"
+            />
+          </a-form-item>
+        </a-col>
+        <a-col :span="12">
+          <a-form-item :label-col="{ span: 6 }" label="工资待遇" name="salary">
+            <a-input
+              allow-clear
+              placeholder="请输入工作经历"
+              v-model:value="form.salary"
+            />
+          </a-form-item>
+        </a-col>
+      </a-row>
+      <a-row>
+        <a-col :span="24">
+          <a-form-item label="岗位福利" name="tags">
+            <tag-select v-model:value="form.tags" />
+          </a-form-item>
+        </a-col>
+      </a-row>
+      <a-row>
+        <a-col :span="24">
+          <a-form-item label="岗位介绍" name="description">
+            <tinymce-editor v-model:value="form.description" :init="config" />
+          </a-form-item>
+        </a-col>
+      </a-row>
+    </a-form>
+  </ele-modal>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive, watch } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import CompanySelect from './company-select.vue';
+  import CateSelect from './category-select.vue';
+  import TagSelect from './tag-select.vue';
+  import { addJob, updateJob } from '@/api/employ/jobs';
+  import { Job } from '@/api/employ/jobs/model';
+  import request from '@/utils/request';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'done'): void;
+    (e: 'update:visible', visible: boolean): void;
+  }>();
+
+  const props = defineProps<{
+    // 弹窗是否打开
+    visible: boolean;
+    // 修改回显的数据
+    data?: Job | null;
+  }>();
+
+  //
+  const formRef = ref<FormInstance | null>(null);
+
+  // 是否是修改
+  const isUpdate = ref(false);
+
+  // 提交状态
+  const loading = ref(false);
+
+  // 表单数据
+  const { form, resetFields, assignFields } = useFormData<Job>({
+    jobId: undefined,
+    title: '',
+    companyId: '',
+    categoryId: undefined,
+    needs: '',
+    degree: '',
+    seniority: '',
+    salary: '',
+    tags: [],
+    description: ''
+  });
+
+  const category = ref();
+  const company = ref();
+  // 表单验证规则
+  const rules = reactive<Record<string, Rule[]>>({
+    title: [
+      {
+        required: true,
+        type: 'string',
+        message: '请输入岗位名称',
+        trigger: 'blur'
+      }
+    ],
+    description: [
+      {
+        required: true,
+        message: '请输入岗位描述',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ]
+  });
+  const config = ref({
+    height: 300,
+    external_plugins: {
+      editor135: 'http://cdn.cqtlcm.com/plugin/plugin.js'
+    },
+    toolbar:
+      'removeformat forecolor backcolor bold italic underline strikethrough | alignleft aligncenter alignright alignjustify outdent indent | formatselect fontselect editor135',
+    plugins:
+      'editor135 media image link table code preview fullscreen wordcount',
+    automatic_uploads: true,
+    paste_data_images: true, // 设置为允许粘帖图片
+    images_upload_handler: (blobInfo, success, error) => {
+      const file = blobInfo.blob();
+      // 使用 axios 上传,实际开发这段建议写在 api 中再调用 api
+      const formData = new FormData();
+      formData.append('file', file, file.name);
+      request({
+        url: '/file/upload',
+        method: 'post',
+        data: formData
+      })
+        .then((res) => {
+          if (res.data.data) {
+            success(res.data.data);
+          } else {
+            error(res.data.message);
+          }
+        })
+        .catch((e) => {
+          error(e.message);
+        });
+    },
+    file_picker_callback: (callback, value, meta) => {
+      const input = document.createElement('input');
+      input.setAttribute('type', 'file');
+      // 设定文件可选类型
+      if (meta.filetype === 'image') {
+        input.setAttribute('accept', 'image/*');
+      } else if (meta.filetype === 'media') {
+        input.setAttribute('accept', 'video/*');
+        //input.setAttribute('accept', 'audio/*');
+      }
+      input.onchange = () => {
+        const file = input.files[0];
+        // 使用 axios 上传,实际开发这段建议写在 api 中再调用 api
+        const formData = new FormData();
+        formData.append('file', file, file.name);
+        request({
+          url: '/file/upload',
+          method: 'post',
+          data: formData
+        })
+          .then((res) => {
+            if (res.data.data) {
+              callback(res.data.data);
+            } else {
+              message.error(res.data.message);
+            }
+          })
+          .catch((e) => {
+            message.error(e.message);
+          });
+      };
+      input.click();
+    }
+  });
+  /* 保存编辑 */
+  const save = () => {
+    if (!formRef.value) {
+      return;
+    }
+    formRef.value
+      .validate()
+      .then(() => {
+        loading.value = true;
+        const saveOrUpdate = isUpdate.value ? updateJob : addJob;
+        assignFields({
+          ...form,
+          companyId: company.value[0].companyId,
+          categoryId: category.value[0].categoryId
+        });
+        saveOrUpdate(form)
+          .then((msg) => {
+            loading.value = false;
+            message.success(msg);
+            updateVisible(false);
+            emit('done');
+          })
+          .catch((e) => {
+            loading.value = false;
+            message.error(e.message);
+          });
+      })
+      .catch(() => {});
+  };
+
+  /* 更新visible */
+  const updateVisible = (value: boolean) => {
+    emit('update:visible', value);
+    category.value = [];
+    company.value = [];
+  };
+
+  watch(
+    () => props.visible,
+    (visible) => {
+      if (visible) {
+        if (props.data) {
+          company.value = props.data.company;
+          category.value = props.data.category;
+          assignFields(props.data);
+          isUpdate.value = true;
+        } else {
+          isUpdate.value = false;
+        }
+      } else {
+        resetFields();
+        formRef.value?.clearValidate();
+      }
+    }
+  );
+</script>
diff --git a/src/views/employ/job/components/job-search.vue b/src/views/employ/job/components/job-search.vue
new file mode 100644
index 0000000..71a4577
--- /dev/null
+++ b/src/views/employ/job/components/job-search.vue
@@ -0,0 +1,115 @@
+<!-- 搜索表单 -->
+<template>
+  <a-form
+    :label-col="
+      styleResponsive ? { xl: 7, lg: 5, md: 7, sm: 4 } : { flex: '90px' }
+    "
+    :wrapper-col="
+      styleResponsive ? { xl: 17, lg: 19, md: 17, sm: 20 } : { flex: '1' }
+    "
+  >
+    <a-row :gutter="8">
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="职位名称">
+          <a-input
+            v-model:value.trim="form.title"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="所属公司">
+          <a-input
+            v-model:value.trim="form.company"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="状态">
+          <a-select
+            v-model:value="form.status"
+            placeholder="请选择"
+            allow-clear
+          >
+            <a-select-option value="0">正常</a-select-option>
+            <a-select-option value="1">禁用</a-select-option>
+          </a-select>
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item class="ele-text-right" :wrapper-col="{ span: 24 }">
+          <a-space>
+            <a-button type="primary" @click="search">查询</a-button>
+            <a-button @click="reset">重置</a-button>
+          </a-space>
+        </a-form-item>
+      </a-col>
+    </a-row>
+  </a-form>
+</template>
+
+<script lang="ts" setup>
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import type { JobParam } from '@/api/employ/jobs/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const props = defineProps<{
+    // 默认搜索条件
+    where?: JobParam;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'search', where?: JobParam): void;
+  }>();
+
+  // 表单数据
+  const { form, resetFields } = useFormData<JobParam>({
+    title: '',
+    company: '',
+    status: '',
+    ...props.where
+  });
+
+  /* 搜索 */
+  const search = () => {
+    emit('search', form);
+  };
+
+  /*  重置 */
+  const reset = () => {
+    resetFields();
+    search();
+  };
+</script>
diff --git a/src/views/employ/job/components/tag-select.vue b/src/views/employ/job/components/tag-select.vue
new file mode 100644
index 0000000..20b14a0
--- /dev/null
+++ b/src/views/employ/job/components/tag-select.vue
@@ -0,0 +1,67 @@
+<!-- 选择下拉框 -->
+<template>
+  <a-select
+    allow-clear
+    mode="multiple"
+    :value="tagIds"
+    :placeholder="placeholder"
+    @update:value="updateValue"
+    @blur="onBlur"
+  >
+    <a-select-option v-for="item in data" :key="item.tagId" :value="item.tagId">
+      {{ item.name }}
+    </a-select-option>
+  </a-select>
+</template>
+
+<script lang="ts" setup>
+  import { ref, computed } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import { listTags } from '@/api/employ/tags';
+  import { Tag } from '@/api/employ/tags/model';
+
+  const emit = defineEmits<{
+    (e: 'update:value', value: Tag[]): void;
+    (e: 'blur'): void;
+  }>();
+
+  const props = withDefaults(
+    defineProps<{
+      // 选中的标签
+      value?: Tag[];
+      //
+      placeholder?: string;
+    }>(),
+    {
+      placeholder: '请选择岗位福利'
+    }
+  );
+
+  // 选中的id
+  const tagIds = computed(() => props.value?.map((d) => d.tagId as number));
+
+  //数据
+  const data = ref<Tag[]>([]);
+
+  /* 更新选中数据 */
+  const updateValue = (value: number[]) => {
+    emit(
+      'update:value',
+      value.map((v) => ({ tagId: v }))
+    );
+  };
+
+  /* 获取数据 */
+  listTags()
+    .then((list) => {
+      data.value = list;
+    })
+    .catch((e) => {
+      message.error(e.message);
+    });
+
+  /* 失去焦点 */
+  const onBlur = () => {
+    emit('blur');
+  };
+</script>
diff --git a/src/views/employ/job/index.vue b/src/views/employ/job/index.vue
new file mode 100644
index 0000000..27c8ed1
--- /dev/null
+++ b/src/views/employ/job/index.vue
@@ -0,0 +1,256 @@
+<template>
+  <div class="ele-body">
+    <a-card :bordered="false">
+      <!-- 搜索表单 -->
+      <job-search :where="defaultWhere" @search="reload" />
+      <!-- 表格 -->
+      <ele-pro-table
+        ref="tableRef"
+        row-key="jobId"
+        :columns="columns"
+        :datasource="datasource"
+        v-model:selection="selection"
+        :scroll="{ x: 1000 }"
+        :where="defaultWhere"
+        cache-key="proEmployJobTable"
+      >
+        <template #toolbar>
+          <a-space>
+            <a-button type="primary" class="ele-btn-icon" @click="openEdit()">
+              <template #icon>
+                <plus-outlined />
+              </template>
+              <span>新建</span>
+            </a-button>
+            <a-button
+              danger
+              type="primary"
+              class="ele-btn-icon"
+              @click="removeBatch"
+            >
+              <template #icon>
+                <delete-outlined />
+              </template>
+              <span>删除</span>
+            </a-button>
+          </a-space>
+        </template>
+        <template #bodyCell="{ column, record }">
+          <template v-if="column.key === 'company'">
+            {{ record.company[0].name }}
+          </template>
+          <template v-else-if="column.key === 'category'">
+            {{ record.category[0].name }}
+          </template>
+          <template v-else-if="column.key === 'tags'">
+            <a-tag v-for="item in record.tags" :key="item.tagId" color="blue">
+              {{ item.name }}
+            </a-tag>
+          </template>
+          <template v-else-if="column.key === 'status'">
+            <a-switch
+              :checked="record.status === 0"
+              @change="(checked: boolean) => editStatus(checked, record)"
+            />
+          </template>
+          <template v-else-if="column.key === 'action'">
+            <a-space>
+              <a @click="openEdit(record)">修改</a>
+              <a-divider type="vertical" />
+              <a-popconfirm
+                placement="topRight"
+                title="确定要删除此岗位吗?"
+                @confirm="remove(record)"
+              >
+                <a class="ele-text-danger">删除</a>
+              </a-popconfirm>
+            </a-space>
+          </template>
+        </template>
+      </ele-pro-table>
+    </a-card>
+    <!-- 编辑弹窗 -->
+    <job-edit v-model:visible="showEdit" :data="current" @done="reload" />
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { createVNode, ref, reactive } from 'vue';
+  import { message, Modal } from 'ant-design-vue/es';
+  import {
+    PlusOutlined,
+    DeleteOutlined,
+    ExclamationCircleOutlined
+  } from '@ant-design/icons-vue';
+  import type { EleProTable } from 'ele-admin-pro/es';
+  import type {
+    DatasourceFunction,
+    ColumnItem
+  } from 'ele-admin-pro/es/ele-pro-table/types';
+  import {
+    pageJobs,
+    removeJob,
+    removeJobBatch,
+    updateJobStatus
+  } from '@/api/employ/jobs';
+  import { toDateString, messageLoading } from 'ele-admin-pro/es';
+  import JobSearch from './components/job-search.vue';
+  import JobEdit from './components/job-edit.vue';
+  import { Job, JobParam } from '@/api/employ/jobs/model';
+
+  // 表格实例
+  const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
+
+  // 表格列配置
+  const columns = ref<ColumnItem[]>([
+    {
+      key: 'index',
+      width: 48,
+      align: 'center',
+      fixed: 'left',
+      hideInSetting: true,
+      customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
+    },
+    {
+      title: '职位名称',
+      dataIndex: 'title',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '职位分类',
+      key: 'category',
+      dataIndex: 'category',
+      showSorterTooltip: false
+    },
+    {
+      title: '所属企业',
+      key: 'company',
+      dataIndex: 'company',
+      showSorterTooltip: false
+    },
+    {
+      title: '福利待遇',
+      key: 'tags',
+      dataIndex: 'tags',
+      showSorterTooltip: false
+    },
+    {
+      title: '创建时间',
+      dataIndex: 'createTime',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true,
+      customRender: ({ text }) => toDateString(text)
+    },
+    {
+      title: '状态',
+      key: 'status',
+      dataIndex: 'status',
+      sorter: true,
+      showSorterTooltip: false,
+      width: 90,
+      align: 'center'
+    },
+    {
+      title: '操作',
+      key: 'action',
+      width: 200,
+      align: 'center'
+    }
+  ]);
+
+  // 表格选中数据
+  const selection = ref<Job[]>([]);
+
+  // 当前编辑数据
+  const current = ref<Job | null>(null);
+
+  // 是否显示编辑弹窗
+  const showEdit = ref(false);
+
+  // 默认搜索条件
+  const defaultWhere = reactive({
+    title: '',
+    company: '',
+    status: ''
+  });
+
+  // 表格数据源
+  const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
+    return pageJobs({ ...where, ...orders, page, limit });
+  };
+
+  /* 搜索 */
+  const reload = (where?: JobParam) => {
+    selection.value = [];
+    tableRef?.value?.reload({ page: 1, where });
+  };
+
+  /* 打开编辑弹窗 */
+  const openEdit = (row?: Job) => {
+    current.value = row ?? null;
+    showEdit.value = true;
+  };
+
+  /* 删除单个 */
+  const remove = (row: Job) => {
+    const hide = messageLoading('请求中..', 0);
+    removeJob(row.jobId)
+      .then((msg) => {
+        hide();
+        message.success(msg);
+        reload();
+      })
+      .catch((e) => {
+        hide();
+        message.error(e.message);
+      });
+  };
+
+  /* 批量删除 */
+  const removeBatch = () => {
+    if (!selection.value.length) {
+      message.error('请至少选择一条数据');
+      return;
+    }
+    Modal.confirm({
+      title: '提示',
+      content: '确定要删除选中的岗位吗?',
+      icon: createVNode(ExclamationCircleOutlined),
+      maskClosable: true,
+      onOk: () => {
+        const hide = messageLoading('请求中..', 0);
+        removeJobBatch(selection.value.map((d) => d.jobId))
+          .then((msg) => {
+            hide();
+            message.success(msg);
+            reload();
+          })
+          .catch((e) => {
+            hide();
+            message.error(e.message);
+          });
+      }
+    });
+  };
+
+  /* 修改状态 */
+  const editStatus = (checked: boolean, row: Job) => {
+    const status = checked ? 0 : 1;
+    updateJobStatus(row.jobId, status)
+      .then((msg) => {
+        row.status = status;
+        message.success(msg);
+      })
+      .catch((e) => {
+        message.error(e.message);
+      });
+  };
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'EmployJob'
+  };
+</script>
diff --git a/src/views/employ/tag/components/tag-edit.vue b/src/views/employ/tag/components/tag-edit.vue
new file mode 100644
index 0000000..511a33b
--- /dev/null
+++ b/src/views/employ/tag/components/tag-edit.vue
@@ -0,0 +1,141 @@
+<!-- 编辑弹窗 -->
+<template>
+  <ele-modal
+    :width="460"
+    :visible="visible"
+    :confirm-loading="loading"
+    :title="isUpdate ? '修改福利标签' : '添加福利标签'"
+    :body-style="{ paddingBottom: '8px' }"
+    @update:visible="updateVisible"
+    @ok="save"
+  >
+    <a-form
+      ref="formRef"
+      :model="form"
+      :rules="rules"
+      :label-col="styleResponsive ? { md: 5, sm: 5, xs: 24 } : { flex: '90px' }"
+      :wrapper-col="
+        styleResponsive ? { md: 19, sm: 19, xs: 24 } : { flex: '1' }
+      "
+    >
+      <a-form-item label="标签名称" name="name">
+        <a-input
+          allow-clear
+          :maxlength="20"
+          placeholder="请输入标签名称"
+          v-model:value="form.name"
+        />
+      </a-form-item>
+      <a-form-item label="备注">
+        <a-textarea
+          :rows="4"
+          :maxlength="200"
+          placeholder="请输入备注"
+          v-model:value="form.comments"
+        />
+      </a-form-item>
+    </a-form>
+  </ele-modal>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive, watch } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import { addTag, updateTag } from '@/api/employ/tags';
+  import { Tag } from '@/api/employ/tags/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'done'): void;
+    (e: 'update:visible', visible: boolean): void;
+  }>();
+
+  const props = defineProps<{
+    // 弹窗是否打开
+    visible: boolean;
+    // 修改回显的数据
+    data?: Tag | null;
+  }>();
+
+  //
+  const formRef = ref<FormInstance | null>(null);
+
+  // 是否是修改
+  const isUpdate = ref(false);
+
+  // 提交状态
+  const loading = ref(false);
+
+  // 表单数据
+  const { form, resetFields, assignFields } = useFormData<Tag>({
+    tagId: undefined,
+    name: '',
+    comments: ''
+  });
+
+  // 表单验证规则
+  const rules = reactive<Record<string, Rule[]>>({
+    name: [
+      {
+        required: true,
+        message: '请输入分类名称',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ]
+  });
+
+  /* 保存编辑 */
+  const save = () => {
+    if (!formRef.value) {
+      return;
+    }
+    formRef.value
+      .validate()
+      .then(() => {
+        loading.value = true;
+        const saveOrUpdate = isUpdate.value ? updateTag : addTag;
+        saveOrUpdate(form)
+          .then((msg) => {
+            loading.value = false;
+            message.success(msg);
+            updateVisible(false);
+            emit('done');
+          })
+          .catch((e) => {
+            loading.value = false;
+            message.error(e.message);
+          });
+      })
+      .catch(() => {});
+  };
+
+  /* 更新visible */
+  const updateVisible = (value: boolean) => {
+    emit('update:visible', value);
+  };
+
+  watch(
+    () => props.visible,
+    (visible) => {
+      if (visible) {
+        if (props.data) {
+          assignFields(props.data);
+          isUpdate.value = true;
+        } else {
+          isUpdate.value = false;
+        }
+      } else {
+        resetFields();
+        formRef.value?.clearValidate();
+      }
+    }
+  );
+</script>
diff --git a/src/views/employ/tag/components/tag-search.vue b/src/views/employ/tag/components/tag-search.vue
new file mode 100644
index 0000000..d30f8ec
--- /dev/null
+++ b/src/views/employ/tag/components/tag-search.vue
@@ -0,0 +1,89 @@
+<!-- 搜索表单 -->
+<template>
+  <a-form
+    :label-col="
+      styleResponsive ? { xl: 7, lg: 5, md: 7, sm: 4 } : { flex: '90px' }
+    "
+    :wrapper-col="
+      styleResponsive ? { xl: 17, lg: 19, md: 17, sm: 20 } : { flex: '1' }
+    "
+  >
+    <a-row :gutter="8">
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 8, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 8 }
+        "
+      >
+        <a-form-item label="标签名称">
+          <a-input
+            v-model:value.trim="form.name"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 8, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 8 }
+        "
+      >
+        <a-form-item label="备注">
+          <a-input
+            v-model:value.trim="form.comments"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 8, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 8 }
+        "
+      >
+        <a-form-item class="ele-text-right" :wrapper-col="{ span: 24 }">
+          <a-space>
+            <a-button type="primary" @click="search">查询</a-button>
+            <a-button @click="reset">重置</a-button>
+          </a-space>
+        </a-form-item>
+      </a-col>
+    </a-row>
+  </a-form>
+</template>
+
+<script lang="ts" setup>
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import { TagParam } from '@/api/employ/tags/model';
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'search', where?: TagParam): void;
+  }>();
+
+  // 表单数据
+  const { form, resetFields } = useFormData<TagParam>({
+    name: '',
+    comments: ''
+  });
+
+  /* 搜索 */
+  const search = () => {
+    emit('search', form);
+  };
+
+  /*  重置 */
+  const reset = () => {
+    resetFields();
+    search();
+  };
+</script>
diff --git a/src/views/employ/tag/index.vue b/src/views/employ/tag/index.vue
new file mode 100644
index 0000000..67df81c
--- /dev/null
+++ b/src/views/employ/tag/index.vue
@@ -0,0 +1,198 @@
+<template>
+  <div class="ele-body">
+    <a-card :bordered="false">
+      <!-- 搜索表单 -->
+      <tag-search @search="reload" />
+      <!-- 表格 -->
+      <ele-pro-table
+        ref="tableRef"
+        row-key="tagId"
+        :columns="columns"
+        :datasource="datasource"
+        v-model:selection="selection"
+        :scroll="{ x: 800 }"
+        cache-key="proEmployTagTable"
+      >
+        <template #toolbar>
+          <a-space>
+            <a-button type="primary" class="ele-btn-icon" @click="openEdit()">
+              <template #icon>
+                <plus-outlined />
+              </template>
+              <span>新建</span>
+            </a-button>
+            <a-button
+              danger
+              type="primary"
+              class="ele-btn-icon"
+              @click="removeBatch"
+            >
+              <template #icon>
+                <delete-outlined />
+              </template>
+              <span>删除</span>
+            </a-button>
+          </a-space>
+        </template>
+        <template #bodyCell="{ column, record }">
+          <template v-if="column.key === 'action'">
+            <a-space>
+              <a @click="openEdit(record)">修改</a>
+              <a-divider type="vertical" />
+              <a-popconfirm
+                placement="topRight"
+                title="确定要删除此角色吗?"
+                @confirm="remove(record)"
+              >
+                <a class="ele-text-danger">删除</a>
+              </a-popconfirm>
+            </a-space>
+          </template>
+        </template>
+      </ele-pro-table>
+    </a-card>
+    <!-- 编辑弹窗 -->
+    <tag-edit v-model:visible="showEdit" :data="current" @done="reload" />
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { createVNode, ref } from 'vue';
+import { message, Modal } from 'ant-design-vue/es';
+import {
+  PlusOutlined,
+  DeleteOutlined,
+  ExclamationCircleOutlined
+} from '@ant-design/icons-vue';
+import type { EleProTable } from 'ele-admin-pro/es';
+import type {
+  DatasourceFunction,
+  ColumnItem
+} from 'ele-admin-pro/es/ele-pro-table/types';
+import { messageLoading, toDateString } from 'ele-admin-pro/es';
+import TagSearch from './components/tag-search.vue';
+import TagEdit from './components/tag-edit.vue';
+import { pageTags, removeTag, removeTagBatch } from '@/api/employ/tags';
+import { Tag, TagParam } from '@/api/employ/tags/model';
+
+// 表格实例
+const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
+
+// 表格列配置
+const columns = ref<ColumnItem[]>([
+  {
+    key: 'index',
+    width: 48,
+    align: 'center',
+    fixed: 'left',
+    hideInSetting: true,
+    customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
+  },
+  {
+    title: '标签ID',
+    dataIndex: 'tagId',
+    sorter: true,
+    showSorterTooltip: false
+  },
+  {
+    title: '标签名称',
+    dataIndex: 'name',
+    sorter: true,
+    showSorterTooltip: false
+  },
+  {
+    title: '备注',
+    dataIndex: 'comments',
+    sorter: true,
+    showSorterTooltip: false
+  },
+  {
+    title: '创建时间',
+    dataIndex: 'createTime',
+    sorter: true,
+    showSorterTooltip: false,
+    ellipsis: true,
+    customRender: ({ text }) => toDateString(text)
+  },
+  {
+    title: '操作',
+    key: 'action',
+    width: 200,
+    align: 'center'
+  }
+]);
+
+// 表格选中数据
+const selection = ref<Tag[]>([]);
+
+// 当前编辑数据
+const current = ref<Tag | null>(null);
+
+// 是否显示编辑弹窗
+const showEdit = ref(false);
+
+// 表格数据源
+const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
+  return pageTags({ ...where, ...orders, page, limit });
+};
+
+/* 搜索 */
+const reload = (where?: TagParam) => {
+  selection.value = [];
+  tableRef?.value?.reload({ page: 1, where });
+};
+
+/* 打开编辑弹窗 */
+const openEdit = (row?: Tag) => {
+  current.value = row ?? null;
+  showEdit.value = true;
+};
+
+/* 删除单个 */
+const remove = (row: Tag) => {
+  const hide = messageLoading('请求中..', 0);
+  removeTag(row.tagId)
+    .then((msg) => {
+      hide();
+      message.success(msg);
+      reload();
+    })
+    .catch((e) => {
+      hide();
+      message.error(e.message);
+    });
+};
+
+/* 批量删除 */
+const removeBatch = () => {
+  if (!selection.value.length) {
+    message.error('请至少选择一条数据');
+    return;
+  }
+  Modal.confirm({
+    title: '提示',
+    content: '确定要删除选中的标签吗?',
+    icon: createVNode(ExclamationCircleOutlined),
+    maskClosable: true,
+    onOk: () => {
+      const hide = messageLoading('请求中..', 0);
+      removeTagBatch(selection.value.map((d) => d.tagId))
+        .then((msg) => {
+          hide();
+          message.success(msg);
+          reload();
+        })
+        .catch((e) => {
+          hide();
+          message.error(e.message);
+        });
+    }
+  });
+};
+</script>
+
+<script lang="ts">
+export default {
+  name: 'EmployTags'
+};
+</script>
diff --git a/src/views/exception/403/index.vue b/src/views/exception/403/index.vue
new file mode 100644
index 0000000..6b2bc27
--- /dev/null
+++ b/src/views/exception/403/index.vue
@@ -0,0 +1,17 @@
+<template>
+  <div style="padding-top: 80px">
+    <a-result status="403" title="403" sub-title="抱歉, 你无权访问该页面.">
+      <template #extra>
+        <router-link to="/">
+          <a-button type="primary">返回首页</a-button>
+        </router-link>
+      </template>
+    </a-result>
+  </div>
+</template>
+
+<script lang="ts">
+  export default {
+    name: 'Exception403'
+  };
+</script>
diff --git a/src/views/exception/404/index.vue b/src/views/exception/404/index.vue
new file mode 100644
index 0000000..1c2b453
--- /dev/null
+++ b/src/views/exception/404/index.vue
@@ -0,0 +1,17 @@
+<template>
+  <div style="padding-top: 80px">
+    <a-result status="404" title="404" sub-title="抱歉, 你访问的页面不存在.">
+      <template #extra>
+        <router-link to="/">
+          <a-button type="primary">返回首页</a-button>
+        </router-link>
+      </template>
+    </a-result>
+  </div>
+</template>
+
+<script lang="ts">
+  export default {
+    name: 'Exception404'
+  };
+</script>
diff --git a/src/views/exception/500/index.vue b/src/views/exception/500/index.vue
new file mode 100644
index 0000000..ff7c853
--- /dev/null
+++ b/src/views/exception/500/index.vue
@@ -0,0 +1,17 @@
+<template>
+  <div style="padding-top: 80px">
+    <a-result status="500" title="500" sub-title="抱歉, 服务器出错了.">
+      <template #extra>
+        <router-link to="/">
+          <a-button type="primary">返回首页</a-button>
+        </router-link>
+      </template>
+    </a-result>
+  </div>
+</template>
+
+<script lang="ts">
+  export default {
+    name: 'Exception500'
+  };
+</script>
diff --git a/src/views/forget/index.vue b/src/views/forget/index.vue
new file mode 100644
index 0000000..13d655b
--- /dev/null
+++ b/src/views/forget/index.vue
@@ -0,0 +1,407 @@
+<template>
+  <div class="login-wrapper">
+    <a-form
+      ref="formRef"
+      :model="form"
+      :rules="rules"
+      class="login-form ele-bg-white"
+    >
+      <h4>忘记密码</h4>
+      <a-form-item name="phone">
+        <a-input
+          placeholder="请输入绑定手机号"
+          v-model:value="form.phone"
+          allow-clear
+          size="large"
+        >
+          <template #prefix>
+            <mobile-outlined />
+          </template>
+        </a-input>
+      </a-form-item>
+      <a-form-item name="password">
+        <a-input-password
+          placeholder="请输入新的登录密码"
+          v-model:value="form.password"
+          size="large"
+        >
+          <template #prefix>
+            <lock-outlined />
+          </template>
+        </a-input-password>
+      </a-form-item>
+      <a-form-item name="password2">
+        <a-input-password
+          placeholder="请再次输入登录密码"
+          v-model:value="form.password2"
+          size="large"
+        >
+          <template #prefix>
+            <key-outlined />
+          </template>
+        </a-input-password>
+      </a-form-item>
+      <a-form-item name="code">
+        <div class="login-input-group">
+          <a-input
+            placeholder="请输入验证码"
+            v-model:value="form.code"
+            allow-clear
+            size="large"
+          >
+            <template #prefix>
+              <safety-certificate-outlined />
+            </template>
+          </a-input>
+          <a-button
+            class="login-captcha"
+            :disabled="!!countdownTime"
+            @click="openImgCodeModal"
+          >
+            <span v-if="!countdownTime">发送验证码</span>
+            <span v-else>已发送 {{ countdownTime }} s</span>
+          </a-button>
+        </div>
+      </a-form-item>
+      <a-form-item>
+        <router-link
+          to="/login"
+          class="ele-pull-right"
+          style="line-height: 22px"
+        >
+          返回登录
+        </router-link>
+      </a-form-item>
+      <a-form-item>
+        <a-button
+          block
+          size="large"
+          type="primary"
+          :loading="loading"
+          @click="submit"
+        >
+          修改密码
+        </a-button>
+      </a-form-item>
+    </a-form>
+    <div class="login-copyright">
+      copyright © 2022 eleadmin.com all rights reserved.
+    </div>
+  </div>
+  <!-- 编辑弹窗 -->
+  <a-modal
+    :width="340"
+    :footer="null"
+    title="发送验证码"
+    v-model:visible="visible"
+  >
+    <div class="login-input-group" style="margin-bottom: 16px">
+      <a-input
+        v-model:value="imgCode"
+        placeholder="请输入图形验证码"
+        allow-clear
+        size="large"
+      />
+      <a-button class="login-captcha">
+        <img alt="" :src="captcha" @click="changeImgCode" />
+      </a-button>
+    </div>
+    <a-button
+      block
+      size="large"
+      type="primary"
+      :loading="codeLoading"
+      @click="sendCode"
+    >
+      立即发送
+    </a-button>
+  </a-modal>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive, onBeforeUnmount } from 'vue';
+  import { useRouter } from 'vue-router';
+  import { message } from 'ant-design-vue/es';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import {
+    MobileOutlined,
+    LockOutlined,
+    KeyOutlined,
+    SafetyCertificateOutlined
+  } from '@ant-design/icons-vue';
+
+  const { push } = useRouter();
+
+  //
+  const formRef = ref<FormInstance | null>(null);
+
+  // 加载状态
+  const loading = ref(false);
+
+  // 表单数据
+  const form = reactive({
+    phone: '1234567890',
+    password: '',
+    password2: '',
+    code: ''
+  });
+
+  // 表单验证规则
+  const rules = reactive<Record<string, Rule[]>>({
+    phone: [
+      {
+        required: true,
+        message: '请输入绑定手机号',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    password: [
+      {
+        required: true,
+        message: '请输入新的登录密码',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    password2: [
+      {
+        required: true,
+        type: 'string',
+        validator: async (_rule: Rule, value: string) => {
+          if (!value) {
+            return Promise.reject('请再次输入新密码');
+          }
+          if (value !== form.password) {
+            return Promise.reject('两次输入密码不一致');
+          }
+          return Promise.resolve();
+        },
+        trigger: 'blur'
+      }
+    ],
+    code: [
+      {
+        required: true,
+        message: '请输入验证码',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ]
+  });
+
+  // 是否显示图形验证码弹窗
+  const visible = ref(false);
+
+  // 图形验证码
+  const imgCode = ref('');
+
+  // 发送验证码按钮loading
+  const codeLoading = ref(false);
+
+  // 验证码倒计时时间
+  const countdownTime = ref(0);
+
+  // 图形验证码地址
+  const captcha = ref('https://eleadmin.com/assets/captcha?v=');
+
+  // 验证码倒计时定时器
+  let countdownTimer: number | null = null;
+
+  /* 提交 */
+  const submit = () => {
+    if (!formRef.value) {
+      return;
+    }
+    formRef.value
+      .validate()
+      .then(() => {
+        loading.value = true;
+        setTimeout(() => {
+          message.success('密码修改成功');
+          push('/login');
+        }, 1000);
+      })
+      .catch(() => {});
+  };
+
+  /* 更换图形验证码 */
+  const changeImgCode = () => {
+    // 这里演示的验证码是后端地址直接是图片的形式, 如果后端返回base64格式请参考登录页面
+    captcha.value = captcha.value.replace(/v=.*/, 'v=' + new Date().getTime());
+  };
+
+  /* 显示发送短信验证码弹窗 */
+  const openImgCodeModal = () => {
+    if (!form.phone) {
+      message.error('请输入手机号码');
+      return;
+    }
+    imgCode.value = '';
+    changeImgCode();
+    visible.value = true;
+  };
+
+  /* 发送短信验证码 */
+  const sendCode = () => {
+    if (!imgCode.value) {
+      message.error('请输入图形验证码');
+      return;
+    }
+    codeLoading.value = true;
+    setTimeout(() => {
+      message.success('短信验证码发送成功, 请注意查收!');
+      visible.value = false;
+      codeLoading.value = false;
+      countdownTime.value = 30;
+      // 开始对按钮进行倒计时
+      countdownTimer = window.setInterval(() => {
+        if (countdownTime.value <= 1) {
+          countdownTimer && clearInterval(countdownTimer);
+          countdownTimer = null;
+        }
+        countdownTime.value--;
+      }, 1000);
+    }, 1000);
+  };
+
+  onBeforeUnmount(() => {
+    countdownTimer && clearInterval(countdownTimer);
+  });
+</script>
+
+<style lang="less" scoped>
+  /* 背景 */
+  .login-wrapper {
+    padding: 48px 16px 0 16px;
+    position: relative;
+    box-sizing: border-box;
+    background-image: url('@/assets/bg-login.jpg');
+    background-repeat: no-repeat;
+    background-size: cover;
+    min-height: 100vh;
+
+    &:before {
+      content: '';
+      position: absolute;
+      top: 0;
+      left: 0;
+      right: 0;
+      bottom: 0;
+      background: rgba(0, 0, 0, 0.2);
+    }
+  }
+
+  /* 卡片 */
+  .login-form {
+    width: 360px;
+    margin: 0 auto;
+    max-width: 100%;
+    padding: 0 28px 16px 28px;
+    box-sizing: border-box;
+    box-shadow: 0 3px 6px rgba(0, 0, 0, 0.15);
+    border-radius: 2px;
+    position: relative;
+    z-index: 2;
+
+    h4 {
+      padding: 22px 0;
+      text-align: center;
+    }
+  }
+
+  .login-form-right .login-form {
+    margin: 0 15% 0 auto;
+  }
+
+  .login-form-left .login-form {
+    margin: 0 auto 0 15%;
+  }
+
+  /* 验证码 */
+  .login-input-group {
+    display: flex;
+    align-items: center;
+
+    :deep(.ant-input-affix-wrapper) {
+      flex: 1;
+    }
+
+    .login-captcha {
+      width: 102px;
+      height: 40px;
+      margin-left: 10px;
+      padding: 0;
+
+      & > img {
+        width: 100%;
+        height: 100%;
+      }
+    }
+  }
+
+  /* 第三方登录图标 */
+  .login-oauth-icon {
+    color: #fff;
+    padding: 5px;
+    margin: 0 12px;
+    font-size: 18px;
+    border-radius: 50%;
+    cursor: pointer;
+  }
+
+  /* 底部版权 */
+  .login-copyright {
+    color: #eee;
+    text-align: center;
+    padding: 48px 0 22px 0;
+    position: relative;
+    z-index: 1;
+  }
+
+  /* 响应式 */
+  @media screen and (min-height: 640px) {
+    .login-wrapper {
+      padding-top: 0;
+    }
+
+    .login-form {
+      position: absolute;
+      top: 50%;
+      left: 50%;
+      transform: translateX(-50%);
+      margin-top: -230px;
+    }
+
+    .login-form-right .login-form,
+    .login-form-left .login-form {
+      left: auto;
+      right: 15%;
+      transform: translateX(0);
+      margin: -230px auto auto auto;
+    }
+
+    .login-form-left .login-form {
+      right: auto;
+      left: 15%;
+    }
+
+    .login-copyright {
+      position: absolute;
+      left: 0;
+      right: 0;
+      bottom: 0;
+    }
+  }
+
+  @media screen and (max-width: 768px) {
+    .login-form-right .login-form,
+    .login-form-left .login-form {
+      left: 50%;
+      right: auto;
+      margin-left: 0;
+      margin-right: auto;
+      transform: translateX(-50%);
+    }
+  }
+</style>
diff --git a/src/views/login/index.vue b/src/views/login/index.vue
new file mode 100644
index 0000000..d73f40d
--- /dev/null
+++ b/src/views/login/index.vue
@@ -0,0 +1,356 @@
+<template>
+  <div
+    :class="[
+      'login-wrapper',
+      ['', 'login-form-right', 'login-form-left'][direction]
+    ]"
+  >
+    <a-form
+      ref="formRef"
+      :model="form"
+      :rules="rules"
+      class="login-form ele-bg-white"
+    >
+      <h4>{{ t('login.title') }}</h4>
+      <a-form-item name="username">
+        <a-input
+          allow-clear
+          size="large"
+          v-model:value="form.username"
+          :placeholder="t('login.username')"
+        >
+          <template #prefix>
+            <user-outlined />
+          </template>
+        </a-input>
+      </a-form-item>
+      <a-form-item name="password">
+        <a-input-password
+          size="large"
+          v-model:value="form.password"
+          :placeholder="t('login.password')"
+        >
+          <template #prefix>
+            <lock-outlined />
+          </template>
+        </a-input-password>
+      </a-form-item>
+      <a-form-item name="code">
+        <div class="login-input-group">
+          <a-input
+            allow-clear
+            size="large"
+            v-model:value="form.code"
+            :placeholder="t('login.code')"
+          >
+            <template #prefix>
+              <safety-certificate-outlined />
+            </template>
+          </a-input>
+          <a-button class="login-captcha" @click="changeCaptcha">
+            <img v-if="captcha" :src="captcha" alt="" />
+          </a-button>
+        </div>
+      </a-form-item>
+      <a-form-item>
+        <a-checkbox v-model:checked="form.remember">
+          {{ t('login.remember') }}
+        </a-checkbox>
+        <router-link
+          to="/forget"
+          class="ele-pull-right"
+          style="line-height: 22px"
+        >
+          {{ t('login.forget') }}
+        </router-link>
+      </a-form-item>
+      <a-form-item>
+        <a-button
+          block
+          size="large"
+          type="primary"
+          :loading="loading"
+          @click="submit"
+        >
+          {{ loading ? t('login.loading') : t('login.login') }}
+        </a-button>
+      </a-form-item>
+      <div class="ele-text-center" style="padding-bottom: 32px">
+        <wechat-outlined
+          class="login-oauth-icon"
+          style="background: #4daf29"
+          @click="wechatLogin"
+        />
+      </div>
+    </a-form>
+    <div class="login-copyright">
+      copyright © 2022 ERR5.com all rights reserved.
+    </div>
+    <!-- 多语言切换 -->
+    <div style="position: absolute; right: 30px; top: 20px; z-index: 999">
+      <i18n-icon
+        placement="bottomLeft"
+        :style="{ fontSize: '18px', color: '#fff' }"
+      />
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive, computed, unref } from 'vue';
+  import { useI18n } from 'vue-i18n';
+  import { useRouter } from 'vue-router';
+  import { message } from 'ant-design-vue/es';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import {
+    UserOutlined,
+    LockOutlined,
+    SafetyCertificateOutlined,
+    WechatOutlined
+  } from '@ant-design/icons-vue';
+  import I18nIcon from '@/layout/components/i18n-icon.vue';
+  import { getToken } from '@/utils/token-util';
+  import { goHomeRoute, cleanPageTabs } from '@/utils/page-tab-util';
+  import { login, getCaptcha } from '@/api/login';
+
+  const { currentRoute } = useRouter();
+  const { t } = useI18n();
+
+  // 登录框方向, 0 居中, 1 居右, 2 居左
+  const direction = ref(0);
+
+  //
+  const formRef = ref<FormInstance | null>(null);
+
+  // 加载状态
+  const loading = ref(false);
+
+  // 表单数据
+  const form = reactive({
+    username: '',
+    password: '',
+    code: '',
+    key: '',
+    remember: true
+  });
+
+  // 验证码 数据
+  const captcha = ref('');
+
+  // 表单验证规则
+  const rules = computed<Record<string, Rule[]>>(() => {
+    return {
+      username: [
+        {
+          required: true,
+          message: t('login.username'),
+          type: 'string',
+          trigger: 'blur'
+        }
+      ],
+      password: [
+        {
+          required: true,
+          message: t('login.password'),
+          type: 'string',
+          trigger: 'blur'
+        }
+      ],
+      code: [
+        {
+          required: true,
+          message: t('login.code'),
+          type: 'string',
+          trigger: 'blur'
+        }
+      ]
+    };
+  });
+
+  /* 跳转到首页 */
+  const goHome = () => {
+    const { query } = unref(currentRoute);
+    goHomeRoute(query.from as string);
+  };
+
+  /* 提交 */
+  const submit = () => {
+    if (!formRef.value) {
+      return;
+    }
+    formRef.value
+      .validate()
+      .then(() => {
+        loading.value = true;
+        login(form)
+          .then((msg) => {
+            message.success(msg);
+            cleanPageTabs();
+            goHome();
+          })
+          .catch((e: Error) => {
+            message.error(e.message);
+            loading.value = false;
+          });
+      })
+      .catch(() => {});
+  };
+  const wechatLogin = () => {
+    //微信登录
+  };
+
+  /* 获取图形验证码 */
+  const changeCaptcha = () => {
+    getCaptcha()
+      .then((data) => {
+        captcha.value = data.img;
+        form.key = data.key;
+        formRef.value?.clearValidate();
+      })
+      .catch((e) => {
+        message.error(e.message);
+      });
+  };
+
+  if (getToken()) {
+    goHome();
+  } else {
+    changeCaptcha();
+  }
+</script>
+
+<style lang="less" scoped>
+  /* 背景 */
+  .login-wrapper {
+    padding: 48px 16px 0 16px;
+    position: relative;
+    box-sizing: border-box;
+    background-image: url('@/assets/bg-login.jpg');
+    background-repeat: no-repeat;
+    background-size: cover;
+    min-height: 100vh;
+
+    &:before {
+      content: '';
+      position: absolute;
+      top: 0;
+      left: 0;
+      right: 0;
+      bottom: 0;
+      background: rgba(0, 0, 0, 0.2);
+    }
+  }
+
+  /* 卡片 */
+  .login-form {
+    width: 360px;
+    margin: 0 auto;
+    max-width: 100%;
+    padding: 0 28px;
+    box-sizing: border-box;
+    box-shadow: 0 3px 6px rgba(0, 0, 0, 0.15);
+    border-radius: 2px;
+    position: relative;
+    z-index: 2;
+
+    h4 {
+      padding: 22px 0;
+      text-align: center;
+    }
+  }
+
+  .login-form-right .login-form {
+    margin: 0 15% 0 auto;
+  }
+
+  .login-form-left .login-form {
+    margin: 0 auto 0 15%;
+  }
+
+  /* 验证码 */
+  .login-input-group {
+    display: flex;
+    align-items: center;
+
+    :deep(.ant-input-affix-wrapper) {
+      flex: 1;
+    }
+
+    .login-captcha {
+      width: 102px;
+      height: 40px;
+      margin-left: 10px;
+      padding: 0;
+
+      & > img {
+        width: 100%;
+        height: 100%;
+      }
+    }
+  }
+
+  /* 第三方登录图标 */
+  .login-oauth-icon {
+    color: #fff;
+    padding: 5px;
+    margin: 0 12px;
+    font-size: 18px;
+    border-radius: 50%;
+    cursor: pointer;
+  }
+
+  /* 底部版权 */
+  .login-copyright {
+    color: #eee;
+    text-align: center;
+    padding: 48px 0 22px 0;
+    position: relative;
+    z-index: 1;
+  }
+
+  /* 响应式 */
+  @media screen and (min-height: 640px) {
+    .login-wrapper {
+      padding-top: 0;
+    }
+
+    .login-form {
+      position: absolute;
+      top: 50%;
+      left: 50%;
+      transform: translateX(-50%);
+      margin-top: -230px;
+    }
+
+    .login-form-right .login-form,
+    .login-form-left .login-form {
+      left: auto;
+      right: 15%;
+      transform: translateX(0);
+      margin: -230px auto auto auto;
+    }
+
+    .login-form-left .login-form {
+      right: auto;
+      left: 15%;
+    }
+
+    .login-copyright {
+      position: absolute;
+      left: 0;
+      right: 0;
+      bottom: 0;
+    }
+  }
+
+  @media screen and (max-width: 768px) {
+    .login-form-right .login-form,
+    .login-form-left .login-form {
+      left: 50%;
+      right: auto;
+      margin-left: 0;
+      margin-right: auto;
+      transform: translateX(-50%);
+    }
+  }
+</style>
diff --git a/src/views/meeting/components/meeting-edit.vue b/src/views/meeting/components/meeting-edit.vue
new file mode 100644
index 0000000..6e76bd6
--- /dev/null
+++ b/src/views/meeting/components/meeting-edit.vue
@@ -0,0 +1,348 @@
+<!-- 编辑弹窗 -->
+<template>
+  <ele-modal
+    :width="800"
+    :visible="visible"
+    :confirm-loading="loading"
+    :title="isUpdate ? '修改会议' : '添加会议'"
+    :body-style="{ paddingBottom: '8px' }"
+    @update:visible="updateVisible"
+    @ok="save"
+  >
+    <a-form
+      ref="formRef"
+      :model="form"
+      :rules="rules"
+      :label-col="{ flex: '90px' }"
+      :wrapper-col="{ flex: '1' }"
+    >
+      <a-form-item label="会议名称" name="title">
+        <a-input
+          allow-clear
+          :maxlength="20"
+          placeholder="请输入会议名称"
+          v-model:value="form.title"
+        />
+      </a-form-item>
+      <a-row>
+        <a-col :span="12">
+          <a-form-item label="会议地址" name="room">
+            <a-input
+              allow-clear
+              placeholder="请输入会议地址"
+              v-model:value="form.room"
+            />
+          </a-form-item>
+        </a-col>
+        <a-col :span="12">
+          <a-form-item label="坐标地址" name="location">
+            <a-input
+              allow-clear
+              placeholder="请选择地址"
+              v-model:value="form.location"
+            />
+          </a-form-item>
+        </a-col>
+      </a-row>
+
+      <a-row>
+        <a-col :span="12">
+          <a-form-item label="会议时间" name="meeting_time">
+            <a-date-picker
+              :show-time="true"
+              value-format="YYYY-MM-DD HH:mm:ss"
+              placeholder="请选择会议时间"
+              v-model:value="form.meeting_time"
+            />
+            <!-- <a-range-picker
+              class="ele-fluid"
+              :show-time="true"
+              value-format="YYYY-MM-DD HH:mm:ss"
+              v-model:value="form.meeting_time"
+            /> -->
+          </a-form-item>
+        </a-col>
+        <a-col :span="12">
+          <a-form-item label="签到模式" name="mode">
+            <a-radio-group v-model:value="form.mode">
+              <a-radio :value="0">报名模式</a-radio>
+              <a-radio :value="1">不报名模式</a-radio>
+            </a-radio-group>
+          </a-form-item>
+        </a-col>
+      </a-row>
+
+      <a-row>
+        <a-col :span="12">
+          <a-form-item label="报名日期" name="entry_time">
+            <a-range-picker
+              class="ele-fluid"
+              :show-time="true"
+              :disabled="form.mode === 1"
+              value-format="YYYY-MM-DD HH:mm:ss"
+              v-model:value="form.entry_time"
+            />
+          </a-form-item>
+        </a-col>
+        <a-col :span="12">
+          <a-form-item label="签到日期" name="sign_time">
+            <a-range-picker
+              class="ele-fluid"
+              :show-time="true"
+              value-format="YYYY-MM-DD HH:mm:ss"
+              v-model:value="form.sign_time"
+            />
+          </a-form-item>
+        </a-col>
+      </a-row>
+
+      <a-form-item label="会议会标" name="image">
+        <ele-image-upload
+          v-model:value="images"
+          :limit="1"
+          :before-upload="onBeforeUpload"
+          :remove-handler="removeHandler"
+          :item-style="{ width: '250px', height: '135px' }"
+          :button-style="{ width: '250px', height: '135px' }"
+          @upload="onUpload"
+        />
+      </a-form-item>
+      <a-form-item label="会议介绍">
+        <tinymce-editor
+          ref="editorRef"
+          :init="config"
+          v-model:value="form.content"
+        />
+      </a-form-item>
+    </a-form>
+  </ele-modal>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive, watch } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import useFormData from '@/utils/use-form-data';
+  import TinymceEditor from '@/components/TinymceEditor/index.vue';
+  import { Meeting } from '@/api/meeting/model';
+  import { addMeeting, updateMeeting } from '@/api/meeting';
+  import {
+    BeforeUploadType,
+    ItemType
+  } from 'ele-admin-pro/es/ele-image-upload/types';
+  import request from '@/utils/request';
+
+  const emit = defineEmits<{
+    (e: 'done'): void;
+    (e: 'update:visible', visible: boolean): void;
+  }>();
+
+  const props = defineProps<{
+    // 弹窗是否打开
+    visible: boolean;
+    // 修改回显的数据
+    data?: Meeting | null;
+  }>();
+
+  const editorRef = ref<InstanceType<typeof TinymceEditor> | null>(null);
+  //
+  const formRef = ref<FormInstance | null>(null);
+  const images = ref<any>([]);
+  const img_obj = reactive<any>({
+    url: ''
+  });
+  // 是否是修改
+  const isUpdate = ref(false);
+  // 提交状态
+  const loading = ref(false);
+
+  const config = ref({
+    height: 360,
+    // 自定义文件上传(这里使用把选择的文件转成 blob 演示)
+    file_picker_callback: (callback: any, _value: any, meta: any) => {
+      const input = document.createElement('input');
+      input.setAttribute('type', 'file');
+      // 设定文件可选类型
+      if (meta.filetype === 'image') {
+        input.setAttribute('accept', 'image/*');
+      } else if (meta.filetype === 'media') {
+        input.setAttribute('accept', 'video/*');
+      }
+      input.onchange = () => {
+        const file = input.files?.[0];
+        if (!file) {
+          return;
+        }
+        if (meta.filetype === 'media') {
+          if (!file.type.startsWith('video/')) {
+            editorRef.value?.alert({ content: '只能选择视频文件' });
+            return;
+          }
+        }
+        if (file.size / 1024 / 1024 > 20) {
+          editorRef.value?.alert({ content: '大小不能超过 20MB' });
+          return;
+        }
+        const reader = new FileReader();
+        reader.onload = (e) => {
+          if (e.target?.result != null) {
+            const blob = new Blob([e.target.result], { type: file.type });
+            callback(URL.createObjectURL(blob));
+          }
+        };
+        reader.readAsArrayBuffer(file);
+      };
+      input.click();
+    }
+  });
+  // 表单数据
+  const { form, resetFields, assignFields } = useFormData<Meeting>({
+    id: undefined,
+    title: '',
+    room: '',
+    mode: 0,
+    image: '',
+    meeting_time: '',
+    entry_time: ['', ''],
+    sign_time: ['', ''],
+    location: '',
+    content: ''
+  });
+
+  // 表单验证规则
+  const rules = reactive<Record<string, Rule[]>>({
+    title: [
+      {
+        required: true,
+        message: '请输入会议名称',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    room: [
+      {
+        required: true,
+        message: '请填写会议地址',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    meeting_time: [
+      {
+        required: true,
+        message: '请选择会议时间',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    sign_time: [
+      {
+        required: true,
+        message: '请选择签到时间',
+        type: 'array',
+        trigger: 'blur'
+      }
+    ]
+  });
+
+  const onBeforeUpload: BeforeUploadType = (file: File) => {
+    // file 即选择后的文件
+    if (!file.type.startsWith('image')) {
+      message.error('只能选择图片');
+      return false;
+    }
+    if (file.size / 1024 / 1024 > 2) {
+      message.error('大小不能超过 2MB');
+      return false;
+    }
+  };
+  const removeHandler = (item) => {
+    images.value.forEach((d: any) => {
+      if (d.uid === item.uid) {
+        images.value = [];
+        form.image = '';
+        d.deleted = 1;
+      }
+    });
+  };
+  const onUpload = (d: ItemType) => {
+    const item = images.value.find((t: any) => t.uid === d.uid) ?? d;
+    // item 包含的字段参考前面说明
+    item.status = 'uploading';
+    const formData = new FormData();
+    formData.append('file', item.file);
+    request({
+      url: '/file/upload',
+      method: 'post',
+      data: formData,
+      onUploadProgress: (e: any) => {
+        // 文件上传进度回调
+        if (e.lengthComputable) {
+          item.progress = (e.loaded / e.total) * 100;
+        }
+      }
+    })
+      .then((res) => {
+        if (res.data.code === 0) {
+          item.status = 'done';
+          item.url = res.data.data;
+          form.image = res.data.data;
+        }
+      })
+      .catch((e: Error) => {
+        message.warning(e.message);
+        item.status = 'exception';
+      });
+  };
+  /* 保存编辑 */
+  const save = () => {
+    if (!formRef.value) {
+      return;
+    }
+    formRef.value
+      .validate()
+      .then(() => {
+        loading.value = true;
+        const saveOrUpdate = isUpdate.value ? updateMeeting : addMeeting;
+        saveOrUpdate(form)
+          .then((msg) => {
+            loading.value = false;
+            message.success(msg);
+            updateVisible(false);
+            emit('done');
+          })
+          .catch((e) => {
+            loading.value = false;
+            message.error(e.message);
+          });
+      })
+      .catch(() => {});
+  };
+
+  /* 更新visible */
+  const updateVisible = (value: boolean) => {
+    images.value = [];
+    emit('update:visible', value);
+  };
+
+  watch(
+    () => props.visible,
+    (visible) => {
+      if (visible) {
+        if (props.data) {
+          assignFields(props.data);
+          if (props.data.image != null && props.data.image != '') {
+            img_obj.url = props.data.image;
+            images.value.push(img_obj);
+          }
+          isUpdate.value = true;
+        } else {
+          isUpdate.value = false;
+        }
+      } else {
+        resetFields();
+        formRef.value?.clearValidate();
+      }
+    }
+  );
+</script>
diff --git a/src/views/meeting/components/meeting-search.vue b/src/views/meeting/components/meeting-search.vue
new file mode 100644
index 0000000..7a405b5
--- /dev/null
+++ b/src/views/meeting/components/meeting-search.vue
@@ -0,0 +1,106 @@
+<!-- 搜索表单 -->
+<template>
+  <a-form
+    :label-col="
+      styleResponsive ? { xl: 7, lg: 5, md: 7, sm: 4 } : { flex: '90px' }
+    "
+    :wrapper-col="
+      styleResponsive ? { xl: 17, lg: 19, md: 17, sm: 20 } : { flex: '1' }
+    "
+  >
+    <a-row :gutter="8">
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="会议名称">
+          <a-input
+            v-model:value.trim="form.title"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="会议地址">
+          <a-input
+            v-model:value.trim="form.room"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="会议内容">
+          <a-input
+            v-model:value.trim="form.content"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item class="ele-text-right" :wrapper-col="{ span: 24 }">
+          <a-space>
+            <a-button type="primary" @click="search">查询</a-button>
+            <a-button @click="reset">重置</a-button>
+          </a-space>
+        </a-form-item>
+      </a-col>
+    </a-row>
+  </a-form>
+</template>
+
+<script lang="ts" setup>
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import { MeetingParam } from '@/api/meeting/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'search', where?: MeetingParam): void;
+  }>();
+
+  // 表单数据
+  const { form, resetFields } = useFormData<MeetingParam>({
+    title: '',
+    room: '',
+    content: ''
+  });
+
+  /* 搜索 */
+  const search = () => {
+    emit('search', form);
+  };
+
+  /*  重置 */
+  const reset = () => {
+    resetFields();
+    search();
+  };
+</script>
diff --git a/src/views/meeting/components/sign-user.vue b/src/views/meeting/components/sign-user.vue
new file mode 100644
index 0000000..b6d34c2
--- /dev/null
+++ b/src/views/meeting/components/sign-user.vue
@@ -0,0 +1,263 @@
+<template>
+  <div class="ele-body">
+    <ele-modal
+      :width="1200"
+      :visible="visible"
+      :confirm-loading="loading"
+      title="报名管理"
+      :body-style="{ paddingBottom: '8px' }"
+      @update:visible="updateVisible"
+    >
+      <!-- 表格 -->
+      <ele-pro-table
+        ref="tableRef"
+        row-key="id"
+        :columns="columns"
+        :datasource="datasource"
+        v-model:selection="selection"
+        :scroll="{ x: 1000 }"
+        :where="defaultWhere"
+        :needPage="false"
+        cache-key="proSignUserTable"
+      >
+        <template #toolbar>
+          <a-space>
+            <user-search :where="defaultWhere" @search="reload" />
+          </a-space>
+        </template>
+        <template #bodyCell="{ column, record }">
+          <template v-if="column.key === 'isSign'">
+            <a-tag v-if="record.isSign === 0" color="cyan">未签到</a-tag>
+            <a-tag v-else color="red">已签到</a-tag>
+          </template>
+          <template v-else-if="column.key === 'row'">
+            <a-input-number
+              v-model:value="record.row"
+              placeholder="行"
+              @blur="enterSeat(record)"
+            />
+          </template>
+          <template v-else-if="column.key === 'column'">
+            <a-input-number
+              v-model:value="record.column"
+              placeholder="列"
+              @blur="enterSeat(record)"
+            />
+          </template>
+        </template>
+      </ele-pro-table>
+      <template #footer>
+        <a-button type="primary" danger @click="dataExport">导出</a-button>
+        <a-button type="primary" @click="close">关闭</a-button>
+      </template>
+    </ele-modal>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive, watch } from 'vue';
+  import type { EleProTable } from 'ele-admin-pro/es';
+  import type {
+    DatasourceFunction,
+    ColumnItem
+  } from 'ele-admin-pro/es/ele-pro-table/types';
+  import { toDateString } from 'ele-admin-pro/es';
+  import UserSearch from './user-search.vue';
+  import { listUsers, updateSignUser } from '@/api/meeting';
+  import type { User, UserParam } from '@/api/meeting/model';
+  import { message } from 'ant-design-vue';
+  import { utils, writeFile } from 'xlsx';
+
+  const emit = defineEmits<{
+    (e: 'done'): void;
+    (e: 'update:visible', visible: boolean): void;
+  }>();
+
+  const props = defineProps<{
+    // 弹窗是否打开
+    visible: boolean;
+    // 修改回显的数据
+    data?: number | string;
+  }>();
+  // 表格实例
+  const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
+
+  // 提交状态
+  const loading = ref(false);
+  // 表格列配置
+  const columns = ref<ColumnItem[]>([
+    {
+      key: 'index',
+      width: 48,
+      align: 'center',
+      fixed: 'left',
+      hideInSetting: true,
+      customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
+    },
+    {
+      title: '姓名',
+      dataIndex: 'name',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '企业',
+      dataIndex: 'company',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '职位',
+      dataIndex: 'position',
+      width: 80,
+      align: 'center',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '手机号',
+      dataIndex: 'phone',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '座位排',
+      key: 'row',
+      dataIndex: 'row',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '座位列',
+      key: 'column',
+      dataIndex: 'column',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '报名时间',
+      dataIndex: 'entry_time',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true,
+      customRender: ({ text }) => toDateString(text, 'MM-dd HH:mm')
+    },
+    {
+      title: '签到时间',
+      dataIndex: 'sign_time',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true,
+      customRender: ({ text }) => toDateString(text, 'MM-dd HH:mm')
+    },
+    {
+      title: '是否签到',
+      key: 'isSign',
+      dataIndex: 'isSign',
+      sorter: true,
+      showSorterTooltip: false,
+      width: 90,
+      align: 'center'
+    }
+  ]);
+
+  // 表格选中数据
+  const selection = ref<User[]>([]);
+
+  // 默认搜索条件
+  const defaultWhere = reactive<{
+    meetingId?: number | string;
+    name?: string;
+    company?: string;
+  }>({
+    meetingId: '',
+    name: '',
+    company: ''
+  });
+
+  // 表格数据源
+
+  const datasource: DatasourceFunction = ({ where, orders }) => {
+    return listUsers({ ...where, ...orders });
+  };
+
+  /* 搜索 */
+  const reload = (where?: UserParam) => {
+    selection.value = [];
+    tableRef?.value?.reload({ page: 1, where });
+  };
+
+  /* 导出excel */
+  const dataExport = () => {
+    listUsers({ meetingId: defaultWhere.meetingId }).then((res) => {
+      const array: (string | number | undefined)[][] = [
+        [
+          '序号',
+          '会议id',
+          '姓名',
+          '单位',
+          '职位',
+          '电话',
+          '是否签到(1:签到)',
+          '座位排',
+          '座位列',
+          '报名时间',
+          '签到时间'
+        ]
+      ];
+      res.forEach((d, i) => {
+        array.push([
+          i + 1,
+          d.meetingId,
+          d.name,
+          d.company,
+          d.position,
+          d.phone,
+          d.isSign,
+          d.row,
+          d.column,
+          d.entry_time,
+          d.sign_time
+        ]);
+      });
+      const sheetName = 'Sheet1';
+      const workbook = {
+        SheetNames: [sheetName],
+        Sheets: {}
+      };
+      const sheet = utils.aoa_to_sheet(array);
+      workbook.Sheets[sheetName] = sheet;
+      writeFile(workbook, '签到数据.xlsx');
+    });
+  };
+
+  const close = () => {
+    updateVisible(false);
+  };
+
+  /* 更新visible */
+  const updateVisible = (value: boolean) => {
+    emit('update:visible', value);
+  };
+
+  const enterSeat = (value: any) => {
+    updateSignUser(value).then((res) => {
+      if (res.code !== 0) {
+        message.error('出错了!');
+      }
+    });
+  };
+  watch(
+    () => props.data,
+    (value) => {
+      defaultWhere.meetingId = value;
+      reload();
+    }
+  );
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'SignUser'
+  };
+</script>
diff --git a/src/views/meeting/components/user-search.vue b/src/views/meeting/components/user-search.vue
new file mode 100644
index 0000000..ce0ef3d
--- /dev/null
+++ b/src/views/meeting/components/user-search.vue
@@ -0,0 +1,107 @@
+<!-- 搜索表单 -->
+<template>
+  <a-form
+    :label-col="
+      styleResponsive ? { xl: 10, lg: 10, md: 10, sm: 4 } : { flex: '90px' }
+    "
+    :wrapper-col="
+      styleResponsive ? { xl: 14, lg: 14, md: 14, sm: 20 } : { flex: '1' }
+    "
+  >
+    <a-row :gutter="8">
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="用户姓名" style="margin-bottom: 0">
+          <a-input
+            v-model:value.trim="form.name"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="企业名称" style="margin-bottom: 0">
+          <a-input
+            v-model:value.trim="form.company"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      />
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item
+          class="ele-text-right"
+          :wrapper-col="{ span: 24 }"
+          style="margin-bottom: 0"
+        >
+          <a-space>
+            <a-button type="primary" @click="search">查询</a-button>
+            <a-button @click="reset">重置</a-button>
+          </a-space>
+        </a-form-item>
+      </a-col>
+    </a-row>
+  </a-form>
+</template>
+
+<script lang="ts" setup>
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import type { UserParam } from '@/api/meeting/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const props = defineProps<{
+    // 默认搜索条件
+    where?: UserParam;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'search', where?: UserParam): void;
+  }>();
+
+  // 表单数据
+  const { form, resetFields } = useFormData<UserParam>({
+    name: '',
+    company: '',
+    ...props.where
+  });
+
+  /* 搜索 */
+  const search = () => {
+    emit('search', form);
+  };
+
+  /*  重置 */
+  const reset = () => {
+    resetFields();
+    search();
+  };
+</script>
diff --git a/src/views/meeting/index.vue b/src/views/meeting/index.vue
new file mode 100644
index 0000000..4d5d5db
--- /dev/null
+++ b/src/views/meeting/index.vue
@@ -0,0 +1,245 @@
+<template>
+  <div class="ele-body">
+    <a-card :bordered="false">
+      <!-- 搜索表单 -->
+      <meet-search @search="reload" />
+      <!-- 表格 -->
+      <ele-pro-table
+        ref="tableRef"
+        row-key="roleId"
+        :columns="columns"
+        :datasource="datasource"
+        v-model:selection="selection"
+        :scroll="{ x: 800 }"
+        cache-key="signMeeting"
+      >
+        <template #toolbar>
+          <a-space>
+            <a-button type="primary" class="ele-btn-icon" @click="openEdit()">
+              <template #icon>
+                <plus-outlined />
+              </template>
+              <span>新建</span>
+            </a-button>
+            <a-button
+              danger
+              type="primary"
+              class="ele-btn-icon"
+              @click="removeBatch"
+            >
+              <template #icon>
+                <delete-outlined />
+              </template>
+              <span>删除</span>
+            </a-button>
+          </a-space>
+        </template>
+        <template #bodyCell="{ column, record }">
+          <template v-if="column.key === 'action'">
+            <a-space>
+              <a @click="showQrCode(record)">二维码</a>
+              <a-divider type="vertical" />
+              <a @click="openEdit(record)">修改</a>
+              <a-divider type="vertical" />
+              <a @click="openSign(record)">签到管理</a>
+              <a-divider type="vertical" />
+              <a-popconfirm
+                placement="topRight"
+                title="确定要删除此会议吗?"
+                @confirm="remove(record)"
+              >
+                <a class="ele-text-danger">删除</a>
+              </a-popconfirm>
+            </a-space>
+          </template>
+        </template>
+      </ele-pro-table>
+    </a-card>
+    <!-- 编辑弹窗 -->
+    <meet-edit v-model:visible="showEdit" :data="current" @done="reload" />
+    <!-- 报名用户弹窗 -->
+    <sign-user v-model:visible="showSign" :data="meetingId" />
+    <!-- 二维码弹窗 -->
+    <ele-modal
+      :width="170"
+      :footer="null"
+      title="二维码"
+      v-model:visible="qrShow"
+    >
+      <ele-qr-code :value="qrcode" :size="120" />
+    </ele-modal>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { createVNode, ref } from 'vue';
+  import { message, Modal } from 'ant-design-vue/es';
+  import {
+    PlusOutlined,
+    DeleteOutlined,
+    ExclamationCircleOutlined
+  } from '@ant-design/icons-vue';
+  import type { EleProTable } from 'ele-admin-pro/es';
+  import type {
+    DatasourceFunction,
+    ColumnItem
+  } from 'ele-admin-pro/es/ele-pro-table/types';
+  import { messageLoading, toDateString } from 'ele-admin-pro/es';
+  import MeetSearch from './components/meeting-search.vue';
+  import MeetEdit from './components/meeting-edit.vue';
+  import SignUser from './components/sign-user.vue';
+  import {
+    pageMeeting,
+    removeMeeting,
+    removeMeetingBatch
+  } from '@/api/meeting';
+  import type { Meeting, MeetingParam } from '@/api/meeting/model';
+  import { HOME_BASE_URL } from '@/config/setting';
+
+  // 表格实例
+  const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
+  const meetingId = ref();
+  // 表格列配置
+  const columns = ref<ColumnItem[]>([
+    {
+      key: 'index',
+      width: 48,
+      align: 'center',
+      fixed: 'left',
+      hideInSetting: true,
+      customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
+    },
+    {
+      title: '会议名称',
+      dataIndex: 'title',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '会议地址',
+      dataIndex: 'room',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '会议时间',
+      dataIndex: 'meeting_time',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '签到时间',
+      dataIndex: 'sign_time',
+      sorter: true,
+      showSorterTooltip: false,
+      customRender: ({ text }) =>
+        toDateString(text[0], 'MM-dd HH:mm') +
+        '/' +
+        toDateString(text[1], 'MM-dd HH:mm')
+    },
+    {
+      title: '创建时间',
+      dataIndex: 'created_at',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true,
+      customRender: ({ text }) => toDateString(text)
+    },
+    {
+      title: '操作',
+      key: 'action',
+      width: 240,
+      align: 'center'
+    }
+  ]);
+
+  // 表格选中数据
+  const selection = ref<Meeting[]>([]);
+
+  const qrShow = ref(false);
+  const qrcode = ref<string>();
+  // 当前编辑数据
+  const current = ref<Meeting | null>(null);
+
+  // 是否显示编辑弹窗
+  const showEdit = ref(false);
+
+  // 是否显示报名弹窗
+  const showSign = ref(false);
+
+  // 表格数据源
+  const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
+    return pageMeeting({ ...where, ...orders, page, limit });
+  };
+
+  /* 搜索 */
+  const reload = (where?: MeetingParam) => {
+    meetingId.value = null;
+    selection.value = [];
+    tableRef?.value?.reload({ page: 1, where });
+  };
+
+  /* 打开编辑弹窗 */
+  const openEdit = (row?: Meeting) => {
+    current.value = row ?? null;
+    showEdit.value = true;
+  };
+
+  /* 打开签到管理弹窗 */
+  const openSign = (row?: Meeting) => {
+    meetingId.value = row?.id;
+    showSign.value = true;
+  };
+
+  const showQrCode = (row?: Meeting) => {
+    qrShow.value = true;
+    qrcode.value = HOME_BASE_URL + '/pages/attendance/index?id=' + row?.id;
+  };
+  /* 删除单个 */
+  const remove = (row: Meeting) => {
+    const hide = messageLoading('请求中..', 0);
+    removeMeeting(row.id)
+      .then((msg) => {
+        hide();
+        message.success(msg);
+        reload();
+      })
+      .catch((e) => {
+        hide();
+        message.error(e.message);
+      });
+  };
+
+  /* 批量删除 */
+  const removeBatch = () => {
+    if (!selection.value.length) {
+      message.error('请至少选择一条数据');
+      return;
+    }
+    Modal.confirm({
+      title: '提示',
+      content: '确定要删除选中的会议吗?',
+      icon: createVNode(ExclamationCircleOutlined),
+      maskClosable: true,
+      onOk: () => {
+        const hide = messageLoading('请求中..', 0);
+        removeMeetingBatch(selection.value.map((d) => d.id))
+          .then((msg) => {
+            hide();
+            message.success(msg);
+            reload();
+          })
+          .catch((e) => {
+            hide();
+            message.error(e.message);
+          });
+      }
+    });
+  };
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'SignMeeting'
+  };
+</script>
diff --git a/src/views/setting/file/index.vue b/src/views/setting/file/index.vue
new file mode 100644
index 0000000..b67069d
--- /dev/null
+++ b/src/views/setting/file/index.vue
@@ -0,0 +1,189 @@
+<template>
+  <div>
+    <a-page-header :ghost="false" title="文件存储设置">
+      <div class="ele-text-secondary"> 用于指定文件存储引擎 </div>
+    </a-page-header>
+    <div class="ele-body">
+      <a-card :bordered="false">
+        <a-form
+          ref="formRef"
+          :model="form"
+          :rules="rules"
+          :label-col="styleResponsive ? { sm: 4, xs: 24 } : { flex: '100px' }"
+          :wrapper-col="styleResponsive ? { sm: 20, xs: 24 } : { flex: '1' }"
+          style="max-width: 800px; margin: 0 auto"
+        >
+          <a-form-item label="存储位置">
+            <a-radio-group v-model:value="form.type" button-style="solid">
+              <a-radio-button value="local">本地</a-radio-button>
+              <a-radio-button value="qiniu">七牛</a-radio-button>
+            </a-radio-group>
+          </a-form-item>
+
+          <div class="local" v-if="form.type === 'local'">
+            <a-form-item label="本地目录" name="file_path">
+              <a-input
+                allow-clear
+                placeholder="请输入目录"
+                v-model:value="form.file_path"
+              />
+            </a-form-item>
+          </div>
+
+          <div class="qiniu" v-if="form.type === 'qiniu'">
+            <a-form-item label="domain" name="domain">
+              <a-input
+                allow-clear
+                placeholder="请输入domain"
+                v-model:value="form.domain"
+              />
+            </a-form-item>
+            <a-form-item label="bucket" name="bucket">
+              <a-input
+                allow-clear
+                placeholder="请输入bucket"
+                v-model:value="form.bucket"
+              />
+            </a-form-item>
+            <a-form-item label="access_key" name="access_key">
+              <a-input
+                allow-clear
+                placeholder="请输入AK"
+                v-model:value="form.access_key"
+              />
+            </a-form-item>
+            <a-form-item label="secret_key" name="secret_key">
+              <a-input
+                allow-clear
+                placeholder="请输入SK"
+                v-model:value="form.secret_key"
+              />
+            </a-form-item>
+          </div>
+          <a-form-item
+            :wrapper-col="
+              styleResponsive ? { sm: { offset: 4 } } : { offset: 3 }
+            "
+          >
+            <a-space size="middle">
+              <a-button @click="finishPageTab()">关闭</a-button>
+              <a-button type="primary" :loading="loading" @click="submit">
+                提交
+              </a-button>
+            </a-space>
+          </a-form-item>
+        </a-form>
+      </a-card>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import { finishPageTab } from '@/utils/page-tab-util';
+  import { FileForm } from '@/api/setting/model';
+  import { getConfig, submitForm } from '@/api/setting';
+  import { assignObject } from 'ele-admin-pro';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  //
+  const formRef = ref<FormInstance | null>(null);
+
+  // 加载状态
+  const loading = ref(false);
+
+  // 表单数据
+  const { form, resetFields } = useFormData<FileForm>({
+    type: 'local',
+    file_path: '',
+    domain: '',
+    bucket: '',
+    access_key: '',
+    secret_key: ''
+  });
+
+  // 表单验证规则
+  const rules = reactive<Record<string, Rule[]>>({
+    domain: [
+      {
+        required: true,
+        message: 'domain',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    bucket: [
+      {
+        required: true,
+        message: '请输入bucket',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    access_key: [
+      {
+        required: true,
+        message: '请输入access_key',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    secret_key: [
+      {
+        required: true,
+        message: '请输入secret_key',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ]
+  });
+
+  const query = () => {
+    getConfig().then((res) => {
+      let data = JSON.parse(res.file.data);
+      assignObject(form, data);
+    });
+  };
+  /* 提交 */
+  const submit = () => {
+    if (!formRef.value) {
+      return;
+    }
+    const newForm = {
+      ...form,
+      field: 'file'
+    };
+    formRef.value
+      .validate()
+      .then(() => {
+        loading.value = true;
+        submitForm(newForm).then((res) => {
+          if (res.code === 0) {
+            loading.value = false;
+            resetFields();
+            query();
+            return message.success(res.message);
+          } else {
+            loading.value = false;
+            return message.error(res.message);
+          }
+        });
+      })
+      .catch(() => {});
+  };
+  query();
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'FormFile'
+  };
+</script>
diff --git a/src/views/setting/site/index.vue b/src/views/setting/site/index.vue
new file mode 100644
index 0000000..c70441c
--- /dev/null
+++ b/src/views/setting/site/index.vue
@@ -0,0 +1,334 @@
+<template>
+  <div>
+    <a-page-header :ghost="false" title="站点配置">
+      <div class="ele-text-secondary"> 用于配置站点的基本信息 </div>
+    </a-page-header>
+    <div class="ele-body">
+      <a-card :bordered="false">
+        <a-form
+          ref="formRef"
+          :model="form"
+          :rules="rules"
+          :label-col="styleResponsive ? { sm: 4, xs: 24 } : { flex: '100px' }"
+          :wrapper-col="styleResponsive ? { sm: 20, xs: 24 } : { flex: '1' }"
+          style="max-width: 800px; margin: 0 auto"
+        >
+          <a-form-item label="站点域名" name="url">
+            <a-input v-model:value="form.url">
+              <template #addonBefore>
+                <a-select v-model:value="form.urlPre" style="width: 90px">
+                  <a-select-option value="http://">http://</a-select-option>
+                  <a-select-option value="https://">https://</a-select-option>
+                </a-select>
+              </template>
+            </a-input>
+          </a-form-item>
+          <a-form-item label="站点名称" name="siteName">
+            <a-input
+              allow-clear
+              placeholder="请输入站点名称"
+              v-model:value="form.siteName"
+            />
+          </a-form-item>
+          <a-row :gutter="16">
+            <a-col
+              v-bind="
+                styleResponsive ? { md: 12, sm: 24, xs: 24 } : { span: 12 }
+              "
+            >
+              <a-form-item
+                label="站点icon"
+                name="icon"
+                :label-col="
+                  styleResponsive ? { md: 8, sm: 4, xs: 24 } : { flex: '90px' }
+                "
+                :wrapper-col="
+                  styleResponsive ? { md: 21, sm: 20, xs: 24 } : { flex: '1' }
+                "
+              >
+                <ele-image-upload
+                  v-model:value="icon"
+                  :limit="1"
+                  :before-upload="onBeforeUpload"
+                  :remove-handler="iconRemoveHandler"
+                  :item-style="{ width: '150px', height: '100px' }"
+                  :button-style="{ width: '150px', height: '100px' }"
+                  @upload="iconUpload"
+                />
+              </a-form-item>
+            </a-col>
+            <a-col
+              v-bind="
+                styleResponsive ? { md: 12, sm: 24, xs: 24 } : { span: 12 }
+              "
+            >
+              <a-form-item
+                label="站点logo"
+                name="logo"
+                :label-col="
+                  styleResponsive ? { md: 8, sm: 4, xs: 24 } : { flex: '90px' }
+                "
+                :wrapper-col="
+                  styleResponsive ? { md: 21, sm: 20, xs: 24 } : { flex: '1' }
+                "
+              >
+                <ele-image-upload
+                  v-model:value="logo"
+                  :limit="1"
+                  :before-upload="onBeforeUpload"
+                  :remove-handler="logoRemoveHandler"
+                  :item-style="{ width: '150px', height: '100px' }"
+                  :button-style="{ width: '150px', height: '100px' }"
+                  @upload="logoUpload"
+                />
+              </a-form-item>
+            </a-col>
+          </a-row>
+
+          <a-form-item label="关键字">
+            <a-input
+              allow-clear
+              placeholder="请输入关键字"
+              v-model:value="form.keywords"
+            />
+          </a-form-item>
+          <a-form-item label="站点描述">
+            <a-textarea
+              :rows="4"
+              v-model:value="form.description"
+              placeholder="请输入站点描述"
+            />
+          </a-form-item>
+          <a-form-item label="ICP备案">
+            <a-input
+              allow-clear
+              placeholder="请输入ICP备案"
+              v-model:value="form.icp"
+            />
+          </a-form-item>
+          <a-form-item label="公安备案">
+            <a-input
+              allow-clear
+              placeholder="请输入公安备案"
+              v-model:value="form.beian"
+            />
+          </a-form-item>
+          <a-form-item label="授权密钥">
+            <a-input
+              allow-clear
+              placeholder="请输入授权密钥"
+              v-model:value="form.key"
+            />
+          </a-form-item>
+          <a-form-item
+            :wrapper-col="
+              styleResponsive ? { sm: { offset: 4 } } : { offset: 3 }
+            "
+          >
+            <a-space size="middle">
+              <a-button @click="finishPageTab()">关闭</a-button>
+              <a-button type="primary" :loading="loading" @click="submit">
+                提交
+              </a-button>
+            </a-space>
+          </a-form-item>
+        </a-form>
+      </a-card>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import { finishPageTab } from '@/utils/page-tab-util';
+  import { SiteForm } from '@/api/setting/model';
+  import {
+    BeforeUploadType,
+    ItemType
+  } from 'ele-admin-pro/es/ele-image-upload/types';
+  import request from '@/utils/request';
+  import { submitForm, getConfig } from '@/api/setting';
+  import { assignObject } from 'ele-admin-pro';
+
+  // 表单验证规则
+  const rules = reactive<Record<string, Rule[]>>({
+    url: [
+      {
+        required: true,
+        message: '请输入url',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    siteName: [
+      {
+        required: true,
+        type: 'string',
+        message: '请输入站点名',
+        trigger: 'blur'
+      }
+    ]
+  });
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  //
+  const formRef = ref<FormInstance | null>(null);
+
+  // 加载状态
+  const loading = ref(false);
+  const icon = ref<any>([]);
+  const logo = ref<any>([]);
+  // 表单数据
+  const { form, resetFields } = useFormData<SiteForm>({
+    type: '',
+    urlPre: 'http://',
+    url: '',
+    icon: '',
+    logo: '',
+    siteName: '',
+    keywords: '',
+    description: '',
+    icp: '',
+    beian: '',
+    key: ''
+  });
+  const query = () => {
+    getConfig().then((res) => {
+      let data = JSON.parse(res.basic.data);
+      assignObject(form, data);
+      if (icon.value == '') {
+        icon.value.push(...icon.value, { url: data.icon });
+      }
+      if (logo.value == '') {
+        logo.value.push(...logo.value, { url: data.logo });
+      }
+    });
+  };
+  const onBeforeUpload: BeforeUploadType = (file: File) => {
+    if (!file.type.startsWith('image')) {
+      message.error('只能选择图片');
+      return false;
+    }
+    if (file.size / 1024 / 1024 > 2) {
+      message.error('大小不能超过 2MB');
+      return false;
+    }
+  };
+  //上传图片
+  const iconUpload = (d: ItemType) => {
+    const item = icon.value.find((t) => t.uid === d.uid) ?? d;
+    // item 包含的字段参考前面说明
+    item.status = 'uploading';
+    const formData = new FormData();
+    formData.append('file', item.file);
+    request({
+      url: '/file/upload',
+      method: 'post',
+      data: formData,
+      onUploadProgress: (e: any) => {
+        // 文件上传进度回调
+        if (e.lengthComputable) {
+          item.progress = (e.loaded / e.total) * 100;
+        }
+      }
+    })
+      .then((res) => {
+        if (res.data.code === 0) {
+          item.status = 'done';
+          item.url = res.data.data;
+          form.icon = res.data.data;
+        }
+      })
+      .catch((e: Error) => {
+        message.warning(e.message);
+        item.status = 'exception';
+      });
+  };
+  const logoUpload = (d: ItemType) => {
+    const item = logo.value.find((t) => t.uid === d.uid) ?? d;
+    // item 包含的字段参考前面说明
+    item.status = 'uploading';
+    const formData = new FormData();
+    formData.append('file', item.file);
+    request({
+      url: '/file/upload',
+      method: 'post',
+      data: formData,
+      onUploadProgress: (e: any) => {
+        // 文件上传进度回调
+        if (e.lengthComputable) {
+          item.progress = (e.loaded / e.total) * 100;
+        }
+      }
+    }).then((res) => {
+        if (res.data.code === 0) {
+          item.status = 'done';
+          item.url = res.data.data;
+          form.logo = res.data.data;
+        }
+      })
+      .catch((e: Error) => {
+        message.warning(e.message);
+        item.status = 'exception';
+      });
+  };
+  const iconRemoveHandler = (item) => {
+    icon.value.forEach((d: any) => {
+      if (d.uid === item.uid) {
+        icon.value = [];
+        form.icon = '';
+        d.deleted = 1;
+      }
+    });
+  };
+  const logoRemoveHandler = (item) => {
+    logo.value.forEach((d: any) => {
+      if (d.uid === item.uid) {
+        logo.value = [];
+        form.logo = '';
+        d.deleted = 1;
+      }
+    });
+  };
+  /* 提交 */
+  const submit = () => {
+    if (!formRef.value) {
+      return;
+    }
+    const newForm = {
+      ...form,
+      field: 'basic'
+    };
+    formRef.value
+      .validate()
+      .then(() => {
+        loading.value = true;
+        submitForm(newForm).then((res: any) => {
+          if (res.code === 0) {
+            loading.value = false;
+            resetFields();
+            query();
+            return message.success(res.message);
+          } else {
+            loading.value = false;
+            return message.error(res.message);
+          }
+        });
+      })
+      .catch(() => {});
+  };
+  query();
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'FormBasic'
+  };
+</script>
diff --git a/src/views/setting/wechat/index.vue b/src/views/setting/wechat/index.vue
new file mode 100644
index 0000000..c8743bb
--- /dev/null
+++ b/src/views/setting/wechat/index.vue
@@ -0,0 +1,133 @@
+<template>
+  <div>
+    <a-page-header :ghost="false" title="微信配置">
+      <div class="ele-text-secondary"> 用于绑定微信appid和app_secret </div>
+    </a-page-header>
+    <div class="ele-body">
+      <a-card :bordered="false">
+        <a-form
+          ref="formRef"
+          :model="form"
+          :rules="rules"
+          :label-col="styleResponsive ? { sm: 4, xs: 24 } : { flex: '100px' }"
+          :wrapper-col="styleResponsive ? { sm: 20, xs: 24 } : { flex: '1' }"
+          style="max-width: 800px; margin: 0 auto"
+        >
+          <a-form-item label="appid" name="appid">
+            <a-input
+              allow-clear
+              placeholder="请输入appid"
+              v-model:value="form.appid"
+            />
+          </a-form-item>
+          <a-form-item label="appsecret" name="appsecret">
+            <a-input
+              allow-clear
+              placeholder="请输入appsecret"
+              v-model:value="form.appsecret"
+            />
+          </a-form-item>
+          <a-form-item
+            :wrapper-col="
+              styleResponsive ? { sm: { offset: 4 } } : { offset: 3 }
+            "
+          >
+            <a-space size="middle">
+              <a-button @click="finishPageTab()">关闭</a-button>
+              <a-button type="primary" :loading="loading" @click="submit">
+                提交
+              </a-button>
+            </a-space>
+          </a-form-item>
+        </a-form>
+      </a-card>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import { finishPageTab } from '@/utils/page-tab-util';
+  import { WeChatForm } from '@/api/setting/model';
+  import { submitForm, getConfig } from '@/api/setting';
+  import { assignObject } from 'ele-admin-pro';
+
+  // 表单验证规则
+  const rules = reactive<Record<string, Rule[]>>({
+    appid: [
+      {
+        required: true,
+        message: '请输入appid',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    appsecret: [
+      {
+        required: true,
+        type: 'string',
+        message: '请输入appsecret',
+        trigger: 'blur'
+      }
+    ]
+  });
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  //
+  const formRef = ref<FormInstance | null>(null);
+
+  // 加载状态
+  const loading = ref(false);
+  // 表单数据
+  const { form, resetFields } = useFormData<WeChatForm>({
+    appid: '',
+    appsecret: ''
+  });
+  const query = () => {
+    getConfig().then((res) => {
+      let data = JSON.parse(res.wechat.data);
+      assignObject(form, data);
+    });
+  };
+  /* 提交 */
+  const submit = () => {
+    if (!formRef.value) {
+      return;
+    }
+    const newForm = {
+      ...form,
+      field: 'wechat'
+    };
+    formRef.value
+      .validate()
+      .then(() => {
+        loading.value = true;
+        submitForm(newForm).then((res: any) => {
+          if (res.code === 0) {
+            loading.value = false;
+            resetFields();
+            query();
+            return message.success(res.message);
+          } else {
+            loading.value = false;
+            return message.error(res.message);
+          }
+        });
+      })
+      .catch(() => {});
+  };
+  query();
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'WeixinSetting'
+  };
+</script>
diff --git a/src/views/system/dictionary/components/dict-data-edit.vue b/src/views/system/dictionary/components/dict-data-edit.vue
new file mode 100644
index 0000000..8a7cb77
--- /dev/null
+++ b/src/views/system/dictionary/components/dict-data-edit.vue
@@ -0,0 +1,186 @@
+<!-- 字典项编辑弹窗 -->
+<template>
+  <ele-modal
+    :width="460"
+    :visible="visible"
+    :confirm-loading="loading"
+    :body-style="{ paddingBottom: '8px' }"
+    :title="isUpdate ? '修改字典项' : '添加字典项'"
+    @update:visible="updateVisible"
+    @ok="save"
+  >
+    <a-form
+      ref="formRef"
+      :model="form"
+      :rules="rules"
+      :label-col="styleResponsive ? { md: 6, sm: 6, xs: 24 } : { flex: '98px' }"
+      :wrapper-col="
+        styleResponsive ? { md: 18, sm: 18, xs: 24 } : { flex: '1' }
+      "
+    >
+      <a-form-item label="字典项名称" name="dictDataName">
+        <a-input
+          allow-clear
+          :maxlength="20"
+          placeholder="请输入字典项名称"
+          v-model:value="form.dictDataName"
+        />
+      </a-form-item>
+      <a-form-item label="字典项值" name="dictDataCode">
+        <a-input
+          allow-clear
+          :maxlength="20"
+          placeholder="请输入字典项值"
+          v-model:value="form.dictDataCode"
+        />
+      </a-form-item>
+      <a-form-item label="排序号" name="sortNumber">
+        <a-input-number
+          :min="0"
+          :max="9999"
+          class="ele-fluid"
+          placeholder="请输入排序号"
+          v-model:value="form.sortNumber"
+        />
+      </a-form-item>
+      <a-form-item label="备注">
+        <a-textarea
+          :rows="4"
+          :maxlength="200"
+          placeholder="请输入备注"
+          v-model:value="form.comments"
+        />
+      </a-form-item>
+    </a-form>
+  </ele-modal>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive, watch } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import {
+    addDictionaryData,
+    updateDictionaryData
+  } from '@/api/system/dictionary-data';
+  import type { DictionaryData } from '@/api/system/dictionary-data/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'done'): void;
+    (e: 'update:visible', visible: boolean): void;
+  }>();
+
+  const props = defineProps<{
+    // 弹窗是否打开
+    visible: boolean;
+    // 修改回显的数据
+    data?: DictionaryData | null;
+    // 字典id
+    dictId: number;
+  }>();
+
+  //
+  const formRef = ref<FormInstance | null>(null);
+
+  // 是否是修改
+  const isUpdate = ref(false);
+
+  // 提交状态
+  const loading = ref(false);
+
+  // 表单数据
+  const { form, resetFields, assignFields } = useFormData<DictionaryData>({
+    dictDataId: undefined,
+    dictDataName: '',
+    dictDataCode: '',
+    sortNumber: '',
+    comments: ''
+  });
+
+  // 表单验证规则
+  const rules = reactive<Record<string, Rule[]>>({
+    dictDataName: [
+      {
+        required: true,
+        message: '请输入字典项名称',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    dictDataCode: [
+      {
+        required: true,
+        message: '请输入字典项值',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    sortNumber: [
+      {
+        required: true,
+        message: '请输入排序号',
+        type: 'number',
+        trigger: 'blur'
+      }
+    ]
+  });
+
+  /* 保存编辑 */
+  const save = () => {
+    if (!formRef.value) {
+      return;
+    }
+    formRef.value
+      .validate()
+      .then(() => {
+        loading.value = true;
+        const saveOrUpdate = isUpdate.value
+          ? updateDictionaryData
+          : addDictionaryData;
+        saveOrUpdate({
+          ...form,
+          dictId: props.dictId
+        })
+          .then((msg) => {
+            loading.value = false;
+            message.success(msg);
+            updateVisible(false);
+            emit('done');
+          })
+          .catch((e) => {
+            loading.value = false;
+            message.error(e.message);
+          });
+      })
+      .catch(() => {});
+  };
+
+  /* 更新visible */
+  const updateVisible = (value: boolean) => {
+    emit('update:visible', value);
+  };
+
+  watch(
+    () => props.visible,
+    (visible) => {
+      if (visible) {
+        if (props.data) {
+          assignFields(props.data);
+          isUpdate.value = true;
+        } else {
+          isUpdate.value = false;
+        }
+      } else {
+        resetFields();
+        formRef.value?.clearValidate();
+      }
+    }
+  );
+</script>
diff --git a/src/views/system/dictionary/components/dict-data-search.vue b/src/views/system/dictionary/components/dict-data-search.vue
new file mode 100644
index 0000000..8dfafa3
--- /dev/null
+++ b/src/views/system/dictionary/components/dict-data-search.vue
@@ -0,0 +1,86 @@
+<!-- 搜索表单 -->
+<template>
+  <a-row :gutter="16">
+    <a-col
+      v-bind="
+        styleResponsive ? { xl: 6, lg: 8, md: 11, sm: 24, xs: 24 } : { span: 6 }
+      "
+    >
+      <a-input
+        v-model:value.trim="form.keywords"
+        placeholder="输入关键字搜索"
+        allow-clear
+      />
+    </a-col>
+    <a-col
+      v-bind="
+        styleResponsive
+          ? { xl: 18, lg: 16, md: 13, sm: 24, xs: 24 }
+          : { span: 18 }
+      "
+    >
+      <a-space :size="10" style="flex-wrap: wrap">
+        <a-button type="primary" class="ele-btn-icon" @click="search">
+          <template #icon>
+            <search-outlined />
+          </template>
+          <span>查询</span>
+        </a-button>
+        <a-button type="primary" class="ele-btn-icon" @click="add">
+          <template #icon>
+            <plus-outlined />
+          </template>
+          <span>新建</span>
+        </a-button>
+        <a-button danger type="primary" class="ele-btn-icon" @click="remove">
+          <template #icon>
+            <delete-outlined />
+          </template>
+          <span>删除</span>
+        </a-button>
+      </a-space>
+    </a-col>
+  </a-row>
+</template>
+
+<script lang="ts" setup>
+  import {
+    PlusOutlined,
+    DeleteOutlined,
+    SearchOutlined
+  } from '@ant-design/icons-vue';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import type { DictionaryDataParam } from '@/api/system/dictionary-data/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'search', where?: DictionaryDataParam): void;
+    (e: 'add'): void;
+    (e: 'remove'): void;
+  }>();
+
+  // 表单数据
+  const { form } = useFormData<DictionaryDataParam>({
+    keywords: ''
+  });
+
+  /* 搜索 */
+  const search = () => {
+    emit('search', form);
+  };
+
+  /* 添加 */
+  const add = () => {
+    emit('add');
+  };
+
+  /* 删除 */
+  const remove = () => {
+    emit('remove');
+  };
+</script>
diff --git a/src/views/system/dictionary/components/dict-data.vue b/src/views/system/dictionary/components/dict-data.vue
new file mode 100644
index 0000000..eb1821b
--- /dev/null
+++ b/src/views/system/dictionary/components/dict-data.vue
@@ -0,0 +1,212 @@
+<template>
+  <!-- 表格 -->
+  <ele-pro-table
+    ref="tableRef"
+    row-key="dictDataId"
+    :columns="columns"
+    :datasource="datasource"
+    tool-class="ele-toolbar-form"
+    v-model:selection="selection"
+    :row-selection="{ columnWidth: 48 }"
+    :scroll="{ x: 800 }"
+    height="calc(100vh - 290px)"
+    tools-theme="default"
+    bordered
+    cache-key="proSystemDictDataTable"
+    class="sys-dict-data-table"
+  >
+    <template #toolbar>
+      <dict-data-search
+        @search="reload"
+        @add="openEdit()"
+        @remove="removeBatch"
+      />
+    </template>
+    <template #bodyCell="{ column, record }">
+      <template v-if="column.key === 'action'">
+        <a-space>
+          <a @click="openEdit(record)">修改</a>
+          <a-divider type="vertical" />
+          <a-popconfirm
+            placement="topRight"
+            title="确定要删除此字典项吗?"
+            @confirm="remove(record)"
+          >
+            <a class="ele-text-danger">删除</a>
+          </a-popconfirm>
+        </a-space>
+      </template>
+    </template>
+  </ele-pro-table>
+  <!-- 编辑弹窗 -->
+  <dict-data-edit
+    v-model:visible="showEdit"
+    :data="current"
+    :dict-id="dictId"
+    @done="reload"
+  />
+</template>
+
+<script lang="ts" setup>
+  import { createVNode, ref, watch } from 'vue';
+  import { message, Modal } from 'ant-design-vue/es';
+  import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
+  import type { EleProTable } from 'ele-admin-pro/es';
+  import type {
+    DatasourceFunction,
+    ColumnItem
+  } from 'ele-admin-pro/es/ele-pro-table/types';
+  import { messageLoading, toDateString } from 'ele-admin-pro/es';
+  import DictDataSearch from './dict-data-search.vue';
+  import DictDataEdit from './dict-data-edit.vue';
+  import {
+    pageDictionaryData,
+    removeDictionaryData,
+    removeDictionaryDataBatch
+  } from '@/api/system/dictionary-data';
+  import type {
+    DictionaryData,
+    DictionaryDataParam
+  } from '@/api/system/dictionary-data/model';
+
+  const props = defineProps<{
+    // 字典id
+    dictId: number;
+  }>();
+
+  // 表格实例
+  const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
+
+  // 表格列配置
+  const columns = ref<ColumnItem[]>([
+    {
+      title: '字典项名称',
+      dataIndex: 'dictDataName',
+      ellipsis: true,
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '字典项值',
+      dataIndex: 'dictDataCode',
+      ellipsis: true,
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '排序号',
+      dataIndex: 'sortNumber',
+      sorter: true,
+      showSorterTooltip: false,
+      width: 120,
+      align: 'center'
+    },
+    {
+      title: '创建时间',
+      dataIndex: 'createTime',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true,
+      customRender: ({ text }) => toDateString(text)
+    },
+    {
+      title: '操作',
+      key: 'action',
+      width: 130,
+      align: 'center'
+    }
+  ]);
+
+  // 表格选中数据
+  const selection = ref<DictionaryData[]>([]);
+
+  // 当前编辑数据
+  const current = ref<DictionaryData | null>(null);
+
+  // 是否显示编辑弹窗
+  const showEdit = ref(false);
+
+  // 表格数据源
+  const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
+    return pageDictionaryData({
+      ...where,
+      ...orders,
+      page,
+      limit,
+      dictId: props.dictId
+    });
+  };
+
+  /* 刷新表格 */
+  const reload = (where?: DictionaryDataParam) => {
+    tableRef?.value?.reload({ page: 1, where });
+  };
+
+  /* 打开编辑弹窗 */
+  const openEdit = (row?: DictionaryData) => {
+    current.value = row ?? null;
+    showEdit.value = true;
+  };
+
+  /* 删除单个 */
+  const remove = (row: DictionaryData) => {
+    const hide = messageLoading('请求中..', 0);
+    removeDictionaryData(row.dictDataId)
+      .then((msg) => {
+        hide();
+        message.success(msg);
+        reload();
+      })
+      .catch((e) => {
+        hide();
+        message.error(e.message);
+      });
+  };
+
+  /* 批量删除 */
+  const removeBatch = () => {
+    if (!selection.value.length) {
+      message.error('请至少选择一条数据');
+      return;
+    }
+    Modal.confirm({
+      title: '提示',
+      content: '确定要删除选中的字典项吗?',
+      icon: createVNode(ExclamationCircleOutlined),
+      maskClosable: true,
+      onOk: () => {
+        const hide = messageLoading('请求中..', 0);
+        removeDictionaryDataBatch(selection.value.map((d) => d.dictDataId))
+          .then((msg) => {
+            hide();
+            message.success(msg);
+            reload();
+          })
+          .catch((e) => {
+            hide();
+            message.error(e.message);
+          });
+      }
+    });
+  };
+
+  // 监听字典id变化
+  watch(
+    () => props.dictId,
+    () => {
+      reload();
+    }
+  );
+</script>
+
+<style lang="less" scoped>
+  .sys-dict-data-table :deep(.ant-table-body) {
+    overflow: auto !important;
+    overflow: overlay !important;
+  }
+
+  .sys-dict-data-table :deep(.ant-table-pagination.ant-pagination) {
+    padding: 0 4px;
+    margin-bottom: 0;
+  }
+</style>
diff --git a/src/views/system/dictionary/components/dict-edit.vue b/src/views/system/dictionary/components/dict-edit.vue
new file mode 100644
index 0000000..b2de3db
--- /dev/null
+++ b/src/views/system/dictionary/components/dict-edit.vue
@@ -0,0 +1,176 @@
+<!-- 字典编辑弹窗 -->
+<template>
+  <ele-modal
+    :width="460"
+    :visible="visible"
+    :confirm-loading="loading"
+    :title="isUpdate ? '修改字典' : '添加字典'"
+    :body-style="{ paddingBottom: '8px' }"
+    @update:visible="updateVisible"
+    @ok="save"
+  >
+    <a-form
+      ref="formRef"
+      :model="form"
+      :rules="rules"
+      :label-col="styleResponsive ? { md: 5, sm: 5, xs: 24 } : { flex: '90px' }"
+      :wrapper-col="
+        styleResponsive ? { md: 19, sm: 19, xs: 24 } : { flex: '1' }
+      "
+    >
+      <a-form-item label="字典名称" name="dictName">
+        <a-input
+          allow-clear
+          :maxlength="20"
+          placeholder="请输入字典名称"
+          v-model:value="form.dictName"
+        />
+      </a-form-item>
+      <a-form-item label="字典值" name="dictCode">
+        <a-input
+          allow-clear
+          :maxlength="20"
+          placeholder="请输入字典值"
+          v-model:value="form.dictCode"
+        />
+      </a-form-item>
+      <a-form-item label="排序号" name="sortNumber">
+        <a-input-number
+          :min="0"
+          :max="9999"
+          class="ele-fluid"
+          placeholder="请输入排序号"
+          v-model:value="form.sortNumber"
+        />
+      </a-form-item>
+      <a-form-item label="备注">
+        <a-textarea
+          :rows="4"
+          :maxlength="200"
+          placeholder="请输入备注"
+          v-model:value="form.comments"
+        />
+      </a-form-item>
+    </a-form>
+  </ele-modal>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive, watch } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import { addDictionary, updateDictionary } from '@/api/system/dictionary';
+  import type { Dictionary } from '@/api/system/dictionary/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'done'): void;
+    (e: 'update:visible', visible: boolean): void;
+  }>();
+
+  const props = defineProps<{
+    // 弹窗是否打开
+    visible: boolean;
+    // 修改回显的数据
+    data?: Dictionary | null;
+  }>();
+
+  //
+  const formRef = ref<FormInstance | null>(null);
+
+  // 是否是修改
+  const isUpdate = ref(false);
+
+  // 提交状态
+  const loading = ref(false);
+
+  // 表单数据
+  const { form, resetFields, assignFields } = useFormData<Dictionary>({
+    dictId: undefined,
+    dictName: '',
+    dictCode: '',
+    sortNumber: undefined,
+    comments: ''
+  });
+
+  // 表单验证规则
+  const rules = reactive<Record<string, Rule[]>>({
+    dictName: [
+      {
+        required: true,
+        message: '请输入字典名称',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    dictCode: [
+      {
+        required: true,
+        message: '请输入字典值',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    sortNumber: [
+      {
+        required: true,
+        message: '请输入排序号',
+        type: 'number',
+        trigger: 'blur'
+      }
+    ]
+  });
+
+  /* 保存编辑 */
+  const save = () => {
+    if (!formRef.value) {
+      return;
+    }
+    formRef.value
+      .validate()
+      .then(() => {
+        loading.value = true;
+        const saveOrUpdate = isUpdate.value ? updateDictionary : addDictionary;
+        saveOrUpdate(form)
+          .then((msg) => {
+            loading.value = false;
+            message.success(msg);
+            updateVisible(false);
+            emit('done');
+          })
+          .catch((e) => {
+            loading.value = false;
+            message.error(e.message);
+          });
+      })
+      .catch(() => {});
+  };
+
+  /* 更新visible */
+  const updateVisible = (value: boolean) => {
+    emit('update:visible', value);
+  };
+
+  watch(
+    () => props.visible,
+    (visible) => {
+      if (visible) {
+        if (props.data) {
+          assignFields(props.data);
+          isUpdate.value = true;
+        } else {
+          isUpdate.value = false;
+        }
+      } else {
+        resetFields();
+        formRef.value?.clearValidate();
+      }
+    }
+  );
+</script>
diff --git a/src/views/system/dictionary/index.vue b/src/views/system/dictionary/index.vue
new file mode 100644
index 0000000..be8be9c
--- /dev/null
+++ b/src/views/system/dictionary/index.vue
@@ -0,0 +1,197 @@
+<template>
+  <div class="ele-body">
+    <a-card :bordered="false" :body-style="{ padding: '16px' }">
+      <ele-split-layout
+        width="266px"
+        allow-collapse
+        :right-style="{ overflow: 'hidden' }"
+        :style="{ minHeight: 'calc(100vh - 152px)' }"
+      >
+        <!-- 表格 -->
+        <ele-pro-table
+          ref="tableRef"
+          row-key="dictId"
+          :columns="columns"
+          :datasource="datasource"
+          v-model:current="current"
+          selection-type="radio"
+          :row-selection="{ columnWidth: 32 }"
+          :need-page="false"
+          :toolkit="[]"
+          height="calc(100vh - 290px)"
+          tools-theme="default"
+          bordered
+          :custom-row="customRow"
+          class="sys-dict-table"
+          @done="done"
+        >
+          <template #toolbar>
+            <a-space :size="10">
+              <a-button type="primary" class="ele-btn-icon" @click="openEdit()">
+                <template #icon>
+                  <plus-outlined />
+                </template>
+                <span>新建</span>
+              </a-button>
+              <a-button
+                type="primary"
+                :disabled="!current"
+                class="ele-btn-icon"
+                @click="openEdit(current)"
+              >
+                <template #icon>
+                  <edit-outlined />
+                </template>
+                <span>修改</span>
+              </a-button>
+              <a-button
+                danger
+                type="primary"
+                :disabled="!current"
+                class="ele-btn-icon"
+                @click="remove"
+              >
+                <template #icon>
+                  <delete-outlined />
+                </template>
+                <span>删除</span>
+              </a-button>
+            </a-space>
+          </template>
+        </ele-pro-table>
+        <template #content>
+          <dict-data
+            v-if="current && current.dictId"
+            :dict-id="current.dictId"
+          />
+        </template>
+      </ele-split-layout>
+    </a-card>
+    <!-- 编辑弹窗 -->
+    <dict-edit v-model:visible="showEdit" :data="editData" @done="reload" />
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { createVNode, ref } from 'vue';
+  import { message, Modal } from 'ant-design-vue/es';
+  import {
+    PlusOutlined,
+    EditOutlined,
+    DeleteOutlined,
+    ExclamationCircleOutlined
+  } from '@ant-design/icons-vue';
+  import { messageLoading } from 'ele-admin-pro/es';
+  import type { EleProTable } from 'ele-admin-pro/es';
+  import type {
+    DatasourceFunction,
+    ColumnItem,
+    EleProTableDone
+  } from 'ele-admin-pro/es/ele-pro-table/types';
+  import DictData from './components/dict-data.vue';
+  import DictEdit from './components/dict-edit.vue';
+  import { listDictionaries, removeDictionary } from '@/api/system/dictionary';
+  import type { Dictionary } from '@/api/system/dictionary/model';
+
+  // 表格实例
+  const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
+
+  // 表格列配置
+  const columns = ref<ColumnItem[]>([
+    {
+      key: 'index',
+      width: 32,
+      align: 'center',
+      fixed: 'left',
+      hideInSetting: true,
+      customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
+    },
+    {
+      title: '字典名称',
+      dataIndex: 'dictName'
+    }
+  ]);
+
+  // 表格选中数据
+  const current = ref<Dictionary | null>(null);
+
+  // 是否显示编辑弹窗
+  const showEdit = ref(false);
+
+  // 编辑回显数据
+  const editData = ref<Dictionary | null>(null);
+
+  // 表格数据源
+  const datasource: DatasourceFunction = () => {
+    return listDictionaries();
+  };
+
+  /* 表格渲染完成回调 */
+  const done: EleProTableDone<Dictionary> = (res) => {
+    if (res.data?.length) {
+      current.value = res.data[0];
+    }
+  };
+
+  /* 刷新表格 */
+  const reload = () => {
+    tableRef?.value?.reload();
+  };
+
+  /* 打开编辑弹窗 */
+  const openEdit = (row?: Dictionary | null) => {
+    editData.value = row ?? null;
+    showEdit.value = true;
+  };
+
+  /* 删除 */
+  const remove = () => {
+    Modal.confirm({
+      title: '提示',
+      content: '确定要删除选中的字典吗?',
+      icon: createVNode(ExclamationCircleOutlined),
+      maskClosable: true,
+      onOk: () => {
+        const hide = messageLoading('请求中..', 0);
+        removeDictionary(current.value?.dictId)
+          .then((msg) => {
+            hide();
+            message.success(msg);
+            reload();
+          })
+          .catch((e) => {
+            hide();
+            message.error(e.message);
+          });
+      }
+    });
+  };
+
+  /* 行点击事件 */
+  const customRow = (record: Dictionary) => {
+    return {
+      onClick: () => {
+        current.value = record;
+      }
+    };
+  };
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'SystemDictionary'
+  };
+</script>
+
+<style lang="less" scoped>
+  .sys-dict-table {
+    :deep(.ant-table-body) {
+      overflow: auto !important;
+      overflow: overlay !important;
+    }
+
+    :deep(.ant-table-row) {
+      cursor: pointer;
+    }
+  }
+</style>
diff --git a/src/views/system/file/components/file-search.vue b/src/views/system/file/components/file-search.vue
new file mode 100644
index 0000000..bd9ded8
--- /dev/null
+++ b/src/views/system/file/components/file-search.vue
@@ -0,0 +1,106 @@
+<!-- 搜索表单 -->
+<template>
+  <a-form
+    :label-col="
+      styleResponsive ? { xl: 7, lg: 5, md: 7, sm: 4 } : { flex: '90px' }
+    "
+    :wrapper-col="
+      styleResponsive ? { xl: 17, lg: 19, md: 17, sm: 20 } : { flex: '1' }
+    "
+  >
+    <a-row :gutter="8">
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="文件名称">
+          <a-input
+            v-model:value.trim="form.name"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="文件路径">
+          <a-input
+            v-model:value.trim="form.path"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="上传人">
+          <a-input
+            v-model:value.trim="form.createNickname"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item class="ele-text-right" :wrapper-col="{ span: 24 }">
+          <a-space>
+            <a-button type="primary" @click="search">查询</a-button>
+            <a-button @click="reset">重置</a-button>
+          </a-space>
+        </a-form-item>
+      </a-col>
+    </a-row>
+  </a-form>
+</template>
+
+<script lang="ts" setup>
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import type { FileRecordParam } from '@/api/system/file/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'search', where?: FileRecordParam): void;
+  }>();
+
+  // 表单数据
+  const { form, resetFields } = useFormData<FileRecordParam>({
+    name: '',
+    path: '',
+    createNickname: ''
+  });
+
+  /* 搜索 */
+  const search = () => {
+    emit('search', form);
+  };
+
+  /*  重置 */
+  const reset = () => {
+    resetFields();
+    search();
+  };
+</script>
diff --git a/src/views/system/file/index.vue b/src/views/system/file/index.vue
new file mode 100644
index 0000000..e2e5cb4
--- /dev/null
+++ b/src/views/system/file/index.vue
@@ -0,0 +1,244 @@
+<template>
+  <div class="ele-body">
+    <a-card :bordered="false">
+      <!-- 搜索表单 -->
+      <file-search @search="reload" />
+      <!-- 表格 -->
+      <ele-pro-table
+        ref="tableRef"
+        row-key="id"
+        :columns="columns"
+        :datasource="datasource"
+        v-model:selection="selection"
+        :scroll="{ x: 800 }"
+        cache-key="proSystemFileTable"
+      >
+        <template #toolbar>
+          <a-space>
+            <a-upload :show-upload-list="false" :customRequest="onUpload">
+              <a-button type="primary" class="ele-btn-icon">
+                <template #icon>
+                  <upload-outlined />
+                </template>
+                <span>上传</span>
+              </a-button>
+            </a-upload>
+            <a-button
+              danger
+              type="primary"
+              class="ele-btn-icon"
+              @click="removeBatch"
+            >
+              <template #icon>
+                <delete-outlined />
+              </template>
+              <span>删除</span>
+            </a-button>
+          </a-space>
+        </template>
+        <template #bodyCell="{ column, record }">
+          <template v-if="column.key === 'path'">
+            <a :href="record.url" target="_blank">
+              {{ record.path }}
+            </a>
+          </template>
+          <template v-else-if="column.key === 'action'">
+            <a-space>
+              <a :href="record.downloadUrl" target="_blank">下载</a>
+              <a-divider type="vertical" />
+              <a-popconfirm
+                placement="topRight"
+                title="确定要删除此文件吗?"
+                @confirm="remove(record)"
+              >
+                <a class="ele-text-danger">删除</a>
+              </a-popconfirm>
+            </a-space>
+          </template>
+        </template>
+      </ele-pro-table>
+    </a-card>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { createVNode, ref } from 'vue';
+  import { message, Modal } from 'ant-design-vue/es';
+  import {
+    UploadOutlined,
+    DeleteOutlined,
+    ExclamationCircleOutlined
+  } from '@ant-design/icons-vue';
+  import type { EleProTable } from 'ele-admin-pro/es';
+  import type {
+    DatasourceFunction,
+    ColumnItem
+  } from 'ele-admin-pro/es/ele-pro-table/types';
+  import { messageLoading, toDateString } from 'ele-admin-pro/es';
+  import FileSearch from './components/file-search.vue';
+  import {
+    pageFiles,
+    removeFile,
+    removeFiles,
+    uploadFile
+  } from '@/api/system/file';
+  import type { FileRecord, FileRecordParam } from '@/api/system/file/model';
+
+  // 表格实例
+  const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
+
+  // 表格列配置
+  const columns = ref<ColumnItem[]>([
+    {
+      key: 'index',
+      width: 48,
+      align: 'center',
+      fixed: 'left',
+      hideInSetting: true,
+      customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
+    },
+    {
+      title: '文件名称',
+      dataIndex: 'name',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true
+    },
+    {
+      title: '文件路径',
+      key: 'path',
+      dataIndex: 'path',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true
+    },
+    {
+      title: '文件大小',
+      dataIndex: 'length',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true,
+      customRender: ({ text }) => {
+        if (text < 1024) {
+          return text + 'B';
+        } else if (text < 1024 * 1024) {
+          return (text / 1024).toFixed(1) + 'KB';
+        } else if (text < 1024 * 1024 * 1024) {
+          return (text / 1024 / 1024).toFixed(1) + 'M';
+        } else {
+          return (text / 1024 / 1024 / 1024).toFixed(1) + 'G';
+        }
+      },
+      width: 120
+    },
+    {
+      title: '上传人',
+      dataIndex: 'createNickname',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true,
+      width: 120
+    },
+    {
+      title: '上传时间',
+      dataIndex: 'createTime',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true,
+      customRender: ({ text }) => toDateString(text),
+      width: 160
+    },
+    {
+      title: '操作',
+      key: 'action',
+      width: 120,
+      align: 'center'
+    }
+  ]);
+
+  // 表格选中数据
+  const selection = ref<FileRecord[]>([]);
+
+  // 表格数据源
+  const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
+    return pageFiles({ ...where, ...orders, page, limit });
+  };
+
+  /* 搜索 */
+  const reload = (where?: FileRecordParam) => {
+    selection.value = [];
+    tableRef?.value?.reload({ page: 1, where });
+  };
+
+  /* 删除单个 */
+  const remove = (row: FileRecord) => {
+    const hide = messageLoading('请求中..', 0);
+    removeFile(row.id)
+      .then((msg) => {
+        hide();
+        message.success(msg);
+        reload();
+      })
+      .catch((e) => {
+        hide();
+        message.error(e.message);
+      });
+  };
+
+  /* 批量删除 */
+  const removeBatch = () => {
+    if (!selection.value.length) {
+      message.error('请至少选择一条数据');
+      return;
+    }
+    Modal.confirm({
+      title: '提示',
+      content: '确定要删除选中的文件吗?',
+      icon: createVNode(ExclamationCircleOutlined),
+      maskClosable: true,
+      onOk: () => {
+        const hide = messageLoading('请求中..', 0);
+        removeFiles(selection.value.map((d) => d.id))
+          .then((msg) => {
+            hide();
+            message.success(msg);
+            reload();
+          })
+          .catch((e) => {
+            hide();
+            message.error(e.message);
+          });
+      }
+    });
+  };
+
+  /* 上传 */
+  const onUpload = ({ file }) => {
+    if (file.size / 1024 / 1024 > 100) {
+      message.error('大小不能超过 100MB');
+      return false;
+    }
+    const hide = messageLoading({
+      content: '上传中..',
+      duration: 0,
+      mask: true
+    });
+    uploadFile(file)
+      .then(() => {
+        hide();
+        message.success('上传成功');
+        reload();
+      })
+      .catch((e) => {
+        hide();
+        message.error(e.message);
+      });
+    return false;
+  };
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'SystemFile'
+  };
+</script>
diff --git a/src/views/system/login-record/components/login-record-search.vue b/src/views/system/login-record/components/login-record-search.vue
new file mode 100644
index 0000000..c1c480b
--- /dev/null
+++ b/src/views/system/login-record/components/login-record-search.vue
@@ -0,0 +1,115 @@
+<!-- 搜索表单 -->
+<template>
+  <a-form
+    :label-col="
+      styleResponsive ? { xl: 7, lg: 5, md: 7, sm: 4 } : { flex: '90px' }
+    "
+    :wrapper-col="
+      styleResponsive ? { xl: 17, lg: 19, md: 17, sm: 20 } : { flex: '1' }
+    "
+  >
+    <a-row :gutter="8">
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="用户账号">
+          <a-input
+            v-model:value.trim="form.username"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="用户名">
+          <a-input
+            v-model:value.trim="form.nickname"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="登录时间">
+          <a-range-picker
+            v-model:value="dateRange"
+            value-format="YYYY-MM-DD"
+            class="ele-fluid"
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item class="ele-text-right" :wrapper-col="{ span: 24 }">
+          <a-space>
+            <a-button type="primary" @click="search">查询</a-button>
+            <a-button @click="reset">重置</a-button>
+          </a-space>
+        </a-form-item>
+      </a-col>
+    </a-row>
+  </a-form>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import type { LoginRecordParam } from '@/api/system/login-record/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'search', where?: LoginRecordParam): void;
+  }>();
+
+  // 表单数据
+  const { form, resetFields } = useFormData<LoginRecordParam>({
+    username: '',
+    nickname: ''
+  });
+
+  // 日期范围选择
+  const dateRange = ref<[string, string]>(['', '']);
+
+  /* 搜索 */
+  const search = () => {
+    const [d1, d2] = dateRange.value ?? [];
+    emit('search', {
+      ...form,
+      createTimeStart: d1 ? d1 + ' 00:00:00' : '',
+      createTimeEnd: d2 ? d2 + ' 23:59:59' : ''
+    });
+  };
+
+  /*  重置 */
+  const reset = () => {
+    resetFields();
+    dateRange.value = ['', ''];
+    search();
+  };
+</script>
diff --git a/src/views/system/login-record/index.vue b/src/views/system/login-record/index.vue
new file mode 100644
index 0000000..2f62337
--- /dev/null
+++ b/src/views/system/login-record/index.vue
@@ -0,0 +1,235 @@
+<template>
+  <div class="ele-body">
+    <a-card :bordered="false">
+      <!-- 搜索表单 -->
+      <login-record-search @search="reload" />
+      <!-- 表格 -->
+      <ele-pro-table
+        ref="tableRef"
+        row-key="id"
+        :columns="columns"
+        :datasource="datasource"
+        :scroll="{ x: 900 }"
+        cache-key="proSystemLoginRecordTable"
+      >
+        <template #toolbar>
+          <a-space>
+            <a-button type="primary" class="ele-btn-icon" @click="exportData">
+              <template #icon>
+                <download-outlined />
+              </template>
+              <span>导出</span>
+            </a-button>
+          </a-space>
+        </template>
+        <template #bodyCell="{ column, record }">
+          <template v-if="column.key === 'loginType'">
+            <a-tag v-if="record.loginType === 0" color="green">登录成功</a-tag>
+            <a-tag v-else-if="record.loginType === 1" color="red">
+              登录失败
+            </a-tag>
+            <a-tag v-else-if="record.loginType === 2">退出登录</a-tag>
+            <a-tag v-else-if="record.loginType === 3" color="orange">
+              刷新TOKEN
+            </a-tag>
+          </template>
+        </template>
+      </ele-pro-table>
+    </a-card>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import { utils, writeFile } from 'xlsx';
+  import { DownloadOutlined } from '@ant-design/icons-vue';
+  import type { EleProTable } from 'ele-admin-pro/es';
+  import type {
+    DatasourceFunction,
+    ColumnItem
+  } from 'ele-admin-pro/es/ele-pro-table/types';
+  import { messageLoading, toDateString } from 'ele-admin-pro/es';
+  import LoginRecordSearch from './components/login-record-search.vue';
+  import {
+    pageLoginRecords,
+    listLoginRecords
+  } from '@/api/system/login-record';
+  import type { LoginRecordParam } from '@/api/system/login-record/model';
+
+  // 表格实例
+  const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
+
+  // 表格列配置
+  const columns = ref<ColumnItem[]>([
+    {
+      key: 'index',
+      width: 48,
+      align: 'center',
+      fixed: 'left',
+      hideInSetting: true,
+      customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
+    },
+    {
+      title: '账号',
+      dataIndex: 'username',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '用户名',
+      dataIndex: 'nickname',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: 'IP地址',
+      dataIndex: 'ip',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true
+    },
+    {
+      title: '设备型号',
+      dataIndex: 'device',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true
+    },
+    {
+      title: '操作系统',
+      dataIndex: 'os',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true
+    },
+    {
+      title: '浏览器',
+      dataIndex: 'browser',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true
+    },
+    {
+      title: '操作类型',
+      key: 'loginType',
+      dataIndex: 'loginType',
+      sorter: true,
+      showSorterTooltip: false,
+      width: 120,
+      filters: [
+        {
+          text: '登录成功',
+          value: 0
+        },
+        {
+          text: '登录失败',
+          value: 1
+        },
+        {
+          text: '退出登录',
+          value: 2
+        },
+        {
+          text: '刷新TOKEN',
+          value: 3
+        }
+      ],
+      filterMultiple: false
+    },
+    {
+      title: '备注',
+      dataIndex: 'comments',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true
+    },
+    {
+      title: '登录时间',
+      dataIndex: 'createTime',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true,
+      customRender: ({ text }) => toDateString(text)
+    }
+  ]);
+
+  // 表格数据源
+  const datasource: DatasourceFunction = ({
+    page,
+    limit,
+    where,
+    orders,
+    filters
+  }) => {
+    return pageLoginRecords({
+      ...where,
+      ...orders,
+      ...filters,
+      page,
+      limit
+    });
+  };
+
+  /* 刷新表格 */
+  const reload = (where?: LoginRecordParam) => {
+    tableRef?.value?.reload({ page: 1, where });
+  };
+
+  /* 导出数据 */
+  const exportData = () => {
+    const array = [
+      [
+        '账号',
+        '用户名',
+        'IP地址',
+        '设备型号',
+        '操作系统',
+        '浏览器',
+        '操作类型',
+        '备注',
+        '登录时间'
+      ]
+    ];
+    // 请求查询全部接口
+    const hide = messageLoading('请求中..', 0);
+    tableRef.value?.doRequest(({ where, orders, filters }) => {
+      listLoginRecords({ ...where, ...orders, ...filters })
+        .then((data) => {
+          hide();
+          data.forEach((d) => {
+            array.push([
+              d.username,
+              d.nickname,
+              d.ip,
+              d.device,
+              d.os,
+              d.browser,
+              ['登录成功', '登录失败', '退出登录', '刷新TOKEN'][d.loginType],
+              d.comments,
+              toDateString(d.createTime)
+            ]);
+          });
+          writeFile(
+            {
+              SheetNames: ['Sheet1'],
+              Sheets: {
+                Sheet1: utils.aoa_to_sheet(array)
+              }
+            },
+            '登录日志.xlsx'
+          );
+        })
+        .catch((e) => {
+          hide();
+          message.error(e.message);
+        });
+    });
+  };
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'SystemLoginRecord'
+  };
+</script>
diff --git a/src/views/system/menu/components/menu-edit.vue b/src/views/system/menu/components/menu-edit.vue
new file mode 100644
index 0000000..f336a0d
--- /dev/null
+++ b/src/views/system/menu/components/menu-edit.vue
@@ -0,0 +1,414 @@
+<!-- 编辑弹窗 -->
+<template>
+  <ele-modal
+    :width="740"
+    :visible="visible"
+    :confirm-loading="loading"
+    :title="isUpdate ? '修改菜单' : '新建菜单'"
+    :body-style="{ paddingBottom: '8px' }"
+    @update:visible="updateVisible"
+    @ok="save"
+  >
+    <a-form
+      ref="formRef"
+      :model="form"
+      :rules="rules"
+      :label-col="styleResponsive ? { md: 6, sm: 4, xs: 24 } : { flex: '90px' }"
+      :wrapper-col="
+        styleResponsive ? { md: 18, sm: 20, xs: 24 } : { flex: '1' }
+      "
+    >
+      <a-row :gutter="16">
+        <a-col
+          v-bind="styleResponsive ? { md: 12, sm: 24, xs: 24 } : { span: 12 }"
+        >
+          <a-form-item label="上级菜单" name="parentId">
+            <a-tree-select
+              allow-clear
+              :tree-data="menuList"
+              tree-default-expand-all
+              placeholder="请选择上级菜单"
+              :value="form.parentId || undefined"
+              :dropdown-style="{ maxHeight: '360px', overflow: 'auto' }"
+              @update:value="(value?: number) => (form.parentId = value)"
+            />
+          </a-form-item>
+          <a-form-item label="菜单名称" name="title">
+            <a-input
+              allow-clear
+              placeholder="请输入菜单名称"
+              v-model:value="form.title"
+            />
+          </a-form-item>
+        </a-col>
+        <a-col
+          v-bind="styleResponsive ? { md: 12, sm: 24, xs: 24 } : { span: 12 }"
+        >
+          <a-form-item label="菜单类型" name="menuType">
+            <a-radio-group
+              v-model:value="form.menuType"
+              @change="onMenuTypeChange"
+            >
+              <a-radio :value="0">目录</a-radio>
+              <a-radio :value="1">菜单</a-radio>
+              <a-radio :value="2">按钮</a-radio>
+            </a-radio-group>
+          </a-form-item>
+          <a-form-item label="打开方式">
+            <a-radio-group
+              v-model:value="form.openType"
+              :disabled="form.menuType === 0 || form.menuType === 2"
+              @change="onOpenTypeChange"
+            >
+              <a-radio :value="0">组件</a-radio>
+              <a-radio :value="1">内链</a-radio>
+              <a-radio :value="2">外链</a-radio>
+            </a-radio-group>
+          </a-form-item>
+        </a-col>
+      </a-row>
+      <div style="margin-bottom: 22px">
+        <a-divider />
+      </div>
+      <a-row :gutter="16">
+        <a-col
+          v-bind="styleResponsive ? { md: 12, sm: 24, xs: 24 } : { span: 12 }"
+        >
+          <a-form-item label="菜单图标" name="icon">
+            <ele-icon-picker
+              :data="iconData"
+              :allow-search="false"
+              v-model:value="form.icon"
+              placeholder="请选择菜单图标"
+              :disabled="form.menuType === 2"
+            >
+              <template #icon="{ icon }">
+                <component :is="icon" />
+              </template>
+            </ele-icon-picker>
+          </a-form-item>
+          <a-form-item name="path">
+            <template #label>
+              <a-tooltip
+                v-if="form.openType === 2"
+                title="需要以`http://`、`https://`、`//`开头"
+              >
+                <question-circle-outlined
+                  style="vertical-align: -2px; margin-right: 4px"
+                />
+              </a-tooltip>
+              <span>{{ form.openType === 2 ? '外链地址' : '路由地址' }}</span>
+            </template>
+            <a-input
+              allow-clear
+              v-model:value="form.path"
+              :disabled="form.menuType === 2"
+              :placeholder="
+                form.openType === 2 ? '请输入外链地址' : '请输入路由地址'
+              "
+            />
+          </a-form-item>
+          <a-form-item name="component">
+            <template #label>
+              <a-tooltip
+                v-if="form.openType === 1"
+                title="需要以`http://`、`https://`、`//`开头"
+              >
+                <question-circle-outlined
+                  style="vertical-align: -2px; margin-right: 4px"
+                />
+              </a-tooltip>
+              <span>{{ form.openType === 1 ? '内链地址' : '组件路径' }}</span>
+            </template>
+            <a-input
+              allow-clear
+              v-model:value="form.component"
+              :disabled="
+                form.menuType === 0 ||
+                form.menuType === 2 ||
+                form.openType === 2
+              "
+              :placeholder="
+                form.openType === 1 ? '请输入内链地址' : '请输入组件路径'
+              "
+            />
+          </a-form-item>
+        </a-col>
+        <a-col
+          v-bind="styleResponsive ? { md: 12, sm: 24, xs: 24 } : { span: 12 }"
+        >
+          <a-form-item label="权限标识" name="authority">
+            <a-input
+              allow-clear
+              placeholder="请输入权限标识"
+              v-model:value="form.authority"
+              :disabled="
+                form.menuType === 0 ||
+                (form.menuType === 1 && form.openType === 2)
+              "
+            />
+          </a-form-item>
+          <a-form-item label="排序号" name="sortNumber">
+            <a-input-number
+              :min="0"
+              :max="99999"
+              class="ele-fluid"
+              placeholder="请输入排序号"
+              v-model:value="form.sortNumber"
+            />
+          </a-form-item>
+          <a-form-item label="是否展示">
+            <a-switch
+              checked-children="是"
+              un-checked-children="否"
+              :checked="form.hide === 0"
+              :disabled="form.menuType === 2"
+              @update:checked="updateHideValue"
+            />
+            <a-tooltip
+              title="选择不展示只注册路由不展示在侧边栏, 比如添加页面应该选择不展示"
+            >
+              <question-circle-outlined
+                style="vertical-align: -4px; margin-left: 16px"
+              />
+            </a-tooltip>
+          </a-form-item>
+        </a-col>
+      </a-row>
+      <a-form-item
+        label="路由元数据"
+        name="meta"
+        :label-col="
+          styleResponsive ? { md: 3, sm: 4, xs: 24 } : { flex: '90px' }
+        "
+        :wrapper-col="
+          styleResponsive ? { md: 21, sm: 20, xs: 24 } : { flex: '1' }
+        "
+      >
+        <a-textarea
+          :rows="4"
+          :maxlength="200"
+          placeholder="请输入JSON格式的路由元数据"
+          v-model:value="form.meta"
+        />
+      </a-form-item>
+    </a-form>
+  </ele-modal>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive, watch } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import { QuestionCircleOutlined } from '@ant-design/icons-vue';
+  import { isExternalLink } from 'ele-admin-pro/es';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import { addMenu, updateMenu } from '@/api/system/menu';
+  import type { Menu } from '@/api/system/menu/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'done'): void;
+    (e: 'update:visible', visible: boolean): void;
+  }>();
+
+  const props = defineProps<{
+    // 弹窗是否打开
+    visible: boolean;
+    // 修改回显的数据
+    data?: Menu | null;
+    // 上级菜单id
+    parentId?: number;
+    // 全部菜单数据
+    menuList: Menu[];
+  }>();
+
+  //
+  const formRef = ref<FormInstance | null>(null);
+
+  // 是否是修改
+  const isUpdate = ref(false);
+
+  // 提交状态
+  const loading = ref(false);
+
+  // 表单数据
+  const { form, resetFields, assignFields } = useFormData<Menu>({
+    menuId: undefined,
+    parentId: undefined,
+    title: '',
+    menuType: 0,
+    openType: 0,
+    icon: '',
+    path: '',
+    component: '',
+    authority: '',
+    sortNumber: undefined,
+    hide: 0,
+    meta: ''
+  });
+
+  // 表单验证规则
+  const rules = reactive<Record<string, Rule[]>>({
+    title: [
+      {
+        required: true,
+        message: '请输入菜单名称',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    sortNumber: [
+      {
+        required: true,
+        message: '请输入排序号',
+        type: 'number',
+        trigger: 'blur'
+      }
+    ],
+    meta: [
+      {
+        type: 'string',
+        validator: async (_rule: Rule, value: string) => {
+          if (value) {
+            const msg = '请输入正确的JSON格式';
+            try {
+              const obj = JSON.parse(value);
+              if (typeof obj !== 'object' || obj === null) {
+                return Promise.reject(msg);
+              }
+            } catch (_e) {
+              return Promise.reject(msg);
+            }
+          }
+          return Promise.resolve();
+        },
+        trigger: 'blur'
+      }
+    ]
+  });
+
+  /* 保存编辑 */
+  const save = () => {
+    if (!formRef.value) {
+      return;
+    }
+    formRef.value
+      .validate()
+      .then(() => {
+        loading.value = true;
+        const menuForm = {
+          ...form,
+          // menuType 对应的值与后端不一致在前端处理
+          menuType: form.menuType === 2 ? 1 : 0,
+          parentId: form.parentId || 0
+        };
+        const saveOrUpdate = isUpdate.value ? updateMenu : addMenu;
+        saveOrUpdate(menuForm)
+          .then((msg) => {
+            loading.value = false;
+            message.success(msg);
+            updateVisible(false);
+            emit('done');
+          })
+          .catch((e) => {
+            loading.value = false;
+            message.error(e.message);
+          });
+      })
+      .catch(() => {});
+  };
+
+  /* 更新visible */
+  const updateVisible = (value: boolean) => {
+    emit('update:visible', value);
+  };
+
+  /* menuType选择改变 */
+  const onMenuTypeChange = () => {
+    if (form.menuType === 0) {
+      form.authority = '';
+      form.openType = 0;
+      form.component = '';
+    } else if (form.menuType === 1) {
+      if (form.openType === 2) {
+        form.authority = '';
+      }
+    } else {
+      form.openType = 0;
+      form.icon = '';
+      form.path = '';
+      form.component = '';
+      form.hide = 0;
+    }
+  };
+
+  /* openType选择改变 */
+  const onOpenTypeChange = () => {
+    if (form.openType === 2) {
+      form.component = '';
+      form.authority = '';
+    }
+  };
+
+  const updateHideValue = (value: boolean) => {
+    form.hide = value ? 0 : 1;
+  };
+
+  /* 判断是否是目录 */
+  const isDirectory = (d: Menu) => {
+    return !!d.children?.length && !d.component;
+  };
+
+  watch(
+    () => props.visible,
+    (visible) => {
+      if (visible) {
+        if (props.data) {
+          const isExternal = isExternalLink(props.data.path);
+          const isInner = isExternalLink(props.data.component);
+          // menuType 对应的值与后端不一致在前端处理
+          const menuType =
+            props.data.menuType === 1 ? 2 : isDirectory(props.data) ? 0 : 1;
+          assignFields({
+            ...props.data,
+            menuType,
+            openType: isExternal ? 2 : isInner ? 1 : 0,
+            parentId:
+              props.data.parentId === 0 ? undefined : props.data.parentId
+          });
+          isUpdate.value = true;
+        } else {
+          form.parentId = props.parentId;
+          isUpdate.value = false;
+        }
+      } else {
+        resetFields();
+        formRef.value?.clearValidate();
+      }
+    }
+  );
+</script>
+
+<script lang="ts">
+  import * as icons from '@/layout/menu-icons';
+
+  export default {
+    components: icons,
+    data() {
+      return {
+        iconData: [
+          {
+            title: '已引入的图标',
+            icons: Object.keys(icons)
+          }
+        ]
+      };
+    }
+  };
+</script>
diff --git a/src/views/system/menu/components/menu-search.vue b/src/views/system/menu/components/menu-search.vue
new file mode 100644
index 0000000..81afbf5
--- /dev/null
+++ b/src/views/system/menu/components/menu-search.vue
@@ -0,0 +1,106 @@
+<!-- 搜索表单 -->
+<template>
+  <a-form
+    :label-col="
+      styleResponsive ? { xl: 7, lg: 5, md: 7, sm: 4 } : { flex: '90px' }
+    "
+    :wrapper-col="
+      styleResponsive ? { xl: 17, lg: 19, md: 17, sm: 20 } : { flex: '1' }
+    "
+  >
+    <a-row :gutter="8">
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="菜单名称">
+          <a-input
+            v-model:value.trim="form.title"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="菜单地址">
+          <a-input
+            v-model:value.trim="form.path"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="权限标识">
+          <a-input
+            v-model:value.trim="form.authority"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item class="ele-text-right" :wrapper-col="{ span: 24 }">
+          <a-space>
+            <a-button type="primary" @click="search">查询</a-button>
+            <a-button @click="reset">重置</a-button>
+          </a-space>
+        </a-form-item>
+      </a-col>
+    </a-row>
+  </a-form>
+</template>
+
+<script lang="ts" setup>
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import type { MenuParam } from '@/api/system/menu/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'search', where?: MenuParam): void;
+  }>();
+
+  // 表单数据
+  const { form, resetFields } = useFormData<MenuParam>({
+    title: '',
+    path: '',
+    authority: ''
+  });
+
+  /* 搜索 */
+  const search = () => {
+    emit('search', form);
+  };
+
+  /*  重置 */
+  const reset = () => {
+    resetFields();
+    search();
+  };
+</script>
diff --git a/src/views/system/menu/index.vue b/src/views/system/menu/index.vue
new file mode 100644
index 0000000..24a24e1
--- /dev/null
+++ b/src/views/system/menu/index.vue
@@ -0,0 +1,291 @@
+<template>
+  <div class="ele-body">
+    <a-card :bordered="false">
+      <!-- 搜索表单 -->
+      <menu-search @search="reload" />
+      <!-- 表格 -->
+      <ele-pro-table
+        ref="tableRef"
+        row-key="menuId"
+        :columns="columns"
+        :datasource="datasource"
+        :parse-data="parseData"
+        :need-page="false"
+        :expand-icon-column-index="1"
+        :expanded-row-keys="expandedRowKeys"
+        :scroll="{ x: 1200 }"
+        cache-key="proSystemMenuTable"
+        @done="onDone"
+        @expand="onExpand"
+      >
+        <template #toolbar>
+          <a-space>
+            <a-button type="primary" class="ele-btn-icon" @click="openEdit()">
+              <template #icon>
+                <plus-outlined />
+              </template>
+              <span>新建</span>
+            </a-button>
+            <a-button type="dashed" class="ele-btn-icon" @click="expandAll">
+              展开全部
+            </a-button>
+            <a-button type="dashed" class="ele-btn-icon" @click="foldAll">
+              折叠全部
+            </a-button>
+          </a-space>
+        </template>
+        <template #bodyCell="{ column, record }">
+          <template v-if="column.key === 'menuType'">
+            <a-tag v-if="isExternalLink(record.path)" color="red">外链</a-tag>
+            <a-tag v-else-if="isExternalLink(record.component)" color="orange">
+              内链
+            </a-tag>
+            <a-tag v-else-if="isDirectory(record)" color="blue">目录</a-tag>
+            <a-tag v-else-if="record.menuType === 0" color="green">菜单</a-tag>
+            <a-tag v-else-if="record.menuType === 1">按钮</a-tag>
+          </template>
+          <template v-else-if="column.key === 'title'">
+            <component v-if="record.icon" :is="record.icon" />
+            <span style="padding-left: 8px">{{ record.title }}</span>
+          </template>
+          <template v-else-if="column.key === 'action'">
+            <a-space>
+              <a @click="openEdit(null, record.menuId)">添加</a>
+              <a-divider type="vertical" />
+              <a @click="openEdit(record)">修改</a>
+              <a-divider type="vertical" />
+              <a-popconfirm
+                placement="topRight"
+                title="确定要删除此菜单吗?"
+                @confirm="remove(record)"
+              >
+                <a class="ele-text-danger">删除</a>
+              </a-popconfirm>
+            </a-space>
+          </template>
+        </template>
+      </ele-pro-table>
+    </a-card>
+    <!-- 编辑弹窗 -->
+    <menu-edit
+      v-model:visible="showEdit"
+      :data="current"
+      :parent-id="parentId"
+      :menu-list="menuData"
+      @done="reload"
+    />
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import { PlusOutlined } from '@ant-design/icons-vue';
+  import type {
+    DatasourceFunction,
+    ColumnItem,
+    EleProTableDone
+  } from 'ele-admin-pro/es/ele-pro-table/types';
+  import MenuSearch from './components/menu-search.vue';
+  import {
+    messageLoading,
+    toDateString,
+    isExternalLink,
+    toTreeData,
+    eachTreeData
+  } from 'ele-admin-pro/es';
+  import type { EleProTable } from 'ele-admin-pro/es';
+  import MenuEdit from './components/menu-edit.vue';
+  import { listMenus, removeMenu } from '@/api/system/menu';
+  import type { Menu, MenuParam } from '@/api/system/menu/model';
+
+  // 表格实例
+  const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
+
+  // 表格列配置
+  const columns = ref<ColumnItem[]>([
+    {
+      key: 'index',
+      width: 48,
+      align: 'center',
+      fixed: 'left',
+      hideInSetting: true,
+      customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
+    },
+    {
+      title: '菜单名称',
+      key: 'title',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true
+    },
+    {
+      title: '路由地址',
+      dataIndex: 'path',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true
+    },
+    {
+      title: '组件路径',
+      dataIndex: 'component',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true
+    },
+    {
+      title: '权限标识',
+      dataIndex: 'authority',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true
+    },
+    {
+      title: '排序',
+      dataIndex: 'sortNumber',
+      sorter: true,
+      showSorterTooltip: false,
+      width: 90
+    },
+    {
+      title: '可见',
+      dataIndex: 'hide',
+      sorter: true,
+      showSorterTooltip: false,
+      customRender: ({ text }) => ['是', '否'][text],
+      width: 90
+    },
+    {
+      title: '类型',
+      key: 'menuType',
+      sorter: true,
+      showSorterTooltip: false,
+      width: 90
+    },
+    {
+      title: '创建时间',
+      dataIndex: 'createTime',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true,
+      customRender: ({ text }) => toDateString(text)
+    },
+    {
+      title: '操作',
+      key: 'action',
+      width: 200,
+      align: 'center'
+    }
+  ]);
+
+  // 当前编辑数据
+  const current = ref<Menu | null>(null);
+
+  // 是否显示编辑弹窗
+  const showEdit = ref(false);
+
+  // 上级菜单id
+  const parentId = ref<number>();
+
+  // 菜单数据
+  const menuData = ref<Menu[]>([]);
+
+  // 表格展开的行
+  const expandedRowKeys = ref<number[]>([]);
+
+  // 表格数据源
+  const datasource: DatasourceFunction = ({ where }) => {
+    return listMenus({ ...where });
+  };
+
+  /* 数据转为树形结构 */
+  const parseData = (data: Menu[]) => {
+    return toTreeData({
+      data: data.map((d) => {
+        return { ...d, key: d.menuId, value: d.menuId };
+      }),
+      idField: 'menuId',
+      parentIdField: 'parentId'
+    });
+  };
+
+  /* 表格渲染完成回调 */
+  const onDone: EleProTableDone<Menu> = ({ data }) => {
+    menuData.value = data;
+  };
+
+  /* 刷新表格 */
+  const reload = (where?: MenuParam) => {
+    tableRef?.value?.reload({ where });
+  };
+
+  /* 打开编辑弹窗 */
+  const openEdit = (row?: Menu | null, id?: number) => {
+    current.value = row ?? null;
+    parentId.value = id;
+    showEdit.value = true;
+  };
+
+  /* 删除单个 */
+  const remove = (row: Menu) => {
+    if (row.children?.length) {
+      message.error('请先删除子节点');
+      return;
+    }
+    const hide = messageLoading('请求中..', 0);
+    removeMenu(row.menuId)
+      .then((msg) => {
+        hide();
+        message.success(msg);
+        reload();
+      })
+      .catch((e) => {
+        hide();
+        message.error(e.message);
+      });
+  };
+
+  /* 展开全部 */
+  const expandAll = () => {
+    let keys: number[] = [];
+    eachTreeData(menuData.value, (d) => {
+      if (d.children && d.children.length && d.menuId) {
+        keys.push(d.menuId);
+      }
+    });
+    expandedRowKeys.value = keys;
+  };
+
+  /* 折叠全部 */
+  const foldAll = () => {
+    expandedRowKeys.value = [];
+  };
+
+  /* 点击展开图标时触发 */
+  const onExpand = (expanded: boolean, record: Menu) => {
+    if (expanded) {
+      expandedRowKeys.value = [
+        ...expandedRowKeys.value,
+        record.menuId as number
+      ];
+    } else {
+      expandedRowKeys.value = expandedRowKeys.value.filter(
+        (d) => d !== record.menuId
+      );
+    }
+  };
+
+  /* 判断是否是目录 */
+  const isDirectory = (d: Menu) => {
+    return !!d.children?.length && !d.component;
+  };
+</script>
+
+<script lang="ts">
+  import * as MenuIcons from '@/layout/menu-icons';
+
+  export default {
+    name: 'SystemMenu',
+    components: MenuIcons
+  };
+</script>
diff --git a/src/views/system/operation-record/components/operation-record-detail.vue b/src/views/system/operation-record/components/operation-record-detail.vue
new file mode 100644
index 0000000..deb5740
--- /dev/null
+++ b/src/views/system/operation-record/components/operation-record-detail.vue
@@ -0,0 +1,131 @@
+<!-- 详情弹窗 -->
+<template>
+  <ele-modal
+    title="详情"
+    :width="640"
+    :footer="null"
+    :visible="visible"
+    @update:visible="updateVisible"
+  >
+    <a-form
+      class="ele-form-detail"
+      :label-col="{ sm: { span: 8 }, xs: { span: 6 } }"
+      :wrapper-col="{ sm: { span: 16 }, xs: { span: 18 } }"
+    >
+      <a-row :gutter="16">
+        <a-col :sm="12" :xs="24">
+          <a-form-item label="操作人">
+            <div class="ele-text-secondary">
+              {{ data.nickname }}({{ data.username }})
+            </div>
+          </a-form-item>
+          <a-form-item label="操作模块">
+            <div class="ele-text-secondary">
+              {{ data.module }}
+            </div>
+          </a-form-item>
+          <a-form-item label="操作时间">
+            <div class="ele-text-secondary">
+              {{ toDateString(data.createTime) }}
+            </div>
+          </a-form-item>
+          <a-form-item label="请求方式">
+            <div class="ele-text-secondary">
+              {{ data.requestMethod }}
+            </div>
+          </a-form-item>
+        </a-col>
+        <a-col :sm="12" :xs="24">
+          <a-form-item label="IP地址">
+            <div class="ele-text-secondary">
+              {{ data.ip }}
+            </div>
+          </a-form-item>
+          <a-form-item label="操作功能">
+            <div class="ele-text-secondary">
+              {{ data.description }}
+            </div>
+          </a-form-item>
+          <a-form-item label="请求耗时">
+            <div v-if="!isNaN(data.spendTime)" class="ele-text-secondary">
+              {{ data.spendTime / 1000 }}s
+            </div>
+          </a-form-item>
+          <a-form-item label="请求状态">
+            <a-tag :color="['green', 'red'][data.status]">
+              {{ ['正常', '异常'][data.status] }}
+            </a-tag>
+          </a-form-item>
+        </a-col>
+      </a-row>
+      <div style="margin: 12px 0">
+        <a-divider />
+      </div>
+      <a-form-item
+        label="请求地址"
+        :label-col="{ sm: { span: 4 }, xs: { span: 6 } }"
+        :wrapper-col="{ sm: { span: 20 }, xs: { span: 18 } }"
+      >
+        <div class="ele-text-secondary">
+          {{ data.url }}
+        </div>
+      </a-form-item>
+      <a-form-item
+        label="调用方法"
+        :label-col="{ sm: { span: 4 }, xs: { span: 6 } }"
+        :wrapper-col="{ sm: { span: 20 }, xs: { span: 18 } }"
+      >
+        <div class="ele-text-secondary">
+          {{ data.method }}
+        </div>
+      </a-form-item>
+      <a-form-item
+        label="请求参数"
+        :label-col="{ sm: { span: 4 }, xs: { span: 6 } }"
+        :wrapper-col="{ sm: { span: 20 }, xs: { span: 18 } }"
+      >
+        <div class="ele-text-secondary">
+          {{ data.params }}
+        </div>
+      </a-form-item>
+      <a-form-item
+        v-if="data.status === 0"
+        label="返回结果"
+        :label-col="{ sm: { span: 4 }, xs: { span: 6 } }"
+        :wrapper-col="{ sm: { span: 20 }, xs: { span: 18 } }"
+      >
+        <text-ellipsis :content="data.result" class="ele-text-secondary" />
+      </a-form-item>
+      <a-form-item
+        v-else
+        label="异常信息"
+        :label-col="{ sm: { span: 4 }, xs: { span: 6 } }"
+        :wrapper-col="{ sm: { span: 20 }, xs: { span: 18 } }"
+      >
+        <text-ellipsis :content="data.error" class="ele-text-secondary" />
+      </a-form-item>
+    </a-form>
+  </ele-modal>
+</template>
+
+<script lang="ts" setup>
+  import { toDateString } from 'ele-admin-pro/es';
+  import type { OperationRecord } from '@/api/system/operation-record/model';
+  import TextEllipsis from './text-ellipsis.vue';
+
+  const emit = defineEmits<{
+    (e: 'update:visible', visible: boolean): void;
+  }>();
+
+  defineProps<{
+    // 弹窗是否打开
+    visible?: boolean;
+    // 修改回显的数据
+    data: OperationRecord;
+  }>();
+
+  /* 更新visible */
+  const updateVisible = (value: boolean) => {
+    emit('update:visible', value);
+  };
+</script>
diff --git a/src/views/system/operation-record/components/operation-record-search.vue b/src/views/system/operation-record/components/operation-record-search.vue
new file mode 100644
index 0000000..d6e4309
--- /dev/null
+++ b/src/views/system/operation-record/components/operation-record-search.vue
@@ -0,0 +1,112 @@
+<!-- 搜索表单 -->
+<template>
+  <a-form
+    :label-col="
+      styleResponsive ? { xl: 7, lg: 5, md: 7, sm: 4 } : { flex: '90px' }
+    "
+    :wrapper-col="
+      styleResponsive ? { xl: 17, lg: 19, md: 17, sm: 20 } : { flex: '1' }
+    "
+  >
+    <a-row :gutter="8">
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="用户账号">
+          <a-input
+            v-model:value.trim="form.username"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="操作模块">
+          <a-input
+            v-model:value.trim="form.module"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="操作时间">
+          <a-range-picker
+            v-model:value="dateRange"
+            :show-time="true"
+            value-format="YYYY-MM-DD HH:mm:ss"
+            class="ele-fluid"
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item class="ele-text-right" :wrapper-col="{ span: 24 }">
+          <a-space>
+            <a-button type="primary" @click="search">查询</a-button>
+            <a-button @click="reset">重置</a-button>
+          </a-space>
+        </a-form-item>
+      </a-col>
+    </a-row>
+  </a-form>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import type { OperationRecordParam } from '@/api/system/operation-record/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'search', where?: OperationRecordParam): void;
+  }>();
+
+  // 表单数据
+  const { form, resetFields } = useFormData<OperationRecordParam>({
+    username: '',
+    module: ''
+  });
+
+  // 日期范围选择
+  const dateRange = ref<[string, string]>(['', '']);
+
+  /* 搜索 */
+  const search = () => {
+    const [createTimeStart, createTimeEnd] = dateRange.value;
+    emit('search', { ...form, createTimeStart, createTimeEnd });
+  };
+
+  /*  重置 */
+  const reset = () => {
+    resetFields();
+    dateRange.value = ['', ''];
+    search();
+  };
+</script>
diff --git a/src/views/system/operation-record/components/text-ellipsis.vue b/src/views/system/operation-record/components/text-ellipsis.vue
new file mode 100644
index 0000000..05b204a
--- /dev/null
+++ b/src/views/system/operation-record/components/text-ellipsis.vue
@@ -0,0 +1,59 @@
+<!-- 文本超出隐藏 -->
+<template>
+  <div
+    :class="[
+      'demo-text-ellipsis ele-bg-white ele-border-split',
+      { expanded: expanded }
+    ]"
+  >
+    <div>{{ content }}</div>
+    <div
+      class="demo-text-ellipsis-footer ele-border-split ele-bg-white"
+      @click="expanded = !expanded"
+    >
+      <up-outlined v-if="expanded" />
+      <down-outlined v-else />
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import { DownOutlined, UpOutlined } from '@ant-design/icons-vue';
+
+  defineProps<{
+    content?: string;
+  }>();
+
+  const expanded = ref(false);
+</script>
+
+<style lang="less" scoped>
+  .demo-text-ellipsis {
+    border-radius: 4px;
+    padding: 6px 12px 20px 12px;
+    position: relative;
+    border-width: 1px;
+    border-style: solid;
+    word-break: break-all;
+
+    &:not(.expanded) {
+      max-height: 192px;
+      overflow: hidden;
+    }
+  }
+
+  .demo-text-ellipsis-footer {
+    border-top-width: 1px;
+    border-top-style: solid;
+    position: absolute;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    text-align: center;
+    font-size: 12px;
+    cursor: pointer;
+    border-bottom-left-radius: 4px;
+    border-bottom-right-radius: 4px;
+  }
+</style>
diff --git a/src/views/system/operation-record/index.vue b/src/views/system/operation-record/index.vue
new file mode 100644
index 0000000..760464e
--- /dev/null
+++ b/src/views/system/operation-record/index.vue
@@ -0,0 +1,273 @@
+<template>
+  <div class="ele-body">
+    <a-card :bordered="false">
+      <!-- 搜索表单 -->
+      <operation-record-search @search="reload" />
+      <!-- 表格 -->
+      <ele-pro-table
+        ref="tableRef"
+        row-key="id"
+        :columns="columns"
+        :datasource="datasource"
+        :scroll="{ x: 1000 }"
+        cache-key="proSystemOperationRecordTable"
+      >
+        <template #toolbar>
+          <a-space>
+            <a-button type="primary" class="ele-btn-icon" @click="exportData">
+              <template #icon>
+                <download-outlined />
+              </template>
+              <span>导出</span>
+            </a-button>
+          </a-space>
+        </template>
+        <template #bodyCell="{ column, record }">
+          <template v-if="column.key === 'status'">
+            <a-tag v-if="record.status === 0" color="green">正常</a-tag>
+            <a-tag v-else-if="record.status === 1" color="red">异常</a-tag>
+          </template>
+          <template v-else-if="column.key === 'action'">
+            <a @click="openDetail(record)">详情</a>
+          </template>
+        </template>
+      </ele-pro-table>
+    </a-card>
+    <!-- 详情弹窗 -->
+    <operation-record-detail v-model:visible="showInfo" :data="current" />
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import { DownloadOutlined } from '@ant-design/icons-vue';
+  import type { EleProTable } from 'ele-admin-pro/es';
+  import type {
+    DatasourceFunction,
+    ColumnItem
+  } from 'ele-admin-pro/es/ele-pro-table/types';
+  import { utils, writeFile } from 'xlsx';
+  import { messageLoading, toDateString } from 'ele-admin-pro/es';
+  import OperationRecordSearch from './components/operation-record-search.vue';
+  import OperationRecordDetail from './components/operation-record-detail.vue';
+  import {
+    pageOperationRecords,
+    listOperationRecords
+  } from '@/api/system/operation-record';
+  import type {
+    OperationRecord,
+    OperationRecordParam
+  } from '@/api/system/operation-record/model';
+
+  // 表格实例
+  const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
+
+  // 表格列配置
+  const columns = ref<ColumnItem[]>([
+    {
+      key: 'index',
+      width: 48,
+      align: 'center',
+      fixed: 'left',
+      hideInSetting: true,
+      customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
+    },
+    {
+      title: '账号',
+      dataIndex: 'username',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true
+    },
+    {
+      title: '用户名',
+      dataIndex: 'nickname',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true
+    },
+    {
+      title: '操作模块',
+      dataIndex: 'module',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true
+    },
+    {
+      title: '操作功能',
+      dataIndex: 'description',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true
+    },
+    {
+      title: '请求地址',
+      dataIndex: 'url',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true
+    },
+    {
+      title: '请求方式',
+      dataIndex: 'requestMethod',
+      sorter: true,
+      showSorterTooltip: false,
+      width: 100,
+      align: 'center'
+    },
+    {
+      title: '状态',
+      key: 'status',
+      dataIndex: 'status',
+      sorter: true,
+      showSorterTooltip: false,
+      width: 100,
+      filters: [
+        {
+          text: '正常',
+          value: 0
+        },
+        {
+          text: '异常',
+          value: 1
+        }
+      ],
+      filterMultiple: false,
+      align: 'center'
+    },
+    {
+      title: '耗时',
+      dataIndex: 'spendTime',
+      sorter: true,
+      showSorterTooltip: false,
+      width: 100,
+      customRender: ({ text }) => text / 1000 + 's'
+    },
+    {
+      title: '操作时间',
+      dataIndex: 'createTime',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true,
+      customRender: ({ text }) => toDateString(text),
+      align: 'center'
+    },
+    {
+      title: '操作',
+      key: 'action',
+      width: 90,
+      align: 'center',
+      fixed: 'right'
+    }
+  ]);
+
+  // 当前选中数据
+  const current = ref<OperationRecord>({
+    module: '',
+    description: '',
+    url: '',
+    requestMethod: '',
+    method: '',
+    params: '',
+    result: '',
+    error: '',
+    spendTime: 0,
+    os: '',
+    device: '',
+    browser: '',
+    ip: '',
+    status: 0,
+    createTime: '',
+    nickname: '',
+    username: ''
+  });
+
+  // 是否显示查看弹窗
+  const showInfo = ref(false);
+
+  // 表格数据源
+  const datasource: DatasourceFunction = ({
+    page,
+    limit,
+    where,
+    orders,
+    filters
+  }) => {
+    return pageOperationRecords({
+      ...where,
+      ...orders,
+      ...filters,
+      page,
+      limit
+    });
+  };
+
+  /* 刷新表格 */
+  const reload = (where?: OperationRecordParam) => {
+    tableRef?.value?.reload({ page: 1, where });
+  };
+
+  /* 详情 */
+  const openDetail = (row: OperationRecord) => {
+    current.value = row;
+    showInfo.value = true;
+  };
+
+  /* 导出数据 */
+  const exportData = () => {
+    const array = [
+      [
+        '账号',
+        '用户名',
+        '操作模块',
+        '操作功能',
+        '请求地址',
+        '请求方式',
+        '状态',
+        '耗时',
+        '操作时间'
+      ]
+    ];
+    // 请求查询全部(不分页)的接口
+    const hide = messageLoading('请求中..', 0);
+    tableRef.value?.doRequest(({ where, orders, filters }) => {
+      listOperationRecords({ ...where, ...orders, ...filters })
+        .then((data) => {
+          hide();
+          data.forEach((d) => {
+            array.push([
+              d.username,
+              d.nickname,
+              d.module,
+              d.description,
+              d.url,
+              d.requestMethod,
+              ['正常', '异常'][d.status],
+              d.spendTime / 1000 + 's',
+              toDateString(d.createTime)
+            ]);
+          });
+          writeFile(
+            {
+              SheetNames: ['Sheet1'],
+              Sheets: {
+                Sheet1: utils.aoa_to_sheet(array)
+              }
+            },
+            '操作日志.xlsx'
+          );
+        })
+        .catch((e) => {
+          hide();
+          message.error(e.message);
+        });
+    });
+  };
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'SystemOperationRecord'
+  };
+</script>
diff --git a/src/views/system/role/components/role-auth.vue b/src/views/system/role/components/role-auth.vue
new file mode 100644
index 0000000..ae3ff0b
--- /dev/null
+++ b/src/views/system/role/components/role-auth.vue
@@ -0,0 +1,159 @@
+<!-- 角色权限分配弹窗 -->
+<template>
+  <ele-modal
+    :width="460"
+    title="分配权限"
+    :visible="visible"
+    :confirm-loading="loading"
+    @update:visible="updateVisible"
+    @ok="save"
+  >
+    <a-spin :spinning="authLoading">
+      <div style="height: 60vh" class="ele-scrollbar-hover">
+        <a-tree
+          :checkable="true"
+          :show-icon="true"
+          :tree-data="(authData as any)"
+          v-model:expandedKeys="expandKeys"
+          v-model:checkedKeys="checkedKeys"
+        >
+          <template #icon="{ menuIcon }">
+            <component v-if="menuIcon" :is="menuIcon" />
+          </template>
+        </a-tree>
+      </div>
+    </a-spin>
+  </ele-modal>
+</template>
+
+<script lang="ts" setup>
+  import { ref, watch, nextTick } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import { toTreeData, eachTreeData } from 'ele-admin-pro/es';
+  import { listRoleMenus, updateRoleMenus } from '@/api/system/role';
+  import type { Role } from '@/api/system/role/model';
+  import type { Menu } from '@/api/system/menu/model';
+
+  const emit = defineEmits<{
+    (e: 'update:visible', visible: boolean): void;
+  }>();
+
+  const props = defineProps<{
+    // 弹窗是否打开
+    visible: boolean;
+    // 当前角色数据
+    data?: Role | null;
+  }>();
+
+  // 权限数据
+  const authData = ref<Menu[]>([]);
+
+  // 权限数据请求状态
+  const authLoading = ref(false);
+
+  // 提交状态
+  const loading = ref(false);
+
+  // 角色权限展开的keys
+  const expandKeys = ref<number[]>([]);
+
+  // 角色权限选中的keys
+  const checkedKeys = ref<number[]>([]);
+
+  /* 查询权限数据 */
+  const query = () => {
+    authData.value = [];
+    expandKeys.value = [];
+    checkedKeys.value = [];
+    if (!props.data) {
+      return;
+    }
+    authLoading.value = true;
+    listRoleMenus(props.data.roleId)
+      .then((data) => {
+        authLoading.value = false;
+        // 转成树形结构的数据
+        authData.value = toTreeData({
+          data: data?.map((d) => ({
+            ...d,
+            key: d.menuId,
+            icon: undefined,
+            menuIcon: d.icon
+          })),
+          idField: 'menuId',
+          parentIdField: 'parentId',
+          addParentIds: true,
+          parentIds: []
+        });
+        // 全部默认展开以及回显选中的数据
+        nextTick(() => {
+          const eks: number[] = [];
+          const cks: number[] = [];
+          eachTreeData(authData.value, (d) => {
+            if (d.key) {
+              if (d.children?.length) {
+                eks.push(d.key);
+              } else if (d.checked) {
+                cks.push(d.key);
+              }
+            }
+          });
+          expandKeys.value = eks;
+          checkedKeys.value = cks;
+        });
+      })
+      .catch((e) => {
+        authLoading.value = false;
+        message.error(e.message);
+      });
+  };
+
+  /* 保存权限分配 */
+  const save = () => {
+    loading.value = true;
+    // 获取选中的id,包含所有半选的父级的id
+    const ids = new Set<number>();
+    eachTreeData(authData.value, (d) => {
+      if (d.key && checkedKeys.value.some((c) => c === d.key)) {
+        ids.add(d.key);
+        if (d.parentIds) {
+          d.parentIds.forEach((id: number) => {
+            ids.add(id);
+          });
+        }
+      }
+    });
+    updateRoleMenus(props.data?.roleId, Array.from(ids))
+      .then((msg) => {
+        loading.value = false;
+        message.success(msg);
+        updateVisible(false);
+      })
+      .catch((e) => {
+        loading.value = false;
+        message.error(e.message);
+      });
+  };
+
+  /* 更新visible */
+  const updateVisible = (value: boolean) => {
+    emit('update:visible', value);
+  };
+
+  watch(
+    () => props.visible,
+    (visible) => {
+      if (visible) {
+        query();
+      }
+    }
+  );
+</script>
+
+<script lang="ts">
+  import * as MenuIcons from '@/layout/menu-icons';
+
+  export default {
+    components: MenuIcons
+  };
+</script>
diff --git a/src/views/system/role/components/role-edit.vue b/src/views/system/role/components/role-edit.vue
new file mode 100644
index 0000000..b3a5f39
--- /dev/null
+++ b/src/views/system/role/components/role-edit.vue
@@ -0,0 +1,158 @@
+<!-- 角色编辑弹窗 -->
+<template>
+  <ele-modal
+    :width="460"
+    :visible="visible"
+    :confirm-loading="loading"
+    :title="isUpdate ? '修改角色' : '添加角色'"
+    :body-style="{ paddingBottom: '8px' }"
+    @update:visible="updateVisible"
+    @ok="save"
+  >
+    <a-form
+      ref="formRef"
+      :model="form"
+      :rules="rules"
+      :label-col="styleResponsive ? { md: 5, sm: 5, xs: 24 } : { flex: '90px' }"
+      :wrapper-col="
+        styleResponsive ? { md: 19, sm: 19, xs: 24 } : { flex: '1' }
+      "
+    >
+      <a-form-item label="角色名称" name="roleName">
+        <a-input
+          allow-clear
+          :maxlength="20"
+          placeholder="请输入角色名称"
+          v-model:value="form.roleName"
+        />
+      </a-form-item>
+      <a-form-item label="角色标识" name="roleCode">
+        <a-input
+          allow-clear
+          :maxlength="20"
+          placeholder="请输入角色标识"
+          v-model:value="form.roleCode"
+        />
+      </a-form-item>
+      <a-form-item label="备注">
+        <a-textarea
+          :rows="4"
+          :maxlength="200"
+          placeholder="请输入备注"
+          v-model:value="form.comments"
+        />
+      </a-form-item>
+    </a-form>
+  </ele-modal>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive, watch } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import { addRole, updateRole } from '@/api/system/role';
+  import type { Role } from '@/api/system/role/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'done'): void;
+    (e: 'update:visible', visible: boolean): void;
+  }>();
+
+  const props = defineProps<{
+    // 弹窗是否打开
+    visible: boolean;
+    // 修改回显的数据
+    data?: Role | null;
+  }>();
+
+  //
+  const formRef = ref<FormInstance | null>(null);
+
+  // 是否是修改
+  const isUpdate = ref(false);
+
+  // 提交状态
+  const loading = ref(false);
+
+  // 表单数据
+  const { form, resetFields, assignFields } = useFormData<Role>({
+    roleId: undefined,
+    roleName: '',
+    roleCode: '',
+    comments: ''
+  });
+
+  // 表单验证规则
+  const rules = reactive<Record<string, Rule[]>>({
+    roleName: [
+      {
+        required: true,
+        message: '请输入角色名称',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    roleCode: [
+      {
+        required: true,
+        message: '请输入角色标识',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ]
+  });
+
+  /* 保存编辑 */
+  const save = () => {
+    if (!formRef.value) {
+      return;
+    }
+    formRef.value
+      .validate()
+      .then(() => {
+        loading.value = true;
+        const saveOrUpdate = isUpdate.value ? updateRole : addRole;
+        saveOrUpdate(form)
+          .then((msg) => {
+            loading.value = false;
+            message.success(msg);
+            updateVisible(false);
+            emit('done');
+          })
+          .catch((e) => {
+            loading.value = false;
+            message.error(e.message);
+          });
+      })
+      .catch(() => {});
+  };
+
+  /* 更新visible */
+  const updateVisible = (value: boolean) => {
+    emit('update:visible', value);
+  };
+
+  watch(
+    () => props.visible,
+    (visible) => {
+      if (visible) {
+        if (props.data) {
+          assignFields(props.data);
+          isUpdate.value = true;
+        } else {
+          isUpdate.value = false;
+        }
+      } else {
+        resetFields();
+        formRef.value?.clearValidate();
+      }
+    }
+  );
+</script>
diff --git a/src/views/system/role/components/role-search.vue b/src/views/system/role/components/role-search.vue
new file mode 100644
index 0000000..2169dca
--- /dev/null
+++ b/src/views/system/role/components/role-search.vue
@@ -0,0 +1,106 @@
+<!-- 搜索表单 -->
+<template>
+  <a-form
+    :label-col="
+      styleResponsive ? { xl: 7, lg: 5, md: 7, sm: 4 } : { flex: '90px' }
+    "
+    :wrapper-col="
+      styleResponsive ? { xl: 17, lg: 19, md: 17, sm: 20 } : { flex: '1' }
+    "
+  >
+    <a-row :gutter="8">
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="角色名称">
+          <a-input
+            v-model:value.trim="form.roleName"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="角色标识">
+          <a-input
+            v-model:value.trim="form.roleCode"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="备注">
+          <a-input
+            v-model:value.trim="form.comments"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item class="ele-text-right" :wrapper-col="{ span: 24 }">
+          <a-space>
+            <a-button type="primary" @click="search">查询</a-button>
+            <a-button @click="reset">重置</a-button>
+          </a-space>
+        </a-form-item>
+      </a-col>
+    </a-row>
+  </a-form>
+</template>
+
+<script lang="ts" setup>
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import type { RoleParam } from '@/api/system/role/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'search', where?: RoleParam): void;
+  }>();
+
+  // 表单数据
+  const { form, resetFields } = useFormData<RoleParam>({
+    roleName: '',
+    roleCode: '',
+    comments: ''
+  });
+
+  /* 搜索 */
+  const search = () => {
+    emit('search', form);
+  };
+
+  /*  重置 */
+  const reset = () => {
+    resetFields();
+    search();
+  };
+</script>
diff --git a/src/views/system/role/index.vue b/src/views/system/role/index.vue
new file mode 100644
index 0000000..497d2fb
--- /dev/null
+++ b/src/views/system/role/index.vue
@@ -0,0 +1,212 @@
+<template>
+  <div class="ele-body">
+    <a-card :bordered="false">
+      <!-- 搜索表单 -->
+      <role-search @search="reload" />
+      <!-- 表格 -->
+      <ele-pro-table
+        ref="tableRef"
+        row-key="roleId"
+        :columns="columns"
+        :datasource="datasource"
+        v-model:selection="selection"
+        :scroll="{ x: 800 }"
+        cache-key="proSystemRoleTable"
+      >
+        <template #toolbar>
+          <a-space>
+            <a-button type="primary" class="ele-btn-icon" @click="openEdit()">
+              <template #icon>
+                <plus-outlined />
+              </template>
+              <span>新建</span>
+            </a-button>
+            <a-button
+              danger
+              type="primary"
+              class="ele-btn-icon"
+              @click="removeBatch"
+            >
+              <template #icon>
+                <delete-outlined />
+              </template>
+              <span>删除</span>
+            </a-button>
+          </a-space>
+        </template>
+        <template #bodyCell="{ column, record }">
+          <template v-if="column.key === 'action'">
+            <a-space>
+              <a @click="openEdit(record)">修改</a>
+              <a-divider type="vertical" />
+              <a @click="openAuth(record)">分配权限</a>
+              <a-divider type="vertical" />
+              <a-popconfirm
+                placement="topRight"
+                title="确定要删除此角色吗?"
+                @confirm="remove(record)"
+              >
+                <a class="ele-text-danger">删除</a>
+              </a-popconfirm>
+            </a-space>
+          </template>
+        </template>
+      </ele-pro-table>
+    </a-card>
+    <!-- 编辑弹窗 -->
+    <role-edit v-model:visible="showEdit" :data="current" @done="reload" />
+    <!-- 权限分配弹窗 -->
+    <role-auth v-model:visible="showAuth" :data="current" />
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { createVNode, ref } from 'vue';
+  import { message, Modal } from 'ant-design-vue/es';
+  import {
+    PlusOutlined,
+    DeleteOutlined,
+    ExclamationCircleOutlined
+  } from '@ant-design/icons-vue';
+  import type { EleProTable } from 'ele-admin-pro/es';
+  import type {
+    DatasourceFunction,
+    ColumnItem
+  } from 'ele-admin-pro/es/ele-pro-table/types';
+  import { messageLoading, toDateString } from 'ele-admin-pro/es';
+  import RoleSearch from './components/role-search.vue';
+  import RoleEdit from './components/role-edit.vue';
+  import RoleAuth from './components/role-auth.vue';
+  import { pageRoles, removeRole, removeRoles } from '@/api/system/role';
+  import type { Role, RoleParam } from '@/api/system/role/model';
+
+  // 表格实例
+  const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
+
+  // 表格列配置
+  const columns = ref<ColumnItem[]>([
+    {
+      key: 'index',
+      width: 48,
+      align: 'center',
+      fixed: 'left',
+      hideInSetting: true,
+      customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
+    },
+    {
+      title: '角色名称',
+      dataIndex: 'roleName',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '角色标识',
+      dataIndex: 'roleCode',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '备注',
+      dataIndex: 'comments',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '创建时间',
+      dataIndex: 'createTime',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true,
+      customRender: ({ text }) => toDateString(text)
+    },
+    {
+      title: '操作',
+      key: 'action',
+      width: 200,
+      align: 'center'
+    }
+  ]);
+
+  // 表格选中数据
+  const selection = ref<Role[]>([]);
+
+  // 当前编辑数据
+  const current = ref<Role | null>(null);
+
+  // 是否显示编辑弹窗
+  const showEdit = ref(false);
+
+  // 是否显示权限分配弹窗
+  const showAuth = ref(false);
+
+  // 表格数据源
+  const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
+    return pageRoles({ ...where, ...orders, page, limit });
+  };
+
+  /* 搜索 */
+  const reload = (where?: RoleParam) => {
+    selection.value = [];
+    tableRef?.value?.reload({ page: 1, where });
+  };
+
+  /* 打开编辑弹窗 */
+  const openEdit = (row?: Role) => {
+    current.value = row ?? null;
+    showEdit.value = true;
+  };
+
+  /* 打开权限分配弹窗 */
+  const openAuth = (row?: Role) => {
+    current.value = row ?? null;
+    showAuth.value = true;
+  };
+
+  /* 删除单个 */
+  const remove = (row: Role) => {
+    const hide = messageLoading('请求中..', 0);
+    removeRole(row.roleId)
+      .then((msg) => {
+        hide();
+        message.success(msg);
+        reload();
+      })
+      .catch((e) => {
+        hide();
+        message.error(e.message);
+      });
+  };
+
+  /* 批量删除 */
+  const removeBatch = () => {
+    if (!selection.value.length) {
+      message.error('请至少选择一条数据');
+      return;
+    }
+    Modal.confirm({
+      title: '提示',
+      content: '确定要删除选中的角色吗?',
+      icon: createVNode(ExclamationCircleOutlined),
+      maskClosable: true,
+      onOk: () => {
+        const hide = messageLoading('请求中..', 0);
+        removeRoles(selection.value.map((d) => d.roleId))
+          .then((msg) => {
+            hide();
+            message.success(msg);
+            reload();
+          })
+          .catch((e) => {
+            hide();
+            message.error(e.message);
+          });
+      }
+    });
+  };
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'SystemRole'
+  };
+</script>
diff --git a/src/views/system/user/components/role-select.vue b/src/views/system/user/components/role-select.vue
new file mode 100644
index 0000000..04534b9
--- /dev/null
+++ b/src/views/system/user/components/role-select.vue
@@ -0,0 +1,71 @@
+<!-- 角色选择下拉框 -->
+<template>
+  <a-select
+    allow-clear
+    mode="multiple"
+    :value="roleIds"
+    :placeholder="placeholder"
+    @update:value="updateValue"
+    @blur="onBlur"
+  >
+    <a-select-option
+      v-for="item in data"
+      :key="item.roleId"
+      :value="item.roleId"
+    >
+      {{ item.roleName }}
+    </a-select-option>
+  </a-select>
+</template>
+
+<script lang="ts" setup>
+  import { ref, computed } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import { listRoles } from '@/api/system/role';
+  import type { Role } from '@/api/system/role/model';
+
+  const emit = defineEmits<{
+    (e: 'update:value', value: Role[]): void;
+    (e: 'blur'): void;
+  }>();
+
+  const props = withDefaults(
+    defineProps<{
+      // 选中的角色
+      value?: Role[];
+      //
+      placeholder?: string;
+    }>(),
+    {
+      placeholder: '请选择角色'
+    }
+  );
+
+  // 选中的角色id
+  const roleIds = computed(() => props.value?.map((d) => d.roleId as number));
+
+  // 角色数据
+  const data = ref<Role[]>([]);
+
+  /* 更新选中数据 */
+  const updateValue = (value: number[]) => {
+    emit(
+      'update:value',
+      value.map((v) => ({ roleId: v }))
+    );
+  };
+
+  /* 获取角色数据 */
+  listRoles()
+    .then((list) => {
+      data.value = list;
+    })
+    .catch((e) => {
+      message.error(e.message);
+    });
+
+  /* 失去焦点 */
+  const onBlur = () => {
+    emit('blur');
+  };
+</script>
diff --git a/src/views/system/user/components/user-edit.vue b/src/views/system/user/components/user-edit.vue
new file mode 100644
index 0000000..a27cbc2
--- /dev/null
+++ b/src/views/system/user/components/user-edit.vue
@@ -0,0 +1,285 @@
+<!-- 用户编辑弹窗 -->
+<template>
+  <ele-modal
+    :width="680"
+    :visible="visible"
+    :confirm-loading="loading"
+    :title="isUpdate ? '修改用户' : '新建用户'"
+    :body-style="{ paddingBottom: '8px' }"
+    @update:visible="updateVisible"
+    @ok="save"
+  >
+    <a-form
+      ref="formRef"
+      :model="form"
+      :rules="rules"
+      :label-col="styleResponsive ? { md: 7, sm: 4, xs: 24 } : { flex: '90px' }"
+      :wrapper-col="
+        styleResponsive ? { md: 17, sm: 20, xs: 24 } : { flex: '1' }
+      "
+    >
+      <a-row :gutter="16">
+        <a-col
+          v-bind="styleResponsive ? { md: 12, sm: 24, xs: 24 } : { span: 12 }"
+        >
+          <a-form-item label="用户账号" name="username">
+            <a-input
+              allow-clear
+              :maxlength="20"
+              placeholder="请输入用户账号"
+              v-model:value="form.username"
+            />
+          </a-form-item>
+          <a-form-item label="用户名" name="nickname">
+            <a-input
+              allow-clear
+              :maxlength="20"
+              placeholder="请输入用户名"
+              v-model:value="form.nickname"
+            />
+          </a-form-item>
+          <a-form-item label="性别" name="sex">
+            <a-select v-model:value="form.sex" :options="sexOption"></a-select>
+          </a-form-item>
+          <a-form-item label="角色" name="roles">
+            <role-select v-model:value="form.roles" />
+          </a-form-item>
+          <a-form-item label="邮箱" name="email">
+            <a-input
+              allow-clear
+              :maxlength="100"
+              placeholder="请输入邮箱"
+              v-model:value="form.email"
+            />
+          </a-form-item>
+        </a-col>
+        <a-col
+          v-bind="styleResponsive ? { md: 12, sm: 24, xs: 24 } : { span: 12 }"
+        >
+          <a-form-item label="手机号" name="phone">
+            <a-input
+              allow-clear
+              :maxlength="11"
+              placeholder="请输入手机号"
+              v-model:value="form.phone"
+            />
+          </a-form-item>
+          <a-form-item label="出生日期">
+            <a-date-picker
+              class="ele-fluid"
+              value-format="YYYY-MM-DD"
+              placeholder="请选择出生日期"
+              v-model:value="form.birthday"
+            />
+          </a-form-item>
+          <a-form-item v-if="!isUpdate" label="登录密码" name="password">
+            <a-input-password
+              :maxlength="20"
+              v-model:value="form.password"
+              placeholder="请输入登录密码"
+            />
+          </a-form-item>
+          <a-form-item label="个人简介">
+            <a-textarea
+              :rows="4"
+              :maxlength="200"
+              placeholder="请输入个人简介"
+              v-model:value="form.introduction"
+            />
+          </a-form-item>
+        </a-col>
+      </a-row>
+    </a-form>
+  </ele-modal>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive, watch } from 'vue';
+  import { message } from 'ant-design-vue/es';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import { emailReg, phoneReg } from 'ele-admin-pro/es';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import RoleSelect from './role-select.vue';
+  import { addUser, updateUser, checkExistence } from '@/api/system/user';
+  import type { User } from '@/api/system/user/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const emit = defineEmits<{
+    (e: 'done'): void;
+    (e: 'update:visible', visible: boolean): void;
+  }>();
+
+  const props = defineProps<{
+    // 弹窗是否打开
+    visible: boolean;
+    // 修改回显的数据
+    data?: User | null;
+  }>();
+
+  //
+  const formRef = ref<FormInstance | null>(null);
+
+  // 是否是修改
+  const isUpdate = ref(false);
+
+  // 提交状态
+  const loading = ref(false);
+
+  const sexOption = ref([
+      {
+        value: '0',
+        label: '保密',
+      },{
+        value: '1',
+        label: '男',
+      },{
+        value: '2',
+        label: '女',
+      },
+  ])
+  // 表单数据
+  const { form, resetFields, assignFields } = useFormData<User>({
+    userId: undefined,
+    username: '',
+    nickname: '',
+    sex: undefined,
+    roles: [],
+    email: '',
+    phone: '',
+    introduction: '',
+    birthday: ''
+  });
+
+  // 表单验证规则
+  const rules = reactive<Record<string, Rule[]>>({
+    username: [
+      {
+        required: true,
+        type: 'string',
+        validator: (_rule: Rule, value: string) => {
+          return new Promise<void>((resolve, reject) => {
+            if (!value) {
+              return reject('请输入用户账号');
+            }
+            checkExistence('username', value, props.data?.userId)
+              .then(() => {
+                reject('账号已经存在');
+              })
+              .catch(() => {
+                resolve();
+              });
+          });
+        },
+        trigger: 'blur'
+      }
+    ],
+    nickname: [
+      {
+        required: true,
+        message: '请输入用户名',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    sex: [
+      {
+        required: true,
+        message: '请选择性别',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    roles: [
+      {
+        required: true,
+        message: '请选择角色',
+        type: 'array',
+        trigger: 'blur'
+      }
+    ],
+    email: [
+      {
+        pattern: emailReg,
+        message: '邮箱格式不正确',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ],
+    password: [
+      {
+        required: true,
+        type: 'string',
+        validator: async (_rule: Rule, value: string) => {
+          if (isUpdate.value || /^[\S]{5,18}$/.test(value)) {
+            return Promise.resolve();
+          }
+          return Promise.reject('密码必须为5-18位非空白字符');
+        },
+        trigger: 'blur'
+      }
+    ],
+    phone: [
+      {
+        pattern: phoneReg,
+        message: '手机号格式不正确',
+        type: 'string',
+        trigger: 'blur'
+      }
+    ]
+  });
+
+  /* 保存编辑 */
+  const save = () => {
+    if (!formRef.value) {
+      return;
+    }
+    formRef.value
+      .validate()
+      .then(() => {
+        loading.value = true;
+        const saveOrUpdate = isUpdate.value ? updateUser : addUser;
+        saveOrUpdate(form)
+          .then((msg) => {
+            loading.value = false;
+            message.success(msg);
+            updateVisible(false);
+            emit('done');
+          })
+          .catch((e) => {
+            loading.value = false;
+            message.error(e.message);
+          });
+      })
+      .catch(() => {});
+  };
+
+  /* 更新visible */
+  const updateVisible = (value: boolean) => {
+    emit('update:visible', value);
+  };
+
+  watch(
+    () => props.visible,
+    (visible) => {
+      if (visible) {
+        if (props.data) {
+          assignFields({
+            ...props.data,
+            password: ''
+          });
+          isUpdate.value = true;
+        } else {
+          isUpdate.value = false;
+        }
+      } else {
+        resetFields();
+        formRef.value?.clearValidate();
+      }
+    }
+  );
+</script>
diff --git a/src/views/system/user/components/user-search.vue b/src/views/system/user/components/user-search.vue
new file mode 100644
index 0000000..aef0215
--- /dev/null
+++ b/src/views/system/user/components/user-search.vue
@@ -0,0 +1,112 @@
+<!-- 搜索表单 -->
+<template>
+  <a-form
+    :label-col="
+      styleResponsive ? { xl: 7, lg: 5, md: 7, sm: 4 } : { flex: '90px' }
+    "
+    :wrapper-col="
+      styleResponsive ? { xl: 17, lg: 19, md: 17, sm: 20 } : { flex: '1' }
+    "
+  >
+    <a-row :gutter="8">
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="用户账号">
+          <a-input
+            v-model:value.trim="form.username"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="用户名">
+          <a-input
+            v-model:value.trim="form.nickname"
+            placeholder="请输入"
+            allow-clear
+          />
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item label="性别">
+          <a-select v-model:value="form.sex" placeholder="请选择" allow-clear>
+            <a-select-option value="0">保密</a-select-option>
+            <a-select-option value="1">男</a-select-option>
+            <a-select-option value="2">女</a-select-option>
+          </a-select>
+        </a-form-item>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xl: 6, lg: 12, md: 12, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-form-item class="ele-text-right" :wrapper-col="{ span: 24 }">
+          <a-space>
+            <a-button type="primary" @click="search">查询</a-button>
+            <a-button @click="reset">重置</a-button>
+          </a-space>
+        </a-form-item>
+      </a-col>
+    </a-row>
+  </a-form>
+</template>
+
+<script lang="ts" setup>
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import type { UserParam } from '@/api/system/user/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const props = defineProps<{
+    // 默认搜索条件
+    where?: UserParam;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'search', where?: UserParam): void;
+  }>();
+
+  // 表单数据
+  const { form, resetFields } = useFormData<UserParam>({
+    username: '',
+    nickname: '',
+    sex: undefined,
+    ...props.where
+  });
+
+  /* 搜索 */
+  const search = () => {
+    emit('search', form);
+  };
+
+  /*  重置 */
+  const reset = () => {
+    resetFields();
+    search();
+  };
+</script>
diff --git a/src/views/system/user/details/index.vue b/src/views/system/user/details/index.vue
new file mode 100644
index 0000000..177bb22
--- /dev/null
+++ b/src/views/system/user/details/index.vue
@@ -0,0 +1,122 @@
+<template>
+  <div class="ele-body">
+    <a-card title="基本信息" :bordered="false">
+      <a-form
+        class="ele-form-detail"
+        :label-col="
+          styleResponsive ? { md: 2, sm: 4, xs: 6 } : { flex: '90px' }
+        "
+        :wrapper-col="
+          styleResponsive ? { md: 22, sm: 20, xs: 18 } : { flex: '1' }
+        "
+      >
+        <a-form-item label="账号">
+          <div class="ele-text-secondary">{{ form.username }}</div>
+        </a-form-item>
+        <a-form-item label="用户名">
+          <div class="ele-text-secondary">{{ form.nickname }}</div>
+        </a-form-item>
+        <a-form-item label="性别">
+          <div class="ele-text-secondary">{{ form.sexName }}</div>
+        </a-form-item>
+        <a-form-item label="手机号">
+          <div class="ele-text-secondary">{{ form.phone }}</div>
+        </a-form-item>
+        <a-form-item label="角色">
+          <a-tag v-for="item in form.roles" :key="item.roleId" color="blue">
+            {{ item.roleName }}
+          </a-tag>
+        </a-form-item>
+        <a-form-item label="创建时间">
+          <div class="ele-text-secondary">{{ form.createTime }}</div>
+        </a-form-item>
+        <a-form-item label="状态">
+          <a-badge
+            v-if="typeof form.status === 'number'"
+            :status="(['processing', 'error'][form.status] as any)"
+            :text="['正常', '冻结'][form.status]"
+          />
+        </a-form-item>
+      </a-form>
+    </a-card>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref, watch, unref } from 'vue';
+  import { useRouter } from 'vue-router';
+  import { message } from 'ant-design-vue/es';
+  import { toDateString } from 'ele-admin-pro/es';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import useFormData from '@/utils/use-form-data';
+  import { setPageTabTitle } from '@/utils/page-tab-util';
+  import { getUser } from '@/api/system/user';
+  import type { User } from '@/api/system/user/model';
+  const ROUTE_PATH = '/system/user/details';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const { currentRoute } = useRouter();
+
+  // 用户信息
+  const { form, assignFields } = useFormData<User>({
+    userId: undefined,
+    username: '',
+    nickname: '',
+    sexName: '',
+    phone: '',
+    roles: [],
+    createTime: undefined,
+    status: undefined
+  });
+
+  // 请求状态
+  const loading = ref(true);
+
+  /*  */
+  const query = () => {
+    const { query } = unref(currentRoute);
+    const id = query.id;
+    if (!id || form.userId === Number(id)) {
+      return;
+    }
+    loading.value = true;
+    getUser(Number(id))
+      .then((data) => {
+        loading.value = false;
+        assignFields({
+          ...data,
+          createTime: toDateString(data.createTime)
+        });
+        // 修改页签标题
+        if (unref(currentRoute).path === ROUTE_PATH) {
+          setPageTabTitle(data.nickname + '的信息');
+        }
+      })
+      .catch((e) => {
+        loading.value = false;
+        message.error(e.message);
+      });
+  };
+
+  watch(
+    currentRoute,
+    (route) => {
+      const { path } = unref(route);
+      if (path !== ROUTE_PATH) {
+        return;
+      }
+      query();
+    },
+    { immediate: true }
+  );
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'SystemUserDetails'
+  };
+</script>
diff --git a/src/views/system/user/index.vue b/src/views/system/user/index.vue
new file mode 100644
index 0000000..51f3c92
--- /dev/null
+++ b/src/views/system/user/index.vue
@@ -0,0 +1,295 @@
+<template>
+  <div class="ele-body">
+    <a-card :bordered="false">
+      <!-- 搜索表单 -->
+      <user-search :where="defaultWhere" @search="reload" />
+      <!-- 表格 -->
+      <ele-pro-table
+        ref="tableRef"
+        row-key="userId"
+        :columns="columns"
+        :datasource="datasource"
+        v-model:selection="selection"
+        :scroll="{ x: 1000 }"
+        :where="defaultWhere"
+        cache-key="proSystemUserTable"
+      >
+        <template #toolbar>
+          <a-space>
+            <a-button type="primary" class="ele-btn-icon" @click="openEdit()">
+              <template #icon>
+                <plus-outlined />
+              </template>
+              <span>新建</span>
+            </a-button>
+            <a-button
+              danger
+              type="primary"
+              class="ele-btn-icon"
+              @click="removeBatch"
+            >
+              <template #icon>
+                <delete-outlined />
+              </template>
+              <span>删除</span>
+            </a-button>
+          </a-space>
+        </template>
+        <template #bodyCell="{ column, record }">
+          <template v-if="column.key === 'nickname'">
+            <router-link :to="'/system/user/details?id=' + record.userId">
+              {{ record.nickname }}
+            </router-link>
+          </template>
+          <template v-else-if="column.key === 'roles'">
+            <a-tag v-for="item in record.roles" :key="item.roleId" color="blue">
+              {{ item.roleName }}
+            </a-tag>
+          </template>
+          <template v-else-if="column.key === 'sex'">
+            <text v-if="record.sex=='1'">男</text>
+            <text v-else-if="record.sex=='2'">女</text>
+            <text v-else>保密</text>
+          </template>
+          <template v-else-if="column.key === 'status'">
+            <a-switch
+              :checked="record.status === 0"
+              @change="(checked: boolean) => editStatus(checked, record)"
+            />
+          </template>
+          <template v-else-if="column.key === 'action'">
+            <a-space>
+              <a @click="openEdit(record)">修改</a>
+              <a-divider type="vertical" />
+              <a @click="resetPsw(record)">重置密码</a>
+              <a-divider type="vertical" />
+              <a-popconfirm
+                placement="topRight"
+                title="确定要删除此用户吗?"
+                @confirm="remove(record)"
+              >
+                <a class="ele-text-danger">删除</a>
+              </a-popconfirm>
+            </a-space>
+          </template>
+        </template>
+      </ele-pro-table>
+    </a-card>
+    <!-- 编辑弹窗 -->
+    <user-edit v-model:visible="showEdit" :data="current" @done="reload" />
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { createVNode, ref, reactive } from 'vue';
+  import { message, Modal } from 'ant-design-vue/es';
+  import {
+    PlusOutlined,
+    DeleteOutlined,
+    ExclamationCircleOutlined
+  } from '@ant-design/icons-vue';
+  import type { EleProTable } from 'ele-admin-pro/es';
+  import type {
+    DatasourceFunction,
+    ColumnItem
+  } from 'ele-admin-pro/es/ele-pro-table/types';
+  import { toDateString, messageLoading } from 'ele-admin-pro/es';
+  import UserSearch from './components/user-search.vue';
+  import UserEdit from './components/user-edit.vue';
+  import {
+    pageUsers,
+    removeUser,
+    removeUsers,
+    updateUserStatus,
+    updateUserPassword
+  } from '@/api/system/user';
+  import type { User, UserParam } from '@/api/system/user/model';
+
+  // 表格实例
+  const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
+
+  // 表格列配置
+  const columns = ref<ColumnItem[]>([
+    {
+      key: 'index',
+      width: 48,
+      align: 'center',
+      fixed: 'left',
+      hideInSetting: true,
+      customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
+    },
+    {
+      title: '用户账号',
+      dataIndex: 'username',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '用户名',
+      key: 'nickname',
+      dataIndex: 'nickname',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '性别',
+      key: 'sex',
+      dataIndex: 'sex',
+      width: 80,
+      align: 'center',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '手机号',
+      dataIndex: 'phone',
+      sorter: true,
+      showSorterTooltip: false
+    },
+    {
+      title: '角色',
+      key: 'roles'
+    },
+    {
+      title: '创建时间',
+      dataIndex: 'createTime',
+      sorter: true,
+      showSorterTooltip: false,
+      ellipsis: true,
+      customRender: ({ text }) => toDateString(text)
+    },
+    {
+      title: '状态',
+      key: 'status',
+      dataIndex: 'status',
+      sorter: true,
+      showSorterTooltip: false,
+      width: 90,
+      align: 'center'
+    },
+    {
+      title: '操作',
+      key: 'action',
+      width: 200,
+      align: 'center'
+    }
+  ]);
+
+  // 表格选中数据
+  const selection = ref<User[]>([]);
+
+  // 当前编辑数据
+  const current = ref<User | null>(null);
+
+  // 是否显示编辑弹窗
+  const showEdit = ref(false);
+
+  // 是否显示用户导入弹窗
+  const showImport = ref(false);
+
+  // 默认搜索条件
+  const defaultWhere = reactive({
+    username: '',
+    nickname: ''
+  });
+
+  // 表格数据源
+  const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
+    return pageUsers({ ...where, ...orders, page, limit });
+  };
+
+  /* 搜索 */
+  const reload = (where?: UserParam) => {
+    selection.value = [];
+    tableRef?.value?.reload({ page: 1, where });
+  };
+
+  /* 打开编辑弹窗 */
+  const openEdit = (row?: User) => {
+    current.value = row ?? null;
+    showEdit.value = true;
+  };
+
+  /* 删除单个 */
+  const remove = (row: User) => {
+    const hide = messageLoading('请求中..', 0);
+    removeUser(row.userId)
+      .then((msg) => {
+        hide();
+        message.success(msg);
+        reload();
+      })
+      .catch((e) => {
+        hide();
+        message.error(e.message);
+      });
+  };
+
+  /* 批量删除 */
+  const removeBatch = () => {
+    if (!selection.value.length) {
+      message.error('请至少选择一条数据');
+      return;
+    }
+    Modal.confirm({
+      title: '提示',
+      content: '确定要删除选中的用户吗?',
+      icon: createVNode(ExclamationCircleOutlined),
+      maskClosable: true,
+      onOk: () => {
+        const hide = messageLoading('请求中..', 0);
+        removeUsers(selection.value.map((d) => d.userId))
+          .then((msg) => {
+            hide();
+            message.success(msg);
+            reload();
+          })
+          .catch((e) => {
+            hide();
+            message.error(e.message);
+          });
+      }
+    });
+  };
+
+  /* 重置用户密码 */
+  const resetPsw = (row: User) => {
+    Modal.confirm({
+      title: '提示',
+      content: '确定要重置此用户的密码为"123456"吗?',
+      icon: createVNode(ExclamationCircleOutlined),
+      maskClosable: true,
+      onOk: () => {
+        const hide = messageLoading('请求中..', 0);
+        updateUserPassword(row.userId)
+          .then((msg) => {
+            hide();
+            message.success(msg);
+          })
+          .catch((e) => {
+            hide();
+            message.error(e.message);
+          });
+      }
+    });
+  };
+
+  /* 修改用户状态 */
+  const editStatus = (checked: boolean, row: User) => {
+    const status = checked ? 0 : 1;
+    updateUserStatus(row.userId, status)
+      .then((msg) => {
+        row.status = status;
+        message.success(msg);
+      })
+      .catch((e) => {
+        message.error(e.message);
+      });
+  };
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'SystemUser'
+  };
+</script>
diff --git a/src/views/user/message/components/message-notice.vue b/src/views/user/message/components/message-notice.vue
new file mode 100644
index 0000000..0e81b77
--- /dev/null
+++ b/src/views/user/message/components/message-notice.vue
@@ -0,0 +1,152 @@
+<template>
+  <div>
+    <ele-pro-table
+      ref="tableRef"
+      row-key="id"
+      :columns="columns"
+      :datasource="datasource"
+      v-model:selection="selection"
+      :scroll="{ x: 600 }"
+    >
+      <template #toolbar>
+        <a-space>
+          <a-button type="primary" class="ele-btn-icon" @click="confirmBatch">
+            批量确认
+          </a-button>
+          <a-button
+            danger
+            type="primary"
+            class="ele-btn-icon"
+            @click="removeBatch"
+          >
+            删除通知
+          </a-button>
+        </a-space>
+      </template>
+      <template #bodyCell="{ column, record }">
+        <template v-if="column.key === 'status'">
+          <span :class="['ele-text-warning', 'ele-text-info'][record.status]">
+            {{ ['未确认', '已确认'][record.status] }}
+          </span>
+        </template>
+        <template v-else-if="column.key === 'action'">
+          <a-space>
+            <a @click="confirm(record)">确认</a>
+            <a-divider type="vertical" />
+            <a-popconfirm
+              placement="topRight"
+              title="确定要删除此通知吗"
+              @confirm="remove(record)"
+            >
+              <a class="ele-text-danger">删除</a>
+            </a-popconfirm>
+          </a-space>
+        </template>
+      </template>
+    </ele-pro-table>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import { message } from 'ant-design-vue/es';
+import type { EleProTable } from 'ele-admin-pro/es';
+import { pageNotices } from '@/api/user/message';
+import type { Message } from '@/api/user/message/model';
+import type {
+  DatasourceFunction,
+  ColumnItem
+} from 'ele-admin-pro/es/ele-pro-table/types';
+
+const emit = defineEmits<{
+  (e: 'update-data'): void;
+}>();
+
+// 表格实例
+const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
+
+// 表格列配置
+const columns = ref<ColumnItem[]>([
+  {
+    key: 'index',
+    width: 48,
+    align: 'center',
+    fixed: 'left',
+    hideInSetting: true,
+    customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
+  },
+  {
+    title: '通知标题',
+    dataIndex: 'title',
+    ellipsis: true
+  },
+  {
+    title: '通知时间',
+    dataIndex: 'time',
+    ellipsis: true,
+    width: 140,
+    align: 'center'
+  },
+  {
+    title: '状态',
+    key: 'status',
+    width: 90,
+    align: 'center'
+  },
+  {
+    title: '操作',
+    key: 'action',
+    width: 120,
+    align: 'center',
+    hideInSetting: true
+  }
+]);
+
+// 列表选中数据
+const selection = ref<Message[]>([]);
+
+// 表格数据源
+const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
+  return pageNotices({ ...where, ...orders, page, limit });
+};
+
+/* 确认 */
+const confirm = (row: Message) => {
+  console.log(row);
+  message.info('点击了确认');
+};
+
+/* 删除单个 */
+const remove = (row: Message) => {
+  console.log(row);
+  message.info('点击了删除');
+  updateUnReadNum();
+};
+
+/* 批量删除 */
+const removeBatch = () => {
+  if (!selection.value.length) {
+    message.error('请至少选择一条数据');
+    return;
+  }
+  message.info('点击了删除');
+  updateUnReadNum();
+};
+
+/* 批量确认 */
+const confirmBatch = () => {
+  if (!selection.value.length) {
+    message.error('请至少选择一条数据');
+    return;
+  }
+  selection.value.forEach((d) => {
+    d.status = 1;
+  });
+  updateUnReadNum();
+};
+
+/* 触发更新未读数量事件 */
+const updateUnReadNum = () => {
+  emit('update-data');
+};
+</script>
diff --git a/src/views/user/message/index.vue b/src/views/user/message/index.vue
new file mode 100644
index 0000000..e837851
--- /dev/null
+++ b/src/views/user/message/index.vue
@@ -0,0 +1,153 @@
+<template>
+  <div :class="['ele-body', { 'demo-message-responsive': styleResponsive }]">
+    <a-card :bordered="false" :body-style="{ padding: '0px' }">
+      <div class="ele-cell ele-cell-align-top ele-user-message">
+        <div class="message-menu-wrap">
+          <a-menu :selected-keys="active" :mode="mode">
+            <a-menu-item key="notice">
+              <router-link to="/user/message?type=notice">
+                <a-badge v-if="unReadNotice" :count="unReadNotice" />
+                <span>系统通知</span>
+              </router-link>
+            </a-menu-item>
+          </a-menu>
+        </div>
+        <div class="ele-cell-content" style="overflow-x: hidden">
+          <transition name="slide-right" mode="out-in">
+            <message-notice
+              v-if="active.includes('notice')"
+              @update-data="queryUnReadNum"
+            />
+          </transition>
+        </div>
+      </div>
+    </a-card>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, watch, unref, computed } from 'vue';
+import { useRouter } from 'vue-router';
+import { storeToRefs } from 'pinia';
+import { message } from 'ant-design-vue/es';
+import { useThemeStore } from '@/store/modules/theme';
+import MessageNotice from './components/message-notice.vue';
+import { getUnReadNum } from '@/api/user/message';
+
+const { currentRoute } = useRouter();
+const themeStore = useThemeStore();
+const { screenWidth, styleResponsive } = storeToRefs(themeStore);
+
+// 导航选中
+const active = ref<string[]>([]);
+
+// 通知未读数量
+const unReadNotice = ref(0);
+
+// 导航模式
+const mode = computed(() => {
+  return styleResponsive.value && screenWidth.value < 768
+    ? 'horizontal'
+    : 'inline';
+});
+
+watch(
+  currentRoute,
+  (route) => {
+    const { path, query } = unref(route);
+    if (path === '/user/message') {
+      const defaultType = 'notice';
+      if (!query.type) {
+        active.value = [defaultType];
+      } else if (typeof query.type === 'string') {
+        active.value = [query.type || defaultType];
+      } else if (query.type.length && query.type[0]) {
+        active.value = [query.type[0]];
+      } else {
+        active.value = [defaultType];
+      }
+    }
+  },
+  {
+    immediate: true
+  }
+);
+
+/* 查询未读数量 */
+const queryUnReadNum = () => {
+  getUnReadNum()
+    .then((result) => {
+      unReadNotice.value = result.notice;
+    })
+    .catch((e) => {
+      message.error(e.message);
+    });
+};
+
+queryUnReadNum();
+</script>
+
+<script lang="ts">
+export default {
+  name: 'UserMessage'
+};
+</script>
+
+<style lang="less" scoped>
+.message-menu-wrap {
+  width: 150px;
+  display: flex;
+
+  :deep(.ant-menu) {
+    padding-top: 16px;
+
+    .ant-badge {
+      vertical-align: -2px;
+      margin-right: 10px;
+    }
+
+    .ant-badge-count {
+      height: 16px;
+      line-height: 16px;
+      border-radius: 8px;
+      box-shadow: none;
+      min-width: 16px;
+      padding: 0 2px;
+    }
+
+    .ant-scroll-number-only {
+      height: 16px;
+
+      & > p.ant-scroll-number-only-unit {
+        height: 16px;
+      }
+    }
+  }
+
+  & + .ele-cell-content {
+    padding: 16px 24px;
+    overflow: auto;
+  }
+}
+
+@media screen and (max-width: 768px) {
+  .demo-message-responsive {
+    .ele-user-message {
+      display: block;
+
+      & > .ele-cell-content {
+        padding: 16px 16px;
+      }
+    }
+
+    .message-menu-wrap {
+      width: auto;
+      display: block;
+
+      :deep(.ant-menu) {
+        padding-top: 0;
+      }
+    }
+  }
+}
+</style>
diff --git a/src/views/user/profile/index.vue b/src/views/user/profile/index.vue
new file mode 100644
index 0000000..7eb17ae
--- /dev/null
+++ b/src/views/user/profile/index.vue
@@ -0,0 +1,366 @@
+<template>
+  <div class="ele-body ele-body-card">
+    <a-row :gutter="16">
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xxl: 6, xl: 7, lg: 9, md: 10, sm: 24, xs: 24 }
+            : { span: 6 }
+        "
+      >
+        <a-card :bordered="false">
+          <div class="ele-text-center">
+            <div class="user-info-avatar-group" @click="openCropper">
+              <a-avatar :size="110" :src="form.avatar" />
+              <upload-outlined class="user-info-avatar-icon" />
+            </div>
+            <h1>{{ loginUser.nickname }}</h1>
+            <div>{{ loginUser.introduction }}</div>
+          </div>
+        </a-card>
+      </a-col>
+      <a-col
+        v-bind="
+          styleResponsive
+            ? { xxl: 18, xl: 17, lg: 15, md: 14, sm: 24, xs: 24 }
+            : { span: 18 }
+        "
+      >
+        <a-card
+          :bordered="false"
+          :body-style="{ paddingTop: '0px', minHeight: '600px' }"
+        >
+          <a-tabs v-model:active-key="active" size="large">
+            <a-tab-pane tab="基本信息" key="info">
+              <a-form
+                ref="formRef"
+                :model="form"
+                :rules="rules"
+                :label-col="
+                  styleResponsive
+                    ? { lg: 4, md: 6, sm: 4, xs: 24 }
+                    : { flex: '100px' }
+                "
+                :wrapper-col="
+                  styleResponsive
+                    ? { lg: 20, md: 18, sm: 20, xs: 24 }
+                    : { flex: '1' }
+                "
+                style="max-width: 580px; margin-top: 20px"
+              >
+                <a-form-item label="昵称" name="nickname">
+                  <a-input
+                    v-model:value="form.nickname"
+                    placeholder="请输入昵称"
+                    allow-clear
+                  />
+                </a-form-item>
+                <a-form-item label="性别" name="sex">
+                  <a-select
+                    v-model:value="form.sex"
+                    placeholder="请选择性别"
+                    allow-clear
+                  >
+                    <a-select-option value="0">保密</a-select-option>
+                    <a-select-option value="1">男</a-select-option>
+                    <a-select-option value="2">女</a-select-option>
+                  </a-select>
+                </a-form-item>
+                <a-form-item label="邮箱" name="email">
+                  <a-input
+                    v-model:value="form.email"
+                    placeholder="请输入邮箱"
+                    allow-clear
+                  />
+                </a-form-item>
+                <a-form-item label="个人简介">
+                  <a-textarea
+                    v-model:value="form.introduction"
+                    placeholder="请输入个人简介"
+                    :rows="4"
+                  />
+                </a-form-item>
+                <a-form-item label="联系电话:">
+                  <div class="ele-cell">
+                    <div class="ele-cell-content">
+                      <a-input
+                        v-model:value="form.phone"
+                        placeholder="请输入联系电话"
+                        allow-clear
+                      />
+                    </div>
+                  </div>
+                </a-form-item>
+                <a-form-item
+                  :wrapper-col="
+                    styleResponsive
+                      ? {
+                          lg: { offset: 4 },
+                          md: { offset: 6 },
+                          sm: { offset: 4 }
+                        }
+                      : { offset: 4 }
+                  "
+                >
+                  <a-button type="primary" :loading="loading" @click="save">
+                    {{ loading ? '保存中..' : '保存更改' }}
+                  </a-button>
+                </a-form-item>
+              </a-form>
+            </a-tab-pane>
+            <a-tab-pane tab="账号绑定" key="account">
+              <div class="user-account-list">
+                <div class="ele-cell">
+                  <wechat-outlined class="user-account-icon" />
+                  <div class="ele-cell-content">
+                    <div class="ele-cell-title">绑定微信</div>
+                    <div class="ele-cell-desc">当前未绑定绑定微信账号</div>
+                  </div>
+                  <div @click="showQR">去绑定</div>
+                </div>
+              </div>
+            </a-tab-pane>
+          </a-tabs>
+        </a-card>
+      </a-col>
+    </a-row>
+    <!-- 头像裁剪弹窗 -->
+    <ele-cropper-modal
+      :src="form.avatar"
+      v-model:visible="visible"
+      :to-blob="true"
+      :options="{ autoCropArea: 1, viewMode: 1, dragMode: 'move' }"
+      @done="onCrop"
+    />
+
+    <ele-modal v-model:visible="qrCodeShow" title="扫码绑定微信" width="240px">
+      <template #footer>
+        <a-button @click="handleCancel">关闭</a-button>
+      </template>
+      <ele-qr-code :value="QrText" :size="190" :margin="0" />
+      <a-qrcode :value="QrText" />
+    </ele-modal>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive, computed } from 'vue';
+  import { UploadOutlined, WechatOutlined } from '@ant-design/icons-vue';
+  import { message } from 'ant-design-vue/es';
+  import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+  import { useUserStore } from '@/store/modules/user';
+  import { storeToRefs } from 'pinia';
+  import { useThemeStore } from '@/store/modules/theme';
+  import { updateUser, uploadAvatar } from '@/api/user/profile';
+  import { ProfileForm } from '@/api/user/profile/model';
+
+  // 是否开启响应式布局
+  const themeStore = useThemeStore();
+  const { styleResponsive } = storeToRefs(themeStore);
+
+  const userStore = useUserStore();
+
+  //
+  const formRef = ref<FormInstance | null>(null);
+
+  // tab 页选中
+  const active = ref('info');
+
+  // 保存按钮 loading
+  const loading = ref(false);
+
+  // 是否显示裁剪弹窗
+  const visible = ref(false);
+
+  // 登录用户信息
+  const loginUser = computed(() => userStore.info ?? {});
+
+  // 表单数据
+  const form = reactive<ProfileForm>({
+    userId: loginUser.value.userId,
+    nickname: loginUser.value.nickname,
+    sex: loginUser.value.sex,
+    email: loginUser.value.email,
+    introduction: loginUser.value.introduction,
+    phone: loginUser.value.phone,
+    avatar: loginUser.value.avatar
+  });
+
+  // 表单验证规则
+  const rules = reactive<Record<string, Rule[]>>({
+    nickname: [
+      {
+        required: true,
+        message: '请输入昵称',
+        type: 'string'
+      }
+    ],
+    sex: [
+      {
+        required: true,
+        message: '请选择性别',
+        type: 'string'
+      }
+    ],
+    email: [
+      {
+        required: true,
+        message: '请输入邮箱',
+        type: 'string'
+      }
+    ]
+  });
+
+  const QrText = ref('http://test.cqtlcm.com/api/postCode');
+  const qrCodeShow = ref(false);
+
+  const showQR = () => {
+    qrCodeShow.value = true;
+  };
+
+  const handleCancel = () => {
+    qrCodeShow.value = false;
+  };
+
+  /* 修改登录用户 */
+  const updateLoginUser = (obj: Record<string, any>) => {
+    userStore.setInfo({ ...loginUser.value, ...obj });
+  };
+
+  /* 保存更改 */
+  const save = () => {
+    if (!formRef.value) {
+      return;
+    }
+    formRef.value
+      .validate()
+      .then(() => {
+        loading.value = true;
+        updateUser(form).then((res) => {
+          if (res.code === 0) {
+            message.success(res.message);
+            loading.value = false;
+          } else {
+            message.error('修改失败!');
+            loading.value = false;
+          }
+        });
+      })
+      .catch(() => {});
+  };
+
+  /* 头像裁剪完成回调 */
+  const onCrop = (blob: Blob) => {
+    visible.value = false;
+    const formData = new FormData();
+    formData.append('file', blob);
+    uploadAvatar(formData).then((res: any) => {
+      form.avatar = res.data;
+    });
+    updateLoginUser(form);
+  };
+
+  /* 打开图片裁剪 */
+  const openCropper = () => {
+    visible.value = true;
+  };
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'UserProfile'
+  };
+</script>
+
+<style lang="less" scoped>
+  /* 用户资料卡片 */
+  .user-info-avatar-group {
+    margin: 16px 0;
+    display: inline-block;
+    position: relative;
+    cursor: pointer;
+
+    .user-info-avatar-icon {
+      position: absolute;
+      top: 50%;
+      left: 50%;
+      transform: translate(-50%, -50%);
+      color: #fff;
+      font-size: 30px;
+      display: none;
+      z-index: 2;
+    }
+
+    &:hover .user-info-avatar-icon {
+      display: block;
+    }
+
+    &:after {
+      content: '';
+      position: absolute;
+      top: 0;
+      left: 0;
+      width: 100%;
+      height: 100%;
+      border-radius: 50%;
+      background-color: transparent;
+      transition: background-color 0.3s;
+    }
+
+    &:hover:after {
+      background-color: rgba(0, 0, 0, 0.4);
+    }
+
+    & + h1 {
+      margin-bottom: 8px;
+    }
+  }
+
+  /* 用户信息列表 */
+  .user-info-list {
+    margin: 47px 0 32px 0;
+
+    .ele-cell + .ele-cell {
+      margin-top: 16px;
+    }
+
+    & + .ant-divider {
+      margin-bottom: 16px;
+    }
+  }
+
+  /* 用户标签 */
+  .user-info-tags {
+    margin: 16px 0 4px 0;
+
+    .ant-tag {
+      margin: 0 12px 8px 0;
+    }
+  }
+
+  /* 用户账号绑定列表 */
+  .user-account-list {
+    & > .ele-cell {
+      padding: 16px 8px;
+    }
+
+    .user-account-icon {
+      color: #fff;
+      padding: 8px;
+      font-size: 26px;
+      border-radius: 50%;
+
+      &.anticon-qq {
+        background: #3492ed;
+      }
+
+      &.anticon-wechat {
+        background: #4daf29;
+      }
+
+      &.anticon-alipay {
+        background: #1476fe;
+      }
+    }
+  }
+</style>
diff --git a/src/views/wechat/menu/index.vue b/src/views/wechat/menu/index.vue
new file mode 100644
index 0000000..a0b2d3d
--- /dev/null
+++ b/src/views/wechat/menu/index.vue
@@ -0,0 +1,1218 @@
+<template>
+  <div>
+    <a-page-header :ghost="false" title="自定义菜单">
+      <div class="ele-text-secondary">
+        微信自定义菜单的扩展,不用登录微信平台即可修改微信菜单
+      </div>
+    </a-page-header>
+    <div class="wechat-body">
+      <a-card :bordered="false">
+        <div class="content" style="width: 900px; margin: 0 auto">
+          <a-row justify="center" :gutter="24">
+            <a-col :span="10">
+              <div class="weixin-preview">
+                <div class="weixin-hd">
+                  <div class="weixin-title">{{ weixinTitle }}</div>
+                </div>
+                <div class="weixin-bd">
+                  <div class="weixin-menu" id="weixin-menu">
+                    <div
+                      v-for="(btn, i) in menu.button"
+                      class="menu-item"
+                      :key="i"
+                      :class="{
+                        current:
+                          selectedMenuIndex === i && selectedMenuLevel() === 1
+                      }"
+                      @click="selectedMenu(i)"
+                    >
+                      <div class="menu-item-title">
+                        <pause-circle-outlined
+                          :rotate="90"
+                          style="font-size: 10px"
+                        />
+                        <span>{{ btn.name }}</span>
+                      </div>
+                      <div
+                        class="weixin-sub-menu"
+                        v-show="selectedMenuIndex === i"
+                      >
+                        <div
+                          v-for="(sub, i2) in btn.sub_button"
+                          class="menu-sub-item"
+                          :key="i2"
+                          :class="{
+                            current:
+                              selectedSubMenuIndex === i2 &&
+                              selectedMenuLevel() === 2
+                          }"
+                          @click.stop="selectedSubMenu(i2)"
+                        >
+                          <div class="menu-item-title">
+                            <span>{{ sub.name }}</span>
+                          </div>
+                        </div>
+                        <div
+                          v-if="btn.sub_button.length < 5"
+                          class="menu-sub-item"
+                          @click.stop="addMenu(2)"
+                        >
+                          <div class="menu-item-title">
+                            <plus-outlined />
+                          </div>
+                        </div>
+                        <i class="menu-arrow arrow_out"></i>
+                        <i class="menu-arrow arrow_in"></i>
+                      </div>
+                    </div>
+                    <template v-if="menu.button.length < 3">
+                      <div class="menu-item" @click="addMenu(1)">
+                        <plus-outlined />
+                      </div>
+                    </template>
+                  </div>
+                </div>
+              </div>
+            </a-col>
+            <a-col :span="14">
+              <div class="weixin-menu-detail" v-if="selectedMenuLevel() === 1">
+                <div
+                  class="menu-input-group"
+                  style="border-bottom: 2px #e8e8e8 solid"
+                >
+                  <div class="menu-name">
+                    {{ menu.button[selectedMenuIndex].name }}
+                  </div>
+                  <div class="menu-del" @click="delMenu">删除菜单</div>
+                </div>
+                <div class="menu-input-group">
+                  <div class="menu-label">菜单名称</div>
+                  <div class="menu-input">
+                    <a-input
+                      placeholder="请输入菜单名称"
+                      class="menu-input-text"
+                      v-model:value="menu.button[selectedMenuIndex].name"
+                      @change="
+                        checkMenuName(menu.button[selectedMenuIndex].name)
+                      "
+                    />
+                    <p
+                      class="menu-tips"
+                      style="color: #e15f63"
+                      v-show="menuNameBounds"
+                      >字数超过上限</p
+                    >
+                    <p class="menu-tips">字数不超过4个汉字或8个字母</p>
+                  </div>
+                </div>
+                <template
+                  v-if="menu.button[selectedMenuIndex].sub_button.length === 0"
+                >
+                  <div class="menu-input-group">
+                    <div class="menu-label">菜单内容</div>
+                    <div class="menu-input">
+                      <a-select
+                        v-model:value="menu.button[selectedMenuIndex].type"
+                        name="type"
+                        class="menu-input-text"
+                      >
+                        <a-select-option value="view"
+                          >跳转网页(view)</a-select-option
+                        >
+                        <a-select-option value="media_id"
+                          >发送消息(media_id)</a-select-option
+                        >
+                        <a-select-option value="miniprogram"
+                          >打开指定小程序(miniprogram)</a-select-option
+                        >
+                        <a-select-option value="click"
+                          >自定义点击事件(click)</a-select-option
+                        >
+                        <a-select-option value="scancode_push"
+                          >扫码上传消息(scancode_push)</a-select-option
+                        >
+                        <a-select-option value="scancode_waitmsg"
+                          >扫码提示下发(scancode_waitmsg)</a-select-option
+                        >
+                        <a-select-option value="pic_sysphoto"
+                          >系统相机拍照(pic_sysphoto)</a-select-option
+                        >
+                        <a-select-option value="pic_photo_or_album"
+                          >弹出拍照或者相册(pic_photo_or_album)</a-select-option
+                        >
+                        <a-select-option value="pic_weixin"
+                          >弹出微信相册(pic_weixin)</a-select-option
+                        >
+                        <a-select-option value="location_select"
+                          >弹出地理位置选择器(location_select)</a-select-option
+                        >
+                      </a-select>
+                    </div>
+                  </div>
+                  <a-card
+                    class="menu-content"
+                    v-if="selectedMenuType() === 1"
+                    style="padding: 0"
+                  >
+                    <div class="menu-input-group">
+                      <p class="menu-tips">订阅者点击该子菜单会跳到以下链接</p>
+                      <div>
+                        <div class="menu-label">页面地址</div>
+                        <div class="menu-input">
+                          <a-input
+                            placeholder="请输入页面地址"
+                            class="menu-input-text"
+                            v-model:value="menu.button[selectedMenuIndex].url"
+                          />
+                          <p class="menu-tips cursor" @click="selectNewsUrl"
+                            >从公众号图文消息中选择</p
+                          >
+                        </div>
+                      </div>
+                    </div>
+                  </a-card>
+                  <div
+                    class="menu-msg-content"
+                    v-else-if="selectedMenuType() === 2"
+                  >
+                    <div class="menu-msg-head">
+                      <i class="icon_msg_sender"></i>
+                      图文消息
+                    </div>
+                    <div
+                      class="menu-msg-panel"
+                      v-if="menu.button[selectedMenuIndex].media_id"
+                    >
+                      <div class="menu-msg-select">
+                        <div class="menu-msg-title">
+                          <i class="icon_msg_sender"></i>
+                          {{ material.title }}
+                        </div>
+                        <a
+                          :href="material.url"
+                          target="_blank"
+                          class="el-button el-button--mini"
+                          >查看</a
+                        >
+                        <a-button
+                          size="small"
+                          type="primary"
+                          danger
+                          @click="delMaterialId"
+                          >删除</a-button
+                        >
+                      </div>
+                    </div>
+                    <div class="menu-msg-panel" v-else>
+                      <div class="menu-msg-select" @click="selectMaterialId">
+                        <i class="icon36_common add_gray"></i>
+                        <strong>从素材库中选择</strong>
+                      </div>
+                    </div>
+                  </div>
+                  <div
+                    class="menu-content"
+                    v-else-if="selectedMenuType() === 3"
+                  >
+                    <div class="menu-input-group">
+                      <p class="menu-tips">用于消息接口推送,不超过128字节</p>
+                      <div class="menu-label">菜单KEY值</div>
+                      <div class="menu-input">
+                        <a-input
+                          placeholder=""
+                          class="menu-input-text"
+                          v-model:value="menu.button[selectedMenuIndex].key"
+                        />
+                      </div>
+                    </div>
+                  </div>
+                  <div
+                    class="menu-content"
+                    v-else-if="selectedMenuType() === 4"
+                  >
+                    <div class="menu-input-group">
+                      <p class="menu-tips"
+                        >订阅者点击该子菜单会跳到以下小程序</p
+                      >
+                      <div class="menu-label">小程序APPID</div>
+                      <div class="menu-input">
+                        <a-input
+                          placeholder="小程序的appid(仅认证公众号可配置)"
+                          class="menu-input-text"
+                          v-model:value="menu.button[selectedMenuIndex].appid"
+                        />
+                      </div>
+                    </div>
+                    <div class="menu-input-group">
+                      <div class="menu-label">小程序路径</div>
+                      <div class="menu-input">
+                        <a-input
+                          placeholder="小程序的页面路径 pages/Index/index"
+                          class="menu-input-text"
+                          v-model="menu.button[selectedMenuIndex].pagepath"
+                        />
+                      </div>
+                    </div>
+                    <div class="menu-input-group">
+                      <div class="menu-label">备用网页</div>
+                      <div class="menu-input">
+                        <a-input
+                          placeholder=""
+                          class="menu-input-text"
+                          v-model="menu.button[selectedMenuIndex].url"
+                        />
+                        <p class="menu-tips">
+                          旧版微信客户端无法支持小程序,用户点击菜单时将会打开备用网页。
+                        </p>
+                      </div>
+                    </div>
+                  </div>
+                </template>
+              </div>
+              <!-- 子菜单 -->
+              <div class="weixin-menu-detail" v-if="selectedMenuLevel() === 2">
+                <div
+                  class="menu-input-group"
+                  style="border-bottom: 2px #e8e8e8 solid"
+                >
+                  <div class="menu-name">
+                    {{
+                      menu.button[selectedMenuIndex].sub_button[
+                        selectedSubMenuIndex
+                      ].name
+                    }}
+                  </div>
+                  <div class="menu-del" @click="delMenu">删除子菜单</div>
+                </div>
+                <div class="menu-input-group">
+                  <div class="menu-label">子菜单名称</div>
+                  <div class="menu-input">
+                    <a-input
+                      placeholder="请输入子菜单名称"
+                      class="menu-input-text"
+                      v-model:value="
+                        menu.button[selectedMenuIndex].sub_button[
+                          selectedSubMenuIndex
+                        ].name
+                      "
+                      @change="
+                        checkMenuName(
+                          menu.button[selectedMenuIndex].sub_button[
+                            selectedSubMenuIndex
+                          ].name
+                        )
+                      "
+                    />
+                    <p
+                      class="menu-tips"
+                      style="color: #e15f63"
+                      v-show="menuNameBounds"
+                    >
+                      字数超过上限
+                    </p>
+                    <p class="menu-tips">字数不超过8个汉字或16个字母</p>
+                  </div>
+                </div>
+                <div class="menu-input-group">
+                  <div class="menu-label">子菜单内容</div>
+                  <div class="menu-input">
+                    <a-select
+                      v-model:value="
+                        menu.button[selectedMenuIndex].sub_button[
+                          selectedSubMenuIndex
+                        ].type
+                      "
+                      name="type"
+                      class="menu-input-text"
+                    >
+                      <a-select-option value="view"
+                        >跳转网页(view)</a-select-option
+                      >
+                      <a-select-option value="media_id"
+                        >发送消息(media_id)</a-select-option
+                      >
+                      <a-select-option value="miniprogram"
+                        >打开指定小程序(miniprogram)</a-select-option
+                      >
+                      <a-select-option value="click"
+                        >自定义点击事件(click)</a-select-option
+                      >
+                      <a-select-option value="scancode_push"
+                        >扫码上传消息(scancode_push)</a-select-option
+                      >
+                      <a-select-option value="scancode_waitmsg"
+                        >扫码提示下发(scancode_waitmsg)</a-select-option
+                      >
+                      <a-select-option value="pic_sysphoto"
+                        >系统相机拍照(pic_sysphoto)</a-select-option
+                      >
+                      <a-select-option value="pic_photo_or_album"
+                        >弹出拍照或者相册(pic_photo_or_album)</a-select-option
+                      >
+                      <a-select-option value="pic_weixin"
+                        >弹出微信相册(pic_weixin)</a-select-option
+                      >
+                      <a-select-option value="location_select"
+                        >弹出地理位置选择器(location_select)</a-select-option
+                      >
+                    </a-select>
+                  </div>
+                </div>
+                <div class="menu-content" v-if="selectedMenuType() === 1">
+                  <div class="menu-input-group">
+                    <p class="menu-tips">订阅者点击该子菜单会跳到以下链接</p>
+                    <div class="menu-label">页面地址</div>
+                    <div class="menu-input">
+                      <a-input
+                        placeholder="请输入页面地址"
+                        class="menu-input-text"
+                        v-model:value="
+                          menu.button[selectedMenuIndex].sub_button[
+                            selectedSubMenuIndex
+                          ].url
+                        "
+                      />
+                      <p class="menu-tips cursor" @click="selectNewsUrl"
+                        >从公众号图文消息中选择</p
+                      >
+                    </div>
+                  </div>
+                </div>
+                <div
+                  class="menu-msg-content"
+                  v-else-if="selectedMenuType() === 2"
+                >
+                  <div class="menu-msg-head">
+                    <i class="icon_msg_sender"></i>
+                    图文消息
+                  </div>
+                  <div
+                    class="menu-msg-panel"
+                    v-if="
+                      menu.button[selectedMenuIndex].sub_button[
+                        selectedSubMenuIndex
+                      ].media_id
+                    "
+                  >
+                    <div class="menu-msg-select">
+                      <i class="icon_msg_sender"></i>
+                      <span>{{ material.title }}</span>
+                      <a
+                        :href="material.url"
+                        target="_blank"
+                        class="el-button el-button--mini"
+                        >查看</a
+                      >
+                      <a-button
+                        size="small"
+                        type="primary"
+                        danger
+                        @click="delMaterialId"
+                        >删除</a-button
+                      >
+                    </div>
+                  </div>
+                  <div class="menu-msg-panel" v-else>
+                    <div class="menu-msg-select" @click="selectMaterialId">
+                      <i class="icon36_common add_gray"></i>
+                      <strong>从素材库中选择</strong>
+                    </div>
+                  </div>
+                </div>
+                <div class="menu-content" v-else-if="selectedMenuType() === 3">
+                  <div class="menu-input-group">
+                    <p class="menu-tips">用于消息接口推送,不超过128字节</p>
+                    <div class="menu-label">菜单KEY值</div>
+                    <div class="menu-input">
+                      <a-input
+                        placeholder=""
+                        class="menu-input-text"
+                        v-model:value="
+                          menu.button[selectedMenuIndex].sub_button[
+                            selectedSubMenuIndex
+                          ].key
+                        "
+                      />
+                    </div>
+                  </div>
+                </div>
+                <div class="menu-content" v-else-if="selectedMenuType() === 4">
+                  <div class="menu-input-group">
+                    <p class="menu-tips">订阅者点击该子菜单会跳到以下小程序</p>
+                    <div class="menu-label">小程序APPID</div>
+                    <div class="menu-input">
+                      <a-input
+                        placeholder="小程序的appid(仅认证公众号可配置)"
+                        class="menu-input-text"
+                        v-model:value="
+                          menu.button[selectedMenuIndex].sub_button[
+                            selectedSubMenuIndex
+                          ].appid
+                        "
+                      />
+                    </div>
+                  </div>
+                  <div class="menu-input-group">
+                    <div class="menu-label">小程序路径</div>
+                    <div class="menu-input">
+                      <a-input
+                        placeholder="小程序的页面路径 pages/Index/index"
+                        class="menu-input-text"
+                        v-model:value="
+                          menu.button[selectedMenuIndex].sub_button[
+                            selectedSubMenuIndex
+                          ].pagepath
+                        "
+                      />
+                    </div>
+                  </div>
+                  <div class="menu-input-group">
+                    <div class="menu-label">备用网页</div>
+                    <div class="menu-input">
+                      <a-input
+                        placeholder=""
+                        class="menu-input-text"
+                        v-model:value="
+                          menu.button[selectedMenuIndex].sub_button[
+                            selectedSubMenuIndex
+                          ].url
+                        "
+                      />
+                      <p class="menu-tips"
+                        >旧版微信客户端无法支持小程序,用户点击菜单时将会打开备用网页。</p
+                      >
+                    </div>
+                  </div>
+                </div>
+              </div>
+            </a-col>
+          </a-row>
+
+          <div class="weixin-btn-group">
+            <a-button type="primary" @click="onMenuSubmit">发布</a-button>
+          </div>
+          <a-modal title="选择图文" v-model:visible="newsDialog">
+            <a-table :data-source="newsList" size="small" :pagination="{total:newsListTotal,pageSize:10}" :columns="newsTableHeader">
+              <template #bodyCell="{ column ,row }">
+                <a-button
+                  v-if="column.key === 'action'"
+                  type="primary" size="small"
+                  @click="setNewsUrl(row)"
+                  >选择</a-button>
+              </template>
+            </a-table>
+          </a-modal>
+          <a-modal title="选择素材" v-model:visible="materialDialog">
+            <a-table :data-source="materialList" :columns="materialListColumns">
+              <template #bodyCell="{ row }">
+                <div
+                  v-for="(item, index) in row.content.news_item"
+                  :key="index"
+                >
+                  ({{ index + 1 }}).{{ item.title }}
+                </div>
+              </template>
+              <template #default="{ row }">
+                <a-button
+                  type="primary"
+                  size="small"
+                  @click="setMaterialId(row)"
+                >选择</a-button
+                >
+              </template>
+            </a-table>
+          </a-modal>
+        </div>
+      </a-card>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive, createVNode } from 'vue';
+  import { WxMenuForm } from '@/api/wechat/model';
+  import {
+    addWxMenu,
+    getWxMaterial,
+    getWxMaterialList,
+    getWxMenu
+  } from '@/api/wechat';
+  import { message, Modal } from 'ant-design-vue';
+  import {
+    ExclamationCircleOutlined,
+    PauseCircleOutlined,
+    PlusOutlined
+  } from '@ant-design/icons-vue';
+
+  const weixinTitle = ref('公众号');
+  const selectedMenuIndex = ref<any>(0); //当前选中菜单索引
+  const selectedSubMenuIndex = ref<any>(''); //当前选中子菜单索引
+  //当前菜单
+  const menu = reactive<WxMenuForm>({
+    button: [
+      {
+        name: '菜单名称',
+        type: '',
+        url: '',
+        sub_button: [{ name: '子菜单名称', type: '', url: '' }]
+      }
+    ]
+  });
+
+  const menuNameBounds = ref<boolean>(false); //菜单长度是否过长
+  const material = reactive({
+    title: '',
+    url: '',
+    thumb_url: ''
+  });
+  const materialLoading = ref<boolean>(false);
+  const materialDialog = ref<boolean>(false);
+  const materialList = ref([]);
+  const materialListOffset = ref(0);
+  const materialListTotal = ref(0);
+  const newsDialog = ref<boolean>(false);
+  const newsList = ref([]);
+  const newsListOffset = ref(0);
+  const newsListTotal = ref(0);
+  const newsTableHeader = [
+    {
+      title: '图文标题',
+      dataIndex: 'content.news_item[0].title'
+    },
+    {
+      title: '日期',
+      dataIndex: 'update_time'
+    },
+    {
+      title: '操作',
+      key:'action'
+    }
+  ];
+  const materialListColumns = [
+    {
+      title: '姓名',
+      dataIndex: 'name',
+      key: 'name',
+    },
+    {
+      title: '年龄',
+      dataIndex: 'age',
+      key: 'age',
+    },
+    {
+      title: '住址',
+      dataIndex: 'address',
+      key: 'address',
+    },
+  ];
+  const queryMenu = () => {
+    getWxMenu().then((res) => {
+      Object.assign(menu.button, res.menu.button);
+    });
+  };
+  //选中主菜单
+  const selectedMenu = (i) => {
+    selectedSubMenuIndex.value = '';
+    selectedMenuIndex.value = i;
+    let selectedMenu = menu.button[selectedMenuIndex.value];
+    //清空选中media_id 防止再次请求
+    if (selectedMenu.media_id && selectedMenuType() == 2) {
+      getMaterial(selectedMenu.media_id);
+    }
+    //检查名称长度
+    checkMenuName(selectedMenu.name);
+  };
+  //选中子菜单
+  const selectedSubMenu = (i) => {
+    selectedSubMenuIndex.value = i;
+    let selectedSubMenu =
+      menu.button[selectedMenuIndex.value].sub_button[
+        selectedSubMenuIndex.value
+      ];
+    if (selectedSubMenu.media_id && selectedMenuType() == 2) {
+      getMaterial(selectedSubMenu.media_id);
+    }
+    checkMenuName(selectedSubMenu.name);
+  };
+  //选中菜单级别
+  const selectedMenuLevel = () => {
+    if (selectedMenuIndex.value !== '' && selectedSubMenuIndex.value === '') {
+      //主菜单
+      return 1;
+    } else if (
+      selectedMenuIndex.value !== '' &&
+      selectedSubMenuIndex.value !== ''
+    ) {
+      //子菜单
+      return 2;
+    } else {
+      //未选中任何菜单
+      return 0;
+    }
+  };
+  //获取菜单类型 1. view网页类型,2. media_id类型和view_limited类型 3. click点击类型,4.miniprogram表示小程序类型
+
+  const selectedMenuType = () => {
+    if (
+      selectedMenuLevel() == 1 &&
+      menu.button[selectedMenuIndex.value].sub_button.length == 0
+    ) {
+      //主菜单
+      switch (menu.button[selectedMenuIndex.value].type) {
+        case 'view':
+          return 1;
+        case 'media_id':
+          return 2;
+        case 'view_limited':
+          return 2;
+        case 'click':
+          return 3;
+        case 'scancode_push':
+          return 3;
+        case 'scancode_waitmsg':
+          return 3;
+        case 'pic_sysphoto':
+          return 3;
+        case 'pic_photo_or_album':
+          return 3;
+        case 'pic_weixin':
+          return 3;
+        case 'location_select':
+          return 3;
+        case 'miniprogram':
+          return 4;
+      }
+    } else if (selectedMenuLevel() == 2) {
+      //子菜单
+      switch (
+        menu.button[selectedMenuIndex.value].sub_button[
+          selectedSubMenuIndex.value
+        ].type
+      ) {
+        case 'view':
+          return 1;
+        case 'media_id':
+          return 2;
+        case 'view_limited':
+          return 2;
+        case 'click':
+          return 3;
+        case 'scancode_push':
+          return 3;
+        case 'scancode_waitmsg':
+          return 3;
+        case 'pic_sysphoto':
+          return 3;
+        case 'pic_photo_or_album':
+          return 3;
+        case 'pic_weixin':
+          return 3;
+        case 'location_select':
+          return 3;
+        case 'miniprogram':
+          return 4;
+      }
+    } else {
+      return 1;
+    }
+  };
+  //添加菜单
+  const addMenu = (level) => {
+    if (level == 1 && menu.button.length < 3) {
+      menu.button.push({
+        type: 'view',
+        name: '菜单名称',
+        sub_button: [],
+        url: ''
+      });
+      selectedMenuIndex.value = menu.button.length - 1;
+      selectedSubMenuIndex.value = '';
+    }
+    if (
+      level == 2 &&
+      menu.button[selectedMenuIndex.value].sub_button.length < 5
+    ) {
+      menu.button[selectedMenuIndex.value].sub_button.push({
+        type: 'view',
+        name: '子菜单名称',
+        url: ''
+      });
+      selectedSubMenuIndex.value =
+        menu.button[selectedMenuIndex.value].sub_button.length - 1;
+    }
+  };
+  //删除菜单
+  const delMenu = () => {
+    if (selectedMenuLevel() == 1) {
+      Modal.confirm({
+        title: '警告',
+        content: '删除后菜单下设置的内容将被删除',
+        icon: createVNode(ExclamationCircleOutlined),
+        maskClosable: true,
+        onOk: () => {
+          if (selectedMenuIndex.value === 0) {
+            menu.button.splice(selectedMenuIndex.value, 1);
+            selectedMenuIndex.value = 0;
+          } else {
+            menu.button.splice(selectedMenuIndex.value, 1);
+            selectedMenuIndex.value -= 1;
+          }
+          if (menu.button.length == 0) {
+            selectedMenuIndex.value = '';
+          }
+        },
+        onCancel: () => {
+          return;
+        }
+      });
+    } else if (selectedMenuLevel() == 2) {
+      if (selectedSubMenuIndex.value === 0) {
+        menu.button[selectedMenuIndex.value].sub_button.splice(
+          selectedSubMenuIndex.value,
+          1
+        );
+        selectedSubMenuIndex.value = 0;
+      } else {
+        menu.button[selectedMenuIndex.value].sub_button.splice(
+          selectedSubMenuIndex.value,
+          1
+        );
+        selectedSubMenuIndex.value -= 1;
+      }
+      if (menu.button[selectedMenuIndex.value].sub_button.length == 0) {
+        selectedSubMenuIndex.value = '';
+      }
+    }
+  };
+  //检查菜单名称长度
+  const checkMenuName = (val) => {
+    if (selectedMenuLevel() == 1 && getMenuNameLen(val) <= 16) {
+      menuNameBounds.value = false;
+    } else
+      menuNameBounds.value = !(
+        selectedMenuLevel() == 2 && getMenuNameLen(val) <= 32
+      );
+  };
+  //获取菜单名称长度
+  const getMenuNameLen = (val) => {
+    let len = 0;
+    for (let i = 0; i < val.length; i++) {
+      const a = val.charAt(i);
+      a.match(/[^\x00-\xff]/gi) != null ? (len += 2) : (len += 1);
+    }
+    return len;
+  };
+  //选择公众号素材库素材
+  const selectMaterialId = () => {
+    materialDialog.value = true;
+    getMaterialList();
+  };
+  //选择公众号图文链接
+  const selectNewsUrl = () => {
+    newsDialog.value = true;
+    getNewsList();
+  };
+  //设置选择的素材id
+  // const setMaterialId = (row) => {
+  //   let { media_id, content } = row;
+  //   if (selectedMenuLevel() == 1) {
+  //     menu.button[selectedMenuIndex.value].media_id = media_id;
+  //   } else if (selectedMenuLevel() == 2) {
+  //     menu.button[selectedMenuIndex.value].sub_button[
+  //       selectedSubMenuIndex.value
+  //     ].media_id = media_id;
+  //   }
+  //   let { news_item } = content;
+  //   let item = news_item[0];
+  //   material.title = item.title;
+  //   material.url = item.url;
+  //   materialDialog.value = false;
+  // };
+  //删除选择的素材id
+  const delMaterialId = () => {
+    if (selectedMenuLevel() == 1) {
+      menu.button[selectedMenuIndex.value].media_id = '';
+    } else if (selectedMenuLevel() == 2) {
+      menu.button[selectedMenuIndex.value].sub_button[
+        selectedSubMenuIndex.value
+      ].media_id = '';
+    }
+  };
+  //设置选择的图文链接
+  const setNewsUrl = (row) => {
+    let { url } = row;
+    if (selectedMenuLevel() == 1) {
+      menu.button[selectedMenuIndex.value].url = url;
+    } else if (selectedMenuLevel() == 2) {
+      menu.button[selectedMenuIndex.value].sub_button[
+        selectedSubMenuIndex.value
+      ].url = url;
+    }
+    newsDialog.value = false;
+  };
+  //获取永久素材信息
+  const getMaterial = (id) => {
+    materialLoading.value = true;
+    getWxMaterial(id).then((res) => {
+      material.title = res.data.news_item[0].title;
+      material.url = res.data.news_item[0].url;
+    });
+    materialLoading.value = false;
+  };
+  //获取永久图文列表
+  const getNewsList = () => {
+    if (
+      newsListOffset.value > 0 &&
+      newsListOffset.value >= newsListOffset.value
+    ) {
+      return;
+    }
+    let params = {
+      type: 'news',
+      offset: newsListOffset.value,
+      count: 10
+    };
+    getWxMaterialList(params).then((res: any) => {
+      newsList.value = newsList.value.concat(res.item);
+      newsListOffset.value += res.item_count;
+      newsListTotal.value = res.total_count;
+    });
+  };
+  //获取永久素材列表
+  const getMaterialList = () => {
+    if (
+      materialListOffset.value > 0 &&
+      materialListOffset.value >= materialListTotal.value
+    ) {
+      return;
+    }
+    let params = {
+      type: 'image',
+      offset: newsListOffset.value,
+      count: 10
+    };
+    getWxMaterialList(params).then((res:any) => {
+      console.log(res.item)
+      materialList.value = materialList.value.concat(res.item);
+      materialListOffset.value += res.item_count;
+      materialListTotal.value = res.total_count;
+    });
+  };
+  //提交自定义菜单
+  const onMenuSubmit = () => {
+    addWxMenu(menu).then((res) => {
+      if (res.errcode === 0) {
+        message.success('修改成功!');
+      } else {
+        message.error('修改失败!');
+      }
+    });
+  };
+  queryMenu();
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'WeixinMenu'
+  };
+</script>
+
+<style lang="less" scoped>
+  .weixin-preview {
+    position: relative;
+    width: 320px;
+    height: 540px;
+    float: left;
+    margin-right: 10px;
+    border: 1px solid #e7e7eb;
+    .weixin-hd {
+      color: #fff;
+      text-align: center;
+      position: relative;
+      top: 0;
+      left: 0;
+      width: 320px;
+      height: 64px;
+      background: transparent url('@/assets/menu_head.png') no-repeat 0 0;
+      .weixin-title {
+        color: #fff;
+        font-size: 15px;
+        width: 100%;
+        text-align: center;
+        position: absolute;
+        top: 33px;
+        left: 0;
+      }
+    }
+    .weixin-menu {
+      position: absolute;
+      bottom: 0;
+      left: 0;
+      right: 0;
+      border-top: 1px solid #e7e7e7;
+      background: transparent url('@/assets/menu_foot.png') no-repeat 0 0;
+      padding-left: 43px;
+      margin-bottom: 0;
+      .menu-item {
+        position: relative;
+        float: left;
+        line-height: 50px;
+        height: 50px;
+        text-align: center;
+        width: 33.33%;
+        padding: 0;
+        border-left: 1px solid #e7e7e7;
+        border-right: 1px solid #e7e7e7;
+        cursor: pointer;
+        color: #616161;
+        .menu-item-title {
+          width: 100%;
+          overflow: hidden;
+          white-space: nowrap;
+          text-overflow: ellipsis;
+          box-sizing: border-box;
+        }
+        .current {
+          border: 1px solid #44b549;
+          background: #fff;
+          color: #44b549;
+        }
+      }
+    }
+    .weixin-sub-menu {
+      position: absolute;
+      bottom: 60px;
+      left: 0;
+      right: 0;
+      border-top: 1px solid #d0d0d0;
+      margin-bottom: 0;
+      background: #fafafa;
+      display: block;
+      padding: 0;
+      .menu-sub-item {
+        line-height: 50px;
+        height: 50px;
+        text-align: center;
+        width: 100%;
+        padding: 0;
+        border: 1px solid #d0d0d0;
+        border-top-width: 0;
+        cursor: pointer;
+        position: relative;
+        color: #616161;
+        .menu-item-title {
+          width: 100%;
+          overflow: hidden;
+          white-space: nowrap;
+          text-overflow: ellipsis;
+          box-sizing: border-box;
+        }
+        .current {
+          border: 1px solid #44b549;
+          background: #fff;
+          color: #44b549;
+        }
+      }
+      .show {
+        display: block;
+      }
+    }
+    .menu-arrow {
+      position: absolute;
+      left: 50%;
+      margin-left: -6px;
+    }
+    .arrow_in {
+      bottom: -4px;
+      display: inline-block;
+      width: 0;
+      height: 0;
+      border-width: 6px 6px 0;
+      border-style: solid dashed dashed;
+      border-color: #fafafa transparent transparent;
+    }
+    .arrow_out {
+      bottom: -5px;
+      display: inline-block;
+      width: 0;
+      height: 0;
+      border-width: 6px 6px 0;
+      border-style: solid dashed dashed;
+      border-color: #d0d0d0 transparent transparent;
+    }
+    a {
+      text-decoration: none;
+      color: #616161;
+    }
+  }
+  //}
+
+  .weixin-preview .menu-item:hover {
+    color: #000;
+  }
+  .weixin-preview .menu-sub-item:hover {
+    background: #eee;
+  }
+
+  .weixin-preview li .current:hover {
+    background: #fff;
+    color: #44b549;
+  }
+
+  /*菜单内容*/
+  .weixin-menu-detail {
+    width: 600px;
+    padding: 0 20px 5px;
+    background-color: #f4f5f9;
+    border: 1px solid #e7e7eb;
+    float: left;
+    min-height: 540px;
+    .menu-name {
+      float: left;
+      height: 40px;
+      line-height: 40px;
+      font-size: 18px;
+    }
+    .menu-del {
+      float: right;
+      height: 40px;
+      line-height: 40px;
+      color: #459ae9;
+      cursor: pointer;
+    }
+    .menu-input-group {
+      //width: 540px;
+      margin: 10px 0 30px 0;
+      overflow: hidden;
+    }
+    .menu-label {
+      float: left;
+      height: 30px;
+      line-height: 30px;
+      width: 80px;
+      text-align: right;
+    }
+    .menu-input {
+      float: left;
+      width: 380px;
+      .menu-tips {
+        margin: 0 0 0 10px;
+      }
+    }
+    .menu-input-text {
+      width: 300px;
+      margin-left: 10px;
+    }
+    .menu-tips {
+      color: #8d8d8d;
+      padding-top: 4px;
+      margin: 0;
+    }
+    .menu-tips.cursor {
+      color: #459ae9;
+      cursor: pointer;
+    }
+    .menu-content {
+      padding: 16px 20px;
+      border: 1px solid #e7e7eb;
+      background-color: #fff;
+      .menu-input-group {
+        margin: 0 0 10px 0;
+      }
+      .menu-label {
+        text-align: left;
+        width: 100px;
+      }
+      .menu-input-text {
+        border: 1px solid #e7e7eb;
+      }
+      .menu-tips {
+        padding-bottom: 10px;
+      }
+    }
+    .menu-msg-content {
+      padding: 0;
+      border: 1px solid #e7e7eb;
+      background-color: #fff;
+      .menu-msg-head {
+        overflow: hidden;
+        border-bottom: 1px solid #e7e7eb;
+        line-height: 38px;
+        height: 38px;
+        padding: 0 20px;
+      }
+      .menu-msg-panel {
+        padding: 30px 50px;
+      }
+      .menu-msg-select {
+        padding: 40px 20px;
+        border: 2px dotted #d9dadc;
+        text-align: center;
+        display: flex;
+        justify-content: center;
+        align-items: center;
+      }
+      .menu-msg-select:hover {
+        border-color: #b3b3b3;
+      }
+      strong {
+        display: block;
+        padding-top: 3px;
+        font-weight: 400;
+        font-style: normal;
+      }
+      .menu-msg-title {
+        float: left;
+        width: 310px;
+        height: 30px;
+        line-height: 30px;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+    }
+  }
+  .icon36_common {
+    width: 36px;
+    height: 36px;
+    vertical-align: middle;
+    display: inline-block;
+    .add_gray {
+      background: url('@/assets/base.png') 0 -2548px no-repeat;
+    }
+  }
+  .icon_msg_sender {
+    margin-right: 3px;
+    margin-top: -2px;
+    width: 20px;
+    height: 20px;
+    vertical-align: middle;
+    display: inline-block;
+    background: url('@/assets/msg_tab.png') 0 -270px no-repeat;
+  }
+
+  .weixin-btn-group {
+    text-align: center;
+    width: 950px;
+    margin-top: 30px;
+    overflow: hidden;
+    .btn {
+      width: 100px;
+      border-radius: 0;
+    }
+  }
+
+  #material-list {
+    padding: 20px;
+    overflow-y: scroll;
+    height: 558px;
+    table {
+      width: 100%;
+    }
+  }
+  #news-list {
+    padding: 20px;
+    overflow-y: scroll;
+    height: 558px;
+  }
+</style>
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/src/vite-env.d.ts
@@ -0,0 +1 @@
+/// <reference types="vite/client" />
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..dd32d4b
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,37 @@
+{
+  "compilerOptions": {
+    "target": "esnext",
+    "module": "esnext",
+    "moduleResolution": "node",
+    "strict": true,
+    "forceConsistentCasingInFileNames": true,
+    "allowSyntheticDefaultImports": true,
+    "strictFunctionTypes": false,
+    "jsx": "preserve",
+    "baseUrl": "./",
+    "allowJs": true,
+    "sourceMap": true,
+    "resolveJsonModule": true,
+    "esModuleInterop": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "experimentalDecorators": true,
+    "lib": ["esnext", "dom"],
+    "types": ["vite/client"],
+    "typeRoots": ["./node_modules/@types/"],
+    "noImplicitAny": false,
+    "skipLibCheck": true,
+    "paths": {
+      "@/*": ["src/*"]
+    }
+  },
+  "include": [
+    "src/**/*.ts",
+    "src/**/*.d.ts",
+    "src/**/*.tsx",
+    "src/**/*.vue",
+    "components.d.ts",
+    "vite.config.ts"
+  ],
+  "exclude": ["node_modules", "dist", "**/*.js"]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..7137502
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,72 @@
+import { defineConfig } from 'vite';
+import vue from '@vitejs/plugin-vue';
+import ViteCompression from 'vite-plugin-compression';
+import ViteComponents from 'unplugin-vue-components/vite';
+import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers';
+import { EleAdminResolver } from 'ele-admin-pro/lib/utils/resolvers';
+import { DynamicAntdLess } from 'ele-admin-pro/lib/utils/dynamic-theme';
+import { resolve } from 'path';
+
+export default defineConfig(({ command }) => {
+  const isBuild = command === 'build';
+  return {
+    resolve: {
+      alias: {
+        '@/': resolve('src') + '/',
+        'vue-i18n': 'vue-i18n/dist/vue-i18n.cjs.js'
+      }
+    },
+    base: './',
+    plugins: [
+      vue(),
+      // 组件按需引入
+      ViteComponents({
+        dts: false,
+        resolvers: [
+          AntDesignVueResolver({
+            importStyle: isBuild ? 'less' : false
+          }),
+          EleAdminResolver({
+            importStyle: isBuild ? 'less' : false
+          })
+        ]
+      }),
+      // gzip 压缩
+      ViteCompression({
+        disable: !isBuild,
+        threshold: 10240,
+        algorithm: 'gzip',
+        ext: '.gz'
+      })
+    ],
+    css: {
+      preprocessorOptions: {
+        less: {
+          javascriptEnabled: true,
+          plugins: [new DynamicAntdLess()],
+          modifyVars: {
+            // 组件样式开发环境全局引入生产环境按需引入
+            'style-entry-file': isBuild ? 'as-needed' : 'global-import'
+          }
+        }
+      }
+    },
+    optimizeDeps: {
+      include: [
+        'sortablejs',
+        'vuedraggable',
+        'echarts/core',
+        'echarts/charts',
+        'echarts/renderers',
+        'echarts/components',
+        'vue-echarts',
+        'echarts-wordcloud',
+        'xlsx'
+      ]
+    },
+    build: {
+      target: 'chrome63',
+      chunkSizeWarningLimit: 2000
+    }
+  };
+});