Преглед на файлове

feat: redesign mailhub admin console

AI-Co-Authored-By: Codex
chendeben преди 1 месец
родител
ревизия
ed7377db84
променени са 53 файла, в които са добавени 7488 реда и са изтрити 446 реда
  1. 43 0
      README.md
  2. 12 0
      index.html
  3. 12 0
      login.html
  4. 2953 0
      package-lock.json
  5. 20 1
      package.json
  6. 0 0
      public/assets/index-BhoF66Ow.css
  7. 0 0
      public/assets/index-Bv1_CvmS.js
  8. 0 0
      public/assets/index-CQhlXgMS.js
  9. 0 0
      public/assets/index-Cnh93foy.js
  10. 1 0
      public/assets/index-D13udm4g.js
  11. 0 0
      public/assets/index-Tu04tXLf.css
  12. 0 0
      public/assets/index-aEHvatu0.js
  13. 0 0
      public/assets/index-psgafNtk.css
  14. 0 0
      public/assets/login-Bf3wwaVX.js
  15. 0 0
      public/assets/styles-DMRLjM8Z.css
  16. 0 0
      public/assets/styles-DmsKpU2U.js
  17. 7 372
      public/index.html
  18. 7 70
      public/login.html
  19. 55 0
      scripts/deploy-remote.sh
  20. 23 0
      src/components/common/StatusTag.tsx
  21. 175 0
      src/components/domain/AddDomainDrawer.tsx
  22. 92 0
      src/components/domain/DnsRecordCard.tsx
  23. 105 0
      src/components/domain/DomainHealthCard.tsx
  24. 472 0
      src/frontend/App.tsx
  25. 90 0
      src/frontend/analytics-model.js
  26. 15 0
      src/frontend/api-token-model.js
  27. 157 0
      src/frontend/auth/AuthApp.tsx
  28. 30 0
      src/frontend/auth/main.tsx
  29. 53 0
      src/frontend/domain-model.js
  30. 496 0
      src/frontend/i18n/index.js
  31. 50 0
      src/frontend/i18n/react.tsx
  32. 7 0
      src/frontend/main.tsx
  33. 117 0
      src/frontend/services/api.ts
  34. 442 0
      src/frontend/styles.css
  35. 198 0
      src/frontend/types.ts
  36. 121 0
      src/layouts/AdminLayout.tsx
  37. 104 0
      src/pages/ApiTokens.tsx
  38. 223 0
      src/pages/Dashboard.tsx
  39. 143 0
      src/pages/DnsApi.tsx
  40. 411 0
      src/pages/Domains/DomainDetail.tsx
  41. 180 0
      src/pages/Domains/index.tsx
  42. 12 0
      src/pages/PlaceholderPage.tsx
  43. 92 0
      src/pages/SendingLogs.tsx
  44. 74 0
      src/pages/Settings.tsx
  45. 75 0
      src/pages/SmtpCredentials.tsx
  46. 5 3
      src/server.js
  47. 103 0
      test/frontend-analytics-model.test.js
  48. 27 0
      test/frontend-api-token-model.test.js
  49. 80 0
      test/frontend-domain-model.test.js
  50. 29 0
      test/frontend-i18n.test.js
  51. 131 0
      test/server-admin-api.test.js
  52. 21 0
      tsconfig.json
  53. 25 0
      vite.config.ts

+ 43 - 0
README.md

@@ -19,6 +19,49 @@ docker compose logs -f app postfix
 
 管理面板默认监听宿主机 `127.0.0.1:3025`,nginx 可反代到公网域名。
 
+## 发布流程
+
+生产发布采用 Git 拉取式部署,避免直接覆盖远端目录:
+
+```bash
+npm run release:check
+git status --short
+git add <changed-files>
+git commit -m "feat: ..."
+git push origin master
+npm run deploy:remote
+```
+
+`npm run deploy:remote` 会在远端 `/www/wwwroot/mail.ss5.xyz` 执行:
+
+```bash
+git fetch origin master
+git checkout master
+git pull --ff-only origin master
+docker compose up -d --build
+docker compose ps
+```
+
+脚本默认目标是 `root@192.227.215.183:/www/wwwroot/mail.ss5.xyz`。如需覆盖,可设置:
+
+```bash
+MAILHUB_DEPLOY_REMOTE=root@example.com \
+MAILHUB_DEPLOY_DIR=/www/wwwroot/mail.ss5.xyz \
+MAILHUB_DEPLOY_BRANCH=master \
+MAILHUB_DEPLOY_GIT_URL=git@git.ss5.xyz:chendeben/MailSend.git \
+npm run deploy:remote
+```
+
+首次使用前,远端服务器必须具备 Git 仓库读取权限,例如给 `git@git.ss5.xyz:chendeben/MailSend.git` 配置 deploy key。远端如果还没有 `origin`,脚本会使用 `MAILHUB_DEPLOY_GIT_URL` 或本地 `origin` 自动补齐。
+
+远端如果存在未提交变更或无法快进,`git pull --ff-only` 会失败;此时应先人工确认远端状态。确认可以收纳远端工作区时再显式加:
+
+```bash
+MAILHUB_DEPLOY_STASH_REMOTE=1 npm run deploy:remote
+```
+
+该开关只会 `git stash push -u` 远端工作区,不会覆盖 `.env`、`data/`、`certs/` 等 ignored 生产数据。
+
 ## 账号和权限
 
 - 首次启动会根据 `.env` 中的 `ADMIN_USER`、`ADMIN_EMAIL`、`ADMIN_PASSWORD` 创建或修复一个 admin 用户。

+ 12 - 0
index.html

@@ -0,0 +1,12 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>MailHub</title>
+  </head>
+  <body>
+    <div id="root"></div>
+    <script type="module" src="/src/frontend/main.tsx"></script>
+  </body>
+</html>

+ 12 - 0
login.html

@@ -0,0 +1,12 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>MailHub Auth</title>
+  </head>
+  <body>
+    <div id="auth-root"></div>
+    <script type="module" src="/src/frontend/auth/main.tsx"></script>
+  </body>
+</html>

+ 2953 - 0
package-lock.json

