From d4c13341430444ceacfb737173901661e8645106 Mon Sep 17 00:00:00 2001 From: BigCode Date: Wed, 30 Sep 2026 03:34:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20WmailHub=20v1.0.0=20=E2=80=94=E2=80=94?= =?UTF-8?q?=20Cloudflare=20Email=20Workers=20=E9=82=AE=E4=BB=B6=E6=8E=A5?= =?UTF-8?q?=E6=94=B6=E4=B8=8E=E8=BD=AC=E5=8F=91=E5=B9=B3=E5=8F=B0(?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E5=8F=AF=E7=94=A8=E7=89=88=E6=9C=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Console:React 19 + antd 6.6.5 前端(登录 admin/admin)+ Hono 管理 API (Nodes/Domains/Mailboxes/Emails/Webhooks/Keys/OAuth/Admin) - Node:Bun 转发节点(HMAC 签名、有限重试、心跳、结构化日志、优雅退出) - Email Worker:Cloudflare email handler + 本地联调入口 - shared:HMAC 协议(docs/04)、wmh_ Key(32 位)、AES-GCM 加密、SSRF 防护 - 邮件管线:postal-mime 解析、附件、catch-all、限额、malformed 降级、TTL 1 天 - Webhook:签名投递、指数退避重试、SSRF 校验、投递日志 - API v1 + MCP(JSON-RPC,六工具,凭据 kind 隔离) - Cloudflare OAuth:真实 + 本地 sim 模式,自动安装管线(幂等,无需手工命令) - 测试:bun test 41 例(协议安全/管线/TTL/Webhook/SSRF/MCP/OAuth/鉴权)+ Playwright UI 冒烟 - LOGO/favicon 由 codex CLI 生成 --- .gitignore | 25 ++ README.md | 102 +++++++ assets/favicon.ico | Bin 0 -> 32038 bytes assets/favicon.svg | 7 + assets/logo.svg | 15 + bun.lock | 192 ++++++++++++ console/frontend/index.html | 16 + console/frontend/src/App.tsx | 121 ++++++++ console/frontend/src/api.ts | 291 ++++++++++++++++++ console/frontend/src/main.tsx | 55 ++++ console/frontend/src/pages/Dashboard.tsx | 115 +++++++ console/frontend/src/pages/Domains.tsx | 116 ++++++++ console/frontend/src/pages/Emails.tsx | 228 ++++++++++++++ console/frontend/src/pages/Keys.tsx | 158 ++++++++++ console/frontend/src/pages/Login.tsx | 57 ++++ console/frontend/src/pages/Mailboxes.tsx | 157 ++++++++++ console/frontend/src/pages/Nodes.tsx | 349 ++++++++++++++++++++++ console/frontend/src/pages/OAuthSim.tsx | 55 ++++ console/frontend/src/pages/Webhooks.tsx | 266 +++++++++++++++++ console/frontend/src/styles.css | 128 ++++++++ console/frontend/src/theme.ts | 25 ++ console/src/app.ts | 74 +++++ console/src/auth.ts | 144 +++++++++ console/src/db.ts | 106 +++++++ console/src/env.ts | 85 ++++++ console/src/local/d1.ts | 77 +++++ console/src/local/main.ts | 135 +++++++++ console/src/local/r2.ts | 66 +++++ console/src/middleware.ts | 98 ++++++ console/src/routes/admin.ts | 68 +++++ console/src/routes/apiv1.ts | 123 ++++++++ console/src/routes/internal.ts | 152 ++++++++++ console/src/routes/keys.ts | 66 +++++ console/src/routes/mcp.ts | 251 ++++++++++++++++ console/src/routes/oauth.ts | 154 ++++++++++ console/src/routes/resources.ts | 362 +++++++++++++++++++++++ console/src/routes/webhooks.ts | 161 ++++++++++ console/src/services/cfapi.ts | 243 +++++++++++++++ console/src/services/cleanup.ts | 116 ++++++++ console/src/services/email_ingest.ts | 281 ++++++++++++++++++ console/src/services/provision.ts | 339 +++++++++++++++++++++ console/src/services/webhook_delivery.ts | 202 +++++++++++++ console/src/ssrf-resolver.ts | 40 +++ console/src/util.ts | 47 +++ console/src/version.ts | 11 + console/src/worker.ts | 28 ++ console/wrangler.jsonc | 39 +++ docs/00_OVERVIEW.md | 100 +++++++ docs/01_ARCHITECTURE.md | 47 +++ docs/02_CLOUDFLARE_OAUTH.md | 11 + docs/03_NODE.md | 44 +++ docs/04_NODE_PROTOCOL.md | 49 +++ docs/05_DATA_MODEL.md | 57 ++++ docs/06_EMAIL_LIFECYCLE.md | 42 +++ docs/07_WEBHOOK_API_MCP.md | 45 +++ docs/08_SECURITY.md | 47 +++ docs/09_CLOUDFLARE_DEPLOYMENT.md | 42 +++ docs/10_IMPLEMENTATION_PLAN.md | 61 ++++ migrations/0001_init.sql | 173 +++++++++++ node/src/main.ts | 247 ++++++++++++++++ node/src/version.ts | 4 + package.json | 32 ++ scripts/build-frontend.ts | 36 +++ scripts/dev.ts | 179 +++++++++++ shared/src/constants.ts | 39 +++ shared/src/crypto.ts | 149 ++++++++++ shared/src/email-worker-template.ts | 70 +++++ shared/src/ids.ts | 37 +++ shared/src/index.ts | 8 + shared/src/keys.ts | 54 ++++ shared/src/protocol.ts | 169 +++++++++++ shared/src/ssrf.ts | 135 +++++++++ tests/api-mcp.test.ts | 239 +++++++++++++++ tests/auth.test.ts | 58 ++++ tests/fixtures/test-mail.eml | 24 ++ tests/fixtures/webhook-receiver.ts | 41 +++ tests/helpers.ts | 172 +++++++++++ tests/oauth.test.ts | 154 ++++++++++ tests/pipeline.test.ts | 190 ++++++++++++ tests/protocol.test.ts | 141 +++++++++ tests/ui-smoke.ts | 93 ++++++ tests/webhook.test.ts | 172 +++++++++++ tsconfig.json | 19 ++ worker/src/env.ts | 7 + worker/src/index.ts | 74 +++++ worker/src/local.ts | 46 +++ worker/wrangler.jsonc | 15 + 87 files changed, 9268 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 assets/favicon.ico create mode 100644 assets/favicon.svg create mode 100644 assets/logo.svg create mode 100644 bun.lock create mode 100644 console/frontend/index.html create mode 100644 console/frontend/src/App.tsx create mode 100644 console/frontend/src/api.ts create mode 100644 console/frontend/src/main.tsx create mode 100644 console/frontend/src/pages/Dashboard.tsx create mode 100644 console/frontend/src/pages/Domains.tsx create mode 100644 console/frontend/src/pages/Emails.tsx create mode 100644 console/frontend/src/pages/Keys.tsx create mode 100644 console/frontend/src/pages/Login.tsx create mode 100644 console/frontend/src/pages/Mailboxes.tsx create mode 100644 console/frontend/src/pages/Nodes.tsx create mode 100644 console/frontend/src/pages/OAuthSim.tsx create mode 100644 console/frontend/src/pages/Webhooks.tsx create mode 100644 console/frontend/src/styles.css create mode 100644 console/frontend/src/theme.ts create mode 100644 console/src/app.ts create mode 100644 console/src/auth.ts create mode 100644 console/src/db.ts create mode 100644 console/src/env.ts create mode 100644 console/src/local/d1.ts create mode 100644 console/src/local/main.ts create mode 100644 console/src/local/r2.ts create mode 100644 console/src/middleware.ts create mode 100644 console/src/routes/admin.ts create mode 100644 console/src/routes/apiv1.ts create mode 100644 console/src/routes/internal.ts create mode 100644 console/src/routes/keys.ts create mode 100644 console/src/routes/mcp.ts create mode 100644 console/src/routes/oauth.ts create mode 100644 console/src/routes/resources.ts create mode 100644 console/src/routes/webhooks.ts create mode 100644 console/src/services/cfapi.ts create mode 100644 console/src/services/cleanup.ts create mode 100644 console/src/services/email_ingest.ts create mode 100644 console/src/services/provision.ts create mode 100644 console/src/services/webhook_delivery.ts create mode 100644 console/src/ssrf-resolver.ts create mode 100644 console/src/util.ts create mode 100644 console/src/version.ts create mode 100644 console/src/worker.ts create mode 100644 console/wrangler.jsonc create mode 100644 docs/00_OVERVIEW.md create mode 100644 docs/01_ARCHITECTURE.md create mode 100644 docs/02_CLOUDFLARE_OAUTH.md create mode 100644 docs/03_NODE.md create mode 100644 docs/04_NODE_PROTOCOL.md create mode 100644 docs/05_DATA_MODEL.md create mode 100644 docs/06_EMAIL_LIFECYCLE.md create mode 100644 docs/07_WEBHOOK_API_MCP.md create mode 100644 docs/08_SECURITY.md create mode 100644 docs/09_CLOUDFLARE_DEPLOYMENT.md create mode 100644 docs/10_IMPLEMENTATION_PLAN.md create mode 100644 migrations/0001_init.sql create mode 100644 node/src/main.ts create mode 100644 node/src/version.ts create mode 100644 package.json create mode 100644 scripts/build-frontend.ts create mode 100644 scripts/dev.ts create mode 100644 shared/src/constants.ts create mode 100644 shared/src/crypto.ts create mode 100644 shared/src/email-worker-template.ts create mode 100644 shared/src/ids.ts create mode 100644 shared/src/index.ts create mode 100644 shared/src/keys.ts create mode 100644 shared/src/protocol.ts create mode 100644 shared/src/ssrf.ts create mode 100644 tests/api-mcp.test.ts create mode 100644 tests/auth.test.ts create mode 100644 tests/fixtures/test-mail.eml create mode 100644 tests/fixtures/webhook-receiver.ts create mode 100644 tests/helpers.ts create mode 100644 tests/oauth.test.ts create mode 100644 tests/pipeline.test.ts create mode 100644 tests/protocol.test.ts create mode 100644 tests/ui-smoke.ts create mode 100644 tests/webhook.test.ts create mode 100644 tsconfig.json create mode 100644 worker/src/env.ts create mode 100644 worker/src/index.ts create mode 100644 worker/src/local.ts create mode 100644 worker/wrangler.jsonc diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2b02b1b --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# 依赖与构建产物 +node_modules/ +console/public/ +worker/dist/ + +# 本地运行时数据(含 secret,严禁入库) +console/data/ +node/config.json +*.local.sqlite +*.local.sqlite-shm +*.local.sqlite-wal + +# 环境 +.env +.env.* +!.env.example + +# 日志与临时 +*.log +nohup.out +/tmp/ + +# 编辑器 +.DS_Store +.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..fef351e --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# WmailHub + +基于 **Cloudflare Email Workers** 的轻量邮件接收与转发平台。 + +```text +Internet → Cloudflare Email Routing → Email Worker → Node → Console → D1/R2 + ├→ Webhook + ├→ API v1 + └→ MCP +``` + +- **Console**(控制平面)= 前端(React + antd v6)+ 管理 API,部署在 Cloudflare Workers,负责 Cloudflare OAuth、Node/Domain/Mailbox/Email/Webhook 管理、数据存储与 TTL 清理。 +- **Node**(转发平面)= 纯后端转发节点(Bun),收到什么转发什么,不做业务判断。 +- **Email Worker** = Cloudflare 邮件入口,只把邮件事件安全转发给 Node。 + +## 本地开发(无需 Cloudflare 账号) + +```sh +bun install +bun run dev # 一键启动:console + node + email worker + 自动 OAuth(sim)安装 +bun run dev -- --reset # 清空本地数据重新安装 +bun test # 测试套件(协议安全/管线/TTL/Webhook/MCP/OAuth) +bun tests/ui-smoke.ts # UI 冒烟(需本机 Playwright chromium) +``` + +启动后: + +| 服务 | 地址 | +| --- | --- | +| Console(前端 + API) | http://127.0.0.1:18787 | +| Node | http://127.0.0.1:18788/__health | +| Email Worker(本地联调) | http://127.0.0.1:18789/__health | + +**登录**:默认 `admin / admin`。 + +**发测试邮件**(走完整链路 Worker→Node→Console): + +```sh +SECRET=$(python3 -c "import json;print(json.load(open('node/config.json'))['ingressSecret'])") +curl -X POST http://127.0.0.1:18789/__email_test \ + -H "X-WmailHub-Ingress-Secret: $SECRET" \ + -H "X-WmailHub-Test-From: alice@example.org" \ + -H "X-WmailHub-Test-To: hello@example.test" \ + --data-binary @tests/fixtures/test-mail.eml +``` + +`bun run dev` 首次运行会自动:创建本地 Node → enrollment 换取身份(config.json,0600)→ sim 模式 Cloudflare OAuth 自动安装(Email Worker / Email Routing / Domain `example.test` / catch-all Mailbox)→ Node 心跳上线。全程无需复制任何安装命令。 + +## 功能 + +- **用户登录**:用户名密码(PBKDF2),默认 admin/admin,HMAC 签名会话 cookie。 +- **Node 管理**:Add Node → Cloudflare OAuth(本地 sim / 真实 OAuth)→ 自动安装,步骤可视化,凭据轮换。 +- **邮件管线**:MIME 解析(postal-mime)、附件、catch-all/精确匹配、大小限额、malformed 降级入库。 +- **TTL**:邮件默认保留 1 天(`expires_at`),Cron 定期清理 R2 + DB + orphan,幂等。 +- **Webhook**:`email.received` 事件推送,HMAC 签名头(`X-WmailHub-Signature`),指数退避重试,SSRF 防护。 +- **API v1**:`Authorization: Bearer wmh_...` 访问 `/api/v1/*`。 +- **MCP**:`/api/v1/mcp`(JSON-RPC over HTTP),工具:`list_mailboxes` / `list_emails` / `get_email` / `create_mailbox` / `delete_mailbox` / `list_webhooks`。 +- **Key 管理**:API Key 与 MCP Key 在后端设置,`wmh_` 前缀 + 随机串,总长 32 位;库中只存 SHA-256,明文仅创建时展示一次。 + +## Node 协议(docs/04) + +```text +body_hash = SHA256(raw_body) +canonical = METHOD \n PATH \n TIMESTAMP \n NONCE \n body_hash +signature = HMAC-SHA256(node_credential_secret, canonical) +``` + +请求头:`Authorization: WmailHub-Node `、`X-WmailHub-Timestamp`(±5 分钟)、`X-WmailHub-Nonce`(防重放)、`X-WmailHub-Body-SHA256`、`X-WmailHub-Signature`、`X-WmailHub-Protocol: 1`。 + +## 目录结构 + +```text +WmailHub/ + console/ Console(Workers 入口 src/worker.ts + 本地运行时 src/local/main.ts + 前端 frontend/) + node/ Node 转发节点(Bun) + worker/ Email Worker(Cloudflare 部署入口 + 本地联调 local.ts) + shared/ 共享协议:HMAC 签名 / Key 生成 / 加密 / SSRF 防护 / Email Worker 脚本模板 + migrations/ D1 schema + tests/ bun test 测试 + UI 冒烟 + fixtures + scripts/ dev 启动 / 前端构建 + docs/ 设计文档(00~10) +``` + +## 生产部署(Cloudflare Workers) + +1. `bunx wrangler d1 create wmailhub` → 将 `database_id` 填入 `console/wrangler.jsonc` +2. `bunx wrangler r2 bucket create wmailhub` +3. `bunx wrangler d1 execute wmailhub --remote --file=./migrations/0001_init.sql` +4. `cd console && bunx wrangler deploy`(前端先 `bun run build`) +5. `bunx wrangler secret put SESSION_SECRET --config console/wrangler.jsonc`(≥32 位随机串) +6. Cloudflare Dashboard 创建 OAuth App(client id/secret),配置 `CF_OAUTH_CLIENT_ID/SECRET` vars 与回调 `/api/oauth/cloudflare/callback`;未配置时自动进入 sim 模式(仅供本地) +7. Node 部署在任意能被 Email Worker 访问的服务器:`WMAILHUB_ENROLLMENT_TOKEN=... bun node/src/main.ts`(token 在 Console「添加节点」后展示一次) + +Cron(`*/1 * * * *`)驱动 TTL 清理与 Webhook 重试;Email Worker 由 Console provisioning 自动上传,无需手工部署。 + +## 安全设计(docs/08) + +- OAuth token、Node credential、ingress secret 全部 AES-256-GCM 加密存储;凭据撤销即时生效。 +- Node→Console 强制 HTTPS + HMAC 签名 + timestamp 窗口 + nonce 防重放 + body hash 防篡改。 +- Worker 只持有 Node ingress secret,不持有 Console 凭据。 +- Webhook endpoint 防 SSRF(私网/loopback/metadata/非法 scheme;本地联调需显式 `WMAILHUB_ALLOW_PRIVATE_WEBHOOKS=1`)。 +- 会话 cookie `SameSite=Strict` + 跨域 Origin 校验;日志不落 secret 与邮件正文。 diff --git a/assets/favicon.ico b/assets/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..f954547c695bee43b74a047c6feab98064f5b3ea GIT binary patch literal 32038 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 + + + + + + diff --git a/assets/logo.svg b/assets/logo.svg new file mode 100644 index 0000000..f3760cd --- /dev/null +++ b/assets/logo.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..c21ae4a --- /dev/null +++ b/bun.lock @@ -0,0 +1,192 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "wmailhub", + "dependencies": { + "@ant-design/icons": "^6.3.4", + "antd": "^6.6.5", + "hono": "^4.9.0", + "postal-mime": "^2.4.1", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router-dom": "^7.9.0", + }, + "devDependencies": { + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "bun-types": "^1.2.0", + "playwright-core": "^1.63.0", + "typescript": "^5.9.0", + }, + }, + }, + "packages": { + "@ant-design/colors": ["@ant-design/colors@8.0.1", "", { "dependencies": { "@ant-design/fast-color": "^3.0.0" } }, "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ=="], + + "@ant-design/cssinjs": ["@ant-design/cssinjs@2.1.2", "", { "dependencies": { "@babel/runtime": "^7.11.1", "@emotion/hash": "^0.8.0", "@emotion/unitless": "^0.7.5", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1", "csstype": "^3.1.3", "stylis": "^4.3.4" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-2Hy8BnCEH31xPeSLbhhB2ctCPXE2ZnASdi+KbSeS79BNbUhL9hAEe20SkUk+BR8aKTmqb6+FKFruk7w8z0VoRQ=="], + + "@ant-design/cssinjs-utils": ["@ant-design/cssinjs-utils@2.1.2", "", { "dependencies": { "@ant-design/cssinjs": "^2.1.2", "@babel/runtime": "^7.23.2", "@rc-component/util": "^1.4.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-5fTHQ158jJJ5dC/ECeyIdZUzKxE/mpEMRZxthyG1sw/AKRHKgJBg00Yi6ACVXgycdje7KahRNvNET/uBccwCnA=="], + + "@ant-design/fast-color": ["@ant-design/fast-color@3.0.1", "", {}, "sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw=="], + + "@ant-design/icons": ["@ant-design/icons@6.3.4", "", { "dependencies": { "@ant-design/colors": "^8.0.1", "@ant-design/icons-svg": "^4.6.0", "@rc-component/util": "^1.11.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-kDoUlppczVyCUTFthF6cHmPzMBYqNZQhu9j6EyIX/YIdM9j9CZNZoJUjzVw7/1v1VO8WwVMmPp15r/uwIAS0AQ=="], + + "@ant-design/icons-svg": ["@ant-design/icons-svg@4.6.0", "", {}, "sha512-PRomU725ABMf/lnQp5HiB7my1kjEbFY0D10N4lXYxK6TIB1gKjIVD5MRThpDaezLgw1D774J8eOeeDB0M6wHrQ=="], + + "@ant-design/react-slick": ["@ant-design/react-slick@2.0.0", "", { "dependencies": { "@babel/runtime": "^7.28.4", "clsx": "^2.1.1", "json2mq": "^0.2.0", "throttle-debounce": "^5.0.0" }, "peerDependencies": { "react": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-HMS9sRoEmZey8LsE/Yo6+klhlzU12PisjrVcydW3So7RdklyEd2qehyU6a7Yp+OYN72mgsYs3NFCyP2lCPFVqg=="], + + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + + "@emotion/hash": ["@emotion/hash@0.8.0", "", {}, "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow=="], + + "@emotion/unitless": ["@emotion/unitless@0.7.5", "", {}, "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg=="], + + "@rc-component/async-validator": ["@rc-component/async-validator@6.0.0", "", { "dependencies": { "@babel/runtime": "^7.24.4" } }, "sha512-D3AGQwdyE58gmvx6waVSXJ80JGO+IY5L2O8HDnSOex7JNlzB3GuN/4hyHNTdhy2qtOhkpbIjmeAN3tL993wKbA=="], + + "@rc-component/cascader": ["@rc-component/cascader@1.22.0", "", { "dependencies": { "@rc-component/select": "~1.10.0", "@rc-component/tree": "~1.4.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-SffrA57aS9oub3VuI7ajPhJTPtaNxngSvtRhD40Rd8dwJ5vfWPSrVanWgeepdWFGBt7EHftIK5RUU0u3rCTwWw=="], + + "@rc-component/checkbox": ["@rc-component/checkbox@2.0.0", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-3CXGPpAR9gsPKeO2N78HAPOzU30UdemD6HGJoWVJOpa6WleaGB5kzZj3v6bdTZab31YuWgY/RxV3VKPctn0DwQ=="], + + "@rc-component/collapse": ["@rc-component/collapse@1.2.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/motion": "^1.1.4", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-ZRYSKSS39qsFx93p26bde7JUZJshsUBEQRlRXPuJYlAiNX0vyYlF5TsAm8JZN3LcF8XvKikdzPbgAtXSbkLUkw=="], + + "@rc-component/color-picker": ["@rc-component/color-picker@3.1.1", "", { "dependencies": { "@ant-design/fast-color": "^3.0.1", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-OHaCHLHszCegdXmIq2ZRIZBN/EtpT6Wm8SG/gpzLATHbVKc/avvuKi+zlOuk05FTWvgaMmpxAko44uRJ3M+2pg=="], + + "@rc-component/context": ["@rc-component/context@2.0.2", "", { "dependencies": { "@rc-component/util": "^1.11.0" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-uiGpAlblCNlziHPwj4S4Iy/oemeuz/hR03mbiEjTCXwsqOIN3BOzsRMyDwpyO5Fm0vIEEJRUf9ZtbRLbhksuTA=="], + + "@rc-component/dialog": ["@rc-component/dialog@1.10.0", "", { "dependencies": { "@rc-component/motion": "^1.3.3", "@rc-component/portal": "^2.1.0", "@rc-component/util": "^1.9.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-eDukNlz9vNszAGv7i3zKXdxEd3wgVmNxuJijYt8zvTh17QwTu8KK/bdURRd/lU4qaMzhO1HKKmMrwOnkaw0BvQ=="], + + "@rc-component/drawer": ["@rc-component/drawer@1.4.2", "", { "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/portal": "^2.1.3", "@rc-component/util": "^1.9.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-1ib+fZEp6FBu+YvcIktm+nCQ+Q+qIpwpoaJH6opGr4ofh2QMq+qdr5DLC4oCf5qf3pcWX9lUWPYX652k4ini8Q=="], + + "@rc-component/dropdown": ["@rc-component/dropdown@1.0.3", "", { "dependencies": { "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.11.0", "react-dom": ">=16.11.0" } }, "sha512-YTST/N6kpqpDz3IMuM/PSSZnrDpSOA6dgHv12gPA90ZTSLv2CoqkZ0+9NtwTY6BeO7dstPblSic2QJg7dSFy/g=="], + + "@rc-component/form": ["@rc-component/form@1.8.6", "", { "dependencies": { "@rc-component/async-validator": "^6.0.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-1EXVsSKPZC6kGrIqxVck7kAWM6T65b6et/n5wIcyiaIa41VrDshD0nVLHoBDEIZr2ATQsOP6icb4XQkNFMLBAw=="], + + "@rc-component/image": ["@rc-component/image@1.10.0", "", { "dependencies": { "@rc-component/motion": "^1.0.0", "@rc-component/portal": "^2.1.2", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-BjeZCRQ+hw+4WAhvrw8rJvy5fckA2xpf/X2XQEOABUHvLTNB9inB98X3Mp54jYQ7g10DfWERQWHXeC4ylxp1Uw=="], + + "@rc-component/input": ["@rc-component/input@1.3.1", "", { "dependencies": { "@rc-component/resize-observer": "^1.1.1", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-iFvTUT9W+JC/MSin2aGAk8NqsVlTzcExNC9DZariON1IWirju9NoNeEk47an4Q8iHazkoVI/y1LnDi88+CPcig=="], + + "@rc-component/input-number": ["@rc-component/input-number@1.6.2", "", { "dependencies": { "@rc-component/mini-decimal": "^1.0.1", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-Gjcq7meZlCOiWN1t1xCC+7/s85humHVokTBI7PJgTfoyw5OWF74y3e6P8PHX104g9+b54jsodFIzyaj6p8LI9w=="], + + "@rc-component/listy": ["@rc-component/listy@1.2.3", "", { "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/portal": "^2.0.0", "@rc-component/resize-observer": "^1.0.0", "@rc-component/util": "^1.3.1", "@rc-component/virtual-list": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-IXiMjV5s0rczLBlfh7G5nB4M3365mrEeedjwKtf5I+Ns3PqRUsebR2h5u8CeFarsVfLUPC2I5p0h09TNoOWyvQ=="], + + "@rc-component/mentions": ["@rc-component/mentions@1.12.0", "", { "dependencies": { "@rc-component/input": "~1.3.1", "@rc-component/menu": "~1.5.0", "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-v5MBx8zDcqCT+uybJgKpcx3Fgrvj0m3u+A/15RlSBaMx12lLoJZTTYggC3pbYYfohcKujWWUyBz6O6Gp2vGiNA=="], + + "@rc-component/menu": ["@rc-component/menu@1.5.0", "", { "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/overflow": "^1.0.0", "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-RjvzPsZkwVEg3xu+/jDDqDeUCBJAnWylONFlGeQgg5enlNAnlpybqhaMGE/UPEIvr6hL90PGhjFBguJqj0Qb2Q=="], + + "@rc-component/mini-decimal": ["@rc-component/mini-decimal@1.1.4", "", { "dependencies": { "@babel/runtime": "^7.18.0" } }, "sha512-xiuXcaCwyOWpD8a8scdExFl+bntNphAW8XeenL1ig2en0AAZY0Pcp4pC0dI22qJ+NvxKn9RoNIoRdqYU3BLH4w=="], + + "@rc-component/motion": ["@rc-component/motion@1.3.3", "", { "dependencies": { "@rc-component/util": "^1.11.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-Xh3IszxvlSv3/PLYFyC2UZi9LNB83yOnkB/LNmRzaypZLvkhqUIPS7MQpGZcCMWrNsXV2p6YTSWbSGvFpEle9A=="], + + "@rc-component/mutate-observer": ["@rc-component/mutate-observer@2.0.1", "", { "dependencies": { "@rc-component/util": "^1.2.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-AyarjoLU5YlxuValRi+w8JRH2Z84TBbFO2RoGWz9d8bSu0FqT8DtugH3xC3BV7mUwlmROFauyWuXFuq4IFbH+w=="], + + "@rc-component/notification": ["@rc-component/notification@2.0.9", "", { "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/util": "^1.13.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-jQT5IVSWSHPTNFXQM2YQONPptjiKXZa4A4Kzwh2xkh3MkL31gRzyjb98N3QHBzLxsyGGJK1aNpn9j9aSO/O0dg=="], + + "@rc-component/overflow": ["@rc-component/overflow@1.0.1", "", { "dependencies": { "@babel/runtime": "^7.11.1", "@rc-component/resize-observer": "^1.0.1", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-syfmgAABaHCnCDzPwHZ/2tuvIcpOO3jefYZMmfkN+pmo8HKTzsfhS57vxo4ksPdN0By+uWVJhJWNFozNBxi2eA=="], + + "@rc-component/pagination": ["@rc-component/pagination@1.4.0", "", { "dependencies": { "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-CW1g7P9V8u+e8JQdUsl2RWg+GCsoee0mtJjZUCCxn/vb3jzOwDKm6hAdwddHCVBfWJ58eGUBZz3IvnU8rRktjw=="], + + "@rc-component/picker": ["@rc-component/picker@1.12.2", "", { "dependencies": { "@rc-component/overflow": "^1.0.0", "@rc-component/resize-observer": "^1.0.0", "@rc-component/trigger": "^3.6.15", "@rc-component/util": "^1.13.0", "clsx": "^2.1.1" }, "peerDependencies": { "date-fns": ">= 2.x", "dayjs": ">= 1.x", "luxon": ">= 3.x", "moment": ">= 2.x", "react": ">=16.9.0", "react-dom": ">=16.9.0" }, "optionalPeers": ["date-fns", "dayjs", "luxon", "moment"] }, "sha512-ZB1A+tzTQkgtR4flPrAMvxIAOelIIOOOsp22fU2Nad6PbZcJacnyGJUJqUH7ZS9mwxeUwurf1pLMKXrVsK1tlA=="], + + "@rc-component/portal": ["@rc-component/portal@2.2.1", "", { "dependencies": { "@rc-component/util": "^1.11.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-ck+r1kW/JSv0wxPji3KN2ss9K6Z0qqwusw/mf/0JobXhZ8hC2ejZwCJObW/SvDi0uhA0VzmCnx0CaCci95tcmA=="], + + "@rc-component/progress": ["@rc-component/progress@1.0.3", "", { "dependencies": { "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-Q1nPLIvKr95el/q76yaBh5nv1hqCsWaHQb8U7vXEDMefEhHgM7n3CPoN+o4LHnTyANQHmMFgoS/naAT4Bhxuaw=="], + + "@rc-component/qrcode": ["@rc-component/qrcode@2.0.0", "", { "dependencies": { "@babel/runtime": "^7.24.7" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-aAv3QhPP1xyafuTZOxub6a54pCeBnN3IwQkpETrBtthq4BL5IgxnCbuoBWPDpdLw1y1j6BgBUCAKV92+yX06Dw=="], + + "@rc-component/rate": ["@rc-component/rate@1.0.1", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-bkXxeBqDpl5IOC7yL7GcSYjQx9G8H+6kLYQnNZWeBYq2OYIv1MONd6mqKTjnnJYpV0cQIU2z3atdW0j1kttpTw=="], + + "@rc-component/resize-observer": ["@rc-component/resize-observer@1.1.2", "", { "dependencies": { "@rc-component/util": "^1.2.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-t/Bb0W8uvL4PYKAB3YcChC+DlHh0Wt5kM7q/J+0qpVEUMLe7Hk5zuvc9km0hMnTFPSx5Z7Wu/fzCLN6erVLE8Q=="], + + "@rc-component/segmented": ["@rc-component/segmented@1.4.0", "", { "dependencies": { "@babel/runtime": "^8.0.0", "@rc-component/motion": "^1.1.4", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-NZlpKGSplOYlmF5ukUF1fCLr0ns5WfjNoqMlMkf4AHP0iu90wyiP7hn2qZKRHEuRlG4CvEEz/lF9qRiYnLTofQ=="], + + "@rc-component/select": ["@rc-component/select@1.10.1", "", { "dependencies": { "@rc-component/overflow": "^1.0.0", "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.11.1", "@rc-component/virtual-list": "^1.2.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-H+yQsl+qED9NilQ3g6zdpsMwUgwVjrcMTkNHAWRVU/MoNCYgTbDgU+MIMgZDK+rVdd2JUfI/MkysMcZZ0cyQKw=="], + + "@rc-component/slider": ["@rc-component/slider@1.1.1", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-LSzgWGYDgeCDgR4r1XlU29gbYws6HpLnvJd/uMhLeW/vQgxldeR+Wb4uzHDCHiYEbr1bnEHWdjkPxjJRHxuiig=="], + + "@rc-component/steps": ["@rc-component/steps@1.2.3", "", { "dependencies": { "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-/b9gHcvDGjTDWJZW7+1kKFvzs0jrEeQ8mFvvIgX+h7xNT0FsOYODJYKjyJR4fPbvMBCfCOEqaBK66HBxov5BQA=="], + + "@rc-component/switch": ["@rc-component/switch@1.0.3", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-Jgi+EbOBquje/XNdofr7xbJQZPYJP+BlPfR0h+WN4zFkdtB2EWqEfvkXJWeipflwjWip0/17rNbxEAqs8hVHfw=="], + + "@rc-component/table": ["@rc-component/table@1.11.1", "", { "dependencies": { "@rc-component/context": "^2.0.1", "@rc-component/resize-observer": "^1.0.0", "@rc-component/util": "^1.11.1", "@rc-component/virtual-list": "^1.0.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-OWdS6DMmeWb7bJBGqPxYZpQbzBlBiXZUu2sqo6Ii7Sjs9GeK1IsrXrWk26SL2c6KEseabswdxrRj7WUm9LdECw=="], + + "@rc-component/tabs": ["@rc-component/tabs@1.13.0", "", { "dependencies": { "@rc-component/dropdown": "~1.0.0", "@rc-component/menu": "~1.5.0", "@rc-component/motion": "^1.1.3", "@rc-component/resize-observer": "^1.0.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-3FXr+9ZII8tFLWuDWrgjRaxZd2mazkKJAGRFEfVmp4r1qnBglOlxQ78l54Td1aj6/iFaaEReKSAF2HToztU5uw=="], + + "@rc-component/tooltip": ["@rc-component/tooltip@1.5.2", "", { "dependencies": { "@rc-component/trigger": "^3.10.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-VLq4BclU3LgRe46g2hTr2bdsM8tFYs7dVwpuD88rPNNg4lCGe8mBSS4FiRzYzS+ffnZ46y4SI8KYKo9K7qBnhw=="], + + "@rc-component/tour": ["@rc-component/tour@2.4.0", "", { "dependencies": { "@rc-component/portal": "^2.2.0", "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.7.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-aui4r4TqmTzwaBgcQxHYep8kM8PTjZFufjokObpy35KfFeZ0k9ArquWFZqegQlH24P14t+F0qO0mGTgzlav1yg=="], + + "@rc-component/tree": ["@rc-component/tree@1.4.0", "", { "dependencies": { "@rc-component/motion": "^1.0.0", "@rc-component/util": "^1.11.1", "@rc-component/virtual-list": "^1.2.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-dGsJGDJQedA0BqqVgj3F8BvHXTSZijyhTXdbAdkcx8lynzZkty/CV3Z3LOm/fxz+BCfl3dfGiAQpb7Q5XNvl0Q=="], + + "@rc-component/tree-select": ["@rc-component/tree-select@1.16.1", "", { "dependencies": { "@rc-component/select": "~1.10.0", "@rc-component/tree": "~1.4.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-a1Oi6EJhqAhdOxxupdJi6fP0RPHMKn5TcfkX2+llaQ4lF4nwfH7b6SCHcnsybaa2s+pk1yZYwVyeOYkDnEBRdg=="], + + "@rc-component/trigger": ["@rc-component/trigger@3.10.1", "", { "dependencies": { "@rc-component/motion": "^1.3.3", "@rc-component/portal": "^2.2.1", "@rc-component/resize-observer": "^1.1.2", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-mXlDN0IXdtV8Yqqm8195ECCyrbmfvvfKvwVvSlH0+qvKD6BUF8gRhEjSy0FOcD1+CcDRHgTiX99LoxfQrmh3Cw=="], + + "@rc-component/upload": ["@rc-component/upload@1.1.1", "", { "dependencies": { "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-GvYWSKeaJTOxxC5p6+nOSadzfvXA1h8C/iHFPFZX+szH3JUXrvs+DLiW8YUTBgvMh8m63mJeHrlYlJzAlg+pDA=="], + + "@rc-component/util": ["@rc-component/util@1.13.0", "", { "dependencies": { "is-mobile": "^5.0.0", "react-is": "^19.2.7" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-IXf2zBfZrbQGT+G8LxnoICDBGnMHIPIiq5HCRn3EDfKUx28x1rnbZBj46ULJf4sM8ImTG1SepqfNBdxZziqQ4w=="], + + "@rc-component/virtual-list": ["@rc-component/virtual-list@1.5.2", "", { "dependencies": { "@babel/runtime": "^8.0.0", "@rc-component/resize-observer": "^1.0.1", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-igfe7NP9cOmDAhLy5z+Juz8fhjOMzMbF7TRoR/yqXIsbU6XZm4TU7ogcm5O553P0rLF6n0j5F6J+n15Izb1tqg=="], + + "@types/node": ["@types/node@26.6.3", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg=="], + + "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], + + "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="], + + "antd": ["antd@6.6.5", "", { "dependencies": { "@ant-design/colors": "^8.0.1", "@ant-design/cssinjs": "^2.1.2", "@ant-design/cssinjs-utils": "^2.1.2", "@ant-design/fast-color": "^3.0.1", "@ant-design/icons": "^6.3.4", "@ant-design/react-slick": "~2.0.0", "@babel/runtime": "^7.29.2", "@rc-component/cascader": "~1.22.0", "@rc-component/checkbox": "~2.0.0", "@rc-component/collapse": "~1.2.0", "@rc-component/color-picker": "~3.1.1", "@rc-component/dialog": "~1.10.0", "@rc-component/drawer": "~1.4.2", "@rc-component/dropdown": "~1.0.3", "@rc-component/form": "~1.8.6", "@rc-component/image": "~1.10.0", "@rc-component/input": "~1.3.1", "@rc-component/input-number": "~1.6.2", "@rc-component/listy": "~1.2.3", "@rc-component/mentions": "~1.12.0", "@rc-component/menu": "~1.5.0", "@rc-component/motion": "^1.3.3", "@rc-component/mutate-observer": "^2.0.1", "@rc-component/notification": "~2.0.8", "@rc-component/pagination": "~1.4.0", "@rc-component/picker": "~1.12.2", "@rc-component/progress": "~1.0.3", "@rc-component/qrcode": "~2.0.0", "@rc-component/rate": "~1.0.1", "@rc-component/resize-observer": "^1.1.2", "@rc-component/segmented": "~1.4.0", "@rc-component/select": "~1.10.1", "@rc-component/slider": "~1.1.1", "@rc-component/steps": "~1.2.3", "@rc-component/switch": "~1.0.3", "@rc-component/table": "~1.11.1", "@rc-component/tabs": "~1.13.0", "@rc-component/tooltip": "~1.5.2", "@rc-component/tour": "~2.4.0", "@rc-component/tree": "~1.4.0", "@rc-component/tree-select": "~1.16.1", "@rc-component/trigger": "^3.10.1", "@rc-component/upload": "~1.1.1", "@rc-component/util": "^1.13.0", "clsx": "^2.1.1", "dayjs": "^1.11.11", "scroll-into-view-if-needed": "^3.1.0", "throttle-debounce": "^5.0.2" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-biJB/aORNU2lvZZCX783S/g8atfE8akrEKRjRrsXVttoMtJbSHLf/Z29niv0blzt6H3BgKvhkQgHoUn5MgCx7w=="], + + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "compute-scroll-into-view": ["compute-scroll-into-view@3.1.1", "", {}, "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw=="], + + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "dayjs": ["dayjs@1.11.23", "", {}, "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ=="], + + "hono": ["hono@4.13.11", "", {}, "sha512-/SMX/RQNJn7oNmFwH6DtwDqcrzZU2otm1FD6OJe2cWF6cfy/F8hq0XVBv7xW3FTJshRQwuBnXHDq2kNsdZnshg=="], + + "is-mobile": ["is-mobile@5.0.0", "", {}, "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ=="], + + "json2mq": ["json2mq@0.2.0", "", { "dependencies": { "string-convert": "^0.2.0" } }, "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA=="], + + "playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="], + + "postal-mime": ["postal-mime@2.7.6", "", {}, "sha512-UUlyE2KlxmvwMGq060onF/VWU3zD6dVW31FwokQ5v26jWd35fbs2MoTFzh+jXnPwAyV2MULMKXcBInGOl5TO6Q=="], + + "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], + + "react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="], + + "react-is": ["react-is@19.3.0", "", {}, "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA=="], + + "react-router": ["react-router@7.18.4", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ=="], + + "react-router-dom": ["react-router-dom@7.18.4", "", { "dependencies": { "react-router": "7.18.4" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw=="], + + "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], + + "scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.1.0", "", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ=="], + + "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], + + "string-convert": ["string-convert@0.2.1", "", {}, "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A=="], + + "stylis": ["stylis@4.4.0", "", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="], + + "throttle-debounce": ["throttle-debounce@5.0.2", "", {}, "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + + "@rc-component/segmented/@babel/runtime": ["@babel/runtime@8.0.5", "", {}, "sha512-7NK+Lz3spQ52XsUGTxIEVU4jYN2/dIaX8sTxRFAUtYmttYZnVh3aehiihv+/Gb7+duMNHl2OrFcBQRyOHScxpg=="], + + "@rc-component/virtual-list/@babel/runtime": ["@babel/runtime@8.0.5", "", {}, "sha512-7NK+Lz3spQ52XsUGTxIEVU4jYN2/dIaX8sTxRFAUtYmttYZnVh3aehiihv+/Gb7+duMNHl2OrFcBQRyOHScxpg=="], + } +} diff --git a/console/frontend/index.html b/console/frontend/index.html new file mode 100644 index 0000000..3d11e43 --- /dev/null +++ b/console/frontend/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + WmailHub + + +
+ + + diff --git a/console/frontend/src/App.tsx b/console/frontend/src/App.tsx new file mode 100644 index 0000000..065db1f --- /dev/null +++ b/console/frontend/src/App.tsx @@ -0,0 +1,121 @@ +/** 应用框架:100vh 布局(侧栏 + 顶栏大标题 + 内容区滚动),会话守卫 */ +import { useEffect, useState } from "react"; +import { Outlet, useLocation, useNavigate } from "react-router-dom"; +import { Avatar, Dropdown, Layout, Menu } from "antd"; +import { + DashboardOutlined, + MailOutlined, + CloudServerOutlined, + GlobalOutlined, + BellOutlined, + KeyOutlined, + UserOutlined, + LogoutOutlined, +} from "@ant-design/icons"; +import { health, logout, me } from "./api.ts"; + +const { Sider, Header, Content } = Layout; + +/** # 成员变量: 侧栏菜单项 **/ +const MENU_ITEMS = [ + { key: "/", icon: , label: "概览" }, + { key: "/nodes", icon: , label: "节点 Nodes" }, + { key: "/domains", icon: , label: "域名 Domains" }, + { key: "/mailboxes", icon: , label: "邮箱 Mailboxes" }, + { key: "/emails", icon: , label: "邮件 Emails" }, + { key: "/webhooks", icon: , label: "Webhooks" }, + { key: "/keys", icon: , label: "API / MCP Keys" }, +]; + +/** # 成员变量: 路由 -> 页面标题/副标题 **/ +const PAGE_META: Record = { + "/": { title: "概览", subtitle: "节点、邮件与投递总览" }, + "/nodes": { title: "节点管理", subtitle: "通过 Cloudflare OAuth 自动安装的转发节点" }, + "/domains": { title: "域名管理", subtitle: "接入 Email Routing 的收件域名" }, + "/mailboxes": { title: "邮箱管理", subtitle: "收件地址与保留策略" }, + "/emails": { title: "邮件", subtitle: "已接收邮件(默认保留 1 天)" }, + "/webhooks": { title: "Webhooks", subtitle: "邮件事件推送与投递日志" }, + "/keys": { title: "API / MCP Keys", subtitle: "wmh_ 前缀凭据,仅创建时可见" }, +}; + +/** # 函数 + * 应用框架组件:未登录跳 /login;顶栏展示页面标题 + 版本号(package.json 来源) + */ +export function App() { + const navigate = useNavigate(); + const location = useLocation(); + const [ready, setReady] = useState(false); + const [username, setUsername] = useState(""); + const [version, setVersion] = useState(""); + + useEffect(() => { + void (async () => { + try { + const [healthInfo, meInfo] = await Promise.all([health(), me()]); + setVersion(healthInfo.version); + setUsername(meInfo.user.username); + setReady(true); + } catch { + navigate("/login", { replace: true }); + } + })(); + }, [navigate]); + + if (!ready) return null; + const meta = PAGE_META[location.pathname] ?? { title: "WmailHub", subtitle: "" }; + + return ( + + +
+ WmailHub + WmailHub +
+ navigate(key)} + style={{ borderInlineEnd: "none" }} + /> + + +
+
+
{meta.title}
+
{meta.subtitle}
+
+
+ v{version} + {/* Dropdown 子元素必须是 DOM 元素(antd v6 坑),所以外包 div */} +
+ , + label: "退出登录", + onClick: async () => { + await logout().catch(() => undefined); + window.location.href = "/login"; + }, + }, + ], + }} + > +
+ } /> + {username} +
+
+
+
+
+ + + +
+ + ); +} diff --git a/console/frontend/src/api.ts b/console/frontend/src/api.ts new file mode 100644 index 0000000..9f7f068 --- /dev/null +++ b/console/frontend/src/api.ts @@ -0,0 +1,291 @@ +/** Console API 客户端:统一 JSON 请求、错误处理、401 跳登录 */ + +/** # 成员变量: 未登录回调(由应用注册,跳转登录页) **/ +let onUnauthorized: (() => void) | null = null; + +/** # 函数 + * 注册 401 处理器 + */ +export function setUnauthorizedHandler(fn: () => void): void { + onUnauthorized = fn; +} + +/** # 函数 + * 通用请求封装 + * 参数: method/url/body(JSON)/query + * 返回值: 解析后的 JSON;非 2xx 抛 ApiError + */ +export async function api( + url: string, + method: string = "GET", + body?: unknown, +): Promise { + const resp = await fetch(url, { + method, + credentials: "include", + headers: body !== undefined ? { "Content-Type": "application/json" } : undefined, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + if (resp.status === 401) { + onUnauthorized?.(); + throw new ApiError(401, "未登录或会话已过期"); + } + const text = await resp.text(); + let data: unknown = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = { raw: text }; + } + if (!resp.ok) { + const message = + (data as { error?: string } | null)?.error ?? `请求失败 (${resp.status})`; + throw new ApiError(resp.status, message, data); + } + return data as T; +} + +/** # 成员变量: API 错误 **/ +export class ApiError extends Error { + constructor( + public status: number, + message: string, + public data?: unknown, + ) { + super(message); + } +} + +/** # 函数 + * 分页查询串拼接 + */ +export function qs(params: Record): string { + const sp = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) { + if (v !== undefined && v !== "") sp.set(k, String(v)); + } + const s = sp.toString(); + return s ? `?${s}` : ""; +} + +/* ---- 具体接口封装 ---- */ + +/** 登录 */ +export const login = (username: string, password: string) => + api<{ ok: boolean; user: { id: string; username: string } }>("/api/auth/login", "POST", { username, password }); + +/** 登出 */ +export const logout = () => api("/api/auth/logout", "POST"); + +/** 当前用户 */ +export const me = () => api<{ user: { id: string; username: string } }>("/api/auth/me"); + +/** 健康检查(含版本号) */ +export const health = () => api<{ ok: boolean; name: string; version: string }>("/api/health"); + +/** Dashboard 统计 */ +export const stats = () => + api<{ + nodes_online: number; + nodes_total: number; + domains: number; + mailboxes: number; + emails: number; + emails_expired: number; + webhooks: number; + deliveries_pending: number; + oauth_connections: number; + recent_emails: { id: string; subject: string; from_address: string; received_at: string; size: number }[]; + }>("/api/admin/stats"); + +/** 手动触发 Cron */ +export const runCron = () => api<{ ok: boolean; stats: Record }>("/api/admin/cron/run", "POST"); + +/* Nodes */ + +export interface NodeRow { + id: string; + name: string; + cloudflare_account_id: string | null; + status: string; + version: string | null; + last_seen_at: string | null; + ingress_url: string; + created_at: string; +} + +export const listNodes = () => api<{ items: NodeRow[]; total: number }>("/api/nodes"); + +export const createNode = (name: string, ingress_url: string) => + api<{ ok: boolean; node_id: string; enrollment_token: string; ingress_secret: string; worker_name: string }>( + "/api/nodes", + "POST", + { name, ingress_url }, + ); + +export interface NodeDetail { + node: NodeRow & { enrollment_expires_at: string | null }; + steps: Record; + oauth: { id: string; account_id: string | null; created_at: string } | null; + domains: { id: string; name: string; status: string; worker_id: string | null }[]; +} + +export const getNode = (id: string) => api(`/api/nodes/${id}`); + +export const deleteNode = (id: string) => api(`/api/nodes/${id}`, "DELETE"); + +export const rotateNode = (id: string) => + api<{ ok: boolean; enrollment_token: string }>(`/api/nodes/${id}/rotate`, "POST"); + +export const getNodeIngressSecret = (id: string) => + api<{ ingress_secret: string }>(`/api/nodes/${id}/ingress-secret`); + +/* OAuth */ + +export const oauthMode = () => api<{ mode: "cloudflare" | "sim" }>("/api/oauth/mode"); + +export const oauthStart = (nodeId: string) => + api<{ url: string; state: string }>(`/api/oauth/cloudflare/start?node_id=${encodeURIComponent(nodeId)}`); + +export const oauthSimApprove = (state: string) => + api<{ ok: boolean; error?: string; node_id: string }>("/api/oauth/cloudflare/sim/approve", "POST", { state }); + +/* Domains */ + +export interface DomainRow { + id: string; + name: string; + status: string; + node_id: string | null; + node_name: string | null; + worker_id: string | null; + mailbox_count: number; + created_at: string; +} + +export const listDomains = () => api<{ items: DomainRow[]; total: number }>("/api/domains"); + +export const createDomain = (name: string) => api<{ ok: boolean; id: string }>("/api/domains", "POST", { name }); + +export const deleteDomain = (id: string) => api(`/api/domains/${id}`, "DELETE"); + +/* Mailboxes */ + +export interface MailboxRow { + id: string; + address: string; + status: string; + is_catch_all: number; + retention_seconds: number | null; + domain_id: string; + domain_name: string; + email_count: number; + created_at: string; +} + +export const listMailboxes = (domainId?: string) => + api<{ items: MailboxRow[]; total: number }>(`/api/mailboxes${qs({ domain_id: domainId })}`); + +export const createMailbox = (body: { domain_id: string; address: string; retention_seconds?: number; catch_all?: boolean }) => + api<{ ok: boolean; id: string; address: string }>("/api/mailboxes", "POST", body); + +export const patchMailbox = (id: string, body: { status?: string; retention_seconds?: number | null }) => + api(`/api/mailboxes/${id}`, "PATCH", body); + +export const deleteMailbox = (id: string) => api(`/api/mailboxes/${id}`, "DELETE"); + +/* Emails */ + +export interface EmailRow { + id: string; + mailbox_id: string | null; + mailbox_address: string | null; + message_id: string | null; + from_address: string | null; + to_addresses: string; + subject: string | null; + size: number; + received_at: string; + expires_at: string; + attachment_count: number; +} + +export const listEmails = (params: { mailbox_id?: string; q?: string; limit?: number; offset?: number }) => + api<{ items: EmailRow[]; total: number }>(`/api/emails${qs(params)}`); + +export interface EmailDetail { + email: EmailRow & { text_body: string | null; html_body: string | null; raw_storage_key: string; cc_addresses: string }; + attachments: { id: string; filename: string; content_type: string; size: number; content_id: string | null }[]; +} + +export const getEmail = (id: string) => api(`/api/emails/${id}`); + +export const deleteEmail = (id: string) => api(`/api/emails/${id}`, "DELETE"); + +/* Webhooks */ + +export interface WebhookRow { + id: string; + name: string; + endpoint: string; + enabled: number; + delivery_count: number; + success_count: number; + created_at: string; +} + +export const listWebhooks = () => api<{ items: WebhookRow[]; total: number }>("/api/webhooks"); + +export const createWebhook = (name: string, endpoint: string) => + api<{ ok: boolean; id: string; secret: string }>("/api/webhooks", "POST", { name, endpoint }); + +export const patchWebhook = (id: string, body: { name?: string; endpoint?: string; enabled?: boolean }) => + api(`/api/webhooks/${id}`, "PATCH", body); + +export const rotateWebhookSecret = (id: string) => + api<{ ok: boolean; secret: string }>(`/api/webhooks/${id}/rotate`, "POST"); + +export const deleteWebhook = (id: string) => api(`/api/webhooks/${id}`, "DELETE"); + +export const testWebhook = (id: string) => + api<{ ok: boolean; status?: number; error?: string }>(`/api/webhooks/${id}/test`, "POST"); + +export interface DeliveryRow { + id: string; + webhook_id: string; + email_id: string; + status: string; + attempt_count: number; + next_retry_at: string | null; + last_error: string | null; + delivered_at: string | null; + created_at: string; + subject: string | null; +} + +export const listDeliveries = (webhookId: string) => + api<{ items: DeliveryRow[] }>(`/api/webhooks/${webhookId}/deliveries`); + +export const redeliver = (deliveryId: string) => + api(`/api/webhooks/deliveries/${deliveryId}/redeliver`, "POST"); + +/* API/MCP Keys */ + +export interface KeyRow { + id: string; + kind: "api" | "mcp"; + name: string; + key_last4: string; + created_at: string; + revoked_at: string | null; + last_used_at: string | null; +} + +export const listKeys = () => api<{ items: KeyRow[] }>("/api/keys"); + +export const createKey = (kind: "api" | "mcp", name?: string) => + api<{ ok: boolean; id: string; kind: string; key: string }>("/api/keys", "POST", { kind, name }); + +export const revokeKey = (id: string) => api(`/api/keys/${id}/revoke`, "POST"); + +export const deleteKey = (id: string) => api(`/api/keys/${id}`, "DELETE"); diff --git a/console/frontend/src/main.tsx b/console/frontend/src/main.tsx new file mode 100644 index 0000000..6add694 --- /dev/null +++ b/console/frontend/src/main.tsx @@ -0,0 +1,55 @@ +/** 前端入口:antd v6 + react-router,路由守卫由 App 内 authGate 状态控制 */ +import React from "react"; +import { createRoot } from "react-dom/client"; +import { ConfigProvider, App as AntdApp } from "antd"; +import zhCN from "antd/locale/zh_CN"; +import { createBrowserRouter, RouterProvider } from "react-router-dom"; +import { antdTheme } from "./theme.ts"; +import "./styles.css"; +import { App } from "./App.tsx"; +import { LoginPage } from "./pages/Login.tsx"; +import { OAuthSimPage } from "./pages/OAuthSim.tsx"; +import { DashboardPage } from "./pages/Dashboard.tsx"; +import { NodesPage } from "./pages/Nodes.tsx"; +import { DomainsPage } from "./pages/Domains.tsx"; +import { MailboxesPage } from "./pages/Mailboxes.tsx"; +import { EmailsPage } from "./pages/Emails.tsx"; +import { WebhooksPage } from "./pages/Webhooks.tsx"; +import { KeysPage } from "./pages/Keys.tsx"; +import { setUnauthorizedHandler } from "./api.ts"; + +/** # 成员变量: 路由表 **/ +const router = createBrowserRouter([ + { path: "/login", element: }, + { path: "/oauth/sim/authorize", element: }, + { + path: "/", + element: , + children: [ + { index: true, element: }, + { path: "nodes", element: }, + { path: "domains", element: }, + { path: "mailboxes", element: }, + { path: "emails", element: }, + { path: "webhooks", element: }, + { path: "keys", element: }, + ], + }, +]); + +/** 未登录统一跳登录页 */ +setUnauthorizedHandler(() => { + if (!location.pathname.startsWith("/login")) { + location.href = "/login"; + } +}); + +createRoot(document.getElementById("root")!).render( + + + + + + + , +); diff --git a/console/frontend/src/pages/Dashboard.tsx b/console/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..484f305 --- /dev/null +++ b/console/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,115 @@ +/** 概览页:统计卡 + 最近邮件 + 手动 Cron */ +import { useCallback, useEffect, useState } from "react"; +import { Button, Card, Col, Row, Statistic, Table, message } from "antd"; +import { ReloadOutlined, ClearOutlined } from "@ant-design/icons"; +import { health, runCron, stats } from "../api.ts"; + +/** # 成员变量: 最近邮件表格列 **/ +const recentColumns = [ + { title: "主题", dataIndex: "subject", ellipsis: true }, + { title: "发件人", dataIndex: "from_address", width: 240, ellipsis: true }, + { title: "大小", dataIndex: "size", width: 100, render: (v: number) => fmtSize(v) }, + { title: "接收时间", dataIndex: "received_at", width: 190, render: (v: string) => new Date(v).toLocaleString() }, +]; + +/** # 函数 + * 字节数格式化 + */ +function fmtSize(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / 1024 / 1024).toFixed(2)} MB`; +} + +/** # 函数 + * 概览页组件 + */ +export function DashboardPage() { + const [data, setData] = useState> | null>(null); + const [version, setVersion] = useState(""); + const [loading, setLoading] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + try { + const [s, h] = await Promise.all([stats(), health()]); + setData(s); + setVersion(h.version); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +