更新个人中心页面

This commit is contained in:
Eamon
2026-09-03 14:37:18 +08:00
parent 3072b97bcf
commit 6d6d300203
20 changed files with 1135 additions and 378 deletions

3
.env.development Normal file
View File

@@ -0,0 +1,3 @@
# 开发环境:php-api 请求走同源 vite proxy(见 vite.config.ts)
# 生产环境使用 .env 中的正式地址
VITE_PHP_API_BASE_URL=

View File

@@ -1,14 +1,14 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
### 首页免费试用按钮做权限校验
Currently, two official plugins are available:
### 个人中心的二维码生成 使用qrcode.js 生成、hover 放大等
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
### 访问权限地方共享状态的封装与使用
## React Compiler
### 实名机制
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
### 联系客服
### dockerfile 文件
### 提取IP流程

View File

@@ -15,6 +15,7 @@
"date-fns": "^4.4.0",
"lucide-react": "^1.17.0",
"next-themes": "^0.4.6",
"qrcode": "^1.5.4",
"radix-ui": "^1.5.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
@@ -30,6 +31,7 @@
"devDependencies": {
"@biomejs/biome": "2.4.16",
"@types/node": "^25.9.2",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
@@ -436,6 +438,8 @@
"@types/node": ["@types/node@25.9.2", "https://registry.npmmirror.com/@types/node/-/node-25.9.2.tgz", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="],
"@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="],
"@types/react": ["@types/react@19.2.17", "https://registry.npmmirror.com/@types/react/-/react-19.2.17.tgz", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.3.tgz", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
@@ -464,6 +468,8 @@
"ansi-regex": ["ansi-regex@6.2.2", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"argparse": ["argparse@2.0.1", "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"aria-hidden": ["aria-hidden@1.2.6", "https://registry.npmmirror.com/aria-hidden/-/aria-hidden-1.2.6.tgz", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
@@ -496,6 +502,8 @@
"callsites": ["callsites@3.1.0", "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
"caniuse-lite": ["caniuse-lite@1.0.30001797", "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", {}, "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w=="],
"ccount": ["ccount@2.0.1", "https://registry.npmmirror.com/ccount/-/ccount-2.0.1.tgz", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
@@ -516,12 +524,18 @@
"cli-spinners": ["cli-spinners@2.9.2", "https://registry.npmmirror.com/cli-spinners/-/cli-spinners-2.9.2.tgz", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
"cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
"clsx": ["clsx@2.1.1", "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"code-block-writer": ["code-block-writer@13.0.3", "https://registry.npmmirror.com/code-block-writer/-/code-block-writer-13.0.3.tgz", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="],
"collapse-white-space": ["collapse-white-space@2.1.0", "https://registry.npmmirror.com/collapse-white-space/-/collapse-white-space-2.1.0.tgz", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "https://registry.npmmirror.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"commander": ["commander@14.0.3", "https://registry.npmmirror.com/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
@@ -552,6 +566,8 @@
"debug": ["debug@4.4.3", "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "https://registry.npmmirror.com/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
"dedent": ["dedent@1.7.2", "https://registry.npmmirror.com/dedent/-/dedent-1.7.2.tgz", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="],
@@ -576,6 +592,8 @@
"diff": ["diff@8.0.4", "https://registry.npmmirror.com/diff/-/diff-8.0.4.tgz", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
"dotenv": ["dotenv@17.4.2", "https://registry.npmmirror.com/dotenv/-/dotenv-17.4.2.tgz", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
"dunder-proto": ["dunder-proto@1.0.1", "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
@@ -586,7 +604,7 @@
"electron-to-chromium": ["electron-to-chromium@1.5.371", "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.371.tgz", {}, "sha512-e9htk9mAYL6AzmkEhSvVVw7IWGSBJ/Bqdn2eRyRLrj1g6sncN4WbFt5qnILYoCktktr45pyjIrOiRvBThQ808w=="],
"emoji-regex": ["emoji-regex@10.6.0", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-10.6.0.tgz", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"encodeurl": ["encodeurl@2.0.0", "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
@@ -660,6 +678,8 @@
"finalhandler": ["finalhandler@2.1.1", "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.1.tgz", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"formdata-polyfill": ["formdata-polyfill@4.0.10", "https://registry.npmmirror.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
"forwarded": ["forwarded@0.2.0", "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
@@ -676,6 +696,8 @@
"gensync": ["gensync@1.0.0-beta.2", "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-east-asian-width": ["get-east-asian-width@1.6.0", "https://registry.npmmirror.com/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
@@ -740,6 +762,8 @@
"is-extglob": ["is-extglob@2.1.1", "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-glob": ["is-glob@4.0.3", "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-hexadecimal": ["is-hexadecimal@2.0.1", "https://registry.npmmirror.com/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
@@ -816,6 +840,8 @@
"lines-and-columns": ["lines-and-columns@1.2.4", "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"log-symbols": ["log-symbols@6.0.0", "https://registry.npmmirror.com/log-symbols/-/log-symbols-6.0.0.tgz", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="],
"longest-streak": ["longest-streak@3.1.0", "https://registry.npmmirror.com/longest-streak/-/longest-streak-3.1.0.tgz", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
@@ -958,6 +984,12 @@
"ora": ["ora@8.2.0", "https://registry.npmmirror.com/ora/-/ora-8.2.0.tgz", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="],
"p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
"parent-module": ["parent-module@1.0.1", "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"parse-entities": ["parse-entities@4.0.2", "https://registry.npmmirror.com/parse-entities/-/parse-entities-4.0.2.tgz", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
@@ -970,6 +1002,8 @@
"path-browserify": ["path-browserify@1.0.1", "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-to-regexp": ["path-to-regexp@8.4.2", "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-8.4.2.tgz", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
@@ -980,6 +1014,8 @@
"pkce-challenge": ["pkce-challenge@5.0.1", "https://registry.npmmirror.com/pkce-challenge/-/pkce-challenge-5.0.1.tgz", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
"postcss": ["postcss@8.5.15", "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
"postcss-selector-parser": ["postcss-selector-parser@7.1.2", "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-7.1.2.tgz", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Wjvt4scRFouioIInHf51IFNP4ltJ2EngJM+cZPGiqbKetBfmP3vpdPV8ID2S6JS6/jdo74N8+aEYH9lQr2C6sA=="],
@@ -994,6 +1030,8 @@
"proxy-addr": ["proxy-addr@2.0.7", "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
"qs": ["qs@6.15.2", "https://registry.npmmirror.com/qs/-/qs-6.15.2.tgz", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="],
"queue-microtask": ["queue-microtask@1.2.3", "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
@@ -1038,8 +1076,12 @@
"remark-rehype": ["remark-rehype@11.1.2", "https://registry.npmmirror.com/remark-rehype/-/remark-rehype-11.1.2.tgz", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"require-from-string": ["require-from-string@2.0.2", "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
"resolve-from": ["resolve-from@4.0.0", "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"restore-cursor": ["restore-cursor@5.1.0", "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-5.1.0.tgz", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
@@ -1066,6 +1108,8 @@
"serve-static": ["serve-static@2.2.1", "https://registry.npmmirror.com/serve-static/-/serve-static-2.2.1.tgz", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
"set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
"set-cookie-parser": ["set-cookie-parser@2.7.2", "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
"setprototypeof": ["setprototypeof@1.2.0", "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
@@ -1100,7 +1144,7 @@
"stdin-discarder": ["stdin-discarder@0.2.2", "https://registry.npmmirror.com/stdin-discarder/-/stdin-discarder-0.2.2.tgz", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="],
"string-width": ["string-width@7.2.0", "https://registry.npmmirror.com/string-width/-/string-width-7.2.0.tgz", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"stringify-entities": ["stringify-entities@4.0.4", "https://registry.npmmirror.com/stringify-entities/-/stringify-entities-4.0.4.tgz", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
@@ -1190,12 +1234,22 @@
"which": ["which@4.0.0", "https://registry.npmmirror.com/which/-/which-4.0.0.tgz", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
"which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"wrappy": ["wrappy@1.0.2", "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"wsl-utils": ["wsl-utils@0.3.1", "https://registry.npmmirror.com/wsl-utils/-/wsl-utils-0.3.1.tgz", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="],
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
"yallist": ["yallist@3.1.1", "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
"yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
"yocto-spinner": ["yocto-spinner@1.2.0", "https://registry.npmmirror.com/yocto-spinner/-/yocto-spinner-1.2.0.tgz", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-Yw0hUB6UA3o4YUgKy3oSe9a4cxoaZ9sBfYDw+JSxo6Id0KoJGoxzPA24qqUXYKBWABs/zDSGTz9kww7t3F0XGw=="],
"yoctocolors": ["yoctocolors@2.1.2", "https://registry.npmmirror.com/yoctocolors/-/yoctocolors-2.1.2.tgz", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
@@ -1226,6 +1280,8 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"cliui/strip-ansi": ["strip-ansi@6.0.1", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"cross-spawn/which": ["which@2.0.2", "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"enquirer/strip-ansi": ["strip-ansi@6.0.1", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@@ -1238,6 +1294,8 @@
"npm-run-path/path-key": ["path-key@4.0.0", "https://registry.npmmirror.com/path-key/-/path-key-4.0.0.tgz", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
"ora/string-width": ["string-width@7.2.0", "https://registry.npmmirror.com/string-width/-/string-width-7.2.0.tgz", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"parse-entities/@types/unist": ["@types/unist@2.0.11", "https://registry.npmmirror.com/@types/unist/-/unist-2.0.11.tgz", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"prompts/kleur": ["kleur@3.0.3", "https://registry.npmmirror.com/kleur/-/kleur-3.0.3.tgz", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
@@ -1248,8 +1306,12 @@
"shadcn/zod": ["zod@3.25.76", "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"string-width/strip-ansi": ["strip-ansi@6.0.1", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"type-is/content-type": ["content-type@2.0.0", "https://registry.npmmirror.com/content-type/-/content-type-2.0.0.tgz", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"@dotenvx/dotenvx/execa/get-stream": ["get-stream@6.0.1", "https://registry.npmmirror.com/get-stream/-/get-stream-6.0.1.tgz", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
"@dotenvx/dotenvx/execa/human-signals": ["human-signals@2.1.0", "https://registry.npmmirror.com/human-signals/-/human-signals-2.1.0.tgz", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
@@ -1262,8 +1324,16 @@
"@dotenvx/dotenvx/execa/strip-final-newline": ["strip-final-newline@2.0.0", "https://registry.npmmirror.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"cross-spawn/which/isexe": ["isexe@2.0.0", "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"enquirer/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ora/string-width/emoji-regex": ["emoji-regex@10.6.0", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-10.6.0.tgz", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
}
}

View File

@@ -19,6 +19,7 @@
"date-fns": "^4.4.0",
"lucide-react": "^1.17.0",
"next-themes": "^0.4.6",
"qrcode": "^1.5.4",
"radix-ui": "^1.5.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
@@ -34,6 +35,7 @@
"devDependencies": {
"@biomejs/biome": "2.4.16",
"@types/node": "^25.9.2",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",

View File

@@ -2,7 +2,6 @@ import DataTable from "@/components/data-table"
import Page from "@/components/page"
import type { CashOutItem } from "@/lib/models/cashOut"
export default function CashOutPage() {
return (
<Page>

View File

@@ -3,7 +3,6 @@ import type {
ConsumptionStats,
DashboardBalance,
IpAccountStats,
RecentOrderItem,
} from "@/lib/models/dashboard"
// TODO: 后端仪表盘接口就绪后删除(见 src/api/dashboard.ts)
@@ -21,29 +20,6 @@ export const MOCK_ACCOUNT_INFO: AccountInfo = {
taobao: "123456123",
}
export const MOCK_RECENT_ORDERS: RecentOrderItem[] = [
{
date: "2025.04.01",
product: "极光IP",
plan: "天卡(尊享)",
price: 3.0,
connections: 1,
account: "jx1234",
orderAmount: 3.0,
paidAmount: 3.0,
},
{
date: "2025.04.01",
product: "极光IP",
plan: "天卡(尊享)",
price: 3.0,
connections: 1,
account: "jx1234",
orderAmount: 3.0,
paidAmount: 3.0,
},
]
export const MOCK_CONSUMPTION: ConsumptionStats = {
today: 30.0,
month: 30.0,

View File

@@ -1,11 +1,22 @@
import { useState } from "react"
import { Check } from "lucide-react"
import { type ReactNode, useEffect, useState } from "react"
import { fetchAgreementStatus, fetchRealNameStatus } from "@/api/agreement"
import { getUserInfo } from "@/api/token"
import AgreementDialog from "@/components/composites/dialogs/agreementDialog"
import {
EditInfoDialog,
type EditInfoValues,
} from "@/components/composites/dialogs/editInfoDialog"
import DataTable from "@/components/data-table"
import RealNameDialog from "@/components/composites/dialogs/realNameDialog"
import Page from "@/components/page"
import { QrCodeImage } from "@/components/qrCodeImage"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { cn } from "@/lib/utils"
import b3 from "../assets/b3.webp"
import b5 from "../assets/b5.webp"
@@ -23,365 +34,465 @@ import {
MOCK_BALANCE,
MOCK_CONSUMPTION,
MOCK_IP_STATS,
MOCK_RECENT_ORDERS,
} from "./mock"
const fmt = (n: number) => n.toFixed(2)
const MANAGER_QR_URLS: Record<number, string> = {
1029: "https://work.weixin.qq.com/u/vc887a78c8a042ee8e?v=4.1.8.133653",
1030: "https://work.weixin.qq.com/u/vc83c509032e079ac3?v=4.1.8.23932",
1017: "https://work.weixin.qq.com/u/vc7e4f48bc9e52e3d0?v=4.1.8.133653",
}
function readUserInfo() {
const raw = getUserInfo<Record<string, unknown>>()
const info = (raw ?? {}) as Record<string, unknown>
const phone = String(
info.Phone ??
info.LoginCode ??
info.phone ??
info.logincode ??
info.Mobile ??
info.mobile ??
"",
)
const managerId = Number(info.ManagerId ?? info.managerId ?? 0)
return {
phone,
managerQrUrl:
Number.isFinite(managerId) && MANAGER_QR_URLS[managerId]
? MANAGER_QR_URLS[managerId]
: "",
}
}
export default function DashboardPage() {
// ========== 数据定义(待接口就绪后改为 useEffect 拉取) ==========
const balanceData = MOCK_BALANCE
const accountInfo = MOCK_ACCOUNT_INFO
const recentOrders = MOCK_RECENT_ORDERS
const consumptionData = MOCK_CONSUMPTION
const ipAccountData = MOCK_IP_STATS
const { phone, managerQrUrl } = readUserInfo()
// 实名认证状态(php-api /agent/index/isverify)、网络安全协议签署状态
// (php-api /tencent/index/get_is_upload)
const [verified, setVerified] = useState<boolean | null>(null)
const [agreementSigned, setAgreementSigned] = useState<boolean | null>(null)
const refreshVerifyState = () => {
if (!phone) return
void fetchRealNameStatus(phone).then(res => {
if (res.success) setVerified(res.data)
})
}
const refreshAgreementState = () => {
void fetchAgreementStatus().then(res => {
if (res.success) setAgreementSigned(res.data)
})
}
useEffect(() => {
if (phone) {
void fetchRealNameStatus(phone).then(res => {
if (res.success) setVerified(res.data)
})
}
void fetchAgreementStatus().then(res => {
if (res.success) setAgreementSigned(res.data)
})
}, [phone])
const [editDialogOpen, setEditDialogOpen] = useState(false)
const [realNameOpen, setRealNameOpen] = useState(false)
const [agreementOpen, setAgreementOpen] = useState(false)
const [managerQrOpen, setManagerQrOpen] = useState(false)
const handleSaveInfo = (data: EditInfoValues) => {
// TODO: 接入 src/api/user.ts updateUserProfile
void data
}
return (
<Page
className={cn(
"grid gap-4 p-4",
"grid-cols-1",
"md:grid-cols-[1fr_1fr_1fr_300px]",
"md:grid-rows-[150px_200px_1fr_1fr]",
)}
>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 md:row-start-1 md:col-start-1 md:col-span-3">
{/* 第一个卡片 */}
<div className="flex items-center justify-between overflow-hidden rounded-lg p-5 bg-linear-to-b from-blue-200 to-blue-50">
<div className="flex flex-col gap-4 min-w-0">
<div>
<span className="text-sm font-medium text-gray-500">
可用余额(元)
</span>
</div>
<div className="flex flex-wrap items-center gap-4 sm:gap-8">
<p className="text-2xl sm:text-3xl font-bold">
{balanceData.balance.toFixed(2)}
</p>
<div className="flex gap-2 sm:gap-4">
<Button
size="sm"
variant="outline"
className="text-xs bg-white text-blue-600 hover:bg-white/90 hover:text-blue-700"
>
去提现
</Button>
<Button
size="sm"
className="text-xs bg-white text-blue-600 hover:bg-white/90 hover:text-blue-700"
>
去充值
</Button>
</div>
</div>
</div>
<img
src={banner}
alt="banner"
className="w-16 h-16 sm:w-24 sm:h-24 object-contain shrink-0"
/>
</div>
// 未返回状态(请求中/失败/无手机号)时默认按「未实名/未签署」展示,
// 接口成功后自动更新为真实状态
const verifyBadge = (() => {
if (verified === true) {
return (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-600">
<Check className="size-3.5" />
已认证
</span>
)
}
return (
<Button
size="sm"
variant="outline"
className="shrink-0 text-blue-600 hover:text-blue-700"
onClick={() => setRealNameOpen(true)}
>
去实名
</Button>
)
})()
{/* 第二个卡片 */}
<div className="flex items-center justify-between overflow-hidden rounded-lg p-5 bg-linear-to-b from-cyan-200 to-cyan-50">
<div className="flex flex-col gap-4 min-w-0">
<div>
<p className="text-sm font-medium text-gray-500">可用H币(个)</p>
const agreementAction = (mobile = false) => {
const className = mobile
? "mt-3 inline-flex items-center gap-1 self-start text-xs sm:hidden"
: "hidden items-center gap-1 text-xs sm:inline-flex"
if (agreementSigned === true) {
return (
<span className={cn(className, "text-emerald-600")}>
签署网络安全协议
<Check className="size-3.5" />
</span>
)
}
return (
<button
type="button"
onClick={() => setAgreementOpen(true)}
className={cn(className, "text-blue-600 hover:text-blue-700")}
>
签署网络安全协议
</button>
)
}
return (
<Page className="grid grid-cols-1 gap-4 content-start">
<section className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-[6fr_3fr_3fr]">
<div className="flex flex-col overflow-hidden rounded-xl bg-card md:col-span-2 lg:col-span-1">
<div className="flex min-h-12 shrink-0 flex-wrap items-center justify-between gap-x-3 gap-y-2 border-b border-gray-100 px-4 py-2 sm:px-5">
<div className="flex min-w-0 items-center gap-2.5">
<h3 className="shrink-0 text-base font-semibold">账户信息</h3>
{verifyBadge}
</div>
<div className="flex flex-wrap items-center gap-4 sm:gap-8">
<p className="text-2xl sm:text-3xl font-bold">
{balanceData.hCoin.toFixed(2)}
</p>
<div className="flex shrink-0 items-center gap-4">
{agreementAction()}
<Button
size="sm"
variant="outline"
className="text-xs bg-white text-blue-600 hover:bg-white/90 hover:text-blue-700"
className="text-blue-600 hover:text-blue-700"
onClick={() => setEditDialogOpen(true)}
>
去充值
编辑
</Button>
</div>
</div>
<img
src={banner2}
alt="banner2"
className="w-16 h-16 sm:w-24 sm:h-24 object-contain shrink-0"
/>
<div className="grid grid-cols-1 md:grid-cols-2">
<div className="flex items-center gap-4 p-5 md:border-r md:border-dashed md:border-gray-200">
<div className="flex w-1/2 shrink-0 flex-col items-center gap-1.5">
<p className="text-center text-[11px] leading-4 text-muted-foreground">
扫二维码联系
<br />
我的客户经理
</p>
<div className="group relative">
<button
type="button"
onClick={() => managerQrUrl && setManagerQrOpen(true)}
className="block size-16 overflow-hidden rounded-lg border border-gray-200"
>
{managerQrUrl ? (
<QrCodeImage text={managerQrUrl} size={64} />
) : (
<img
src="/logo.png"
alt="账号二维码"
className="size-full object-cover"
/>
)}
</button>
{managerQrUrl && (
<div className="pointer-events-none absolute left-20 top-0 z-50 hidden rounded-lg border border-gray-200 bg-white p-2 shadow-lg group-hover:block max-md:hidden">
<QrCodeImage text={managerQrUrl} size={200} />
</div>
)}
</div>
</div>
<div className="flex w-1/2 min-w-0 flex-col justify-center gap-3">
<div className="min-w-0">
<p className="text-xs text-muted-foreground">手机号/用户名</p>
<p className="mt-0.5 truncate text-base font-medium">
{phone || accountInfo.phone}
</p>
</div>
<div>
<p className="text-xs text-muted-foreground">密码</p>
<div className="mt-0.5 flex items-center justify-between gap-2">
<span className="font-mono text-sm">********</span>
<button
type="button"
onClick={() => setEditDialogOpen(true)}
className="shrink-0 text-xs text-blue-600 hover:text-blue-700"
>
修改密码
</button>
</div>
</div>
</div>
</div>
<div className="flex flex-col justify-center p-5">
<dl className="divide-y divide-gray-100">
<InfoRow label="QQ号" value={accountInfo.qq} />
<InfoRow label="微信号" value={accountInfo.wechat} />
<InfoRow label="淘宝会员名" value={accountInfo.taobao} />
<InfoRow label="邮箱" value={accountInfo.email} />
</dl>
{agreementAction(true)}
</div>
</div>
</div>
<BalanceCard
label="可用余额(元)"
amount={fmt(balanceData.balance)}
image={banner}
imageAlt="可用余额"
gradientClassName="from-blue-100 to-blue-50"
>
<Button
size="sm"
className="bg-white text-blue-600 shadow-sm hover:bg-white/80"
>
去提现
</Button>
<Button
size="sm"
className="bg-blue-600 text-white hover:bg-blue-700"
>
去充值
</Button>
</BalanceCard>
<BalanceCard
label="可用H币(个)"
amount={fmt(balanceData.hCoin)}
image={banner2}
imageAlt="可用H币"
gradientClassName="from-cyan-100 to-cyan-50"
>
<Button
size="sm"
className="bg-blue-600 text-white hover:bg-blue-700"
>
去充值
</Button>
</BalanceCard>
</section>
<div className="md:col-start-4 md:row-start-1 md:row-span-2 bg-white rounded-lg p-4 flex flex-col min-h-0">
<h3 className="font-semibold text-lg shrink-0">账号信息</h3>
<div className="mt-4 flex flex-col sm:flex-row justify-between items-start gap-4 flex-1 min-h-0">
{/* 头像/二维码区域 */}
<div className="flex flex-col items-center gap-3 shrink-0 w-full sm:w-auto">
<span className="text-base font-medium truncate w-full text-center sm:text-left">
{accountInfo.phone}
</span>
<Button
variant="outline"
size="sm"
className="w-20 text-xs bg-white text-blue-600 hover:bg-white/90 hover:text-blue-700 border border-blue-600"
>
去实名
</Button>
<div className="w-20 h-20 border border-gray-200 rounded shrink-0 overflow-hidden">
<img
src="/qrcode-placeholder.png"
alt="账号二维码"
className="w-full h-full object-cover"
<section className="grid grid-cols-1 gap-4 lg:grid-cols-[7fr_5fr]">
<div className="flex flex-col overflow-hidden rounded-xl bg-card">
<div className="flex h-12 shrink-0 items-center border-b border-gray-100 px-4 sm:px-5">
<h3 className="text-base font-semibold">消费信息</h3>
</div>
<div className="grid grid-cols-1 gap-4 p-4 sm:grid-cols-[1fr_150px] sm:p-5">
<div className="grid grid-cols-2 gap-3 xl:grid-cols-3">
<StatTile
icon={b3}
alt="今日消费"
label="今日消费"
value={fmt(consumptionData.today)}
/>
<StatTile
icon={b6}
alt="今日退款"
label="今日退款"
value={fmt(consumptionData.todayRefund)}
/>
<StatTile
icon={b8}
alt="今日充值"
label="今日充值"
value={fmt(consumptionData.todayRecharge)}
/>
<StatTile
icon={b5}
alt="当月消费"
label="当月消费"
value={fmt(consumptionData.month)}
/>
<StatTile
icon={b6}
alt="当月退款"
label="当月退款"
value={fmt(consumptionData.monthRefund)}
/>
<StatTile
icon={b9}
alt="当月充值"
label="当月充值"
value={fmt(consumptionData.monthRecharge)}
/>
</div>
</div>
{/* 信息列表 */}
<div className="flex flex-col gap-2 text-sm flex-1 min-w-0 w-full">
<div className="flex items-start py-1 border-b border-gray-50 last:border-0 min-h-7">
<span className="text-gray-400 w-14 shrink-0">密码</span>
<span className="text-gray-700 font-mono tracking-wide truncate">
********
</span>
</div>
<div className="flex items-start py-1 border-b border-gray-50 last:border-0 min-h-7">
<span className="text-gray-400 w-14 shrink-0">QQ</span>
<span className="text-gray-700 truncate">
{accountInfo.qq || "未绑定"}
</span>
</div>
<div className="flex items-start py-1 border-b border-gray-50 last:border-0 min-h-7">
<span className="text-gray-400 w-14 shrink-0">微信号</span>
<span className="text-gray-700 truncate">
{accountInfo.wechat || "未绑定"}
</span>
</div>
<div className="flex items-start py-1 border-b border-gray-50 last:border-0 min-h-7">
<span className="text-gray-400 w-14 shrink-0">淘宝</span>
<span className="text-gray-700 truncate">
{accountInfo.taobao || "未绑定"}
</span>
</div>
<div className="flex items-start py-1 border-b border-gray-50 last:border-0 min-h-7">
<span className="text-gray-400 w-14 shrink-0">邮箱</span>
<span className="text-gray-700 truncate">
{accountInfo.email || "未绑定"}
</span>
<div className="flex flex-row items-center justify-center gap-3 rounded-lg bg-linear-to-br from-blue-50 to-blue-100/60 p-4 text-center ring-1 ring-blue-100 sm:flex-col">
<img src={b7} alt="本年消费" className="size-12 object-contain" />
<div>
<p className="text-2xl font-bold tabular-nums text-blue-600">
{fmt(consumptionData.year)}
</p>
<p className="text-xs text-muted-foreground">本年消费</p>
</div>
</div>
</div>
</div>
<div className="mt-4 flex justify-end shrink-0">
<Button
variant="outline"
size="sm"
className="text-xs bg-white text-blue-600 hover:bg-white/90 hover:text-blue-700 border-transparent hover:border-blue-200"
onClick={() => setEditDialogOpen(true)}
>
编辑信息
</Button>
</div>
</div>
{/* 可以优化掉不要 */}
<div className="md:row-start-2 md:col-start-1 md:col-span-3 bg-white rounded-lg p-4 flex flex-col min-h-0">
<h3 className="font-semibold text-lg mb-4 shrink-0">最近订单</h3>
<div className="flex-1 overflow-auto min-h-0">
<DataTable
data={recentOrders}
status="done"
columns={[
{ accessorKey: "date", header: "日期" },
{ accessorKey: "product", header: "产品" },
{ accessorKey: "plan", header: "套餐" },
{
accessorKey: "price",
header: "单价(元)",
cell: ({ row }) => row.original.price.toFixed(2),
},
{ accessorKey: "connections", header: "总连接数" },
{ accessorKey: "account", header: "账号" },
{
accessorKey: "orderAmount",
header: "订单金额(元)",
cell: ({ row }) => row.original.orderAmount.toFixed(2),
},
{
accessorKey: "paidAmount",
header: "实付金额(元)",
cell: ({ row }) => row.original.paidAmount.toFixed(2),
},
]}
/>
</div>
</div>
{/* ====== 消费信息 + IP账号 ====== */}
<div className="md:col-start-1 md:row-start-3 md:col-span-3 md:row-span-2 bg-white rounded-lg p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-lg">消费信息</h3>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{/* 今日消费 */}
<div className="bg-blue-50 rounded-lg p-3 sm:p-4 flex items-center gap-2 sm:gap-3">
<img
src={b3}
alt="今日消费"
className="w-8 h-8 sm:w-10 sm:h-10 shrink-0"
/>
<div className="min-w-0">
<p className="text-xs sm:text-sm text-gray-500">今日消费</p>
<p className="text-lg sm:text-2xl font-bold text-blue-600">
{consumptionData.today}
</p>
</div>
<div className="flex flex-col overflow-hidden rounded-xl bg-card">
<div className="flex h-12 shrink-0 items-center border-b border-gray-100 px-4 sm:px-5">
<h3 className="text-base font-semibold">IP账号</h3>
</div>
{/* 当月消费 */}
<div className="bg-cyan-50 rounded-lg p-3 sm:p-4 flex items-center gap-2 sm:gap-3">
<img
src={b5}
alt="当月消费"
className="w-8 h-8 sm:w-10 sm:h-10 shrink-0"
<div className="grid grid-cols-3 divide-x divide-gray-100">
<IpStat
icon={b10}
alt="使用中"
value={ipAccountData.using}
label="使用中"
chipClassName="bg-emerald-50"
/>
<div className="min-w-0">
<p className="text-xs sm:text-sm text-gray-500">当月消费</p>
<p className="text-lg sm:text-2xl font-bold text-cyan-600">
{consumptionData.month}
</p>
</div>
</div>
{/* 今日退款 */}
<div className="bg-orange-50 rounded-lg p-3 sm:p-4 flex items-center gap-2 sm:gap-3">
<img
src={b6}
alt="今日退款"
className="w-8 h-8 sm:w-10 sm:h-10 shrink-0"
<IpStat
icon={b12}
alt="总个数"
value={ipAccountData.total}
label="总个数"
chipClassName="bg-blue-50"
/>
<div className="min-w-0">
<p className="text-xs sm:text-sm text-gray-500">今日退款</p>
<p className="text-lg sm:text-2xl font-bold text-orange-600">
{consumptionData.todayRefund}
</p>
</div>
</div>
{/* 本年消费 */}
<div className="bg-fuchsia-50 rounded-lg p-3 sm:p-4 flex items-center gap-2 sm:gap-3">
<img
src={b7}
alt="本年消费"
className="w-8 h-8 sm:w-10 sm:h-10 shrink-0"
<IpStat
icon={b11}
alt="已过期"
value={ipAccountData.expired}
label="已过期"
chipClassName="bg-rose-50"
/>
<div className="min-w-0">
<p className="text-xs sm:text-sm text-gray-500">本年消费</p>
<p className="text-lg sm:text-2xl font-bold text-fuchsia-600">
{consumptionData.year}
</p>
</div>
</div>
{/* 今日充值 */}
<div className="bg-amber-50 rounded-lg p-3 sm:p-4 flex items-center gap-2 sm:gap-3">
<img
src={b8}
alt="今日充值"
className="w-8 h-8 sm:w-10 sm:h-10 shrink-0"
/>
<div className="min-w-0">
<p className="text-xs sm:text-sm text-gray-500">今日充值</p>
<p className="text-lg sm:text-2xl font-bold text-amber-600">
{consumptionData.todayRecharge}
</p>
</div>
</div>
{/* 当月充值 */}
<div className="bg-emerald-50 rounded-lg p-3 sm:p-4 flex items-center gap-2 sm:gap-3">
<img
src={b9}
alt="当月充值"
className="w-8 h-8 sm:w-10 sm:h-10 shrink-0"
/>
<div className="min-w-0">
<p className="text-xs sm:text-sm text-gray-500">当月充值</p>
<p className="text-lg sm:text-2xl font-bold text-emerald-600">
{consumptionData.monthRecharge}
</p>
</div>
</div>
{/* 当月退款 */}
<div className="bg-purple-50 rounded-lg p-3 sm:p-4 flex items-center gap-2 sm:gap-3">
<img
src={b6}
alt="当月退款"
className="w-8 h-8 sm:w-10 sm:h-10 shrink-0"
/>
<div className="min-w-0">
<p className="text-xs sm:text-sm text-gray-500">当月退款</p>
<p className="text-lg sm:text-2xl font-bold text-purple-600">
{consumptionData.monthRefund}
</p>
</div>
</div>
</div>
</div>
{/* ====== IP账号 ====== */}
<div className="md:col-start-4 md:row-start-3 md:row-span-2 bg-white rounded-lg p-4">
<h3 className="font-semibold text-lg mb-6">IP账号</h3>
<div className="grid grid-cols-3 gap-4 text-center">
{/* 使用中 */}
<div className="flex flex-col items-center gap-3">
<div className="w-16 h-16 flex items-center justify-center">
<img
src={b10}
alt="使用中"
className="w-10 h-10 object-contain"
/>
</div>
<p className="text-3xl font-bold">{ipAccountData.using}</p>
<p className="text-gray-500 text-sm">使用中</p>
</div>
{/* 总个数 */}
<div className="flex flex-col items-center gap-3">
<div className="w-16 h-16 flex items-center justify-center">
<img
src={b12}
alt="总个数"
className="w-10 h-10 object-contain"
/>
</div>
<p className="text-3xl font-bold">{ipAccountData.total}</p>
<p className="text-gray-500 text-sm">总个数</p>
</div>
{/* 已过期 */}
<div className="flex flex-col items-center gap-3">
<div className="w-16 h-16 flex items-center justify-center">
<img
src={b11}
alt="已过期"
className="w-10 h-10 object-contain"
/>
</div>
<p className="text-3xl font-bold">{ipAccountData.expired}</p>
<p className="text-gray-500 text-sm">已过期</p>
</div>
</div>
</div>
</section>
<EditInfoDialog
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
onSave={handleSaveInfo}
/>
<RealNameDialog
open={realNameOpen}
onOpenChange={setRealNameOpen}
onVerified={refreshVerifyState}
/>
<AgreementDialog
open={agreementOpen}
onOpenChange={setAgreementOpen}
onSigned={refreshAgreementState}
/>
<Dialog open={managerQrOpen} onOpenChange={setManagerQrOpen}>
<DialogContent className="sm:max-w-xs">
<DialogHeader>
<DialogTitle className="text-center">我的客户经理</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center gap-2 pb-2">
<div className="rounded-lg border border-gray-200 p-2">
<QrCodeImage text={managerQrUrl} size={200} />
</div>
<p className="text-xs text-muted-foreground">请扫码添加</p>
</div>
</DialogContent>
</Dialog>
</Page>
)
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between gap-4 py-2 text-sm">
<dt className="shrink-0 text-muted-foreground">{label}</dt>
<dd className="min-w-0 truncate text-right">{value || "--"}</dd>
</div>
)
}
function BalanceCard(props: {
label: string
amount: string
image: string
imageAlt: string
gradientClassName?: string
children?: ReactNode
}) {
return (
<div
className={cn(
"flex flex-col justify-between gap-5 overflow-hidden rounded-xl bg-linear-to-b p-5",
props.gradientClassName,
)}
>
<div className="flex items-start justify-between gap-3">
<p className="text-sm font-medium text-gray-600">{props.label}</p>
<img
src={props.image}
alt={props.imageAlt}
className="-mr-1 -mt-1 size-14 object-contain"
/>
</div>
<div className="flex min-w-0 flex-col gap-3">
<p className="truncate text-3xl font-bold tabular-nums">
{props.amount}
</p>
<div className="flex flex-wrap gap-2">{props.children}</div>
</div>
</div>
)
}
function StatTile(props: {
icon: string
alt: string
label: string
value: string
}) {
return (
<div className="flex min-w-0 items-center gap-3 rounded-lg bg-gray-50 p-3 ring-1 ring-gray-100">
<img
src={props.icon}
alt={props.alt}
className="size-9 shrink-0 object-contain"
/>
<div className="min-w-0">
<p className="truncate text-xs text-muted-foreground">{props.label}</p>
<p className="truncate text-lg font-semibold tabular-nums">
{props.value}
</p>
</div>
</div>
)
}
function IpStat(props: {
icon: string
alt: string
value: number
label: string
chipClassName?: string
}) {
return (
<div className="flex flex-col items-center justify-center gap-2.5 p-3 text-center sm:p-4">
<div
className={cn(
"flex size-14 shrink-0 items-center justify-center rounded-full",
props.chipClassName,
)}
>
<img
src={props.icon}
alt={props.alt}
className="size-8 object-contain"
/>
</div>
<p className="text-2xl font-bold tabular-nums">{props.value}</p>
<p className="text-xs text-muted-foreground">{props.label}</p>
</div>
)
}

View File

@@ -1,8 +1,6 @@
import DataTable from "@/components/data-table"
import Page from "@/components/page"
export default function FundsPage() {
return (
<Page>

View File

@@ -2,7 +2,6 @@ import DataTable from "@/components/data-table"
import Page from "@/components/page"
import type { HttpRechargeItem } from "@/lib/models/httpRecharge"
export default function HttpRechargePage() {
return (
<Page>

View File

@@ -52,7 +52,7 @@ function AdminMenuItem({
const [isOpen, setIsOpen] = useState(false)
const [pos, setPos] = useState({ top: 0, left: 0 })
const triggerRef = useRef<HTMLDivElement>(null)
const timeoutRef = useRef<number | null>(null)
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const updatePosition = useCallback(() => {
if (triggerRef.current) {

82
src/api/agreement.ts Normal file
View File

@@ -0,0 +1,82 @@
import type { ApiResponse } from "@/lib/api"
import { phpForm, phpGet, phpPost } from "./http"
// ==================== 实名认证 / 网络安全协议(php-api) ====================
// 接口来源:参考 juipnet 老项目 Host/Views/User/Index.cshtml、upload.cshtml
export type RealNameVerifyResult = {
// 10000:进入微信扫码扫脸阶段(返回二维码 url)
// 20000:已认证成功
// 30000:该身份已被其他账号认证
// 其他:姓名与身份证不匹配
code: number
url?: string
msg?: string
}
export async function fetchRealNameStatus(
phone: string,
): Promise<ApiResponse<boolean>> {
const res = await phpGet<{ is_verify?: string | number }>(
`/agent/index/isverify?phone=${encodeURIComponent(phone)}`,
)
if (!res.success) return res
return {
success: true,
data: String(res.data?.is_verify) === "1",
}
}
export async function submitRealName(props: {
idCode: string
name: string
}): Promise<ApiResponse<RealNameVerifyResult>> {
const res = await phpPost<Record<string, unknown>>("/realname/index/verify", {
cookie: document.cookie,
id_code: props.idCode,
name: props.name,
})
if (!res.success) return res
const raw = (res.data ?? {}) as Record<string, unknown>
return {
success: true,
data: {
code: Number(raw.Code ?? raw.code ?? 0),
url: typeof raw.url === "string" ? raw.url : undefined,
msg: typeof raw.msg === "string" ? raw.msg : undefined,
},
}
}
export async function fetchAgreementStatus(): Promise<ApiResponse<boolean>> {
const res = await phpPost<Record<string, unknown>>(
"/tencent/index/get_is_upload",
{ cookie: document.cookie },
)
if (!res.success) return res
return {
success: true,
data: Number(res.data?.code ?? 0) === 1,
}
}
export async function uploadAgreement(props: {
describe: string
file: File
}): Promise<ApiResponse<{ msg?: string }>> {
const form = new FormData()
form.append("cookie", document.cookie)
form.append("describe", props.describe)
form.append("status", "true")
form.append("xieyi", props.file)
const res = await phpForm<Record<string, unknown>>(
"/tencent/index/upload",
form,
)
if (!res.success) return res
return {
success: true,
data: { msg: typeof res.data?.msg === "string" ? res.data.msg : undefined },
}
}

View File

@@ -1,5 +1,4 @@
import type { ApiResponse } from "@/lib/api"
import type { OrderItem, OrderQueryParams } from "@/lib/models/order"
import { callByUser } from "./base"
import { fetchHttpBalance } from "./http"
@@ -20,16 +19,6 @@ export async function fetchDashboardConsumption(
)
}
export async function fetchDashboardRecentOrders(
params: OrderQueryParams,
): Promise<ApiResponse<OrderItem[]>> {
return await callByUser<OrderItem[]>(
"/api/course/v1/order/Page",
{ ...params, PageIndex: 1, PageSize: 10 },
"GET",
)
}
// TODO: 账号信息/IP 统计接口路径待后端确认
export async function fetchDashboardAccountInfo(): Promise<
ApiResponse<Record<string, unknown>>

View File

@@ -48,7 +48,8 @@ export async function phpFetch<T = RawHttpResponse>(
try {
const response = await fetch(`${PHP_API_BASE_URL}${path}`, {
method,
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
headers:
body !== undefined ? { "Content-Type": "application/json" } : undefined,
credentials,
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: controller.signal,
@@ -87,6 +88,47 @@ export async function phpPost<T = RawHttpResponse>(
return phpFetch<T>("POST", path, body, { credentials: "include" })
}
// multipart 上传(手持身份证等文件场景),不设 Content-Type,由浏览器自动补全 boundary
export async function phpForm<T = RawHttpResponse>(
path: string,
form: FormData,
): Promise<ApiResponse<T>> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT)
try {
const response = await fetch(`${PHP_API_BASE_URL}${path}`, {
method: "POST",
credentials: "include",
body: form,
signal: controller.signal,
})
if (!response.ok) {
return {
success: false,
status: response.status,
message: "请求失败",
}
}
const data = (await response.json()) as T
return { success: true, data }
} catch (e) {
if (controller.signal.aborted) {
return {
success: false,
status: 408,
message: "请求超时,请稍后重试",
}
}
return {
success: false,
status: 500,
message: (e as Error).message || "网络错误",
}
} finally {
clearTimeout(timer)
}
}
export async function phpGet<T = RawHttpResponse>(
path: string,
): Promise<ApiResponse<T>> {

View File

@@ -0,0 +1,216 @@
import { useRef, useState } from "react"
import { toast } from "sonner"
import { uploadAgreement } from "@/api/agreement"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
export default function AgreementDialog({
open,
onOpenChange,
onSigned,
}: {
open: boolean
onOpenChange: (open: boolean) => void
onSigned: () => void
}) {
const [describe, setDescribe] = useState("")
const [file, setFile] = useState<File | null>(null)
const [agreed, setAgreed] = useState(false)
const [submitting, setSubmitting] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const handleOpenChange = (next: boolean) => {
onOpenChange(next)
if (!next) {
setDescribe("")
setFile(null)
setAgreed(false)
if (fileInputRef.current) fileInputRef.current.value = ""
}
}
const handleSubmit = async () => {
if (!agreed) {
toast.error("请勾选同意协议!")
return
}
if (describe.trim().length < 2) {
toast.error("业务描述必须填写!")
return
}
if (!file) {
toast.error("手持身份证必须上传!")
return
}
setSubmitting(true)
const res = await uploadAgreement({ describe: describe.trim(), file })
setSubmitting(false)
if (!res.success) {
toast.error(res.message)
return
}
toast.success(res.data.msg || "提交成功,请等待客服审核")
handleOpenChange(false)
onSigned()
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-h-[85dvh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="text-xl font-semibold">
签署网络安全协议
</DialogTitle>
</DialogHeader>
<p className="text-xs leading-5 text-gray-500">
您可通过以下要求勾选同意网络安全协议书后联系客服审核,然后解除微信QQ等屏蔽,新增的账号不会自动解除,记得联系客服哦
</p>
<p className="text-xs leading-5 text-red-500">
注:解除屏蔽后,qq和微信也仅可用于游戏之类的登录授权,不可用于聊天添加好友等聊天软件内部的功能,系统有严密日志,如有此类情况,账号立即封停,且不予退款,若触犯相关法律将上报给公安部门,请您知晓!
</p>
<div className="space-y-1.5">
<Label className="text-sm text-gray-600">
详细描述业务用途<span className="text-red-500">*</span>
</Label>
<Input
value={describe}
onChange={e => setDescribe(e.target.value)}
placeholder="请描述业务用途"
className="h-11 bg-gray-50 border-gray-200"
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm text-gray-600">
上传手持身份证<span className="text-red-500">*</span>
</Label>
<div className="flex items-center gap-3">
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={e => setFile(e.target.files?.[0] ?? null)}
className="block w-full text-sm text-gray-500 file:mr-3 file:rounded-md file:border-0 file:bg-blue-50 file:px-3 file:py-2 file:text-sm file:font-medium file:text-blue-600 hover:file:bg-blue-100"
/>
</div>
<p className="text-xs text-gray-400">手持身份证的半身照片</p>
</div>
<div className="space-y-1.5">
<Label className="text-sm text-gray-600">
网络安全协议书<span className="text-red-500">*</span>
</Label>
<div className="max-h-52 overflow-y-auto rounded-lg border border-gray-200 bg-white p-4 text-xs leading-6 text-gray-600">
<h1 className="text-center text-base font-semibold">
网络信息安全承诺书
</h1>
<p>
本单位(包含个人)郑重承诺遵守本承诺书的有关条款,如有违反本承诺书有关条款的行为,本单位(包含个人)承担由此带来的一切民事、行政和刑事责任。
</p>
<p>
一、本单位(包含个人)承诺遵守《中华人民共和国计算机信息系统安全保护条例》和《计算机信息网络国际联网安全保护管理办法》及有关法律、法规和行政规章制度、文件规定。
</p>
<p>
二、本单位(包含个人)保证不利用网络危害国家安全、泄露国家秘密,不侵犯国家的、社会的、集体的利益和第三方的合法权益,不从事违法犯罪活动。
</p>
<p>
三、本单位(包含个人)承诺严格按照国家相关法律法规做好本单位(包含个人)网站的信息安全管理工作,按政府有关部门要求设立信息安全责任人和信息安全审查员,信息安全责任人和信息安全审查员应在通过公安机关的安全技术培训后,持证上岗。
</p>
<p>
四、本单位(包含个人)承诺健全各项网络安全管理制度和落实各项安全保护技术措施。
</p>
<p>
五、本单位(包含个人)承诺接受公安机关的监督和检查,如实主动提供有关安全保护的信息、资料及数据文件,积极协助查处通过国际联网的计算机信息网络违法犯罪行为。
</p>
<p>
六、本单位(包含个人)承诺不通过互联网制作、复制、查阅和传播下列信息:
</p>
<p className="pl-4">
1、反对宪法所确定的基本原则的。
<br />
2、危害国家安全,泄露国家秘密,颠覆国家政权,破坏国家统一的。
<br />
3、损害国家荣誉和利益的。
<br />
4、煽动民族仇恨、民族歧视,破坏民族团结的。
<br />
5、破坏国家宗教政策,宣扬邪教和封建迷信的。
<br />
6、散布谣言,扰乱社会秩序,破坏社会稳定的。
<br />
7、散布淫秽、色情、赌博、暴力、凶杀、恐怖或者教唆犯罪的。
<br />
8、侮辱或者诽谤他人,侵害他人合法权益的。
<br />
9、含有法律法规禁止的其他内容的。
</p>
<p>
七、本单位(包含个人)承诺不从事任何危害计算机信息网络安全的活动,包括但不限于:
</p>
<p className="pl-4">
1、未经允许,进入计算机信息网络或者使用计算机信息网络资源的。
<br />
2、未经允许,对计算机信息网络功能进行删除、修改或者增加的。
<br />
3、未经允许,对计算机信息网络中存储或者传输的数据和应用程序进行删除、修改或者增加的。
<br />
4、故意制作、传播计算机病毒等破坏性程序的。
<br />
5、其他危害计算机信息网络安全的。
</p>
<p>
八、本单位(包含个人)承诺,当计算机信息系统发生重大安全事故时,立即采取应急措施,保留有关原始记录,并在24小时内向政府监管部门报告,并书面知会贵单位(包含个人)。
</p>
<p>
九、本单位(包含个人)承诺,若本单位(包含个人)租用的拨号账号出现违规违法行为导致贵单位(指华连网络科技有限公司)被处罚的,造成的直接损失由本单位(包含个人)赔偿。
</p>
<p>
十、若违反本承诺书有关条款和国家相关法律法规的,本单位(包含个人)直接承担相应法律责任,造成财产损失的,由本单位(包含个人)直接赔偿。同时,贵单位(指华连网络科技有限公司)有权暂停直至解除双方合作合同。
</p>
<p>
十一、解除屏蔽后,qq和微信也仅可用于游戏之类的登录授权,不可用于聊天添加好友等聊天软件内部的功能,系统有严密日志,如有此类情况,账号立即封停,且不预退款,若触犯相关法律将上报给公安部门,请您知晓!
</p>
<p>十二、本承诺书自勾选确认之日起生效并遵行。</p>
</div>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="agreement-agree"
checked={agreed}
onCheckedChange={checked => setAgreed(checked === true)}
/>
<Label
htmlFor="agreement-agree"
className="text-sm text-gray-600 cursor-pointer"
>
阅读、接受并同意签署以上网络信息安全承诺书
</Label>
</div>
<DialogFooter>
<Button
onClick={() => void handleSubmit()}
disabled={submitting}
className="w-full bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90"
>
{submitting ? "提交中..." : "提交"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -79,7 +79,7 @@ export function EditInfoDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-120 p-6">
<DialogContent className="max-h-[85dvh] overflow-y-auto sm:max-w-120 p-6">
<DialogHeader>
<DialogTitle className="text-xl font-semibold text-center">
编辑信息

View File

@@ -0,0 +1,213 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import { submitRealName } from "@/api/agreement"
import { QrCodeImage } from "@/components/qrCodeImage"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
const realNameSchema = z.object({
name: z.string().trim().min(1, "请输入真实姓名"),
idCode: z
.string()
.trim()
.regex(/^\d{15}$|^\d{18}$|^\d{17}(\d|X|x)$/, "身份证号码不合法"),
})
type RealNameValues = z.infer<typeof realNameSchema>
export default function RealNameDialog({
open,
onOpenChange,
onVerified,
}: {
open: boolean
onOpenChange: (open: boolean) => void
onVerified: () => void
}) {
const [stage, setStage] = useState<"form" | "scan">("form")
const [qrUrl, setQrUrl] = useState("")
const [submitting, setSubmitting] = useState(false)
const form = useForm<RealNameValues>({
resolver: zodResolver(realNameSchema),
defaultValues: { name: "", idCode: "" },
})
const handleOpenChange = (next: boolean) => {
onOpenChange(next)
if (!next) {
form.reset({ name: "", idCode: "" })
setStage("form")
setQrUrl("")
}
}
const handleFinish = () => {
onVerified()
onOpenChange(false)
}
const onSubmit = async (data: RealNameValues) => {
setSubmitting(true)
const res = await submitRealName({
idCode: data.idCode,
name: data.name,
})
setSubmitting(false)
if (!res.success) {
toast.error(res.message)
return
}
const { code, url } = res.data
if (code === 20000) {
toast.success("已经认证成功")
handleFinish()
return
}
if (code === 30000) {
toast.error("该身份已经进行认证,无法再次进行认证,请联系在线客服处理")
handleFinish()
return
}
if (code === 10000 && url) {
setQrUrl(url)
setStage("scan")
return
}
toast.error("请正确填写姓名和身份证号码")
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-h-[85dvh] overflow-y-auto sm:max-w-lg">
{stage === "form" ? (
<>
<DialogHeader>
<DialogTitle className="text-xl font-semibold text-blue-600">
尊敬的用户,您还未进行实名认证!
</DialogTitle>
</DialogHeader>
<div className="max-h-56 overflow-y-auto space-y-2 rounded-lg bg-gray-50 p-4 text-xs leading-5 text-gray-600">
<p>
《中华人民共和国网络安全法》第二十四条规定:
<br />
网络运营者为用户办理网络接入、域名注册服务,办理固定电话、移动电话等入网手续,或者为用户提供信息发布、即时通讯等服务,在与用户签订协议或者确认提供服务时,应当要求用户提供真实身份信息。用户不提供真实身份信息的,网络运营者不得为其提供相关服务。
</p>
<p className="text-red-500">
1、为了你的帐号安全请勿使用他人手机号或身份信息
</p>
<p>
2、聚IP承诺:用户的个人隐私数据仅与国家公安一所信息库比对,本公司不调用及储存用户的实人认证数据,仅接收公安一所方面的实人认证结果!
</p>
<p>3、若您扫脸认证不成功,请联系客服</p>
</div>
<form
className="space-y-4"
onSubmit={form.handleSubmit(onSubmit)}
noValidate
>
<div className="space-y-1.5">
<Label className="text-sm text-gray-600">真实姓名</Label>
<Controller
name="name"
control={form.control}
render={({ field, fieldState }) => (
<>
<Input
{...field}
placeholder="请输入真实姓名"
className="h-11 bg-gray-50 border-gray-200"
/>
<FieldError>{fieldState.error?.message}</FieldError>
</>
)}
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm text-gray-600">身份证号</Label>
<Controller
name="idCode"
control={form.control}
render={({ field, fieldState }) => (
<>
<Input
{...field}
placeholder="请输入身份证号码"
className="h-11 bg-gray-50 border-gray-200"
/>
<FieldError>{fieldState.error?.message}</FieldError>
</>
)}
/>
</div>
<DialogFooter className="gap-3">
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
>
取消
</Button>
<Button
type="submit"
disabled={submitting}
className="bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90"
>
{submitting ? "认证中..." : "确认"}
</Button>
</DialogFooter>
</form>
</>
) : (
<>
<DialogHeader>
<DialogTitle className="text-xl font-semibold text-blue-600">
请打开微信扫码进行认证
</DialogTitle>
</DialogHeader>
<p className="text-center text-sm text-gray-600">
若扫脸后仍未认证成功,请联系客服
</p>
<div className="flex justify-center py-2">
<div className="rounded-lg border border-gray-200 p-2">
<QrCodeImage text={qrUrl} size={240} />
</div>
</div>
<DialogFooter className="gap-3">
<Button variant="outline" onClick={() => handleOpenChange(false)}>
取消
</Button>
<Button
onClick={handleFinish}
className="bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90"
>
实名扫脸结束后请点击
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,48 @@
import QRCode from "qrcode"
import { useEffect, useState } from "react"
import { cn } from "@/lib/utils"
export function QrCodeImage({
text,
size = 70,
className,
}: {
text: string
size?: number
className?: string
}) {
const [dataUrl, setDataUrl] = useState("")
useEffect(() => {
if (!text) {
setDataUrl("")
return
}
let cancelled = false
QRCode.toDataURL(text, {
width: size * 4,
margin: 1,
errorCorrectionLevel: "M",
})
.then(url => {
if (!cancelled) setDataUrl(url)
})
.catch(() => {
if (!cancelled) setDataUrl("")
})
return () => {
cancelled = true
}
}, [text, size])
if (!dataUrl) return null
return (
<img
src={dataUrl}
alt="二维码"
style={{ width: size, height: size }}
className={cn("object-contain", className)}
/>
)
}

View File

@@ -156,7 +156,7 @@ const MenuItem = ({
onMouseEnter: () => void
onMouseLeave: () => void
}) => {
const timeoutRef = useRef<number | null>(null)
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const handleMouseEnter = () => {
if (timeoutRef.current) {

View File

@@ -11,17 +11,6 @@ export type AccountInfo = {
taobao: string
}
export type RecentOrderItem = {
date: string
product: string
plan: string
price: number
connections: number
account: string
orderAmount: number
paidAmount: number
}
export type ConsumptionStats = {
today: number
month: number

View File

@@ -25,6 +25,26 @@ export default defineConfig({
},
},
"/script": "https://php-api.juip.com",
"/http": {
target: "https://php-api.juip.com",
changeOrigin: true,
secure: false,
},
"/agent": {
target: "https://php-api.juip.com",
changeOrigin: true,
secure: false,
},
"/realname": {
target: "https://php-api.juip.com",
changeOrigin: true,
secure: false,
},
"/tencent": {
target: "https://php-api.juip.com",
changeOrigin: true,
secure: false,
},
},
},
})