@@ -0,0 +1,2953 @@
+{
+  "name": "mailhub",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "mailhub",
+      "version": "1.0.0",
+      "dependencies": {
+        "@ant-design/icons": "^6.3.2",
+        "@ant-design/plots": "^2.6.8",
+        "antd": "^5.29.3",
+        "react": "^19.2.7",
+        "react-dom": "^19.2.7"
+      },
+      "devDependencies": {
+        "@types/react": "^19.2.17",
+        "@types/react-dom": "^19.2.3",
+        "@vitejs/plugin-react": "^6.0.3",
+        "typescript": "^5.9.3",
+        "vite": "^8.1.3"
+      },
+      "engines": {
+        "node": ">=24.0.0"
+      }
+    },
+    "node_modules/@ant-design/charts-util": {
+      "version": "0.0.3",
+      "resolved": "https://registry.npmjs.org/@ant-design/charts-util/-/charts-util-0.0.3.tgz",
+      "integrity": "sha512-x1H7UT6t4dXAyGRoHqlOnEsEqBSTANFGTZEAMI0CWYhYUpp13n0o9grl9oPtoL6FEQMjUBTY+zGJKlHkz8smMw==",
+      "license": "MIT",
+      "dependencies": {
+        "lodash": "^4.17.21"
+      },
+      "peerDependencies": {
+        "react": ">=16.8.4",
+        "react-dom": ">=16.8.4"
+      }
+    },
+    "node_modules/@ant-design/colors": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-8.0.1.tgz",
+      "integrity": "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@ant-design/fast-color": "^3.0.0"
+      }
+    },
+    "node_modules/@ant-design/cssinjs": {
+      "version": "1.24.0",
+      "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-1.24.0.tgz",
+      "integrity": "sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.11.1",
+        "@emotion/hash": "^0.8.0",
+        "@emotion/unitless": "^0.7.5",
+        "classnames": "^2.3.1",
+        "csstype": "^3.1.3",
+        "rc-util": "^5.35.0",
+        "stylis": "^4.3.4"
+      },
+      "peerDependencies": {
+        "react": ">=16.0.0",
+        "react-dom": ">=16.0.0"
+      }
+    },
+    "node_modules/@ant-design/cssinjs-utils": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-1.1.3.tgz",
+      "integrity": "sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg==",
+      "license": "MIT",
+      "dependencies": {
+        "@ant-design/cssinjs": "^1.21.0",
+        "@babel/runtime": "^7.23.2",
+        "rc-util": "^5.38.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/@ant-design/fast-color": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-3.0.1.tgz",
+      "integrity": "sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8.x"
+      }
+    },
+    "node_modules/@ant-design/icons": {
+      "version": "6.3.2",
+      "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-6.3.2.tgz",
+      "integrity": "sha512-B6O5a5XJ4wjtNOfZejXYwHW5zvKV5gYkjGf11dHGLEbKn0ABDGndo41+gfIiXyTFhvESj4XTotuud33mUFid0g==",
+      "license": "MIT",
+      "dependencies": {
+        "@ant-design/colors": "^8.0.1",
+        "@ant-design/icons-svg": "^4.5.0",
+        "@rc-component/util": "^1.11.0",
+        "clsx": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "peerDependencies": {
+        "react": ">=16.0.0",
+        "react-dom": ">=16.0.0"
+      }
+    },
+    "node_modules/@ant-design/icons-svg": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.5.0.tgz",
+      "integrity": "sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA==",
+      "license": "MIT"
+    },
+    "node_modules/@ant-design/plots": {
+      "version": "2.6.8",
+      "resolved": "https://registry.npmjs.org/@ant-design/plots/-/plots-2.6.8.tgz",
+      "integrity": "sha512-QsunUs2d5rbq/1BwVhga/siA5H50OaG23YopMYwPD4sPsza6NQzPQ8FM3elNIsD/BIk298tihqX1cJ/MmvVJbQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@ant-design/charts-util": "0.0.3",
+        "@antv/event-emitter": "^0.1.3",
+        "@antv/g": "^6.1.7",
+        "@antv/g2": "^5.2.7",
+        "@antv/g2-extension-plot": "^0.2.1",
+        "lodash": "^4.17.21"
+      },
+      "peerDependencies": {
+        "react": ">=16.8.4",
+        "react-dom": ">=16.8.4"
+      }
+    },
+    "node_modules/@ant-design/react-slick": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-1.1.2.tgz",
+      "integrity": "sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.4",
+        "classnames": "^2.2.5",
+        "json2mq": "^0.2.0",
+        "resize-observer-polyfill": "^1.5.1",
+        "throttle-debounce": "^5.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0"
+      }
+    },
+    "node_modules/@antv/component": {
+      "version": "2.1.11",
+      "resolved": "https://registry.npmjs.org/@antv/component/-/component-2.1.11.tgz",
+      "integrity": "sha512-dTdz8VAd3rpjOaGEZTluz82mtzrP4XCtNlNQyrxY7VNRNcjtvpTLDn57bUL2lRu1T+iklKvgbE2llMriWkq9vQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@antv/g": "^6.1.11",
+        "@antv/scale": "^0.4.16",
+        "@antv/util": "^3.3.10",
+        "svg-path-parser": "^1.1.0"
+      }
+    },
+    "node_modules/@antv/component/node_modules/@antv/scale": {
+      "version": "0.4.16",
+      "resolved": "https://registry.npmjs.org/@antv/scale/-/scale-0.4.16.tgz",
+      "integrity": "sha512-5wg/zB5kXHxpTV5OYwJD3ja6R8yTiqIOkjOhmpEJiowkzRlbEC/BOyMvNUq5fqFIHnMCE9woO7+c3zxEQCKPjw==",
+      "license": "MIT",
+      "dependencies": {
+        "@antv/util": "^3.3.7",
+        "color-string": "^1.5.5",
+        "fecha": "^4.2.1"
+      }
+    },
+    "node_modules/@antv/coord": {
+      "version": "0.4.7",
+      "resolved": "https://registry.npmjs.org/@antv/coord/-/coord-0.4.7.tgz",
+      "integrity": "sha512-UTbrMLhwJUkKzqJx5KFnSRpU3BqrdLORJbwUbHK2zHSCT3q3bjcFA//ZYLVfIlwqFDXp/hzfMyRtp0c77A9ZVA==",
+      "license": "MIT",
+      "dependencies": {
+        "@antv/scale": "^0.4.12",
+        "@antv/util": "^2.0.13",
+        "gl-matrix": "^3.4.3"
+      }
+    },
+    "node_modules/@antv/coord/node_modules/@antv/scale": {
+      "version": "0.4.16",
+      "resolved": "https://registry.npmjs.org/@antv/scale/-/scale-0.4.16.tgz",
+      "integrity": "sha512-5wg/zB5kXHxpTV5OYwJD3ja6R8yTiqIOkjOhmpEJiowkzRlbEC/BOyMvNUq5fqFIHnMCE9woO7+c3zxEQCKPjw==",
+      "license": "MIT",
+      "dependencies": {
+        "@antv/util": "^3.3.7",
+        "color-string": "^1.5.5",
+        "fecha": "^4.2.1"
+      }
+    },
+    "node_modules/@antv/coord/node_modules/@antv/scale/node_modules/@antv/util": {
+      "version": "3.3.11",
+      "resolved": "https://registry.npmjs.org/@antv/util/-/util-3.3.11.tgz",
+      "integrity": "sha512-FII08DFM4ABh2q5rPYdr0hMtKXRgeZazvXaFYCs7J7uTcWDHUhczab2qOCJLNDugoj8jFag1djb7wS9ehaRYBg==",
+      "license": "MIT",
+      "dependencies": {
+        "fast-deep-equal": "^3.1.3",
+        "gl-matrix": "^3.3.0",
+        "tslib": "^2.3.1"
+      }
+    },
+    "node_modules/@antv/coord/node_modules/@antv/util": {
+      "version": "2.0.17",
+      "resolved": "https://registry.npmjs.org/@antv/util/-/util-2.0.17.tgz",
+      "integrity": "sha512-o6I9hi5CIUvLGDhth0RxNSFDRwXeywmt6ExR4+RmVAzIi48ps6HUy+svxOCayvrPBN37uE6TAc2KDofRo0nK9Q==",
+      "license": "ISC",
+      "dependencies": {
+        "csstype": "^3.0.8",
+        "tslib": "^2.0.3"
+      }
+    },
+    "node_modules/@antv/event-emitter": {
+      "version": "0.1.3",
+      "resolved": "https://registry.npmjs.org/@antv/event-emitter/-/event-emitter-0.1.3.tgz",
+      "integrity": "sha512-4ddpsiHN9Pd4UIlWuKVK1C4IiZIdbwQvy9i7DUSI3xNJ89FPUFt8lxDYj8GzzfdllV0NkJTRxnG+FvLk0llidg==",
+      "license": "MIT"
+    },
+    "node_modules/@antv/expr": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@antv/expr/-/expr-1.0.2.tgz",
+      "integrity": "sha512-vrfdmPHkTuiS5voVutKl2l06w1ihBh9A8SFdQPEE+2KMVpkymzGOF1eWpfkbGZ7tiFE15GodVdhhHomD/hdIwg==",
+      "license": "MIT"
+    },
+    "node_modules/@antv/g": {
+      "version": "6.3.1",
+      "resolved": "https://registry.npmjs.org/@antv/g/-/g-6.3.1.tgz",
+      "integrity": "sha512-WYEKqy86LHB2PzTmrZXrIsIe+3Epeds2f68zceQ+BJtRoGki7Sy4IhlC8LrUMztgfT1t3d/0L745NWZwITroKA==",
+      "license": "MIT",
+      "dependencies": {
+        "@antv/g-lite": "2.7.0",
+        "@antv/util": "^3.3.5",
+        "@babel/runtime": "^7.25.6",
+        "gl-matrix": "^3.4.3",
+        "html2canvas": "^1.4.1"
+      }
+    },
+    "node_modules/@antv/g-canvas": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/@antv/g-canvas/-/g-canvas-2.2.0.tgz",
+      "integrity": "sha512-h7zVBBo2aO64DuGKvq9sG+yTU3sCUb9DALCVm7nz8qGPs8hhLuFOkKPEzUDNfNYZGJUGzY8UDtJ3QRGRFcvEQg==",
+      "license": "MIT",
+      "dependencies": {
+        "@antv/g-lite": "2.7.0",
+        "@antv/g-math": "3.1.0",
+        "@antv/util": "^3.3.5",
+        "@babel/runtime": "^7.25.6",
+        "gl-matrix": "^3.4.3",
+        "tslib": "^2.5.3"
+      }
+    },
+    "node_modules/@antv/g-lite": {
+      "version": "2.7.0",
+      "resolved": "https://registry.npmjs.org/@antv/g-lite/-/g-lite-2.7.0.tgz",
+      "integrity": "sha512-uSzgHYa5bwR5L2Au7/5tsOhFmXKZKLPBH90+Q9bP9teVs5VT4kOAi0isPSpDI8uhdDC2/VrfTWu5K9HhWI6FWw==",
+      "license": "MIT",
+      "dependencies": {
+        "@antv/g-math": "3.1.0",
+        "@antv/util": "^3.3.5",
+        "@antv/vendor": "^1.0.3",
+        "@babel/runtime": "^7.25.6",
+        "eventemitter3": "^5.0.1",
+        "gl-matrix": "^3.4.3",
+        "tslib": "^2.5.3"
+      }
+    },
+    "node_modules/@antv/g-math": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/@antv/g-math/-/g-math-3.1.0.tgz",
+      "integrity": "sha512-DtN1Gj/yI0UiK18nSBsZX8RK0LszGwqfb+cBYWgE+ddyTm8dZnW4tPUhV7QXePsS6/A5hHC+JFpAAK7OEGo5ZQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@antv/util": "^3.3.5",
+        "@babel/runtime": "^7.25.6",
+        "gl-matrix": "^3.4.3",
+        "tslib": "^2.5.3"
+      }
+    },
+    "node_modules/@antv/g-plugin-dragndrop": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/@antv/g-plugin-dragndrop/-/g-plugin-dragndrop-2.1.1.tgz",
+      "integrity": "sha512-+aesDUJVQDs6UJ2bOBbDlaGAPCfHmU0MbrMTlQlfpwNplWueqtgVAZ3L57oZ2ZGHRWUHiRwZGPjXMBM3O2LELw==",
+      "license": "MIT",
+      "dependencies": {
+        "@antv/g-lite": "2.7.0",
+        "@antv/util": "^3.3.5",
+        "@babel/runtime": "^7.25.6",
+        "tslib": "^2.5.3"
+      }
+    },
+    "node_modules/@antv/g2": {
+      "version": "5.4.8",
+      "resolved": "https://registry.npmjs.org/@antv/g2/-/g2-5.4.8.tgz",
+      "integrity": "sha512-IvgIpwmT4M5/QAd3Mn2WiHIDeBqFJ4WA2gcZhRRSZuZ2KmgCqZWZwwIT0hc+kIGxwYeDoCQqf//t6FMVu3ryBg==",
+      "license": "MIT",
+      "dependencies": {
+        "@antv/component": "^2.1.9",
+        "@antv/coord": "^0.4.7",
+        "@antv/event-emitter": "^0.1.3",
+        "@antv/expr": "^1.0.2",
+        "@antv/g": "^6.1.24",
+        "@antv/g-canvas": "^2.0.43",
+        "@antv/g-plugin-dragndrop": "^2.0.35",
+        "@antv/scale": "^0.5.1",
+        "@antv/util": "^3.3.10",
+        "@antv/vendor": "^1.0.11",
+        "flru": "^1.0.2",
+        "pdfast": "^0.2.0"
+      }
+    },
+    "node_modules/@antv/g2-extension-plot": {
+      "version": "0.2.2",
+      "resolved": "https://registry.npmjs.org/@antv/g2-extension-plot/-/g2-extension-plot-0.2.2.tgz",
+      "integrity": "sha512-KJXCXO7as+h0hDqirGXf1omrNuYzQmY3VmBmp7lIvkepbQ7sz3pPwy895r1FWETGF3vTk5UeFcAF5yzzBHWgbw==",
+      "dependencies": {
+        "@antv/g2": "^5.1.8",
+        "@antv/util": "^3.3.5",
+        "@antv/vendor": "^1.0.10"
+      }
+    },
+    "node_modules/@antv/scale": {
+      "version": "0.5.2",
+      "resolved": "https://registry.npmjs.org/@antv/scale/-/scale-0.5.2.tgz",
+      "integrity": "sha512-rTHRAwvpHWC5PGZF/mJ2ZuTDqwwvVBDRph0Uu5PV9BXwzV7K8+9lsqGJ+XHVLxe8c6bKog5nlzvV/dcYb0d5Ow==",
+      "license": "MIT",
+      "dependencies": {
+        "@antv/util": "^3.3.7",
+        "color-string": "^1.5.5",
+        "fecha": "^4.2.1"
+      }
+    },
+    "node_modules/@antv/util": {
+      "version": "3.3.11",
+      "resolved": "https://registry.npmjs.org/@antv/util/-/util-3.3.11.tgz",
+      "integrity": "sha512-FII08DFM4ABh2q5rPYdr0hMtKXRgeZazvXaFYCs7J7uTcWDHUhczab2qOCJLNDugoj8jFag1djb7wS9ehaRYBg==",
+      "license": "MIT",
+      "dependencies": {
+        "fast-deep-equal": "^3.1.3",
+        "gl-matrix": "^3.3.0",
+        "tslib": "^2.3.1"
+      }
+    },
+    "node_modules/@antv/vendor": {
+      "version": "1.0.11",
+      "resolved": "https://registry.npmjs.org/@antv/vendor/-/vendor-1.0.11.tgz",
+      "integrity": "sha512-LmhPEQ+aapk3barntaiIxJ5VHno/Tyab2JnfdcPzp5xONh/8VSfed4bo/9xKo5HcUAEydko38vYLfj6lJliLiw==",
+      "license": "MIT AND ISC",
+      "dependencies": {
+        "@types/d3-array": "^3.2.1",
+        "@types/d3-color": "^3.1.3",
+        "@types/d3-dispatch": "^3.0.6",
+        "@types/d3-dsv": "^3.0.7",
+        "@types/d3-ease": "^3.0.2",
+        "@types/d3-fetch": "^3.0.7",
+        "@types/d3-force": "^3.0.10",
+        "@types/d3-format": "^3.0.4",
+        "@types/d3-geo": "^3.1.0",
+        "@types/d3-hierarchy": "^3.1.7",
+        "@types/d3-interpolate": "^3.0.4",
+        "@types/d3-path": "^3.1.0",
+        "@types/d3-quadtree": "^3.0.6",
+        "@types/d3-random": "^3.0.3",
+        "@types/d3-scale": "^4.0.9",
+        "@types/d3-scale-chromatic": "^3.1.0",
+        "@types/d3-shape": "^3.1.7",
+        "@types/d3-time": "^3.0.4",
+        "@types/d3-timer": "^3.0.2",
+        "d3-array": "^3.2.4",
+        "d3-color": "^3.1.0",
+        "d3-dispatch": "^3.0.1",
+        "d3-dsv": "^3.0.1",
+        "d3-ease": "^3.0.1",
+        "d3-fetch": "^3.0.1",
+        "d3-force": "^3.0.0",
+        "d3-force-3d": "^3.0.5",
+        "d3-format": "^3.1.0",
+        "d3-geo": "^3.1.1",
+        "d3-geo-projection": "^4.0.0",
+        "d3-hierarchy": "^3.1.2",
+        "d3-interpolate": "^3.0.1",
+        "d3-path": "^3.1.0",
+        "d3-quadtree": "^3.0.1",
+        "d3-random": "^3.0.1",
+        "d3-regression": "^1.3.10",
+        "d3-scale": "^4.0.2",
+        "d3-scale-chromatic": "^3.1.0",
+        "d3-shape": "^3.2.0",
+        "d3-time": "^3.1.0",
+        "d3-timer": "^3.0.1"
+      }
+    },
+    "node_modules/@babel/runtime": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+      "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@emnapi/wasi-threads": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
+      "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@emotion/hash": {
+      "version": "0.8.0",
+      "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz",
+      "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==",
+      "license": "MIT"
+    },
+    "node_modules/@emotion/unitless": {
+      "version": "0.7.5",
+      "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz",
+      "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==",
+      "license": "MIT"
+    },
+    "node_modules/@napi-rs/wasm-runtime": {
+      "version": "1.1.6",
+      "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
+      "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@tybys/wasm-util": "^0.10.3"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      },
+      "peerDependencies": {
+        "@emnapi/core": "^1.7.1",
+        "@emnapi/runtime": "^1.7.1"
+      }
+    },
+    "node_modules/@oxc-project/types": {
+      "version": "0.138.0",
+      "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz",
+      "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/Boshen"
+      }
+    },
+    "node_modules/@rc-component/async-validator": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.1.2.tgz",
+      "integrity": "sha512-WYbrZSjzznU1ekD0qFq2qRxt309VoS61MTG5npnFQlKYcoy9IzU8T+ZCIhq5bGAXRbXysABFWTspicMfmWFwow==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.24.4"
+      },
+      "engines": {
+        "node": ">=14.x"
+      }
+    },
+    "node_modules/@rc-component/color-picker": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-2.0.1.tgz",
+      "integrity": "sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@ant-design/fast-color": "^2.0.6",
+        "@babel/runtime": "^7.23.6",
+        "classnames": "^2.2.6",
+        "rc-util": "^5.38.1"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/@rc-component/color-picker/node_modules/@ant-design/fast-color": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz",
+      "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.24.7"
+      },
+      "engines": {
+        "node": ">=8.x"
+      }
+    },
+    "node_modules/@rc-component/context": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-1.4.0.tgz",
+      "integrity": "sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "rc-util": "^5.27.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/@rc-component/mini-decimal": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.4.tgz",
+      "integrity": "sha512-xiuXcaCwyOWpD8a8scdExFl+bntNphAW8XeenL1ig2en0AAZY0Pcp4pC0dI22qJ+NvxKn9RoNIoRdqYU3BLH4w==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.18.0"
+      },
+      "engines": {
+        "node": ">=8.x"
+      }
+    },
+    "node_modules/@rc-component/mutate-observer": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-1.1.0.tgz",
+      "integrity": "sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.18.0",
+        "classnames": "^2.3.2",
+        "rc-util": "^5.24.4"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/@rc-component/portal": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-1.1.2.tgz",
+      "integrity": "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.18.0",
+        "classnames": "^2.3.2",
+        "rc-util": "^5.24.4"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/@rc-component/qrcode": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-1.1.3.tgz",
+      "integrity": "sha512-aGv6alnn4HbDEsURzKP+jv13rbi1VxmAYfBNZr5GKF1iohMNWy5tAVoJ1E3cOvzMB1kbUPvCXchM6zSFlRGPhA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.24.7"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/@rc-component/tour": {
+      "version": "1.15.1",
+      "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-1.15.1.tgz",
+      "integrity": "sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.18.0",
+        "@rc-component/portal": "^1.0.0-9",
+        "@rc-component/trigger": "^2.0.0",
+        "classnames": "^2.3.2",
+        "rc-util": "^5.24.4"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/@rc-component/trigger": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-2.3.1.tgz",
+      "integrity": "sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.23.2",
+        "@rc-component/portal": "^1.1.0",
+        "classnames": "^2.3.2",
+        "rc-motion": "^2.0.0",
+        "rc-resize-observer": "^1.3.1",
+        "rc-util": "^5.44.0"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/@rc-component/util": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.11.1.tgz",
+      "integrity": "sha512-awVlI3ub2vqfqkYxOBc/uQ0efm3jw0wcrhtO/YWLyZfxiKXczKwNbVuhlnyxytDt7H9pbbVQiqr+O6MLATtRYg==",
+      "license": "MIT",
+      "dependencies": {
+        "is-mobile": "^5.0.0",
+        "react-is": "^18.2.0"
+      },
+      "peerDependencies": {
+        "react": ">=18.0.0",
+        "react-dom": ">=18.0.0"
+      }
+    },
+    "node_modules/@rolldown/binding-android-arm64": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
+      "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-darwin-arm64": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz",
+      "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-darwin-x64": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz",
+      "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-freebsd-x64": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz",
+      "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz",
+      "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm64-gnu": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz",
+      "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm64-musl": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz",
+      "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz",
+      "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-s390x-gnu": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz",
+      "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-x64-gnu": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz",
+      "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-x64-musl": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz",
+      "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-openharmony-arm64": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz",
+      "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-wasm32-wasi": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz",
+      "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==",
+      "cpu": [
+        "wasm32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/core": "1.11.1",
+        "@emnapi/runtime": "1.11.1",
+        "@napi-rs/wasm-runtime": "^1.1.6"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
+      "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/wasi-threads": "1.2.2",
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
+      "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@rolldown/binding-win32-arm64-msvc": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz",
+      "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-win32-x64-msvc": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz",
+      "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/pluginutils": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+      "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@tybys/wasm-util": {
+      "version": "0.10.3",
+      "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
+      "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@types/d3-array": {
+      "version": "3.2.2",
+      "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
+      "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-color": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+      "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-dispatch": {
+      "version": "3.0.7",
+      "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
+      "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-dsv": {
+      "version": "3.0.7",
+      "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
+      "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-ease": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+      "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-fetch": {
+      "version": "3.0.7",
+      "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
+      "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-dsv": "*"
+      }
+    },
+    "node_modules/@types/d3-force": {
+      "version": "3.0.10",
+      "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
+      "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-format": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
+      "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-geo": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
+      "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/geojson": "*"
+      }
+    },
+    "node_modules/@types/d3-hierarchy": {
+      "version": "3.1.7",
+      "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
+      "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-interpolate": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+      "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-color": "*"
+      }
+    },
+    "node_modules/@types/d3-path": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
+      "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-quadtree": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
+      "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-random": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.4.tgz",
+      "integrity": "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-scale": {
+      "version": "4.0.9",
+      "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+      "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-time": "*"
+      }
+    },
+    "node_modules/@types/d3-scale-chromatic": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
+      "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-shape": {
+      "version": "3.1.8",
+      "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
+      "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-path": "*"
+      }
+    },
+    "node_modules/@types/d3-time": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+      "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-timer": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+      "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
+      "license": "MIT"
+    },
+    "node_modules/@types/geojson": {
+      "version": "7946.0.16",
+      "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
+      "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+      "license": "MIT"
+    },
+    "node_modules/@types/react": {
+      "version": "19.2.17",
+      "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
+      "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "csstype": "^3.2.2"
+      }
+    },
+    "node_modules/@types/react-dom": {
+      "version": "19.2.3",
+      "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+      "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+      "dev": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "^19.2.0"
+      }
+    },
+    "node_modules/@vitejs/plugin-react": {
+      "version": "6.0.3",
+      "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz",
+      "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@rolldown/pluginutils": "^1.0.1"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "peerDependencies": {
+        "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
+        "babel-plugin-react-compiler": "^1.0.0",
+        "vite": "^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@rolldown/plugin-babel": {
+          "optional": true
+        },
+        "babel-plugin-react-compiler": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/antd": {
+      "version": "5.29.3",
+      "resolved": "https://registry.npmjs.org/antd/-/antd-5.29.3.tgz",
+      "integrity": "sha512-3DdbGCa9tWAJGcCJ6rzR8EJFsv2CtyEbkVabZE14pfgUHfCicWCj0/QzQVLDYg8CPfQk9BH7fHCoTXHTy7MP/A==",
+      "license": "MIT",
+      "dependencies": {
+        "@ant-design/colors": "^7.2.1",
+        "@ant-design/cssinjs": "^1.23.0",
+        "@ant-design/cssinjs-utils": "^1.1.3",
+        "@ant-design/fast-color": "^2.0.6",
+        "@ant-design/icons": "^5.6.1",
+        "@ant-design/react-slick": "~1.1.2",
+        "@babel/runtime": "^7.26.0",
+        "@rc-component/color-picker": "~2.0.1",
+        "@rc-component/mutate-observer": "^1.1.0",
+        "@rc-component/qrcode": "~1.1.0",
+        "@rc-component/tour": "~1.15.1",
+        "@rc-component/trigger": "^2.3.0",
+        "classnames": "^2.5.1",
+        "copy-to-clipboard": "^3.3.3",
+        "dayjs": "^1.11.11",
+        "rc-cascader": "~3.34.0",
+        "rc-checkbox": "~3.5.0",
+        "rc-collapse": "~3.9.0",
+        "rc-dialog": "~9.6.0",
+        "rc-drawer": "~7.3.0",
+        "rc-dropdown": "~4.2.1",
+        "rc-field-form": "~2.7.1",
+        "rc-image": "~7.12.0",
+        "rc-input": "~1.8.0",
+        "rc-input-number": "~9.5.0",
+        "rc-mentions": "~2.20.0",
+        "rc-menu": "~9.16.1",
+        "rc-motion": "^2.9.5",
+        "rc-notification": "~5.6.4",
+        "rc-pagination": "~5.1.0",
+        "rc-picker": "~4.11.3",
+        "rc-progress": "~4.0.0",
+        "rc-rate": "~2.13.1",
+        "rc-resize-observer": "^1.4.3",
+        "rc-segmented": "~2.7.0",
+        "rc-select": "~14.16.8",
+        "rc-slider": "~11.1.9",
+        "rc-steps": "~6.0.1",
+        "rc-switch": "~4.1.0",
+        "rc-table": "~7.54.0",
+        "rc-tabs": "~15.7.0",
+        "rc-textarea": "~1.10.2",
+        "rc-tooltip": "~6.4.0",
+        "rc-tree": "~5.13.1",
+        "rc-tree-select": "~5.27.0",
+        "rc-upload": "~4.11.0",
+        "rc-util": "^5.44.4",
+        "scroll-into-view-if-needed": "^3.1.0",
+        "throttle-debounce": "^5.0.2"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/ant-design"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/antd/node_modules/@ant-design/colors": {
+      "version": "7.2.1",
+      "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz",
+      "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@ant-design/fast-color": "^2.0.6"
+      }
+    },
+    "node_modules/antd/node_modules/@ant-design/fast-color": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz",
+      "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.24.7"
+      },
+      "engines": {
+        "node": ">=8.x"
+      }
+    },
+    "node_modules/antd/node_modules/@ant-design/icons": {
+      "version": "5.6.1",
+      "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz",
+      "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==",
+      "license": "MIT",
+      "dependencies": {
+        "@ant-design/colors": "^7.0.0",
+        "@ant-design/icons-svg": "^4.4.0",
+        "@babel/runtime": "^7.24.8",
+        "classnames": "^2.2.6",
+        "rc-util": "^5.31.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "peerDependencies": {
+        "react": ">=16.0.0",
+        "react-dom": ">=16.0.0"
+      }
+    },
+    "node_modules/base64-arraybuffer": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
+      "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6.0"
+      }
+    },
+    "node_modules/classnames": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
+      "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
+      "license": "MIT"
+    },
+    "node_modules/clsx": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+      "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "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==",
+      "license": "MIT"
+    },
+    "node_modules/color-string": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
+      "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
+      "license": "MIT",
+      "dependencies": {
+        "color-name": "^1.0.0",
+        "simple-swizzle": "^0.2.2"
+      }
+    },
+    "node_modules/commander": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
+      "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/compute-scroll-into-view": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz",
+      "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==",
+      "license": "MIT"
+    },
+    "node_modules/copy-to-clipboard": {
+      "version": "3.3.3",
+      "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz",
+      "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==",
+      "license": "MIT",
+      "dependencies": {
+        "toggle-selection": "^1.0.6"
+      }
+    },
+    "node_modules/css-line-break": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
+      "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
+      "license": "MIT",
+      "dependencies": {
+        "utrie": "^1.0.2"
+      }
+    },
+    "node_modules/csstype": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+      "license": "MIT"
+    },
+    "node_modules/d3-array": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+      "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+      "license": "ISC",
+      "dependencies": {
+        "internmap": "1 - 2"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-binarytree": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz",
+      "integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==",
+      "license": "MIT"
+    },
+    "node_modules/d3-color": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+      "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-dispatch": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+      "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-dsv": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
+      "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
+      "license": "ISC",
+      "dependencies": {
+        "commander": "7",
+        "iconv-lite": "0.6",
+        "rw": "1"
+      },
+      "bin": {
+        "csv2json": "bin/dsv2json.js",
+        "csv2tsv": "bin/dsv2dsv.js",
+        "dsv2dsv": "bin/dsv2dsv.js",
+        "dsv2json": "bin/dsv2json.js",
+        "json2csv": "bin/json2dsv.js",
+        "json2dsv": "bin/json2dsv.js",
+        "json2tsv": "bin/json2dsv.js",
+        "tsv2csv": "bin/dsv2dsv.js",
+        "tsv2json": "bin/dsv2json.js"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-ease": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+      "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-fetch": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
+      "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-dsv": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-force": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
+      "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-dispatch": "1 - 3",
+        "d3-quadtree": "1 - 3",
+        "d3-timer": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-force-3d": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz",
+      "integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==",
+      "license": "MIT",
+      "dependencies": {
+        "d3-binarytree": "1",
+        "d3-dispatch": "1 - 3",
+        "d3-octree": "1",
+        "d3-quadtree": "1 - 3",
+        "d3-timer": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-format": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
+      "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-geo": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
+      "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-array": "2.5.0 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-geo-projection": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/d3-geo-projection/-/d3-geo-projection-4.0.0.tgz",
+      "integrity": "sha512-p0bK60CEzph1iqmnxut7d/1kyTmm3UWtPlwdkM31AU+LW+BXazd5zJdoCn7VFxNCHXRngPHRnsNn5uGjLRGndg==",
+      "license": "ISC",
+      "dependencies": {
+        "commander": "7",
+        "d3-array": "1 - 3",
+        "d3-geo": "1.12.0 - 3"
+      },
+      "bin": {
+        "geo2svg": "bin/geo2svg.js",
+        "geograticule": "bin/geograticule.js",
+        "geoproject": "bin/geoproject.js",
+        "geoquantize": "bin/geoquantize.js",
+        "geostitch": "bin/geostitch.js"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-hierarchy": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
+      "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-interpolate": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+      "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-color": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-octree": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz",
+      "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==",
+      "license": "MIT"
+    },
+    "node_modules/d3-path": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+      "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-quadtree": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
+      "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-random": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
+      "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-regression": {
+      "version": "1.3.10",
+      "resolved": "https://registry.npmjs.org/d3-regression/-/d3-regression-1.3.10.tgz",
+      "integrity": "sha512-PF8GWEL70cHHWpx2jUQXc68r1pyPHIA+St16muk/XRokETzlegj5LriNKg7o4LR0TySug4nHYPJNNRz/W+/Niw==",
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/d3-scale": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+      "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-array": "2.10.0 - 3",
+        "d3-format": "1 - 3",
+        "d3-interpolate": "1.2.0 - 3",
+        "d3-time": "2.1.1 - 3",
+        "d3-time-format": "2 - 4"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-scale-chromatic": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
+      "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-color": "1 - 3",
+        "d3-interpolate": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-shape": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+      "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-path": "^3.1.0"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-time": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+      "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-array": "2 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-time-format": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+      "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-time": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-timer": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+      "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/dayjs": {
+      "version": "1.11.21",
+      "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
+      "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
+      "license": "MIT",
+      "peer": true
+    },
+    "node_modules/detect-libc": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/eventemitter3": {
+      "version": "5.0.4",
+      "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
+      "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
+      "license": "MIT"
+    },
+    "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==",
+      "license": "MIT"
+    },
+    "node_modules/fdir": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "peerDependencies": {
+        "picomatch": "^3 || ^4"
+      },
+      "peerDependenciesMeta": {
+        "picomatch": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/fecha": {
+      "version": "4.2.3",
+      "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz",
+      "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==",
+      "license": "MIT"
+    },
+    "node_modules/flru": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/flru/-/flru-1.0.2.tgz",
+      "integrity": "sha512-kWyh8ADvHBFz6ua5xYOPnUroZTT/bwWfrCeL0Wj1dzG4/YOmOcfJ99W8dOVyyynJN35rZ9aCOtHChqQovV7yog==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/gl-matrix": {
+      "version": "3.4.4",
+      "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
+      "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
+      "license": "MIT"
+    },
+    "node_modules/html2canvas": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
+      "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
+      "license": "MIT",
+      "dependencies": {
+        "css-line-break": "^2.1.0",
+        "text-segmentation": "^1.0.3"
+      },
+      "engines": {
+        "node": ">=8.0.0"
+      }
+    },
+    "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==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/internmap": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+      "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/is-arrayish": {
+      "version": "0.3.4",
+      "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
+      "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
+      "license": "MIT"
+    },
+    "node_modules/is-mobile": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-5.0.0.tgz",
+      "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==",
+      "license": "MIT"
+    },
+    "node_modules/json2mq": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz",
+      "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==",
+      "license": "MIT",
+      "dependencies": {
+        "string-convert": "^0.2.0"
+      }
+    },
+    "node_modules/lightningcss": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+      "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+      "dev": true,
+      "license": "MPL-2.0",
+      "dependencies": {
+        "detect-libc": "^2.0.3"
+      },
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      },
+      "optionalDependencies": {
+        "lightningcss-android-arm64": "1.32.0",
+        "lightningcss-darwin-arm64": "1.32.0",
+        "lightningcss-darwin-x64": "1.32.0",
+        "lightningcss-freebsd-x64": "1.32.0",
+        "lightningcss-linux-arm-gnueabihf": "1.32.0",
+        "lightningcss-linux-arm64-gnu": "1.32.0",
+        "lightningcss-linux-arm64-musl": "1.32.0",
+        "lightningcss-linux-x64-gnu": "1.32.0",
+        "lightningcss-linux-x64-musl": "1.32.0",
+        "lightningcss-win32-arm64-msvc": "1.32.0",
+        "lightningcss-win32-x64-msvc": "1.32.0"
+      }
+    },
+    "node_modules/lightningcss-android-arm64": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+      "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-arm64": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+      "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-x64": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+      "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-freebsd-x64": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+      "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm-gnueabihf": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+      "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-gnu": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+      "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-musl": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+      "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-gnu": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+      "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-musl": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+      "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-arm64-msvc": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+      "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-x64-msvc": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+      "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lodash": {
+      "version": "4.18.1",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+      "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+      "license": "MIT"
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.15",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
+      "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/pdfast": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/pdfast/-/pdfast-0.2.0.tgz",
+      "integrity": "sha512-cq6TTu6qKSFUHwEahi68k/kqN2mfepjkGrG9Un70cgdRRKLKY6Rf8P8uvP2NvZktaQZNF3YE7agEkLj0vGK9bA==",
+      "license": "MIT"
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/picomatch": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+      "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true,
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.16",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
+      "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
+      "dev": true,
+      "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"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.12",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/rc-cascader": {
+      "version": "3.34.0",
+      "resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.34.0.tgz",
+      "integrity": "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.25.7",
+        "classnames": "^2.3.1",
+        "rc-select": "~14.16.2",
+        "rc-tree": "~5.13.0",
+        "rc-util": "^5.43.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-checkbox": {
+      "version": "3.5.0",
+      "resolved": "https://registry.npmjs.org/rc-checkbox/-/rc-checkbox-3.5.0.tgz",
+      "integrity": "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "classnames": "^2.3.2",
+        "rc-util": "^5.25.2"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-collapse": {
+      "version": "3.9.0",
+      "resolved": "https://registry.npmjs.org/rc-collapse/-/rc-collapse-3.9.0.tgz",
+      "integrity": "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "classnames": "2.x",
+        "rc-motion": "^2.3.4",
+        "rc-util": "^5.27.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-dialog": {
+      "version": "9.6.0",
+      "resolved": "https://registry.npmjs.org/rc-dialog/-/rc-dialog-9.6.0.tgz",
+      "integrity": "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "@rc-component/portal": "^1.0.0-8",
+        "classnames": "^2.2.6",
+        "rc-motion": "^2.3.0",
+        "rc-util": "^5.21.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-drawer": {
+      "version": "7.3.0",
+      "resolved": "https://registry.npmjs.org/rc-drawer/-/rc-drawer-7.3.0.tgz",
+      "integrity": "sha512-DX6CIgiBWNpJIMGFO8BAISFkxiuKitoizooj4BDyee8/SnBn0zwO2FHrNDpqqepj0E/TFTDpmEBCyFuTgC7MOg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.23.9",
+        "@rc-component/portal": "^1.1.1",
+        "classnames": "^2.2.6",
+        "rc-motion": "^2.6.1",
+        "rc-util": "^5.38.1"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-dropdown": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/rc-dropdown/-/rc-dropdown-4.2.1.tgz",
+      "integrity": "sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.18.3",
+        "@rc-component/trigger": "^2.0.0",
+        "classnames": "^2.2.6",
+        "rc-util": "^5.44.1"
+      },
+      "peerDependencies": {
+        "react": ">=16.11.0",
+        "react-dom": ">=16.11.0"
+      }
+    },
+    "node_modules/rc-field-form": {
+      "version": "2.7.1",
+      "resolved": "https://registry.npmjs.org/rc-field-form/-/rc-field-form-2.7.1.tgz",
+      "integrity": "sha512-vKeSifSJ6HoLaAB+B8aq/Qgm8a3dyxROzCtKNCsBQgiverpc4kWDQihoUwzUj+zNWJOykwSY4dNX3QrGwtVb9A==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.18.0",
+        "@rc-component/async-validator": "^5.0.3",
+        "rc-util": "^5.32.2"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-image": {
+      "version": "7.12.0",
+      "resolved": "https://registry.npmjs.org/rc-image/-/rc-image-7.12.0.tgz",
+      "integrity": "sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.11.2",
+        "@rc-component/portal": "^1.0.2",
+        "classnames": "^2.2.6",
+        "rc-dialog": "~9.6.0",
+        "rc-motion": "^2.6.2",
+        "rc-util": "^5.34.1"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-input": {
+      "version": "1.8.0",
+      "resolved": "https://registry.npmjs.org/rc-input/-/rc-input-1.8.0.tgz",
+      "integrity": "sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.11.1",
+        "classnames": "^2.2.1",
+        "rc-util": "^5.18.1"
+      },
+      "peerDependencies": {
+        "react": ">=16.0.0",
+        "react-dom": ">=16.0.0"
+      }
+    },
+    "node_modules/rc-input-number": {
+      "version": "9.5.0",
+      "resolved": "https://registry.npmjs.org/rc-input-number/-/rc-input-number-9.5.0.tgz",
+      "integrity": "sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "@rc-component/mini-decimal": "^1.0.1",
+        "classnames": "^2.2.5",
+        "rc-input": "~1.8.0",
+        "rc-util": "^5.40.1"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-mentions": {
+      "version": "2.20.0",
+      "resolved": "https://registry.npmjs.org/rc-mentions/-/rc-mentions-2.20.0.tgz",
+      "integrity": "sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.22.5",
+        "@rc-component/trigger": "^2.0.0",
+        "classnames": "^2.2.6",
+        "rc-input": "~1.8.0",
+        "rc-menu": "~9.16.0",
+        "rc-textarea": "~1.10.0",
+        "rc-util": "^5.34.1"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-menu": {
+      "version": "9.16.1",
+      "resolved": "https://registry.npmjs.org/rc-menu/-/rc-menu-9.16.1.tgz",
+      "integrity": "sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "@rc-component/trigger": "^2.0.0",
+        "classnames": "2.x",
+        "rc-motion": "^2.4.3",
+        "rc-overflow": "^1.3.1",
+        "rc-util": "^5.27.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-motion": {
+      "version": "2.9.5",
+      "resolved": "https://registry.npmjs.org/rc-motion/-/rc-motion-2.9.5.tgz",
+      "integrity": "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.11.1",
+        "classnames": "^2.2.1",
+        "rc-util": "^5.44.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-notification": {
+      "version": "5.6.4",
+      "resolved": "https://registry.npmjs.org/rc-notification/-/rc-notification-5.6.4.tgz",
+      "integrity": "sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "classnames": "2.x",
+        "rc-motion": "^2.9.0",
+        "rc-util": "^5.20.1"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-overflow": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/rc-overflow/-/rc-overflow-1.5.0.tgz",
+      "integrity": "sha512-Lm/v9h0LymeUYJf0x39OveU52InkdRXqnn2aYXfWmo8WdOonIKB2kfau+GF0fWq6jPgtdO9yMqveGcK6aIhJmg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.11.1",
+        "classnames": "^2.2.1",
+        "rc-resize-observer": "^1.0.0",
+        "rc-util": "^5.37.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-pagination": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/rc-pagination/-/rc-pagination-5.1.0.tgz",
+      "integrity": "sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "classnames": "^2.3.2",
+        "rc-util": "^5.38.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-picker": {
+      "version": "4.11.3",
+      "resolved": "https://registry.npmjs.org/rc-picker/-/rc-picker-4.11.3.tgz",
+      "integrity": "sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.24.7",
+        "@rc-component/trigger": "^2.0.0",
+        "classnames": "^2.2.1",
+        "rc-overflow": "^1.3.2",
+        "rc-resize-observer": "^1.4.0",
+        "rc-util": "^5.43.0"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "date-fns": ">= 2.x",
+        "dayjs": ">= 1.x",
+        "luxon": ">= 3.x",
+        "moment": ">= 2.x",
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      },
+      "peerDependenciesMeta": {
+        "date-fns": {
+          "optional": true
+        },
+        "dayjs": {
+          "optional": true
+        },
+        "luxon": {
+          "optional": true
+        },
+        "moment": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/rc-progress": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/rc-progress/-/rc-progress-4.0.0.tgz",
+      "integrity": "sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "classnames": "^2.2.6",
+        "rc-util": "^5.16.1"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-rate": {
+      "version": "2.13.1",
+      "resolved": "https://registry.npmjs.org/rc-rate/-/rc-rate-2.13.1.tgz",
+      "integrity": "sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "classnames": "^2.2.5",
+        "rc-util": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-resize-observer": {
+      "version": "1.4.3",
+      "resolved": "https://registry.npmjs.org/rc-resize-observer/-/rc-resize-observer-1.4.3.tgz",
+      "integrity": "sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.20.7",
+        "classnames": "^2.2.1",
+        "rc-util": "^5.44.1",
+        "resize-observer-polyfill": "^1.5.1"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-segmented": {
+      "version": "2.7.1",
+      "resolved": "https://registry.npmjs.org/rc-segmented/-/rc-segmented-2.7.1.tgz",
+      "integrity": "sha512-izj1Nw/Dw2Vb7EVr+D/E9lUTkBe+kKC+SAFSU9zqr7WV2W5Ktaa9Gc7cB2jTqgk8GROJayltaec+DBlYKc6d+g==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.11.1",
+        "classnames": "^2.2.1",
+        "rc-motion": "^2.4.4",
+        "rc-util": "^5.17.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.0.0",
+        "react-dom": ">=16.0.0"
+      }
+    },
+    "node_modules/rc-select": {
+      "version": "14.16.8",
+      "resolved": "https://registry.npmjs.org/rc-select/-/rc-select-14.16.8.tgz",
+      "integrity": "sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "@rc-component/trigger": "^2.1.1",
+        "classnames": "2.x",
+        "rc-motion": "^2.0.1",
+        "rc-overflow": "^1.3.1",
+        "rc-util": "^5.16.1",
+        "rc-virtual-list": "^3.5.2"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": "*",
+        "react-dom": "*"
+      }
+    },
+    "node_modules/rc-slider": {
+      "version": "11.1.9",
+      "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-11.1.9.tgz",
+      "integrity": "sha512-h8IknhzSh3FEM9u8ivkskh+Ef4Yo4JRIY2nj7MrH6GQmrwV6mcpJf5/4KgH5JaVI1H3E52yCdpOlVyGZIeph5A==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "classnames": "^2.2.5",
+        "rc-util": "^5.36.0"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-steps": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/rc-steps/-/rc-steps-6.0.1.tgz",
+      "integrity": "sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.16.7",
+        "classnames": "^2.2.3",
+        "rc-util": "^5.16.1"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-switch": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/rc-switch/-/rc-switch-4.1.0.tgz",
+      "integrity": "sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.21.0",
+        "classnames": "^2.2.1",
+        "rc-util": "^5.30.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-table": {
+      "version": "7.54.0",
+      "resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.54.0.tgz",
+      "integrity": "sha512-/wDTkki6wBTjwylwAGjpLKYklKo9YgjZwAU77+7ME5mBoS32Q4nAwoqhA2lSge6fobLW3Tap6uc5xfwaL2p0Sw==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "@rc-component/context": "^1.4.0",
+        "classnames": "^2.2.5",
+        "rc-resize-observer": "^1.1.0",
+        "rc-util": "^5.44.3",
+        "rc-virtual-list": "^3.14.2"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-tabs": {
+      "version": "15.7.0",
+      "resolved": "https://registry.npmjs.org/rc-tabs/-/rc-tabs-15.7.0.tgz",
+      "integrity": "sha512-ZepiE+6fmozYdWf/9gVp7k56PKHB1YYoDsKeQA1CBlJ/POIhjkcYiv0AGP0w2Jhzftd3AVvZP/K+V+Lpi2ankA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.11.2",
+        "classnames": "2.x",
+        "rc-dropdown": "~4.2.0",
+        "rc-menu": "~9.16.0",
+        "rc-motion": "^2.6.2",
+        "rc-resize-observer": "^1.0.0",
+        "rc-util": "^5.34.1"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-textarea": {
+      "version": "1.10.2",
+      "resolved": "https://registry.npmjs.org/rc-textarea/-/rc-textarea-1.10.2.tgz",
+      "integrity": "sha512-HfaeXiaSlpiSp0I/pvWpecFEHpVysZ9tpDLNkxQbMvMz6gsr7aVZ7FpWP9kt4t7DB+jJXesYS0us1uPZnlRnwQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "classnames": "^2.2.1",
+        "rc-input": "~1.8.0",
+        "rc-resize-observer": "^1.0.0",
+        "rc-util": "^5.27.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-tooltip": {
+      "version": "6.4.0",
+      "resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-6.4.0.tgz",
+      "integrity": "sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.11.2",
+        "@rc-component/trigger": "^2.0.0",
+        "classnames": "^2.3.1",
+        "rc-util": "^5.44.3"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-tree": {
+      "version": "5.13.1",
+      "resolved": "https://registry.npmjs.org/rc-tree/-/rc-tree-5.13.1.tgz",
+      "integrity": "sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "classnames": "2.x",
+        "rc-motion": "^2.0.1",
+        "rc-util": "^5.16.1",
+        "rc-virtual-list": "^3.5.1"
+      },
+      "engines": {
+        "node": ">=10.x"
+      },
+      "peerDependencies": {
+        "react": "*",
+        "react-dom": "*"
+      }
+    },
+    "node_modules/rc-tree-select": {
+      "version": "5.27.0",
+      "resolved": "https://registry.npmjs.org/rc-tree-select/-/rc-tree-select-5.27.0.tgz",
+      "integrity": "sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.25.7",
+        "classnames": "2.x",
+        "rc-select": "~14.16.2",
+        "rc-tree": "~5.13.0",
+        "rc-util": "^5.43.0"
+      },
+      "peerDependencies": {
+        "react": "*",
+        "react-dom": "*"
+      }
+    },
+    "node_modules/rc-upload": {
+      "version": "4.11.0",
+      "resolved": "https://registry.npmjs.org/rc-upload/-/rc-upload-4.11.0.tgz",
+      "integrity": "sha512-ZUyT//2JAehfHzjWowqROcwYJKnZkIUGWaTE/VogVrepSl7AFNbQf4+zGfX4zl9Vrj/Jm8scLO0R6UlPDKK4wA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.18.3",
+        "classnames": "^2.2.5",
+        "rc-util": "^5.2.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-util": {
+      "version": "5.44.4",
+      "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.44.4.tgz",
+      "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.18.3",
+        "react-is": "^18.2.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-virtual-list": {
+      "version": "3.19.2",
+      "resolved": "https://registry.npmjs.org/rc-virtual-list/-/rc-virtual-list-3.19.2.tgz",
+      "integrity": "sha512-Ys6NcjwGkuwkeaWBDqfI3xWuZ7rDiQXlH1o2zLfFzATfEgXcqpk8CkgMfbJD81McqjcJVez25a3kPxCR807evA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.20.0",
+        "classnames": "^2.2.6",
+        "rc-resize-observer": "^1.0.0",
+        "rc-util": "^5.36.0"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/react": {
+      "version": "19.2.7",
+      "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
+      "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
+      "license": "MIT",
+      "peer": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react-dom": {
+      "version": "19.2.7",
+      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
+      "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "scheduler": "^0.27.0"
+      },
+      "peerDependencies": {
+        "react": "^19.2.7"
+      }
+    },
+    "node_modules/react-is": {
+      "version": "18.3.1",
+      "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+      "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
+      "license": "MIT"
+    },
+    "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==",
+      "license": "MIT"
+    },
+    "node_modules/rolldown": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
+      "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@oxc-project/types": "=0.138.0",
+        "@rolldown/pluginutils": "^1.0.0"
+      },
+      "bin": {
+        "rolldown": "bin/cli.mjs"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "optionalDependencies": {
+        "@rolldown/binding-android-arm64": "1.1.4",
+        "@rolldown/binding-darwin-arm64": "1.1.4",
+        "@rolldown/binding-darwin-x64": "1.1.4",
+        "@rolldown/binding-freebsd-x64": "1.1.4",
+        "@rolldown/binding-linux-arm-gnueabihf": "1.1.4",
+        "@rolldown/binding-linux-arm64-gnu": "1.1.4",
+        "@rolldown/binding-linux-arm64-musl": "1.1.4",
+        "@rolldown/binding-linux-ppc64-gnu": "1.1.4",
+        "@rolldown/binding-linux-s390x-gnu": "1.1.4",
+        "@rolldown/binding-linux-x64-gnu": "1.1.4",
+        "@rolldown/binding-linux-x64-musl": "1.1.4",
+        "@rolldown/binding-openharmony-arm64": "1.1.4",
+        "@rolldown/binding-wasm32-wasi": "1.1.4",
+        "@rolldown/binding-win32-arm64-msvc": "1.1.4",
+        "@rolldown/binding-win32-x64-msvc": "1.1.4"
+      }
+    },
+    "node_modules/rw": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
+      "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "license": "MIT"
+    },
+    "node_modules/scheduler": {
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+      "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+      "license": "MIT"
+    },
+    "node_modules/scroll-into-view-if-needed": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz",
+      "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==",
+      "license": "MIT",
+      "dependencies": {
+        "compute-scroll-into-view": "^3.0.2"
+      }
+    },
+    "node_modules/simple-swizzle": {
+      "version": "0.2.4",
+      "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
+      "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
+      "license": "MIT",
+      "dependencies": {
+        "is-arrayish": "^0.3.1"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/string-convert": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz",
+      "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==",
+      "license": "MIT"
+    },
+    "node_modules/stylis": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz",
+      "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==",
+      "license": "MIT"
+    },
+    "node_modules/svg-path-parser": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/svg-path-parser/-/svg-path-parser-1.1.0.tgz",
+      "integrity": "sha512-jGCUqcQyXpfe38R7RFfhrMyfXcBmpMNJI/B+4CE9/Unkh98UporAc461GTthv+TVDuZXsBx7/WiwJb1Oh4tt4A==",
+      "license": "MIT"
+    },
+    "node_modules/text-segmentation": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
+      "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
+      "license": "MIT",
+      "dependencies": {
+        "utrie": "^1.0.2"
+      }
+    },
+    "node_modules/throttle-debounce": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
+      "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.22"
+      }
+    },
+    "node_modules/tinyglobby": {
+      "version": "0.2.17",
+      "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+      "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.4"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/SuperchupuDev"
+      }
+    },
+    "node_modules/toggle-selection": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz",
+      "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==",
+      "license": "MIT"
+    },
+    "node_modules/tslib": {
+      "version": "2.8.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+      "license": "0BSD"
+    },
+    "node_modules/typescript": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/utrie": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
+      "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
+      "license": "MIT",
+      "dependencies": {
+        "base64-arraybuffer": "^1.0.2"
+      }
+    },
+    "node_modules/vite": {
+      "version": "8.1.3",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
+      "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "lightningcss": "^1.32.0",
+        "picomatch": "^4.0.4",
+        "postcss": "^8.5.16",
+        "rolldown": "~1.1.3",
+        "tinyglobby": "^0.2.17"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^20.19.0 || >=22.12.0",
+        "@vitejs/devtools": "^0.3.0",
+        "esbuild": "^0.27.0 || ^0.28.0",
+        "jiti": ">=1.21.0",
+        "less": "^4.0.0",
+        "sass": "^1.70.0",
+        "sass-embedded": "^1.70.0",
+        "stylus": ">=0.54.8",
+        "sugarss": "^5.0.0",
+        "terser": "^5.16.0",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "@vitejs/devtools": {
+          "optional": true
+        },
+        "esbuild": {
+          "optional": true
+        },
+        "jiti": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "yaml": {
+          "optional": true
+        }
+      }
+    }
+  }
+}

+ 20 - 1
package.json

@@ -7,9 +7,28 @@
   "scripts": {
     "start": "node src/server.js",
     "dev": "NODE_ENV=development node src/server.js",
-    "test": "node --test"
+    "dev:ui": "vite --host 0.0.0.0",
+    "build": "tsc --noEmit && vite build",
+    "preview:ui": "vite preview --host 0.0.0.0",
+    "test": "node --test",
+    "release:check": "npm test && npm run build",
+    "deploy:remote": "bash scripts/deploy-remote.sh"
   },
   "engines": {
     "node": ">=24.0.0"
+  },
+  "dependencies": {
+    "@ant-design/icons": "^6.3.2",
+    "@ant-design/plots": "^2.6.8",
+    "antd": "^5.29.3",
+    "react": "^19.2.7",
+    "react-dom": "^19.2.7"
+  },
+  "devDependencies": {
+    "@types/react": "^19.2.17",
+    "@types/react-dom": "^19.2.3",
+    "@vitejs/plugin-react": "^6.0.3",
+    "typescript": "^5.9.3",
+    "vite": "^8.1.3"
   }
 }

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
public/assets/index-BhoF66Ow.css


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
public/assets/index-Bv1_CvmS.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
public/assets/index-CQhlXgMS.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
public/assets/index-Cnh93foy.js


Файловите разлики са ограничени, защото са твърде много
+ 1 - 0
public/assets/index-D13udm4g.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
public/assets/index-Tu04tXLf.css


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
public/assets/index-aEHvatu0.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
public/assets/index-psgafNtk.css


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
public/assets/login-Bf3wwaVX.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
public/assets/styles-DMRLjM8Z.css


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
public/assets/styles-DmsKpU2U.js


+ 7 - 372
public/index.html

@@ -1,380 +1,15 @@
 <!doctype html>
 <html lang="zh-CN">
   <head>
-    <meta charset="utf-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>MailHub</title>
-    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.0.0/dist/css/tabler.min.css">
-    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3.31.0/dist/tabler-icons.min.css">
-    <link rel="stylesheet" href="/styles.css?v=20260708-tabs">
+    <script type="module" crossorigin src="/assets/index-D13udm4g.js"></script>
+    <link rel="modulepreload" crossorigin href="/assets/styles-DmsKpU2U.js">
+    <link rel="stylesheet" crossorigin href="/assets/styles-DMRLjM8Z.css">
+    <link rel="stylesheet" crossorigin href="/assets/index-Tu04tXLf.css">
   </head>
   <body>
-    <div class="page">
-      <aside class="navbar navbar-vertical navbar-expand-lg" data-bs-theme="dark">
-        <div class="container-fluid">
-          <h1 class="navbar-brand navbar-brand-autodark">
-            <span class="brand-mark">MH</span>
-            <span>MailHub</span>
-          </h1>
-          <div class="navbar-nav flex-row d-lg-none">
-            <button class="btn btn-ghost-light" id="refreshButtonMobile" type="button">刷新</button>
-          </div>
-          <div class="collapse navbar-collapse show">
-            <ul class="navbar-nav pt-lg-3 primary-nav" aria-label="主导航">
-              <li class="nav-item">
-                <button class="nav-link active" data-view="dashboard" type="button">
-                  <i class="ti ti-layout-dashboard nav-icon" aria-hidden="true"></i>
-                  <span class="nav-link-title">仪表盘</span>
-                </button>
-              </li>
-              <li class="nav-item">
-                <button class="nav-link" data-view="analytics" type="button">
-                  <i class="ti ti-chart-arcs nav-icon" aria-hidden="true"></i>
-                  <span class="nav-link-title">统计分析</span>
-                  <span class="badge bg-cyan-lt ms-auto" id="analyticsEventCount">0</span>
-                </button>
-              </li>
-              <li class="nav-item">
-                <button class="nav-link" data-view="domains" type="button">
-                  <i class="ti ti-world-www nav-icon" aria-hidden="true"></i>
-                  <span class="nav-link-title">发信域名</span>
-                  <span class="badge bg-blue-lt ms-auto" id="domainCount">0</span>
-                </button>
-              </li>
-              <li class="nav-item">
-                <button class="nav-link" data-view="dns" type="button">
-                  <i class="ti ti-cloud-cog nav-icon" aria-hidden="true"></i>
-                  <span class="nav-link-title">DNS API</span>
-                  <span class="badge bg-azure-lt ms-auto" id="dnsCredentialCount">0</span>
-                </button>
-              </li>
-              <li class="nav-item">
-                <button class="nav-link" data-view="smtp" type="button">
-                  <i class="ti ti-mail-cog nav-icon" aria-hidden="true"></i>
-                  <span class="nav-link-title">SMTP 凭据</span>
-                  <span class="badge bg-secondary-lt ms-auto" id="smtpCredentialState">未配置</span>
-                </button>
-              </li>
-              <li class="nav-item">
-                <button class="nav-link" data-view="tokens" type="button">
-                  <i class="ti ti-key nav-icon" aria-hidden="true"></i>
-                  <span class="nav-link-title">API Token</span>
-                  <span class="badge bg-secondary-lt ms-auto" id="apiTokenCount">0</span>
-                </button>
-              </li>
-              <li class="nav-item hidden" id="adminNavButton">
-                <button class="nav-link" data-view="admin" type="button">
-                  <i class="ti ti-settings nav-icon" aria-hidden="true"></i>
-                  <span class="nav-link-title">系统设置</span>
-                  <span class="badge bg-green-lt ms-auto">Admin</span>
-                </button>
-              </li>
-            </ul>
-
-            <div class="sidebar-block">
-              <div class="d-flex align-items-center justify-content-between mb-2">
-                <h2 class="h4 m-0">当前账号</h2>
-                <span class="badge bg-secondary-lt" id="userRole">加载中</span>
-              </div>
-              <div class="list-group list-group-flush" id="accountBox"></div>
-            </div>
-
-            <div class="sidebar-block">
-              <div class="d-flex align-items-center justify-content-between mb-2">
-                <h2 class="h4 m-0">发信域名</h2>
-                <button class="btn btn-sm btn-outline-light" data-view="domains" type="button">新增</button>
-              </div>
-              <div class="domain-list" id="domainList"></div>
-            </div>
-          </div>
-        </div>
-      </aside>
-
-      <div class="page-wrapper">
-        <header class="navbar navbar-expand-md d-print-none topbar">
-          <div class="container-xl">
-            <div>
-              <div class="text-uppercase text-secondary fw-bold small" id="viewEyebrow">Domains</div>
-              <h2 class="page-title" id="viewTitle">发信域名</h2>
-              <div class="text-secondary" id="runtimeLine">加载中</div>
-            </div>
-            <div class="navbar-nav flex-row order-md-last gap-2">
-              <button class="btn btn-outline-secondary" id="refreshButton" type="button">刷新</button>
-              <button class="btn btn-outline-secondary" id="logoutButton" type="button">退出</button>
-            </div>
-          </div>
-        </header>
-
-        <main class="page-body">
-          <div class="container-xl">
-            <div class="alert alert-danger hidden" id="securityNotice"></div>
-
-            <section class="view active" id="dashboardView" data-view-panel="dashboard">
-              <div id="dashboardPanel"></div>
-            </section>
-
-            <section class="view" id="analyticsView" data-view-panel="analytics">
-              <div id="analyticsPanel"></div>
-            </section>
-
-            <section class="view" id="domainsView" data-view-panel="domains">
-              <div class="row row-cards mb-3">
-                <div class="col-lg-5">
-                  <form class="card compact-form" id="addDomainForm">
-                    <div class="card-header">
-                      <div>
-                        <h3 class="card-title">添加发信域名</h3>
-                        <p class="card-subtitle">生成 DKIM、SPF、DMARC 记录;绑定 DNS API 后可一键写入。</p>
-                      </div>
-                    </div>
-                    <div class="card-body">
-                      <div class="mb-3">
-                        <label class="form-label">域名</label>
-                        <input class="form-control" name="domain" placeholder="example.com" autocomplete="off" required>
-                        <div class="form-hint">填写根域名,不需要带 http 或路径。</div>
-                      </div>
-                      <div class="mb-3">
-                        <label class="form-label">DNS API</label>
-                        <select class="form-select" name="dnsCredentialId" id="domainDnsCredential"></select>
-                        <div class="form-hint">为空时仍可添加域名,稍后在域名设置里绑定。</div>
-                      </div>
-                      <div class="row">
-                        <div class="col-sm-6 mb-3">
-                          <label class="form-label">DKIM selector</label>
-                          <input class="form-control" name="selector" placeholder="mh202607">
-                          <div class="form-hint">默认按当前月份生成。</div>
-                        </div>
-                        <div class="col-sm-6 mb-3">
-                          <label class="form-label">DMARC 策略</label>
-                          <select class="form-select" name="dmarcPolicy">
-                            <option value="none">none</option>
-                            <option value="quarantine">quarantine</option>
-                            <option value="reject">reject</option>
-                          </select>
-                          <div class="form-hint">新域名建议先用 none。</div>
-                        </div>
-                      </div>
-                      <div class="row">
-                        <div class="col-sm-6 mb-3">
-                          <label class="form-label">发信主机</label>
-                          <input class="form-control" name="senderHost" id="defaultSenderHost" autocomplete="off">
-                          <div class="form-hint">用于 A 记录和 SMTP HELO。</div>
-                        </div>
-                        <div class="col-sm-6 mb-3">
-                          <label class="form-label">发信 IP</label>
-                          <input class="form-control" name="sendingIp" id="defaultSendingIp" autocomplete="off">
-                          <div class="form-hint">服务器公网 IPv4。</div>
-                        </div>
-                      </div>
-                      <div class="mb-3">
-                        <label class="form-label">兼容第三方 SPF</label>
-                        <textarea class="form-control" name="spfExtra" id="defaultSpfExtra" rows="2"></textarea>
-                        <div class="form-hint">例如 include:spf.mailjet.com,会被合并进唯一 SPF。</div>
-                      </div>
-                    </div>
-                    <div class="card-footer text-end">
-                      <button class="btn btn-primary" type="submit">添加域名</button>
-                    </div>
-                  </form>
-                </div>
-
-                <div class="col-lg-7">
-                  <section class="card h-100" id="domainReadiness"></section>
-                </div>
-              </div>
-
-              <section class="detail-shell main-panel" id="detailPanel">
-                <div class="empty-state">
-                  <h2>选择或添加一个域名</h2>
-                  <p>DNS 引导、验证结果、DKIM 记录和测试发送会显示在这里。</p>
-                </div>
-              </section>
-            </section>
-
-            <section class="view" id="dnsView" data-view-panel="dns">
-              <div class="row row-cards">
-                <div class="col-lg-7">
-                  <section class="card">
-                    <div class="card-header">
-                      <div>
-                        <h3 class="card-title">DNS API 凭据</h3>
-                        <p class="card-subtitle">每个根域名或账号可以单独保存一组凭据,并按域名绑定使用。</p>
-                      </div>
-                    </div>
-                    <div class="card-body">
-                      <div class="credential-list" id="dnsCredentialList"></div>
-                    </div>
-                  </section>
-                </div>
-
-                <div class="col-lg-5">
-                  <form class="card compact-form" id="dnsCredentialForm">
-                    <input name="id" id="dnsCredentialIdField" type="hidden">
-                    <div class="card-header">
-                      <div>
-                        <h3 class="card-title" id="dnsCredentialFormTitle">新增 DNS API</h3>
-                        <p class="card-subtitle" id="dnsCredentialFormHint">选择服务商后只填写对应字段。</p>
-                      </div>
-                      <div class="card-actions">
-                        <button class="btn btn-sm btn-outline-secondary" id="resetDnsCredentialForm" type="button">清空</button>
-                      </div>
-                    </div>
-                    <div class="card-body">
-                      <div class="mb-3">
-                        <label class="form-label">名称</label>
-                        <input class="form-control" name="name" id="dnsCredentialName" placeholder="Cloudflare 主账号" required>
-                        <div class="form-hint">显示在域名绑定下拉中,建议写清账号或用途。</div>
-                      </div>
-                      <div class="row">
-                        <div class="col-sm-7 mb-3">
-                          <label class="form-label">服务商</label>
-                          <select class="form-select" name="provider" id="dnsProvider" required>
-                            <option value="cloudflare">Cloudflare</option>
-                            <option value="aliyun">阿里云 DNS</option>
-                            <option value="dnspod">腾讯云 DNSPod</option>
-                          </select>
-                        </div>
-                        <div class="col-sm-5 mb-3">
-                          <label class="form-label">TTL</label>
-                          <input class="form-control" name="defaultTtl" id="dnsDefaultTtl" type="number" min="60" max="86400" value="600">
-                        </div>
-                      </div>
-                      <div class="mb-3">
-                        <label class="form-label">Zone / 根域名</label>
-                        <input class="form-control" name="zoneName" id="dnsZoneName" placeholder="example.com" autocomplete="off" required>
-                        <div class="form-hint">Cloudflare 可填 Zone ID 或根域名;阿里云和 DNSPod 需要根域名。</div>
-                      </div>
-                      <div class="provider-fields" data-provider-field="cloudflare">
-                        <div class="mb-3">
-                          <label class="form-label">Cloudflare API Token</label>
-                          <input class="form-control" name="apiToken" autocomplete="off" placeholder="仅保存时填写">
-                          <div class="form-hint">需要 Zone DNS Edit 权限。</div>
-                        </div>
-                        <div class="mb-3">
-                          <label class="form-label">Cloudflare Zone ID</label>
-                          <input class="form-control" name="zoneId" autocomplete="off" placeholder="可选">
-                        </div>
-                      </div>
-                      <div class="provider-fields hidden" data-provider-field="aliyun">
-                        <div class="mb-3">
-                          <label class="form-label">阿里云 AccessKeyId</label>
-                          <input class="form-control" name="accessKeyId" autocomplete="off" placeholder="仅保存时填写">
-                        </div>
-                        <div class="mb-3">
-                          <label class="form-label">阿里云 AccessKeySecret</label>
-                          <input class="form-control" name="accessKeySecret" autocomplete="off" placeholder="仅保存时填写">
-                        </div>
-                      </div>
-                      <div class="provider-fields hidden" data-provider-field="dnspod">
-                        <div class="mb-3">
-                          <label class="form-label">腾讯云 SecretId</label>
-                          <input class="form-control" name="secretId" autocomplete="off" placeholder="仅保存时填写">
-                        </div>
-                        <div class="mb-3">
-                          <label class="form-label">腾讯云 SecretKey</label>
-                          <input class="form-control" name="secretKey" autocomplete="off" placeholder="仅保存时填写">
-                        </div>
-                      </div>
-                    </div>
-                    <div class="card-footer text-end">
-                      <button class="btn btn-primary" type="submit">保存 DNS API</button>
-                    </div>
-                  </form>
-                </div>
-              </div>
-            </section>
-
-            <section class="view" id="smtpView" data-view-panel="smtp">
-              <div class="row row-cards">
-                <div class="col-lg-5">
-                  <form class="card compact-form" id="smtpCredentialForm">
-                    <div class="card-header">
-                      <div>
-                        <h3 class="card-title">SMTP 凭据</h3>
-                        <p class="card-subtitle">用于 SMTP Submission 认证,保存后可复制给发信客户端。</p>
-                      </div>
-                    </div>
-                    <div class="card-body">
-                      <div class="mb-3">
-                        <label class="form-label">用户名</label>
-                        <input class="form-control" name="username" id="smtpUsername" autocomplete="off" required>
-                      </div>
-                      <div class="mb-3">
-                        <label class="form-label">新密码</label>
-                        <input class="form-control" name="password" id="smtpPassword" type="password" autocomplete="new-password" placeholder="留空则不修改">
-                      </div>
-                    </div>
-                    <div class="card-footer d-flex justify-content-between">
-                      <button class="btn btn-outline-secondary" id="generateSmtpPassword" type="button">生成密码</button>
-                      <button class="btn btn-primary" type="submit">保存凭据</button>
-                    </div>
-                  </form>
-                </div>
-
-                <div class="col-lg-7">
-                  <section class="card">
-                    <div class="card-header">
-                      <div>
-                        <h3 class="card-title">可复制信息</h3>
-                        <p class="card-subtitle">旧数据只有哈希时,需要重新设置一次密码才能回显。</p>
-                      </div>
-                    </div>
-                    <div class="card-body">
-                      <div class="credential-copy" id="smtpCredentialCopy"></div>
-                    </div>
-                  </section>
-                </div>
-              </div>
-            </section>
-
-            <section class="view" id="tokensView" data-view-panel="tokens">
-              <div class="row row-cards">
-                <div class="col-lg-4">
-                  <form class="card compact-form" id="apiTokenForm">
-                    <div class="card-header">
-                      <div>
-                        <h3 class="card-title">发送 API Token</h3>
-                        <p class="card-subtitle">Token 只在生成时完整显示一次。</p>
-                      </div>
-                    </div>
-                    <div class="card-body">
-                      <div class="mb-3">
-                        <label class="form-label">名称</label>
-                        <input class="form-control" name="name" placeholder="Production sender" required>
-                      </div>
-                    </div>
-                    <div class="card-footer text-end">
-                      <button class="btn btn-primary" type="submit">生成 Token</button>
-                    </div>
-                  </form>
-                </div>
-
-                <div class="col-lg-8">
-                  <section class="card">
-                    <div class="card-header">
-                      <div>
-                        <h3 class="card-title">Token 列表</h3>
-                        <p class="card-subtitle">按用途拆分 Token,泄露时可以单独删除。</p>
-                      </div>
-                    </div>
-                    <div class="card-body">
-                      <div class="mini-list" id="apiTokenList"></div>
-                    </div>
-                  </section>
-                </div>
-              </div>
-            </section>
-
-            <section class="view" id="adminView" data-view-panel="admin">
-              <section class="card hidden" id="adminPanel"></section>
-            </section>
-          </div>
-        </main>
-      </div>
-    </div>
-
-    <div class="toast-host" id="toastHost"></div>
-    <script src="https://cdn.jsdelivr.net/npm/@tabler/core@1.0.0/dist/js/tabler.min.js"></script>
-    <script src="/app.js?v=20260708-tabs" type="module"></script>
+    <div id="root"></div>
   </body>
 </html>

+ 7 - 70
public/login.html

@@ -1,77 +1,14 @@
 <!doctype html>
 <html lang="zh-CN">
   <head>
-    <meta charset="utf-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1">
-    <title>登录 MailHub</title>
-    <link rel="stylesheet" href="/login.css">
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>MailHub Auth</title>
+    <script type="module" crossorigin src="/assets/login-Bf3wwaVX.js"></script>
+    <link rel="modulepreload" crossorigin href="/assets/styles-DmsKpU2U.js">
+    <link rel="stylesheet" crossorigin href="/assets/styles-DMRLjM8Z.css">
   </head>
   <body>
-    <main class="login-shell">
-      <section class="brand-pane">
-        <div class="brand-mark">MH</div>
-        <div class="brand-copy">
-          <h1>MailHub</h1>
-          <p>发信控制台</p>
-        </div>
-        <div class="signal-grid">
-          <div>
-            <span>SMTP</span>
-            <strong>AUTH</strong>
-          </div>
-          <div>
-            <span>DNS</span>
-            <strong>API 自动配置</strong>
-          </div>
-          <div>
-            <span>Auth</span>
-            <strong>多用户隔离</strong>
-          </div>
-        </div>
-      </section>
-
-      <section class="login-card">
-        <div class="login-heading">
-          <span class="eyebrow" id="modeEyebrow">Sign in</span>
-          <h2 id="modeTitle">登录控制台</h2>
-        </div>
-
-        <div class="auth-tabs">
-          <button class="active" data-mode="login" type="button">登录</button>
-          <button data-mode="register" type="button">注册</button>
-        </div>
-
-        <form id="loginForm" class="login-form" method="post" action="/login">
-          <label>
-            用户名或邮箱
-            <input name="username" autocomplete="username" required autofocus>
-          </label>
-          <label>
-            密码
-            <input name="password" type="password" autocomplete="current-password" required>
-          </label>
-          <button type="submit">登录</button>
-        </form>
-
-        <form id="registerForm" class="login-form hidden" method="post" action="/register">
-          <label>
-            用户名
-            <input name="username" autocomplete="username" minlength="3" required>
-          </label>
-          <label>
-            邮箱
-            <input name="email" type="email" autocomplete="email" required>
-          </label>
-          <label>
-            密码
-            <input name="password" type="password" autocomplete="new-password" minlength="8" required>
-          </label>
-          <button type="submit">注册并进入</button>
-        </form>
-
-        <p id="loginMessage" class="login-message" role="alert"></p>
-      </section>
-    </main>
-    <script src="/login.js" type="module"></script>
+    <div id="auth-root"></div>
   </body>
 </html>

+ 55 - 0
scripts/deploy-remote.sh

@@ -0,0 +1,55 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+remote="${MAILHUB_DEPLOY_REMOTE:-root@192.227.215.183}"
+remote_dir="${MAILHUB_DEPLOY_DIR:-/www/wwwroot/mail.ss5.xyz}"
+branch="${MAILHUB_DEPLOY_BRANCH:-$(git branch --show-current)}"
+git_url="${MAILHUB_DEPLOY_GIT_URL:-$(git remote get-url origin)}"
+stash_remote="${MAILHUB_DEPLOY_STASH_REMOTE:-0}"
+
+if [[ -z "${branch}" ]]; then
+  echo "Unable to detect current git branch." >&2
+  exit 1
+fi
+
+git fetch origin "${branch}"
+
+local_head="$(git rev-parse HEAD)"
+remote_head="$(git rev-parse "origin/${branch}")"
+if [[ "${local_head}" != "${remote_head}" ]]; then
+  echo "Local HEAD is not pushed to origin/${branch}." >&2
+  echo "Commit and push first, then run this deploy script." >&2
+  exit 1
+fi
+
+ssh -o ServerAliveInterval=15 -o ServerAliveCountMax=4 "${remote}" \
+  'bash -s' -- "${remote_dir}" "${branch}" "${git_url}" "${stash_remote}" <<'REMOTE'
+set -euo pipefail
+
+remote_dir="$1"
+branch="$2"
+git_url="$3"
+stash_remote="$4"
+
+cd "${remote_dir}"
+
+if ! git remote get-url origin >/dev/null 2>&1; then
+  git remote add origin "${git_url}"
+fi
+
+if [[ -n "$(git status --porcelain)" ]]; then
+  if [[ "${stash_remote}" == "1" ]]; then
+    git stash push -u -m "pre-deploy-$(date -u +%Y%m%d-%H%M%S)"
+  else
+    echo "Remote working tree is dirty. Set MAILHUB_DEPLOY_STASH_REMOTE=1 to stash it before pulling." >&2
+    git status --short >&2
+    exit 1
+  fi
+fi
+
+git fetch origin "${branch}"
+git checkout "${branch}"
+git pull --ff-only origin "${branch}"
+docker compose up -d --build
+docker compose ps
+REMOTE

+ 23 - 0
src/components/common/StatusTag.tsx

@@ -0,0 +1,23 @@
+import { Badge, Tag } from 'antd';
+
+import { getRecordStatusMeta } from '../../frontend/domain-model.js';
+import { useI18n } from '../../frontend/i18n/react';
+
+interface StatusTagProps {
+  status?: string;
+  record?: { status?: string };
+  label?: string;
+  mode?: 'tag' | 'badge';
+}
+
+export function StatusTag({ status, record, label, mode = 'tag' }: StatusTagProps) {
+  const { t } = useI18n();
+  const meta = getRecordStatusMeta(record || { status });
+  const text = label || t(`status.${meta.key}`);
+
+  if (mode === 'badge') {
+    return <Badge status={meta.color === 'default' ? 'default' : meta.color} text={text} />;
+  }
+
+  return <Tag color={meta.color}>{text}</Tag>;
+}

+ 175 - 0
src/components/domain/AddDomainDrawer.tsx

@@ -0,0 +1,175 @@
+import { CheckOutlined } from '@ant-design/icons';
+import { Button, Checkbox, Drawer, Form, Input, Select, Space, Steps, Typography } from 'antd';
+import { useEffect, useState } from 'react';
+
+import { useI18n } from '../../frontend/i18n/react';
+import type { AddDomainPayload, DnsCredential, RuntimeConfig } from '../../frontend/types';
+
+interface AddDomainDrawerProps {
+  open: boolean;
+  loading?: boolean;
+  config: RuntimeConfig | null;
+  dnsCredentials: DnsCredential[];
+  onClose: () => void;
+  onSubmit: (values: AddDomainPayload) => Promise<void>;
+}
+
+export function AddDomainDrawer({
+  open,
+  loading,
+  config,
+  dnsCredentials,
+  onClose,
+  onSubmit
+}: AddDomainDrawerProps) {
+  const { t } = useI18n();
+  const [form] = Form.useForm<AddDomainPayload>();
+  const [current, setCurrent] = useState(0);
+  const steps = [t('addDomain.stepDomain'), t('addDomain.stepDns'), t('addDomain.stepPolicy'), t('addDomain.stepConfirm')];
+
+  useEffect(() => {
+    if (!open) return;
+    form.setFieldsValue({
+      senderHost: config?.mailHostname || '',
+      sendingIp: config?.sendingIp || '',
+      selector: defaultSelector(),
+      dmarcPolicy: config?.dmarcPolicy || 'none',
+      spfExtra: config?.defaultSpfMechanisms || '',
+      immediateCheck: true
+    });
+    setCurrent(0);
+  }, [config, form, open]);
+
+  async function next() {
+    await form.validateFields(stepFields(current));
+    setCurrent((value) => Math.min(value + 1, steps.length - 1));
+  }
+
+  async function submit() {
+    const values = await form.validateFields();
+    await onSubmit(values);
+    form.resetFields();
+    setCurrent(0);
+  }
+
+  return (
+    <Drawer
+      title={t('addDomain.title')}
+      width={560}
+      open={open}
+      onClose={onClose}
+      destroyOnHidden
+      footer={
+        <div className="drawer-footer">
+          <Button onClick={onClose}>{t('common.cancel')}</Button>
+          <Space>
+            <Button disabled={current === 0} onClick={() => setCurrent((value) => value - 1)}>
+              {t('common.previous')}
+            </Button>
+            {current < steps.length - 1 ? (
+              <Button type="primary" onClick={next}>
+                {t('common.next')}
+              </Button>
+            ) : (
+              <Button type="primary" icon={<CheckOutlined />} loading={loading} onClick={submit}>
+                {t('addDomain.create')}
+              </Button>
+            )}
+          </Space>
+        </div>
+      }
+    >
+      <Space direction="vertical" size={24} className="full-width">
+        <Steps current={current} items={steps.map((title) => ({ title }))} direction="vertical" responsive />
+        <Form form={form} layout="vertical" requiredMark="optional">
+          <div hidden={current !== 0}>
+            <Form.Item
+              name="domain"
+              label={t('domains.domain')}
+              rules={[{ required: true, message: t('addDomain.domainRequired') }]}
+              extra={t('addDomain.domainExtra')}
+            >
+              <Input placeholder="example.com" autoComplete="off" />
+            </Form.Item>
+            <Form.Item name="senderHost" label={t('domains.senderHost')} rules={[{ required: true, message: t('addDomain.senderHostRequired') }]}>
+              <Input placeholder="mail.example.com" autoComplete="off" />
+            </Form.Item>
+            <Form.Item name="sendingIp" label={t('domains.sendingIp')} rules={[{ required: true, message: t('addDomain.sendingIpRequired') }]}>
+              <Input placeholder="203.0.113.10" autoComplete="off" />
+            </Form.Item>
+          </div>
+          <div hidden={current !== 1}>
+            <Form.Item
+              name="dnsCredentialId"
+              label={t('domains.dnsApi')}
+              extra={t('addDomain.dnsExtra')}
+            >
+              <Select
+                allowClear
+                placeholder={t('addDomain.manualDns')}
+                options={dnsCredentials.map((credential) => ({
+                  value: credential.id,
+                  label: `${credential.name} · ${providerLabel(credential.provider)}`
+                }))}
+              />
+            </Form.Item>
+            <Typography.Paragraph type="secondary">
+              {t('addDomain.dnsHint')}
+            </Typography.Paragraph>
+          </div>
+          <div hidden={current !== 2}>
+            <Form.Item name="selector" label="DKIM selector" rules={[{ required: true, message: t('addDomain.selectorRequired') }]}>
+              <Input placeholder="mh202607" autoComplete="off" />
+            </Form.Item>
+            <Form.Item name="dmarcPolicy" label="DMARC">
+              <Select
+                options={[
+                  { value: 'none', label: 'none' },
+                  { value: 'quarantine', label: 'quarantine' },
+                  { value: 'reject', label: 'reject' }
+                ]}
+              />
+            </Form.Item>
+            <Form.Item name="spfExtra" label={t('addDomain.spfExtra')}>
+              <Input.TextArea rows={3} placeholder="include:spf.mailjet.com include:amazonses.com" />
+            </Form.Item>
+          </div>
+          <div hidden={current !== 3}>
+            <Typography.Title level={5}>{t('addDomain.generatedTitle')}</Typography.Title>
+            <ul className="confirm-list">
+              <li>{t('addDomain.recordVerification')}</li>
+              <li>{t('addDomain.recordDkim')}</li>
+              <li>{t('addDomain.recordSpf')}</li>
+              <li>{t('addDomain.recordDmarc')}</li>
+              <li>{t('addDomain.recordSenderA')}</li>
+              <li>{t('addDomain.recordPtr')}</li>
+            </ul>
+            <Form.Item name="immediateCheck" valuePropName="checked">
+              <Checkbox>{t('addDomain.immediateCheck')}</Checkbox>
+            </Form.Item>
+          </div>
+        </Form>
+      </Space>
+    </Drawer>
+  );
+}
+
+function stepFields(step: number): Array<keyof AddDomainPayload> {
+  if (step === 0) return ['domain', 'senderHost', 'sendingIp'];
+  if (step === 1) return ['dnsCredentialId'];
+  if (step === 2) return ['selector', 'dmarcPolicy', 'spfExtra'];
+  return [];
+}
+
+function defaultSelector() {
+  const date = new Date();
+  return `mh${date.getUTCFullYear()}${String(date.getUTCMonth() + 1).padStart(2, '0')}`;
+}
+
+function providerLabel(provider: string) {
+  return {
+    cloudflare: 'Cloudflare',
+    aliyun: 'Aliyun DNS',
+    dnspod: 'Tencent DNSPod'
+  }[provider] || provider;
+}

+ 92 - 0
src/components/domain/DnsRecordCard.tsx

@@ -0,0 +1,92 @@
+import { CopyOutlined, ReloadOutlined } from '@ant-design/icons';
+import { Alert, Button, Card, Space, Tooltip, Typography } from 'antd';
+
+import { useI18n } from '../../frontend/i18n/react';
+import type { DnsRecord } from '../../frontend/types';
+import { StatusTag } from '../common/StatusTag';
+
+interface DnsRecordCardProps {
+  record: DnsRecord;
+  loading?: boolean;
+  onCopy: (value: string) => void;
+  onRecheck: () => void;
+}
+
+export function DnsRecordCard({ record, loading, onCopy, onRecheck }: DnsRecordCardProps) {
+  const { t } = useI18n();
+  const currentValues = Array.isArray(record.current)
+    ? record.current
+    : record.current
+      ? [record.current]
+      : [];
+
+  return (
+    <Card
+      className="dns-record-card"
+      title={
+        <Space wrap>
+          <span>{record.label}</span>
+          <StatusTag record={record} />
+        </Space>
+      }
+      extra={<Typography.Text code>{record.type}</Typography.Text>}
+    >
+      <Space direction="vertical" size={14} className="full-width">
+        <DnsValueRow label={t('dnsRecord.hostname')} value={record.host} onCopy={onCopy} />
+        <DnsValueRow label={t('dnsRecord.targetValue')} value={record.value || '-'} onCopy={onCopy} />
+        <div>
+          <Typography.Text type="secondary">{t('dnsRecord.currentValue')}</Typography.Text>
+          {currentValues.length ? (
+            <Space direction="vertical" size={8} className="full-width value-stack">
+              {currentValues.map((value) => (
+                <Typography.Paragraph key={value} code copyable className="dns-code-block">
+                  {value}
+                </Typography.Paragraph>
+              ))}
+            </Space>
+          ) : (
+            <Typography.Paragraph type="secondary" className="dns-empty-value">
+              {t('dnsRecord.emptyCurrent')}
+            </Typography.Paragraph>
+          )}
+        </div>
+        {record.key === 'ptr' ? (
+          <Alert
+            type="info"
+            showIcon
+            message={t('dnsRecord.ptrHint')}
+          />
+        ) : null}
+        {(record.warnings || []).map((warning) => (
+          <Alert key={warning} type="warning" showIcon message={warning} />
+        ))}
+        <Button icon={<ReloadOutlined />} loading={loading} onClick={onRecheck}>
+          {t('dnsRecord.recheck')}
+        </Button>
+      </Space>
+    </Card>
+  );
+}
+
+function DnsValueRow({
+  label,
+  value,
+  onCopy
+}: {
+  label: string;
+  value: string;
+  onCopy: (value: string) => void;
+}) {
+  const { t } = useI18n();
+  return (
+    <div className="dns-value-row">
+      <Typography.Text type="secondary">{label}</Typography.Text>
+      <Typography.Paragraph code copyable className="dns-code-block">
+        {value}
+      </Typography.Paragraph>
+      <Tooltip title={`${t('dnsRecord.copyLabel')}${label}`}>
+        <Button icon={<CopyOutlined />} onClick={() => onCopy(value)} aria-label={`${t('dnsRecord.copyLabel')}${label}`} />
+      </Tooltip>
+    </div>
+  );
+}

+ 105 - 0
src/components/domain/DomainHealthCard.tsx

@@ -0,0 +1,105 @@
+import { CheckCircleOutlined, ClockCircleOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
+import { Button, Card, Col, Progress, Row, Space, Statistic, Tag, Typography } from 'antd';
+
+import { buildDomainHealth } from '../../frontend/domain-model.js';
+import { useI18n } from '../../frontend/i18n/react';
+import type { Domain } from '../../frontend/types';
+
+interface DomainHealthCardProps {
+  domain: Domain;
+  lastSentAt?: string;
+  dnsApiName?: string;
+  loading?: boolean;
+  onApplyDns: () => void;
+  onCheck: () => void;
+  onSendTest: () => void;
+  onEdit: () => void;
+}
+
+export function DomainHealthCard({
+  domain,
+  lastSentAt,
+  dnsApiName,
+  loading,
+  onApplyDns,
+  onCheck,
+  onSendTest,
+  onEdit
+}: DomainHealthCardProps) {
+  const { t } = useI18n();
+  const health = buildDomainHealth(domain);
+  const icon = health.status === 'success'
+    ? <CheckCircleOutlined />
+    : health.status === 'warning'
+      ? <ClockCircleOutlined />
+      : <ExclamationCircleOutlined />;
+
+  return (
+    <Card className="domain-health-card">
+      <Row gutter={[24, 24]} align="middle">
+        <Col xs={24} xl={16}>
+          <Space direction="vertical" size={16} className="full-width">
+            <div className="domain-title-row">
+              <div>
+                <Typography.Text type="secondary">{t('domainHealth.sendingDomain')}</Typography.Text>
+                <Typography.Title level={2}>{domain.domain}</Typography.Title>
+              </div>
+              <Tag color={health.status === 'success' ? 'success' : health.status === 'warning' ? 'warning' : 'error'} icon={icon}>
+                {domainHealthLabel(health.status, t)}
+              </Tag>
+            </div>
+            <Row gutter={[16, 16]}>
+              <Col xs={12} md={6}>
+                <Statistic title={t('domains.senderHost')} value={domain.senderHost || '-'} valueStyle={{ fontSize: 14 }} />
+              </Col>
+              <Col xs={12} md={6}>
+                <Statistic title={t('domains.sendingIp')} value={domain.sendingIp || '-'} valueStyle={{ fontSize: 14 }} />
+              </Col>
+              <Col xs={12} md={6}>
+                <Statistic title="DKIM selector" value={domain.selector} valueStyle={{ fontSize: 14 }} />
+              </Col>
+              <Col xs={12} md={6}>
+                <Statistic title={t('domains.lastSent')} value={lastSentAt || t('common.notFound')} valueStyle={{ fontSize: 14 }} />
+              </Col>
+            </Row>
+            <div className="health-progress">
+              <div>
+                <Typography.Text strong>{t('domainHealth.dnsProgress')}</Typography.Text>
+                <Typography.Text type="secondary">
+                  {health.passed}/{health.total} {t('domainHealth.passed')} · {t('domainHealth.dnsIssues')} {health.dnsIssues}
+                </Typography.Text>
+              </div>
+              <Progress percent={health.percent} status={health.status === 'error' ? 'exception' : 'active'} />
+            </div>
+            <Space wrap>
+              <Tag>{t('domainHealth.dnsApi')}:{dnsApiName || t('common.notConfigured')}</Tag>
+              <Tag>{t('domainDetail.lastCheck')}:{health.checkedAt ? new Date(health.checkedAt).toLocaleString() : t('domainDetail.notChecked')}</Tag>
+            </Space>
+          </Space>
+        </Col>
+        <Col xs={24} xl={8}>
+          <div className="domain-action-panel">
+            <Button type="primary" block loading={loading} onClick={onApplyDns} disabled={!domain.dnsCredentialId}>
+              {t('domainHealth.oneClickDns')}
+            </Button>
+            <Button block loading={loading} onClick={onCheck}>
+              {t('domainHealth.checkNow')}
+            </Button>
+            <Button block onClick={onSendTest}>
+              {t('domainHealth.sendTest')}
+            </Button>
+            <Button block onClick={onEdit}>
+              {t('domainHealth.edit')}
+            </Button>
+          </div>
+        </Col>
+      </Row>
+    </Card>
+  );
+}
+
+function domainHealthLabel(status: string, t: (key: string) => string) {
+  if (status === 'success') return t('domains.healthy');
+  if (status === 'warning') return t('domains.waitingDns');
+  return t('domains.needsAction');
+}

+ 472 - 0
src/frontend/App.tsx

@@ -0,0 +1,472 @@
+import { App as AntApp, ConfigProvider, Form, Input, Modal } from 'antd';
+import { useEffect, useMemo, useState } from 'react';
+
+import { AddDomainDrawer } from '../components/domain/AddDomainDrawer';
+import { AdminLayout } from '../layouts/AdminLayout';
+import ApiTokens from '../pages/ApiTokens';
+import Dashboard from '../pages/Dashboard';
+import DnsApi from '../pages/DnsApi';
+import DomainDetail from '../pages/Domains/DomainDetail';
+import DomainsPage from '../pages/Domains';
+import PlaceholderPage from '../pages/PlaceholderPage';
+import SendingLogs from '../pages/SendingLogs';
+import Settings from '../pages/Settings';
+import SmtpCredentials from '../pages/SmtpCredentials';
+import { I18nProvider, useI18n } from './i18n/react';
+import { api } from './services/api';
+import './styles.css';
+import type {
+  AddDomainPayload,
+  ApiToken,
+  AppData,
+  DnsCredential,
+  Domain,
+  DomainMode,
+  DomainPatchPayload,
+  RuntimeConfig,
+  User,
+  ViewKey
+} from './types';
+
+const emptyData: AppData = {
+  me: null,
+  config: null,
+  domains: [],
+  events: [],
+  analytics: null,
+  smtpCredential: null,
+  dnsCredentials: [],
+  apiTokens: [],
+  settings: null,
+  users: []
+};
+
+const viewTitleKeys: Record<ViewKey, string> = {
+  dashboard: 'nav.dashboard',
+  domains: 'nav.domains',
+  'dns-api': 'nav.dnsApi',
+  smtp: 'nav.smtp',
+  tokens: 'nav.tokens',
+  logs: 'nav.logs',
+  webhooks: 'nav.webhooks',
+  settings: 'nav.settings'
+};
+
+export default function App() {
+  return (
+    <ConfigProvider
+      theme={{
+        token: {
+          colorPrimary: '#1677ff',
+          borderRadius: 10,
+          colorBgLayout: '#f5f7fb',
+          colorBorderSecondary: '#e5eaf2'
+        },
+        components: {
+          Card: {
+            borderRadiusLG: 12
+          },
+          Table: {
+            cellPaddingBlock: 14,
+            cellPaddingInline: 14
+          }
+        }
+      }}
+    >
+      <AntApp>
+        <I18nProvider>
+          <MailHubConsole />
+        </I18nProvider>
+      </AntApp>
+    </ConfigProvider>
+  );
+}
+
+function MailHubConsole() {
+  const { message } = AntApp.useApp();
+  const { t } = useI18n();
+  const [data, setData] = useState<AppData>(emptyData);
+  const [activeView, setActiveView] = useState<ViewKey>('dashboard');
+  const [domainMode, setDomainMode] = useState<DomainMode>('list');
+  const [selectedDomainId, setSelectedDomainId] = useState<number | null>(null);
+  const [initialDomainTab, setInitialDomainTab] = useState('overview');
+  const [addOpen, setAddOpen] = useState(false);
+  const [loading, setLoading] = useState(true);
+  const [actionLoading, setActionLoading] = useState(false);
+  const [testDomain, setTestDomain] = useState<Domain | null>(null);
+  const [testForm] = Form.useForm();
+
+  const selectedDomain = data.domains.find((domain) => domain.id === selectedDomainId) || data.domains[0] || null;
+
+  useEffect(() => {
+    void loadAll();
+  }, []);
+
+  async function loadAll() {
+    setLoading(true);
+    try {
+      const me = await api.me();
+      const [config, domains, events, analytics, smtpCredential, dnsCredentials, apiTokens] = await Promise.all([
+        api.config(),
+        api.domains(),
+        api.events(),
+        api.analytics(30),
+        api.smtpCredential(),
+        api.dnsCredentials(),
+        api.apiTokens()
+      ]);
+      let settings: RuntimeConfig | null = null;
+      let users: User[] = [];
+      if (me.user.role === 'admin') {
+        const [settingsResult, usersResult] = await Promise.all([api.adminSettings(), api.adminUsers()]);
+        settings = settingsResult.settings;
+        users = usersResult.users;
+      }
+      setData({
+        me: me.user,
+        config,
+        domains: domains.domains || [],
+        events: events.events || [],
+        analytics: analytics.analytics || null,
+        smtpCredential: smtpCredential.credential || null,
+        dnsCredentials: dnsCredentials.credentials || [],
+        apiTokens: apiTokens.tokens || [],
+        settings,
+        users
+      });
+      setSelectedDomainId((current) => {
+        if (current && domains.domains.some((domain) => domain.id === current)) return current;
+        return domains.domains[0]?.id || null;
+      });
+    } catch (error) {
+      const text = error instanceof Error ? error.message : t('common.error');
+      message.error(text);
+      if (/Authentication required/i.test(text)) window.location.href = '/login';
+    } finally {
+      setLoading(false);
+    }
+  }
+
+  function replaceDomain(domain: Domain) {
+    setData((current) => ({
+      ...current,
+      domains: current.domains.map((item) => item.id === domain.id ? domain : item)
+    }));
+  }
+
+  async function runAction<T>(fn: () => Promise<T>, success?: string) {
+    setActionLoading(true);
+    try {
+      const result = await fn();
+      if (success) message.success(success);
+      return result;
+    } catch (error) {
+      message.error(error instanceof Error ? error.message : t('common.error'));
+      return null;
+    } finally {
+      setActionLoading(false);
+    }
+  }
+
+  async function createDomain(values: AddDomainPayload) {
+    const immediateCheck = Boolean(values.immediateCheck);
+    const result = await runAction(async () => api.createDomain(values), t('actions.domainCreated'));
+    if (!result?.domain) return;
+    let nextDomain = result.domain;
+    setData((current) => ({ ...current, domains: [nextDomain, ...current.domains] }));
+    setSelectedDomainId(nextDomain.id);
+    setActiveView('domains');
+    setDomainMode('detail');
+    setInitialDomainTab('dns');
+    setAddOpen(false);
+    if (immediateCheck) {
+      const checked = await runAction(async () => api.checkDomain(nextDomain.id), t('actions.dnsCheckCompleted'));
+      if (checked?.domain) {
+        nextDomain = checked.domain;
+        replaceDomain(nextDomain);
+      }
+    }
+  }
+
+  function viewDetail(domain: Domain, tab = 'overview') {
+    setSelectedDomainId(domain.id);
+    setInitialDomainTab(tab);
+    setDomainMode('detail');
+    setActiveView('domains');
+  }
+
+  async function checkDomain(domain: Domain) {
+    const result = await runAction(async () => api.checkDomain(domain.id), t('actions.dnsCheckRefreshed'));
+    if (result?.domain) replaceDomain(result.domain);
+  }
+
+  async function applyDns(domain: Domain) {
+    const result = await runAction(async () => api.applyDns(domain.id), t('actions.dnsApplyCompleted'));
+    if (result?.domain) {
+      replaceDomain(result.domain);
+      setInitialDomainTab('dns');
+      viewDetail(result.domain, 'dns');
+    }
+  }
+
+  async function patchDomain(domain: Domain, values: DomainPatchPayload) {
+    const result = await runAction(async () => api.patchDomain(domain.id, values), t('actions.domainSaved'));
+    if (result?.domain) replaceDomain(result.domain);
+  }
+
+  async function deleteDomain(domain: Domain) {
+    const result = await runAction(async () => api.deleteDomain(domain.id), t('actions.domainDeleted'));
+    if (!result?.deleted) return;
+    setData((current) => ({ ...current, domains: current.domains.filter((item) => item.id !== domain.id) }));
+    if (selectedDomainId === domain.id) {
+      setSelectedDomainId(null);
+      setDomainMode('list');
+    }
+  }
+
+  function openTestModal(domain: Domain) {
+    setTestDomain(domain);
+    testForm.setFieldsValue({
+      from: `noreply@${domain.domain}`,
+      subject: `MailHub test for ${domain.domain}`,
+      text: `This is a MailHub test message from ${domain.domain}.`
+    });
+  }
+
+  async function submitTestMail() {
+    if (!testDomain) return;
+    const values = await testForm.validateFields();
+    await runAction(async () => api.sendTest(testDomain.id, values), t('actions.testMailQueued'));
+    setTestDomain(null);
+    const [events, analytics] = await Promise.all([api.events(), api.analytics(30)]);
+    setData((current) => ({ ...current, events: events.events || [], analytics: analytics.analytics || current.analytics }));
+  }
+
+  async function copy(value: string) {
+    if (!value || value === '-') return;
+    await navigator.clipboard.writeText(value);
+    message.success(t('common.copied'));
+  }
+
+  async function saveDnsCredential(values: Record<string, unknown>, id?: number) {
+    const result = await runAction(async () => api.saveDnsCredential(values, id), id ? t('actions.dnsApiUpdated') : t('actions.dnsApiCreated'));
+    if (!result?.credential) return;
+    setData((current) => ({
+      ...current,
+      dnsCredentials: id
+        ? current.dnsCredentials.map((item) => item.id === id ? result.credential : item)
+        : [result.credential, ...current.dnsCredentials]
+    }));
+  }
+
+  async function testDnsCredential(credential: DnsCredential) {
+    await runAction(async () => api.testDnsCredential(credential.id), `${credential.name} ${t('actions.dnsApiTestCompleted')}`);
+  }
+
+  async function deleteDnsCredential(credential: DnsCredential) {
+    const result = await runAction(async () => api.deleteDnsCredential(credential.id), t('actions.dnsApiDeleted'));
+    if (!result?.deleted) return;
+    setData((current) => ({
+      ...current,
+      dnsCredentials: current.dnsCredentials.filter((item) => item.id !== credential.id),
+      domains: current.domains.map((domain) => domain.dnsCredentialId === credential.id ? { ...domain, dnsCredentialId: null } : domain)
+    }));
+  }
+
+  async function saveSmtpCredential(values: { username: string; password?: string }) {
+    const result = await runAction(async () => api.saveSmtpCredential(values), t('actions.smtpSaved'));
+    if (!result?.credential) return;
+    setData((current) => ({
+      ...current,
+      smtpCredential: result.credential,
+      config: current.config?.submission
+        ? {
+            ...current.config,
+            submission: {
+              ...current.config.submission,
+              username: result.credential.username,
+              passwordSet: Boolean(result.credential.passwordSet)
+            }
+          }
+        : current.config
+    }));
+  }
+
+  async function createApiToken(name: string) {
+    const result = await runAction(async () => api.createApiToken(name), t('tokens.createdSuccess'));
+    if (!result?.token) return null;
+    setData((current) => ({ ...current, apiTokens: [result.token, ...current.apiTokens] }));
+    return result.token;
+  }
+
+  async function deleteApiToken(token: ApiToken) {
+    const result = await runAction(async () => api.deleteApiToken(token.id), t('tokens.deletedSuccess'));
+    if (!result?.deleted) return;
+    setData((current) => ({ ...current, apiTokens: current.apiTokens.filter((item) => item.id !== token.id) }));
+  }
+
+  async function saveSettings(values: Partial<RuntimeConfig>) {
+    const result = await runAction(async () => api.saveAdminSettings(values), t('actions.settingsSaved'));
+    if (!result?.settings) return;
+    setData((current) => ({ ...current, settings: result.settings, config: { ...current.config, ...result.settings } as RuntimeConfig }));
+  }
+
+  async function logout() {
+    await api.logout().catch(() => null);
+    window.location.href = '/login';
+  }
+
+  const breadcrumb = useMemo(() => {
+    if (activeView === 'domains' && domainMode === 'detail' && selectedDomain) return [t('nav.domains'), selectedDomain.domain];
+    return [t(viewTitleKeys[activeView])];
+  }, [activeView, domainMode, selectedDomain, t]);
+
+  const runtimeLine = data.config
+    ? `${data.config.mailHostname} · ${data.config.sendingIp || t('common.unsetSendingIp')}`
+    : t('common.loadingConfig');
+
+  const content = renderContent();
+
+  return (
+    <>
+      <AdminLayout
+        activeView={activeView}
+        breadcrumb={breadcrumb}
+        user={data.me}
+        runtimeLine={runtimeLine}
+        loading={loading}
+        onViewChange={(view) => {
+          setActiveView(view);
+          if (view === 'domains') setDomainMode('list');
+        }}
+        onRefresh={loadAll}
+        onAddDomain={() => setAddOpen(true)}
+        onLogout={logout}
+      >
+        {content}
+      </AdminLayout>
+      <AddDomainDrawer
+        open={addOpen}
+        loading={actionLoading}
+        config={data.config}
+        dnsCredentials={data.dnsCredentials}
+        onClose={() => setAddOpen(false)}
+        onSubmit={createDomain}
+      />
+      <Modal
+        title={testDomain ? `${t('testMail.title')} · ${testDomain.domain}` : t('testMail.title')}
+        open={Boolean(testDomain)}
+        confirmLoading={actionLoading}
+        onCancel={() => setTestDomain(null)}
+        onOk={submitTestMail}
+      >
+        <Form form={testForm} layout="vertical">
+          <Form.Item name="from" label="From" rules={[{ required: true, message: t('testMail.fromRequired') }]}>
+            <Input />
+          </Form.Item>
+          <Form.Item name="to" label="To" rules={[{ required: true, message: t('testMail.toRequired') }]}>
+            <Input placeholder="user@example.com" />
+          </Form.Item>
+          <Form.Item name="subject" label="Subject">
+            <Input />
+          </Form.Item>
+          <Form.Item name="text" label="Text">
+            <Input.TextArea rows={5} />
+          </Form.Item>
+        </Form>
+      </Modal>
+    </>
+  );
+
+  function renderContent() {
+    if (activeView === 'dashboard') {
+      return (
+        <Dashboard
+          analytics={data.analytics}
+          domains={data.domains}
+          events={data.events}
+          config={data.config}
+          smtpCredential={data.smtpCredential}
+        />
+      );
+    }
+    if (activeView === 'domains') {
+      if (domainMode === 'detail' && selectedDomain) {
+        return (
+          <DomainDetail
+            key={selectedDomain.id}
+            domain={selectedDomain}
+            config={data.config}
+            smtpCredential={data.smtpCredential}
+            apiTokens={data.apiTokens}
+            events={data.events}
+            dnsCredentials={data.dnsCredentials}
+            actionLoading={actionLoading}
+            initialTab={initialDomainTab}
+            onBack={() => setDomainMode('list')}
+            onApplyDns={applyDns}
+            onCheck={checkDomain}
+            onSendTest={openTestModal}
+            onPatchDomain={patchDomain}
+            onCopy={copy}
+            onDelete={deleteDomain}
+          />
+        );
+      }
+      return (
+        <DomainsPage
+          domains={data.domains}
+          events={data.events}
+          dnsCredentials={data.dnsCredentials}
+          actionLoading={actionLoading}
+          onViewDetail={viewDetail}
+          onApplyDns={applyDns}
+          onCheck={checkDomain}
+          onSendTest={openTestModal}
+          onDelete={deleteDomain}
+          onAddDomain={() => setAddOpen(true)}
+        />
+      );
+    }
+    if (activeView === 'dns-api') {
+      return (
+        <DnsApi
+          credentials={data.dnsCredentials}
+          loading={actionLoading}
+          onSave={saveDnsCredential}
+          onTest={testDnsCredential}
+          onDelete={deleteDnsCredential}
+        />
+      );
+    }
+    if (activeView === 'smtp') {
+      return (
+        <SmtpCredentials
+          config={data.config}
+          credential={data.smtpCredential}
+          loading={actionLoading}
+          onCopy={copy}
+          onSave={saveSmtpCredential}
+        />
+      );
+    }
+    if (activeView === 'tokens') {
+      return <ApiTokens tokens={data.apiTokens} loading={actionLoading} onCreate={createApiToken} onDelete={deleteApiToken} onCopy={copy} />;
+    }
+    if (activeView === 'logs') {
+      return <SendingLogs events={data.events} domains={data.domains} />;
+    }
+    if (activeView === 'settings') {
+      return (
+        <Settings
+          me={data.me}
+          settings={data.settings}
+          users={data.users}
+          loading={actionLoading}
+          onSave={saveSettings}
+        />
+      );
+    }
+    return <PlaceholderPage title={t(viewTitleKeys[activeView])} />;
+  }
+}

+ 90 - 0
src/frontend/analytics-model.js

@@ -0,0 +1,90 @@
+import { buildDomainHealth } from './domain-model.js';
+
+/**
+ * @param {{
+ *   analytics?: any;
+ *   domains?: any[];
+ *   events?: any[];
+ *   config?: any;
+ *   smtpCredential?: any;
+ * }} [input]
+ */
+export function buildDashboardSummary({
+  analytics = null,
+  domains = [],
+  events = [],
+  config = null,
+  smtpCredential = null
+} = {}) {
+  const summary = analytics?.summary || {};
+  const total = Number(summary.total || 0);
+  const failed = Number(summary.failed || 0);
+  const verifiedDomains = summary.verifiedDomains ?? domains.filter((domain) => domain.status?.verified).length;
+  const dnsIssues = domains.reduce((count, domain) => count + buildDomainHealth(domain).dnsIssues, 0);
+
+  return {
+    verifiedDomains,
+    today: Number(summary.today || 0),
+    successRate: Number(summary.successRate || 0),
+    bounceRate: total ? Math.round((failed / total) * 1000) / 10 : 0,
+    complaintRate: Number(summary.complaintRate || 0),
+    lastSentAt: events[0]?.createdAt || '',
+    dnsIssues,
+    smtpReady: Boolean(config?.submission?.enabled && smtpCredential?.passwordSet)
+  };
+}
+
+/**
+ * @param {any} [analytics]
+ * @returns {Array<{date: string; total: number; accepted: number; failed: number; recipients: number}>}
+ */
+export function buildTrendSeries(analytics = null) {
+  return (analytics?.byDay || []).map((item) => ({
+    date: item.date || item.day,
+    total: Number(item.total || 0),
+    accepted: Number(item.queued || 0),
+    failed: Number(item.failed || 0),
+    recipients: Number(item.recipients || 0)
+  }));
+}
+
+/**
+ * @param {any} [analytics]
+ * @returns {Array<{status: string; label: string; value: number}>}
+ */
+export function buildStatusDistribution(analytics = null) {
+  return (analytics?.byStatus || []).map((item) => ({
+    status: item.status || 'unknown',
+    label: item.status || 'unknown',
+    value: Number(item.total || 0)
+  }));
+}
+
+/**
+ * @param {any} [analytics]
+ * @returns {Array<{domain: string; total: number; accepted: number; failed: number; recipients: number}>}
+ */
+export function buildDomainRanking(analytics = null) {
+  return [...(analytics?.byDomain || [])]
+    .sort((a, b) => Number(b.total || 0) - Number(a.total || 0))
+    .map((item) => ({
+      domain: item.domain || 'unknown',
+      total: Number(item.total || 0),
+      accepted: Number(item.queued || 0),
+      failed: Number(item.failed || 0),
+      recipients: Number(item.recipients || 0)
+    }));
+}
+
+/**
+ * @param {any} [analytics]
+ * @returns {Array<{hour: string; total: number; accepted: number; failed: number}>}
+ */
+export function buildHourlyHeatmap(analytics = null) {
+  return (analytics?.hourly || []).map((item) => ({
+    hour: `${String(Number(item.hour || 0)).padStart(2, '0')}:00`,
+    total: Number(item.total || 0),
+    accepted: Number(item.queued || 0),
+    failed: Number(item.failed || 0)
+  }));
+}

+ 15 - 0
src/frontend/api-token-model.js

@@ -0,0 +1,15 @@
+export function canCopyFullApiToken(token = {}) {
+  return Boolean(token.token);
+}
+
+export function getCreatedApiTokenSecret(token = {}) {
+  return canCopyFullApiToken(token) ? String(token.token) : '';
+}
+
+export function getCopyableApiToken(token = {}) {
+  return getCreatedApiTokenSecret(token);
+}
+
+export function formatApiTokenPrefix(token = {}) {
+  return token.tokenPrefix ? `${token.tokenPrefix}...` : '-';
+}

+ 157 - 0
src/frontend/auth/AuthApp.tsx

@@ -0,0 +1,157 @@
+import {
+  ApiOutlined,
+  CloudSyncOutlined,
+  LockOutlined,
+  MailOutlined,
+  SafetyCertificateOutlined,
+  UserOutlined
+} from '@ant-design/icons';
+import { Alert, Button, Card, Form, Input, Segmented, Select, Space, Typography } from 'antd';
+import { useEffect, useMemo, useState, type ReactNode } from 'react';
+
+import { useI18n } from '../i18n/react';
+
+type AuthMode = 'login' | 'register';
+
+interface LoginValues {
+  username: string;
+  password: string;
+}
+
+interface RegisterValues extends LoginValues {
+  email: string;
+}
+
+export function AuthApp() {
+  const { locale, locales, setLocale, t } = useI18n();
+  const [mode, setMode] = useState<AuthMode>(() => window.location.pathname === '/register' ? 'register' : 'login');
+  const [message, setMessage] = useState('');
+  const [loading, setLoading] = useState(false);
+  const [loginForm] = Form.useForm<LoginValues>();
+  const [registerForm] = Form.useForm<RegisterValues>();
+
+  useEffect(() => {
+    const params = new URLSearchParams(window.location.search);
+    const error = params.get('error');
+    if (error) {
+      setMessage(error);
+      window.history.replaceState(null, '', window.location.pathname);
+    }
+  }, []);
+
+  const modeOptions = useMemo(() => [
+    { label: t('auth.login'), value: 'login' },
+    { label: t('auth.register'), value: 'register' }
+  ], [t]);
+
+  async function submit(path: string, values: LoginValues | RegisterValues) {
+    setLoading(true);
+    setMessage('');
+    try {
+      const response = await fetch(path, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify(values)
+      });
+      const data = await response.json().catch(() => ({}));
+      if (!response.ok) throw new Error(data.error || t('auth.requestFailed'));
+      window.location.href = '/';
+    } catch (error) {
+      setMessage(error instanceof Error ? error.message : t('auth.requestFailed'));
+    } finally {
+      setLoading(false);
+    }
+  }
+
+  return (
+    <main className="auth-page">
+      <section className="auth-brand-panel">
+        <div className="auth-brand-top">
+          <div className="brand-logo auth-logo">MH</div>
+          <div>
+            <Typography.Title level={1}>MailHub</Typography.Title>
+            <Typography.Text>{t('auth.subtitle')}</Typography.Text>
+          </div>
+        </div>
+        <div className="auth-signal-list">
+          <Signal icon={<CloudSyncOutlined />} title="DNS" text={t('auth.valueDns')} />
+          <Signal icon={<SafetyCertificateOutlined />} title="SMTP" text={t('auth.valueIsolation')} />
+          <Signal icon={<ApiOutlined />} title="API" text={t('auth.valueObservability')} />
+        </div>
+      </section>
+
+      <section className="auth-form-panel">
+        <div className="auth-language-row">
+          <Select value={locale} options={locales} onChange={setLocale} className="language-select" />
+        </div>
+        <Card className="auth-card">
+          <Space direction="vertical" size={22} className="full-width">
+            <div className="auth-heading">
+              <Typography.Text className="auth-eyebrow">
+                {mode === 'login' ? t('auth.loginEyebrow') : t('auth.registerEyebrow')}
+              </Typography.Text>
+              <Typography.Title level={2}>
+                {mode === 'login' ? t('auth.loginTitle') : t('auth.registerTitle')}
+              </Typography.Title>
+            </div>
+
+            <Segmented
+              block
+              value={mode}
+              options={modeOptions}
+              onChange={(value) => {
+                const nextMode = value as AuthMode;
+                setMode(nextMode);
+                setMessage('');
+                window.history.replaceState(null, '', nextMode === 'register' ? '/register' : '/login');
+              }}
+            />
+
+            {message ? <Alert type="error" showIcon message={message} /> : null}
+
+            {mode === 'login' ? (
+              <Form form={loginForm} layout="vertical" onFinish={(values) => submit('/api/login', values)} requiredMark={false}>
+                <Form.Item name="username" label={t('auth.usernameOrEmail')} rules={[{ required: true, message: t('auth.usernameOrEmail') }]}>
+                  <Input prefix={<UserOutlined />} autoComplete="username" autoFocus />
+                </Form.Item>
+                <Form.Item name="password" label={t('auth.password')} rules={[{ required: true, message: t('auth.password') }]}>
+                  <Input.Password prefix={<LockOutlined />} autoComplete="current-password" />
+                </Form.Item>
+                <Button type="primary" htmlType="submit" loading={loading} block size="large">
+                  {t('auth.submitLogin')}
+                </Button>
+              </Form>
+            ) : (
+              <Form form={registerForm} layout="vertical" onFinish={(values) => submit('/api/register', values)} requiredMark={false}>
+                <Form.Item name="username" label={t('auth.username')} rules={[{ required: true, min: 3, message: t('auth.username') }]}>
+                  <Input prefix={<UserOutlined />} autoComplete="username" autoFocus />
+                </Form.Item>
+                <Form.Item name="email" label={t('auth.email')} rules={[{ required: true, type: 'email', message: t('auth.email') }]}>
+                  <Input prefix={<MailOutlined />} autoComplete="email" placeholder={t('auth.emailPlaceholder')} />
+                </Form.Item>
+                <Form.Item name="password" label={t('auth.password')} rules={[{ required: true, min: 8, message: t('auth.password') }]}>
+                  <Input.Password prefix={<LockOutlined />} autoComplete="new-password" />
+                </Form.Item>
+                <Button type="primary" htmlType="submit" loading={loading} block size="large">
+                  {t('auth.registerButton')}
+                </Button>
+              </Form>
+            )}
+          </Space>
+        </Card>
+      </section>
+    </main>
+  );
+}
+
+function Signal({ icon, title, text }: { icon: ReactNode; title: string; text: string }) {
+  return (
+    <div className="auth-signal-item">
+      <span>{icon}</span>
+      <div>
+        <Typography.Text>{title}</Typography.Text>
+        <Typography.Title level={5}>{text}</Typography.Title>
+      </div>
+    </div>
+  );
+}

+ 30 - 0
src/frontend/auth/main.tsx

@@ -0,0 +1,30 @@
+import { createRoot } from 'react-dom/client';
+import { App as AntApp, ConfigProvider } from 'antd';
+
+import { I18nProvider } from '../i18n/react';
+import '../styles.css';
+import { AuthApp } from './AuthApp';
+
+createRoot(document.getElementById('auth-root')!).render(
+  <ConfigProvider
+    theme={{
+      token: {
+        colorPrimary: '#1677ff',
+        borderRadius: 10,
+        colorBgLayout: '#f5f7fb',
+        colorBorderSecondary: '#e5eaf2'
+      },
+      components: {
+        Card: {
+          borderRadiusLG: 12
+        }
+      }
+    }}
+  >
+    <AntApp>
+      <I18nProvider>
+        <AuthApp />
+      </I18nProvider>
+    </AntApp>
+  </ConfigProvider>
+);

+ 53 - 0
src/frontend/domain-model.js

@@ -0,0 +1,53 @@
+const REQUIRED_DNS_KEYS = ['verification', 'dkim', 'spf', 'dmarc', 'sender-a', 'ptr'];
+
+const STATUS_META = {
+  ok: { key: 'success', label: '已通过', color: 'success' },
+  verified: { key: 'success', label: '已通过', color: 'success' },
+  pending: { key: 'pending', label: '等待生效', color: 'warning' },
+  warn: { key: 'error', label: '配置错误', color: 'error' },
+  failed: { key: 'error', label: '配置错误', color: 'error' },
+  error: { key: 'error', label: '配置错误', color: 'error' },
+  missing: { key: 'idle', label: '未配置', color: 'default' },
+  idle: { key: 'idle', label: '未配置', color: 'default' }
+};
+
+export function getRecordStatusMeta(record = {}) {
+  return STATUS_META[String(record.status || '').toLowerCase()] || STATUS_META.missing;
+}
+
+export function getRequiredDnsRecords(domain = {}) {
+  const records = Array.isArray(domain.status?.records) ? domain.status.records : [];
+  const byKey = new Map(records.map((record) => [record.key, record]));
+  return REQUIRED_DNS_KEYS.map((key) => byKey.get(key)).filter(Boolean);
+}
+
+export function buildDomainHealth(domain = {}) {
+  const records = getRequiredDnsRecords(domain);
+  const total = REQUIRED_DNS_KEYS.length;
+  const passed = records.filter((record) => getRecordStatusMeta(record).key === 'success').length;
+  const dnsIssues = records.filter((record) => {
+    const key = getRecordStatusMeta(record).key;
+    return key === 'error' || key === 'idle';
+  }).length
+    + Math.max(0, total - records.length);
+  const percent = total ? Math.round((passed / total) * 100) : 0;
+  const status = dnsIssues > 0 ? 'error' : (passed === total ? 'success' : 'warning');
+
+  return {
+    status,
+    label: status === 'success' ? '健康' : (status === 'warning' ? '等待 DNS 生效' : '需要处理'),
+    passed,
+    total,
+    percent,
+    dnsIssues,
+    checkedAt: domain.status?.checkedAt || ''
+  };
+}
+
+export function isDomainVerified(domain = {}) {
+  return Boolean(domain.status?.verified) || buildDomainHealth(domain).status === 'success';
+}
+
+export function getDnsRecordOrder() {
+  return [...REQUIRED_DNS_KEYS];
+}

+ 496 - 0
src/frontend/i18n/index.js

@@ -0,0 +1,496 @@
+export const DEFAULT_LOCALE = 'zh-CN';
+export const supportedLocales = ['zh-CN', 'en-US'];
+
+const messages = {
+  'zh-CN': {
+    'common.account': '账号',
+    'common.addDomain': '添加域名',
+    'common.cancel': '取消',
+    'common.confirm': '确认',
+    'common.copy': '复制',
+    'common.copied': '已复制',
+    'common.delete': '删除',
+    'common.details': '详情',
+    'common.error': '操作失败',
+    'common.loadingConfig': '加载运行配置中',
+    'common.logout': '退出登录',
+    'common.manual': '手动',
+    'common.next': '下一步',
+    'common.notConfigured': '未配置',
+    'common.notFound': '暂无',
+    'common.previous': '上一步',
+    'common.refresh': '刷新',
+    'common.save': '保存',
+    'common.status': '状态',
+    'common.user': '用户',
+    'common.unsetSendingIp': '未设置发信 IP',
+    'auth.email': '邮箱',
+    'auth.emailPlaceholder': 'name@example.com',
+    'auth.login': '登录',
+    'auth.loginEyebrow': 'Sign in',
+    'auth.loginTitle': '登录控制台',
+    'auth.password': '密码',
+    'auth.register': '注册',
+    'auth.registerButton': '注册并进入',
+    'auth.registerEyebrow': 'Register',
+    'auth.registerTitle': '创建账号',
+    'auth.submitLogin': '登录',
+    'auth.username': '用户名',
+    'auth.usernameOrEmail': '用户名或邮箱',
+    'auth.valueDns': 'DNS 自动化',
+    'auth.valueIsolation': '多用户隔离',
+    'auth.valueObservability': '发送可观测',
+    'auth.subtitle': '发信域名、DNS、SMTP 与 API Token 的统一控制台',
+    'auth.requestFailed': '请求失败。',
+    'dashboard.title': '统计概览',
+    'dashboard.verifiedDomains': '已验证域名',
+    'dashboard.todaySent': '今日发送量',
+    'dashboard.successRate': '成功率',
+    'dashboard.bounceRate': '退信率',
+    'dashboard.complaintRate': '投诉率',
+    'dashboard.lastSentAt': '最近一次发送',
+    'dashboard.dnsIssues': 'DNS 异常数量',
+    'dashboard.smtpStatus': 'SMTP 状态',
+    'dashboard.smtpReady': '可用',
+    'dashboard.smtpNotConfigured': '未配置',
+    'dashboard.trend': '发送趋势',
+    'dashboard.statusDistribution': '状态分布',
+    'dashboard.domainRanking': '域名发送排行',
+    'dashboard.domainHealth': '域名健康',
+    'dashboard.hourlyHeatmap': '小时发送分布',
+    'dashboard.recentFailures': '最近失败原因',
+    'dashboard.recentLogs': '最近发送记录',
+    'dashboard.noTrend': '暂无发送趋势数据',
+    'dashboard.noDomains': '暂无域名',
+    'dashboard.noFailures': '暂无失败记录',
+    'dashboard.defaultPasswordWarning': '当前仍在使用默认管理密码,请修改 .env 后重启服务。',
+    'dashboard.acceptedMail': '已接收邮件',
+    'dashboard.failedMail': '失败邮件',
+    'dashboard.dnsActionHint': '优先处理 DNS 异常域名,避免影响新域名投递。',
+    'dashboard.statusQueued': '已接收',
+    'dashboard.statusFailed': '失败',
+    'dashboard.statusUnknown': '未知',
+    'domains.title': '发信域名',
+    'domains.domain': '域名',
+    'domains.senderHost': '发信主机',
+    'domains.sendingIp': '发信 IP',
+    'domains.dnsApi': 'DNS API',
+    'domains.smtp': 'SMTP',
+    'domains.lastSent': '最近发送',
+    'domains.overallStatus': '整体状态',
+    'domains.actions': '操作',
+    'domains.searchPlaceholder': '搜索域名或发信主机',
+    'domains.statusPlaceholder': '整体状态',
+    'domains.healthy': '健康',
+    'domains.pending': '等待生效',
+    'domains.needsAction': '需要处理',
+    'domains.waitingDns': '等待 DNS 生效',
+    'domains.sendable': '可发送',
+    'domains.waitingVerify': '待验证',
+    'domains.oneClickDns': '一键 DNS',
+    'domains.check': '检查',
+    'domains.test': '测试',
+    'domains.deleteConfirm': '确认删除该域名?',
+    'domainDetail.back': '返回域名列表',
+    'domainDetail.overview': '域名概览',
+    'domainDetail.danger': '危险操作',
+    'domainDetail.deleteHint': '删除域名会移除该域名配置,但不会删除 DNS 服务商中的记录。',
+    'domainDetail.editTitle': '修改域名配置',
+    'domainDetail.lastCheck': '最后检查',
+    'domainDetail.notChecked': '尚未检查',
+    'domainDetail.noDnsResult': '尚未生成 DNS 检查结果',
+    'domainDetail.currentDnsResult': '当前 DNS 检测结果',
+    'domainDetail.recheckAll': '重新检查全部',
+    'domainDetail.copyAll': '复制全部配置',
+    'domainDetail.noPublicDns': '暂无公共 DNS 查询数据',
+    'domainDetail.notFoundRecord': '未发现',
+    'domainDetail.needAttention': '需要关注',
+    'domainDetail.noSmtpPassword': '请在 SMTP 页面重新设置后复制',
+    'domainDetail.noApiToken': '尚未创建',
+    'domainDetail.apiExample': '发送 API 示例',
+    'domainDetail.placeholder': '配置将在后续版本接入',
+    'dnsRecord.hostname': '主机名',
+    'dnsRecord.targetValue': '目标值',
+    'dnsRecord.currentValue': '当前值',
+    'dnsRecord.emptyCurrent': '未查询到记录',
+    'dnsRecord.ptrHint': 'PTR 通常需要在服务器商、云厂商或 IP 提供商处配置,DNS API 一般无法直接写入。',
+    'dnsRecord.recheck': '重新检测',
+    'dnsRecord.copyLabel': '复制',
+    'dnsRecord.rootTxt': '根域 TXT',
+    'dnsRecord.verificationTxt': '验证 TXT',
+    'dnsRecord.dkimTxt': 'DKIM TXT',
+    'dnsRecord.dmarcTxt': 'DMARC TXT',
+    'dnsRecord.senderA': '发信主机 A',
+    'dnsRecord.ptr': '发信 IP PTR',
+    'domainHealth.sendingDomain': 'Sending domain',
+    'domainHealth.dnsProgress': 'DNS 检查进度',
+    'domainHealth.passed': '通过',
+    'domainHealth.dnsIssues': 'DNS 异常',
+    'domainHealth.dnsApi': 'DNS API',
+    'domainHealth.oneClickDns': '一键配置 DNS',
+    'domainHealth.checkNow': '立即检查',
+    'domainHealth.sendTest': '发送测试邮件',
+    'domainHealth.edit': '修改配置',
+    'addDomain.title': '添加发信域名',
+    'addDomain.stepDomain': '域名信息',
+    'addDomain.stepDns': 'DNS 配置方式',
+    'addDomain.stepPolicy': 'DKIM / SPF / DMARC',
+    'addDomain.stepConfirm': '确认并创建',
+    'addDomain.create': '确认创建',
+    'addDomain.domainExtra': '填写根域名,不需要 http、路径或邮箱地址。',
+    'addDomain.domainRequired': '请输入发信域名',
+    'addDomain.senderHostRequired': '请输入发信主机',
+    'addDomain.sendingIpRequired': '请输入发信 IP',
+    'addDomain.dnsExtra': '未选择时仍可创建域名,稍后手动配置 DNS 或绑定凭据。',
+    'addDomain.manualDns': '手动配置 DNS',
+    'addDomain.dnsHint': '选择 DNS API 后,域名详情页可以使用“一键配置 DNS”写入验证、DKIM、SPF、DMARC 和发信主机 A 记录。',
+    'addDomain.selectorRequired': '请输入 DKIM selector',
+    'addDomain.spfExtra': '兼容第三方 SPF include',
+    'addDomain.generatedTitle': '创建后会生成以下配置',
+    'addDomain.recordVerification': '域名验证 TXT',
+    'addDomain.recordDkim': 'DKIM TXT',
+    'addDomain.recordSpf': 'SPF TXT',
+    'addDomain.recordDmarc': 'DMARC TXT',
+    'addDomain.recordSenderA': '发信主机 A 记录',
+    'addDomain.recordPtr': 'PTR 反向解析检查提示',
+    'addDomain.immediateCheck': '创建后立即检查 DNS',
+    'logs.title': '发送记录',
+    'logs.time': '时间',
+    'logs.recipient': '收件人',
+    'logs.domain': '发件域名',
+    'logs.errorReason': '错误原因',
+    'logs.viewDetail': '查看详情',
+    'logs.domainPlaceholder': '发信域名',
+    'logs.statusPlaceholder': '状态',
+    'logs.recipientPlaceholder': '搜索收件人',
+    'smtp.connectionTitle': 'SMTP 连接信息',
+    'smtp.updateTitle': '更新 SMTP 凭据',
+    'smtp.usernameRequired': '请输入 SMTP Username',
+    'smtp.passwordExtra': '留空保存时会保留原密码;旧密码无法回显时请重新生成。',
+    'smtp.regenerate': '重新生成密码',
+    'smtp.save': '保存 SMTP 凭据',
+    'smtp.resetToCopy': '请重新设置后复制',
+    'dnsApi.title': 'DNS API 凭据',
+    'dnsApi.createTitle': '新增 DNS API',
+    'dnsApi.editTitle': '编辑',
+    'dnsApi.zone': 'Zone / 根域名',
+    'dnsApi.zoneRequired': '请输入 Zone 或根域名',
+    'dnsApi.tokenExtra': '需要 Zone DNS Edit 权限。',
+    'dnsApi.keepSecret': '留空表示保留原密钥。',
+    'dnsApi.save': '保存修改',
+    'dnsApi.create': '新增凭据',
+    'dnsApi.secretHint': '密钥只在服务端加密保存,不会在列表中回显。',
+    'settings.noPermission': '当前账号没有系统设置权限。',
+    'settings.save': '保存设置',
+    'metrics.accepted': '已接收',
+    'metrics.failed': '失败',
+    'metrics.recipients': '收件人',
+    'metrics.total': '总量',
+    'status.success': '已通过',
+    'status.pending': '等待生效',
+    'status.error': '配置错误',
+    'status.idle': '未配置',
+    'tokens.createTitle': '创建 API Token',
+    'tokens.name': '名称',
+    'tokens.namePlaceholder': 'Production API',
+    'tokens.nameRequired': '请输入 Token 名称',
+    'tokens.create': '创建 Token',
+    'tokens.listTitle': 'API Tokens',
+    'tokens.prefix': 'Token 前缀',
+    'tokens.fullToken': '完整 Token',
+    'tokens.copyFull': '复制完整 Token',
+    'tokens.copyPrefix': '复制前缀',
+    'tokens.createdTitle': 'API Token 已创建',
+    'tokens.createdWarning': '完整 Token 只会显示这一次。关闭后无法再次查看,请立即复制并保存到安全位置。',
+    'tokens.createdSuccess': 'Token 已创建',
+    'tokens.deletedSuccess': 'Token 已删除',
+    'tokens.secretUnavailable': '完整 Token 仅在创建时显示',
+    'tokens.lastUsed': '最近使用',
+    'tokens.neverUsed': '未使用',
+    'tokens.createdAt': '创建时间',
+    'tokens.actions': '操作',
+    'tokens.deleteConfirm': '确认删除该 Token?',
+    'tokens.copyCreated': '复制完整 Token',
+    'tokens.prefixOnlyHelp': '历史 Token 不保存明文,只能复制前缀用于识别。',
+    'testMail.title': '发送测试邮件',
+    'testMail.fromRequired': '请输入发件人',
+    'testMail.toRequired': '请输入收件人',
+    'actions.domainCreated': '域名已创建',
+    'actions.dnsCheckCompleted': 'DNS 检查已完成',
+    'actions.dnsCheckRefreshed': 'DNS 检查已刷新',
+    'actions.dnsApplyCompleted': 'DNS 写入请求已完成',
+    'actions.domainSaved': '域名配置已保存',
+    'actions.domainDeleted': '域名已删除',
+    'actions.testMailQueued': '已提交到发信队列',
+    'actions.dnsApiCreated': 'DNS API 已新增',
+    'actions.dnsApiUpdated': 'DNS API 已更新',
+    'actions.dnsApiDeleted': 'DNS API 已删除',
+    'actions.dnsApiTestCompleted': '连接测试完成',
+    'actions.smtpSaved': 'SMTP 凭据已保存',
+    'actions.settingsSaved': '系统设置已保存',
+    'nav.dashboard': 'Dashboard',
+    'nav.domains': 'Domains',
+    'nav.dnsApi': 'DNS API',
+    'nav.smtp': 'SMTP Credentials',
+    'nav.tokens': 'API Tokens',
+    'nav.logs': 'Sending Logs',
+    'nav.webhooks': 'Webhooks',
+    'nav.settings': 'Settings'
+  },
+  'en-US': {
+    'common.account': 'Account',
+    'common.addDomain': 'Add Domain',
+    'common.cancel': 'Cancel',
+    'common.confirm': 'Confirm',
+    'common.copy': 'Copy',
+    'common.copied': 'Copied',
+    'common.delete': 'Delete',
+    'common.details': 'Details',
+    'common.error': 'Operation failed',
+    'common.loadingConfig': 'Loading runtime config',
+    'common.logout': 'Log out',
+    'common.manual': 'Manual',
+    'common.next': 'Next',
+    'common.notConfigured': 'Not configured',
+    'common.notFound': 'None',
+    'common.previous': 'Previous',
+    'common.refresh': 'Refresh',
+    'common.save': 'Save',
+    'common.status': 'Status',
+    'common.user': 'User',
+    'common.unsetSendingIp': 'Sending IP not set',
+    'auth.email': 'Email',
+    'auth.emailPlaceholder': 'name@example.com',
+    'auth.login': 'Sign in',
+    'auth.loginEyebrow': 'Sign in',
+    'auth.loginTitle': 'Sign in to console',
+    'auth.password': 'Password',
+    'auth.register': 'Register',
+    'auth.registerButton': 'Create account',
+    'auth.registerEyebrow': 'Register',
+    'auth.registerTitle': 'Create account',
+    'auth.submitLogin': 'Sign in',
+    'auth.username': 'Username',
+    'auth.usernameOrEmail': 'Username or email',
+    'auth.valueDns': 'DNS automation',
+    'auth.valueIsolation': 'User isolation',
+    'auth.valueObservability': 'Delivery visibility',
+    'auth.subtitle': 'One console for sending domains, DNS, SMTP, and API tokens',
+    'auth.requestFailed': 'Request failed.',
+    'dashboard.title': 'Analytics',
+    'dashboard.verifiedDomains': 'Verified domains',
+    'dashboard.todaySent': 'Sent today',
+    'dashboard.successRate': 'Success rate',
+    'dashboard.bounceRate': 'Bounce rate',
+    'dashboard.complaintRate': 'Complaint rate',
+    'dashboard.lastSentAt': 'Last sent',
+    'dashboard.dnsIssues': 'DNS issues',
+    'dashboard.smtpStatus': 'SMTP status',
+    'dashboard.smtpReady': 'Ready',
+    'dashboard.smtpNotConfigured': 'Not configured',
+    'dashboard.trend': 'Sending trend',
+    'dashboard.statusDistribution': 'Status distribution',
+    'dashboard.domainRanking': 'Domain ranking',
+    'dashboard.domainHealth': 'Domain health',
+    'dashboard.hourlyHeatmap': 'Hourly distribution',
+    'dashboard.recentFailures': 'Recent failures',
+    'dashboard.recentLogs': 'Recent sending logs',
+    'dashboard.noTrend': 'No trend data yet',
+    'dashboard.noDomains': 'No domains yet',
+    'dashboard.noFailures': 'No failures yet',
+    'dashboard.defaultPasswordWarning': 'The default admin password is still in use. Update .env and restart the service.',
+    'dashboard.acceptedMail': 'Accepted mail',
+    'dashboard.failedMail': 'Failed mail',
+    'dashboard.dnsActionHint': 'Handle domains with DNS issues first to protect delivery for new senders.',
+    'dashboard.statusQueued': 'Accepted',
+    'dashboard.statusFailed': 'Failed',
+    'dashboard.statusUnknown': 'Unknown',
+    'domains.title': 'Sending domains',
+    'domains.domain': 'Domain',
+    'domains.senderHost': 'Sending host',
+    'domains.sendingIp': 'Sending IP',
+    'domains.dnsApi': 'DNS API',
+    'domains.smtp': 'SMTP',
+    'domains.lastSent': 'Last sent',
+    'domains.overallStatus': 'Overall status',
+    'domains.actions': 'Actions',
+    'domains.searchPlaceholder': 'Search domain or sending host',
+    'domains.statusPlaceholder': 'Overall status',
+    'domains.healthy': 'Healthy',
+    'domains.pending': 'Pending',
+    'domains.needsAction': 'Needs action',
+    'domains.waitingDns': 'Waiting for DNS',
+    'domains.sendable': 'Sendable',
+    'domains.waitingVerify': 'Waiting verification',
+    'domains.oneClickDns': 'One-click DNS',
+    'domains.check': 'Check',
+    'domains.test': 'Test',
+    'domains.deleteConfirm': 'Delete this domain?',
+    'domainDetail.back': 'Back to domains',
+    'domainDetail.overview': 'Domain overview',
+    'domainDetail.danger': 'Danger zone',
+    'domainDetail.deleteHint': 'Deleting the domain removes MailHub configuration, but does not delete records at your DNS provider.',
+    'domainDetail.editTitle': 'Edit domain settings',
+    'domainDetail.lastCheck': 'Last check',
+    'domainDetail.notChecked': 'Not checked',
+    'domainDetail.noDnsResult': 'No DNS check result yet',
+    'domainDetail.currentDnsResult': 'Current DNS results',
+    'domainDetail.recheckAll': 'Recheck all',
+    'domainDetail.copyAll': 'Copy all records',
+    'domainDetail.noPublicDns': 'No public DNS query data',
+    'domainDetail.notFoundRecord': 'Not found',
+    'domainDetail.needAttention': 'Needs attention',
+    'domainDetail.noSmtpPassword': 'Reset it on the SMTP page before copying',
+    'domainDetail.noApiToken': 'No token yet',
+    'domainDetail.apiExample': 'Sending API example',
+    'domainDetail.placeholder': 'configuration will be connected in a later version',
+    'dnsRecord.hostname': 'Hostname',
+    'dnsRecord.targetValue': 'Target value',
+    'dnsRecord.currentValue': 'Current value',
+    'dnsRecord.emptyCurrent': 'No record found',
+    'dnsRecord.ptrHint': 'PTR is usually configured at your server, cloud, or IP provider. DNS APIs usually cannot write it directly.',
+    'dnsRecord.recheck': 'Recheck',
+    'dnsRecord.copyLabel': 'Copy',
+    'dnsRecord.rootTxt': 'Root TXT',
+    'dnsRecord.verificationTxt': 'Verification TXT',
+    'dnsRecord.dkimTxt': 'DKIM TXT',
+    'dnsRecord.dmarcTxt': 'DMARC TXT',
+    'dnsRecord.senderA': 'Sending host A',
+    'dnsRecord.ptr': 'Sending IP PTR',
+    'domainHealth.sendingDomain': 'Sending domain',
+    'domainHealth.dnsProgress': 'DNS check progress',
+    'domainHealth.passed': 'passed',
+    'domainHealth.dnsIssues': 'DNS issues',
+    'domainHealth.dnsApi': 'DNS API',
+    'domainHealth.oneClickDns': 'Configure DNS',
+    'domainHealth.checkNow': 'Check now',
+    'domainHealth.sendTest': 'Send test email',
+    'domainHealth.edit': 'Edit settings',
+    'addDomain.title': 'Add sending domain',
+    'addDomain.stepDomain': 'Domain',
+    'addDomain.stepDns': 'DNS method',
+    'addDomain.stepPolicy': 'DKIM / SPF / DMARC',
+    'addDomain.stepConfirm': 'Confirm',
+    'addDomain.create': 'Create domain',
+    'addDomain.domainExtra': 'Use the root domain without http, paths, or email addresses.',
+    'addDomain.domainRequired': 'Enter the sending domain',
+    'addDomain.senderHostRequired': 'Enter the sending host',
+    'addDomain.sendingIpRequired': 'Enter the sending IP',
+    'addDomain.dnsExtra': 'You can create the domain without a credential and configure DNS manually later.',
+    'addDomain.manualDns': 'Manual DNS',
+    'addDomain.dnsHint': 'With a DNS API credential, the detail page can write verification, DKIM, SPF, DMARC, and sending host A records.',
+    'addDomain.selectorRequired': 'Enter the DKIM selector',
+    'addDomain.spfExtra': 'Third-party SPF includes',
+    'addDomain.generatedTitle': 'MailHub will generate',
+    'addDomain.recordVerification': 'Domain verification TXT',
+    'addDomain.recordDkim': 'DKIM TXT',
+    'addDomain.recordSpf': 'SPF TXT',
+    'addDomain.recordDmarc': 'DMARC TXT',
+    'addDomain.recordSenderA': 'Sending host A record',
+    'addDomain.recordPtr': 'PTR reverse DNS check hint',
+    'addDomain.immediateCheck': 'Check DNS immediately after creation',
+    'logs.title': 'Sending logs',
+    'logs.time': 'Time',
+    'logs.recipient': 'Recipient',
+    'logs.domain': 'Sending domain',
+    'logs.errorReason': 'Error reason',
+    'logs.viewDetail': 'View detail',
+    'logs.domainPlaceholder': 'Sending domain',
+    'logs.statusPlaceholder': 'Status',
+    'logs.recipientPlaceholder': 'Search recipient',
+    'smtp.connectionTitle': 'SMTP connection',
+    'smtp.updateTitle': 'Update SMTP credential',
+    'smtp.usernameRequired': 'Enter SMTP username',
+    'smtp.passwordExtra': 'Leave empty to keep the current password. Reset old hidden passwords before copying.',
+    'smtp.regenerate': 'Regenerate password',
+    'smtp.save': 'Save SMTP credential',
+    'smtp.resetToCopy': 'Reset before copying',
+    'dnsApi.title': 'DNS API credentials',
+    'dnsApi.createTitle': 'New DNS API',
+    'dnsApi.editTitle': 'Edit',
+    'dnsApi.zone': 'Zone / root domain',
+    'dnsApi.zoneRequired': 'Enter the Zone or root domain',
+    'dnsApi.tokenExtra': 'Requires Zone DNS Edit permission.',
+    'dnsApi.keepSecret': 'Leave empty to keep the existing secret.',
+    'dnsApi.save': 'Save changes',
+    'dnsApi.create': 'Create credential',
+    'dnsApi.secretHint': 'Secrets are encrypted server-side and never shown in the list.',
+    'settings.noPermission': 'This account cannot access system settings.',
+    'settings.save': 'Save settings',
+    'metrics.accepted': 'Accepted',
+    'metrics.failed': 'Failed',
+    'metrics.recipients': 'Recipients',
+    'metrics.total': 'Total',
+    'status.success': 'Passed',
+    'status.pending': 'Pending',
+    'status.error': 'Misconfigured',
+    'status.idle': 'Not configured',
+    'tokens.createTitle': 'Create API Token',
+    'tokens.name': 'Name',
+    'tokens.namePlaceholder': 'Production API',
+    'tokens.nameRequired': 'Enter a token name',
+    'tokens.create': 'Create Token',
+    'tokens.listTitle': 'API Tokens',
+    'tokens.prefix': 'Token Prefix',
+    'tokens.fullToken': 'Full Token',
+    'tokens.copyFull': 'Copy full token',
+    'tokens.copyPrefix': 'Copy prefix',
+    'tokens.createdTitle': 'API Token created',
+    'tokens.createdWarning': 'The full token is shown only once. Copy it now and store it somewhere secure.',
+    'tokens.createdSuccess': 'Token created',
+    'tokens.deletedSuccess': 'Token deleted',
+    'tokens.secretUnavailable': 'Full token is only shown at creation time',
+    'tokens.lastUsed': 'Last used',
+    'tokens.neverUsed': 'Never used',
+    'tokens.createdAt': 'Created at',
+    'tokens.actions': 'Actions',
+    'tokens.deleteConfirm': 'Delete this token?',
+    'tokens.copyCreated': 'Copy full token',
+    'tokens.prefixOnlyHelp': 'Historical tokens do not store plaintext. The prefix is only for identification.',
+    'testMail.title': 'Send test email',
+    'testMail.fromRequired': 'Enter the sender',
+    'testMail.toRequired': 'Enter the recipient',
+    'actions.domainCreated': 'Domain created',
+    'actions.dnsCheckCompleted': 'DNS check completed',
+    'actions.dnsCheckRefreshed': 'DNS check refreshed',
+    'actions.dnsApplyCompleted': 'DNS write request completed',
+    'actions.domainSaved': 'Domain settings saved',
+    'actions.domainDeleted': 'Domain deleted',
+    'actions.testMailQueued': 'Submitted to sending queue',
+    'actions.dnsApiCreated': 'DNS API created',
+    'actions.dnsApiUpdated': 'DNS API updated',
+    'actions.dnsApiDeleted': 'DNS API deleted',
+    'actions.dnsApiTestCompleted': 'Connection test completed',
+    'actions.smtpSaved': 'SMTP credential saved',
+    'actions.settingsSaved': 'System settings saved',
+    'nav.dashboard': 'Dashboard',
+    'nav.domains': 'Domains',
+    'nav.dnsApi': 'DNS API',
+    'nav.smtp': 'SMTP Credentials',
+    'nav.tokens': 'API Tokens',
+    'nav.logs': 'Sending Logs',
+    'nav.webhooks': 'Webhooks',
+    'nav.settings': 'Settings'
+  }
+};
+
+export function normalizeLocale(locale = DEFAULT_LOCALE) {
+  const value = String(locale || '').trim();
+  if (supportedLocales.includes(value)) return value;
+  const lower = value.toLowerCase();
+  if (lower.startsWith('zh')) return 'zh-CN';
+  if (lower.startsWith('en')) return 'en-US';
+  return DEFAULT_LOCALE;
+}
+
+export function createTranslator(locale = DEFAULT_LOCALE) {
+  const normalized = normalizeLocale(locale);
+  return (key) => messages[normalized]?.[key] || messages[DEFAULT_LOCALE]?.[key] || key;
+}
+
+export function getLocaleLabel(locale) {
+  return {
+    'zh-CN': '简体中文',
+    'en-US': 'English'
+  }[normalizeLocale(locale)] || String(locale || DEFAULT_LOCALE);
+}

+ 50 - 0
src/frontend/i18n/react.tsx

@@ -0,0 +1,50 @@
+import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
+
+import {
+  DEFAULT_LOCALE,
+  createTranslator,
+  getLocaleLabel,
+  normalizeLocale,
+  supportedLocales
+} from './index.js';
+
+const storageKey = 'mailhub.locale';
+
+interface I18nContextValue {
+  locale: string;
+  locales: Array<{ value: string; label: string }>;
+  setLocale: (locale: string) => void;
+  t: (key: string) => string;
+}
+
+const I18nContext = createContext<I18nContextValue | null>(null);
+
+export function I18nProvider({ children }: { children: ReactNode }) {
+  const [locale, setLocaleState] = useState(() => initialLocale());
+
+  useEffect(() => {
+    document.documentElement.lang = locale;
+    window.localStorage.setItem(storageKey, locale);
+  }, [locale]);
+
+  const value = useMemo<I18nContextValue>(() => ({
+    locale,
+    locales: supportedLocales.map((value) => ({ value, label: getLocaleLabel(value) })),
+    setLocale: (nextLocale) => setLocaleState(normalizeLocale(nextLocale)),
+    t: createTranslator(locale)
+  }), [locale]);
+
+  return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
+}
+
+export function useI18n() {
+  const context = useContext(I18nContext);
+  if (!context) throw new Error('useI18n must be used inside I18nProvider');
+  return context;
+}
+
+function initialLocale() {
+  if (typeof window === 'undefined') return DEFAULT_LOCALE;
+  const params = new URLSearchParams(window.location.search);
+  return normalizeLocale(params.get('lang') || window.localStorage.getItem(storageKey) || DEFAULT_LOCALE);
+}

+ 7 - 0
src/frontend/main.tsx

@@ -0,0 +1,7 @@
+import 'antd/dist/reset.css';
+
+import { createRoot } from 'react-dom/client';
+
+import App from './App';
+
+createRoot(document.getElementById('root')!).render(<App />);

+ 117 - 0
src/frontend/services/api.ts

@@ -0,0 +1,117 @@
+import type {
+  AddDomainPayload,
+  ApiToken,
+  Analytics,
+  DnsCredential,
+  Domain,
+  DomainPatchPayload,
+  RuntimeConfig,
+  SendEvent,
+  SmtpCredential,
+  User
+} from '../types';
+
+interface RequestOptions extends RequestInit {
+  data?: unknown;
+}
+
+async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
+  const headers = new Headers(options.headers);
+  if (options.data !== undefined && !headers.has('Content-Type')) {
+    headers.set('Content-Type', 'application/json');
+  }
+
+  const response = await send(path, {
+    method: options.method || 'GET',
+    headers,
+    body: options.data === undefined ? options.body : JSON.stringify(options.data)
+  });
+  const text = response.text;
+  const payload = text ? JSON.parse(text) : {};
+
+  if (response.status < 200 || response.status >= 300) {
+    throw new Error(payload.error || payload.message || requestFailedMessage(response.status));
+  }
+
+  return payload as T;
+}
+
+function send(
+  path: string,
+  options: { method: string; headers: Headers; body?: BodyInit | null }
+): Promise<{ status: number; text: string }> {
+  if (typeof globalThis.fetch === 'function') {
+    return globalThis.fetch(path, {
+      method: options.method,
+      headers: options.headers,
+      body: options.body
+    }).then(async (response) => ({
+      status: response.status,
+      text: await response.text()
+    }));
+  }
+
+  return new Promise((resolve, reject) => {
+    const xhr = new XMLHttpRequest();
+    xhr.open(options.method, path, true);
+    options.headers.forEach((value, key) => xhr.setRequestHeader(key, value));
+    xhr.onload = () => resolve({ status: xhr.status, text: xhr.responseText || '' });
+    xhr.onerror = () => reject(new Error(networkFailedMessage()));
+    xhr.send((options.body || null) as XMLHttpRequestBodyInit | null);
+  });
+}
+
+function requestFailedMessage(status: number) {
+  return currentLocale().startsWith('en') ? `Request failed: ${status}` : `请求失败:${status}`;
+}
+
+function networkFailedMessage() {
+  return currentLocale().startsWith('en') ? 'Network request failed' : '网络请求失败';
+}
+
+function currentLocale() {
+  return document.documentElement.lang || window.localStorage.getItem('mailhub.locale') || 'zh-CN';
+}
+
+export const api = {
+  me: () => request<{ user: User }>('/api/me'),
+  config: () => request<RuntimeConfig>('/api/config'),
+  domains: () => request<{ domains: Domain[] }>('/api/domains'),
+  events: () => request<{ events: SendEvent[] }>('/api/events'),
+  analytics: (days = 30) => request<{ analytics: Analytics }>(`/api/analytics?days=${days}`),
+  smtpCredential: () => request<{ credential: SmtpCredential | null }>('/api/smtp-credential'),
+  saveSmtpCredential: (data: { username: string; password?: string }) =>
+    request<{ credential: SmtpCredential }>('/api/smtp-credential', { method: 'PUT', data }),
+  dnsCredentials: () => request<{ credentials: DnsCredential[] }>('/api/dns-credentials'),
+  saveDnsCredential: (data: Record<string, unknown>, id?: number) =>
+    request<{ credential: DnsCredential }>(id ? `/api/dns-credentials/${id}` : '/api/dns-credentials', {
+      method: id ? 'PATCH' : 'POST',
+      data
+    }),
+  testDnsCredential: (id: number) =>
+    request<{ ok: boolean; detail?: string; provider?: string; error?: string }>(`/api/dns-credentials/${id}/test`, {
+      method: 'POST'
+    }),
+  deleteDnsCredential: (id: number) =>
+    request<{ deleted: boolean }>(`/api/dns-credentials/${id}`, { method: 'DELETE' }),
+  apiTokens: () => request<{ tokens: ApiToken[] }>('/api/api-tokens'),
+  createApiToken: (name: string) =>
+    request<{ token: ApiToken }>('/api/api-tokens', { method: 'POST', data: { name } }),
+  deleteApiToken: (id: number) => request<{ deleted: boolean }>(`/api/api-tokens/${id}`, { method: 'DELETE' }),
+  createDomain: (data: AddDomainPayload) => request<{ domain: Domain }>('/api/domains', { method: 'POST', data }),
+  patchDomain: (id: number, data: DomainPatchPayload) =>
+    request<{ domain: Domain }>(`/api/domains/${id}`, { method: 'PATCH', data }),
+  checkDomain: (id: number) => request<{ domain: Domain }>(`/api/domains/${id}/check`, { method: 'POST' }),
+  applyDns: (id: number) =>
+    request<{ domain: Domain; apply?: Domain['status']['apply'] }>(`/api/domains/${id}/apply-dns`, { method: 'POST' }),
+  rotateDkim: (id: number, selector?: string) =>
+    request<{ domain: Domain }>(`/api/domains/${id}/rotate-dkim`, { method: 'POST', data: { selector } }),
+  sendTest: (id: number, data: { from?: string; to: string; subject?: string; text?: string }) =>
+    request<{ queued: boolean }>(`/api/domains/${id}/test-send`, { method: 'POST', data }),
+  deleteDomain: (id: number) => request<{ deleted: boolean }>(`/api/domains/${id}`, { method: 'DELETE' }),
+  adminSettings: () => request<{ settings: RuntimeConfig }>('/api/admin/settings'),
+  saveAdminSettings: (data: Partial<RuntimeConfig>) =>
+    request<{ settings: RuntimeConfig }>('/api/admin/settings', { method: 'PATCH', data }),
+  adminUsers: () => request<{ users: User[] }>('/api/admin/users'),
+  logout: () => request<{ ok: boolean }>('/api/logout', { method: 'POST' })
+};

+ 442 - 0
src/frontend/styles.css

@@ -0,0 +1,442 @@
+html,
+body,
+#root {
+  min-height: 100%;
+}
+
+body {
+  margin: 0;
+  background: #f5f7fb;
+  color: #172033;
+}
+
+.full-width {
+  width: 100%;
+}
+
+.admin-layout {
+  min-height: 100vh;
+}
+
+.admin-sider {
+  background: #111827 !important;
+  border-right: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.brand {
+  align-items: center;
+  display: flex;
+  gap: 12px;
+  min-height: 72px;
+  padding: 18px 20px;
+}
+
+.brand-logo {
+  align-items: center;
+  background: #ffffff;
+  border-radius: 10px;
+  color: #1677ff;
+  display: inline-flex;
+  font-weight: 800;
+  height: 36px;
+  justify-content: center;
+  width: 36px;
+}
+
+.brand-title {
+  color: #ffffff;
+  font-size: 16px;
+  font-weight: 700;
+}
+
+.brand-subtitle {
+  color: rgba(255, 255, 255, 0.56);
+  font-size: 12px;
+}
+
+.admin-header {
+  align-items: center;
+  background: #ffffff;
+  border-bottom: 1px solid #e5eaf2;
+  display: flex;
+  height: auto;
+  justify-content: space-between;
+  min-height: 72px;
+  padding: 14px 24px;
+  position: sticky;
+  top: 0;
+  z-index: 10;
+}
+
+.header-title {
+  display: grid;
+  gap: 4px;
+}
+
+.runtime-line {
+  font-size: 13px;
+}
+
+.user-button {
+  padding-inline: 10px;
+}
+
+.admin-content {
+  background: #f5f7fb;
+  padding: 24px;
+}
+
+.ant-card {
+  border-color: #e5eaf2;
+  box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
+}
+
+.metric-value {
+  margin: 8px 0 0 !important;
+}
+
+.metric-card {
+  min-height: 122px;
+}
+
+.chart-card .ant-card-body {
+  min-height: 364px;
+}
+
+.language-select {
+  min-width: 116px;
+}
+
+.token-list-alert {
+  margin-bottom: 16px;
+}
+
+.trend-bars {
+  align-items: end;
+  display: grid;
+  gap: 8px;
+  grid-template-columns: repeat(auto-fit, minmax(28px, 1fr));
+  min-height: 260px;
+}
+
+.trend-bar-item {
+  align-items: center;
+  display: grid;
+  gap: 8px;
+  justify-items: center;
+}
+
+.trend-bar-track {
+  align-items: end;
+  background: #edf3fb;
+  border-radius: 8px;
+  display: flex;
+  height: 210px;
+  overflow: hidden;
+  width: 100%;
+}
+
+.trend-bar-fill {
+  background: #1677ff;
+  border-radius: 8px 8px 0 0;
+  width: 100%;
+}
+
+.health-row {
+  align-items: center;
+  border-bottom: 1px solid #eef2f7;
+  display: flex;
+  justify-content: space-between;
+  padding: 10px 0;
+}
+
+.health-row > div {
+  display: grid;
+  gap: 2px;
+}
+
+.page-toolbar {
+  align-items: center;
+  display: flex;
+  gap: 16px;
+  justify-content: space-between;
+}
+
+.toolbar-search {
+  width: 260px;
+}
+
+.toolbar-select {
+  min-width: 180px;
+}
+
+.table-link {
+  font-weight: 600;
+  padding: 0;
+}
+
+.domain-health-card .ant-card-body {
+  padding: 24px;
+}
+
+.domain-title-row {
+  align-items: flex-start;
+  display: flex;
+  gap: 16px;
+  justify-content: space-between;
+}
+
+.domain-title-row h2 {
+  margin: 4px 0 0 !important;
+  overflow-wrap: anywhere;
+}
+
+.health-progress {
+  display: grid;
+  gap: 8px;
+}
+
+.health-progress > div {
+  align-items: center;
+  display: flex;
+  justify-content: space-between;
+}
+
+.domain-action-panel {
+  background: #f8fafc;
+  border: 1px solid #e5eaf2;
+  border-radius: 12px;
+  display: grid;
+  gap: 12px;
+  padding: 16px;
+}
+
+.dns-record-card .ant-card-head-title {
+  min-width: 0;
+}
+
+.dns-value-row {
+  display: grid;
+  gap: 8px;
+  grid-template-columns: 72px minmax(0, 1fr) 40px;
+}
+
+.dns-code-block {
+  display: block;
+  margin: 0 !important;
+  max-width: 100%;
+  overflow-wrap: anywhere;
+  white-space: pre-wrap;
+  word-break: break-word;
+}
+
+.dns-empty-value {
+  margin: 6px 0 0 !important;
+}
+
+.value-stack {
+  margin-top: 8px;
+}
+
+.code-sample {
+  display: block;
+  max-width: 100%;
+  overflow-wrap: anywhere;
+  white-space: pre-wrap;
+}
+
+.drawer-footer {
+  align-items: center;
+  display: flex;
+  justify-content: space-between;
+}
+
+.confirm-list {
+  color: #475569;
+  margin: 0 0 16px;
+  padding-left: 18px;
+}
+
+.form-grid {
+  display: grid;
+  gap: 0 16px;
+}
+
+.form-grid.two {
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.auth-page {
+  background:
+    radial-gradient(circle at 8% 8%, rgba(22, 119, 255, 0.12), transparent 28%),
+    linear-gradient(135deg, #f5f7fb 0%, #eef4ff 48%, #f8fafc 100%);
+  display: grid;
+  grid-template-columns: minmax(360px, 0.95fr) minmax(420px, 1.05fr);
+  min-height: 100vh;
+}
+
+.auth-brand-panel {
+  background: #111827;
+  color: #fff;
+  display: grid;
+  gap: 32px;
+  grid-template-rows: 1fr auto;
+  padding: 56px;
+}
+
+.auth-brand-top {
+  align-content: center;
+  display: grid;
+  gap: 20px;
+  max-width: 520px;
+}
+
+.auth-brand-top h1 {
+  color: #fff;
+  font-size: 46px;
+  letter-spacing: 0;
+  line-height: 1.05;
+  margin: 0 0 10px !important;
+}
+
+.auth-brand-top .ant-typography {
+  color: rgba(255, 255, 255, 0.72);
+}
+
+.auth-logo {
+  height: 52px;
+  width: 52px;
+}
+
+.auth-signal-list {
+  display: grid;
+  gap: 12px;
+  max-width: 520px;
+}
+
+.auth-signal-item {
+  align-items: center;
+  background: rgba(255, 255, 255, 0.08);
+  border: 1px solid rgba(255, 255, 255, 0.12);
+  border-radius: 12px;
+  display: flex;
+  gap: 14px;
+  padding: 16px;
+}
+
+.auth-signal-item > span {
+  align-items: center;
+  background: rgba(22, 119, 255, 0.18);
+  border: 1px solid rgba(96, 165, 250, 0.34);
+  border-radius: 10px;
+  color: #93c5fd;
+  display: inline-flex;
+  font-size: 22px;
+  height: 42px;
+  justify-content: center;
+  width: 42px;
+}
+
+.auth-signal-item .ant-typography {
+  color: rgba(255, 255, 255, 0.58);
+  margin: 0 !important;
+}
+
+.auth-signal-item h5.ant-typography {
+  color: #fff;
+}
+
+.auth-form-panel {
+  align-content: center;
+  display: grid;
+  justify-items: center;
+  padding: 48px 28px;
+  position: relative;
+}
+
+.auth-language-row {
+  position: absolute;
+  right: 28px;
+  top: 24px;
+}
+
+.auth-card {
+  max-width: 456px;
+  width: 100%;
+}
+
+.auth-card .ant-card-body {
+  padding: 34px;
+}
+
+.auth-heading h2 {
+  margin: 6px 0 0 !important;
+}
+
+.auth-eyebrow {
+  color: #1677ff;
+  font-size: 12px;
+  font-weight: 700;
+  letter-spacing: 0.08em;
+  text-transform: uppercase;
+}
+
+@media (max-width: 900px) {
+  .admin-header,
+  .page-toolbar,
+  .domain-title-row,
+  .health-progress > div {
+    align-items: flex-start;
+    flex-direction: column;
+  }
+
+  .admin-header {
+    gap: 12px;
+    position: static;
+  }
+
+  .admin-content {
+    padding: 16px;
+  }
+
+  .toolbar-search,
+  .toolbar-select {
+    width: 100%;
+  }
+
+  .form-grid.two {
+    grid-template-columns: 1fr;
+  }
+
+  .dns-value-row {
+    grid-template-columns: 1fr;
+  }
+
+  .auth-page {
+    grid-template-columns: 1fr;
+  }
+
+  .auth-brand-panel {
+    gap: 22px;
+    grid-template-rows: auto auto;
+    padding: 34px 22px;
+  }
+
+  .auth-brand-top h1 {
+    font-size: 34px;
+  }
+
+  .auth-form-panel {
+    padding: 28px 16px 34px;
+  }
+
+  .auth-language-row {
+    justify-self: end;
+    margin-bottom: 14px;
+    position: static;
+  }
+
+  .auth-card .ant-card-body {
+    padding: 24px;
+  }
+}

+ 198 - 0
src/frontend/types.ts

@@ -0,0 +1,198 @@
+export type ViewKey =
+  | 'dashboard'
+  | 'domains'
+  | 'dns-api'
+  | 'smtp'
+  | 'tokens'
+  | 'logs'
+  | 'webhooks'
+  | 'settings';
+
+export type DomainMode = 'list' | 'detail';
+
+export interface User {
+  id: number;
+  username: string;
+  email: string;
+  role: 'admin' | 'user';
+  status: 'active' | 'disabled';
+}
+
+export interface RuntimeConfig {
+  appBaseUrl: string;
+  mailHostname: string;
+  sendingIp: string;
+  defaultSpfMechanisms: string;
+  dmarcPolicy: string;
+  dmarcRua: string;
+  sendRequiresVerified: boolean;
+  submission?: {
+    enabled: boolean;
+    host: string;
+    ports: Array<{ port: number; protocol: string }>;
+    username: string;
+    passwordSet: boolean;
+    tls: boolean;
+    requireTlsForAuth: boolean;
+  };
+  apiTokenSet?: boolean;
+  usingDefaultAdminPassword?: boolean;
+}
+
+export interface DnsRecord {
+  key: string;
+  label: string;
+  host: string;
+  type: string;
+  value?: string;
+  status?: string;
+  current?: string | string[];
+  warnings?: string[];
+  managed?: boolean;
+}
+
+export interface DomainStatus {
+  checkedAt?: string;
+  verified?: boolean;
+  records?: DnsRecord[];
+  optionalRecords?: DnsRecord[];
+  warnings?: string[];
+  live?: Record<string, string[]>;
+  apply?: {
+    ok: boolean;
+    results?: Array<{
+      key: string;
+      type: string;
+      host: string;
+      ok: boolean;
+      skipped?: boolean;
+      detail?: string;
+      error?: string;
+    }>;
+  };
+}
+
+export interface Domain {
+  id: number;
+  userId: number;
+  dnsCredentialId: number | null;
+  domain: string;
+  selector: string;
+  verificationToken: string;
+  dkimPublic: string;
+  senderHost: string;
+  sendingIp: string;
+  spfExtra: string;
+  dmarcPolicy: string;
+  dmarcRua: string;
+  status: DomainStatus;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface DnsCredential {
+  id: number;
+  name: string;
+  provider: 'cloudflare' | 'aliyun' | 'dnspod' | string;
+  zoneName: string;
+  defaultTtl: number;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface SmtpCredential {
+  id?: number;
+  username: string;
+  password?: string;
+  passwordSet?: boolean;
+  updatedAt?: string;
+}
+
+export interface ApiToken {
+  id: number;
+  name: string;
+  tokenPrefix: string;
+  token?: string;
+  lastUsedAt?: string;
+  createdAt: string;
+}
+
+export interface SendEvent {
+  id: number;
+  userId: number;
+  domainId: number | null;
+  domain?: string;
+  sender: string;
+  recipients: string[];
+  subject: string;
+  status: string;
+  detail: string;
+  createdAt: string;
+}
+
+export interface Analytics {
+  windowDays: number;
+  summary: {
+    total: number;
+    queued: number;
+    failed: number;
+    recipients: number;
+    today: number;
+    last7Days: number;
+    successRate: number;
+    domains: number;
+    verifiedDomains: number;
+  };
+  byDay: Array<{
+    day: string;
+    date?: string;
+    total: number;
+    queued: number;
+    failed: number;
+    recipients: number;
+  }>;
+  byDomain: Array<{
+    domain: string;
+    total: number;
+    queued: number;
+    failed: number;
+    recipients: number;
+  }>;
+  byStatus: Array<{ status: string; total: number }>;
+  hourly: Array<{ hour: number; total: number; queued: number; failed: number }>;
+  recentFailures: SendEvent[];
+}
+
+export interface AppData {
+  me: User | null;
+  config: RuntimeConfig | null;
+  domains: Domain[];
+  events: SendEvent[];
+  analytics: Analytics | null;
+  smtpCredential: SmtpCredential | null;
+  dnsCredentials: DnsCredential[];
+  apiTokens: ApiToken[];
+  settings: RuntimeConfig | null;
+  users: User[];
+}
+
+export interface AddDomainPayload {
+  domain: string;
+  senderHost?: string;
+  sendingIp?: string;
+  dnsCredentialId?: number | string;
+  selector?: string;
+  dmarcPolicy?: string;
+  spfExtra?: string;
+  immediateCheck?: boolean;
+}
+
+export interface DomainPatchPayload {
+  selector?: string;
+  dnsCredentialId?: number | string | null;
+  senderHost?: string;
+  sendingIp?: string;
+  spfExtra?: string;
+  dmarcPolicy?: string;
+  dmarcRua?: string;
+}

+ 121 - 0
src/layouts/AdminLayout.tsx

@@ -0,0 +1,121 @@
+import {
+  ApiOutlined,
+  AppstoreOutlined,
+  CloudServerOutlined,
+  DashboardOutlined,
+  GlobalOutlined,
+  KeyOutlined,
+  MailOutlined,
+  ReloadOutlined,
+  SendOutlined,
+  SettingOutlined,
+  UserOutlined
+} from '@ant-design/icons';
+import { Avatar, Breadcrumb, Button, Dropdown, Layout, Menu, Select, Space, Typography } from 'antd';
+import type { ReactNode } from 'react';
+
+import { useI18n } from '../frontend/i18n/react';
+import type { User, ViewKey } from '../frontend/types';
+
+const { Header, Sider, Content } = Layout;
+
+const navItems: Array<{ key: ViewKey; labelKey: string; icon: ReactNode }> = [
+  { key: 'dashboard', labelKey: 'nav.dashboard', icon: <DashboardOutlined /> },
+  { key: 'domains', labelKey: 'nav.domains', icon: <GlobalOutlined /> },
+  { key: 'dns-api', labelKey: 'nav.dnsApi', icon: <CloudServerOutlined /> },
+  { key: 'smtp', labelKey: 'nav.smtp', icon: <MailOutlined /> },
+  { key: 'tokens', labelKey: 'nav.tokens', icon: <KeyOutlined /> },
+  { key: 'logs', labelKey: 'nav.logs', icon: <SendOutlined /> },
+  { key: 'webhooks', labelKey: 'nav.webhooks', icon: <ApiOutlined /> },
+  { key: 'settings', labelKey: 'nav.settings', icon: <SettingOutlined /> }
+];
+
+interface AdminLayoutProps {
+  activeView: ViewKey;
+  breadcrumb: string[];
+  user: User | null;
+  runtimeLine: string;
+  loading: boolean;
+  children: ReactNode;
+  onViewChange: (view: ViewKey) => void;
+  onRefresh: () => void;
+  onAddDomain: () => void;
+  onLogout: () => void;
+}
+
+export function AdminLayout({
+  activeView,
+  breadcrumb,
+  user,
+  runtimeLine,
+  loading,
+  children,
+  onViewChange,
+  onRefresh,
+  onAddDomain,
+  onLogout
+}: AdminLayoutProps) {
+  const { locale, locales, setLocale, t } = useI18n();
+
+  return (
+    <Layout className="admin-layout">
+      <Sider breakpoint="lg" collapsedWidth={0} width={248} className="admin-sider">
+        <div className="brand">
+          <div className="brand-logo">MH</div>
+          <div>
+            <div className="brand-title">MailHub</div>
+            <div className="brand-subtitle">Email Delivery</div>
+          </div>
+        </div>
+        <Menu
+          theme="dark"
+          mode="inline"
+          selectedKeys={[activeView]}
+          items={navItems.map((item) => ({ key: item.key, icon: item.icon, label: t(item.labelKey) }))}
+          onClick={({ key }) => onViewChange(key as ViewKey)}
+        />
+      </Sider>
+      <Layout>
+        <Header className="admin-header">
+          <div className="header-title">
+            <Breadcrumb items={breadcrumb.map((title) => ({ title }))} />
+            <Typography.Text type="secondary" className="runtime-line">
+              {runtimeLine}
+            </Typography.Text>
+          </div>
+          <Space wrap>
+            <Select
+              aria-label="Language"
+              value={locale}
+              options={locales}
+              onChange={setLocale}
+              className="language-select"
+            />
+            <Button icon={<ReloadOutlined />} loading={loading} onClick={onRefresh}>
+              {t('common.refresh')}
+            </Button>
+            <Button type="primary" icon={<AppstoreOutlined />} onClick={onAddDomain}>
+              {t('common.addDomain')}
+            </Button>
+            <Dropdown
+              menu={{
+                items: [
+                  { key: 'profile', label: user?.email || user?.username || t('common.account'), disabled: true },
+                  { key: 'logout', label: t('common.logout'), onClick: onLogout }
+                ]
+              }}
+            >
+              <Button className="user-button">
+                <Space>
+                  <Avatar size={24} icon={<UserOutlined />} />
+                  <span>{user?.username || t('common.user')}</span>
+                </Space>
+              </Button>
+            </Dropdown>
+          </Space>
+        </Header>
+        <Content className="admin-content">{children}</Content>
+      </Layout>
+    </Layout>
+  );
+}

+ 104 - 0
src/pages/ApiTokens.tsx

@@ -0,0 +1,104 @@
+import { CopyOutlined, DeleteOutlined, KeyOutlined } from '@ant-design/icons';
+import { Alert, Button, Card, Form, Input, Modal, Popconfirm, Space, Table, Tag, Tooltip, Typography } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import { useState } from 'react';
+
+import { canCopyFullApiToken, formatApiTokenPrefix, getCreatedApiTokenSecret } from '../frontend/api-token-model.js';
+import { useI18n } from '../frontend/i18n/react';
+import type { ApiToken } from '../frontend/types';
+
+interface ApiTokensProps {
+  tokens: ApiToken[];
+  loading?: boolean;
+  onCreate: (name: string) => Promise<ApiToken | null | void>;
+  onDelete: (token: ApiToken) => void;
+  onCopy: (value: string) => void;
+}
+
+export default function ApiTokens({ tokens, loading, onCreate, onDelete, onCopy }: ApiTokensProps) {
+  const { t } = useI18n();
+  const [form] = Form.useForm<{ name: string }>();
+  const [createdToken, setCreatedToken] = useState<ApiToken | null>(null);
+
+  const columns: ColumnsType<ApiToken> = [
+    { title: t('tokens.name'), dataIndex: 'name' },
+    {
+      title: t('tokens.prefix'),
+      dataIndex: 'tokenPrefix',
+      render: (_, token) => <Typography.Text code>{formatApiTokenPrefix(token)}</Typography.Text>
+    },
+    { title: t('tokens.lastUsed'), dataIndex: 'lastUsedAt', render: (value) => value ? new Date(value).toLocaleString() : t('tokens.neverUsed') },
+    { title: t('tokens.createdAt'), dataIndex: 'createdAt', render: (value) => new Date(value).toLocaleString() },
+    {
+      title: t('tokens.actions'),
+      render: (_, token) => (
+        <Space>
+          <Tooltip title={t('tokens.copyPrefix')}>
+            <Button icon={<CopyOutlined />} onClick={() => onCopy(token.tokenPrefix)} />
+          </Tooltip>
+          <Tooltip title={t('tokens.secretUnavailable')}>
+            <Button disabled icon={<KeyOutlined />} />
+          </Tooltip>
+          <Popconfirm title={t('tokens.deleteConfirm')} onConfirm={() => onDelete(token)}>
+            <Button danger icon={<DeleteOutlined />} />
+          </Popconfirm>
+        </Space>
+      )
+    }
+  ];
+
+  async function submit(values: { name: string }) {
+    const token = await onCreate(values.name);
+    if (token && canCopyFullApiToken(token)) setCreatedToken(token);
+    form.resetFields();
+  }
+
+  return (
+    <>
+      <Space direction="vertical" size={16} className="full-width">
+      <Card title={t('tokens.createTitle')}>
+        <Form form={form} layout="inline" onFinish={submit}>
+          <Form.Item name="name" rules={[{ required: true, message: t('tokens.nameRequired') }]}>
+            <Input placeholder={t('tokens.namePlaceholder')} />
+          </Form.Item>
+          <Button type="primary" htmlType="submit" loading={loading}>
+            {t('tokens.create')}
+          </Button>
+        </Form>
+      </Card>
+      <Card title={t('tokens.listTitle')} extra={<Tag>{tokens.length}</Tag>}>
+        <Alert type="info" showIcon message={t('tokens.prefixOnlyHelp')} className="token-list-alert" />
+        <Table rowKey="id" columns={columns} dataSource={tokens} scroll={{ x: 900 }} />
+      </Card>
+    </Space>
+      <Modal
+        title={t('tokens.createdTitle')}
+        open={Boolean(createdToken)}
+        onCancel={() => setCreatedToken(null)}
+        footer={[
+          <Button key="close" onClick={() => setCreatedToken(null)}>
+            {t('common.cancel')}
+          </Button>,
+          <Button
+            key="copy"
+            type="primary"
+            icon={<CopyOutlined />}
+            onClick={() => {
+              const secret = getCreatedApiTokenSecret(createdToken || {});
+              if (secret) onCopy(secret);
+            }}
+          >
+            {t('tokens.copyCreated')}
+          </Button>
+        ]}
+      >
+        <Space direction="vertical" size={16} className="full-width">
+          <Alert type="warning" showIcon message={t('tokens.createdWarning')} />
+          <Typography.Paragraph code copyable className="code-sample">
+            {getCreatedApiTokenSecret(createdToken || {})}
+          </Typography.Paragraph>
+        </Space>
+      </Modal>
+    </>
+  );
+}

+ 223 - 0
src/pages/Dashboard.tsx

@@ -0,0 +1,223 @@
+import { Area, Bar, Column, Pie } from '@ant-design/plots';
+import { Alert, Card, Col, Empty, List, Row, Space, Table, Tag, Typography } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+
+import {
+  buildDashboardSummary,
+  buildDomainRanking,
+  buildHourlyHeatmap,
+  buildStatusDistribution,
+  buildTrendSeries
+} from '../frontend/analytics-model.js';
+import { buildDomainHealth } from '../frontend/domain-model.js';
+import { useI18n } from '../frontend/i18n/react';
+import type { Analytics, Domain, RuntimeConfig, SendEvent, SmtpCredential } from '../frontend/types';
+
+interface DashboardProps {
+  analytics: Analytics | null;
+  domains: Domain[];
+  events: SendEvent[];
+  config: RuntimeConfig | null;
+  smtpCredential: SmtpCredential | null;
+}
+
+export default function Dashboard({ analytics, domains, events, config, smtpCredential }: DashboardProps) {
+  const { t } = useI18n();
+  const summary = buildDashboardSummary({ analytics, domains, events, config, smtpCredential });
+  const trendSeries = buildTrendSeries(analytics);
+  const trendData = trendSeries.flatMap((item) => [
+    { date: item.date, type: t('metrics.total'), value: item.total },
+    { date: item.date, type: t('metrics.accepted'), value: item.accepted },
+    { date: item.date, type: t('metrics.failed'), value: item.failed }
+  ]);
+  const statusData = buildStatusDistribution(analytics).map((item) => ({
+    ...item,
+    label: statusLabel(item.status, t)
+  }));
+  const rankingData = buildDomainRanking(analytics);
+  const hourlyData = buildHourlyHeatmap(analytics);
+
+  const cards = [
+    { label: t('dashboard.verifiedDomains'), value: summary.verifiedDomains },
+    { label: t('dashboard.todaySent'), value: summary.today },
+    { label: t('dashboard.successRate'), value: `${summary.successRate}%` },
+    { label: t('dashboard.bounceRate'), value: `${summary.bounceRate}%` },
+    { label: t('dashboard.complaintRate'), value: `${summary.complaintRate}%` },
+    { label: t('dashboard.lastSentAt'), value: summary.lastSentAt ? new Date(summary.lastSentAt).toLocaleString() : t('common.notFound') },
+    { label: t('dashboard.dnsIssues'), value: summary.dnsIssues },
+    { label: t('dashboard.smtpStatus'), value: summary.smtpReady ? t('dashboard.smtpReady') : t('dashboard.smtpNotConfigured') }
+  ];
+
+  const columns: ColumnsType<SendEvent> = [
+    { title: 'Time', dataIndex: 'createdAt', render: (value) => new Date(value).toLocaleString() },
+    { title: 'Recipient', dataIndex: 'recipients', render: (value: string[]) => value.join(', ') },
+    { title: 'Domain', dataIndex: 'domain' },
+    { title: 'Subject', dataIndex: 'subject', ellipsis: true },
+    {
+      title: t('common.status'),
+      dataIndex: 'status',
+      render: (value) => <Tag color={value === 'queued' ? 'success' : 'error'}>{statusLabel(value, t)}</Tag>
+    }
+  ];
+
+  return (
+    <Space direction="vertical" size={20} className="full-width">
+      {config?.usingDefaultAdminPassword ? (
+        <Alert type="warning" showIcon message={t('dashboard.defaultPasswordWarning')} />
+      ) : null}
+      <Row gutter={[16, 16]}>
+        {cards.map((card) => (
+          <Col xs={24} sm={12} lg={6} key={card.label}>
+            <Card className="metric-card">
+              <Typography.Text type="secondary">{card.label}</Typography.Text>
+              <Typography.Title level={3} className="metric-value">
+                {card.value}
+              </Typography.Title>
+            </Card>
+          </Col>
+        ))}
+      </Row>
+      <Row gutter={[16, 16]}>
+        <Col xs={24} xl={15}>
+          <Card title={t('dashboard.trend')} className="chart-card">
+            {trendData.length ? (
+              <Area
+                data={trendData}
+                xField="date"
+                yField="value"
+                colorField="type"
+                shapeField="smooth"
+                height={316}
+                axis={{ y: { title: false }, x: { title: false } }}
+                scale={{ color: { range: ['#1677ff', '#52c41a', '#ff4d4f'] } }}
+                tooltip={{ title: 'date' }}
+                legend={{ color: { position: 'top' } }}
+              />
+            ) : (
+              <Empty description={t('dashboard.noTrend')} />
+            )}
+          </Card>
+        </Col>
+        <Col xs={24} xl={9}>
+          <Card title={t('dashboard.statusDistribution')} className="chart-card">
+            {statusData.length ? (
+              <Pie
+                data={statusData}
+                angleField="value"
+                colorField="label"
+                innerRadius={0.64}
+                height={316}
+                scale={{ color: { range: ['#52c41a', '#ff4d4f', '#faad14'] } }}
+                label={{ text: 'value', position: 'outside' }}
+                legend={{ color: { position: 'bottom' } }}
+              />
+            ) : (
+              <Empty description={t('dashboard.noTrend')} />
+            )}
+          </Card>
+        </Col>
+      </Row>
+      <Row gutter={[16, 16]}>
+        <Col xs={24} xl={12}>
+          <Card title={t('dashboard.domainRanking')} className="chart-card">
+            {rankingData.length ? (
+              <Bar
+                data={rankingData}
+                xField="total"
+                yField="domain"
+                height={312}
+                colorField="domain"
+                label={{ text: 'total', position: 'right' }}
+                axis={{ x: { title: false }, y: { title: false } }}
+                legend={false}
+              />
+            ) : (
+              <Empty description={t('dashboard.noDomains')} />
+            )}
+          </Card>
+        </Col>
+        <Col xs={24} xl={12}>
+          <Card title={t('dashboard.hourlyHeatmap')} className="chart-card">
+            {hourlyData.length ? (
+              <Column
+                data={hourlyData}
+                xField="hour"
+                yField="total"
+                height={312}
+                colorField="total"
+                scale={{ color: { range: ['#dbeafe', '#1677ff'] } }}
+                axis={{ x: { title: false }, y: { title: false } }}
+                tooltip={{ title: 'hour' }}
+                legend={false}
+              />
+            ) : (
+              <Empty description={t('dashboard.noTrend')} />
+            )}
+          </Card>
+        </Col>
+      </Row>
+      <Row gutter={[16, 16]}>
+        <Col xs={24} xl={9}>
+          <Card title={t('dashboard.recentFailures')}>
+            {analytics?.recentFailures?.length ? (
+              <List
+                dataSource={analytics.recentFailures}
+                renderItem={(item) => (
+                  <List.Item>
+                    <List.Item.Meta
+                      title={<Typography.Text ellipsis>{item.subject || item.domain || '-'}</Typography.Text>}
+                      description={
+                        <Space direction="vertical" size={2}>
+                          <Typography.Text type="secondary">{new Date(item.createdAt).toLocaleString()}</Typography.Text>
+                          <Typography.Text type="danger" ellipsis>{item.detail}</Typography.Text>
+                        </Space>
+                      }
+                    />
+                  </List.Item>
+                )}
+              />
+            ) : (
+              <Empty description={t('dashboard.noFailures')} />
+            )}
+          </Card>
+        </Col>
+        <Col xs={24} xl={15}>
+          <Card title={t('dashboard.domainHealth')}>
+            <Space direction="vertical" className="full-width">
+              {domains.slice(0, 6).map((domain) => {
+                const health = buildDomainHealth(domain);
+                return (
+                  <div className="health-row" key={domain.id}>
+                    <div>
+                      <Typography.Text strong>{domain.domain}</Typography.Text>
+                      <Typography.Text type="secondary">DNS {health.passed}/{health.total}</Typography.Text>
+                    </div>
+                    <Tag color={health.status === 'success' ? 'success' : health.status === 'warning' ? 'warning' : 'error'}>
+                      {domainHealthLabel(health.status, t)}
+                    </Tag>
+                  </div>
+                );
+              })}
+              {!domains.length ? <Empty description={t('dashboard.noDomains')} /> : null}
+            </Space>
+          </Card>
+        </Col>
+      </Row>
+      <Card title={t('dashboard.recentLogs')}>
+        <Table rowKey="id" columns={columns} dataSource={events.slice(0, 8)} pagination={false} scroll={{ x: 900 }} />
+      </Card>
+    </Space>
+  );
+}
+
+function statusLabel(status: string, t: (key: string) => string) {
+  if (status === 'queued') return t('dashboard.statusQueued');
+  if (status === 'failed') return t('dashboard.statusFailed');
+  return status || t('dashboard.statusUnknown');
+}
+
+function domainHealthLabel(status: string, t: (key: string) => string) {
+  if (status === 'success') return t('domains.healthy');
+  if (status === 'warning') return t('domains.waitingDns');
+  return t('domains.needsAction');
+}

+ 143 - 0
src/pages/DnsApi.tsx

@@ -0,0 +1,143 @@
+import { DeleteOutlined, EditOutlined, ThunderboltOutlined } from '@ant-design/icons';
+import { Button, Card, Form, Input, InputNumber, Popconfirm, Select, Space, Table, Tag, Typography } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import { useState } from 'react';
+
+import { useI18n } from '../frontend/i18n/react';
+import type { DnsCredential } from '../frontend/types';
+
+interface DnsApiProps {
+  credentials: DnsCredential[];
+  loading?: boolean;
+  onSave: (values: Record<string, unknown>, id?: number) => Promise<void>;
+  onTest: (credential: DnsCredential) => void;
+  onDelete: (credential: DnsCredential) => void;
+}
+
+export default function DnsApi({ credentials, loading, onSave, onTest, onDelete }: DnsApiProps) {
+  const { t } = useI18n();
+  const [form] = Form.useForm();
+  const [editing, setEditing] = useState<DnsCredential | null>(null);
+  const provider = Form.useWatch('provider', form) || 'cloudflare';
+
+  function edit(credential: DnsCredential) {
+    setEditing(credential);
+    form.setFieldsValue(credential);
+  }
+
+  async function submit(values: Record<string, unknown>) {
+    await onSave(values, editing?.id);
+    setEditing(null);
+    form.resetFields();
+    form.setFieldValue('provider', 'cloudflare');
+    form.setFieldValue('defaultTtl', 600);
+  }
+
+  const columns: ColumnsType<DnsCredential> = [
+    { title: t('tokens.name'), dataIndex: 'name' },
+    { title: 'Provider', dataIndex: 'provider', render: providerLabel },
+    { title: t('dnsApi.zone'), dataIndex: 'zoneName' },
+    { title: 'TTL', dataIndex: 'defaultTtl' },
+    { title: t('tokens.createdAt'), dataIndex: 'updatedAt', render: (value) => new Date(value).toLocaleString() },
+    {
+      title: t('domains.actions'),
+      render: (_, credential) => (
+        <Space wrap>
+          <Button icon={<ThunderboltOutlined />} onClick={() => onTest(credential)}>
+            {t('domains.test')}
+          </Button>
+          <Button icon={<EditOutlined />} onClick={() => edit(credential)}>
+            {t('dnsApi.editTitle')}
+          </Button>
+          <Popconfirm title={t('tokens.deleteConfirm')} onConfirm={() => onDelete(credential)}>
+            <Button danger icon={<DeleteOutlined />} />
+          </Popconfirm>
+        </Space>
+      )
+    }
+  ];
+
+  return (
+    <Space direction="vertical" size={16} className="full-width">
+      <Card title={t('dnsApi.title')} extra={<Tag>{credentials.length}</Tag>}>
+        <Table rowKey="id" columns={columns} dataSource={credentials} scroll={{ x: 900 }} />
+      </Card>
+      <Card
+        title={editing ? `${t('dnsApi.editTitle')} ${editing.name}` : t('dnsApi.createTitle')}
+        extra={editing ? <Button onClick={() => { setEditing(null); form.resetFields(); }}>{t('common.cancel')}</Button> : null}
+      >
+        <Form
+          form={form}
+          layout="vertical"
+          onFinish={submit}
+          initialValues={{ provider: 'cloudflare', defaultTtl: 600 }}
+        >
+          <div className="form-grid two">
+            <Form.Item name="name" label={t('tokens.name')} rules={[{ required: true, message: t('tokens.nameRequired') }]}>
+              <Input placeholder="Primary Cloudflare" />
+            </Form.Item>
+            <Form.Item name="provider" label="Provider" rules={[{ required: true }]}>
+              <Select
+                options={[
+                  { value: 'cloudflare', label: 'Cloudflare' },
+                  { value: 'aliyun', label: 'Aliyun DNS' },
+                  { value: 'dnspod', label: 'Tencent DNSPod' }
+                ]}
+              />
+            </Form.Item>
+            <Form.Item name="zoneName" label={t('dnsApi.zone')} rules={[{ required: true, message: t('dnsApi.zoneRequired') }]}>
+              <Input placeholder="example.com" />
+            </Form.Item>
+            <Form.Item name="defaultTtl" label="TTL">
+              <InputNumber min={60} max={86400} className="full-width" />
+            </Form.Item>
+          </div>
+          {provider === 'cloudflare' ? (
+            <div className="form-grid two">
+              <Form.Item name="apiToken" label="Cloudflare API Token" extra={editing ? t('dnsApi.keepSecret') : t('dnsApi.tokenExtra')}>
+                <Input.Password autoComplete="off" />
+              </Form.Item>
+              <Form.Item name="zoneId" label="Cloudflare Zone ID">
+                <Input autoComplete="off" />
+              </Form.Item>
+            </div>
+          ) : null}
+          {provider === 'aliyun' ? (
+            <div className="form-grid two">
+              <Form.Item name="accessKeyId" label="AccessKeyId" extra={editing ? t('dnsApi.keepSecret') : undefined}>
+                <Input autoComplete="off" />
+              </Form.Item>
+              <Form.Item name="accessKeySecret" label="AccessKeySecret">
+                <Input.Password autoComplete="off" />
+              </Form.Item>
+            </div>
+          ) : null}
+          {provider === 'dnspod' ? (
+            <div className="form-grid two">
+              <Form.Item name="secretId" label="SecretId" extra={editing ? t('dnsApi.keepSecret') : undefined}>
+                <Input autoComplete="off" />
+              </Form.Item>
+              <Form.Item name="secretKey" label="SecretKey">
+                <Input.Password autoComplete="off" />
+              </Form.Item>
+            </div>
+          ) : null}
+          <Space>
+            <Button type="primary" htmlType="submit" loading={loading}>
+              {editing ? t('dnsApi.save') : t('dnsApi.create')}
+            </Button>
+            <Typography.Text type="secondary">{t('dnsApi.secretHint')}</Typography.Text>
+          </Space>
+        </Form>
+      </Card>
+    </Space>
+  );
+}
+
+function providerLabel(provider: string) {
+  return {
+    cloudflare: <Tag color="blue">Cloudflare</Tag>,
+    aliyun: <Tag color="orange">Aliyun DNS</Tag>,
+    dnspod: <Tag color="cyan">Tencent DNSPod</Tag>
+  }[provider] || <Tag>{provider}</Tag>;
+}

+ 411 - 0
src/pages/Domains/DomainDetail.tsx

@@ -0,0 +1,411 @@
+import { ArrowLeftOutlined, CopyOutlined } from '@ant-design/icons';
+import {
+  Alert,
+  Button,
+  Card,
+  Col,
+  Collapse,
+  Descriptions,
+  Empty,
+  Form,
+  Input,
+  Modal,
+  Row,
+  Select,
+  Space,
+  Table,
+  Tabs,
+  Tag,
+  Typography
+} from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import { useMemo, useState } from 'react';
+
+import { DomainHealthCard } from '../../components/domain/DomainHealthCard';
+import { DnsRecordCard } from '../../components/domain/DnsRecordCard';
+import { getDnsRecordOrder } from '../../frontend/domain-model.js';
+import { useI18n } from '../../frontend/i18n/react';
+import type {
+  ApiToken,
+  DnsCredential,
+  DnsRecord,
+  Domain,
+  DomainPatchPayload,
+  RuntimeConfig,
+  SendEvent,
+  SmtpCredential
+} from '../../frontend/types';
+
+interface DomainDetailProps {
+  domain: Domain;
+  config: RuntimeConfig | null;
+  smtpCredential: SmtpCredential | null;
+  apiTokens: ApiToken[];
+  events: SendEvent[];
+  dnsCredentials: DnsCredential[];
+  actionLoading?: boolean;
+  initialTab?: string;
+  onBack: () => void;
+  onApplyDns: (domain: Domain) => void;
+  onCheck: (domain: Domain) => void;
+  onSendTest: (domain: Domain) => void;
+  onPatchDomain: (domain: Domain, values: DomainPatchPayload) => Promise<void>;
+  onCopy: (value: string) => void;
+  onDelete: (domain: Domain) => void;
+}
+
+export default function DomainDetail({
+  domain,
+  config,
+  smtpCredential,
+  apiTokens,
+  events,
+  dnsCredentials,
+  actionLoading,
+  initialTab,
+  onBack,
+  onApplyDns,
+  onCheck,
+  onSendTest,
+  onPatchDomain,
+  onCopy,
+  onDelete
+}: DomainDetailProps) {
+  const { t } = useI18n();
+  const [activeTab, setActiveTab] = useState(initialTab || 'overview');
+  const [editOpen, setEditOpen] = useState(false);
+  const [form] = Form.useForm<DomainPatchPayload>();
+  const dnsApiName = dnsCredentials.find((item) => item.id === domain.dnsCredentialId)?.name;
+  const domainEvents = events.filter((event) => event.domain === domain.domain);
+  const records = useMemo(() => orderedRecords(domain.status?.records || []), [domain.status?.records]);
+
+  function openEdit() {
+    form.setFieldsValue({
+      selector: domain.selector,
+      dnsCredentialId: domain.dnsCredentialId,
+      senderHost: domain.senderHost,
+      sendingIp: domain.sendingIp,
+      spfExtra: domain.spfExtra,
+      dmarcPolicy: domain.dmarcPolicy,
+      dmarcRua: domain.dmarcRua
+    });
+    setEditOpen(true);
+  }
+
+  async function saveEdit() {
+    const values = await form.validateFields();
+    await onPatchDomain(domain, values);
+    setEditOpen(false);
+  }
+
+  return (
+    <Space direction="vertical" size={16} className="full-width">
+      <Button icon={<ArrowLeftOutlined />} onClick={onBack}>
+        {t('domainDetail.back')}
+      </Button>
+      <DomainHealthCard
+        domain={domain}
+        lastSentAt={domainEvents[0] ? new Date(domainEvents[0].createdAt).toLocaleString() : undefined}
+        dnsApiName={dnsApiName}
+        loading={actionLoading}
+        onApplyDns={() => onApplyDns(domain)}
+        onCheck={() => onCheck(domain)}
+        onSendTest={() => onSendTest(domain)}
+        onEdit={openEdit}
+      />
+      <Tabs
+        activeKey={activeTab}
+        onChange={setActiveTab}
+        items={[
+          { key: 'overview', label: 'Overview', children: <OverviewTab domain={domain} events={domainEvents} onDelete={() => onDelete(domain)} /> },
+          {
+            key: 'dns',
+            label: 'DNS Records',
+            children: (
+              <DnsRecordsTab
+                domain={domain}
+                records={records}
+                loading={actionLoading}
+                onCopy={onCopy}
+                onCheck={() => onCheck(domain)}
+              />
+            )
+          },
+          {
+            key: 'smtp-api',
+            label: 'SMTP / API',
+            children: (
+              <SmtpApiTab
+                domain={domain}
+                config={config}
+                smtpCredential={smtpCredential}
+                apiTokens={apiTokens}
+                onCopy={onCopy}
+              />
+            )
+          },
+          { key: 'logs', label: 'Sending Logs', children: <SendingLogsTab events={domainEvents} /> },
+          { key: 'guide', label: 'Integration Guide', children: <IntegrationGuideTab domain={domain} config={config} apiTokens={apiTokens} /> },
+          { key: 'webhooks', label: 'Webhooks', children: <Placeholder title="Webhooks" /> }
+        ]}
+      />
+      <Modal title={t('domainDetail.editTitle')} open={editOpen} onCancel={() => setEditOpen(false)} onOk={saveEdit} confirmLoading={actionLoading}>
+        <Form form={form} layout="vertical">
+          <Form.Item name="dnsCredentialId" label={t('domains.dnsApi')}>
+            <Select
+              allowClear
+              placeholder={t('addDomain.manualDns')}
+              options={dnsCredentials.map((credential) => ({
+                value: credential.id,
+                label: credential.name
+              }))}
+            />
+          </Form.Item>
+          <Form.Item name="selector" label="DKIM selector" rules={[{ required: true, message: t('addDomain.selectorRequired') }]}>
+            <Input />
+          </Form.Item>
+          <Form.Item name="senderHost" label={t('domains.senderHost')} rules={[{ required: true, message: t('addDomain.senderHostRequired') }]}>
+            <Input />
+          </Form.Item>
+          <Form.Item name="sendingIp" label={t('domains.sendingIp')} rules={[{ required: true, message: t('addDomain.sendingIpRequired') }]}>
+            <Input />
+          </Form.Item>
+          <Form.Item name="dmarcPolicy" label="DMARC">
+            <Select options={['none', 'quarantine', 'reject'].map((value) => ({ value, label: value }))} />
+          </Form.Item>
+          <Form.Item name="spfExtra" label={t('addDomain.spfExtra')}>
+            <Input.TextArea rows={3} />
+          </Form.Item>
+          <Form.Item name="dmarcRua" label="DMARC rua">
+            <Input placeholder="mailto:dmarc@example.com" />
+          </Form.Item>
+        </Form>
+      </Modal>
+    </Space>
+  );
+}
+
+function OverviewTab({ domain, events, onDelete }: { domain: Domain; events: SendEvent[]; onDelete: () => void }) {
+  const { t } = useI18n();
+  return (
+    <Row gutter={[16, 16]}>
+      <Col xs={24} lg={16}>
+        <Card title={t('domainDetail.overview')}>
+          <Descriptions column={1}>
+            <Descriptions.Item label={t('domains.domain')}>{domain.domain}</Descriptions.Item>
+            <Descriptions.Item label={t('domains.senderHost')}>{domain.senderHost}</Descriptions.Item>
+            <Descriptions.Item label={t('domains.sendingIp')}>{domain.sendingIp}</Descriptions.Item>
+            <Descriptions.Item label="DKIM selector">{domain.selector}</Descriptions.Item>
+            <Descriptions.Item label={t('domains.lastSent')}>{events[0] ? new Date(events[0].createdAt).toLocaleString() : t('common.notFound')}</Descriptions.Item>
+          </Descriptions>
+        </Card>
+      </Col>
+      <Col xs={24} lg={8}>
+        <Card title={t('domainDetail.danger')}>
+          <Space direction="vertical" className="full-width">
+            <Typography.Text type="secondary">{t('domainDetail.deleteHint')}</Typography.Text>
+            <Button danger block onClick={onDelete}>
+              {t('common.delete')}
+            </Button>
+          </Space>
+        </Card>
+      </Col>
+    </Row>
+  );
+}
+
+function DnsRecordsTab({
+  domain,
+  records,
+  loading,
+  onCopy,
+  onCheck
+}: {
+  domain: Domain;
+  records: DnsRecord[];
+  loading?: boolean;
+  onCopy: (value: string) => void;
+  onCheck: () => void;
+}) {
+  const { t } = useI18n();
+  const liveEntries = Object.entries(domain.status?.live || {});
+  const copyAll = records.map((record) => `${record.host}\t${record.type}\t${record.value || ''}`).join('\n');
+
+  return (
+    <Row gutter={[16, 16]} align="top">
+      <Col xs={24} xl={15}>
+        <Space direction="vertical" size={12} className="full-width">
+          {records.length ? records.map((record) => (
+              <DnsRecordCard key={record.key} record={record} loading={loading} onCopy={onCopy} onRecheck={onCheck} />
+          )) : (
+            <Card>
+              <Empty description={t('domainDetail.noDnsResult')}>
+                <Button type="primary" onClick={onCheck}>{t('domainHealth.checkNow')}</Button>
+              </Empty>
+            </Card>
+          )}
+        </Space>
+      </Col>
+      <Col xs={24} xl={9}>
+        <Card
+          title={t('domainDetail.currentDnsResult')}
+          extra={
+            <Space>
+              <Button size="small" onClick={onCheck}>{t('domainDetail.recheckAll')}</Button>
+              <Button size="small" icon={<CopyOutlined />} disabled={!copyAll} onClick={() => onCopy(copyAll)}>
+                {t('domainDetail.copyAll')}
+              </Button>
+            </Space>
+          }
+        >
+          <Space direction="vertical" size={16} className="full-width">
+            <Tag>{t('domainDetail.lastCheck')}:{domain.status?.checkedAt ? new Date(domain.status.checkedAt).toLocaleString() : t('domainDetail.notChecked')}</Tag>
+            {liveEntries.length ? (
+              <Collapse
+                items={liveEntries.map(([key, values]) => ({
+                  key,
+                  label: liveLabel(key, t),
+                  children: values.length ? (
+                    <Space direction="vertical" className="full-width">
+                      {values.map((value) => (
+                        <Typography.Paragraph key={value} code copyable className="dns-code-block">
+                          {value}
+                        </Typography.Paragraph>
+                      ))}
+                    </Space>
+                  ) : (
+                    <Typography.Text type="secondary">{t('domainDetail.notFoundRecord')}</Typography.Text>
+                  )
+                }))}
+              />
+            ) : (
+              <Empty description={t('domainDetail.noPublicDns')} />
+            )}
+            {domain.status?.warnings?.length ? (
+              <Alert type="warning" showIcon message={t('domainDetail.needAttention')} description={domain.status.warnings.join('\n')} />
+            ) : null}
+          </Space>
+        </Card>
+      </Col>
+    </Row>
+  );
+}
+
+function SmtpApiTab({
+  domain,
+  config,
+  smtpCredential,
+  apiTokens,
+  onCopy
+}: {
+  domain: Domain;
+  config: RuntimeConfig | null;
+  smtpCredential: SmtpCredential | null;
+  apiTokens: ApiToken[];
+  onCopy: (value: string) => void;
+}) {
+  const { t } = useI18n();
+  const apiEndpoint = `${config?.appBaseUrl || window.location.origin}/api/send`;
+  return (
+    <Row gutter={[16, 16]}>
+      <Col xs={24} lg={12}>
+        <Card title="SMTP">
+          <Descriptions column={1}>
+            <Descriptions.Item label="SMTP Host">{copyable(config?.submission?.host || '-', onCopy)}</Descriptions.Item>
+            <Descriptions.Item label="SMTP Port">
+              {(config?.submission?.ports || []).map((item) => <Tag key={item.port}>{item.port} · {item.protocol}</Tag>)}
+            </Descriptions.Item>
+            <Descriptions.Item label="Username">{copyable(smtpCredential?.username || config?.submission?.username || '-', onCopy)}</Descriptions.Item>
+            <Descriptions.Item label="Password">{smtpCredential?.password ? copyable(smtpCredential.password, onCopy) : t('domainDetail.noSmtpPassword')}</Descriptions.Item>
+            <Descriptions.Item label="TLS / SSL">{config?.submission?.tls ? 'TLS' : 'STARTTLS'}</Descriptions.Item>
+          </Descriptions>
+        </Card>
+      </Col>
+      <Col xs={24} lg={12}>
+        <Card title="API">
+          <Descriptions column={1}>
+            <Descriptions.Item label="API Endpoint">{copyable(apiEndpoint, onCopy)}</Descriptions.Item>
+            <Descriptions.Item label="API Token">{apiTokens[0] ? `${apiTokens[0].tokenPrefix}...` : t('domainDetail.noApiToken')}</Descriptions.Item>
+            <Descriptions.Item label="From">noreply@{domain.domain}</Descriptions.Item>
+          </Descriptions>
+        </Card>
+      </Col>
+    </Row>
+  );
+}
+
+function SendingLogsTab({ events }: { events: SendEvent[] }) {
+  const { t } = useI18n();
+  const columns: ColumnsType<SendEvent> = [
+    { title: t('logs.time'), dataIndex: 'createdAt', render: (value) => new Date(value).toLocaleString() },
+    { title: t('logs.recipient'), dataIndex: 'recipients', render: (value: string[]) => value.join(', ') },
+    { title: 'Subject', dataIndex: 'subject', ellipsis: true },
+    { title: t('common.status'), dataIndex: 'status', render: (value) => <Tag color={value === 'queued' ? 'success' : 'error'}>{value}</Tag> },
+    { title: t('logs.errorReason'), dataIndex: 'detail', ellipsis: true }
+  ];
+  return <Table rowKey="id" columns={columns} dataSource={events} scroll={{ x: 900 }} />;
+}
+
+function IntegrationGuideTab({
+  domain,
+  config,
+  apiTokens
+}: {
+  domain: Domain;
+  config: RuntimeConfig | null;
+  apiTokens: ApiToken[];
+}) {
+  const { t } = useI18n();
+  const token = apiTokens[0] ? `${apiTokens[0].tokenPrefix}...` : '<USER_API_TOKEN>';
+  const endpoint = `${config?.appBaseUrl || window.location.origin}/api/send`;
+  const code = `curl -X POST ${endpoint} \\
+  -H 'Authorization: Bearer ${token}' \\
+  -H 'Content-Type: application/json' \\
+  -d '{
+    "from": "noreply@${domain.domain}",
+    "to": "user@example.com",
+    "subject": "Hello from MailHub",
+    "text": "Signed with DKIM and queued by MailHub."
+  }'`;
+  return (
+    <Card title={t('domainDetail.apiExample')}>
+      <Typography.Paragraph code copyable className="code-sample">
+        {code}
+      </Typography.Paragraph>
+    </Card>
+  );
+}
+
+function Placeholder({ title }: { title: string }) {
+  const { t } = useI18n();
+  return (
+    <Card>
+      <Empty description={`${title} ${t('domainDetail.placeholder')}`} />
+    </Card>
+  );
+}
+
+function orderedRecords(records: DnsRecord[]) {
+  const order = getDnsRecordOrder();
+  return [...records].sort((a, b) => order.indexOf(a.key) - order.indexOf(b.key));
+}
+
+function liveLabel(key: string, t: (key: string) => string) {
+  return {
+    rootTxt: t('dnsRecord.rootTxt'),
+    verificationTxt: t('dnsRecord.verificationTxt'),
+    dkimTxt: t('dnsRecord.dkimTxt'),
+    dmarcTxt: t('dnsRecord.dmarcTxt'),
+    senderA: t('dnsRecord.senderA'),
+    ptr: t('dnsRecord.ptr')
+  }[key] || key;
+}
+
+function copyable(value: string, onCopy: (value: string) => void) {
+  return (
+    <Space>
+      <Typography.Text code>{value}</Typography.Text>
+      <Button size="small" icon={<CopyOutlined />} onClick={() => onCopy(value)} />
+    </Space>
+  );
+}

+ 180 - 0
src/pages/Domains/index.tsx

@@ -0,0 +1,180 @@
+import { DeleteOutlined, EyeOutlined, SearchOutlined } from '@ant-design/icons';
+import { Button, Card, Input, Popconfirm, Select, Space, Table, Tag, Typography } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import { useMemo, useState } from 'react';
+
+import { buildDomainHealth } from '../../frontend/domain-model.js';
+import { useI18n } from '../../frontend/i18n/react';
+import type { DnsCredential, Domain, SendEvent } from '../../frontend/types';
+import { StatusTag } from '../../components/common/StatusTag';
+
+interface DomainsPageProps {
+  domains: Domain[];
+  events: SendEvent[];
+  dnsCredentials: DnsCredential[];
+  actionLoading?: boolean;
+  onViewDetail: (domain: Domain) => void;
+  onApplyDns: (domain: Domain) => void;
+  onCheck: (domain: Domain) => void;
+  onSendTest: (domain: Domain) => void;
+  onDelete: (domain: Domain) => void;
+  onAddDomain: () => void;
+}
+
+export default function DomainsPage({
+  domains,
+  events,
+  dnsCredentials,
+  actionLoading,
+  onViewDetail,
+  onApplyDns,
+  onCheck,
+  onSendTest,
+  onDelete,
+  onAddDomain
+}: DomainsPageProps) {
+  const { t } = useI18n();
+  const [query, setQuery] = useState('');
+  const [status, setStatus] = useState<string>();
+  const credentialName = new Map(dnsCredentials.map((item) => [item.id, item.name]));
+
+  const filtered = useMemo(() => {
+    return domains.filter((domain) => {
+      const health = buildDomainHealth(domain);
+      const matchesQuery = !query || domain.domain.includes(query) || domain.senderHost.includes(query);
+      const matchesStatus = !status || health.status === status;
+      return matchesQuery && matchesStatus;
+    });
+  }, [domains, query, status]);
+
+  const columns: ColumnsType<Domain> = [
+    {
+      title: t('domains.domain'),
+      dataIndex: 'domain',
+      fixed: 'left',
+      width: 190,
+      render: (value, domain) => (
+        <Button type="link" className="table-link" onClick={() => onViewDetail(domain)}>
+          {value}
+        </Button>
+      )
+    },
+    { title: t('domains.senderHost'), dataIndex: 'senderHost', width: 190, ellipsis: true },
+    { title: t('domains.sendingIp'), dataIndex: 'sendingIp', width: 140 },
+    {
+      title: t('domains.dnsApi'),
+      dataIndex: 'dnsCredentialId',
+      width: 150,
+      render: (value: number | null) => value ? <Tag>{credentialName.get(value) || value}</Tag> : <Tag>{t('common.manual')}</Tag>
+    },
+    recordColumn('DKIM', 'dkim'),
+    recordColumn('SPF', 'spf'),
+    recordColumn('DMARC', 'dmarc'),
+    recordColumn('PTR', 'ptr'),
+    {
+      title: t('domains.smtp'),
+      width: 110,
+      render: (_, domain) => <Tag color={domain.status?.verified ? 'success' : 'warning'}>{domain.status?.verified ? t('domains.sendable') : t('domains.waitingVerify')}</Tag>
+    },
+    {
+      title: t('domains.lastSent'),
+      width: 180,
+      render: (_, domain) => {
+        const event = events.find((item) => item.domain === domain.domain);
+        return event ? new Date(event.createdAt).toLocaleString() : t('common.notFound');
+      }
+    },
+    {
+      title: t('domains.overallStatus'),
+      width: 130,
+      render: (_, domain) => {
+        const health = buildDomainHealth(domain);
+        return <Tag color={health.status === 'success' ? 'success' : health.status === 'warning' ? 'warning' : 'error'}>{domainHealthLabel(health.status, t)}</Tag>;
+      }
+    },
+    {
+      title: t('domains.actions'),
+      width: 360,
+      fixed: 'right',
+      render: (_, domain) => (
+        <Space size={8} wrap>
+          <Button icon={<EyeOutlined />} onClick={() => onViewDetail(domain)}>
+            {t('common.details')}
+          </Button>
+          <Button type="primary" disabled={!domain.dnsCredentialId} loading={actionLoading} onClick={() => onApplyDns(domain)}>
+            {t('domains.oneClickDns')}
+          </Button>
+          <Button loading={actionLoading} onClick={() => onCheck(domain)}>
+            {t('domains.check')}
+          </Button>
+          <Button onClick={() => onSendTest(domain)}>{t('domains.test')}</Button>
+          <Popconfirm title={t('domains.deleteConfirm')} onConfirm={() => onDelete(domain)}>
+            <Button danger icon={<DeleteOutlined />} />
+          </Popconfirm>
+        </Space>
+      )
+    }
+  ];
+
+  return (
+    <Space direction="vertical" size={16} className="full-width">
+      <Card>
+        <div className="page-toolbar">
+          <Space wrap>
+            <Input
+              allowClear
+              prefix={<SearchOutlined />}
+              placeholder={t('domains.searchPlaceholder')}
+              value={query}
+              onChange={(event) => setQuery(event.target.value)}
+              className="toolbar-search"
+            />
+            <Select
+              allowClear
+              placeholder={t('domains.statusPlaceholder')}
+              value={status}
+              onChange={setStatus}
+              options={[
+                { value: 'success', label: t('domains.healthy') },
+                { value: 'warning', label: t('domains.pending') },
+                { value: 'error', label: t('domains.needsAction') }
+              ]}
+              className="toolbar-select"
+            />
+          </Space>
+          <Button type="primary" onClick={onAddDomain}>
+            {t('common.addDomain')}
+          </Button>
+        </div>
+      </Card>
+      <Card
+        title={t('domains.title')}
+        extra={
+          <Typography.Text type="secondary">
+            {filtered.length} / {domains.length}
+          </Typography.Text>
+        }
+      >
+        <Table rowKey="id" columns={columns} dataSource={filtered} scroll={{ x: 1800 }} />
+      </Card>
+    </Space>
+  );
+}
+
+function recordColumn(title: string, key: string): ColumnsType<Domain>[number] {
+  return {
+    title,
+    width: 110,
+    render: (_, domain) => {
+      const record = domain.status?.records?.find((item) => item.key === key);
+      if (!record) return <StatusTag status="missing" />;
+      return <StatusTag record={record} />;
+    }
+  };
+}
+
+function domainHealthLabel(status: string, t: (key: string) => string) {
+  if (status === 'success') return t('domains.healthy');
+  if (status === 'warning') return t('domains.waitingDns');
+  return t('domains.needsAction');
+}

+ 12 - 0
src/pages/PlaceholderPage.tsx

@@ -0,0 +1,12 @@
+import { Card, Empty } from 'antd';
+
+import { useI18n } from '../frontend/i18n/react';
+
+export default function PlaceholderPage({ title }: { title: string }) {
+  const { t } = useI18n();
+  return (
+    <Card>
+      <Empty description={`${title} ${t('domainDetail.placeholder')}`} />
+    </Card>
+  );
+}

+ 92 - 0
src/pages/SendingLogs.tsx

@@ -0,0 +1,92 @@
+import { SearchOutlined } from '@ant-design/icons';
+import { Button, Card, DatePicker, Input, Select, Space, Table, Tag } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import { useMemo, useState } from 'react';
+
+import { useI18n } from '../frontend/i18n/react';
+import type { Domain, SendEvent } from '../frontend/types';
+
+const { RangePicker } = DatePicker;
+
+interface SendingLogsProps {
+  events: SendEvent[];
+  domains: Domain[];
+}
+
+export default function SendingLogs({ events, domains }: SendingLogsProps) {
+  const { t } = useI18n();
+  const [domain, setDomain] = useState<string>();
+  const [status, setStatus] = useState<string>();
+  const [recipient, setRecipient] = useState('');
+  const [range, setRange] = useState<[number, number] | null>(null);
+
+  const filtered = useMemo(() => {
+    return events.filter((event) => {
+      const time = new Date(event.createdAt).getTime();
+      const matchesRange = !range || (time >= range[0] && time <= range[1]);
+      const matchesDomain = !domain || event.domain === domain;
+      const matchesStatus = !status || event.status === status;
+      const matchesRecipient = !recipient || event.recipients.join(',').includes(recipient);
+      return matchesRange && matchesDomain && matchesStatus && matchesRecipient;
+    });
+  }, [domain, events, range, recipient, status]);
+
+  const columns: ColumnsType<SendEvent> = [
+    { title: t('logs.time'), dataIndex: 'createdAt', render: (value) => new Date(value).toLocaleString(), width: 190 },
+    { title: t('logs.recipient'), dataIndex: 'recipients', render: (value: string[]) => value.join(', '), ellipsis: true },
+    { title: t('logs.domain'), dataIndex: 'domain', width: 180 },
+    { title: 'Subject', dataIndex: 'subject', ellipsis: true },
+    { title: t('common.status'), dataIndex: 'status', render: (value) => <Tag color={value === 'queued' ? 'success' : 'error'}>{value}</Tag>, width: 110 },
+    { title: 'Message ID', dataIndex: 'id', render: (value) => <span>mh-{value}</span>, width: 140 },
+    { title: t('logs.errorReason'), dataIndex: 'detail', ellipsis: true },
+    { title: t('domains.actions'), render: () => <Button>{t('logs.viewDetail')}</Button>, width: 120 }
+  ];
+
+  return (
+    <Space direction="vertical" size={16} className="full-width">
+      <Card>
+        <div className="page-toolbar">
+          <Space wrap>
+            <RangePicker
+              showTime
+              onChange={(value) => {
+                if (!value?.[0] || !value?.[1]) return setRange(null);
+                setRange([value[0].valueOf(), value[1].valueOf()]);
+              }}
+            />
+            <Select
+              allowClear
+              placeholder={t('logs.domainPlaceholder')}
+              value={domain}
+              onChange={setDomain}
+              options={domains.map((item) => ({ value: item.domain, label: item.domain }))}
+              className="toolbar-select"
+            />
+            <Select
+              allowClear
+              placeholder={t('logs.statusPlaceholder')}
+              value={status}
+              onChange={setStatus}
+              options={[
+                { value: 'queued', label: 'queued' },
+                { value: 'failed', label: 'failed' }
+              ]}
+              className="toolbar-select"
+            />
+            <Input
+              allowClear
+              prefix={<SearchOutlined />}
+              placeholder={t('logs.recipientPlaceholder')}
+              value={recipient}
+              onChange={(event) => setRecipient(event.target.value)}
+              className="toolbar-search"
+            />
+          </Space>
+        </div>
+      </Card>
+      <Card title={t('logs.title')}>
+        <Table rowKey="id" columns={columns} dataSource={filtered} scroll={{ x: 1300 }} />
+      </Card>
+    </Space>
+  );
+}

+ 74 - 0
src/pages/Settings.tsx

@@ -0,0 +1,74 @@
+import { Button, Card, Form, Input, Select, Space, Switch, Table, Tag, Typography } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+
+import { useI18n } from '../frontend/i18n/react';
+import type { RuntimeConfig, User } from '../frontend/types';
+
+interface SettingsProps {
+  me: User | null;
+  settings: RuntimeConfig | null;
+  users: User[];
+  loading?: boolean;
+  onSave: (values: Partial<RuntimeConfig>) => Promise<void>;
+}
+
+export default function Settings({ me, settings, users, loading, onSave }: SettingsProps) {
+  const { t } = useI18n();
+  if (me?.role !== 'admin') {
+    return (
+      <Card>
+        <Typography.Text type="secondary">{t('settings.noPermission')}</Typography.Text>
+      </Card>
+    );
+  }
+
+  const columns: ColumnsType<User> = [
+    { title: 'Username', dataIndex: 'username' },
+    { title: 'Email', dataIndex: 'email' },
+    { title: 'Role', dataIndex: 'role', render: (value) => <Tag>{value}</Tag> },
+    { title: 'Status', dataIndex: 'status', render: (value) => <Tag color={value === 'active' ? 'success' : 'default'}>{value}</Tag> }
+  ];
+
+  return (
+    <Space direction="vertical" size={16} className="full-width">
+      <Card title="System">
+        <Form
+          layout="vertical"
+          initialValues={settings || undefined}
+          onFinish={onSave}
+          disabled={loading}
+        >
+          <div className="form-grid two">
+            <Form.Item name="appBaseUrl" label="APP_BASE_URL">
+              <Input />
+            </Form.Item>
+            <Form.Item name="mailHostname" label="MAIL_HOSTNAME">
+              <Input />
+            </Form.Item>
+            <Form.Item name="sendingIp" label="SENDING_IP">
+              <Input />
+            </Form.Item>
+            <Form.Item name="defaultSpfMechanisms" label="DEFAULT_SPF_MECHANISMS">
+              <Input />
+            </Form.Item>
+            <Form.Item name="dmarcPolicy" label="DMARC_POLICY">
+              <Select options={['none', 'quarantine', 'reject'].map((value) => ({ value, label: value }))} />
+            </Form.Item>
+            <Form.Item name="dmarcRua" label="DMARC_RUA">
+              <Input />
+            </Form.Item>
+          </div>
+          <Form.Item name="sendRequiresVerified" label="SEND_REQUIRES_VERIFIED" valuePropName="checked">
+            <Switch />
+          </Form.Item>
+          <Button type="primary" htmlType="submit" loading={loading}>
+            {t('settings.save')}
+          </Button>
+        </Form>
+      </Card>
+      <Card title="Users">
+        <Table rowKey="id" columns={columns} dataSource={users} />
+      </Card>
+    </Space>
+  );
+}

+ 75 - 0
src/pages/SmtpCredentials.tsx

@@ -0,0 +1,75 @@
+import { CopyOutlined, ReloadOutlined } from '@ant-design/icons';
+import { Button, Card, Descriptions, Form, Input, Space, Tag, Typography } from 'antd';
+
+import { useI18n } from '../frontend/i18n/react';
+import type { RuntimeConfig, SmtpCredential } from '../frontend/types';
+
+interface SmtpCredentialsProps {
+  config: RuntimeConfig | null;
+  credential: SmtpCredential | null;
+  loading?: boolean;
+  onCopy: (value: string) => void;
+  onSave: (values: { username: string; password?: string }) => Promise<void>;
+}
+
+export default function SmtpCredentials({ config, credential, loading, onCopy, onSave }: SmtpCredentialsProps) {
+  const { t } = useI18n();
+  const [form] = Form.useForm();
+
+  function generatePassword() {
+    const bytes = new Uint8Array(24);
+    crypto.getRandomValues(bytes);
+    const password = btoa(String.fromCharCode(...bytes)).replace(/[+/=]/g, '').slice(0, 28);
+    form.setFieldValue('password', password);
+  }
+
+  return (
+    <Space direction="vertical" size={16} className="full-width">
+      <Card title={t('smtp.connectionTitle')}>
+        <Descriptions column={1}>
+          <Descriptions.Item label="SMTP Host">{copyable(config?.submission?.host || '-', onCopy)}</Descriptions.Item>
+          <Descriptions.Item label="SMTP Port">
+            {(config?.submission?.ports || []).map((item) => <Tag key={item.port}>{item.port} · {item.protocol}</Tag>)}
+          </Descriptions.Item>
+          <Descriptions.Item label="TLS / SSL">{config?.submission?.tls ? 'TLS' : 'STARTTLS'}</Descriptions.Item>
+          <Descriptions.Item label="Username">{copyable(credential?.username || config?.submission?.username || '-', onCopy)}</Descriptions.Item>
+          <Descriptions.Item label="Password">
+            {credential?.password ? copyable(credential.password, onCopy) : <Typography.Text type="secondary">{t('smtp.resetToCopy')}</Typography.Text>}
+          </Descriptions.Item>
+        </Descriptions>
+      </Card>
+      <Card title={t('smtp.updateTitle')}>
+        <Form
+          form={form}
+          layout="vertical"
+          onFinish={onSave}
+          initialValues={{ username: credential?.username || config?.submission?.username || '' }}
+        >
+          <Form.Item name="username" label="Username" rules={[{ required: true, message: t('smtp.usernameRequired') }]}>
+            <Input autoComplete="off" />
+          </Form.Item>
+          <Form.Item name="password" label="Password" extra={t('smtp.passwordExtra')}>
+            <Input.Password autoComplete="new-password" />
+          </Form.Item>
+          <Space wrap>
+            <Button icon={<ReloadOutlined />} onClick={generatePassword}>
+              {t('smtp.regenerate')}
+            </Button>
+            <Button type="primary" htmlType="submit" loading={loading}>
+              {t('smtp.save')}
+            </Button>
+          </Space>
+        </Form>
+      </Card>
+    </Space>
+  );
+}
+
+function copyable(value: string, onCopy: (value: string) => void) {
+  return (
+    <Space>
+      <Typography.Text code>{value}</Typography.Text>
+      <Button size="small" icon={<CopyOutlined />} onClick={() => onCopy(value)} />
+    </Space>
+  );
+}

+ 5 - 3
src/server.js

@@ -271,8 +271,9 @@ async function handleApi(req, res, url, user) {
     }
   }
 
-  const adminResponse = await handleAdminApi(req, res, pathname, method, user);
-  if (adminResponse) return adminResponse;
+  if (pathname.startsWith('/api/admin/')) {
+    return await handleAdminApi(req, res, pathname, method, user);
+  }
 
   const domainMatch = pathname.match(/^\/api\/domains\/(\d+)(?:\/([a-z-]+))?$/);
   if (domainMatch) {
@@ -740,7 +741,8 @@ function isUniqueError(error) {
 }
 
 function isLoginAsset(pathname) {
-  return ['/login', '/register', '/login.html', '/login.css', '/login.js'].includes(pathname);
+  return pathname.startsWith('/assets/')
+    || ['/login', '/register', '/login.html', '/login.css', '/login.js'].includes(pathname);
 }
 
 function resolveStaticPathname(pathname) {

+ 103 - 0
test/frontend-analytics-model.test.js

@@ -0,0 +1,103 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import {
+  buildDashboardSummary,
+  buildDomainRanking,
+  buildHourlyHeatmap,
+  buildStatusDistribution,
+  buildTrendSeries
+} from '../src/frontend/analytics-model.js';
+
+test('builds dashboard summary from analytics, DNS health, and SMTP state', () => {
+  const summary = buildDashboardSummary({
+    analytics: {
+      summary: {
+        total: 20,
+        queued: 18,
+        failed: 2,
+        recipients: 25,
+        today: 8,
+        successRate: 90,
+        verifiedDomains: 1
+      }
+    },
+    domains: [
+      domain(true, [
+        record('verification', 'ok'),
+        record('dkim', 'ok'),
+        record('spf', 'ok'),
+        record('dmarc', 'ok'),
+        record('sender-a', 'ok'),
+        record('ptr', 'ok')
+      ]),
+      domain(false, [
+        record('verification', 'ok'),
+        record('dkim', 'warn'),
+        record('spf', 'missing')
+      ])
+    ],
+    events: [{ createdAt: '2026-07-08T12:30:00.000Z' }],
+    config: { submission: { enabled: true } },
+    smtpCredential: { passwordSet: true }
+  });
+
+  assert.equal(summary.verifiedDomains, 1);
+  assert.equal(summary.today, 8);
+  assert.equal(summary.successRate, 90);
+  assert.equal(summary.bounceRate, 10);
+  assert.equal(summary.complaintRate, 0);
+  assert.equal(summary.dnsIssues, 5);
+  assert.equal(summary.smtpReady, true);
+  assert.equal(summary.lastSentAt, '2026-07-08T12:30:00.000Z');
+});
+
+test('normalizes chart datasets for trend, status, ranking, and hourly views', () => {
+  const analytics = {
+    byDay: [
+      { day: '2026-07-07', total: 6, queued: 5, failed: 1, recipients: 8 },
+      { day: '2026-07-08', total: 9, queued: 9, failed: 0, recipients: 10 }
+    ],
+    byStatus: [
+      { status: 'queued', total: 14 },
+      { status: 'failed', total: 1 }
+    ],
+    byDomain: [
+      { domain: 'b.example.com', total: 2, queued: 2, failed: 0, recipients: 2 },
+      { domain: 'a.example.com', total: 8, queued: 7, failed: 1, recipients: 12 }
+    ],
+    hourly: [
+      { hour: 0, total: 0, queued: 0, failed: 0 },
+      { hour: 9, total: 4, queued: 3, failed: 1 }
+    ]
+  };
+
+  assert.deepEqual(buildTrendSeries(analytics), [
+    { date: '2026-07-07', total: 6, accepted: 5, failed: 1, recipients: 8 },
+    { date: '2026-07-08', total: 9, accepted: 9, failed: 0, recipients: 10 }
+  ]);
+  assert.deepEqual(buildStatusDistribution(analytics), [
+    { status: 'queued', label: 'queued', value: 14 },
+    { status: 'failed', label: 'failed', value: 1 }
+  ]);
+  assert.deepEqual(buildDomainRanking(analytics).map((item) => item.domain), ['a.example.com', 'b.example.com']);
+  assert.deepEqual(buildHourlyHeatmap(analytics).find((item) => item.hour === '09:00'), {
+    hour: '09:00',
+    total: 4,
+    accepted: 3,
+    failed: 1
+  });
+});
+
+function domain(verified, records) {
+  return {
+    status: {
+      verified,
+      records
+    }
+  };
+}
+
+function record(key, status) {
+  return { key, status };
+}

+ 27 - 0
test/frontend-api-token-model.test.js

@@ -0,0 +1,27 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import {
+  canCopyFullApiToken,
+  formatApiTokenPrefix,
+  getCopyableApiToken,
+  getCreatedApiTokenSecret
+} from '../src/frontend/api-token-model.js';
+
+test('only exposes full API token when the create response includes the secret', () => {
+  const created = { tokenPrefix: 'mh_123456789', token: 'mh_123456789.full-secret' };
+  const listed = { tokenPrefix: 'mh_987654321' };
+
+  assert.equal(canCopyFullApiToken(created), true);
+  assert.equal(getCreatedApiTokenSecret(created), 'mh_123456789.full-secret');
+  assert.equal(getCopyableApiToken(created), 'mh_123456789.full-secret');
+
+  assert.equal(canCopyFullApiToken(listed), false);
+  assert.equal(getCreatedApiTokenSecret(listed), '');
+  assert.equal(getCopyableApiToken(listed), '');
+});
+
+test('formats token prefixes without pretending the full secret is available', () => {
+  assert.equal(formatApiTokenPrefix({ tokenPrefix: 'mh_abcdef1234' }), 'mh_abcdef1234...');
+  assert.equal(formatApiTokenPrefix({}), '-');
+});

+ 80 - 0
test/frontend-domain-model.test.js

@@ -0,0 +1,80 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import {
+  buildDomainHealth,
+  getRecordStatusMeta,
+  getRequiredDnsRecords
+} from '../src/frontend/domain-model.js';
+
+test('maps DNS record states to stable UI status metadata', () => {
+  assert.deepEqual(getRecordStatusMeta({ status: 'ok' }), {
+    key: 'success',
+    label: '已通过',
+    color: 'success'
+  });
+  assert.deepEqual(getRecordStatusMeta({ status: 'pending' }), {
+    key: 'pending',
+    label: '等待生效',
+    color: 'warning'
+  });
+  assert.deepEqual(getRecordStatusMeta({ status: 'warn' }), {
+    key: 'error',
+    label: '配置错误',
+    color: 'error'
+  });
+  assert.deepEqual(getRecordStatusMeta({ status: 'missing' }), {
+    key: 'idle',
+    label: '未配置',
+    color: 'default'
+  });
+});
+
+test('builds domain health from required DNS records only', () => {
+  const domain = {
+    domain: 'example.com',
+    senderHost: 'mail.example.com',
+    sendingIp: '203.0.113.10',
+    status: {
+      checkedAt: '2026-07-08T10:30:00.000Z',
+      records: [
+        record('verification', 'ok'),
+        record('dkim', 'ok'),
+        record('spf', 'pending'),
+        record('dmarc', 'warn'),
+        record('sender-a', 'missing'),
+        record('ptr', 'ok'),
+        record('optional-mta-sts', 'ok')
+      ]
+    }
+  };
+
+  assert.deepEqual(getRequiredDnsRecords(domain).map((item) => item.key), [
+    'verification',
+    'dkim',
+    'spf',
+    'dmarc',
+    'sender-a',
+    'ptr'
+  ]);
+
+  assert.deepEqual(buildDomainHealth(domain), {
+    status: 'error',
+    label: '需要处理',
+    passed: 3,
+    total: 6,
+    percent: 50,
+    dnsIssues: 2,
+    checkedAt: '2026-07-08T10:30:00.000Z'
+  });
+});
+
+function record(key, status) {
+  return {
+    key,
+    label: key,
+    type: 'TXT',
+    host: `${key}.example.com`,
+    value: 'value',
+    status
+  };
+}

+ 29 - 0
test/frontend-i18n.test.js

@@ -0,0 +1,29 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import {
+  DEFAULT_LOCALE,
+  createTranslator,
+  normalizeLocale,
+  supportedLocales
+} from '../src/frontend/i18n/index.js';
+
+test('uses Chinese as the default locale and normalizes supported aliases', () => {
+  assert.equal(DEFAULT_LOCALE, 'zh-CN');
+  assert.deepEqual(supportedLocales, ['zh-CN', 'en-US']);
+  assert.equal(normalizeLocale(), 'zh-CN');
+  assert.equal(normalizeLocale('zh'), 'zh-CN');
+  assert.equal(normalizeLocale('en'), 'en-US');
+  assert.equal(normalizeLocale('fr-FR'), 'zh-CN');
+});
+
+test('translates known UI keys and falls back safely', () => {
+  const zh = createTranslator('zh-CN');
+  const en = createTranslator('en-US');
+
+  assert.equal(zh('common.refresh'), '刷新');
+  assert.equal(en('common.refresh'), 'Refresh');
+  assert.equal(zh('auth.loginTitle'), '登录控制台');
+  assert.equal(en('auth.loginTitle'), 'Sign in to console');
+  assert.equal(en('missing.translation.key'), 'missing.translation.key');
+});

+ 131 - 0
test/server-admin-api.test.js

@@ -0,0 +1,131 @@
+import assert from 'node:assert/strict';
+import { spawn } from 'node:child_process';
+import { mkdtempSync, readdirSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import process from 'node:process';
+import { test } from 'node:test';
+import net from 'node:net';
+
+test('admin API routes respond once and keep the server alive', async () => {
+  const port = await freePort();
+  const child = spawn(process.execPath, ['src/server.js'], {
+    cwd: process.cwd(),
+    env: {
+      ...process.env,
+      PORT: String(port),
+      DATA_DIR: mkdtempSync(path.join(tmpdir(), 'mailhub-server-test-')),
+      ADMIN_PASSWORD: 'password123',
+      SUBMISSION_ENABLED: 'false'
+    },
+    stdio: ['ignore', 'pipe', 'pipe']
+  });
+
+  try {
+    await waitForOutput(child, 'MailHub listening');
+    const baseUrl = `http://127.0.0.1:${port}`;
+    const login = await fetch(`${baseUrl}/api/login`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ username: 'admin', password: 'password123' })
+    });
+    assert.equal(login.status, 200);
+    const cookie = login.headers.get('set-cookie')?.split(';')[0] || '';
+    assert.ok(cookie);
+
+    const settings = await fetch(`${baseUrl}/api/admin/settings`, {
+      headers: { Cookie: cookie }
+    });
+    assert.equal(settings.status, 200);
+    assert.equal((await settings.json()).settings.mailHostname, 'ali.ss5.xyz');
+
+    const exited = await waitForExit(child, 300);
+    assert.equal(exited, false);
+  } finally {
+    child.kill('SIGTERM');
+    await waitForExit(child, 1000);
+  }
+});
+
+test('built auth assets are served before authentication', async () => {
+  const assetName = readdirSync(path.join(process.cwd(), 'public', 'assets')).find((name) => /\.(js|css)$/.test(name));
+  assert.ok(assetName, 'expected at least one built frontend asset');
+
+  const port = await freePort();
+  const child = spawn(process.execPath, ['src/server.js'], {
+    cwd: process.cwd(),
+    env: {
+      ...process.env,
+      PORT: String(port),
+      DATA_DIR: mkdtempSync(path.join(tmpdir(), 'mailhub-server-test-')),
+      ADMIN_PASSWORD: 'password123',
+      SUBMISSION_ENABLED: 'false'
+    },
+    stdio: ['ignore', 'pipe', 'pipe']
+  });
+
+  try {
+    await waitForOutput(child, 'MailHub listening');
+    const baseUrl = `http://127.0.0.1:${port}`;
+
+    const login = await fetch(`${baseUrl}/login`);
+    assert.equal(login.status, 200);
+
+    const asset = await fetch(`${baseUrl}/assets/${assetName}`, { redirect: 'manual' });
+    assert.equal(asset.status, 200);
+    assert.notEqual(asset.headers.get('location'), '/login');
+  } finally {
+    child.kill('SIGTERM');
+    await waitForExit(child, 1000);
+  }
+});
+
+function freePort() {
+  return new Promise((resolve, reject) => {
+    const server = net.createServer();
+    server.listen(0, '127.0.0.1', () => {
+      const address = server.address();
+      server.close(() => {
+        if (address && typeof address === 'object') resolve(address.port);
+        else reject(new Error('Unable to allocate a test port.'));
+      });
+    });
+  });
+}
+
+function waitForOutput(child, text) {
+  return new Promise((resolve, reject) => {
+    const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${text}`)), 5000);
+    const chunks = [];
+    const onData = (chunk) => {
+      chunks.push(String(chunk));
+      if (chunks.join('').includes(text)) {
+        clearTimeout(timeout);
+        child.stdout.off('data', onData);
+        child.stderr.off('data', onData);
+        resolve();
+      }
+    };
+    child.stdout.on('data', onData);
+    child.stderr.on('data', onData);
+    child.once('exit', (code) => {
+      clearTimeout(timeout);
+      reject(new Error(`Server exited early with code ${code}: ${chunks.join('')}`));
+    });
+  });
+}
+
+function waitForExit(child, timeoutMs) {
+  if (child.exitCode !== null) return Promise.resolve(true);
+  return new Promise((resolve) => {
+    const timeout = setTimeout(() => {
+      child.off('exit', onExit);
+      resolve(false);
+    }, timeoutMs);
+    const onExit = () => {
+      clearTimeout(timeout);
+      resolve(true);
+    };
+    child.once('exit', onExit);
+  });
+}

+ 21 - 0
tsconfig.json

@@ -0,0 +1,21 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "useDefineForClassFields": true,
+    "lib": ["DOM", "DOM.Iterable", "ES2022"],
+    "allowJs": true,
+    "skipLibCheck": true,
+    "esModuleInterop": true,
+    "allowSyntheticDefaultImports": true,
+    "strict": true,
+    "forceConsistentCasingInFileNames": true,
+    "module": "ESNext",
+    "moduleResolution": "Node",
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "noEmit": true,
+    "jsx": "react-jsx"
+  },
+  "include": ["src/frontend", "src/layouts", "src/pages", "src/components"],
+  "references": []
+}

+ 25 - 0
vite.config.ts

@@ -0,0 +1,25 @@
+import react from '@vitejs/plugin-react';
+import { resolve } from 'node:path';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+  plugins: [react()],
+  publicDir: false,
+  build: {
+    outDir: 'public',
+    emptyOutDir: false,
+    sourcemap: false,
+    rollupOptions: {
+      input: {
+        index: resolve(__dirname, 'index.html'),
+        login: resolve(__dirname, 'login.html')
+      }
+    }
+  },
+  server: {
+    proxy: {
+      '/api': 'http://127.0.0.1:3000',
+      '/logout': 'http://127.0.0.1:3000'
+    }
+  }
+});

Някои файлове не бяха показани, защото твърде много файлове са промени