Compare commits
17 Commits
ff56fbd7c3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c295f15724 | ||
|
|
65e1eca221 | ||
|
|
b5076d7ca7 | ||
|
|
1fc625ede8 | ||
|
|
160a9c9b97 | ||
|
|
6151add53f | ||
|
|
eee72ad74a | ||
|
|
a902f5be29 | ||
|
|
aefbb8143e | ||
|
|
8aaf43ed79 | ||
|
|
8d8c9deba7 | ||
|
|
7f03dbfb56 | ||
|
|
f59e929843 | ||
|
|
6d6d300203 | ||
|
|
3072b97bcf | ||
|
|
b1f99b51e2 | ||
|
|
171f5bc44a |
3
.env.development
Normal file
3
.env.development
Normal file
@@ -0,0 +1,3 @@
|
||||
# 开发环境:php-api 请求走同源 vite proxy(见 vite.config.ts)
|
||||
# 生产环境使用 .env 中的正式地址
|
||||
VITE_PHP_API_BASE_URL=
|
||||
16
README.md
16
README.md
@@ -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流程
|
||||
77
bun.lock
77
bun.lock
@@ -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",
|
||||
@@ -26,10 +27,12 @@
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.15",
|
||||
},
|
||||
"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 +439,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 +469,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 +503,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 +525,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 +567,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 +593,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 +605,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 +679,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 +697,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 +763,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 +841,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 +985,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 +1003,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 +1015,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 +1031,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 +1077,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 +1109,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 +1145,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 +1235,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=="],
|
||||
@@ -1204,6 +1259,8 @@
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "https://registry.npmmirror.com/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
||||
|
||||
"zustand": ["zustand@5.0.15", "https://registry.npmmirror.com/zustand/-/zustand-5.0.15.tgz", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A=="],
|
||||
|
||||
"zwitch": ["zwitch@2.0.4", "https://registry.npmmirror.com/zwitch/-/zwitch-2.0.4.tgz", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||
|
||||
"@dotenvx/dotenvx/commander": ["commander@11.1.0", "https://registry.npmmirror.com/commander/-/commander-11.1.0.tgz", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
|
||||
@@ -1226,6 +1283,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 +1297,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 +1309,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 +1327,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=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -29,11 +30,13 @@
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4.4.3"
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"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",
|
||||
|
||||
BIN
public/img/product_compare.png
Normal file
BIN
public/img/product_compare.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 181 KiB |
78
src/admin/cashOut/page.tsx
Normal file
78
src/admin/cashOut/page.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { useMemo, useState } from "react"
|
||||
import { fetchCashOutList } from "@/api/cashOut"
|
||||
import DataTable from "@/components/data-table"
|
||||
import Page from "@/components/page"
|
||||
import { useList } from "@/lib/hooks/useList"
|
||||
import type { CashOutItem } from "@/lib/models/cashOut"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
const columns: ColumnDef<CashOutItem>[] = [
|
||||
{
|
||||
accessorKey: "username",
|
||||
header: "用户",
|
||||
},
|
||||
{
|
||||
accessorKey: "money",
|
||||
header: "提现金额",
|
||||
cell: ({ row }) => `¥${row.original.money}`,
|
||||
},
|
||||
{
|
||||
accessorKey: "apply_reason",
|
||||
header: "提现理由",
|
||||
cell: ({ row }) => row.original.apply_reason || "--",
|
||||
},
|
||||
{
|
||||
accessorKey: "alipay_account",
|
||||
header: "支付宝账号",
|
||||
},
|
||||
{
|
||||
accessorKey: "remark",
|
||||
header: "备注",
|
||||
cell: ({ row }) => row.original.remark || "--",
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "状态",
|
||||
},
|
||||
{
|
||||
accessorKey: "create_time",
|
||||
header: "提现时间",
|
||||
},
|
||||
]
|
||||
|
||||
export default function CashOutPage() {
|
||||
const { data, status } = useList(fetchCashOutList)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE)
|
||||
|
||||
// 接口返回全量列表,本地做前端分页
|
||||
const paged = useMemo(
|
||||
() => data.slice((page - 1) * pageSize, page * pageSize),
|
||||
[data, page, pageSize],
|
||||
)
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="bg-white rounded-lg p-4">
|
||||
<h3 className="font-semibold text-lg">提现管理</h3>
|
||||
<DataTable<CashOutItem>
|
||||
data={paged}
|
||||
status={status}
|
||||
columns={columns}
|
||||
pagination={{
|
||||
page,
|
||||
size: pageSize,
|
||||
total: data.length,
|
||||
onPageChange: setPage,
|
||||
onSizeChange: size => {
|
||||
setPageSize(size)
|
||||
setPage(1)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
@@ -1,303 +1,325 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useMemo, useState } from "react"
|
||||
import { Controller, useForm } from "react-hook-form"
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { toast } from "sonner"
|
||||
import { z } from "zod"
|
||||
import { fetchMyAccounts } from "@/api/account"
|
||||
import DataTable from "@/components/data-table"
|
||||
import Page from "@/components/page"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import type { ChannelRecord } from "@/lib/models/channel"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { useProducts } from "@/lib/hooks/useProducts"
|
||||
import {
|
||||
EXPIRED_DAY_OPTIONS,
|
||||
type MyAccountItem,
|
||||
type MyAccountQueryParams,
|
||||
} from "@/lib/models/account"
|
||||
|
||||
const filterSchema = z.object({
|
||||
expired_at_end: z.string().optional(),
|
||||
product: z.string().optional(),
|
||||
plan: z.string().optional(),
|
||||
account: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
})
|
||||
type ChannelQuery = {
|
||||
expiredDay: string
|
||||
productId: string
|
||||
packageName: string
|
||||
keyword: string
|
||||
}
|
||||
|
||||
type FormValues = z.infer<typeof filterSchema>
|
||||
const INITIAL_QUERY: ChannelQuery = {
|
||||
expiredDay: "-100",
|
||||
productId: "0",
|
||||
packageName: "0",
|
||||
keyword: "",
|
||||
}
|
||||
|
||||
const initialData: ChannelRecord[] = [
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
function toParams(query: ChannelQuery): MyAccountQueryParams {
|
||||
return {
|
||||
ExpiredDay: Number(query.expiredDay),
|
||||
ProductId: Number(query.productId) || undefined,
|
||||
PackageId: query.packageName === "0" ? undefined : query.packageName,
|
||||
Keyword: query.keyword.trim() || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnDef<MyAccountItem>[] = [
|
||||
{
|
||||
id: "1",
|
||||
startAt: "2025.04.09",
|
||||
product: "极光IP",
|
||||
plan: "测试卡(带宽)",
|
||||
account: "ft8765",
|
||||
password: "765修改",
|
||||
ip: "甘肃-兰州125.75.111.127",
|
||||
server: "p3.125.74.129.38.sdip.cc",
|
||||
connections: 1,
|
||||
expiredAt: "2025.04.09",
|
||||
remaining: "已过期",
|
||||
online: "查看",
|
||||
download: "下载",
|
||||
status: "",
|
||||
accessorKey: "StartTime",
|
||||
header: "开通时间",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
startAt: "2025.04.09",
|
||||
product: "极光IP",
|
||||
plan: "测试卡(带宽)",
|
||||
account: "ft8765",
|
||||
password: "765修改",
|
||||
ip: "辽宁-辽阳123.190.161.192",
|
||||
server: "p16.cqdwx4.sdip.cc",
|
||||
connections: 1,
|
||||
expiredAt: "2025.04.09",
|
||||
remaining: "0天00时37分",
|
||||
online: "查看",
|
||||
download: "下载",
|
||||
status: "",
|
||||
accessorKey: "ProductName",
|
||||
header: "产品",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
startAt: "2025.04.09",
|
||||
product: "极光IP",
|
||||
plan: "静态-项目-20M-月卡",
|
||||
account: "ft8765",
|
||||
password: "765修改",
|
||||
ip: "黑龙江-鸡西100.100.4.249",
|
||||
server: "p37.sdlw2.sdip.cc",
|
||||
connections: 1,
|
||||
expiredAt: "2025.04.09",
|
||||
remaining: "0天00时37分",
|
||||
online: "查看",
|
||||
download: "下载",
|
||||
status: "",
|
||||
accessorKey: "PackageName",
|
||||
header: "套餐",
|
||||
},
|
||||
{
|
||||
accessorKey: "Account",
|
||||
header: "账号",
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-sm">{row.original.Account}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "Pwd",
|
||||
header: "密码",
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-sm">{row.original.Pwd}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "ConnectCount",
|
||||
header: "连接数",
|
||||
},
|
||||
{
|
||||
accessorKey: "EndTime",
|
||||
header: "到期时间",
|
||||
},
|
||||
{
|
||||
accessorKey: "RestTime",
|
||||
header: "剩余时间",
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={
|
||||
row.original.RestTime === "已过期" ? "text-red-500" : undefined
|
||||
}
|
||||
>
|
||||
{row.original.RestTime}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "Status",
|
||||
header: "状态",
|
||||
cell: ({ row }) =>
|
||||
row.original.Status === 1 ? (
|
||||
<span className="text-emerald-600">正常</span>
|
||||
) : (
|
||||
<span className="text-red-500">已退货</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export default function ChannelsPage() {
|
||||
const [filterValues, setFilterValues] = useState<FormValues>({})
|
||||
const [status, setStatus] = useState<"load" | "done">("done")
|
||||
// 行选择状态
|
||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({})
|
||||
const products = useProducts()
|
||||
const [query, setQuery] = useState<ChannelQuery>(INITIAL_QUERY)
|
||||
|
||||
const { control, handleSubmit, reset } = useForm<FormValues>({
|
||||
resolver: zodResolver(filterSchema),
|
||||
defaultValues: {
|
||||
expired_at_end: "",
|
||||
product: "",
|
||||
plan: "",
|
||||
account: "",
|
||||
status: "",
|
||||
},
|
||||
})
|
||||
const [data, setData] = useState<MyAccountItem[]>([])
|
||||
const [status, setStatus] = useState<"load" | "done" | "fail">("load")
|
||||
const [page, setPage] = useState(1)
|
||||
const [selection, setSelection] = useState<Record<string, boolean>>({})
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!filterValues || Object.values(filterValues).every(value => !value)) {
|
||||
return initialData
|
||||
}
|
||||
const selectedAccounts = useMemo(
|
||||
() => data.filter(item => selection[String(item.Id)]),
|
||||
[data, selection],
|
||||
)
|
||||
|
||||
return initialData.filter(record => {
|
||||
if (filterValues.expired_at_end) {
|
||||
return record.expiredAt <= filterValues.expired_at_end
|
||||
}
|
||||
const selectedProduct = useMemo(
|
||||
() =>
|
||||
products.find(item => String(item.Product.Id) === query.productId) ??
|
||||
null,
|
||||
[products, query.productId],
|
||||
)
|
||||
|
||||
if (
|
||||
filterValues.product &&
|
||||
!record.product.includes(filterValues.product)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filterValues.plan && !record.plan.includes(filterValues.plan)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
filterValues.account &&
|
||||
!record.account.includes(filterValues.account)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filterValues.status && record.status !== filterValues.status) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}, [filterValues])
|
||||
|
||||
const onSubmit = (values: FormValues) => {
|
||||
const load = useCallback(async (params: MyAccountQueryParams) => {
|
||||
setStatus("load")
|
||||
setTimeout(() => {
|
||||
setFilterValues(values)
|
||||
setRowSelection({})
|
||||
const res = await fetchMyAccounts(params)
|
||||
if (res.success) {
|
||||
setData(res.data)
|
||||
setStatus("done")
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const onReset = () => {
|
||||
reset()
|
||||
setFilterValues({})
|
||||
setRowSelection({})
|
||||
}
|
||||
|
||||
// 根据选中ID获取对应行数据
|
||||
const getSelectedRows = () => {
|
||||
const ids = Object.keys(rowSelection)
|
||||
return filteredData.filter(item => ids.includes(item.id))
|
||||
}
|
||||
|
||||
// 批量续费
|
||||
const handleRenew = () => {
|
||||
const rows = getSelectedRows()
|
||||
console.log("续费选中数据:", rows)
|
||||
}
|
||||
|
||||
// 单个退货
|
||||
const handleReturn = () => {
|
||||
const rows = getSelectedRows()
|
||||
if (rows.length !== 1) {
|
||||
toast.error("请选择一条数据进行退货")
|
||||
return
|
||||
} else {
|
||||
setStatus("fail")
|
||||
toast.error(res.message)
|
||||
}
|
||||
console.log("退货数据:", rows[0])
|
||||
setPage(1)
|
||||
setSelection({})
|
||||
}, [])
|
||||
|
||||
const loadedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (loadedRef.current) return
|
||||
loadedRef.current = true
|
||||
load(toParams(INITIAL_QUERY))
|
||||
}, [load])
|
||||
|
||||
const applyFilters = (patch: Partial<ChannelQuery>) => {
|
||||
const next = { ...query, ...patch }
|
||||
setQuery(next)
|
||||
load(toParams(next))
|
||||
}
|
||||
const handleReset = () => {
|
||||
setQuery(INITIAL_QUERY)
|
||||
load(toParams(INITIAL_QUERY))
|
||||
}
|
||||
const paged = useMemo(
|
||||
() => data.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
|
||||
[data, page],
|
||||
)
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div>
|
||||
<form className="bg-card rounded-lg" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="flex items-center justify-center gap-4 bg-amber-200 px-3 py-3">
|
||||
<span className="text-orange-600 text-sm">
|
||||
为了给您带来更好的服务体验,请完善QQ号和微信号
|
||||
</span>
|
||||
<Link to="/">立即完善</Link>
|
||||
</div>
|
||||
<div className="flex flex-wrap p-4 items-end gap-4">
|
||||
<Controller
|
||||
name="expired_at_end"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field
|
||||
data-invalid={fieldState.invalid}
|
||||
className="w-48 flex-none"
|
||||
>
|
||||
<FieldLabel>到期时间</FieldLabel>
|
||||
<Input type="datetime-local" step="1" {...field} />
|
||||
<FieldError>{fieldState.error?.message}</FieldError>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="product"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field
|
||||
data-invalid={fieldState.invalid}
|
||||
className="w-48 flex-none"
|
||||
>
|
||||
<FieldLabel>全部产品</FieldLabel>
|
||||
<Input {...field} placeholder="请输入产品名称" />
|
||||
<FieldError>{fieldState.error?.message}</FieldError>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="plan"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field
|
||||
data-invalid={fieldState.invalid}
|
||||
className="w-48 flex-none"
|
||||
>
|
||||
<FieldLabel>全部套餐</FieldLabel>
|
||||
<Input {...field} placeholder="请输入套餐名称" />
|
||||
<FieldError>{fieldState.error?.message}</FieldError>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="account"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field
|
||||
data-invalid={fieldState.invalid}
|
||||
className="w-40 flex-none"
|
||||
>
|
||||
<FieldLabel>账号</FieldLabel>
|
||||
<Input {...field} placeholder="请输入账号" />
|
||||
<FieldError>{fieldState.error?.message}</FieldError>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit">查询</Button>
|
||||
<Button type="button" variant="outline" onClick={onReset}>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="bg-card rounded-lg p-4">
|
||||
<div className="flex gap-4 items-center">
|
||||
<Button type="button" variant="outline" onClick={handleRenew}>
|
||||
续费
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={handleReturn}>
|
||||
退货
|
||||
</Button>
|
||||
<p className="text-orange-600 text-sm">
|
||||
温馨提示:批量续费可通过筛选和搜索功能精准定位账号
|
||||
<div className="bg-white rounded-lg flex flex-col">
|
||||
<div className="flex flex-col items-center bg-amber-100 p-3 gap-2">
|
||||
<p className="text-sm text-gray-600">
|
||||
为了给您带来更好的服务体验,请完善qq号和微信号。
|
||||
<Link
|
||||
to="/admin"
|
||||
className="text-blue-600 hover:text-blue-700 underline-offset-2 hover:underline"
|
||||
>
|
||||
立即完善》
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<DataTable<ChannelRecord>
|
||||
data={filteredData}
|
||||
|
||||
<div className="flex flex-wrap items-end p-4 gap-x-4 gap-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500 whitespace-nowrap">
|
||||
过期时间:
|
||||
</span>
|
||||
<Select
|
||||
value={query.expiredDay}
|
||||
onValueChange={value => applyFilters({ expiredDay: value })}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EXPIRED_DAY_OPTIONS.map(option => (
|
||||
<SelectItem key={option.value} value={String(option.value)}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={query.productId}
|
||||
onValueChange={value =>
|
||||
applyFilters({ productId: value, packageName: "0" })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue placeholder="全部产品" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">全部产品</SelectItem>
|
||||
{products.map(item => (
|
||||
<SelectItem
|
||||
key={item.Product.Id}
|
||||
value={String(item.Product.Id)}
|
||||
>
|
||||
{item.Product.Name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={query.packageName}
|
||||
onValueChange={value => applyFilters({ packageName: value })}
|
||||
>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue placeholder="全部套餐" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">全部套餐</SelectItem>
|
||||
{selectedProduct?.Packages.filter(pkg => pkg.Status === 1).map(
|
||||
pkg => (
|
||||
<SelectItem key={pkg.Id} value={pkg.Name}>
|
||||
{pkg.Name}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={query.keyword}
|
||||
onChange={e => setQuery({ ...query, keyword: e.target.value })}
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Enter") applyFilters({})
|
||||
}}
|
||||
placeholder="账号"
|
||||
className="w-40"
|
||||
/>
|
||||
<Button onClick={() => applyFilters({})}>
|
||||
<SearchIcon data-icon="inline-start" />
|
||||
查询
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleReset}>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 pt-2 p-4">
|
||||
<Button
|
||||
className="bg-red-600 hover:bg-red-700 text-white"
|
||||
onClick={() => {
|
||||
if (selectedAccounts.length === 0) {
|
||||
toast.error("请先勾选需要续费的账号")
|
||||
return
|
||||
}
|
||||
const productIds = new Set(
|
||||
selectedAccounts.map(item => item.ProductId),
|
||||
)
|
||||
if (productIds.size > 1) {
|
||||
toast.error("所选账号必须为同一种产品")
|
||||
return
|
||||
}
|
||||
const accounts = selectedAccounts
|
||||
.map(item => item.Account)
|
||||
.join(",")
|
||||
const productId = selectedAccounts[0].ProductId
|
||||
// 续费走后端老页面(/product/rebuyindex → /product/rebuy),与 juipnet 行为一致
|
||||
window.location.href = `/product/rebuyindex?accounts=${encodeURIComponent(accounts)}&productId=${productId}`
|
||||
}}
|
||||
>
|
||||
续费
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
onClick={() => toast.info("退货功能开发中,敬请期待")}
|
||||
>
|
||||
退货
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-amber-500 hover:bg-amber-600 text-white"
|
||||
onClick={() => toast.info("批量修改密码功能开发中,敬请期待")}
|
||||
>
|
||||
批量修改密码
|
||||
</Button>
|
||||
<span className="text-sm text-red-500">
|
||||
*批量续费可通过筛选和搜索功能精准定位账号
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg p-4 flex flex-col gap-4">
|
||||
<h3 className="font-semibold text-lg">IP账号管理</h3>
|
||||
<DataTable
|
||||
data={paged}
|
||||
status={status}
|
||||
columns={[
|
||||
{ accessorKey: "startAt", header: "开通时间" },
|
||||
{ accessorKey: "product", header: "产品" },
|
||||
{ accessorKey: "plan", header: "套餐" },
|
||||
{ accessorKey: "account", header: "账号" },
|
||||
{ accessorKey: "password", header: "密码" },
|
||||
{ accessorKey: "ip", header: "IP地址" },
|
||||
{ accessorKey: "server", header: "服务器" },
|
||||
{ accessorKey: "connections", header: "连接数" },
|
||||
{ accessorKey: "expiredAt", header: "到期时间" },
|
||||
{ accessorKey: "remaining", header: "剩余时间" },
|
||||
{
|
||||
accessorKey: "online",
|
||||
header: "在线及拨线",
|
||||
cell: ({ row }) => (
|
||||
<Button size="sm" variant="outline">
|
||||
{row.original.online}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{ accessorKey: "status", header: "选择地区" },
|
||||
{
|
||||
accessorKey: "download",
|
||||
header: "软件下载",
|
||||
cell: ({ row }) => (
|
||||
<Button size="sm" variant="ghost">
|
||||
{row.original.download}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
pagination={{
|
||||
page: 0,
|
||||
size: 10,
|
||||
total: filteredData.length,
|
||||
}}
|
||||
columns={columns}
|
||||
enableSelection
|
||||
getRowId={row => row.id}
|
||||
rowSelection={rowSelection}
|
||||
onSelectionChange={setRowSelection}
|
||||
rowSelection={selection}
|
||||
onSelectionChange={setSelection}
|
||||
getRowId={row => String(row.Id)}
|
||||
pagination={{
|
||||
page,
|
||||
size: PAGE_SIZE,
|
||||
total: data.length,
|
||||
onPageChange: setPage,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Page>
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
ClipboardList,
|
||||
Clock1,
|
||||
FolderCode,
|
||||
LayoutDashboard,
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftOpenIcon,
|
||||
ReceiptJapaneseYenIcon,
|
||||
@@ -14,10 +13,12 @@ import {
|
||||
ShoppingBag,
|
||||
TicketPercent,
|
||||
UserCheck,
|
||||
UserRound,
|
||||
} from "lucide-react"
|
||||
import { type ReactNode, useState } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import ProfileOrLogin from "@/components/profileOrLogin"
|
||||
import { useLayoutStore } from "@/components/stores/layout"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -27,11 +28,10 @@ import {
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import logoAvatar from "./assets/logo-avatar.svg"
|
||||
import { useLayout } from "./layout-context"
|
||||
import { AdminNavMenu } from "./nav-menu"
|
||||
|
||||
export function Shell(props: { children: ReactNode }) {
|
||||
const { navbar } = useLayout()
|
||||
const navbar = useLayoutStore(state => state.navbar)
|
||||
return (
|
||||
<div
|
||||
data-expand={navbar}
|
||||
@@ -50,7 +50,7 @@ export function Shell(props: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
export function Content(props: { children: ReactNode }) {
|
||||
const { navbar } = useLayout()
|
||||
const navbar = useLayoutStore(state => state.navbar)
|
||||
return (
|
||||
<div
|
||||
data-expand={navbar}
|
||||
@@ -59,7 +59,7 @@ export function Content(props: { children: ReactNode }) {
|
||||
"absolute inset-0 overflow-hidden",
|
||||
"mt-16",
|
||||
"md:ml-16",
|
||||
"lg:data-[expand=true]:ml-50",
|
||||
"md:data-[expand=true]:ml-50",
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
@@ -68,7 +68,8 @@ export function Content(props: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
export function Mask() {
|
||||
const { navbar, setNavbar } = useLayout()
|
||||
const navbar = useLayoutStore(state => state.navbar)
|
||||
const setNavbar = useLayoutStore(state => state.setNavbar)
|
||||
return (
|
||||
<button
|
||||
data-expand={navbar}
|
||||
@@ -85,7 +86,8 @@ export function Mask() {
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
const { navbar, toggleNavbar } = useLayout()
|
||||
const navbar = useLayoutStore(state => state.navbar)
|
||||
const toggleNavbar = useLayoutStore(state => state.toggleNavbar)
|
||||
return (
|
||||
<header className="flex-none h-16 flex items-stretch">
|
||||
<div className="flex-none flex items-center">
|
||||
@@ -108,7 +110,7 @@ export function Header() {
|
||||
}
|
||||
|
||||
export function Navbar() {
|
||||
const { navbar } = useLayout()
|
||||
const navbar = useLayoutStore(state => state.navbar)
|
||||
|
||||
return (
|
||||
<nav
|
||||
@@ -119,8 +121,25 @@ export function Navbar() {
|
||||
`data-[expand=true]:basis-52 data-[expand=false]:basis-16`,
|
||||
)}
|
||||
>
|
||||
<Link to="/" className="flex-none h-16 flex items-center justify-center">
|
||||
<img src={logoAvatar} alt="logo" className="w-10 h-10 object-contain" />
|
||||
<Link
|
||||
to="/"
|
||||
className="flex-none h-16 flex items-center justify-center gap-2 group-data-[expand=false]:gap-0"
|
||||
>
|
||||
<img
|
||||
src={logoAvatar}
|
||||
alt="logo"
|
||||
className="w-10 h-10 object-contain flex-none"
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"text-2xl max-md:text-xl font-bold whitespace-nowrap overflow-hidden",
|
||||
"transition-[width,opacity] duration-300 ease-in-out",
|
||||
"group-data-[expand=true]:w-auto group-data-[expand=true]:opacity-100",
|
||||
"group-data-[expand=false]:w-0 group-data-[expand=false]:opacity-0",
|
||||
)}
|
||||
>
|
||||
聚IP
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<section
|
||||
@@ -133,16 +152,12 @@ export function Navbar() {
|
||||
<TooltipProvider>
|
||||
<NavItem
|
||||
href="/admin"
|
||||
icon={<LayoutDashboard size={20} />}
|
||||
label="账户总览"
|
||||
expand={navbar}
|
||||
/>
|
||||
<NavItem
|
||||
href="/admin/whitelist"
|
||||
icon={<UserCheck size={20} />}
|
||||
label="白名单"
|
||||
icon={<UserRound size={20} />}
|
||||
label="个人中心"
|
||||
expand={navbar}
|
||||
/>
|
||||
<NavTitle label="IP管理" />
|
||||
|
||||
<NavItem
|
||||
href="/admin/channels"
|
||||
icon={<FolderCode size={20} />}
|
||||
@@ -155,6 +170,12 @@ export function Navbar() {
|
||||
label="资金明细"
|
||||
expand={navbar}
|
||||
/>
|
||||
<NavItem
|
||||
href="/admin/rosorder"
|
||||
icon={<Router size={20} />}
|
||||
label="软路由订单"
|
||||
expand={navbar}
|
||||
/>
|
||||
<NavItem
|
||||
href="/admin/orders"
|
||||
icon={<ShoppingBag size={20} />}
|
||||
@@ -164,21 +185,22 @@ export function Navbar() {
|
||||
<NavItem
|
||||
href="/admin/refundOrders"
|
||||
icon={<ClipboardList size={20} />}
|
||||
label="退货管理"
|
||||
label="退货订单管理"
|
||||
expand={navbar}
|
||||
/>
|
||||
<NavItem
|
||||
href="/admin/coupon"
|
||||
icon={<TicketPercent size={20} />}
|
||||
label="优惠券"
|
||||
label="我的优惠券"
|
||||
expand={navbar}
|
||||
/>
|
||||
<NavItem
|
||||
href="/admin/channels"
|
||||
href="/admin/cashOut"
|
||||
icon={<ReceiptJapaneseYenIcon size={20} />}
|
||||
label="提现管理"
|
||||
expand={navbar}
|
||||
/>
|
||||
<NavTitle label="HTTP管理" />
|
||||
<NavItem
|
||||
href="/admin/httpRecharge"
|
||||
icon={<Archive size={20} />}
|
||||
@@ -197,18 +219,24 @@ export function Navbar() {
|
||||
label="长效管理"
|
||||
expand={navbar}
|
||||
/>
|
||||
<NavItem
|
||||
href="/admin/rosorder"
|
||||
icon={<Router size={20} />}
|
||||
label="软路由"
|
||||
expand={navbar}
|
||||
/>
|
||||
<NavItem
|
||||
href="/admin/record"
|
||||
icon={<ClipboardClock size={20} />}
|
||||
label="使用记录"
|
||||
expand={navbar}
|
||||
/>
|
||||
<NavItem
|
||||
href="/admin/whitelist"
|
||||
icon={<UserCheck size={20} />}
|
||||
label="白名单"
|
||||
expand={navbar}
|
||||
/>
|
||||
<NavItem
|
||||
href="/admin/whitelistSU"
|
||||
icon={<UserCheck size={20} />}
|
||||
label="白名单(短效)"
|
||||
expand={navbar}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</section>
|
||||
</nav>
|
||||
@@ -222,7 +250,7 @@ function NavItem(props: {
|
||||
expand?: boolean
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { setNavbar } = useLayout()
|
||||
const setNavbar = useLayoutStore(state => state.setNavbar)
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!props.expand) {
|
||||
@@ -270,3 +298,31 @@ function NavItem(props: {
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function NavTitle(props: { label: string }) {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
`transition-[height] duration-300 ease-in-out`,
|
||||
`text-sm text-gray-500 whitespace-nowrap flex items-center relative`,
|
||||
`group-data-[expand=true]:h-9`,
|
||||
`group-data-[expand=false]:h-4`,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
`transition-opacity duration-200 ease-in-out absolute mx-4`,
|
||||
`group-data-[expand=true]:delay-100 group-data-[expand=true]:opacity-100 group-data-[expand=false]:opacity-0`,
|
||||
)}
|
||||
>
|
||||
{props.label}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
`transition-opacity duration-200 ease-in-out absolute w-full border-b block`,
|
||||
`group-data-[expand=false]:delay-100 group-data-[expand=false]:opacity-100 group-data-[expand=true]:opacity-0`,
|
||||
)}
|
||||
></span>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,170 @@
|
||||
import { CheckCircle, Clock, Gift, Tag, Ticket, XCircle } from "lucide-react"
|
||||
import { useMemo } from "react"
|
||||
import { fetchMyCoupons } from "@/api/coupon"
|
||||
import Page from "@/components/page"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { useList } from "@/lib/hooks/useList"
|
||||
import type { MyCouponItem } from "@/lib/models/coupon"
|
||||
import { formatDate } from "@/lib/utils"
|
||||
|
||||
const COUPON_TYPE_DISCOUNT = 1
|
||||
|
||||
function CouponCard({ item }: { item: MyCouponItem }) {
|
||||
const isDiscount = item.Coupon.CouponType === COUPON_TYPE_DISCOUNT
|
||||
const isUsed = item.IsUsed
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`relative overflow-hidden transition-shadow hover:shadow-md ${isUsed ? "opacity-60" : ""}`}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex min-w-20 flex-col items-center justify-center rounded-lg bg-primary/10 p-3">
|
||||
{isDiscount ? (
|
||||
<>
|
||||
<span className="text-2xl font-bold text-primary">
|
||||
{item.Coupon.CouponValue}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">折</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">¥</span>
|
||||
<span className="text-2xl font-bold text-primary">
|
||||
{item.Coupon.CouponValue}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-medium">{item.Coupon.Name}</h4>
|
||||
{isUsed ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<CheckCircle className="mr-1 h-3 w-3" />
|
||||
已使用
|
||||
</Badge>
|
||||
) : item.IsExpired ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<XCircle className="mr-1 h-3 w-3" />
|
||||
已过期
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="default" className="text-xs">
|
||||
<Ticket className="mr-1 h-3 w-3" />
|
||||
可使用
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Tag className="h-3 w-3" />
|
||||
{item.Coupon.AllowMinAmount > 0
|
||||
? `满${item.Coupon.AllowMinAmount}元可用`
|
||||
: "无限制"}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatDate(item.Orgin.StartTime)} -{" "}
|
||||
{formatDate(item.Orgin.EndTime)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Gift className="h-3 w-3" />
|
||||
{item.Orgin.Remark}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isUsed && (
|
||||
<div className="absolute right-4 top-4">
|
||||
<XCircle className="h-6 w-6 text-muted-foreground/50" />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CouponPage() {
|
||||
return <Page>优惠券</Page>
|
||||
const { data: coupons, status } = useList(fetchMyCoupons)
|
||||
|
||||
const availableCoupons = useMemo(
|
||||
() => coupons.filter(item => !item.IsUsed && !item.IsExpired),
|
||||
[coupons],
|
||||
)
|
||||
const usedCoupons = useMemo(
|
||||
() => coupons.filter(item => item.IsUsed),
|
||||
[coupons],
|
||||
)
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="bg-white rounded-lg p-4">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">我的优惠券</h3>
|
||||
<Badge variant="secondary" className="text-sm">
|
||||
共 {coupons.length} 张
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="available" className="w-full">
|
||||
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||
<TabsTrigger value="available">
|
||||
可使用 ({availableCoupons.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="used">
|
||||
已使用 ({usedCoupons.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="available" className="mt-4">
|
||||
{status === "load" ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
) : status === "fail" ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<XCircle className="mb-4 h-12 w-12 opacity-50" />
|
||||
<p>加载失败,请刷新重试</p>
|
||||
</div>
|
||||
) : availableCoupons.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Ticket className="mb-4 h-12 w-12 opacity-50" />
|
||||
<p>暂无可用优惠券</p>
|
||||
<p className="text-sm">快去参与活动领取吧</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{availableCoupons.map(item => (
|
||||
<CouponCard key={item.Orgin.Id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="used" className="mt-4">
|
||||
{usedCoupons.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<CheckCircle className="mb-4 h-12 w-12 opacity-50" />
|
||||
<p>暂无已使用的优惠券</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{usedCoupons.map(item => (
|
||||
<CouponCard key={item.Orgin.Id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
import { useState } from "react"
|
||||
import {
|
||||
EditInfoDialog,
|
||||
type EditInfoValues,
|
||||
} from "@/components/composites/dialogs/editInfoDialog"
|
||||
import DataTable from "@/components/data-table"
|
||||
import { Check } from "lucide-react"
|
||||
import { type ReactNode, useEffect, useState } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { toast } from "sonner"
|
||||
import type { UserInfoRecord } from "@/api/token"
|
||||
import AgreementDialog from "@/components/composites/dialogs/agreementDialog"
|
||||
import { CashOutDialog } from "@/components/composites/dialogs/cashOutDialog"
|
||||
import { ChangePwdDialog } from "@/components/composites/dialogs/changePwdDialog"
|
||||
import { EditInfoDialog } from "@/components/composites/dialogs/editInfoDialog"
|
||||
import RealNameDialog from "@/components/composites/dialogs/realNameDialog"
|
||||
import { RechargeDialog } from "@/components/composites/dialogs/rechargeDialog"
|
||||
import Page from "@/components/page"
|
||||
import { QrCodeImage } from "@/components/qrCodeImage"
|
||||
import { useDashboardStore } from "@/components/stores/dashboard"
|
||||
import { useProfileStore } from "@/components/stores/profile"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import type {
|
||||
AccountInfo,
|
||||
ConsumptionStats,
|
||||
DashboardBalance,
|
||||
IpAccountStats,
|
||||
} from "@/lib/models/dashboard"
|
||||
import { clearPayPaid, PAY_PAID_KEY } from "@/lib/recharge"
|
||||
import { cn } from "@/lib/utils"
|
||||
import b3 from "../assets/b3.webp"
|
||||
import b5 from "../assets/b5.webp"
|
||||
@@ -19,405 +40,494 @@ import b12 from "../assets/b12.webp"
|
||||
import banner from "../assets/banner.webp"
|
||||
import banner2 from "../assets/banner2.webp"
|
||||
|
||||
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(userInfo: UserInfoRecord | null) {
|
||||
const info = userInfo ?? {}
|
||||
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]
|
||||
: "",
|
||||
}
|
||||
}
|
||||
|
||||
const num = (value: unknown) =>
|
||||
typeof value === "number" && Number.isFinite(value) ? value : 0
|
||||
|
||||
export default function DashboardPage() {
|
||||
const balanceData = {
|
||||
balance: 68.6,
|
||||
hCoin: 68.6,
|
||||
const navigate = useNavigate()
|
||||
const apiIndex = useDashboardStore(state => state.data)
|
||||
const agreementSigned = useDashboardStore(state => state.agreementSigned)
|
||||
const refresh = useDashboardStore(state => state.refresh)
|
||||
const refreshAgreement = useDashboardStore(state => state.refreshAgreement)
|
||||
const sessionUserInfo = useProfileStore(state => state.userInfo)
|
||||
|
||||
// 页面挂载时加载一次账户总览与协议签署状态
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
refreshAgreement()
|
||||
}, [refresh, refreshAgreement])
|
||||
|
||||
// 支付窗口完成支付后(storage 事件)自动刷新余额
|
||||
useEffect(() => {
|
||||
const handler = (e: StorageEvent) => {
|
||||
if (e.key !== PAY_PAID_KEY || !e.newValue) return
|
||||
toast.success("充值成功,余额已更新")
|
||||
clearPayPaid()
|
||||
refresh()
|
||||
}
|
||||
window.addEventListener("storage", handler)
|
||||
return () => window.removeEventListener("storage", handler)
|
||||
}, [refresh])
|
||||
|
||||
const user = apiIndex?.UserModel
|
||||
const statistic = apiIndex?.Statistic
|
||||
const account = apiIndex?.AccountModel
|
||||
|
||||
const balanceData: DashboardBalance = {
|
||||
balance: num(user?.RestAmount),
|
||||
hCoin: num(user?.ju_money),
|
||||
}
|
||||
|
||||
const accountInfo = {
|
||||
phone: "191****1234",
|
||||
qq: "123456123",
|
||||
wechat: "123456123",
|
||||
email: "123456123@qq.com",
|
||||
taobao: "123456123",
|
||||
const accountInfo: AccountInfo = {
|
||||
phone: String(user?.LoginCode ?? user?.Phone ?? ""),
|
||||
qq: String(user?.QQ ?? ""),
|
||||
wechat: String(user?.Wx ?? ""),
|
||||
email: String(user?.Email ?? ""),
|
||||
taobao: "",
|
||||
}
|
||||
|
||||
const recentOrders = [
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
]
|
||||
|
||||
const consumptionData = {
|
||||
today: 2365,
|
||||
month: 312365,
|
||||
todayRefund: 2365,
|
||||
year: 256562,
|
||||
monthRefund: 30,
|
||||
todayRecharge: 50,
|
||||
monthRecharge: 20,
|
||||
const consumptionData: ConsumptionStats = {
|
||||
today: num(statistic?.TodayExpend),
|
||||
todayRefund: num(statistic?.TodayRefund),
|
||||
todayRecharge: num(statistic?.TodayCharege),
|
||||
month: num(statistic?.MonthExpend),
|
||||
monthRefund: num(statistic?.MonthRefund),
|
||||
monthRecharge: num(statistic?.MonthCharege),
|
||||
year: num(statistic?.YearExpend),
|
||||
}
|
||||
|
||||
const ipAccountData = {
|
||||
using: 30,
|
||||
total: 50,
|
||||
expired: 20,
|
||||
yearConsumption: 2365,
|
||||
const ipAccountData: IpAccountStats = {
|
||||
total: num(account?.TotalCount),
|
||||
expired: num(account?.ExpriedCount),
|
||||
using: Math.max(num(account?.TotalCount) - num(account?.ExpriedCount), 0),
|
||||
yearConsumption: 0,
|
||||
}
|
||||
|
||||
const { phone, managerQrUrl } = readUserInfo(sessionUserInfo)
|
||||
|
||||
// 实名认证状态:(0-未认证;1-已认证;2-已禁用)
|
||||
const verified = user ? user.is_verify === 1 : null
|
||||
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false)
|
||||
const handleSaveInfo = (data: EditInfoValues) => {
|
||||
console.log("保存的信息:", data)
|
||||
// 这里调 API 保存数据
|
||||
}
|
||||
return (
|
||||
<Page
|
||||
className={cn(
|
||||
"grid gap-4 p-4",
|
||||
"grid-cols-1",
|
||||
"md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_300px]",
|
||||
"md:grid-rows-[150px_200px_minmax(150px,1fr)_minmax(150px,1fr)]",
|
||||
)}
|
||||
>
|
||||
{/* banner */}
|
||||
<section className="flex md:row-start-1 md:col-start-1 md:col-span-3 gap-4 max-md:flex-col">
|
||||
<div className="flex flex-1 min-w-0 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}
|
||||
</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 [changePwdOpen, setChangePwdOpen] = useState(false)
|
||||
const [realNameOpen, setRealNameOpen] = useState(false)
|
||||
const [agreementOpen, setAgreementOpen] = useState(false)
|
||||
const [managerQrOpen, setManagerQrOpen] = useState(false)
|
||||
const [rechargeOpen, setRechargeOpen] = useState(false)
|
||||
const [cashOutOpen, setCashOutOpen] = useState(false)
|
||||
|
||||
<div className="flex flex-1 min-w-0 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 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>
|
||||
)
|
||||
})()
|
||||
|
||||
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}
|
||||
{user?.is_agent === 1 && (
|
||||
<span className="inline-flex shrink-0 items-center rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-600">
|
||||
经销商
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4 sm:gap-8">
|
||||
<p className="text-2xl sm:text-3xl font-bold">
|
||||
{balanceData.hCoin}
|
||||
</p>
|
||||
<div className="flex shrink-0 items-center gap-4">
|
||||
{agreementAction()}
|
||||
{user?.is_agent === 1 && (
|
||||
<a
|
||||
href="/user/api"
|
||||
className="shrink-0 text-xs text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
代理商API接口
|
||||
</a>
|
||||
)}
|
||||
<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 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">
|
||||
{accountInfo.phone || 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={() => setChangePwdOpen(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"
|
||||
onClick={() => setCashOutOpen(true)}
|
||||
>
|
||||
去提现
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-blue-600 text-white hover:bg-blue-700"
|
||||
onClick={() => setRechargeOpen(true)}
|
||||
>
|
||||
去充值
|
||||
</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"
|
||||
onClick={() => navigate("/product/http")}
|
||||
>
|
||||
去充值
|
||||
</Button>
|
||||
</BalanceCard>
|
||||
</section>
|
||||
|
||||
<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 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="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="grid grid-cols-3 divide-x divide-gray-100">
|
||||
<IpStat
|
||||
icon={b10}
|
||||
alt="使用中"
|
||||
value={ipAccountData.using}
|
||||
label="使用中"
|
||||
chipClassName="bg-emerald-50"
|
||||
/>
|
||||
<IpStat
|
||||
icon={b12}
|
||||
alt="总个数"
|
||||
value={ipAccountData.total}
|
||||
label="总个数"
|
||||
chipClassName="bg-blue-50"
|
||||
/>
|
||||
<IpStat
|
||||
icon={b11}
|
||||
alt="已过期"
|
||||
value={ipAccountData.expired}
|
||||
label="已过期"
|
||||
chipClassName="bg-rose-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 最近订单 */}
|
||||
<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>
|
||||
|
||||
{/* 消费信息 */}
|
||||
<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>
|
||||
<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="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"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
<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>
|
||||
|
||||
{/* 账号信息 */}
|
||||
<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"
|
||||
/>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
{/* 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">使用中</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">总个数</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">已过期</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<EditInfoDialog
|
||||
open={editDialogOpen}
|
||||
onOpenChange={setEditDialogOpen}
|
||||
onSave={handleSaveInfo}
|
||||
initialData={{
|
||||
qq: accountInfo.qq,
|
||||
wechat: accountInfo.wechat,
|
||||
// taobao: accountInfo.taobao,
|
||||
email: accountInfo.email,
|
||||
}}
|
||||
onSaved={refresh}
|
||||
/>
|
||||
<ChangePwdDialog open={changePwdOpen} onOpenChange={setChangePwdOpen} />
|
||||
<RealNameDialog
|
||||
open={realNameOpen}
|
||||
onOpenChange={setRealNameOpen}
|
||||
onVerified={refresh}
|
||||
/>
|
||||
<RechargeDialog
|
||||
open={rechargeOpen}
|
||||
onOpenChange={setRechargeOpen}
|
||||
onPaid={refresh}
|
||||
/>
|
||||
<CashOutDialog
|
||||
open={cashOutOpen}
|
||||
onOpenChange={setCashOutOpen}
|
||||
balance={balanceData.balance}
|
||||
onSubmitted={refresh}
|
||||
/>
|
||||
<AgreementDialog
|
||||
open={agreementOpen}
|
||||
onOpenChange={setAgreementOpen}
|
||||
onSigned={refreshAgreement}
|
||||
/>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,59 +1,24 @@
|
||||
import { type ReactNode, useEffect } from "react"
|
||||
import { useLayout } from "./layout-context"
|
||||
|
||||
type Breakpoints = {
|
||||
sm: boolean
|
||||
md: boolean
|
||||
lg: boolean
|
||||
xl: boolean
|
||||
}
|
||||
|
||||
const breakpoints: Breakpoints = { sm: false, md: false, lg: false, xl: false }
|
||||
|
||||
export function useBreakpoint() {
|
||||
return breakpoints
|
||||
}
|
||||
|
||||
export const bp = breakpoints
|
||||
import { useClientStore } from "@/components/stores/client"
|
||||
import { useLayoutStore } from "@/components/stores/layout"
|
||||
|
||||
export default function Effects(props: { children?: ReactNode }) {
|
||||
const { setNavbar } = useLayout()
|
||||
const md = useClientStore(state => state.breakpoint.md)
|
||||
const setNavbar = useLayoutStore(state => state.setNavbar)
|
||||
|
||||
useEffect(() => {
|
||||
const sm = window.matchMedia("(width >= 40rem)")
|
||||
const md = window.matchMedia("(width >= 48rem)")
|
||||
const lg = window.matchMedia("(width >= 64rem)")
|
||||
const xl = window.matchMedia("(width >= 80rem)")
|
||||
|
||||
breakpoints.sm = sm.matches
|
||||
breakpoints.md = md.matches
|
||||
breakpoints.lg = lg.matches
|
||||
breakpoints.xl = xl.matches
|
||||
|
||||
setNavbar(md.matches)
|
||||
|
||||
sm.addEventListener("change", e => {
|
||||
breakpoints.sm = e.matches
|
||||
})
|
||||
md.addEventListener("change", e => {
|
||||
breakpoints.md = e.matches
|
||||
setNavbar(e.matches)
|
||||
})
|
||||
lg.addEventListener("change", e => {
|
||||
breakpoints.lg = e.matches
|
||||
})
|
||||
xl.addEventListener("change", e => {
|
||||
breakpoints.xl = e.matches
|
||||
})
|
||||
// 断点变化时同步侧边栏默认状态:桌面端展开、移动端收起
|
||||
setNavbar(md)
|
||||
}, [md, setNavbar])
|
||||
|
||||
useEffect(() => {
|
||||
const html = document.documentElement
|
||||
const originalFontSize = html.style.fontSize
|
||||
html.style.fontSize = "16px"
|
||||
|
||||
return () => {
|
||||
html.style.fontSize = originalFontSize
|
||||
}
|
||||
}, [setNavbar])
|
||||
}, [])
|
||||
|
||||
return <>{props.children}</>
|
||||
}
|
||||
|
||||
@@ -1,29 +1,125 @@
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { fetchScoreList } from "@/api/funds"
|
||||
import DataTable from "@/components/data-table"
|
||||
import Page from "@/components/page"
|
||||
import { SCORE_PAGE_SIZE, type ScoreItem } from "@/lib/models/funds"
|
||||
|
||||
const columns: ColumnDef<ScoreItem>[] = [
|
||||
{
|
||||
accessorKey: "CreateTime",
|
||||
header: "交易时间",
|
||||
},
|
||||
{
|
||||
accessorKey: "UserName",
|
||||
header: "用户",
|
||||
},
|
||||
{
|
||||
accessorKey: "ScoreTypeName",
|
||||
header: "资金去向",
|
||||
},
|
||||
{
|
||||
accessorKey: "ScoreValue",
|
||||
header: "金额",
|
||||
cell: ({ row }) => {
|
||||
const value = Number(row.original.ScoreValue)
|
||||
return (
|
||||
<span className={value < 0 ? "text-red-500" : "text-emerald-600"}>
|
||||
{value >= 0 ? "+" : ""}
|
||||
{row.original.ScoreValue}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "RestAmount1",
|
||||
header: "操作前余额",
|
||||
cell: ({ row }) => `¥${row.original.RestAmount1}`,
|
||||
},
|
||||
{
|
||||
accessorKey: "RestAmount2",
|
||||
header: "操作后余额",
|
||||
cell: ({ row }) => `¥${row.original.RestAmount2}`,
|
||||
},
|
||||
{
|
||||
accessorKey: "Remark",
|
||||
header: "备注",
|
||||
cell: ({ row }) => (
|
||||
<span className="block max-w-80 truncate" title={row.original.Remark}>
|
||||
{row.original.Remark}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export default function FundsPage() {
|
||||
const [data, setData] = useState<ScoreItem[]>([])
|
||||
const [status, setStatus] = useState<"load" | "done" | "fail">("load")
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
// 总数恰为 50 整数倍时最后一页也是 50 条,翻到空页后锁死最大页码
|
||||
const [maxPage, setMaxPage] = useState<number | null>(null)
|
||||
const seqRef = useRef(0)
|
||||
|
||||
const load = useCallback(async (targetPage: number) => {
|
||||
const seq = ++seqRef.current
|
||||
setStatus("load")
|
||||
const res = await fetchScoreList(targetPage)
|
||||
console.log(res, 'datadatadatadatadata');
|
||||
|
||||
if (seq !== seqRef.current) return
|
||||
if (!res.success) {
|
||||
setStatus("fail")
|
||||
toast.error(res.message)
|
||||
return
|
||||
}
|
||||
if (res.data.length === 0 && targetPage > 1) {
|
||||
setMaxPage(targetPage - 1)
|
||||
setPage(targetPage - 1)
|
||||
load(targetPage - 1)
|
||||
return
|
||||
}
|
||||
setData(res.data)
|
||||
setHasMore(res.data.length >= SCORE_PAGE_SIZE)
|
||||
setStatus("done")
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load(1)
|
||||
}, [load])
|
||||
|
||||
const handlePageChange = (nextPage: number) => {
|
||||
if (nextPage < 1) return
|
||||
setPage(nextPage)
|
||||
load(nextPage)
|
||||
}
|
||||
|
||||
const canNext = maxPage !== null ? page < maxPage : hasMore
|
||||
const total =
|
||||
maxPage !== null
|
||||
? maxPage * SCORE_PAGE_SIZE
|
||||
: canNext
|
||||
? page * SCORE_PAGE_SIZE + 1
|
||||
: (page - 1) * SCORE_PAGE_SIZE + data.length
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<DataTable
|
||||
data={[]}
|
||||
status="done"
|
||||
columns={[
|
||||
{ accessorKey: "date", header: "交易时间" },
|
||||
{ accessorKey: "product", header: "用户" },
|
||||
{ accessorKey: "plan", header: "资金去向" },
|
||||
{
|
||||
accessorKey: "price",
|
||||
header: "金额",
|
||||
},
|
||||
{ accessorKey: "connections", header: "操作前约(元)" },
|
||||
{ accessorKey: "account", header: "备注" },
|
||||
]}
|
||||
pagination={{
|
||||
page: 0,
|
||||
size: 10,
|
||||
total: 0,
|
||||
}}
|
||||
/>
|
||||
<div className="bg-white rounded-lg p-4 flex flex-col gap-4">
|
||||
<h3 className="font-semibold text-lg">资金明细</h3>
|
||||
<DataTable
|
||||
data={data}
|
||||
status={status}
|
||||
columns={columns}
|
||||
pagination={{
|
||||
page,
|
||||
size: SCORE_PAGE_SIZE,
|
||||
sizeOptions: [SCORE_PAGE_SIZE],
|
||||
total,
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,32 +1,57 @@
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { fetchHttpRechargeList } from "@/api/httpRecharge"
|
||||
import DataTable from "@/components/data-table"
|
||||
import Page from "@/components/page"
|
||||
import {
|
||||
formatPayStatus,
|
||||
type HttpRechargeItem,
|
||||
} from "@/lib/models/httpRecharge"
|
||||
|
||||
export default function HttpRechargePage() {
|
||||
const [data, setData] = useState<HttpRechargeItem[]>([])
|
||||
const [status, setStatus] = useState<"load" | "done" | "fail">("load")
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setStatus("load")
|
||||
const res = await fetchHttpRechargeList()
|
||||
console.log(res, "fewewf")
|
||||
|
||||
if (!res.success) {
|
||||
setStatus("fail")
|
||||
return
|
||||
}
|
||||
setData(res.data)
|
||||
setStatus("done")
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const columns = useMemo<ColumnDef<HttpRechargeItem>[]>(
|
||||
() => [
|
||||
{ accessorKey: "create_time", header: "交易时间" },
|
||||
{ accessorKey: "ju_money", header: "充值H币" },
|
||||
{ accessorKey: "pay_money", header: "支付金额" },
|
||||
{ accessorKey: "pay_type", header: "充值渠道" },
|
||||
{
|
||||
accessorKey: "pay_status",
|
||||
header: "支付状态",
|
||||
cell: ({ row }) => formatPayStatus(row.original.pay_status),
|
||||
},
|
||||
{ accessorKey: "order_no", header: "订单编号" },
|
||||
{ accessorKey: "ali_wx_no", header: "支付宝/微信订单号" },
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<h3 className="font-semibold text-lg">HTTP 充值记录</h3>
|
||||
<DataTable
|
||||
data={[]}
|
||||
status="done"
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "create_time",
|
||||
header: "交易时间",
|
||||
},
|
||||
{
|
||||
accessorKey: "ju_money",
|
||||
header: "充值H币",
|
||||
},
|
||||
{
|
||||
accessorKey: "pay_type",
|
||||
header: "充值渠道",
|
||||
},
|
||||
{
|
||||
accessorKey: "pay_money",
|
||||
header: "充值金额",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className="bg-white rounded-lg p-4">
|
||||
<h3 className="font-semibold text-lg mb-4">HTTP 充值记录</h3>
|
||||
<DataTable data={data} status={status} columns={columns} />
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { createContext, type ReactNode, useContext, useState } from "react"
|
||||
|
||||
type LayoutState = {
|
||||
navbar: boolean
|
||||
setNavbar: (navbar: boolean) => void
|
||||
toggleNavbar: () => void
|
||||
}
|
||||
|
||||
const LayoutContext = createContext<LayoutState | null>(null)
|
||||
|
||||
export function LayoutProvider(props: { children: ReactNode }) {
|
||||
const [navbar, setNavbar] = useState(false)
|
||||
|
||||
const toggleNavbar = () => setNavbar(!navbar)
|
||||
|
||||
return (
|
||||
<LayoutContext value={{ navbar, setNavbar, toggleNavbar }}>
|
||||
{props.children}
|
||||
</LayoutContext>
|
||||
)
|
||||
}
|
||||
|
||||
export function useLayout() {
|
||||
const ctx = useContext(LayoutContext)
|
||||
if (!ctx) throw new Error("useLayout must be used within LayoutProvider")
|
||||
return ctx
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Outlet } from "react-router-dom"
|
||||
import RealNameGate from "@/components/realNameGate"
|
||||
import { Content, Header, Mask, Navbar, Shell } from "./clients"
|
||||
import Effects from "./effects"
|
||||
import { LayoutProvider } from "./layout-context"
|
||||
|
||||
export default function AdminLayout() {
|
||||
return (
|
||||
<LayoutProvider>
|
||||
<>
|
||||
<Effects />
|
||||
<div className="relative h-dvh overflow-hidden">
|
||||
<Shell>
|
||||
@@ -42,9 +42,11 @@ export default function AdminLayout() {
|
||||
</Shell>
|
||||
|
||||
<Content>
|
||||
<Outlet />
|
||||
<RealNameGate>
|
||||
<Outlet />
|
||||
</RealNameGate>
|
||||
</Content>
|
||||
</div>
|
||||
</LayoutProvider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,401 +0,0 @@
|
||||
import type { OrderItem } from "@/lib/models/order"
|
||||
|
||||
export const MOCK_ORDERS: OrderItem[] = [
|
||||
{
|
||||
Id: 23,
|
||||
OrderNo: "JU202508150001",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "天卡",
|
||||
DayPrice: 3,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1234",
|
||||
OrderAmount: 3,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 3,
|
||||
CreateTime: "2025-08-15 10:23:45",
|
||||
UpdateTime: "2025-08-15 10:24:01",
|
||||
},
|
||||
{
|
||||
Id: 22,
|
||||
OrderNo: "JU202508150002",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "周卡",
|
||||
DayPrice: 18,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1235",
|
||||
OrderAmount: 18,
|
||||
CouponAmount: 2,
|
||||
PaymentAmount: 16,
|
||||
CreateTime: "2025-08-15 09:12:33",
|
||||
UpdateTime: "2025-08-15 09:12:50",
|
||||
},
|
||||
{
|
||||
Id: 21,
|
||||
OrderNo: "JU202508140003",
|
||||
OrderType: 2,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "月卡",
|
||||
DayPrice: 68,
|
||||
ConnectCount: 2,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1236",
|
||||
OrderAmount: 68,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 68,
|
||||
CreateTime: "2025-08-14 16:45:21",
|
||||
UpdateTime: "2025-08-14 16:45:39",
|
||||
},
|
||||
{
|
||||
Id: 20,
|
||||
OrderNo: "JU202508140004",
|
||||
OrderType: 3,
|
||||
ProductId: 2,
|
||||
ProductName: "新HTTP/S5",
|
||||
PackageName: "月卡(活动)",
|
||||
DayPrice: 88,
|
||||
ConnectCount: 5,
|
||||
AccountCount: 3,
|
||||
Accounts: "https-001,https-002,https-003",
|
||||
OrderAmount: 264,
|
||||
CouponAmount: 10,
|
||||
PaymentAmount: 254,
|
||||
CreateTime: "2025-08-14 14:02:17",
|
||||
UpdateTime: "2025-08-14 14:02:55",
|
||||
},
|
||||
{
|
||||
Id: 19,
|
||||
OrderNo: "JU202508140005",
|
||||
OrderType: 1,
|
||||
ProductId: 3,
|
||||
ProductName: "HTTP/S5",
|
||||
PackageName: "季卡",
|
||||
DayPrice: 168,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "global-001",
|
||||
OrderAmount: 168,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 168,
|
||||
CreateTime: "2025-08-14 11:30:08",
|
||||
UpdateTime: "2025-08-14 11:30:26",
|
||||
},
|
||||
{
|
||||
Id: 18,
|
||||
OrderNo: "JU202508130006",
|
||||
OrderType: 4,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "年卡",
|
||||
DayPrice: 688,
|
||||
ConnectCount: 10,
|
||||
AccountCount: 5,
|
||||
Accounts: "batch-jx-01,batch-jx-02,batch-jx-03,batch-jx-04,batch-jx-05",
|
||||
OrderAmount: 3440,
|
||||
CouponAmount: 100,
|
||||
PaymentAmount: 3340,
|
||||
CreateTime: "2025-08-13 20:15:44",
|
||||
UpdateTime: "2025-08-13 20:16:02",
|
||||
},
|
||||
{
|
||||
Id: 17,
|
||||
OrderNo: "JU202508130007",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "测试卡",
|
||||
DayPrice: 0.5,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1237",
|
||||
OrderAmount: 0.5,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 0.5,
|
||||
CreateTime: "2025-08-13 18:03:12",
|
||||
UpdateTime: "2025-08-13 18:03:28",
|
||||
},
|
||||
{
|
||||
Id: 16,
|
||||
OrderNo: "JU202508120008",
|
||||
OrderType: 2,
|
||||
ProductId: 2,
|
||||
ProductName: "新HTTP/S5",
|
||||
PackageName: "双月卡(活动)",
|
||||
DayPrice: 128,
|
||||
ConnectCount: 3,
|
||||
AccountCount: 1,
|
||||
Accounts: "https-004",
|
||||
OrderAmount: 128,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 128,
|
||||
CreateTime: "2025-08-12 15:22:37",
|
||||
UpdateTime: "2025-08-12 15:22:59",
|
||||
},
|
||||
{
|
||||
Id: 15,
|
||||
OrderNo: "JU202508120009",
|
||||
OrderType: 1,
|
||||
ProductId: 3,
|
||||
ProductName: "HTTP/S5",
|
||||
PackageName: "月卡",
|
||||
DayPrice: 98,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "global-002",
|
||||
OrderAmount: 98,
|
||||
CouponAmount: 5,
|
||||
PaymentAmount: 93,
|
||||
CreateTime: "2025-08-12 13:40:55",
|
||||
UpdateTime: "2025-08-12 13:41:13",
|
||||
},
|
||||
{
|
||||
Id: 14,
|
||||
OrderNo: "JU202508110010",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "天卡",
|
||||
DayPrice: 3,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1238",
|
||||
OrderAmount: 3,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 3,
|
||||
CreateTime: "2025-08-11 10:05:29",
|
||||
UpdateTime: "2025-08-11 10:05:44",
|
||||
},
|
||||
{
|
||||
Id: 13,
|
||||
OrderNo: "JU202508110011",
|
||||
OrderType: 3,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "周卡",
|
||||
DayPrice: 18,
|
||||
ConnectCount: 4,
|
||||
AccountCount: 4,
|
||||
Accounts: "batch-jx-06,batch-jx-07,batch-jx-08,batch-jx-09",
|
||||
OrderAmount: 72,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 72,
|
||||
CreateTime: "2025-08-11 08:56:48",
|
||||
UpdateTime: "2025-08-11 08:57:06",
|
||||
},
|
||||
{
|
||||
Id: 12,
|
||||
OrderNo: "JU202508100012",
|
||||
OrderType: 2,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "季卡",
|
||||
DayPrice: 168,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1239",
|
||||
OrderAmount: 168,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 168,
|
||||
CreateTime: "2025-08-10 19:33:20",
|
||||
UpdateTime: "2025-08-10 19:33:41",
|
||||
},
|
||||
{
|
||||
Id: 11,
|
||||
OrderNo: "JU202508090013",
|
||||
OrderType: 1,
|
||||
ProductId: 2,
|
||||
ProductName: "新HTTP/S5",
|
||||
PackageName: "年卡",
|
||||
DayPrice: 880,
|
||||
ConnectCount: 20,
|
||||
AccountCount: 2,
|
||||
Accounts: "https-005,https-006",
|
||||
OrderAmount: 1760,
|
||||
CouponAmount: 50,
|
||||
PaymentAmount: 1710,
|
||||
CreateTime: "2025-08-09 21:18:07",
|
||||
UpdateTime: "2025-08-09 21:18:30",
|
||||
},
|
||||
{
|
||||
Id: 10,
|
||||
OrderNo: "JU202508090014",
|
||||
OrderType: 1,
|
||||
ProductId: 3,
|
||||
ProductName: "HTTP/S5",
|
||||
PackageName: "测试卡",
|
||||
DayPrice: 1,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "global-003",
|
||||
OrderAmount: 1,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 1,
|
||||
CreateTime: "2025-08-09 17:47:52",
|
||||
UpdateTime: "2025-08-09 17:48:09",
|
||||
},
|
||||
{
|
||||
Id: 9,
|
||||
OrderNo: "JU202508080015",
|
||||
OrderType: 4,
|
||||
ProductId: 2,
|
||||
ProductName: "新HTTP/S5",
|
||||
PackageName: "月卡(活动)",
|
||||
DayPrice: 88,
|
||||
ConnectCount: 6,
|
||||
AccountCount: 6,
|
||||
Accounts: "https-b1,https-b2,https-b3,https-b4,https-b5,https-b6",
|
||||
OrderAmount: 528,
|
||||
CouponAmount: 20,
|
||||
PaymentAmount: 508,
|
||||
CreateTime: "2025-08-08 12:26:34",
|
||||
UpdateTime: "2025-08-08 12:26:58",
|
||||
},
|
||||
{
|
||||
Id: 8,
|
||||
OrderNo: "JU202508070016",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "月卡(活动)",
|
||||
DayPrice: 58,
|
||||
ConnectCount: 2,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1240",
|
||||
OrderAmount: 58,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 58,
|
||||
CreateTime: "2025-08-07 09:11:26",
|
||||
UpdateTime: "2025-08-07 09:11:43",
|
||||
},
|
||||
{
|
||||
Id: 7,
|
||||
OrderNo: "JU202508060017",
|
||||
OrderType: 2,
|
||||
ProductId: 3,
|
||||
ProductName: "HTTP/S5",
|
||||
PackageName: "季卡",
|
||||
DayPrice: 268,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "global-004",
|
||||
OrderAmount: 268,
|
||||
CouponAmount: 8,
|
||||
PaymentAmount: 260,
|
||||
CreateTime: "2025-08-06 22:05:11",
|
||||
UpdateTime: "2025-08-06 22:05:35",
|
||||
},
|
||||
{
|
||||
Id: 6,
|
||||
OrderNo: "JU202508050018",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "天卡",
|
||||
DayPrice: 3,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1241",
|
||||
OrderAmount: 3,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 3,
|
||||
CreateTime: "2025-08-05 14:38:49",
|
||||
UpdateTime: "2025-08-05 14:39:05",
|
||||
},
|
||||
{
|
||||
Id: 5,
|
||||
OrderNo: "JU202508040019",
|
||||
OrderType: 3,
|
||||
ProductId: 3,
|
||||
ProductName: "HTTP/S5",
|
||||
PackageName: "月卡",
|
||||
DayPrice: 98,
|
||||
ConnectCount: 3,
|
||||
AccountCount: 3,
|
||||
Accounts: "global-b1,global-b2,global-b3",
|
||||
OrderAmount: 294,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 294,
|
||||
CreateTime: "2025-08-04 10:52:18",
|
||||
UpdateTime: "2025-08-04 10:52:37",
|
||||
},
|
||||
{
|
||||
Id: 4,
|
||||
OrderNo: "JU202508030020",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "双月卡(活动)",
|
||||
DayPrice: 98,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1242",
|
||||
OrderAmount: 98,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 98,
|
||||
CreateTime: "2025-08-03 16:07:23",
|
||||
UpdateTime: "2025-08-03 16:07:44",
|
||||
},
|
||||
{
|
||||
Id: 3,
|
||||
OrderNo: "JU202508020021",
|
||||
OrderType: 2,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "年卡",
|
||||
DayPrice: 688,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1243",
|
||||
OrderAmount: 688,
|
||||
CouponAmount: 30,
|
||||
PaymentAmount: 658,
|
||||
CreateTime: "2025-08-02 20:29:56",
|
||||
UpdateTime: "2025-08-02 20:30:14",
|
||||
},
|
||||
{
|
||||
Id: 2,
|
||||
OrderNo: "JU202508010022",
|
||||
OrderType: 1,
|
||||
ProductId: 2,
|
||||
ProductName: "新HTTP/S5",
|
||||
PackageName: "周卡",
|
||||
DayPrice: 25,
|
||||
ConnectCount: 2,
|
||||
AccountCount: 1,
|
||||
Accounts: "https-007",
|
||||
OrderAmount: 25,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 25,
|
||||
CreateTime: "2025-08-01 11:44:02",
|
||||
UpdateTime: "2025-08-01 11:44:21",
|
||||
},
|
||||
{
|
||||
Id: 1,
|
||||
OrderNo: "JU202507310023",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "月卡",
|
||||
DayPrice: 68,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1244",
|
||||
OrderAmount: 68,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 68,
|
||||
CreateTime: "2025-07-31 09:58:37",
|
||||
UpdateTime: "2025-07-31 09:58:55",
|
||||
},
|
||||
]
|
||||
|
||||
export const MOCK_PRODUCTS = [
|
||||
{ id: 1, name: "动静态IP" },
|
||||
{ id: 2, name: "新HTTP/S5" },
|
||||
{ id: 3, name: "HTTP/S5" },
|
||||
]
|
||||
@@ -1,60 +1,31 @@
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { fetchMyOrders } from "@/api/order"
|
||||
import AccountCell from "@/components/composites/accountCell"
|
||||
import OrderFilterBar from "@/components/composites/orderFilterBar"
|
||||
import DataTable from "@/components/data-table"
|
||||
import Page from "@/components/page"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { useMyOrderPage } from "@/lib/hooks/useMyOrderPage"
|
||||
import { useProducts } from "@/lib/hooks/useProducts"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import {
|
||||
cutAccount,
|
||||
DEFAULT_FILTERS,
|
||||
formatDate,
|
||||
formatMoney,
|
||||
formatOrderType,
|
||||
ORDER_TYPE_LABELS,
|
||||
type MyOrderQueryParams,
|
||||
type OrderFilters,
|
||||
type OrderItem,
|
||||
PACKAGE_OPTIONS,
|
||||
totalConnections,
|
||||
} from "@/lib/models/order"
|
||||
import { MOCK_ORDERS, MOCK_PRODUCTS } from "./mock"
|
||||
|
||||
function formatMoney(value: number): string {
|
||||
return value.toFixed(2)
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0")
|
||||
const day = String(date.getDate()).padStart(2, "0")
|
||||
return `${year}.${month}.${day}`
|
||||
}
|
||||
|
||||
function AccountCell({ accounts }: { accounts: string }) {
|
||||
const isCut = accounts.length > 15
|
||||
if (!isCut) return <span className="truncate">{accounts}</span>
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-default truncate">{cutAccount(accounts)}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-80 break-all">{accounts}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
const mapOrderFilters = (
|
||||
filters: OrderFilters,
|
||||
): Partial<MyOrderQueryParams> => ({
|
||||
BTime: filters.bTime || undefined,
|
||||
ETime: filters.eTime || undefined,
|
||||
OrderType: filters.orderType === "0" ? undefined : Number(filters.orderType),
|
||||
ProductId: filters.productId === "0" ? undefined : Number(filters.productId),
|
||||
PackageId: filters.packageName === "0" ? undefined : filters.packageName,
|
||||
KeyWord: filters.keyword.trim() || undefined,
|
||||
})
|
||||
|
||||
const columns: ColumnDef<OrderItem>[] = [
|
||||
{
|
||||
@@ -111,177 +82,44 @@ const columns: ColumnDef<OrderItem>[] = [
|
||||
},
|
||||
]
|
||||
|
||||
function matchOrders(list: typeof MOCK_ORDERS, filters: OrderFilters) {
|
||||
const keyword = filters.keyword.trim()
|
||||
|
||||
return list.filter(order => {
|
||||
if (
|
||||
filters.orderType !== "0" &&
|
||||
order.OrderType !== Number(filters.orderType)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
filters.productId !== "0" &&
|
||||
order.ProductId !== Number(filters.productId)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
filters.packageName !== "0" &&
|
||||
order.PackageName !== filters.packageName
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
keyword &&
|
||||
!order.OrderNo.includes(keyword) &&
|
||||
!order.Accounts.includes(keyword)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (filters.bTime && order.CreateTime.slice(0, 10) < filters.bTime) {
|
||||
return false
|
||||
}
|
||||
if (filters.eTime && order.CreateTime.slice(0, 10) > filters.eTime) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export default function OrdersPage() {
|
||||
const [filters, setFilters] = useState<OrderFilters>(DEFAULT_FILTERS)
|
||||
const [appliedFilters, setAppliedFilters] =
|
||||
useState<OrderFilters>(DEFAULT_FILTERS)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
|
||||
const filtered = useMemo(
|
||||
() => matchOrders(MOCK_ORDERS, appliedFilters),
|
||||
[appliedFilters],
|
||||
)
|
||||
|
||||
const paged = useMemo(
|
||||
() => filtered.slice((page - 1) * pageSize, page * pageSize),
|
||||
[filtered, page, pageSize],
|
||||
)
|
||||
|
||||
const patch = (partial: Partial<OrderFilters>) =>
|
||||
setFilters({ ...filters, ...partial })
|
||||
|
||||
const handleSearch = () => {
|
||||
setAppliedFilters(filters)
|
||||
setPage(1)
|
||||
}
|
||||
const products = useProducts()
|
||||
const {
|
||||
filters,
|
||||
patch,
|
||||
load,
|
||||
reset,
|
||||
total,
|
||||
status,
|
||||
page,
|
||||
size,
|
||||
setPage,
|
||||
changeSize,
|
||||
paged,
|
||||
} = useMyOrderPage(fetchMyOrders, mapOrderFilters)
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="bg-white rounded-lg p-4 flex flex-wrap items-end gap-x-4 gap-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500 whitespace-nowrap">
|
||||
日期筛选:
|
||||
</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.bTime}
|
||||
onChange={e => patch({ bTime: e.target.value })}
|
||||
className="w-36"
|
||||
aria-label="开始时间"
|
||||
/>
|
||||
<span className="text-sm text-gray-400">至</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.eTime}
|
||||
onChange={e => patch({ eTime: e.target.value })}
|
||||
className="w-36"
|
||||
aria-label="结束时间"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={filters.orderType}
|
||||
onValueChange={value => patch({ orderType: value })}
|
||||
>
|
||||
<SelectTrigger className="w-30">
|
||||
<SelectValue placeholder="全部类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">全部类型</SelectItem>
|
||||
{Object.entries(ORDER_TYPE_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={filters.productId}
|
||||
onValueChange={value => patch({ productId: value })}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue placeholder="全部产品" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">全部产品</SelectItem>
|
||||
{MOCK_PRODUCTS.map(product => (
|
||||
<SelectItem key={product.id} value={String(product.id)}>
|
||||
{product.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={filters.packageName}
|
||||
onValueChange={value => patch({ packageName: value })}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue placeholder="全部套餐" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">全部套餐</SelectItem>
|
||||
{PACKAGE_OPTIONS.map(pkg => (
|
||||
<SelectItem key={pkg} value={pkg}>
|
||||
{pkg}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={filters.keyword}
|
||||
onChange={e => patch({ keyword: e.target.value })}
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Enter") handleSearch()
|
||||
}}
|
||||
placeholder="账号"
|
||||
className="w-40"
|
||||
/>
|
||||
<Button onClick={handleSearch}>
|
||||
<SearchIcon data-icon="inline-start" />
|
||||
查询
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<OrderFilterBar
|
||||
filters={filters}
|
||||
products={products}
|
||||
onPatch={patch}
|
||||
onSearch={load}
|
||||
onReset={reset}
|
||||
/>
|
||||
|
||||
<div className="bg-white rounded-lg p-4 flex flex-col gap-4">
|
||||
<h3 className="font-semibold text-lg">订单管理</h3>
|
||||
<DataTable
|
||||
data={paged}
|
||||
status="done"
|
||||
status={status}
|
||||
columns={columns}
|
||||
pagination={{
|
||||
page: page - 1,
|
||||
size: pageSize,
|
||||
total: filtered.length,
|
||||
page,
|
||||
size,
|
||||
total,
|
||||
onPageChange: setPage,
|
||||
onSizeChange: size => {
|
||||
setPageSize(size)
|
||||
setPage(1)
|
||||
},
|
||||
onSizeChange: changeSize,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { HttpUsedLogItem, HttpUsedSummary } from "@/lib/models/record"
|
||||
|
||||
export const MOCK_USED_SUMMARY: HttpUsedSummary = {
|
||||
todayShortPackIpUsed: 3,
|
||||
shortPackIpUsed: 45,
|
||||
}
|
||||
|
||||
const PACK_IP_PREFIX = [
|
||||
{ packType: 21, prefix: "211.90.13" },
|
||||
{ packType: 22, prefix: "112.65.28" },
|
||||
]
|
||||
|
||||
function pad(value: number): string {
|
||||
return String(value).padStart(2, "0")
|
||||
}
|
||||
|
||||
function buildLogs(count: number): HttpUsedLogItem[] {
|
||||
const now = new Date()
|
||||
const logs: HttpUsedLogItem[] = []
|
||||
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const source = PACK_IP_PREFIX[i % PACK_IP_PREFIX.length]
|
||||
const time = new Date(now.getTime() - i * 37 * 60 * 1000)
|
||||
const createTime =
|
||||
`${time.getFullYear()}-${pad(time.getMonth() + 1)}-${pad(time.getDate())} ` +
|
||||
`${pad(time.getHours())}:${pad(time.getMinutes())}:${pad(time.getSeconds())}`
|
||||
|
||||
logs.push({
|
||||
packType: source.packType,
|
||||
userIp: `113.118.${(i % 200) + 10}.${(i % 250) + 3}`,
|
||||
ip: `${source.prefix}.${(i % 240) + 10}`,
|
||||
port: 6000 + (i % 300),
|
||||
createTime,
|
||||
})
|
||||
}
|
||||
|
||||
return logs
|
||||
}
|
||||
|
||||
export const MOCK_USED_LOGS = buildLogs(56)
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { fetchHttpUsedLogs, fetchHttpUsedSummary } from "@/api/record"
|
||||
import DataTable from "@/components/data-table"
|
||||
import Page from "@/components/page"
|
||||
import {
|
||||
@@ -9,36 +11,11 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
formatPackType,
|
||||
HTTP_USE_PACK_TYPE_OPTIONS,
|
||||
type HttpUsedLogItem,
|
||||
} from "@/lib/models/record"
|
||||
import { MOCK_USED_LOGS, MOCK_USED_SUMMARY } from "./mock"
|
||||
import type { HttpUsedLogItem, HttpUsedSummary } from "@/lib/models/record"
|
||||
import { formatPackType, HTTP_USE_PACK_TYPE_OPTIONS } from "@/lib/models/record"
|
||||
import { estimateTotal, formatUnixTime } from "@/lib/utils"
|
||||
|
||||
const columns: ColumnDef<HttpUsedLogItem>[] = [
|
||||
{
|
||||
accessorKey: "packType",
|
||||
header: "使用类型",
|
||||
cell: ({ row }) => formatPackType(row.original.packType),
|
||||
},
|
||||
{
|
||||
accessorKey: "userIp",
|
||||
header: "客户端IP",
|
||||
},
|
||||
{
|
||||
accessorKey: "ip",
|
||||
header: "使用IP",
|
||||
},
|
||||
{
|
||||
accessorKey: "port",
|
||||
header: "端口号",
|
||||
},
|
||||
{
|
||||
accessorKey: "createTime",
|
||||
header: "使用时间",
|
||||
},
|
||||
]
|
||||
const PAGE_SIZE_OPTIONS = [10, 20, 50]
|
||||
|
||||
function StatCard(props: {
|
||||
label: string
|
||||
@@ -58,38 +35,81 @@ function StatCard(props: {
|
||||
}
|
||||
|
||||
export default function RecordPage() {
|
||||
const [summary, setSummary] = useState<HttpUsedSummary | null>(null)
|
||||
const [packType, setPackType] = useState(HTTP_USE_PACK_TYPE_OPTIONS[0])
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [items, setItems] = useState<HttpUsedLogItem[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [status, setStatus] = useState<"load" | "done" | "fail">("load")
|
||||
|
||||
const filtered = useMemo(
|
||||
() => MOCK_USED_LOGS.filter(log => log.packType === packType),
|
||||
[packType],
|
||||
)
|
||||
const loadLogs = useCallback(async () => {
|
||||
setStatus("load")
|
||||
const res = await fetchHttpUsedLogs({ page, limit: pageSize, packType })
|
||||
if (!res.success) {
|
||||
setStatus("fail")
|
||||
toast.error(res.message)
|
||||
return
|
||||
}
|
||||
setItems(res.data.items)
|
||||
setTotal(
|
||||
estimateTotal(
|
||||
res.data.pager.totalCount,
|
||||
res.data.pager.currentPage || page,
|
||||
pageSize,
|
||||
res.data.items.length,
|
||||
),
|
||||
)
|
||||
setStatus("done")
|
||||
}, [page, pageSize, packType])
|
||||
|
||||
const paged = useMemo(
|
||||
() => filtered.slice((page - 1) * pageSize, page * pageSize),
|
||||
[filtered, page, pageSize],
|
||||
)
|
||||
useEffect(() => {
|
||||
fetchHttpUsedSummary().then(res => {
|
||||
if (res.success) setSummary(res.data)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs()
|
||||
}, [loadLogs])
|
||||
|
||||
const handlePackTypeChange = (value: string) => {
|
||||
setPackType(Number(value))
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const columns = useMemo<ColumnDef<HttpUsedLogItem>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "packType",
|
||||
header: "使用类型",
|
||||
cell: ({ row }) => formatPackType(row.original.packType),
|
||||
},
|
||||
{ accessorKey: "userIp", header: "客户端IP" },
|
||||
{ accessorKey: "ip", header: "使用IP" },
|
||||
{ accessorKey: "port", header: "端口号" },
|
||||
{
|
||||
accessorKey: "createTime",
|
||||
header: "使用时间",
|
||||
cell: ({ row }) => formatUnixTime(row.original.createTime),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="bg-white rounded-lg p-4 flex flex-col gap-4">
|
||||
<h3 className="font-semibold text-lg">包天包量使用概况</h3>
|
||||
<div className="grid grid-cols-4 sm:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="当日使用IP数量"
|
||||
value={MOCK_USED_SUMMARY.todayShortPackIpUsed}
|
||||
value={summary?.todayShortPackIpUsed ?? 0}
|
||||
accent="amber"
|
||||
/>
|
||||
<StatCard
|
||||
label="累计使用IP数量"
|
||||
value={MOCK_USED_SUMMARY.shortPackIpUsed}
|
||||
value={summary?.shortPackIpUsed ?? 0}
|
||||
accent="blue"
|
||||
/>
|
||||
</div>
|
||||
@@ -116,13 +136,14 @@ export default function RecordPage() {
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={paged}
|
||||
status="done"
|
||||
data={items}
|
||||
status={status}
|
||||
columns={columns}
|
||||
pagination={{
|
||||
page,
|
||||
size: pageSize,
|
||||
total: filtered.length,
|
||||
total,
|
||||
sizeOptions: PAGE_SIZE_OPTIONS,
|
||||
onPageChange: setPage,
|
||||
onSizeChange: size => {
|
||||
setPageSize(size)
|
||||
|
||||
@@ -1,401 +0,0 @@
|
||||
import type { OrderItem } from "@/lib/models/order"
|
||||
|
||||
export const MOCK_ORDERS: OrderItem[] = [
|
||||
{
|
||||
Id: 23,
|
||||
OrderNo: "JU202508150001",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "天卡",
|
||||
DayPrice: 3,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1234",
|
||||
OrderAmount: 3,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 3,
|
||||
CreateTime: "2025-08-15 10:23:45",
|
||||
UpdateTime: "2025-08-15 10:24:01",
|
||||
},
|
||||
{
|
||||
Id: 22,
|
||||
OrderNo: "JU202508150002",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "周卡",
|
||||
DayPrice: 18,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1235",
|
||||
OrderAmount: 18,
|
||||
CouponAmount: 2,
|
||||
PaymentAmount: 16,
|
||||
CreateTime: "2025-08-15 09:12:33",
|
||||
UpdateTime: "2025-08-15 09:12:50",
|
||||
},
|
||||
{
|
||||
Id: 21,
|
||||
OrderNo: "JU202508140003",
|
||||
OrderType: 2,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "月卡",
|
||||
DayPrice: 68,
|
||||
ConnectCount: 2,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1236",
|
||||
OrderAmount: 68,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 68,
|
||||
CreateTime: "2025-08-14 16:45:21",
|
||||
UpdateTime: "2025-08-14 16:45:39",
|
||||
},
|
||||
{
|
||||
Id: 20,
|
||||
OrderNo: "JU202508140004",
|
||||
OrderType: 3,
|
||||
ProductId: 2,
|
||||
ProductName: "新HTTP/S5",
|
||||
PackageName: "月卡(活动)",
|
||||
DayPrice: 88,
|
||||
ConnectCount: 5,
|
||||
AccountCount: 3,
|
||||
Accounts: "https-001,https-002,https-003",
|
||||
OrderAmount: 264,
|
||||
CouponAmount: 10,
|
||||
PaymentAmount: 254,
|
||||
CreateTime: "2025-08-14 14:02:17",
|
||||
UpdateTime: "2025-08-14 14:02:55",
|
||||
},
|
||||
{
|
||||
Id: 19,
|
||||
OrderNo: "JU202508140005",
|
||||
OrderType: 1,
|
||||
ProductId: 3,
|
||||
ProductName: "HTTP/S5",
|
||||
PackageName: "季卡",
|
||||
DayPrice: 168,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "global-001",
|
||||
OrderAmount: 168,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 168,
|
||||
CreateTime: "2025-08-14 11:30:08",
|
||||
UpdateTime: "2025-08-14 11:30:26",
|
||||
},
|
||||
{
|
||||
Id: 18,
|
||||
OrderNo: "JU202508130006",
|
||||
OrderType: 4,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "年卡",
|
||||
DayPrice: 688,
|
||||
ConnectCount: 10,
|
||||
AccountCount: 5,
|
||||
Accounts: "batch-jx-01,batch-jx-02,batch-jx-03,batch-jx-04,batch-jx-05",
|
||||
OrderAmount: 3440,
|
||||
CouponAmount: 100,
|
||||
PaymentAmount: 3340,
|
||||
CreateTime: "2025-08-13 20:15:44",
|
||||
UpdateTime: "2025-08-13 20:16:02",
|
||||
},
|
||||
{
|
||||
Id: 17,
|
||||
OrderNo: "JU202508130007",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "测试卡",
|
||||
DayPrice: 0.5,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1237",
|
||||
OrderAmount: 0.5,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 0.5,
|
||||
CreateTime: "2025-08-13 18:03:12",
|
||||
UpdateTime: "2025-08-13 18:03:28",
|
||||
},
|
||||
{
|
||||
Id: 16,
|
||||
OrderNo: "JU202508120008",
|
||||
OrderType: 2,
|
||||
ProductId: 2,
|
||||
ProductName: "新HTTP/S5",
|
||||
PackageName: "双月卡(活动)",
|
||||
DayPrice: 128,
|
||||
ConnectCount: 3,
|
||||
AccountCount: 1,
|
||||
Accounts: "https-004",
|
||||
OrderAmount: 128,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 128,
|
||||
CreateTime: "2025-08-12 15:22:37",
|
||||
UpdateTime: "2025-08-12 15:22:59",
|
||||
},
|
||||
{
|
||||
Id: 15,
|
||||
OrderNo: "JU202508120009",
|
||||
OrderType: 1,
|
||||
ProductId: 3,
|
||||
ProductName: "HTTP/S5",
|
||||
PackageName: "月卡",
|
||||
DayPrice: 98,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "global-002",
|
||||
OrderAmount: 98,
|
||||
CouponAmount: 5,
|
||||
PaymentAmount: 93,
|
||||
CreateTime: "2025-08-12 13:40:55",
|
||||
UpdateTime: "2025-08-12 13:41:13",
|
||||
},
|
||||
{
|
||||
Id: 14,
|
||||
OrderNo: "JU202508110010",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "天卡",
|
||||
DayPrice: 3,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1238",
|
||||
OrderAmount: 3,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 3,
|
||||
CreateTime: "2025-08-11 10:05:29",
|
||||
UpdateTime: "2025-08-11 10:05:44",
|
||||
},
|
||||
{
|
||||
Id: 13,
|
||||
OrderNo: "JU202508110011",
|
||||
OrderType: 3,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "周卡",
|
||||
DayPrice: 18,
|
||||
ConnectCount: 4,
|
||||
AccountCount: 4,
|
||||
Accounts: "batch-jx-06,batch-jx-07,batch-jx-08,batch-jx-09",
|
||||
OrderAmount: 72,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 72,
|
||||
CreateTime: "2025-08-11 08:56:48",
|
||||
UpdateTime: "2025-08-11 08:57:06",
|
||||
},
|
||||
{
|
||||
Id: 12,
|
||||
OrderNo: "JU202508100012",
|
||||
OrderType: 2,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "季卡",
|
||||
DayPrice: 168,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1239",
|
||||
OrderAmount: 168,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 168,
|
||||
CreateTime: "2025-08-10 19:33:20",
|
||||
UpdateTime: "2025-08-10 19:33:41",
|
||||
},
|
||||
{
|
||||
Id: 11,
|
||||
OrderNo: "JU202508090013",
|
||||
OrderType: 1,
|
||||
ProductId: 2,
|
||||
ProductName: "新HTTP/S5",
|
||||
PackageName: "年卡",
|
||||
DayPrice: 880,
|
||||
ConnectCount: 20,
|
||||
AccountCount: 2,
|
||||
Accounts: "https-005,https-006",
|
||||
OrderAmount: 1760,
|
||||
CouponAmount: 50,
|
||||
PaymentAmount: 1710,
|
||||
CreateTime: "2025-08-09 21:18:07",
|
||||
UpdateTime: "2025-08-09 21:18:30",
|
||||
},
|
||||
{
|
||||
Id: 10,
|
||||
OrderNo: "JU202508090014",
|
||||
OrderType: 1,
|
||||
ProductId: 3,
|
||||
ProductName: "HTTP/S5",
|
||||
PackageName: "测试卡",
|
||||
DayPrice: 1,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "global-003",
|
||||
OrderAmount: 1,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 1,
|
||||
CreateTime: "2025-08-09 17:47:52",
|
||||
UpdateTime: "2025-08-09 17:48:09",
|
||||
},
|
||||
{
|
||||
Id: 9,
|
||||
OrderNo: "JU202508080015",
|
||||
OrderType: 4,
|
||||
ProductId: 2,
|
||||
ProductName: "新HTTP/S5",
|
||||
PackageName: "月卡(活动)",
|
||||
DayPrice: 88,
|
||||
ConnectCount: 6,
|
||||
AccountCount: 6,
|
||||
Accounts: "https-b1,https-b2,https-b3,https-b4,https-b5,https-b6",
|
||||
OrderAmount: 528,
|
||||
CouponAmount: 20,
|
||||
PaymentAmount: 508,
|
||||
CreateTime: "2025-08-08 12:26:34",
|
||||
UpdateTime: "2025-08-08 12:26:58",
|
||||
},
|
||||
{
|
||||
Id: 8,
|
||||
OrderNo: "JU202508070016",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "月卡(活动)",
|
||||
DayPrice: 58,
|
||||
ConnectCount: 2,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1240",
|
||||
OrderAmount: 58,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 58,
|
||||
CreateTime: "2025-08-07 09:11:26",
|
||||
UpdateTime: "2025-08-07 09:11:43",
|
||||
},
|
||||
{
|
||||
Id: 7,
|
||||
OrderNo: "JU202508060017",
|
||||
OrderType: 2,
|
||||
ProductId: 3,
|
||||
ProductName: "HTTP/S5",
|
||||
PackageName: "季卡",
|
||||
DayPrice: 268,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "global-004",
|
||||
OrderAmount: 268,
|
||||
CouponAmount: 8,
|
||||
PaymentAmount: 260,
|
||||
CreateTime: "2025-08-06 22:05:11",
|
||||
UpdateTime: "2025-08-06 22:05:35",
|
||||
},
|
||||
{
|
||||
Id: 6,
|
||||
OrderNo: "JU202508050018",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "天卡",
|
||||
DayPrice: 3,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1241",
|
||||
OrderAmount: 3,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 3,
|
||||
CreateTime: "2025-08-05 14:38:49",
|
||||
UpdateTime: "2025-08-05 14:39:05",
|
||||
},
|
||||
{
|
||||
Id: 5,
|
||||
OrderNo: "JU202508040019",
|
||||
OrderType: 3,
|
||||
ProductId: 3,
|
||||
ProductName: "HTTP/S5",
|
||||
PackageName: "月卡",
|
||||
DayPrice: 98,
|
||||
ConnectCount: 3,
|
||||
AccountCount: 3,
|
||||
Accounts: "global-b1,global-b2,global-b3",
|
||||
OrderAmount: 294,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 294,
|
||||
CreateTime: "2025-08-04 10:52:18",
|
||||
UpdateTime: "2025-08-04 10:52:37",
|
||||
},
|
||||
{
|
||||
Id: 4,
|
||||
OrderNo: "JU202508030020",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "双月卡(活动)",
|
||||
DayPrice: 98,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1242",
|
||||
OrderAmount: 98,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 98,
|
||||
CreateTime: "2025-08-03 16:07:23",
|
||||
UpdateTime: "2025-08-03 16:07:44",
|
||||
},
|
||||
{
|
||||
Id: 3,
|
||||
OrderNo: "JU202508020021",
|
||||
OrderType: 2,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "年卡",
|
||||
DayPrice: 688,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1243",
|
||||
OrderAmount: 688,
|
||||
CouponAmount: 30,
|
||||
PaymentAmount: 658,
|
||||
CreateTime: "2025-08-02 20:29:56",
|
||||
UpdateTime: "2025-08-02 20:30:14",
|
||||
},
|
||||
{
|
||||
Id: 2,
|
||||
OrderNo: "JU202508010022",
|
||||
OrderType: 1,
|
||||
ProductId: 2,
|
||||
ProductName: "新HTTP/S5",
|
||||
PackageName: "周卡",
|
||||
DayPrice: 25,
|
||||
ConnectCount: 2,
|
||||
AccountCount: 1,
|
||||
Accounts: "https-007",
|
||||
OrderAmount: 25,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 25,
|
||||
CreateTime: "2025-08-01 11:44:02",
|
||||
UpdateTime: "2025-08-01 11:44:21",
|
||||
},
|
||||
{
|
||||
Id: 1,
|
||||
OrderNo: "JU202507310023",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "月卡",
|
||||
DayPrice: 68,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1244",
|
||||
OrderAmount: 68,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 68,
|
||||
CreateTime: "2025-07-31 09:58:37",
|
||||
UpdateTime: "2025-07-31 09:58:55",
|
||||
},
|
||||
]
|
||||
|
||||
export const MOCK_PRODUCTS = [
|
||||
{ id: 1, name: "动静态IP" },
|
||||
{ id: 2, name: "新HTTP/S5" },
|
||||
{ id: 3, name: "HTTP/S5" },
|
||||
]
|
||||
@@ -1,59 +1,29 @@
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { fetchMyRefundOrders } from "@/api/order"
|
||||
import AccountCell from "@/components/composites/accountCell"
|
||||
import OrderFilterBar from "@/components/composites/orderFilterBar"
|
||||
import DataTable from "@/components/data-table"
|
||||
import Page from "@/components/page"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { useMyOrderPage } from "@/lib/hooks/useMyOrderPage"
|
||||
import { useProducts } from "@/lib/hooks/useProducts"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import {
|
||||
cutAccount,
|
||||
DEFAULT_FILTERS,
|
||||
formatDate,
|
||||
formatMoney,
|
||||
formatOrderType,
|
||||
type MyOrderQueryParams,
|
||||
type OrderFilters,
|
||||
type OrderItem,
|
||||
PACKAGE_OPTIONS,
|
||||
totalConnections,
|
||||
} from "@/lib/models/order"
|
||||
import { MOCK_ORDERS, MOCK_PRODUCTS } from "./mock"
|
||||
|
||||
function formatMoney(value: number): string {
|
||||
return value.toFixed(2)
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0")
|
||||
const day = String(date.getDate()).padStart(2, "0")
|
||||
return `${year}.${month}.${day}`
|
||||
}
|
||||
|
||||
function AccountCell({ accounts }: { accounts: string }) {
|
||||
const isCut = accounts.length > 15
|
||||
if (!isCut) return <span className="truncate">{accounts}</span>
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-default truncate">{cutAccount(accounts)}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-80 break-all">{accounts}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
const mapRefundFilters = (
|
||||
filters: OrderFilters,
|
||||
): Partial<MyOrderQueryParams> => ({
|
||||
BTime: filters.bTime || undefined,
|
||||
ETime: filters.eTime || undefined,
|
||||
ProductId: filters.productId === "0" ? undefined : Number(filters.productId),
|
||||
PackageId: filters.packageName === "0" ? undefined : filters.packageName,
|
||||
KeyWord: filters.keyword.trim() || undefined,
|
||||
})
|
||||
|
||||
const columns: ColumnDef<OrderItem>[] = [
|
||||
{
|
||||
@@ -86,12 +56,12 @@ const columns: ColumnDef<OrderItem>[] = [
|
||||
{
|
||||
accessorKey: "ConnectCount",
|
||||
header: "连接数",
|
||||
cell: ({ row }) => totalConnections(row.original),
|
||||
cell: ({ row }) => row.original.ConnectCount,
|
||||
},
|
||||
{
|
||||
accessorKey: "DayPrice",
|
||||
accessorKey: "DayCount",
|
||||
header: "退款时长",
|
||||
cell: ({ row }) => formatMoney(row.original.DayPrice),
|
||||
cell: ({ row }) => `${row.original.DayCount}天`,
|
||||
},
|
||||
{
|
||||
accessorKey: "DayPrice",
|
||||
@@ -104,166 +74,51 @@ const columns: ColumnDef<OrderItem>[] = [
|
||||
cell: ({ row }) => formatMoney(row.original.PaymentAmount),
|
||||
},
|
||||
{
|
||||
accessorKey: "CouponAmount",
|
||||
accessorKey: "RefundAmount",
|
||||
header: "退款金额",
|
||||
cell: ({ row }) => formatMoney(row.original.CouponAmount),
|
||||
cell: ({ row }) => formatMoney(row.original.RefundAmount),
|
||||
},
|
||||
]
|
||||
|
||||
function matchOrders(list: typeof MOCK_ORDERS, filters: OrderFilters) {
|
||||
const keyword = filters.keyword.trim()
|
||||
|
||||
return list.filter(order => {
|
||||
if (
|
||||
filters.orderType !== "0" &&
|
||||
order.OrderType !== Number(filters.orderType)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
filters.productId !== "0" &&
|
||||
order.ProductId !== Number(filters.productId)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
filters.packageName !== "0" &&
|
||||
order.PackageName !== filters.packageName
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
keyword &&
|
||||
!order.OrderNo.includes(keyword) &&
|
||||
!order.Accounts.includes(keyword)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (filters.bTime && order.CreateTime.slice(0, 10) < filters.bTime) {
|
||||
return false
|
||||
}
|
||||
if (filters.eTime && order.CreateTime.slice(0, 10) > filters.eTime) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export default function RefundOrdersPage() {
|
||||
const [filters, setFilters] = useState<OrderFilters>(DEFAULT_FILTERS)
|
||||
const [appliedFilters, setAppliedFilters] =
|
||||
useState<OrderFilters>(DEFAULT_FILTERS)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
|
||||
const filtered = useMemo(
|
||||
() => matchOrders(MOCK_ORDERS, appliedFilters),
|
||||
[appliedFilters],
|
||||
)
|
||||
|
||||
const paged = useMemo(
|
||||
() => filtered.slice((page - 1) * pageSize, page * pageSize),
|
||||
[filtered, page, pageSize],
|
||||
)
|
||||
|
||||
const patch = (partial: Partial<OrderFilters>) =>
|
||||
setFilters({ ...filters, ...partial })
|
||||
|
||||
const handleSearch = () => {
|
||||
setAppliedFilters(filters)
|
||||
setPage(1)
|
||||
}
|
||||
const products = useProducts()
|
||||
const {
|
||||
filters,
|
||||
patch,
|
||||
load,
|
||||
reset,
|
||||
total,
|
||||
status,
|
||||
page,
|
||||
size,
|
||||
setPage,
|
||||
changeSize,
|
||||
paged,
|
||||
} = useMyOrderPage(fetchMyRefundOrders, mapRefundFilters)
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="bg-white rounded-lg p-4 flex flex-wrap items-end gap-x-4 gap-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500 whitespace-nowrap">
|
||||
日期筛选:
|
||||
</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.bTime}
|
||||
onChange={e => patch({ bTime: e.target.value })}
|
||||
className="w-36"
|
||||
aria-label="开始时间"
|
||||
/>
|
||||
<span className="text-sm text-gray-400">至</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.eTime}
|
||||
onChange={e => patch({ eTime: e.target.value })}
|
||||
className="w-36"
|
||||
aria-label="结束时间"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={filters.productId}
|
||||
onValueChange={value => patch({ productId: value })}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue placeholder="全部产品" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">全部产品</SelectItem>
|
||||
{MOCK_PRODUCTS.map(product => (
|
||||
<SelectItem key={product.id} value={String(product.id)}>
|
||||
{product.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={filters.packageName}
|
||||
onValueChange={value => patch({ packageName: value })}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue placeholder="全部套餐" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">全部套餐</SelectItem>
|
||||
{PACKAGE_OPTIONS.map(pkg => (
|
||||
<SelectItem key={pkg} value={pkg}>
|
||||
{pkg}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={filters.keyword}
|
||||
onChange={e => patch({ keyword: e.target.value })}
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Enter") handleSearch()
|
||||
}}
|
||||
placeholder="账号"
|
||||
className="w-40"
|
||||
/>
|
||||
<Button onClick={handleSearch}>
|
||||
<SearchIcon data-icon="inline-start" />
|
||||
查询
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<OrderFilterBar
|
||||
filters={filters}
|
||||
products={products}
|
||||
showOrderType={false}
|
||||
onPatch={patch}
|
||||
onSearch={load}
|
||||
onReset={reset}
|
||||
/>
|
||||
|
||||
<div className="bg-white rounded-lg p-4 flex flex-col gap-4">
|
||||
<h3 className="font-semibold text-lg">订单管理</h3>
|
||||
<h3 className="font-semibold text-lg">退货订单管理</h3>
|
||||
<DataTable
|
||||
data={paged}
|
||||
status="done"
|
||||
status={status}
|
||||
columns={columns}
|
||||
pagination={{
|
||||
page: page - 1,
|
||||
size: pageSize,
|
||||
total: filtered.length,
|
||||
page,
|
||||
size,
|
||||
total,
|
||||
onPageChange: setPage,
|
||||
onSizeChange: size => {
|
||||
setPageSize(size)
|
||||
setPage(1)
|
||||
},
|
||||
onSizeChange: changeSize,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
232
src/admin/resources/long-renew-dialog.tsx
Normal file
232
src/admin/resources/long-renew-dialog.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { calcOrderPrice, rebuyOrder } from "@/api/http"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
type CxPackage,
|
||||
type LangIpItem,
|
||||
PERIOD_OPTIONS,
|
||||
type RebuyData,
|
||||
} from "@/lib/models/package"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type LongRenewSource =
|
||||
| { kind: "package"; pkg: CxPackage }
|
||||
| { kind: "ip"; ip: LangIpItem }
|
||||
|
||||
function buildRebuyData(
|
||||
source: LongRenewSource | null,
|
||||
periodType: number,
|
||||
periodAmount: number,
|
||||
): { orderType: number; data: RebuyData } | null {
|
||||
if (!source) return null
|
||||
if (source.kind === "package") {
|
||||
const { pkg } = source
|
||||
return {
|
||||
orderType: 5,
|
||||
data: {
|
||||
packId: pkg.id,
|
||||
packType: 31,
|
||||
periodType,
|
||||
periodAmount,
|
||||
ipAmount: pkg.ipAmount,
|
||||
isAbroad: pkg.isAbroad2 ?? 0,
|
||||
isRelayed: pkg.isRelayed ?? 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
const { ip } = source
|
||||
return {
|
||||
orderType: 6,
|
||||
data: {
|
||||
packId: ip.packId,
|
||||
packType: 31,
|
||||
periodType,
|
||||
periodAmount,
|
||||
ipAmount: 1,
|
||||
ids: String(ip.id),
|
||||
isAbroad: ip.isAbroad,
|
||||
isRelayed: ip.isRelayed,
|
||||
shareType: ip.shareType2,
|
||||
lineType: ip.lineType2,
|
||||
bandwidth: ip.bandwidth,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default function LongRenewDialog({
|
||||
open,
|
||||
source,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: {
|
||||
open: boolean
|
||||
source: LongRenewSource | null
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const [periodType, setPeriodType] = useState(1)
|
||||
const [periodAmount, setPeriodAmount] = useState(1)
|
||||
const [money, setMoney] = useState(0)
|
||||
const [priceLoading, setPriceLoading] = useState(false)
|
||||
const [paying, setPaying] = useState(false)
|
||||
const seqRef = useRef(0)
|
||||
|
||||
// 弹窗打开时重置选择
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setPeriodType(1)
|
||||
setPeriodAmount(1)
|
||||
setMoney(0)
|
||||
seqRef.current += 1
|
||||
}, [open])
|
||||
|
||||
// 参数变化时重新计算价格
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const target = buildRebuyData(source, periodType, periodAmount)
|
||||
if (!target) return
|
||||
const seq = seqRef.current + 1
|
||||
seqRef.current = seq
|
||||
setPriceLoading(true)
|
||||
|
||||
calcOrderPrice({
|
||||
order_type: target.orderType,
|
||||
money: 1,
|
||||
data: target.data,
|
||||
}).then(res => {
|
||||
if (seq !== seqRef.current) return
|
||||
setPriceLoading(false)
|
||||
if (!res.success) {
|
||||
setMoney(0)
|
||||
toast.error(res.message)
|
||||
return
|
||||
}
|
||||
setMoney(
|
||||
Number((periodAmount * target.data.ipAmount * res.data).toFixed(2)),
|
||||
)
|
||||
})
|
||||
}, [open, source, periodType, periodAmount])
|
||||
|
||||
const title = source
|
||||
? source.kind === "package"
|
||||
? "长效IP订单续费"
|
||||
: "长效IP续费"
|
||||
: ""
|
||||
const periodUnit =
|
||||
PERIOD_OPTIONS.find(opt => opt.value === periodType)?.unit ?? "天"
|
||||
|
||||
const handlePay = async () => {
|
||||
const target = buildRebuyData(source, periodType, periodAmount)
|
||||
if (!target || money <= 0) {
|
||||
toast.error("价格计算失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
setPaying(true)
|
||||
const res = await rebuyOrder({
|
||||
order_type: target.orderType,
|
||||
money,
|
||||
data: target.data,
|
||||
})
|
||||
setPaying(false)
|
||||
if (!res.success) {
|
||||
toast.error(res.message)
|
||||
return
|
||||
}
|
||||
if (res.data.code === 1) {
|
||||
toast.success(res.data.msg || "续费成功")
|
||||
onClose()
|
||||
onSuccess()
|
||||
} else {
|
||||
toast.error(res.data.msg || "续费失败")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={open => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
{source && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-6 py-2">
|
||||
<section>
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-3">
|
||||
套餐周期:
|
||||
</h5>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{PERIOD_OPTIONS.map(opt => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
variant={periodType === opt.value ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setPeriodType(opt.value)}
|
||||
className={cn(
|
||||
"rounded-md",
|
||||
periodType === opt.value &&
|
||||
"bg-linear-to-r from-blue-500 to-cyan-400 border-transparent hover:opacity-90",
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-3">
|
||||
购买时长:
|
||||
</h5>
|
||||
<div className="inline-flex items-center rounded-md border overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPeriodAmount(v => Math.max(1, v - 1))}
|
||||
disabled={periodAmount <= 1}
|
||||
className="px-4 py-2 bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90 disabled:opacity-40 transition-opacity cursor-pointer"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="px-6 py-2 text-sm font-medium tabular-nums bg-white min-w-24 text-center">
|
||||
{periodAmount}
|
||||
{periodUnit}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPeriodAmount(v => v + 1)}
|
||||
className="px-4 py-2 bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90 transition-opacity cursor-pointer"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => handlePay()}
|
||||
disabled={paying || priceLoading}
|
||||
className="w-full bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90"
|
||||
>
|
||||
{priceLoading
|
||||
? "价格计算中..."
|
||||
: paying
|
||||
? "支付中..."
|
||||
: `实付 ¥${money.toFixed(2)}`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
241
src/admin/resources/long-switch-dialog.tsx
Normal file
241
src/admin/resources/long-switch-dialog.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { calcOrderPrice, changeIpOrder, fetchSwitchCities } from "@/api/http"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import type { GameCity } from "@/lib/models/http"
|
||||
import type { LangIpItem } from "@/lib/models/package"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ISP_OPTIONS = [
|
||||
{ value: 0, label: "普通线路" },
|
||||
{ value: 2, label: "电信" },
|
||||
{ value: 3, label: "移动" },
|
||||
{ value: 4, label: "联通" },
|
||||
]
|
||||
|
||||
export default function LongSwitchDialog({
|
||||
open,
|
||||
ip,
|
||||
pop,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: {
|
||||
open: boolean
|
||||
ip: LangIpItem | null
|
||||
pop: 0 | 1
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const [cities, setCities] = useState<GameCity[]>([])
|
||||
const [isp, setIsp] = useState(0)
|
||||
const [cityCode, setCityCode] = useState(0)
|
||||
const [money, setMoney] = useState(0)
|
||||
const [priceLoading, setPriceLoading] = useState(false)
|
||||
const [paying, setPaying] = useState(false)
|
||||
const seqRef = useRef(0)
|
||||
|
||||
// 打开时重置并加载可选城市
|
||||
useEffect(() => {
|
||||
if (!open || !ip) return
|
||||
setIsp(0)
|
||||
setCityCode(0)
|
||||
setCities([])
|
||||
seqRef.current += 1
|
||||
|
||||
fetchSwitchCities(
|
||||
{
|
||||
gameId: ip.gameId,
|
||||
isAbroad: ip.isAbroad,
|
||||
shareType: ip.shareType2,
|
||||
isp: ip.isp,
|
||||
switchPop: pop,
|
||||
packId: ip.packId,
|
||||
},
|
||||
pop,
|
||||
).then(res => {
|
||||
if (!res.success) {
|
||||
toast.error(res.message)
|
||||
return
|
||||
}
|
||||
setCities(res.data)
|
||||
})
|
||||
}, [open, ip, pop])
|
||||
|
||||
// 重新计算价格
|
||||
useEffect(() => {
|
||||
if (!open || !ip) return
|
||||
const seq = seqRef.current + 1
|
||||
seqRef.current = seq
|
||||
setPriceLoading(true)
|
||||
|
||||
const rebuyData = {
|
||||
packId: ip.packId,
|
||||
packType: 31,
|
||||
periodType: 1,
|
||||
periodAmount: 1,
|
||||
ipAmount: 1,
|
||||
ids: String(ip.id),
|
||||
isRelayed: ip.isRelayed,
|
||||
shareType: ip.shareType2,
|
||||
lineType: ip.lineType2,
|
||||
bandwidth: ip.bandwidth,
|
||||
}
|
||||
|
||||
calcOrderPrice({ order_type: 6, money: 1, data: rebuyData }).then(res => {
|
||||
if (seq !== seqRef.current) return
|
||||
setPriceLoading(false)
|
||||
if (!res.success) {
|
||||
setMoney(0)
|
||||
toast.error(res.message)
|
||||
return
|
||||
}
|
||||
setMoney(Number(res.data.toFixed(2)))
|
||||
})
|
||||
}, [open, ip])
|
||||
|
||||
const title = pop === 1 ? "切换入口" : "长效IP切换节点"
|
||||
|
||||
const handlePay = async () => {
|
||||
if (!ip || money <= 0) {
|
||||
toast.error("价格计算失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
setPaying(true)
|
||||
const res = await changeIpOrder({
|
||||
order_type: 7,
|
||||
money,
|
||||
data: {
|
||||
title,
|
||||
id: ip.id,
|
||||
isAbroad: ip.isAbroad,
|
||||
cityCode,
|
||||
isp,
|
||||
switchPop: pop,
|
||||
data: {
|
||||
packId: ip.packId,
|
||||
packType: 31,
|
||||
periodType: 1,
|
||||
periodAmount: 1,
|
||||
ipAmount: 1,
|
||||
ids: String(ip.id),
|
||||
isRelayed: ip.isRelayed,
|
||||
shareType: ip.shareType2,
|
||||
lineType: ip.lineType2,
|
||||
bandwidth: ip.bandwidth,
|
||||
},
|
||||
},
|
||||
})
|
||||
setPaying(false)
|
||||
if (!res.success) {
|
||||
toast.error(res.message)
|
||||
return
|
||||
}
|
||||
if (res.data.code === 1) {
|
||||
toast.success(res.data.msg || "切换成功")
|
||||
onClose()
|
||||
onSuccess()
|
||||
} else {
|
||||
toast.error(res.data.msg || "剩余线路不足,切换下单失败,请稍后再试")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={open => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
{ip && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-6 py-2">
|
||||
{ip.isAbroad === 0 && (
|
||||
<section>
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-3">
|
||||
运营商选择:
|
||||
</h5>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{ISP_OPTIONS.map(opt => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
variant={isp === opt.value ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setIsp(opt.value)}
|
||||
className={cn(
|
||||
"rounded-md",
|
||||
isp === opt.value &&
|
||||
"bg-linear-to-r from-blue-500 to-cyan-400 border-transparent hover:opacity-90",
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-3">
|
||||
地区选择:
|
||||
</h5>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant={cityCode === 0 ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setCityCode(0)}
|
||||
className={cn(
|
||||
"rounded-md justify-center",
|
||||
cityCode === 0 &&
|
||||
"bg-linear-to-r from-blue-500 to-cyan-400 border-transparent hover:opacity-90",
|
||||
)}
|
||||
>
|
||||
随机地区
|
||||
</Button>
|
||||
{cities.map(city => (
|
||||
<Button
|
||||
key={city.code}
|
||||
type="button"
|
||||
variant={cityCode === city.code ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setCityCode(city.code)}
|
||||
className={cn(
|
||||
"rounded-md justify-center",
|
||||
cityCode === city.code &&
|
||||
"bg-linear-to-r from-blue-500 to-cyan-400 border-transparent hover:opacity-90",
|
||||
)}
|
||||
>
|
||||
{city.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => handlePay()}
|
||||
disabled={paying || priceLoading}
|
||||
className="w-full bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90"
|
||||
>
|
||||
{priceLoading
|
||||
? "价格计算中..."
|
||||
: paying
|
||||
? "下单中..."
|
||||
: `实付 ¥${money.toFixed(2)}`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,478 @@
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
fetchCxOrders,
|
||||
fetchCxPackages,
|
||||
fetchLangIpList,
|
||||
fetchLangIps,
|
||||
fetchSwitchList,
|
||||
} from "@/api/http"
|
||||
import DataTable from "@/components/data-table"
|
||||
import Page from "@/components/page"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import type {
|
||||
CxOrder,
|
||||
CxPackage,
|
||||
LangIpItem,
|
||||
SwitchRecord,
|
||||
} from "@/lib/models/package"
|
||||
import {
|
||||
formatAbroad,
|
||||
formatExpired,
|
||||
formatIpState,
|
||||
formatLineType,
|
||||
formatOrderTypeLabel,
|
||||
formatShareType,
|
||||
formatSwitchReason,
|
||||
LONG_ORDER_TYPE_LABELS,
|
||||
parseExpired,
|
||||
} from "@/lib/models/package"
|
||||
import { downloadTextFile, estimateTotal, formatUnixTime } from "@/lib/utils"
|
||||
import LongRenewDialog, { type LongRenewSource } from "./long-renew-dialog"
|
||||
import LongSwitchDialog from "./long-switch-dialog"
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export default function LongPage() {
|
||||
return <Page>长效管理</Page>
|
||||
const navigate = useNavigate()
|
||||
const [activeTab, setActiveTab] = useState("ips")
|
||||
|
||||
// 已开通长效
|
||||
const [ipList, setIpList] = useState<LangIpItem[]>([])
|
||||
const [ipStatus, setIpStatus] = useState<"load" | "done" | "fail">("load")
|
||||
const [ipPage, setIpPage] = useState(1)
|
||||
const [ipTotal, setIpTotal] = useState(0)
|
||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({})
|
||||
|
||||
// 全部套餐
|
||||
const [packages, setPackages] = useState<CxPackage[]>([])
|
||||
const [packageStatus, setPackageStatus] = useState<"load" | "done" | "fail">(
|
||||
"load",
|
||||
)
|
||||
|
||||
// 购买记录
|
||||
const [orders, setOrders] = useState<CxOrder[]>([])
|
||||
const [orderStatus, setOrderStatus] = useState<"load" | "done" | "fail">(
|
||||
"load",
|
||||
)
|
||||
|
||||
// IP切换记录
|
||||
const [switchList, setSwitchList] = useState<SwitchRecord[]>([])
|
||||
const [switchStatus, setSwitchStatus] = useState<"load" | "done" | "fail">(
|
||||
"load",
|
||||
)
|
||||
const [switchPage, setSwitchPage] = useState(1)
|
||||
const [switchTotal, setSwitchTotal] = useState(0)
|
||||
|
||||
// 弹窗
|
||||
const [renewSource, setRenewSource] = useState<LongRenewSource | null>(null)
|
||||
const [switchTarget, setSwitchTarget] = useState<{
|
||||
ip: LangIpItem
|
||||
pop: 0 | 1
|
||||
} | null>(null)
|
||||
|
||||
const loadIps = useCallback(async (page: number) => {
|
||||
setIpStatus("load")
|
||||
const res = await fetchLangIpList({
|
||||
page,
|
||||
limit: PAGE_SIZE,
|
||||
packId: "",
|
||||
isAbroad: "",
|
||||
isExpired: 0,
|
||||
state: -1,
|
||||
ip: "",
|
||||
groupId: "",
|
||||
gameId: "",
|
||||
cityCode: "",
|
||||
orderBy: 0,
|
||||
})
|
||||
if (!res.success) {
|
||||
setIpStatus("fail")
|
||||
toast.error(res.message)
|
||||
return
|
||||
}
|
||||
setIpList(res.data.items)
|
||||
setIpTotal(
|
||||
estimateTotal(
|
||||
res.data.pager.totalCount,
|
||||
res.data.pager.currentPage || page,
|
||||
PAGE_SIZE,
|
||||
res.data.items.length,
|
||||
),
|
||||
)
|
||||
setIpStatus("done")
|
||||
}, [])
|
||||
|
||||
const loadPackages = useCallback(async () => {
|
||||
setPackageStatus("load")
|
||||
const res = await fetchCxPackages()
|
||||
if (!res.success) {
|
||||
setPackageStatus("fail")
|
||||
return
|
||||
}
|
||||
setPackages(res.data)
|
||||
setPackageStatus("done")
|
||||
}, [])
|
||||
|
||||
const loadOrders = useCallback(async () => {
|
||||
setOrderStatus("load")
|
||||
const res = await fetchCxOrders()
|
||||
if (!res.success) {
|
||||
setOrderStatus("fail")
|
||||
return
|
||||
}
|
||||
setOrders(res.data)
|
||||
setOrderStatus("done")
|
||||
}, [])
|
||||
|
||||
const loadSwitch = useCallback(async (page: number) => {
|
||||
setSwitchStatus("load")
|
||||
const res = await fetchSwitchList({
|
||||
page,
|
||||
limit: PAGE_SIZE,
|
||||
})
|
||||
if (!res.success) {
|
||||
setSwitchStatus("fail")
|
||||
toast.error(res.message)
|
||||
return
|
||||
}
|
||||
setSwitchList(res.data.items)
|
||||
setSwitchTotal(
|
||||
estimateTotal(
|
||||
res.data.pager.totalCount,
|
||||
res.data.pager.currentPage || page,
|
||||
PAGE_SIZE,
|
||||
res.data.items.length,
|
||||
),
|
||||
)
|
||||
setSwitchStatus("done")
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadIps(1)
|
||||
}, [loadIps])
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value)
|
||||
if (value === "packages" && !packages.length) loadPackages()
|
||||
if (value === "orders" && !orders.length) loadOrders()
|
||||
if (value === "switch" && !switchList.length) loadSwitch(1)
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setIpPage(page)
|
||||
setRowSelection({})
|
||||
loadIps(page)
|
||||
}
|
||||
|
||||
const handleSwitchPageChange = (page: number) => {
|
||||
setSwitchPage(page)
|
||||
loadSwitch(page)
|
||||
}
|
||||
|
||||
const handleExtract = async () => {
|
||||
const ids = Object.keys(rowSelection)
|
||||
if (ids.length === 0) {
|
||||
toast.error("请选择IP!")
|
||||
return
|
||||
}
|
||||
const res = await fetchLangIps(ids.join(","))
|
||||
if (!res.success) {
|
||||
toast.error(res.message)
|
||||
return
|
||||
}
|
||||
if (res.data.length === 0) {
|
||||
toast.error("提取失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
const content = res.data
|
||||
.map(
|
||||
ip =>
|
||||
`${ip.ip}|${ip.httpPort}|${ip.socks5Port}|${ip.username}|${ip.password}`,
|
||||
)
|
||||
.join("\n")
|
||||
downloadTextFile("ip.txt", content)
|
||||
toast.success("提取成功,已开始下载")
|
||||
}
|
||||
|
||||
const ipColumns = useMemo<ColumnDef<LangIpItem>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "isRelayed",
|
||||
header: "模式",
|
||||
cell: ({ row }) => (row.original.isRelayed ? "中继" : "直连"),
|
||||
},
|
||||
{ accessorKey: "ip", header: "节点入口IP" },
|
||||
{ accessorKey: "realIp", header: "隧道出口IP" },
|
||||
{
|
||||
id: "ports",
|
||||
header: "HTTP/SK5端口",
|
||||
cell: ({ row }) =>
|
||||
`${row.original.httpPort}/${row.original.socks5Port}`,
|
||||
},
|
||||
{ accessorKey: "area", header: "归属地" },
|
||||
{
|
||||
id: "bandwidth",
|
||||
header: "带宽",
|
||||
cell: ({ row }) =>
|
||||
`${row.original.bandwidth}M/${formatLineType(row.original.lineType)}`,
|
||||
},
|
||||
{
|
||||
accessorKey: "shareType",
|
||||
header: "线路",
|
||||
cell: ({ row }) => formatShareType(row.original.shareType),
|
||||
},
|
||||
{
|
||||
accessorKey: "state",
|
||||
header: "状态",
|
||||
cell: ({ row }) => formatIpState(row.original.state),
|
||||
},
|
||||
{
|
||||
id: "account",
|
||||
header: "账号/密码",
|
||||
cell: ({ row }) => `${row.original.username}/${row.original.password}`,
|
||||
},
|
||||
{
|
||||
accessorKey: "expireTime",
|
||||
header: "过期时间",
|
||||
cell: ({ row }) => formatUnixTime(row.original.expireTime),
|
||||
},
|
||||
{
|
||||
accessorKey: "isExpired",
|
||||
header: "是否过期",
|
||||
cell: ({ row }) =>
|
||||
parseExpired(row.original.isExpired) ? "已过期" : "未过期",
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "操作",
|
||||
cell: ({ row }) => {
|
||||
const ip = row.original
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setRenewSource({ kind: "ip", ip })}
|
||||
className="bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90"
|
||||
>
|
||||
续费
|
||||
</Button>
|
||||
{ip.isAbroad === 1 && ip.isRelayed === 1 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setSwitchTarget({ ip, pop: 1 })}
|
||||
>
|
||||
切换入口
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setSwitchTarget({ ip, pop: 0 })}
|
||||
>
|
||||
切换节点
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const packageColumns = useMemo<ColumnDef<CxPackage>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "套餐名称",
|
||||
cell: ({ row }) => row.original.name || "-",
|
||||
},
|
||||
{
|
||||
accessorKey: "isAbroad",
|
||||
header: "套餐类型",
|
||||
cell: ({ row }) => formatAbroad(row.original.isAbroad),
|
||||
},
|
||||
{
|
||||
accessorKey: "shareType",
|
||||
header: "模式",
|
||||
cell: ({ row }) => formatShareType(row.original.shareType),
|
||||
},
|
||||
{ accessorKey: "ipAmount", header: "可用IP数量(个)" },
|
||||
{
|
||||
accessorKey: "isExpired",
|
||||
header: "是否过期",
|
||||
cell: ({ row }) => formatExpired(row.original.isExpired),
|
||||
},
|
||||
{
|
||||
accessorKey: "expireTime",
|
||||
header: "过期时间",
|
||||
cell: ({ row }) => formatUnixTime(row.original.expireTime),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "续费",
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setRenewSource({ kind: "package", pkg: row.original })
|
||||
}
|
||||
className="bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90"
|
||||
>
|
||||
续费
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const orderColumns = useMemo<ColumnDef<CxOrder>[]>(
|
||||
() => [
|
||||
{ accessorKey: "order_no", header: "订单号" },
|
||||
{
|
||||
accessorKey: "order_type",
|
||||
header: "订单类型",
|
||||
cell: ({ row }) =>
|
||||
formatOrderTypeLabel(LONG_ORDER_TYPE_LABELS, row.original.order_type),
|
||||
},
|
||||
{ accessorKey: "game_type", header: "游戏类型" },
|
||||
{ accessorKey: "ip_count", header: "IP数量" },
|
||||
{ accessorKey: "money", header: "实付金额" },
|
||||
{ accessorKey: "create_time", header: "支付时间" },
|
||||
{
|
||||
id: "rebuy",
|
||||
header: "再次购买",
|
||||
cell: () => (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => navigate("/product/http")}
|
||||
>
|
||||
再次购买
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[navigate],
|
||||
)
|
||||
|
||||
const switchColumns = useMemo<ColumnDef<SwitchRecord>[]>(
|
||||
() => [
|
||||
{ accessorKey: "oldIp", header: "原IP" },
|
||||
{ accessorKey: "oldArea", header: "原属地" },
|
||||
{ accessorKey: "newIp", header: "切换后IP" },
|
||||
{ accessorKey: "newArea", header: "切换后属地" },
|
||||
{
|
||||
accessorKey: "reason",
|
||||
header: "切换方式",
|
||||
cell: ({ row }) => formatSwitchReason(row.original.reason),
|
||||
},
|
||||
{
|
||||
accessorKey: "createTime",
|
||||
header: "切换时间",
|
||||
cell: ({ row }) => formatUnixTime(row.original.createTime),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="bg-white rounded-lg p-4">
|
||||
<h3 className="font-semibold text-lg mb-4">长效套餐</h3>
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="ips">已开通长效</TabsTrigger>
|
||||
<TabsTrigger value="packages">全部套餐</TabsTrigger>
|
||||
<TabsTrigger value="orders">购买记录</TabsTrigger>
|
||||
<TabsTrigger value="switch">IP切换记录</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="ips" className="pt-4 flex flex-col gap-3">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<Button
|
||||
onClick={() => handleExtract()}
|
||||
className="bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90"
|
||||
>
|
||||
批量提取IP
|
||||
</Button>
|
||||
<span className="text-xs text-gray-500">
|
||||
格式:IP|http端口|socks5端口|账号|密码
|
||||
</span>
|
||||
</div>
|
||||
<DataTable
|
||||
data={ipList}
|
||||
status={ipStatus}
|
||||
columns={ipColumns}
|
||||
enableSelection
|
||||
rowSelection={rowSelection}
|
||||
onSelectionChange={setRowSelection}
|
||||
getRowId={row => String(row.id)}
|
||||
pagination={{
|
||||
page: ipPage,
|
||||
size: PAGE_SIZE,
|
||||
total: ipTotal,
|
||||
sizeOptions: [PAGE_SIZE],
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="packages" className="pt-4">
|
||||
<DataTable
|
||||
data={packages}
|
||||
status={packageStatus}
|
||||
columns={packageColumns}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="orders" className="pt-4">
|
||||
<DataTable
|
||||
data={orders}
|
||||
status={orderStatus}
|
||||
columns={orderColumns}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="switch" className="pt-4">
|
||||
<DataTable
|
||||
data={switchList}
|
||||
status={switchStatus}
|
||||
columns={switchColumns}
|
||||
pagination={{
|
||||
page: switchPage,
|
||||
size: PAGE_SIZE,
|
||||
total: switchTotal,
|
||||
sizeOptions: [PAGE_SIZE],
|
||||
onPageChange: handleSwitchPageChange,
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<LongRenewDialog
|
||||
open={renewSource !== null}
|
||||
source={renewSource}
|
||||
onClose={() => setRenewSource(null)}
|
||||
onSuccess={() => {
|
||||
loadIps(ipPage)
|
||||
loadPackages()
|
||||
}}
|
||||
/>
|
||||
|
||||
<LongSwitchDialog
|
||||
open={switchTarget !== null}
|
||||
ip={switchTarget?.ip ?? null}
|
||||
pop={switchTarget?.pop ?? 0}
|
||||
onClose={() => setSwitchTarget(null)}
|
||||
onSuccess={() => loadIps(ipPage)}
|
||||
/>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
720
src/admin/resources/short-api-link.tsx
Normal file
720
src/admin/resources/short-api-link.tsx
Normal file
@@ -0,0 +1,720 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { fetchUserApiToken } from "@/api/http"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import {
|
||||
type ApiLinkConfig,
|
||||
type DxPackage,
|
||||
REGION_OPTIONS,
|
||||
SHORT_PLAN_UNLIMITED,
|
||||
type UserApiToken,
|
||||
} from "@/lib/models/package"
|
||||
|
||||
const API_LINK_BASE = "http://get.ip.juip.com/get/ip"
|
||||
|
||||
const DURATION_OPTIONS = [
|
||||
{ value: "0", label: "默认" },
|
||||
{ value: "1", label: "1分钟以上" },
|
||||
{ value: "2", label: "2分钟以上" },
|
||||
{ value: "3", label: "3分钟以上" },
|
||||
{ value: "4", label: "4分钟以上" },
|
||||
]
|
||||
|
||||
const PROTOCOL_OPTIONS = [
|
||||
{ value: "1", label: "HTTP" },
|
||||
{ value: "2", label: "SOCKS5" },
|
||||
]
|
||||
|
||||
const FORMAT_OPTIONS = [
|
||||
{ value: "1", label: "TXT" },
|
||||
{ value: "2", label: "JSON" },
|
||||
{ value: "3", label: "HTML" },
|
||||
]
|
||||
|
||||
const LINE_BREAK_OPTIONS = [
|
||||
{ value: "1", label: "回车换行(\\r\\n)" },
|
||||
{ value: "3", label: "回车(\\r)" },
|
||||
{ value: "4", label: "换行(\\n)" },
|
||||
{ value: "5", label: "Tab(\\t)" },
|
||||
{ value: "6", label: "其他符号:" },
|
||||
]
|
||||
|
||||
const ISP_OPTIONS = [
|
||||
{ value: "0", label: "不限" },
|
||||
{ value: "4", label: "联通" },
|
||||
{ value: "2", label: "电信" },
|
||||
]
|
||||
|
||||
const AUTH_MODE_OPTIONS = [
|
||||
{ value: "1", label: "白名单" },
|
||||
{ value: "2", label: "白名单+账密" },
|
||||
{ value: "3", label: "仅账号密码" },
|
||||
]
|
||||
|
||||
const SEPARATOR_OPTIONS = [
|
||||
{ value: "1", label: "竖线(|)" },
|
||||
{ value: "2", label: "斜线(/)" },
|
||||
{ value: "3", label: "冒号(:)" },
|
||||
{ value: "6", label: "自定义:" },
|
||||
]
|
||||
|
||||
const API_PARAM_DOCS: {
|
||||
name: string
|
||||
type: string
|
||||
required: string
|
||||
desc: string
|
||||
}[] = [
|
||||
{
|
||||
name: "pt",
|
||||
type: "int",
|
||||
required: "是",
|
||||
desc: "套餐类型,0或12-储值套餐,21-短效包天,22-短效包量,23-短效无限量,29-终身免费VIP套餐,31-长效套餐",
|
||||
},
|
||||
{ name: "num", type: "int", required: "是", desc: "提取IP数量" },
|
||||
{
|
||||
name: "gt",
|
||||
type: "int",
|
||||
required: "否",
|
||||
desc: "提取时长 0-默认 1-1分钟以上 2-2分钟以上 3-3分钟以上 4-4分钟以上",
|
||||
},
|
||||
{
|
||||
name: "regions",
|
||||
type: "string",
|
||||
required: "否",
|
||||
desc: "多个用英文逗号换行,不传表示随机",
|
||||
},
|
||||
{ name: "isp", type: "int", required: "是", desc: "0:不限 4:联通 2:电信" },
|
||||
{ name: "port", type: "int", required: "是", desc: "IP协议 1:HTTP 2:SOCKS5" },
|
||||
{
|
||||
name: "time",
|
||||
type: "int",
|
||||
required: "否",
|
||||
desc: "有效时长 1-0.1至1分钟 5-1至5分钟 25-5至25分钟 180-25至180分钟 360-3至6小时",
|
||||
},
|
||||
{
|
||||
name: "type",
|
||||
type: "int",
|
||||
required: "否",
|
||||
desc: "数据格式:1:TXT 2:JSON 3:HTML",
|
||||
},
|
||||
{ name: "pack", type: "int", required: "否", desc: "用户套餐ID" },
|
||||
{
|
||||
name: "ts",
|
||||
type: "int",
|
||||
required: "否",
|
||||
desc: "是否显示IP过期时间: 1显示 0不显示",
|
||||
},
|
||||
{
|
||||
name: "lb",
|
||||
type: "int",
|
||||
required: "否",
|
||||
desc: "换行符(1:\\r\\n 2:/br 3:\\r 4:\\n 5:\\t 6:自定义)",
|
||||
},
|
||||
{ name: "clb", type: "string", required: "否", desc: "自定义换行符" },
|
||||
{
|
||||
name: "sp",
|
||||
type: "int",
|
||||
required: "否",
|
||||
desc: "分隔符(1:竖线| 2:斜线/ 3:冒号 6:自定义)",
|
||||
},
|
||||
{ name: "csp", type: "string", required: "否", desc: "自定义分隔符" },
|
||||
{
|
||||
name: "distinct",
|
||||
type: "int",
|
||||
required: "否",
|
||||
desc: "去重选择(2:严格去重。0:不去重)",
|
||||
},
|
||||
{
|
||||
name: "aw",
|
||||
type: "int",
|
||||
required: "否",
|
||||
desc: "是否将调用者自动添加到IP白名单(0:不自动添加。1:自动添加)",
|
||||
},
|
||||
{
|
||||
name: "at",
|
||||
type: "int",
|
||||
required: "否",
|
||||
desc: "鉴权模式 1-白名单,2-白名单+账密严格鉴权,3-仅账密鉴权(无限量不可用)",
|
||||
},
|
||||
]
|
||||
|
||||
const RESPONSE_FIELD_DOCS: { name: string; type: string; desc: string }[] = [
|
||||
{ name: "success", type: "bool", desc: "是否成功" },
|
||||
{ name: "code", type: "int", desc: "0表示成功,其他参见《错误代码》表" },
|
||||
{ name: "ip", type: "string", desc: "IP" },
|
||||
{ name: "port", type: "int", desc: "端口号" },
|
||||
]
|
||||
|
||||
const ERROR_CODE_DOCS: { code: string; msg: string; desc: string }[] = [
|
||||
{
|
||||
code: "111",
|
||||
msg: "请在1秒后再次请求",
|
||||
desc: "提取链接请求太过频繁,超出限制",
|
||||
},
|
||||
{
|
||||
code: "113",
|
||||
msg: "请将192.168.0.0设置为白名单!",
|
||||
desc: "白名单未添加/白名单掉了",
|
||||
},
|
||||
{ code: "114", msg: "余额不足", desc: "账户金额消耗完毕" },
|
||||
{
|
||||
code: "115",
|
||||
msg: "请更换地区等条件重新生成api链接地址",
|
||||
desc: "没有资源或没有符合条件的数据",
|
||||
},
|
||||
{
|
||||
code: "116",
|
||||
msg: "您的套餐今日已到达上限!",
|
||||
desc: "套餐内IP数量消耗完毕",
|
||||
},
|
||||
{
|
||||
code: "117",
|
||||
msg: "您的套餐pack传参有误!请检测您现在的ip是否在套餐所在账户!",
|
||||
desc: "检测本地白名单是不是在账户下",
|
||||
},
|
||||
{
|
||||
code: "118",
|
||||
msg: "您的账户异常,请联系客服!",
|
||||
desc: "账户处于被禁用状态",
|
||||
},
|
||||
{ code: "121", msg: "您的该套餐已经过期了!", desc: "套餐过期" },
|
||||
{
|
||||
code: "401",
|
||||
msg: "IP:1.1.1.1:2000 Authentication failed!",
|
||||
desc: "白名单错误/使用的IP已经过期",
|
||||
},
|
||||
{
|
||||
code: "403",
|
||||
msg: "Sorry!The site has been banned!",
|
||||
desc: "客户目标网站异常,联系客服处理",
|
||||
},
|
||||
]
|
||||
|
||||
const RESPONSE_SAMPLE = `{
|
||||
"code": 0,
|
||||
"msg": "提示信息",
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"ip": "49.68.68.197", // 隧道ip(代理ip)
|
||||
"port": 33220, // 代理端口
|
||||
"expireTime": "2019-05-24 08:58:31"
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
type FieldRowProps = {
|
||||
label: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function FieldRow({ label, children }: FieldRowProps) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row gap-2 sm:gap-4">
|
||||
<div className="sm:w-32 shrink-0 text-sm text-gray-600 sm:text-right pt-1">
|
||||
{label}
|
||||
</div>
|
||||
<div className="flex-1 flex flex-wrap items-center gap-x-6 gap-y-2">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioItem({
|
||||
idPrefix,
|
||||
value,
|
||||
label,
|
||||
}: {
|
||||
idPrefix: string
|
||||
value: string
|
||||
label: string
|
||||
}) {
|
||||
const id = `${idPrefix}-${value}`
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value={value} id={id} />
|
||||
<Label htmlFor={id} className="text-sm cursor-pointer font-normal">
|
||||
{label}
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ShortApiLink({ packages }: { packages: DxPackage[] }) {
|
||||
const [packId, setPackId] = useState<string>("")
|
||||
const [config, setConfig] = useState<ApiLinkConfig>({
|
||||
num: 1,
|
||||
gt: 0,
|
||||
port: 1,
|
||||
type: 1,
|
||||
lb: 1,
|
||||
distinct: 2,
|
||||
isp: 0,
|
||||
aw: 0,
|
||||
at: 1,
|
||||
sp: 1,
|
||||
})
|
||||
const [regions, setRegions] = useState<string[]>([])
|
||||
const [token, setToken] = useState<UserApiToken | null>(null)
|
||||
const [link, setLink] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserApiToken().then(res => {
|
||||
if (res.success) setToken(res.data)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const currentPackage = packages.find(p => String(p.id) === packId)
|
||||
const isUnlimited = config.pt === SHORT_PLAN_UNLIMITED
|
||||
|
||||
const patchConfig = (partial: Partial<ApiLinkConfig>) =>
|
||||
setConfig(prev => ({ ...prev, ...partial }))
|
||||
|
||||
// 切换套餐:联动套餐类型与有效时长
|
||||
const handlePackageChange = (value: string) => {
|
||||
setPackId(value)
|
||||
const pkg = packages.find(p => String(p.id) === value)
|
||||
if (!pkg) return
|
||||
patchConfig({
|
||||
pack: pkg.id,
|
||||
pt: pkg.planType,
|
||||
time: pkg.durationType2,
|
||||
...(pkg.planType === SHORT_PLAN_UNLIMITED ? { distinct: 0 } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
const toggleRegion = (value: string, checked: boolean) => {
|
||||
setRegions(prev =>
|
||||
checked ? [...prev, value] : prev.filter(v => v !== value),
|
||||
)
|
||||
}
|
||||
|
||||
const buildLink = () => {
|
||||
const params: string[] = []
|
||||
const push = (key: string, value: unknown) => {
|
||||
if (value === undefined || value === null || value === "") return
|
||||
params.push(`${key}=${String(value)}`)
|
||||
}
|
||||
push("pack", config.pack)
|
||||
push("pt", config.pt)
|
||||
push("time", config.time)
|
||||
push("num", config.num)
|
||||
push("gt", config.gt)
|
||||
push("port", config.port)
|
||||
push("type", config.type)
|
||||
push("lb", config.lb)
|
||||
push("clb", config.clb)
|
||||
push("distinct", config.distinct)
|
||||
push("isp", config.isp)
|
||||
push("aw", config.aw)
|
||||
if (config.aw === 1 && token) {
|
||||
push("u", token.uid)
|
||||
push("t", token.token)
|
||||
}
|
||||
push("at", config.at)
|
||||
if (config.at !== 1) {
|
||||
push("sp", config.sp)
|
||||
push("csp", config.csp)
|
||||
}
|
||||
if (regions.length > 0) push("regions", regions.join(","))
|
||||
return `${API_LINK_BASE}?${params.join("&")}`
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!packId) {
|
||||
toast.error("请选择套餐")
|
||||
return
|
||||
}
|
||||
setLink(buildLink())
|
||||
}
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!packId) {
|
||||
toast.error("请选择套餐")
|
||||
return
|
||||
}
|
||||
const url = link || buildLink()
|
||||
setLink(url)
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
toast.success("复制成功")
|
||||
} catch {
|
||||
toast.error("复制失败,请手动复制")
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpen = () => {
|
||||
if (!packId) {
|
||||
toast.error("请选择套餐")
|
||||
return
|
||||
}
|
||||
const url = link || buildLink()
|
||||
setLink(url)
|
||||
window.open(url, "_blank")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<FieldRow label="请选择提取类型">
|
||||
<Select value={packId} onValueChange={handlePackageChange}>
|
||||
<SelectTrigger className="w-72">
|
||||
<SelectValue placeholder="请选择套餐" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{packages.map(item => (
|
||||
<SelectItem key={item.id} value={String(item.id)}>
|
||||
({item.id}){item.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FieldRow>
|
||||
|
||||
<FieldRow label="提取数量">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={200}
|
||||
value={config.num}
|
||||
onChange={e =>
|
||||
patchConfig({
|
||||
num: Math.min(200, Math.max(1, Number(e.target.value) || 1)),
|
||||
})
|
||||
}
|
||||
className="w-24"
|
||||
/>
|
||||
<span className="text-sm text-gray-600">
|
||||
个IP{" "}
|
||||
<span className="text-gray-400">单次可提取最大数量 200 个</span>
|
||||
</span>
|
||||
</div>
|
||||
</FieldRow>
|
||||
|
||||
{(isUnlimited || config.pt === 21) && (
|
||||
<FieldRow label="提取时长">
|
||||
<RadioGroup
|
||||
value={String(config.gt)}
|
||||
onValueChange={v => patchConfig({ gt: Number(v) })}
|
||||
className="flex flex-wrap"
|
||||
>
|
||||
{DURATION_OPTIONS.map(opt => (
|
||||
<RadioItem
|
||||
key={opt.value}
|
||||
idPrefix="gt"
|
||||
value={opt.value}
|
||||
label={opt.label}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FieldRow>
|
||||
)}
|
||||
|
||||
<FieldRow label="IP协议">
|
||||
<RadioGroup
|
||||
value={String(config.port)}
|
||||
onValueChange={v => patchConfig({ port: Number(v) })}
|
||||
className="flex flex-wrap"
|
||||
>
|
||||
{PROTOCOL_OPTIONS.map(opt => (
|
||||
<RadioItem
|
||||
key={opt.value}
|
||||
idPrefix="port"
|
||||
value={opt.value}
|
||||
label={opt.label}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FieldRow>
|
||||
|
||||
<FieldRow label="数据格式">
|
||||
<RadioGroup
|
||||
value={String(config.type)}
|
||||
onValueChange={v => patchConfig({ type: Number(v) })}
|
||||
className="flex flex-wrap"
|
||||
>
|
||||
{FORMAT_OPTIONS.map(opt => (
|
||||
<RadioItem
|
||||
key={opt.value}
|
||||
idPrefix="type"
|
||||
value={opt.value}
|
||||
label={opt.label}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FieldRow>
|
||||
|
||||
<FieldRow label="换行符">
|
||||
<RadioGroup
|
||||
value={String(config.lb)}
|
||||
onValueChange={v => patchConfig({ lb: Number(v) })}
|
||||
className="flex flex-wrap items-center"
|
||||
>
|
||||
{LINE_BREAK_OPTIONS.map(opt => (
|
||||
<RadioItem
|
||||
key={opt.value}
|
||||
idPrefix="lb"
|
||||
value={opt.value}
|
||||
label={opt.label}
|
||||
/>
|
||||
))}
|
||||
<Input
|
||||
value={config.clb ?? ""}
|
||||
onChange={e => patchConfig({ clb: e.target.value })}
|
||||
placeholder="请输入换行符"
|
||||
className="w-36 h-8"
|
||||
/>
|
||||
</RadioGroup>
|
||||
</FieldRow>
|
||||
|
||||
<FieldRow label="地区选择">
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex gap-4 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRegions(REGION_OPTIONS.map(r => r.value))}
|
||||
className="text-blue-500 hover:underline cursor-pointer"
|
||||
>
|
||||
全选
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRegions([])}
|
||||
className="text-blue-500 hover:underline cursor-pointer"
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-5 gap-y-2">
|
||||
{REGION_OPTIONS.map(region => (
|
||||
<label
|
||||
key={region.value}
|
||||
className="flex items-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={regions.includes(region.value)}
|
||||
onCheckedChange={checked =>
|
||||
toggleRegion(region.value, checked === true)
|
||||
}
|
||||
/>
|
||||
<span className="text-sm">{region.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</FieldRow>
|
||||
|
||||
<FieldRow label="IP去重">
|
||||
{isUnlimited ? (
|
||||
<span className="text-sm text-gray-600">不去重</span>
|
||||
) : (
|
||||
<RadioGroup
|
||||
value={String(config.distinct)}
|
||||
onValueChange={v => patchConfig({ distinct: Number(v) })}
|
||||
className="flex flex-wrap"
|
||||
>
|
||||
<RadioItem idPrefix="distinct" value="2" label="严格去重" />
|
||||
<RadioItem idPrefix="distinct" value="0" label="不去重" />
|
||||
</RadioGroup>
|
||||
)}
|
||||
</FieldRow>
|
||||
|
||||
<FieldRow label="运营商">
|
||||
<RadioGroup
|
||||
value={String(config.isp)}
|
||||
onValueChange={v => patchConfig({ isp: Number(v) })}
|
||||
className="flex flex-wrap"
|
||||
>
|
||||
{ISP_OPTIONS.map(opt => (
|
||||
<RadioItem
|
||||
key={opt.value}
|
||||
idPrefix="isp"
|
||||
value={opt.value}
|
||||
label={opt.label}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FieldRow>
|
||||
|
||||
<FieldRow label="自动添加白名单">
|
||||
<RadioGroup
|
||||
value={String(config.aw)}
|
||||
onValueChange={v => patchConfig({ aw: Number(v) })}
|
||||
className="flex flex-wrap"
|
||||
>
|
||||
<RadioItem idPrefix="aw" value="0" label="不自动" />
|
||||
<RadioItem idPrefix="aw" value="1" label="自动" />
|
||||
</RadioGroup>
|
||||
</FieldRow>
|
||||
|
||||
{!isUnlimited && (
|
||||
<FieldRow label="签权模式">
|
||||
<RadioGroup
|
||||
value={String(config.at)}
|
||||
onValueChange={v => patchConfig({ at: Number(v) })}
|
||||
className="flex flex-wrap"
|
||||
>
|
||||
{AUTH_MODE_OPTIONS.map(opt => (
|
||||
<RadioItem
|
||||
key={opt.value}
|
||||
idPrefix="at"
|
||||
value={opt.value}
|
||||
label={opt.label}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FieldRow>
|
||||
)}
|
||||
|
||||
{!isUnlimited && (
|
||||
<FieldRow label="分隔符">
|
||||
<RadioGroup
|
||||
value={String(config.sp)}
|
||||
onValueChange={v => patchConfig({ sp: Number(v) })}
|
||||
className="flex flex-wrap items-center"
|
||||
>
|
||||
{SEPARATOR_OPTIONS.map(opt => (
|
||||
<RadioItem
|
||||
key={opt.value}
|
||||
idPrefix="sp"
|
||||
value={opt.value}
|
||||
label={opt.label}
|
||||
/>
|
||||
))}
|
||||
<Input
|
||||
value={config.csp ?? ""}
|
||||
onChange={e => patchConfig({ csp: e.target.value })}
|
||||
placeholder="请输入分割符"
|
||||
className="w-36 h-8"
|
||||
/>
|
||||
</RadioGroup>
|
||||
</FieldRow>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
size="lg"
|
||||
className="bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90"
|
||||
>
|
||||
生成API链接
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 border border-gray-200 rounded-md px-3 py-2 text-sm text-gray-600 truncate bg-gray-50">
|
||||
{link || "点击“生成API链接”后此处显示链接"}
|
||||
</div>
|
||||
<Button onClick={() => handleCopy()} variant="outline">
|
||||
复制链接
|
||||
</Button>
|
||||
<Button onClick={handleOpen} variant="outline">
|
||||
打开链接
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{currentPackage && (
|
||||
<p className="text-xs text-gray-400">
|
||||
已选套餐:({currentPackage.id}){currentPackage.name}
|
||||
,套餐类型 {currentPackage.planType},有效时长{" "}
|
||||
{currentPackage.durationType}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-dashed border-gray-200 p-4 flex flex-col gap-3">
|
||||
<h4 className="font-semibold">请求参数注释</h4>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>必选</TableHead>
|
||||
<TableHead>说明</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{API_PARAM_DOCS.map(param => (
|
||||
<TableRow key={param.name}>
|
||||
<TableCell className="font-mono">{param.name}</TableCell>
|
||||
<TableCell>{param.type}</TableCell>
|
||||
<TableCell>{param.required}</TableCell>
|
||||
<TableCell className="whitespace-normal min-w-96">
|
||||
{param.desc}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-dashed border-gray-200 p-4 flex flex-col gap-3">
|
||||
<h4 className="font-semibold">返回结果示例</h4>
|
||||
<pre className="rounded-md bg-gray-50 border border-gray-200 p-3 text-xs overflow-x-auto text-gray-700">
|
||||
<code>{RESPONSE_SAMPLE}</code>
|
||||
</pre>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-24">名称</TableHead>
|
||||
<TableHead className="w-24">类型</TableHead>
|
||||
<TableHead>说明</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{RESPONSE_FIELD_DOCS.map(field => (
|
||||
<TableRow key={field.name}>
|
||||
<TableCell className="font-mono">{field.name}</TableCell>
|
||||
<TableCell>{field.type}</TableCell>
|
||||
<TableCell className="whitespace-normal">
|
||||
{field.desc}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-dashed border-gray-200 p-4 flex flex-col gap-3">
|
||||
<h4 className="font-semibold">错误代码</h4>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-32">错误编码</TableHead>
|
||||
<TableHead>错误提示</TableHead>
|
||||
<TableHead>说明</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ERROR_CODE_DOCS.map(err => (
|
||||
<TableRow key={err.code}>
|
||||
<TableCell className="font-mono">code:{err.code}</TableCell>
|
||||
<TableCell>{err.msg}</TableCell>
|
||||
<TableCell className="whitespace-normal">{err.desc}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
219
src/admin/resources/short-renew-dialog.tsx
Normal file
219
src/admin/resources/short-renew-dialog.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { calcOrderPrice, rebuyOrder } from "@/api/http"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
type DxPackage,
|
||||
PERIOD_OPTIONS,
|
||||
SHORT_PLAN_DAILY,
|
||||
SHORT_PLAN_UNLIMITED,
|
||||
} from "@/lib/models/package"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function orderTypeOf(pkg: DxPackage): number {
|
||||
return pkg.planType === SHORT_PLAN_DAILY ? 3 : 2
|
||||
}
|
||||
|
||||
export default function ShortRenewDialog({
|
||||
open,
|
||||
pkg,
|
||||
whitelist,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: {
|
||||
open: boolean
|
||||
pkg: DxPackage | null
|
||||
whitelist: number
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const [periodType, setPeriodType] = useState(1)
|
||||
const [periodAmount, setPeriodAmount] = useState(1)
|
||||
const [money, setMoney] = useState(0)
|
||||
const [priceLoading, setPriceLoading] = useState(false)
|
||||
const [paying, setPaying] = useState(false)
|
||||
const seqRef = useRef(0)
|
||||
|
||||
// 弹窗打开时重置选择
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setPeriodType(1)
|
||||
setPeriodAmount(1)
|
||||
setMoney(0)
|
||||
seqRef.current += 1
|
||||
}, [open])
|
||||
|
||||
// 参数变化时重新计算价格
|
||||
useEffect(() => {
|
||||
if (!open || !pkg) return
|
||||
const seq = seqRef.current + 1
|
||||
seqRef.current = seq
|
||||
|
||||
const orderType = orderTypeOf(pkg)
|
||||
setPriceLoading(true)
|
||||
|
||||
calcOrderPrice({
|
||||
order_type: orderType,
|
||||
money: 1,
|
||||
data: {
|
||||
packId: pkg.id,
|
||||
packType: pkg.planType,
|
||||
periodType,
|
||||
periodAmount,
|
||||
durationType: pkg.durationType2,
|
||||
ipAmount: pkg.ipAmount ?? 0,
|
||||
},
|
||||
}).then(res => {
|
||||
if (seq !== seqRef.current) return
|
||||
setPriceLoading(false)
|
||||
if (!res.success) {
|
||||
setMoney(0)
|
||||
toast.error(res.message)
|
||||
return
|
||||
}
|
||||
|
||||
let price = 0
|
||||
if (orderType === 2) {
|
||||
// 短效无限量:白名单越多折扣越高
|
||||
const discount = Math.max(0.6, 1 - 0.1 * (whitelist - 1))
|
||||
price = periodAmount * whitelist * discount * res.data
|
||||
} else {
|
||||
price = periodAmount * res.data * (pkg.ipAmount ?? 0)
|
||||
}
|
||||
setMoney(Number(price.toFixed(2)))
|
||||
})
|
||||
}, [open, pkg, periodType, periodAmount, whitelist])
|
||||
|
||||
const periodUnit =
|
||||
PERIOD_OPTIONS.find(opt => opt.value === periodType)?.unit ?? "天"
|
||||
|
||||
const handlePay = async () => {
|
||||
if (!pkg) return
|
||||
if (money <= 0) {
|
||||
toast.error("价格计算失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
setPaying(true)
|
||||
const res = await rebuyOrder({
|
||||
order_type: orderTypeOf(pkg),
|
||||
money,
|
||||
data: {
|
||||
packId: pkg.id,
|
||||
packType: pkg.planType,
|
||||
periodType,
|
||||
periodAmount,
|
||||
durationType: pkg.durationType2,
|
||||
ipAmount: pkg.ipAmount ?? 0,
|
||||
},
|
||||
})
|
||||
setPaying(false)
|
||||
if (!res.success) {
|
||||
toast.error(res.message)
|
||||
return
|
||||
}
|
||||
if (res.data.code === 1) {
|
||||
toast.success(res.data.msg || "续费成功")
|
||||
onClose()
|
||||
onSuccess()
|
||||
} else {
|
||||
toast.error(res.data.msg || "续费失败")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={open => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
{pkg && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{pkg.name} - 续费</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-6 py-2">
|
||||
<section>
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-3">
|
||||
套餐周期:
|
||||
</h5>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{PERIOD_OPTIONS.filter(
|
||||
opt =>
|
||||
!(pkg.planType === SHORT_PLAN_DAILY && opt.value === 90),
|
||||
).map(opt => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
variant={periodType === opt.value ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setPeriodType(opt.value)}
|
||||
className={cn(
|
||||
"rounded-md",
|
||||
periodType === opt.value &&
|
||||
"bg-linear-to-r from-blue-500 to-cyan-400 border-transparent hover:opacity-90",
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-3">
|
||||
购买时长:
|
||||
</h5>
|
||||
<div className="inline-flex items-center rounded-md border overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPeriodAmount(v => Math.max(1, v - 1))}
|
||||
disabled={periodAmount <= 1}
|
||||
className="px-4 py-2 bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90 disabled:opacity-40 transition-opacity cursor-pointer"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="px-6 py-2 text-sm font-medium tabular-nums bg-white min-w-24 text-center">
|
||||
{periodAmount}
|
||||
{periodUnit}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPeriodAmount(v => v + 1)}
|
||||
className="px-4 py-2 bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90 transition-opacity cursor-pointer"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{pkg.planType === SHORT_PLAN_UNLIMITED && (
|
||||
<p className="text-xs text-gray-500 -mt-2">
|
||||
无限量白名单上限:{whitelist} 个
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => handlePay()}
|
||||
disabled={paying || priceLoading}
|
||||
className="w-full bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90"
|
||||
>
|
||||
{priceLoading
|
||||
? "价格计算中..."
|
||||
: paying
|
||||
? "支付中..."
|
||||
: `实付 ¥${money.toFixed(2)}`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,182 @@
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { fetchDxOrders, fetchDxPackages, fetchWhiteLimitMax } from "@/api/http"
|
||||
import DataTable from "@/components/data-table"
|
||||
import Page from "@/components/page"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import {
|
||||
type DxOrder,
|
||||
type DxPackage,
|
||||
formatExpired,
|
||||
formatOrderTypeLabel,
|
||||
SHORT_ORDER_TYPE_LABELS,
|
||||
SHORT_PLAN_VOLUME,
|
||||
} from "@/lib/models/package"
|
||||
import ShortApiLink from "./short-api-link"
|
||||
import ShortRenewDialog from "./short-renew-dialog"
|
||||
|
||||
export default function ShortPage() {
|
||||
return <Page>短效套餐</Page>
|
||||
const navigate = useNavigate()
|
||||
const [activeTab, setActiveTab] = useState("packages")
|
||||
|
||||
const [packages, setPackages] = useState<DxPackage[]>([])
|
||||
const [orders, setOrders] = useState<DxOrder[]>([])
|
||||
const [status, setStatus] = useState<"load" | "done" | "fail">("load")
|
||||
const [orderStatus, setOrderStatus] = useState<"load" | "done" | "fail">(
|
||||
"load",
|
||||
)
|
||||
const [whitelist, setWhitelist] = useState(1)
|
||||
const [renewPackage, setRenewPackage] = useState<DxPackage | null>(null)
|
||||
|
||||
const loadPackages = useCallback(async () => {
|
||||
setStatus("load")
|
||||
const res = await fetchDxPackages()
|
||||
console.log(res, "resresresres")
|
||||
if (!res.success) {
|
||||
setStatus("fail")
|
||||
return
|
||||
}
|
||||
setPackages(res.data)
|
||||
setStatus("done")
|
||||
}, [])
|
||||
|
||||
const loadOrders = useCallback(async () => {
|
||||
setOrderStatus("load")
|
||||
const res = await fetchDxOrders()
|
||||
|
||||
if (!res.success) {
|
||||
setOrderStatus("fail")
|
||||
return
|
||||
}
|
||||
setOrders(res.data)
|
||||
setOrderStatus("done")
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadPackages()
|
||||
fetchWhiteLimitMax().then(res => {
|
||||
if (res.success && res.data > 0) setWhitelist(res.data)
|
||||
})
|
||||
}, [loadPackages])
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value)
|
||||
if (value === "orders" && !orders.length) loadOrders()
|
||||
}
|
||||
|
||||
const packageColumns = useMemo<ColumnDef<DxPackage>[]>(
|
||||
() => [
|
||||
{ accessorKey: "name", header: "套餐类型" },
|
||||
{
|
||||
accessorKey: "remainingAmount",
|
||||
header: "当日可用IP数量(个)",
|
||||
cell: ({ row }) => String(row.original.remainingAmount ?? "-"),
|
||||
},
|
||||
{ accessorKey: "durationType", header: "有效时长" },
|
||||
{ accessorKey: "expireTime", header: "过期时间" },
|
||||
{
|
||||
accessorKey: "isExpired",
|
||||
header: "是否过期",
|
||||
cell: ({ row }) => formatExpired(row.original.isExpired),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "续费",
|
||||
cell: ({ row }) => {
|
||||
const pkg = row.original
|
||||
if (pkg.planType === SHORT_PLAN_VOLUME) {
|
||||
return <span className="text-gray-400">不支持续费</span>
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setRenewPackage(pkg)}
|
||||
className="bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90"
|
||||
>
|
||||
续费
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const orderColumns = useMemo<ColumnDef<DxOrder>[]>(
|
||||
() => [
|
||||
{ accessorKey: "order_no", header: "订单号" },
|
||||
{
|
||||
accessorKey: "order_type",
|
||||
header: "订单类型",
|
||||
cell: ({ row }) =>
|
||||
formatOrderTypeLabel(
|
||||
SHORT_ORDER_TYPE_LABELS,
|
||||
row.original.order_type,
|
||||
),
|
||||
},
|
||||
{ accessorKey: "package_type", header: "套餐类型" },
|
||||
{ accessorKey: "durationType", header: "有效时长" },
|
||||
{ accessorKey: "money", header: "支付金额" },
|
||||
{ accessorKey: "create_time", header: "支付时间" },
|
||||
{
|
||||
id: "rebuy",
|
||||
header: "再次购买",
|
||||
cell: () => (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => navigate("/product/http")}
|
||||
>
|
||||
再次购买
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[navigate],
|
||||
)
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="bg-white rounded-lg p-4">
|
||||
<h3 className="font-semibold text-lg mb-4">短效套餐</h3>
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="packages">全部套餐</TabsTrigger>
|
||||
<TabsTrigger value="orders">购买记录</TabsTrigger>
|
||||
<TabsTrigger value="extract">提取短效</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="packages" className="pt-4">
|
||||
<DataTable
|
||||
data={packages}
|
||||
status={status}
|
||||
columns={packageColumns}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="orders" className="pt-4">
|
||||
<DataTable
|
||||
data={orders}
|
||||
status={orderStatus}
|
||||
columns={orderColumns}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="extract" className="pt-4">
|
||||
<ShortApiLink packages={packages} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<ShortRenewDialog
|
||||
open={renewPackage !== null}
|
||||
pkg={renewPackage}
|
||||
whitelist={whitelist}
|
||||
onClose={() => setRenewPackage(null)}
|
||||
onSuccess={() => loadPackages()}
|
||||
/>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,82 @@
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { useMemo, useState } from "react"
|
||||
import { fetchRosOrderList } from "@/api/rosorder"
|
||||
import DataTable from "@/components/data-table"
|
||||
import Page from "@/components/page"
|
||||
import { useList } from "@/lib/hooks/useList"
|
||||
import type { RosOrderItem } from "@/lib/models/rosorder"
|
||||
|
||||
const columns: ColumnDef<RosOrderItem>[] = [
|
||||
{
|
||||
accessorKey: "username",
|
||||
header: "用户",
|
||||
},
|
||||
{
|
||||
accessorKey: "product_name",
|
||||
header: "软路由",
|
||||
},
|
||||
{
|
||||
accessorKey: "real_price",
|
||||
header: "实付",
|
||||
cell: ({ row }) => `¥${row.original.real_price}`,
|
||||
},
|
||||
{
|
||||
accessorKey: "order_no",
|
||||
header: "订单编号",
|
||||
},
|
||||
{
|
||||
accessorKey: "user",
|
||||
header: "收货人姓名",
|
||||
},
|
||||
{
|
||||
accessorKey: "phone",
|
||||
header: "收货人电话",
|
||||
},
|
||||
{
|
||||
accessorKey: "address",
|
||||
header: "收货人地址",
|
||||
cell: ({ row }) => (
|
||||
<span className="block max-w-60 truncate" title={row.original.address}>
|
||||
{row.original.address}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "create_time",
|
||||
header: "购买时间",
|
||||
},
|
||||
]
|
||||
|
||||
export default function RosorderPage() {
|
||||
return <Page>软路由页面</Page>
|
||||
const { data, status } = useList(fetchRosOrderList)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
|
||||
const paged = useMemo(
|
||||
() => data.slice((page - 1) * pageSize, page * pageSize),
|
||||
[data, page, pageSize],
|
||||
)
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="bg-white rounded-lg p-4 flex flex-col gap-4">
|
||||
<h3 className="font-semibold text-lg">软路由订单列表</h3>
|
||||
<DataTable
|
||||
data={paged}
|
||||
status={status}
|
||||
columns={columns}
|
||||
pagination={{
|
||||
page,
|
||||
size: pageSize,
|
||||
total: data.length,
|
||||
onPageChange: setPage,
|
||||
onSizeChange: size => {
|
||||
setPageSize(size)
|
||||
setPage(1)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,176 @@
|
||||
import { Lock, Trash2, Unlock } from "lucide-react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
createWhiteIp,
|
||||
deleteWhiteIp,
|
||||
fetchWhiteList,
|
||||
lockWhiteIp,
|
||||
type WhiteActionResult,
|
||||
} from "@/api/whitelist"
|
||||
import DataTable from "@/components/data-table"
|
||||
import Page from "@/components/page"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type { WhiteIpItem } from "@/lib/models/whitelist"
|
||||
import { formatUnixTime } from "@/lib/utils"
|
||||
|
||||
export default function WhitelistPage() {
|
||||
return <Page>白名单</Page>
|
||||
const [ip, setIp] = useState("")
|
||||
const [list, setList] = useState<WhiteIpItem[]>([])
|
||||
const [status, setStatus] = useState<"load" | "done" | "fail">("load")
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setStatus("load")
|
||||
const res = await fetchWhiteList()
|
||||
if (!res.success) {
|
||||
setStatus("fail")
|
||||
toast.error(res.message)
|
||||
return
|
||||
}
|
||||
setList(res.data)
|
||||
setStatus("done")
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const runAction = useCallback(
|
||||
async (
|
||||
action: () => Promise<ApiResponse<WhiteActionResult>>,
|
||||
successMessage: string,
|
||||
) => {
|
||||
const res = await action()
|
||||
if (!res.success) {
|
||||
toast.error(res.message)
|
||||
return
|
||||
}
|
||||
const { code, msg } = res.data
|
||||
if (code !== undefined && code < 1) {
|
||||
toast.error(msg || "操作失败")
|
||||
return
|
||||
}
|
||||
toast.success(successMessage)
|
||||
load()
|
||||
},
|
||||
[load],
|
||||
)
|
||||
|
||||
const handleSave = async () => {
|
||||
const value = ip.trim()
|
||||
if (!value) {
|
||||
toast.error("请输入IP地址")
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
await runAction(() => createWhiteIp(value), "添加成功")
|
||||
setSaving(false)
|
||||
setIp("")
|
||||
}
|
||||
|
||||
const handleLock = (item: WhiteIpItem) => {
|
||||
const locked = item.isLocked === "已锁定"
|
||||
runAction(
|
||||
() => lockWhiteIp(item.id, locked ? 0 : 1),
|
||||
locked ? "解锁成功" : "锁定成功",
|
||||
)
|
||||
}
|
||||
|
||||
const handleDelete = (item: WhiteIpItem) => {
|
||||
runAction(() => deleteWhiteIp(item.id), "删除成功")
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="bg-white rounded-lg p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
value={ip}
|
||||
onChange={e => setIp(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && handleSave()}
|
||||
placeholder="请输入IP地址并点击保存白名单"
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="font-semibold text-lg">当前白名单列表</h3>
|
||||
<Badge variant="secondary" className="text-yellow-600">
|
||||
当前仅允许保留白名单数量:5个
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<DataTable<WhiteIpItem>
|
||||
data={list}
|
||||
status={status}
|
||||
columns={[
|
||||
{ accessorKey: "ip", header: "IP地址" },
|
||||
{
|
||||
accessorKey: "description",
|
||||
header: "备注",
|
||||
cell: ({ row }) => row.original.description || "-",
|
||||
},
|
||||
{
|
||||
accessorKey: "createTime",
|
||||
header: "设置时间",
|
||||
cell: ({ row }) => formatUnixTime(row.original.createTime),
|
||||
},
|
||||
{
|
||||
accessorKey: "isLocked",
|
||||
header: "锁定状态",
|
||||
cell: ({ row }) => {
|
||||
const isLocked = row.original.isLocked === "已锁定"
|
||||
return (
|
||||
<Badge variant={isLocked ? "destructive" : "outline"}>
|
||||
{row.original.isLocked}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "操作",
|
||||
cell: ({ row }) => {
|
||||
const item = row.original
|
||||
const isLocked = item.isLocked === "已锁定"
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleLock(item)}
|
||||
>
|
||||
{isLocked ? (
|
||||
<Unlock className="mr-1 h-3 w-3" />
|
||||
) : (
|
||||
<Lock className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{isLocked ? "解锁" : "锁定"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(item)}
|
||||
>
|
||||
<Trash2 className="mr-1 h-3 w-3" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
319
src/admin/whitelistSU/page.tsx
Normal file
319
src/admin/whitelistSU/page.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
import { Lock, Minus, Plus, Trash2, Unlock } from "lucide-react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
createWhiteIpSu,
|
||||
deleteWhiteIpSu,
|
||||
fetchUnlimitedAvailable,
|
||||
fetchWhiteLimitSu,
|
||||
fetchWhiteListSu,
|
||||
lockWhiteIpSu,
|
||||
setWhiteLimitSu,
|
||||
type WhiteActionResult,
|
||||
} from "@/api/whitelist"
|
||||
import DataTable from "@/components/data-table"
|
||||
import Page from "@/components/page"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type { UnlimitedAvailable, WhiteIpItem } from "@/lib/models/whitelist"
|
||||
import { formatUnixTime } from "@/lib/utils"
|
||||
|
||||
const clampLimit = (value: number) => Math.min(20, Math.max(1, value))
|
||||
|
||||
function calcRemain(
|
||||
expire: number,
|
||||
maxWhitelist: number,
|
||||
newMaxWhitelist: number,
|
||||
): number {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const prev = expire || now
|
||||
const remain = prev - now
|
||||
const multiple = maxWhitelist / newMaxWhitelist
|
||||
const oldDiscount = Math.max(0.6, 1 - 0.1 * (maxWhitelist - 1))
|
||||
const newDiscount = Math.max(0.6, 1 - 0.1 * (newMaxWhitelist - 1))
|
||||
const offset = oldDiscount / newDiscount
|
||||
const fee = 99 / 100
|
||||
return Math.floor(remain * multiple * offset * fee + now) * 1000
|
||||
}
|
||||
|
||||
export default function WhitelistSUPage() {
|
||||
const [ip, setIp] = useState("")
|
||||
const [list, setList] = useState<WhiteIpItem[]>([])
|
||||
const [status, setStatus] = useState<"load" | "done" | "fail">("load")
|
||||
const [whiteLimit, setWhiteLimit] = useState(0)
|
||||
const [available, setAvailable] = useState<UnlimitedAvailable | null>(null)
|
||||
const [tempWhiteLimit, setTempWhiteLimit] = useState(1)
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setStatus("load")
|
||||
const res = await fetchWhiteListSu()
|
||||
if (!res.success) {
|
||||
setStatus("fail")
|
||||
toast.error(res.message)
|
||||
return
|
||||
}
|
||||
setList(res.data)
|
||||
setStatus("done")
|
||||
}, [])
|
||||
|
||||
const loadLimit = useCallback(async () => {
|
||||
const res = await fetchWhiteLimitSu()
|
||||
if (res.success) setWhiteLimit(res.data)
|
||||
}, [])
|
||||
|
||||
const loadAvailable = useCallback(async () => {
|
||||
const res = await fetchUnlimitedAvailable()
|
||||
if (res.success) setAvailable(res.data)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
loadLimit()
|
||||
loadAvailable()
|
||||
}, [load, loadLimit, loadAvailable])
|
||||
|
||||
const runAction = useCallback(
|
||||
async (
|
||||
action: () => Promise<ApiResponse<WhiteActionResult>>,
|
||||
successMessage: string,
|
||||
) => {
|
||||
const res = await action()
|
||||
if (!res.success) {
|
||||
toast.error(res.message)
|
||||
return
|
||||
}
|
||||
const { code, msg } = res.data
|
||||
if (code !== undefined && code < 1) {
|
||||
toast.error(msg || "操作失败")
|
||||
return
|
||||
}
|
||||
toast.success(successMessage)
|
||||
load()
|
||||
},
|
||||
[load],
|
||||
)
|
||||
|
||||
const handleSaveIp = async () => {
|
||||
const value = ip.trim()
|
||||
if (!value) {
|
||||
toast.error("请输入IP地址")
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
await runAction(() => createWhiteIpSu(value), "添加成功")
|
||||
setSaving(false)
|
||||
setIp("")
|
||||
}
|
||||
|
||||
const handleLock = (item: WhiteIpItem) => {
|
||||
const locked = item.isLocked === "已锁定"
|
||||
runAction(
|
||||
() => lockWhiteIpSu(item.id, locked ? 0 : 1),
|
||||
locked ? "解锁成功" : "锁定成功",
|
||||
)
|
||||
}
|
||||
|
||||
const handleDelete = (item: WhiteIpItem) => {
|
||||
runAction(() => deleteWhiteIpSu(item.id), "删除成功")
|
||||
}
|
||||
|
||||
const openDialog = () => {
|
||||
setTempWhiteLimit(clampLimit(whiteLimit || 1))
|
||||
setIsDialogOpen(true)
|
||||
loadAvailable()
|
||||
}
|
||||
|
||||
const increaseLimit = () => setTempWhiteLimit(prev => clampLimit(prev + 1))
|
||||
|
||||
const decreaseLimit = () => setTempWhiteLimit(prev => clampLimit(prev - 1))
|
||||
|
||||
const tempDiscount = Math.max(6, 11 - tempWhiteLimit)
|
||||
const newExpre = calcRemain(
|
||||
available?.expireTime ?? 0,
|
||||
whiteLimit,
|
||||
tempWhiteLimit,
|
||||
)
|
||||
|
||||
const confirmSetLimit = async () => {
|
||||
setSaving(true)
|
||||
const res = await setWhiteLimitSu(tempWhiteLimit)
|
||||
setSaving(false)
|
||||
if (!res.success) {
|
||||
toast.error(res.message)
|
||||
return
|
||||
}
|
||||
const { code, msg } = res.data
|
||||
if (code !== undefined && code < 1) {
|
||||
toast.error(msg || "设置失败")
|
||||
return
|
||||
}
|
||||
toast.success("白名单上限设置成功")
|
||||
setIsDialogOpen(false)
|
||||
loadLimit()
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="bg-white rounded-lg p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
value={ip}
|
||||
onChange={e => setIp(e.target.value)}
|
||||
placeholder="请输入IP地址并点击保存白名单"
|
||||
className="max-w-sm"
|
||||
onKeyDown={e => e.key === "Enter" && handleSaveIp()}
|
||||
/>
|
||||
<Button onClick={handleSaveIp} disabled={saving}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="font-semibold text-lg">当前白名单列表</h3>
|
||||
<Badge variant="secondary" className="text-yellow-600">
|
||||
当前白名单数量:{whiteLimit} 个
|
||||
</Badge>
|
||||
<Button variant="outline" size="sm" onClick={openDialog}>
|
||||
调整白名单上限
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable<WhiteIpItem>
|
||||
data={list}
|
||||
status={status}
|
||||
columns={[
|
||||
{ accessorKey: "ip", header: "IP地址" },
|
||||
{
|
||||
accessorKey: "description",
|
||||
header: "备注",
|
||||
cell: ({ row }) => row.original.description || "-",
|
||||
},
|
||||
{
|
||||
accessorKey: "createTime",
|
||||
header: "设置时间",
|
||||
cell: ({ row }) => formatUnixTime(row.original.createTime),
|
||||
},
|
||||
{
|
||||
accessorKey: "isLocked",
|
||||
header: "锁定状态",
|
||||
cell: ({ row }) => {
|
||||
const isLocked = row.original.isLocked === "已锁定"
|
||||
return (
|
||||
<Badge variant={isLocked ? "destructive" : "outline"}>
|
||||
{row.original.isLocked}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "操作",
|
||||
cell: ({ row }) => {
|
||||
const item = row.original
|
||||
const isLocked = item.isLocked === "已锁定"
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleLock(item)}
|
||||
>
|
||||
{isLocked ? (
|
||||
<Unlock className="mr-1 h-3 w-3" />
|
||||
) : (
|
||||
<Lock className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{isLocked ? "解锁" : "锁定"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(item)}
|
||||
>
|
||||
<Trash2 className="mr-1 h-3 w-3" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>调整白名单上限</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col items-start gap-4 py-4">
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="focus:outline-none focus:ring-0"
|
||||
onClick={decreaseLimit}
|
||||
disabled={tempWhiteLimit <= 1}
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="w-12 text-center font-medium">
|
||||
{tempWhiteLimit}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="focus:outline-none focus:ring-0"
|
||||
onClick={increaseLimit}
|
||||
disabled={tempWhiteLimit >= 20}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="ml-8">
|
||||
{tempDiscount === 10 ? (
|
||||
<span>
|
||||
价格<span className="text-red-500">无折扣</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-red-500">
|
||||
当前已应用 {tempDiscount * 10}% 折扣
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ml-8">
|
||||
调整后到期时间:{new Date(newExpre).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={() => setIsDialogOpen(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button onClick={confirmSetLimit} disabled={saving}>
|
||||
确定
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
15
src/api/account.ts
Normal file
15
src/api/account.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type { MyAccountItem, MyAccountQueryParams } from "@/lib/models/account"
|
||||
import { callByUserCookie } from "./base"
|
||||
|
||||
export async function fetchMyAccounts(
|
||||
params: MyAccountQueryParams,
|
||||
): Promise<ApiResponse<MyAccountItem[]>> {
|
||||
const res = await callByUserCookie<MyAccountItem[] | unknown>(
|
||||
"/user/ApiMyAccounts",
|
||||
params,
|
||||
"GET",
|
||||
)
|
||||
if (!res.success) return res
|
||||
return { success: true, data: Array.isArray(res.data) ? res.data : [] }
|
||||
}
|
||||
86
src/api/agreement.ts
Normal file
86
src/api/agreement.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import { phpForm, phpGet, phpPost } from "./http"
|
||||
|
||||
export type RealNameVerifyResult = {
|
||||
// 10000:进入微信扫码扫脸阶段(返回二维码 url)
|
||||
// 20000:已认证成功
|
||||
// 30000:该身份已被其他账号认证
|
||||
// -10000:接口问题,联系客服
|
||||
// 其他:姓名与身份证不匹配
|
||||
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
|
||||
// 后端约定:code 1 成功,-1 失败(msg 携带失败原因,如文件过大/已上传过)
|
||||
const code = Number(res.data?.code ?? 0)
|
||||
const msg = typeof res.data?.msg === "string" ? res.data.msg : undefined
|
||||
if (code === -1) {
|
||||
return { success: false, message: msg || "上传失败" }
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: { msg },
|
||||
}
|
||||
}
|
||||
129
src/api/base.ts
129
src/api/base.ts
@@ -4,9 +4,27 @@ import {
|
||||
CLIENT_ID,
|
||||
CLIENT_SECRET,
|
||||
} from "@/lib/api"
|
||||
import { clearSession, getUserToken } from "./token"
|
||||
import { clearCookie, clearSession, getUserToken } from "./token"
|
||||
|
||||
type CallMode = "public" | "device" | "user"
|
||||
type CallMode = "public" | "device" | "user" | "cookie"
|
||||
|
||||
// ==================== 登录态失效全局处理 ====================
|
||||
// user/cookie 模式请求未登录、收到 401(token 失效)或 302(后端跳登录页)时:
|
||||
// 清除本地会话并跳转登录页,带 redirect 参数便于登录后回跳。
|
||||
// 与 logout() 及 juipnet 老项目行为保持一致。
|
||||
let redirectingToLogin = false
|
||||
|
||||
function handleUnauthorized() {
|
||||
if (redirectingToLogin) return
|
||||
clearSession()
|
||||
clearCookie("token")
|
||||
clearCookie("userInfo")
|
||||
const current = window.location.pathname
|
||||
if (current !== "/login") {
|
||||
redirectingToLogin = true
|
||||
window.location.href = `/login?redirect=${encodeURIComponent(current)}`
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT = 15_000
|
||||
|
||||
@@ -45,8 +63,20 @@ async function request<R = undefined>(
|
||||
headers,
|
||||
body: init.body,
|
||||
signal: controller.signal,
|
||||
redirect: "manual",
|
||||
})
|
||||
|
||||
// 登录态失效时后端会把受保护接口 302 到登录页(如 /User/WebLogin)。
|
||||
// manual 模式不自动跟随,此处识别重定向并返回 302,
|
||||
// 由 call() 在 user/cookie 模式下统一触发 handleUnauthorized 跳登录页。
|
||||
if (response.type === "opaqueredirect") {
|
||||
return {
|
||||
success: false,
|
||||
status: 302,
|
||||
message: "登录状态已失效,请重新登录",
|
||||
}
|
||||
}
|
||||
|
||||
const responseHeaders = captureHeaders(response)
|
||||
const contentType = response.headers.get("Content-Type") ?? "text/plain"
|
||||
|
||||
@@ -70,12 +100,20 @@ async function request<R = undefined>(
|
||||
Message?: string
|
||||
Data?: unknown
|
||||
data?: unknown
|
||||
TotalCount?: number
|
||||
}
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
status: response.status,
|
||||
message: json.message || json.error_description || "请求失败",
|
||||
code: typeof json.Code === "number" ? json.Code : undefined,
|
||||
message:
|
||||
json.message ||
|
||||
json.Message ||
|
||||
json.error_description ||
|
||||
(typeof json.Code === "number"
|
||||
? `请求失败(错误码 ${json.Code})`
|
||||
: "请求失败"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,11 +123,14 @@ async function request<R = undefined>(
|
||||
success: true,
|
||||
data: (json.Data ?? json.data ?? undefined) as R,
|
||||
headers: responseHeaders,
|
||||
totalCount:
|
||||
typeof json.TotalCount === "number" ? json.TotalCount : undefined,
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
status: response.status,
|
||||
code: json.Code,
|
||||
message: json.Message || "请求失败",
|
||||
}
|
||||
}
|
||||
@@ -97,7 +138,13 @@ async function request<R = undefined>(
|
||||
return { success: true, data: json, headers: responseHeaders }
|
||||
}
|
||||
|
||||
throw new Error(`无法解析响应数据: ${contentType}`)
|
||||
// 其他内容类型(如 text/html 错误页)无法按 API 格式解析,
|
||||
// 保留真实 HTTP 状态码返回,便于上层识别 401/3xx 等登录态问题
|
||||
return {
|
||||
success: false,
|
||||
status: response.status,
|
||||
message: `无法解析响应数据: ${contentType}`,
|
||||
}
|
||||
} catch (e) {
|
||||
if (controller.signal.aborted) {
|
||||
return {
|
||||
@@ -168,7 +215,28 @@ async function call<R = undefined>(
|
||||
data?: unknown,
|
||||
method: "GET" | "POST" = "POST",
|
||||
): Promise<ApiResponse<R>> {
|
||||
const body = data === undefined ? undefined : JSON.stringify(data)
|
||||
// GET 请求:将 data 序列化为查询串;POST 请求:序列化为 JSON body
|
||||
let url = endpoint
|
||||
let body: string | undefined
|
||||
if (method === "GET") {
|
||||
const params = new URLSearchParams()
|
||||
if (data && typeof data === "object") {
|
||||
for (const [key, value] of Object.entries(
|
||||
data as Record<string, unknown>,
|
||||
)) {
|
||||
if (value === undefined || value === null || value === "") continue
|
||||
if (Array.isArray(value)) {
|
||||
params.append(key, value.join(","))
|
||||
} else {
|
||||
params.append(key, String(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
const query = params.toString()
|
||||
if (query) url = `${endpoint}${endpoint.includes("?") ? "&" : "?"}${query}`
|
||||
} else {
|
||||
body = data === undefined ? undefined : JSON.stringify(data)
|
||||
}
|
||||
|
||||
const run = async (): Promise<ApiResponse<R>> => {
|
||||
const maxAttempts = mode === "device" ? 2 : 1
|
||||
@@ -178,6 +246,7 @@ async function call<R = undefined>(
|
||||
if (mode === "user") {
|
||||
const token = getUserToken()
|
||||
if (!token) {
|
||||
handleUnauthorized()
|
||||
return {
|
||||
success: false,
|
||||
status: 401,
|
||||
@@ -185,9 +254,20 @@ async function call<R = undefined>(
|
||||
}
|
||||
}
|
||||
auth = `Bearer ${token}`
|
||||
} else if (mode === "cookie") {
|
||||
// cookie 模式:凭证走浏览器 cookie(老 /user/* 接口),
|
||||
// 但登录态判断仍以本地 token 为准,缺失时同样触发跳登录
|
||||
const token = getUserToken()
|
||||
if (!token) {
|
||||
handleUnauthorized()
|
||||
return {
|
||||
success: false,
|
||||
status: 401,
|
||||
message: "未登录或会话已过期",
|
||||
}
|
||||
}
|
||||
} else if (mode === "device") {
|
||||
if (attempt > 1) {
|
||||
// 上次请求 401,强制作废旧 token 再重新获取
|
||||
deviceToken = null
|
||||
deviceTokenExpire = 0
|
||||
}
|
||||
@@ -202,16 +282,25 @@ async function call<R = undefined>(
|
||||
auth = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const resp = await request<R>(endpoint, { method, body, auth })
|
||||
const resp = await request<R>(url, { method, body, auth })
|
||||
|
||||
if (!resp.success && resp.status === 401) {
|
||||
if (mode === "user") {
|
||||
clearSession()
|
||||
return resp
|
||||
}
|
||||
if (mode === "device" && attempt < maxAttempts) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
!resp.success &&
|
||||
resp.status === 401 &&
|
||||
mode === "device" &&
|
||||
attempt < maxAttempts
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 会话失效的三种表现:本地无 token(前面已处理)、后端 401、
|
||||
// 或后端 302 重定向到登录页(如 /user/* 过期跳 /User/WebLogin)
|
||||
if (
|
||||
!resp.success &&
|
||||
(resp.status === 401 || resp.status === 302) &&
|
||||
(mode === "user" || mode === "cookie")
|
||||
) {
|
||||
handleUnauthorized()
|
||||
}
|
||||
return resp
|
||||
}
|
||||
@@ -253,4 +342,12 @@ async function callByUser<R = undefined>(
|
||||
return call<R>("user", endpoint, data, method)
|
||||
}
|
||||
|
||||
export { callByDevice, callByUser, callPublic }
|
||||
async function callByUserCookie<R = undefined>(
|
||||
endpoint: string,
|
||||
data?: unknown,
|
||||
method: "GET" | "POST" = "POST",
|
||||
): Promise<ApiResponse<R>> {
|
||||
return call<R>("cookie", endpoint, data, method)
|
||||
}
|
||||
|
||||
export { callByDevice, callByUser, callByUserCookie, callPublic }
|
||||
|
||||
35
src/api/cashOut.ts
Normal file
35
src/api/cashOut.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type { CashOutItem, CashOutSubmitData } from "@/lib/models/cashOut"
|
||||
import { phpPost } from "./http"
|
||||
|
||||
// ==================== 提现处理(php /order/CashOutIndex/*,cookie 认证) ====================
|
||||
// 接口来源:参考 juipnet 老项目 Host/Views/User/CashOut.cshtml、Index.cshtml
|
||||
|
||||
export async function fetchCashOutList(): Promise<ApiResponse<CashOutItem[]>> {
|
||||
const res = await phpPost<Record<string, unknown>>(
|
||||
"/order/CashOutIndex/getData",
|
||||
{ cookie: document.cookie },
|
||||
)
|
||||
if (!res.success) return res
|
||||
const list = res.data?.data ?? res.data
|
||||
return {
|
||||
success: true,
|
||||
data: Array.isArray(list) ? (list as CashOutItem[]) : [],
|
||||
}
|
||||
}
|
||||
|
||||
// 提交提现申请
|
||||
export async function submitCashOut(
|
||||
data: CashOutSubmitData,
|
||||
): Promise<ApiResponse<boolean>> {
|
||||
const res = await phpPost<Record<string, unknown>>(
|
||||
"/order/CashOutIndex/index",
|
||||
{ ...data, cookie: document.cookie },
|
||||
)
|
||||
if (!res.success) return res
|
||||
const ok = Number(res.data?.Code ?? res.data?.code ?? 0) === 10000
|
||||
return {
|
||||
success: true,
|
||||
data: ok,
|
||||
}
|
||||
}
|
||||
47
src/api/coupon.ts
Normal file
47
src/api/coupon.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type {
|
||||
CouponItem,
|
||||
CouponQueryParams,
|
||||
MyCouponItem,
|
||||
} from "@/lib/models/coupon"
|
||||
import { callByUser, callPublic } from "./base"
|
||||
|
||||
export async function fetchCouponPage(
|
||||
params: CouponQueryParams,
|
||||
): Promise<ApiResponse<CouponItem[]>> {
|
||||
return await callByUser<CouponItem[]>(
|
||||
"/api/sells/v1/coupon/Page",
|
||||
params,
|
||||
"GET",
|
||||
)
|
||||
}
|
||||
|
||||
export async function disabledCoupon(
|
||||
id: number,
|
||||
): Promise<ApiResponse<undefined>> {
|
||||
return await callByUser<undefined>("/api/sells/v1/coupon/Disabled", {
|
||||
Id: id,
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteCoupon(
|
||||
id: number,
|
||||
): Promise<ApiResponse<undefined>> {
|
||||
return await callByUser<undefined>("/api/sells/v1/coupon/Delete", { id })
|
||||
}
|
||||
|
||||
export async function giveCoupon(
|
||||
data: Record<string, unknown>,
|
||||
): Promise<ApiResponse<undefined>> {
|
||||
return await callByUser<undefined>("/api/sells/v1/coupon/Give", data)
|
||||
}
|
||||
|
||||
export async function fetchMyCoupons(): Promise<ApiResponse<MyCouponItem[]>> {
|
||||
const res = await callPublic<MyCouponItem[] | unknown>(
|
||||
"/user/ApiMyCoupons",
|
||||
undefined,
|
||||
"GET",
|
||||
)
|
||||
if (!res.success) return res
|
||||
return { success: true, data: Array.isArray(res.data) ? res.data : [] }
|
||||
}
|
||||
17
src/api/dashboard.ts
Normal file
17
src/api/dashboard.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type { ApiIndexItem } from "@/lib/models/dashboard"
|
||||
import { callByUserCookie } from "./base"
|
||||
|
||||
export async function fetchApiIndex(): Promise<
|
||||
ApiResponse<ApiIndexItem | null>
|
||||
> {
|
||||
const res = await callByUserCookie<ApiIndexItem | ApiIndexItem[]>(
|
||||
"/user/ApiIndex",
|
||||
undefined,
|
||||
"GET",
|
||||
)
|
||||
if (!res.success) return res
|
||||
// 兼容后端返回单对象或数组两种格式
|
||||
const item = Array.isArray(res.data) ? res.data[0] : res.data
|
||||
return { success: true, data: item ?? null }
|
||||
}
|
||||
21
src/api/funds.ts
Normal file
21
src/api/funds.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type { ScoreItem } from "@/lib/models/funds"
|
||||
import { phpFetch } from "./http"
|
||||
|
||||
export async function fetchScoreList(
|
||||
page: number,
|
||||
): Promise<ApiResponse<ScoreItem[]>> {
|
||||
const query = page > 1 ? `?page=${page}` : ""
|
||||
const res = await phpFetch<ScoreItem[] | { data?: unknown }>(
|
||||
"POST",
|
||||
`/user/UserScore/getList${query}`,
|
||||
{ cookie: document.cookie },
|
||||
{ credentials: "include" },
|
||||
)
|
||||
if (!res.success) return res
|
||||
if (Array.isArray(res.data)) return { success: true, data: res.data }
|
||||
return {
|
||||
success: true,
|
||||
data: Array.isArray(res.data?.data) ? res.data.data : [],
|
||||
}
|
||||
}
|
||||
445
src/api/http.ts
445
src/api/http.ts
@@ -1,11 +1,30 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type {
|
||||
CreateOrderResult,
|
||||
DxblData,
|
||||
DxbtData,
|
||||
DxwxlData,
|
||||
GameCity,
|
||||
GameItem,
|
||||
GameOrderData,
|
||||
HttpOrderInfo,
|
||||
} from "@/lib/models/http"
|
||||
import type {
|
||||
ChangeIpData,
|
||||
CxOrder,
|
||||
CxPackage,
|
||||
DxOrder,
|
||||
DxPackage,
|
||||
LangIpExtract,
|
||||
LangIpItem,
|
||||
LangIpListResult,
|
||||
OrderActionResult,
|
||||
PackageOrderInfo,
|
||||
Pager,
|
||||
SwitchListResult,
|
||||
SwitchRecord,
|
||||
UserApiToken,
|
||||
} from "@/lib/models/package"
|
||||
|
||||
export const PHP_API_BASE_URL =
|
||||
import.meta.env.VITE_PHP_API_BASE_URL ?? "https://php-api.juip.com"
|
||||
@@ -14,18 +33,119 @@ const DEFAULT_TIMEOUT = 30_000
|
||||
|
||||
type RawHttpResponse = Record<string, unknown>
|
||||
|
||||
async function postJson<T = RawHttpResponse>(
|
||||
// 所有 /http/* 接口都带 cookie 走同一出口:超时、错误归一化在此层处理,
|
||||
// 各接口函数只关心业务数据,不再各自实现 fetch。
|
||||
// credentials 仅对需要登录态的 POST 开启;公开脚本 GET 接口(如线路表)
|
||||
// 不带 cookie,避免跨域时服务端 Access-Control-Allow-Origin 配置不匹配。
|
||||
|
||||
type PhpFetchOptions = {
|
||||
credentials?: RequestCredentials
|
||||
cache?: RequestCache
|
||||
encoding?: "json" | "form"
|
||||
}
|
||||
|
||||
// 表单编码:嵌套对象/数组展开为 data[key] 结构(PHP $_POST 规则)
|
||||
function toFormParams(params: URLSearchParams, prefix: string, value: unknown) {
|
||||
if (value === null || value === undefined) return
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v, i) => {
|
||||
toFormParams(params, `${prefix}[${i}]`, v)
|
||||
})
|
||||
return
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
toFormParams(params, `${prefix}[${k}]`, v)
|
||||
}
|
||||
return
|
||||
}
|
||||
params.set(prefix, String(value))
|
||||
}
|
||||
|
||||
export async function phpFetch<T = RawHttpResponse>(
|
||||
method: "GET" | "POST",
|
||||
path: string,
|
||||
body: unknown,
|
||||
body?: unknown,
|
||||
options: PhpFetchOptions = {},
|
||||
): Promise<ApiResponse<T>> {
|
||||
const { credentials = "same-origin", cache, encoding = "json" } = options
|
||||
const isForm = encoding === "form" && body !== undefined
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT)
|
||||
try {
|
||||
let payload: string | undefined
|
||||
if (body !== undefined) {
|
||||
if (isForm) {
|
||||
const params = new URLSearchParams()
|
||||
for (const [k, v] of Object.entries(body as Record<string, unknown>)) {
|
||||
toFormParams(params, k, v)
|
||||
}
|
||||
payload = params.toString()
|
||||
} else {
|
||||
payload = JSON.stringify(body)
|
||||
}
|
||||
}
|
||||
const response = await fetch(`${PHP_API_BASE_URL}${path}`, {
|
||||
method,
|
||||
headers: isForm
|
||||
? { "Content-Type": "application/x-www-form-urlencoded" }
|
||||
: body !== undefined
|
||||
? { "Content-Type": "application/json" }
|
||||
: undefined,
|
||||
credentials,
|
||||
...(cache ? { cache } : {}),
|
||||
body: payload,
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
status: response.status,
|
||||
message: "请求失败",
|
||||
}
|
||||
}
|
||||
const text = await response.text()
|
||||
// 老 PHP 接口异常时可能返回空响应体,直接 json() 会抛错
|
||||
const data = text ? (JSON.parse(text) as T) : ({} 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 phpPost<T = RawHttpResponse>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
options: PhpFetchOptions = {},
|
||||
): Promise<ApiResponse<T>> {
|
||||
return phpFetch<T>("POST", path, body, { credentials: "include", ...options })
|
||||
}
|
||||
|
||||
// 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",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(body),
|
||||
body: form,
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!response.ok) {
|
||||
@@ -55,7 +175,18 @@ async function postJson<T = RawHttpResponse>(
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapD(raw: RawHttpResponse | undefined): Record<string, unknown> {
|
||||
export async function phpGet<T = RawHttpResponse>(
|
||||
path: string,
|
||||
): Promise<ApiResponse<T>> {
|
||||
return phpFetch<T>("GET", path)
|
||||
}
|
||||
|
||||
// ==================== 响应解析工具 ====================
|
||||
|
||||
// 接口习惯把业务数据包在 d 字段里
|
||||
export function unwrapD(
|
||||
raw: RawHttpResponse | undefined,
|
||||
): Record<string, unknown> {
|
||||
const d = raw?.d
|
||||
if (d && typeof d === "object" && !Array.isArray(d)) {
|
||||
return d as Record<string, unknown>
|
||||
@@ -63,60 +194,162 @@ function unwrapD(raw: RawHttpResponse | undefined): Record<string, unknown> {
|
||||
return {}
|
||||
}
|
||||
|
||||
export function isArray<T>(value: unknown): value is T[] {
|
||||
return Array.isArray(value)
|
||||
}
|
||||
|
||||
export function toNumber(value: unknown): number {
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
// 解析服务端分页信息,缺失字段兜底
|
||||
// 兼容两套字段命名:文档的 total/maxResults 与旧接口的 totalCount/pageSize
|
||||
export function parsePager(raw: unknown, itemCount: number): Pager {
|
||||
const p = (raw ?? {}) as Record<string, unknown>
|
||||
return {
|
||||
currentPage: toNumber(p.currentPage) || 1,
|
||||
pageSize: toNumber(p.maxResults ?? p.pageSize) || itemCount,
|
||||
totalCount: toNumber(p.total ?? p.totalCount),
|
||||
totalPage: toNumber(p.totalPage),
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 游戏产品 ====================
|
||||
|
||||
export async function fetchGameCities(
|
||||
game: GameOrderData,
|
||||
): Promise<ApiResponse<GameCity[]>> {
|
||||
const res = await postJson<RawHttpResponse>("/http/product/city", game)
|
||||
const res = await phpPost<RawHttpResponse>("/http/product/city", game, {
|
||||
encoding: "form",
|
||||
})
|
||||
if (!res.success) return res
|
||||
const cities = unwrapD(res.data).cities
|
||||
return {
|
||||
success: true,
|
||||
data: Array.isArray(cities) ? (cities as GameCity[]) : [],
|
||||
data: isArray<GameCity>(cities) ? cities : [],
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchGameList(
|
||||
game: GameOrderData,
|
||||
): Promise<ApiResponse<GameItem[]>> {
|
||||
const res = await postJson<RawHttpResponse>("/http/product/game", game)
|
||||
const res = await phpPost<RawHttpResponse>("/http/product/game", game, {
|
||||
encoding: "form",
|
||||
})
|
||||
if (!res.success) return res
|
||||
const games = unwrapD(res.data).games
|
||||
return {
|
||||
success: true,
|
||||
data: Array.isArray(games) ? (games as GameItem[]) : [],
|
||||
data: isArray<GameItem>(games) ? games : [],
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchGameLineCount(
|
||||
game: GameOrderData,
|
||||
): Promise<ApiResponse<number>> {
|
||||
const res = await postJson<RawHttpResponse>("/http/product/linecount", game)
|
||||
const res = await phpPost<RawHttpResponse>("/http/product/linecount", game, {
|
||||
encoding: "form",
|
||||
})
|
||||
if (!res.success) return res
|
||||
const count = unwrapD(res.data).count
|
||||
return { success: true, data: Number(count) || 0 }
|
||||
return { success: true, data: toNumber(count) }
|
||||
}
|
||||
|
||||
export async function calcGamePrice(
|
||||
orderInfo: HttpOrderInfo,
|
||||
): Promise<ApiResponse<number>> {
|
||||
const res = await postJson<RawHttpResponse>(
|
||||
return calcOrderPrice(orderInfo as unknown as PackageOrderInfo)
|
||||
}
|
||||
|
||||
export async function calcUnlimitedPrice(
|
||||
data: DxwxlData,
|
||||
): Promise<ApiResponse<number>> {
|
||||
const res = await phpPost<RawHttpResponse>(
|
||||
"/http/product/calc_price",
|
||||
orderInfo,
|
||||
{
|
||||
order_type: 2,
|
||||
money: 1,
|
||||
pay_type: 1,
|
||||
data,
|
||||
},
|
||||
{ encoding: "form" },
|
||||
)
|
||||
if (!res.success) return res
|
||||
const raw = res.data ?? {}
|
||||
const priceValue =
|
||||
typeof raw.price === "number" ? raw.price : unwrapD(raw).price
|
||||
const d = unwrapD(raw)
|
||||
return { success: true, data: toNumber(raw.price ?? d.price) }
|
||||
}
|
||||
|
||||
// calc_price 原始返回:每 ipAmount 个 IP 价格为 price
|
||||
export type CalcPriceResult = {
|
||||
ipAmount: number
|
||||
price: number
|
||||
}
|
||||
|
||||
async function calcPriceWithIp(
|
||||
orderType: number,
|
||||
data: unknown,
|
||||
): Promise<ApiResponse<CalcPriceResult>> {
|
||||
const res = await phpPost<RawHttpResponse>(
|
||||
"/http/product/calc_price",
|
||||
{
|
||||
order_type: orderType,
|
||||
money: 1,
|
||||
pay_type: 1,
|
||||
data,
|
||||
},
|
||||
{ encoding: "form" },
|
||||
)
|
||||
if (!res.success) return res
|
||||
const raw = res.data ?? {}
|
||||
const d = unwrapD(raw)
|
||||
const rawIp = raw.ipAmount ?? d.ipAmount
|
||||
// ipAmount 为空时按 1 处理(每 1 个 IP 价格为 price)
|
||||
return {
|
||||
success: true,
|
||||
data: typeof priceValue === "number" ? priceValue : 0,
|
||||
data: {
|
||||
ipAmount: rawIp === null || rawIp === undefined ? 1 : toNumber(rawIp),
|
||||
price: toNumber(raw.price ?? d.price),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 短效包天:返回每 IP 每天单价(总价 = periodAmount * price * ipAmount)
|
||||
export async function calcDxbtPrice(
|
||||
data: DxbtData,
|
||||
): Promise<ApiResponse<CalcPriceResult>> {
|
||||
return calcPriceWithIp(3, data)
|
||||
}
|
||||
|
||||
// 短效包量:返回每 1 元可购 IP 数量(总 IP = money * ipAmount)
|
||||
export async function calcDxblPrice(
|
||||
data: DxblData,
|
||||
): Promise<ApiResponse<CalcPriceResult>> {
|
||||
return calcPriceWithIp(4, data)
|
||||
}
|
||||
|
||||
export async function calcOrderPrice(
|
||||
orderInfo: PackageOrderInfo,
|
||||
): Promise<ApiResponse<number>> {
|
||||
const res = await phpPost<RawHttpResponse>(
|
||||
"/http/product/calc_price",
|
||||
orderInfo,
|
||||
{ encoding: "form" },
|
||||
)
|
||||
if (!res.success) return res
|
||||
const raw = res.data ?? {}
|
||||
const d = unwrapD(raw)
|
||||
// price 可能为字符串(如 "9.9000"),统一 toNumber 解析
|
||||
return { success: true, data: toNumber(raw.price ?? d.price) }
|
||||
}
|
||||
|
||||
// ==================== 订单 ====================
|
||||
|
||||
export async function createHttpOrder(
|
||||
orderInfo: HttpOrderInfo,
|
||||
): Promise<ApiResponse<CreateOrderResult>> {
|
||||
const res = await postJson<RawHttpResponse>("/http/order/create_order", {
|
||||
const res = await phpPost<RawHttpResponse>("/http/order/create_order", {
|
||||
cookie: document.cookie,
|
||||
order_info: orderInfo,
|
||||
})
|
||||
@@ -124,23 +357,189 @@ export async function createHttpOrder(
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
code: Number(res.data?.code) || 0,
|
||||
code: toNumber(res.data?.code),
|
||||
msg: typeof res.data?.msg === "string" ? res.data.msg : undefined,
|
||||
data: typeof res.data?.data === "string" ? res.data.data : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function parseOrderAction(raw: RawHttpResponse): OrderActionResult {
|
||||
return {
|
||||
code: toNumber(raw.code),
|
||||
msg: typeof raw.msg === "string" ? raw.msg : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export async function rebuyOrder(
|
||||
orderInfo: PackageOrderInfo,
|
||||
): Promise<ApiResponse<OrderActionResult>> {
|
||||
const res = await phpPost<RawHttpResponse>("/http/order/rebuy_order", {
|
||||
cookie: document.cookie,
|
||||
order_info: { ...orderInfo, renew: 1 },
|
||||
})
|
||||
if (!res.success) return res
|
||||
return { success: true, data: parseOrderAction(res.data ?? {}) }
|
||||
}
|
||||
|
||||
export async function changeIpOrder(
|
||||
orderInfo: Omit<PackageOrderInfo, "data"> & { data: ChangeIpData },
|
||||
): Promise<ApiResponse<OrderActionResult>> {
|
||||
const res = await phpPost<RawHttpResponse>("/http/order/change_ip_order", {
|
||||
cookie: document.cookie,
|
||||
order_info: orderInfo,
|
||||
})
|
||||
if (!res.success) return res
|
||||
return { success: true, data: parseOrderAction(res.data ?? {}) }
|
||||
}
|
||||
|
||||
// ==================== 用户信息 ====================
|
||||
|
||||
export async function fetchHttpBalance(): Promise<ApiResponse<number>> {
|
||||
const res = await postJson<RawHttpResponse | number>(
|
||||
const res = await phpPost<RawHttpResponse | number>(
|
||||
"/http/user/get_balance",
|
||||
{
|
||||
cookie: document.cookie,
|
||||
},
|
||||
{ cookie: document.cookie },
|
||||
)
|
||||
if (!res.success) return res
|
||||
if (typeof res.data === "number") return { success: true, data: res.data }
|
||||
const raw = res.data ?? {}
|
||||
const balance = raw.d ?? raw.data ?? raw.balance
|
||||
return { success: true, data: Number(balance) || 0 }
|
||||
return { success: true, data: toNumber(balance) }
|
||||
}
|
||||
|
||||
export async function fetchUserApiToken(): Promise<ApiResponse<UserApiToken>> {
|
||||
const res = await phpPost<RawHttpResponse>("/http/user/get_user_token", {
|
||||
cookie: document.cookie,
|
||||
})
|
||||
if (!res.success) return res
|
||||
const raw = (res.data?.data ?? res.data ?? {}) as Record<string, unknown>
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
uid: raw.uid as UserApiToken["uid"],
|
||||
token: typeof raw.token === "string" ? raw.token : "",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchWhiteLimitMax(): Promise<ApiResponse<number>> {
|
||||
const res = await phpPost<RawHttpResponse>("/http/user/white_limit_get_su", {
|
||||
cookie: document.cookie,
|
||||
})
|
||||
if (!res.success) return res
|
||||
const raw = (res.data?.data ?? res.data ?? {}) as Record<string, unknown>
|
||||
// 返回值可能是 { su: n } / { data: n },也可能直接是数字,逐层尝试
|
||||
return { success: true, data: toNumber(raw.su ?? raw.data ?? raw) }
|
||||
}
|
||||
|
||||
// ==================== 短效套餐 ====================
|
||||
|
||||
export async function fetchDxPackages(): Promise<ApiResponse<DxPackage[]>> {
|
||||
const res = await phpPost<RawHttpResponse>("/http/user/dx_package", {
|
||||
cookie: document.cookie,
|
||||
})
|
||||
if (!res.success) return res
|
||||
const list = res.data?.data ?? res.data
|
||||
return {
|
||||
success: true,
|
||||
data: isArray<DxPackage>(list) ? list : [],
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchDxOrders(): Promise<ApiResponse<DxOrder[]>> {
|
||||
const res = await phpPost<RawHttpResponse>("/http/user/dx_order", {
|
||||
cookie: document.cookie,
|
||||
})
|
||||
if (!res.success) return res
|
||||
const list = res.data?.data ?? res.data
|
||||
return { success: true, data: isArray<DxOrder>(list) ? list : [] }
|
||||
}
|
||||
|
||||
// ==================== 长效套餐 ====================
|
||||
|
||||
export async function fetchCxPackages(): Promise<ApiResponse<CxPackage[]>> {
|
||||
const res = await phpPost<RawHttpResponse>("/http/user/cx_package", {
|
||||
cookie: document.cookie,
|
||||
})
|
||||
if (!res.success) return res
|
||||
const raw = res.data ?? {}
|
||||
// 文档结构为 data.d.packs;兼容 data 直接为数组的情况
|
||||
const list = unwrapD(raw).packs ?? raw.data ?? raw
|
||||
return {
|
||||
success: true,
|
||||
data: isArray<CxPackage>(list) ? list : [],
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchCxOrders(): Promise<ApiResponse<CxOrder[]>> {
|
||||
const res = await phpPost<RawHttpResponse>("/http/user/cx_order", {
|
||||
cookie: document.cookie,
|
||||
})
|
||||
if (!res.success) return res
|
||||
const list = res.data?.data ?? res.data
|
||||
return { success: true, data: isArray<CxOrder>(list) ? list : [] }
|
||||
}
|
||||
|
||||
// ==================== 长效 IP 列表/提取/切换 ====================
|
||||
|
||||
export async function fetchLangIpList(
|
||||
query: Record<string, unknown>,
|
||||
): Promise<ApiResponse<LangIpListResult>> {
|
||||
const res = await phpPost<RawHttpResponse>("/http/user/lang_ip_list", {
|
||||
cookie: document.cookie,
|
||||
...query,
|
||||
})
|
||||
if (!res.success) return res
|
||||
const d = unwrapD(res.data)
|
||||
const items = isArray<LangIpItem>(d.items) ? d.items : []
|
||||
return {
|
||||
success: true,
|
||||
data: { items, pager: parsePager(d.pager, items.length) },
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchSwitchList(
|
||||
query: Record<string, unknown>,
|
||||
): Promise<ApiResponse<SwitchListResult>> {
|
||||
const res = await phpPost<RawHttpResponse>("/http/user/cx_switch", {
|
||||
cookie: document.cookie,
|
||||
...query,
|
||||
})
|
||||
if (!res.success) return res
|
||||
const d = unwrapD(res.data)
|
||||
const items = isArray<SwitchRecord>(d.items) ? d.items : []
|
||||
return {
|
||||
success: true,
|
||||
data: { items, pager: parsePager(d.pager, items.length) },
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchLangIps(
|
||||
ids: string,
|
||||
): Promise<ApiResponse<LangIpExtract[]>> {
|
||||
const res = await phpPost<RawHttpResponse>("/http/user/get_lang_ip", {
|
||||
cookie: document.cookie,
|
||||
data: { ids },
|
||||
})
|
||||
if (!res.success) return res
|
||||
const ips = unwrapD(res.data).ips
|
||||
return {
|
||||
success: true,
|
||||
data: isArray<LangIpExtract>(ips) ? ips : [],
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchSwitchCities(
|
||||
data: Record<string, unknown>,
|
||||
switchPop: 0 | 1,
|
||||
): Promise<ApiResponse<GameCity[]>> {
|
||||
const endpoint =
|
||||
switchPop === 1 ? "/http/product/in_city" : "/http/product/out_city"
|
||||
const res = await phpPost<RawHttpResponse>(endpoint, data)
|
||||
if (!res.success) return res
|
||||
const cities = unwrapD(res.data).cities
|
||||
return {
|
||||
success: true,
|
||||
data: isArray<GameCity>(cities) ? cities : [],
|
||||
}
|
||||
}
|
||||
|
||||
20
src/api/httpRecharge.ts
Normal file
20
src/api/httpRecharge.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type { HttpRechargeItem } from "@/lib/models/httpRecharge"
|
||||
import { isArray, phpPost } from "./http"
|
||||
|
||||
// ==================== HTTP 充值记录 ====================
|
||||
|
||||
export async function fetchHttpRechargeList(): Promise<
|
||||
ApiResponse<HttpRechargeItem[]>
|
||||
> {
|
||||
const res = await phpPost<HttpRechargeItem[] | { data?: unknown }>(
|
||||
"/http/user/charge_list",
|
||||
{ cookie: document.cookie },
|
||||
)
|
||||
if (!res.success) return res
|
||||
if (isArray<HttpRechargeItem>(res.data)) {
|
||||
return { success: true, data: res.data }
|
||||
}
|
||||
const list = isArray<HttpRechargeItem>(res.data?.data) ? res.data.data : []
|
||||
return { success: true, data: list }
|
||||
}
|
||||
@@ -1,65 +1,9 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
|
||||
export const PHP_API_BASE_URL =
|
||||
import.meta.env.VITE_PHP_API_BASE_URL ?? "https://php-api.juip.com"
|
||||
|
||||
const DEFAULT_TIMEOUT = 30_000
|
||||
|
||||
export type LineItem = {
|
||||
city: string | number
|
||||
daikuan: string | number
|
||||
name: string | number
|
||||
nasname: string | number
|
||||
online: string | number
|
||||
supply: string | number
|
||||
}
|
||||
|
||||
export type LineData = {
|
||||
count: number | string
|
||||
use_count: number | string
|
||||
data: LineItem[]
|
||||
}
|
||||
|
||||
export type LineSearchResult = {
|
||||
data: LineItem[]
|
||||
}
|
||||
|
||||
async function getJson<T>(url: string): Promise<ApiResponse<T>> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT)
|
||||
try {
|
||||
const response = await fetch(url, { 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)
|
||||
}
|
||||
}
|
||||
import type { LineData, LineSearchResult } from "@/lib/models/line"
|
||||
import { phpGet } from "./http"
|
||||
|
||||
export function fetchLineData(product: number): Promise<ApiResponse<LineData>> {
|
||||
return getJson<LineData>(
|
||||
`${PHP_API_BASE_URL}/script/linedata/display.php?product=${product}`,
|
||||
)
|
||||
return phpGet<LineData>(`/script/linedata/display.php?product=${product}`)
|
||||
}
|
||||
|
||||
export function searchLineData(
|
||||
@@ -67,7 +11,5 @@ export function searchLineData(
|
||||
info: string,
|
||||
): Promise<ApiResponse<LineSearchResult>> {
|
||||
const query = `type=0&productid=${productid}&info=${encodeURIComponent(info)}`
|
||||
return getJson<LineSearchResult>(
|
||||
`${PHP_API_BASE_URL}/script/linedata/search.php?${query}`,
|
||||
)
|
||||
return phpGet<LineSearchResult>(`/script/linedata/search.php?${query}`)
|
||||
}
|
||||
|
||||
179
src/api/order.ts
179
src/api/order.ts
@@ -1,39 +1,16 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import { callByUser } from "./base"
|
||||
import type {
|
||||
CreateOrderData,
|
||||
CreateOrderRequest,
|
||||
CreateTestAccountRequest,
|
||||
MyOrderListResult,
|
||||
MyOrderQueryParams,
|
||||
OrderItem,
|
||||
OrderQueryParams,
|
||||
} from "@/lib/models/order"
|
||||
import { callByUser, callPublic } from "./base"
|
||||
|
||||
export type CreateOrderRequest = {
|
||||
PackageId: number
|
||||
OrderType: number
|
||||
Account: string
|
||||
Pwd: string
|
||||
ConnectCount: number
|
||||
MinPostfix?: number
|
||||
MaxPostfix?: number
|
||||
CouponId?: number
|
||||
UseAccountAmount?: number
|
||||
OPayType: number
|
||||
PayChannel?: number
|
||||
Price?: number
|
||||
}
|
||||
|
||||
export type CreateTestAccountRequest = {
|
||||
ProductId: number
|
||||
PackageId: number
|
||||
Account: string
|
||||
Pwd: string
|
||||
}
|
||||
|
||||
export type OrderInfo = {
|
||||
OrderNo: string
|
||||
OtherPayAmount: number
|
||||
CreateTime: string
|
||||
PayType: number
|
||||
}
|
||||
|
||||
export type CreateOrderData = {
|
||||
OrderInfo: OrderInfo
|
||||
PayData: string
|
||||
}
|
||||
// ==================== 购买下单(php-api /product/*) ====================
|
||||
|
||||
export async function createOrder(
|
||||
data: CreateOrderRequest,
|
||||
@@ -62,3 +39,137 @@ export async function checkPayStatus(
|
||||
"GET",
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== 管理端订单(.NET /api/course/v1/order/*) ====================
|
||||
|
||||
// 订单分页列表
|
||||
export async function fetchOrderPage(
|
||||
params: OrderQueryParams,
|
||||
): Promise<ApiResponse<OrderItem[]>> {
|
||||
return await callByUser<OrderItem[]>(
|
||||
"/api/course/v1/order/Page",
|
||||
params,
|
||||
"GET",
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== 个人中心订单/退货订单 ====================
|
||||
|
||||
// 响应结构:{ Code: 10000, data: [{ RowCount, List }] }
|
||||
async function fetchMyOrderPage(
|
||||
endpoint: string,
|
||||
params: MyOrderQueryParams,
|
||||
): Promise<ApiResponse<MyOrderListResult>> {
|
||||
const res = await callPublic<
|
||||
MyOrderListResult[] | MyOrderListResult | unknown
|
||||
>(endpoint, params, "GET")
|
||||
if (!res.success) return res
|
||||
|
||||
const data = res.data
|
||||
const raw = Array.isArray(data) ? data[0] : data
|
||||
const page =
|
||||
raw && typeof raw === "object" ? (raw as MyOrderListResult) : null
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
RowCount: Number(page?.RowCount ?? 0),
|
||||
List: Array.isArray(page?.List) ? page.List : [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 我的订单(订单管理)
|
||||
export async function fetchMyOrders(
|
||||
params: MyOrderQueryParams,
|
||||
): Promise<ApiResponse<MyOrderListResult>> {
|
||||
return fetchMyOrderPage("/user/ApiMyOrders", params)
|
||||
}
|
||||
|
||||
// 我的退货订单
|
||||
export async function fetchMyRefundOrders(
|
||||
params: MyOrderQueryParams,
|
||||
): Promise<ApiResponse<MyOrderListResult>> {
|
||||
return fetchMyOrderPage("/user/ApiMyRefundOrders", params)
|
||||
}
|
||||
|
||||
// 优惠券订单分页列表
|
||||
export async function fetchCouponOrders(
|
||||
params: OrderQueryParams,
|
||||
): Promise<ApiResponse<OrderItem[]>> {
|
||||
return await callByUser<OrderItem[]>(
|
||||
"/api/course/v1/order/CouponOrders",
|
||||
params,
|
||||
"GET",
|
||||
)
|
||||
}
|
||||
|
||||
// 订单统计(Statistics 接口返回统计结构,待后端确认字段后收窄类型)
|
||||
export async function fetchOrderStatistics(
|
||||
params: OrderQueryParams,
|
||||
): Promise<ApiResponse<Record<string, unknown>>> {
|
||||
return await callByUser<Record<string, unknown>>(
|
||||
"/api/course/v1/order/Statistics",
|
||||
params,
|
||||
"GET",
|
||||
)
|
||||
}
|
||||
|
||||
// 用户消费统计
|
||||
export async function fetchUserConsumeStatistics(
|
||||
params: Record<string, unknown>,
|
||||
): Promise<ApiResponse<Record<string, unknown>>> {
|
||||
return await callByUser<Record<string, unknown>>(
|
||||
"/api/course/v1/order/UserConsumeStatistics",
|
||||
params,
|
||||
"GET",
|
||||
)
|
||||
}
|
||||
|
||||
// 订单导出
|
||||
export async function exportOrders(
|
||||
params: OrderQueryParams,
|
||||
): Promise<ApiResponse<undefined>> {
|
||||
return await callByUser<undefined>(
|
||||
"/api/course/v1/order/Export",
|
||||
params,
|
||||
"GET",
|
||||
)
|
||||
}
|
||||
|
||||
export type RechargeOrderResult = {
|
||||
OrderInfo: {
|
||||
OrderNo: string
|
||||
CreateTime: string
|
||||
PaymentAmount?: number
|
||||
PayChannel?: number
|
||||
}
|
||||
PayData: string
|
||||
}
|
||||
|
||||
// 创建充值订单:PayChannel 50-支付宝 30-微信
|
||||
export async function createRechargeOrder(data: {
|
||||
ChargeAmount: number
|
||||
PayChannel: number
|
||||
}): Promise<ApiResponse<RechargeOrderResult>> {
|
||||
return await callPublic<CreateOrderData | RechargeOrderResult>(
|
||||
"/user/CreateOrder",
|
||||
data,
|
||||
)
|
||||
}
|
||||
|
||||
// 轮询充值支付状态:返回 true 表示支付成功
|
||||
// 响应体:{ Code: 0|1, Message: "" },Code 本身即支付状态
|
||||
// 兼容旧格式 { Code: 10000, Data: 0|1 }
|
||||
export async function checkRechargeStatus(
|
||||
orderNo: string,
|
||||
): Promise<ApiResponse<boolean>> {
|
||||
const res = await callPublic<number | null>(
|
||||
`/user/IsPay?orderNo=${encodeURIComponent(orderNo)}`,
|
||||
undefined,
|
||||
"GET",
|
||||
)
|
||||
if (res.success) return { success: true, data: res.data === 1 }
|
||||
if (res.code === 1) return { success: true, data: true }
|
||||
if (res.code === 0) return { success: true, data: false }
|
||||
return res
|
||||
}
|
||||
|
||||
@@ -29,3 +29,14 @@ export async function fetchWindowProducts(): Promise<
|
||||
"GET",
|
||||
)
|
||||
}
|
||||
|
||||
// 产品+套餐(用于账号管理筛选)
|
||||
export async function fetchProductWithPackage(): Promise<
|
||||
ApiResponse<ProductItem[]>
|
||||
> {
|
||||
return await callPublic<ProductItem[]>(
|
||||
"/api/course/v1/product/ProductWithPackage",
|
||||
undefined,
|
||||
"GET",
|
||||
)
|
||||
}
|
||||
|
||||
51
src/api/record.ts
Normal file
51
src/api/record.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type {
|
||||
HttpUsedLogItem,
|
||||
HttpUsedLogQuery,
|
||||
HttpUsedLogsResult,
|
||||
HttpUsedSummary,
|
||||
} from "@/lib/models/record"
|
||||
import { isArray, parsePager, phpPost, toNumber, unwrapD } from "./http"
|
||||
|
||||
// ==================== HTTP 使用记录(php-api /http/user/*) ====================
|
||||
|
||||
// 包天包量使用概况
|
||||
export async function fetchHttpUsedSummary(): Promise<
|
||||
ApiResponse<HttpUsedSummary>
|
||||
> {
|
||||
const res = await phpPost<Record<string, unknown>>("/http/user/dx_used", {
|
||||
cookie: document.cookie,
|
||||
})
|
||||
if (!res.success) return res
|
||||
const raw = res.data ?? {}
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
todayCoinPackIpPaid:
|
||||
typeof raw.todayCoinPackIpPaid === "string"
|
||||
? raw.todayCoinPackIpPaid
|
||||
: String(raw.todayCoinPackIpPaid ?? ""),
|
||||
todayCoinPackIpUsed: toNumber(raw.todayCoinPackIpUsed),
|
||||
coinPackIpUsed: toNumber(raw.coinPackIpUsed),
|
||||
todayShortPackIpUsed: toNumber(raw.todayShortPackIpUsed),
|
||||
shortPackIpUsed: toNumber(raw.shortPackIpUsed),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 短效套餐使用记录(服务端分页)
|
||||
export async function fetchHttpUsedLogs(
|
||||
query: HttpUsedLogQuery,
|
||||
): Promise<ApiResponse<HttpUsedLogsResult>> {
|
||||
const res = await phpPost<Record<string, unknown>>("/http/user/dx_used_log", {
|
||||
cookie: document.cookie,
|
||||
data: query,
|
||||
})
|
||||
if (!res.success) return res
|
||||
const d = unwrapD(res.data)
|
||||
const items = isArray<HttpUsedLogItem>(d.items) ? d.items : []
|
||||
return {
|
||||
success: true,
|
||||
data: { items, pager: parsePager(d.pager, items.length) },
|
||||
}
|
||||
}
|
||||
17
src/api/rosorder.ts
Normal file
17
src/api/rosorder.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type { RosOrderItem } from "@/lib/models/rosorder"
|
||||
import { phpPost } from "./http"
|
||||
|
||||
export async function fetchRosOrderList(): Promise<
|
||||
ApiResponse<RosOrderItem[]>
|
||||
> {
|
||||
const res = await phpPost<Record<string, unknown>>("/ros/order/getList", {
|
||||
cookie: document.cookie,
|
||||
})
|
||||
if (!res.success) return res
|
||||
const list = res.data?.data ?? res.data
|
||||
return {
|
||||
success: true,
|
||||
data: Array.isArray(list) ? (list as RosOrderItem[]) : [],
|
||||
}
|
||||
}
|
||||
113
src/api/shuidi.ts
Normal file
113
src/api/shuidi.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type {
|
||||
ShuidiGame,
|
||||
ShuidiOrderInfo,
|
||||
ShuidiOrderResult,
|
||||
ShuidiPackageList,
|
||||
ShuidiRegion,
|
||||
} from "@/lib/models/shuidi"
|
||||
import { phpFetch, phpPost } from "./http"
|
||||
|
||||
// ==================== 水滴静态/固态(jinyou) ====================
|
||||
// - 游戏列表:POST /jinyou/api/gameList(不带 cookie)
|
||||
// - 套餐列表:POST /jinyou/Jinyoujt/getPackage?type=1(1:静态 2:固态),需带 cookie
|
||||
// - 线路列表:GET /jinyou/api/regionSurplusOuts?product_id=&is_unshar=&game_id=(不带 cookie)
|
||||
|
||||
type JinyouResult<T> = {
|
||||
code: number
|
||||
msg?: string
|
||||
data?: T
|
||||
}
|
||||
|
||||
export async function fetchShuidiGameList(): Promise<
|
||||
ApiResponse<ShuidiGame[]>
|
||||
> {
|
||||
const res = await phpFetch<JinyouResult<ShuidiGame[]>>(
|
||||
"POST",
|
||||
"/jinyou/api/gameList",
|
||||
)
|
||||
if (!res.success) return res
|
||||
const data = res.data?.data
|
||||
return {
|
||||
success: true,
|
||||
data: Array.isArray(data) ? data : [],
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchShuidiPackages(
|
||||
type: 1 | 2 = 1,
|
||||
): Promise<ApiResponse<ShuidiPackageList>> {
|
||||
const res = await phpPost<JinyouResult<ShuidiPackageList>>(
|
||||
`/jinyou/Jinyoujt/getPackage?type=${type}`,
|
||||
{ cookie: document.cookie },
|
||||
)
|
||||
if (!res.success) return res
|
||||
const data = res.data?.data
|
||||
if (!data || typeof data !== "object") {
|
||||
return { success: true, data: { game: {}, line: {} } }
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
game: data.game ?? {},
|
||||
line: data.line ?? {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchShuidiRegions(params: {
|
||||
product_id: number
|
||||
is_unshar: 0 | 1
|
||||
game_id: number | ""
|
||||
}): Promise<ApiResponse<ShuidiRegion>> {
|
||||
const query = new URLSearchParams({
|
||||
product_id: String(params.product_id),
|
||||
is_unshar: String(params.is_unshar),
|
||||
game_id: String(params.game_id),
|
||||
})
|
||||
// 余量数据时效性强,禁止浏览器缓存
|
||||
const res = await phpFetch<JinyouResult<{ region?: ShuidiRegion }>>(
|
||||
"GET",
|
||||
`/jinyou/api/regionSurplusOuts?${query.toString()}`,
|
||||
undefined,
|
||||
{ cache: "no-store" },
|
||||
)
|
||||
if (!res.success) return res
|
||||
const region = res.data?.data?.region
|
||||
if (!region || typeof region !== "object") {
|
||||
return {
|
||||
success: true,
|
||||
data: { province_surplus_outs: {} },
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
surplus_out_total: region.surplus_out_total,
|
||||
province_surplus_outs: region.province_surplus_outs ?? {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 下单 ====================
|
||||
// 与 juipnet Index.cshtml pay() 一致:body 为 { cookie, order_info }
|
||||
// code 1-开通成功 2-返回支付宝支付主体 3-返回微信支付主体 -1-错误
|
||||
|
||||
export async function createShuidiOrder(
|
||||
orderInfo: ShuidiOrderInfo,
|
||||
): Promise<ApiResponse<ShuidiOrderResult>> {
|
||||
const res = await phpPost<Record<string, unknown>>(
|
||||
"/jinyou/jinyoujt/create_order",
|
||||
{ cookie: document.cookie, order_info: orderInfo },
|
||||
)
|
||||
if (!res.success) return res
|
||||
const raw = res.data ?? {}
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
code: Number(raw.code) || 0,
|
||||
msg: typeof raw.msg === "string" ? raw.msg : undefined,
|
||||
data: typeof raw.data === "string" ? raw.data : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ export const AUTH_EVENT = "auth-change"
|
||||
const TOKEN_KEY = "auth_token"
|
||||
const USER_KEY = "auth_user"
|
||||
|
||||
export type UserInfoRecord = Record<string, unknown>
|
||||
|
||||
export function readCookie(name: string): string | null {
|
||||
const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`))
|
||||
return match ? decodeURIComponent(match[1]) : null
|
||||
|
||||
64
src/api/user.ts
Normal file
64
src/api/user.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type {
|
||||
PhpUserModel,
|
||||
UpdateUserInfoParams,
|
||||
UpdateUserPwdParams,
|
||||
UserItem,
|
||||
UserQueryParams,
|
||||
} from "@/lib/models/user"
|
||||
import { callByUser, callByUserCookie } from "./base"
|
||||
|
||||
// ==================== 用户管理 ====================
|
||||
|
||||
export async function fetchUserPage(
|
||||
params: UserQueryParams,
|
||||
): Promise<ApiResponse<UserItem[]>> {
|
||||
return await callByUser<UserItem[]>(
|
||||
"/api/baseinfo/v1/user/Page",
|
||||
params,
|
||||
"GET",
|
||||
)
|
||||
}
|
||||
|
||||
export async function fetchUserSearch(
|
||||
params: UserQueryParams,
|
||||
): Promise<ApiResponse<UserItem[]>> {
|
||||
return await callByUser<UserItem[]>(
|
||||
"/api/baseinfo/v1/user/Search",
|
||||
params,
|
||||
"GET",
|
||||
)
|
||||
}
|
||||
|
||||
export async function fetchAmountDetails(
|
||||
params: Record<string, unknown>,
|
||||
): Promise<ApiResponse<Record<string, unknown>>> {
|
||||
return await callByUser<Record<string, unknown>>(
|
||||
"/api/baseinfo/v1/user/AmountDetails",
|
||||
params,
|
||||
"GET",
|
||||
)
|
||||
}
|
||||
|
||||
export async function updateUserProfile(
|
||||
data: Record<string, unknown>,
|
||||
): Promise<ApiResponse<undefined>> {
|
||||
return await callByUser<undefined>(
|
||||
"/api/baseinfo/v1/user/UpdateProfile",
|
||||
data,
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== 个人中心资料/密码 ====================
|
||||
|
||||
export async function updateUserInfo(
|
||||
data: UpdateUserInfoParams,
|
||||
): Promise<ApiResponse<undefined>> {
|
||||
return await callByUserCookie<undefined>("/user/ApiUpdateInfo", data)
|
||||
}
|
||||
|
||||
export async function updateUserPwd(
|
||||
data: UpdateUserPwdParams,
|
||||
): Promise<ApiResponse<PhpUserModel>> {
|
||||
return await callByUserCookie<PhpUserModel>("/user/UpdatePwd", data)
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type { SMSPurpose } from "@/lib/models/auth"
|
||||
import { callPublic } from "./base"
|
||||
|
||||
export type SMSPurpose = "User_Code" | "FindUser_Code" | "User_Login"
|
||||
|
||||
export async function sendSMS(
|
||||
phone: string,
|
||||
purpose: SMSPurpose = "User_Code",
|
||||
|
||||
118
src/api/whitelist.ts
Normal file
118
src/api/whitelist.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type { UnlimitedAvailable, WhiteIpItem } from "@/lib/models/whitelist"
|
||||
import { isArray, phpPost, toNumber, unwrapD } from "./http"
|
||||
|
||||
type RawHttpResponse = Record<string, unknown>
|
||||
|
||||
|
||||
export type WhiteActionResult = {
|
||||
code?: number
|
||||
msg?: string
|
||||
}
|
||||
|
||||
async function postWhiteAction(
|
||||
path: string,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<ApiResponse<WhiteActionResult>> {
|
||||
const res = await phpPost<RawHttpResponse>(path, {
|
||||
cookie: document.cookie,
|
||||
data,
|
||||
})
|
||||
if (!res.success) return res
|
||||
const raw = res.data ?? {}
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
code: raw.code === undefined ? undefined : toNumber(raw.code),
|
||||
msg: typeof raw.msg === "string" ? raw.msg : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 白名单列表接口返回 data 直接为数组,兼容 { list: [...] } / { data: [...] }
|
||||
function parseList(raw: unknown): WhiteIpItem[] {
|
||||
const value = raw as RawHttpResponse | undefined
|
||||
const list = Array.isArray(raw) ? raw : (value?.list ?? value?.data)
|
||||
return isArray<WhiteIpItem>(list) ? list : []
|
||||
}
|
||||
|
||||
// ==================== 白名单(普通) ====================
|
||||
|
||||
export async function fetchWhiteList(): Promise<ApiResponse<WhiteIpItem[]>> {
|
||||
const res = await phpPost<RawHttpResponse>("/http/user/white_list", {
|
||||
cookie: document.cookie,
|
||||
})
|
||||
if (!res.success) return res
|
||||
return { success: true, data: parseList(res.data) }
|
||||
}
|
||||
|
||||
export function createWhiteIp(ip: string) {
|
||||
return postWhiteAction("/http/user/create_white_ip", { ip })
|
||||
}
|
||||
|
||||
export function lockWhiteIp(id: number, lock: 0 | 1) {
|
||||
return postWhiteAction("/http/user/lock_ip", { id, lock })
|
||||
}
|
||||
|
||||
export function deleteWhiteIp(id: number) {
|
||||
return postWhiteAction("/http/user/delete_ip", { id })
|
||||
}
|
||||
|
||||
// ==================== 白名单(短效无限量) ====================
|
||||
|
||||
export async function fetchWhiteListSu(): Promise<ApiResponse<WhiteIpItem[]>> {
|
||||
const res = await phpPost<RawHttpResponse>("/http/user/white_list_su", {
|
||||
cookie: document.cookie,
|
||||
})
|
||||
if (!res.success) return res
|
||||
return { success: true, data: parseList(res.data) }
|
||||
}
|
||||
|
||||
export function createWhiteIpSu(ip: string) {
|
||||
return postWhiteAction("/http/user/create_white_ip_su", { ip })
|
||||
}
|
||||
|
||||
export function lockWhiteIpSu(id: number, lock: 0 | 1) {
|
||||
return postWhiteAction("/http/user/lock_ip_su", { id, lock })
|
||||
}
|
||||
|
||||
export function deleteWhiteIpSu(id: number) {
|
||||
return postWhiteAction("/http/user/delete_ip_su", { id })
|
||||
}
|
||||
|
||||
// 当前白名单数量
|
||||
export async function fetchWhiteLimitSu(): Promise<ApiResponse<number>> {
|
||||
const res = await phpPost<RawHttpResponse>("/http/user/white_limit_get_su", {
|
||||
cookie: document.cookie,
|
||||
})
|
||||
if (!res.success) return res
|
||||
return { success: true, data: toNumber(res.data?.data ?? res.data) }
|
||||
}
|
||||
|
||||
// 调整白名单上限
|
||||
export function setWhiteLimitSu(maxAmount: number) {
|
||||
return postWhiteAction("/http/user/white_limit_set_su", { maxAmount })
|
||||
}
|
||||
|
||||
// 获取最新套餐详情
|
||||
export async function fetchUnlimitedAvailable(): Promise<
|
||||
ApiResponse<UnlimitedAvailable>
|
||||
> {
|
||||
const res = await phpPost<RawHttpResponse>(
|
||||
"/http/user/get_unlimited_available",
|
||||
{ cookie: document.cookie },
|
||||
)
|
||||
if (!res.success) return res
|
||||
// 文档结构为 data.d.{...};兼容 data 直接平铺字段的情况
|
||||
const merged = { ...(res.data ?? {}), ...unwrapD(res.data) }
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
packId: toNumber(merged.packId),
|
||||
poolType: toNumber(merged.poolType),
|
||||
durationType: toNumber(merged.durationType),
|
||||
usage: toNumber(merged.usage),
|
||||
expireTime: toNumber(merged.expireTime),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,12 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { Navigate, useLocation } from "react-router-dom"
|
||||
import { AUTH_EVENT, isAuthed } from "@/api/token"
|
||||
import { useProfileStore } from "@/components/stores/profile"
|
||||
|
||||
export default function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
const location = useLocation()
|
||||
const [isValid, setIsValid] = useState(isAuthed)
|
||||
const token = useProfileStore(state => state.token)
|
||||
const userInfo = useProfileStore(state => state.userInfo)
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => setIsValid(isAuthed())
|
||||
window.addEventListener(AUTH_EVENT, update)
|
||||
return () => window.removeEventListener(AUTH_EVENT, update)
|
||||
}, [])
|
||||
const isValid = token !== null && userInfo !== null
|
||||
|
||||
if (!isValid) {
|
||||
return (
|
||||
|
||||
21
src/components/composites/accountCell.tsx
Normal file
21
src/components/composites/accountCell.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cutAccount } from "@/lib/models/order"
|
||||
|
||||
// 订单账号单元格:过长时截断并用 Tooltip 展示完整内容
|
||||
export default function AccountCell({ accounts }: { accounts: string }) {
|
||||
const isCut = accounts.length > 15
|
||||
if (!isCut) return <span className="truncate">{accounts}</span>
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-default truncate">{cutAccount(accounts)}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-80 break-all">{accounts}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
@@ -7,6 +8,11 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
// 参考 juipnet Host/Views/Home/Index.cshtml 公告弹窗:
|
||||
// 查看活动 -> /product 产品页;联系客服 -> 企点QQ客服二维码页
|
||||
const CUSTOMER_SERVICE_URL =
|
||||
"https://admin.qidian.qq.com/template/blue/mp/menu/qr-code-jump.html?linkType=0&env=ol&kfuin=2852138148&fid=299&key=f377ec024ca45115a03a7632c7bda230&cate=1&source=&isLBS=&isCustomEntry=&type=16&ftype=1&_type=wpa&qidian=true&_pid=l1gunh.657dox.kjy6xnny"
|
||||
|
||||
interface AnnouncementModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
@@ -16,6 +22,18 @@ export default function AnnouncementModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: AnnouncementModalProps) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleViewActivity = () => {
|
||||
onOpenChange(false)
|
||||
navigate("/product")
|
||||
}
|
||||
|
||||
const handleContactService = () => {
|
||||
onOpenChange(false)
|
||||
window.open(CUSTOMER_SERVICE_URL, "_blank", "noopener,noreferrer")
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
@@ -50,13 +68,19 @@ export default function AnnouncementModal({
|
||||
|
||||
<div className="flex mt-2">
|
||||
<DialogClose asChild className="flex-1">
|
||||
<Button className="w-full h-14 rounded-none bg-linear-to-r from-blue-500 to-cyan-400 hover:opacity-90 text-white text-lg font-medium border-0 rounded-bl-xl">
|
||||
<Button
|
||||
onClick={handleViewActivity}
|
||||
className="w-full h-14 rounded-none bg-linear-to-r from-blue-500 to-cyan-400 hover:opacity-90 text-white text-lg font-medium border-0 rounded-bl-xl"
|
||||
>
|
||||
查看活动
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<DialogClose asChild className="flex-1">
|
||||
<Button className="w-full h-14 rounded-none bg-linear-to-r from-cyan-400 to-blue-500 hover:opacity-90 text-white text-lg font-medium border-0 rounded-br-xl">
|
||||
<Button
|
||||
onClick={handleContactService}
|
||||
className="w-full h-14 rounded-none bg-linear-to-r from-cyan-400 to-blue-500 hover:opacity-90 text-white text-lg font-medium border-0 rounded-br-xl"
|
||||
>
|
||||
联系客服
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
220
src/components/composites/dialogs/agreementDialog.tsx
Normal file
220
src/components/composites/dialogs/agreementDialog.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
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
|
||||
}
|
||||
if (file.size > 30 * 1024 * 1024) {
|
||||
toast.error("文件过大,仅限30M")
|
||||
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={() => 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>
|
||||
)
|
||||
}
|
||||
211
src/components/composites/dialogs/cashOutDialog.tsx
Normal file
211
src/components/composites/dialogs/cashOutDialog.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { submitCashOut } from "@/api/cashOut"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const ALIPAY_ACCOUNT_RE =
|
||||
/^(?:1[3-9]\d{9}|[a-zA-Z\d._-]*@[a-zA-Z\d.-]{1,10}\.[a-zA-Z\d]{1,20})$/
|
||||
|
||||
const CUSTOMER_SERVICE_URL =
|
||||
"https://admin.qidian.qq.com/template/blue/mp/menu/qr-code-jump.html?linkType=0&env=ol&kfuin=2852138148&fid=299&key=f377ec024ca45115a03a7632c7bda230&cate=1&source=&isLBS=&isCustomEntry=&type=16&ftype=1&_type=wpa&qidian=true"
|
||||
|
||||
interface CashOutDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
balance: number
|
||||
onSubmitted?: () => void
|
||||
}
|
||||
|
||||
export function CashOutDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
balance,
|
||||
onSubmitted,
|
||||
}: CashOutDialogProps) {
|
||||
const [step, setStep] = useState<"notice" | "form">("notice")
|
||||
const [money, setMoney] = useState("")
|
||||
const [reason, setReason] = useState("")
|
||||
const [alipayAccount, setAlipayAccount] = useState("")
|
||||
const [realName, setRealName] = useState("")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSubmitting(false)
|
||||
// setStep("notice")
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const cashOutMoney = Number(money)
|
||||
if (!Number.isFinite(cashOutMoney) || cashOutMoney <= 0) {
|
||||
toast.error("抱歉!请重新输入提现金额!")
|
||||
return
|
||||
}
|
||||
if (cashOutMoney > balance) {
|
||||
toast.error("抱歉!您提现的额度大于余额,请重新输入提现金额!")
|
||||
return
|
||||
}
|
||||
if (!realName.trim()) {
|
||||
toast.error("抱歉!请重新输入姓名!")
|
||||
return
|
||||
}
|
||||
if (
|
||||
!alipayAccount.trim() ||
|
||||
!ALIPAY_ACCOUNT_RE.test(alipayAccount.trim())
|
||||
) {
|
||||
toast.error("抱歉!请重新输入支付宝账号!")
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
const res = await submitCashOut({
|
||||
cash_out_money: money,
|
||||
reason: reason.trim(),
|
||||
alipay_account: alipayAccount.trim(),
|
||||
real_name: realName.trim(),
|
||||
})
|
||||
setSubmitting(false)
|
||||
|
||||
if (!res.success) {
|
||||
toast.error(res.message || "提现异常!请重试或联系客服。")
|
||||
return
|
||||
}
|
||||
if (!res.data) {
|
||||
toast.error("提现异常!请重试或联系客服。")
|
||||
return
|
||||
}
|
||||
toast.success(
|
||||
"提现请求发送成功,我们会在5到7个工作日进行处理,请注意查看资金明细。",
|
||||
)
|
||||
setMoney("")
|
||||
setReason("")
|
||||
setAlipayAccount("")
|
||||
setRealName("")
|
||||
onOpenChange(false)
|
||||
onSubmitted?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[85dvh] overflow-y-auto sm:max-w-sm p-6">
|
||||
{step === "notice" ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-semibold text-center">
|
||||
余额提现
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-3 space-y-2 text-sm text-gray-600">
|
||||
<p>聚IP涵盖十余种全网知名旗舰产品,总有一款适合您!</p>
|
||||
<p>联系客服帮您推荐合适的产品,我们将最大努力满足您的需求!</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
CUSTOMER_SERVICE_URL,
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
)
|
||||
}
|
||||
>
|
||||
联系客服
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => setStep("form")}
|
||||
>
|
||||
我不需要
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-semibold text-center">
|
||||
余额提现
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 mt-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
当前可用余额:
|
||||
<span className="font-bold text-orange-500">
|
||||
¥{balance.toFixed(2)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm text-gray-600">提现金额</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={money}
|
||||
onChange={e => setMoney(e.target.value)}
|
||||
className="h-11 bg-gray-50 border-gray-200"
|
||||
placeholder="请输入提现金额"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm text-gray-600">提现理由</Label>
|
||||
<Input
|
||||
value={reason}
|
||||
onChange={e => setReason(e.target.value)}
|
||||
className="h-11 bg-gray-50 border-gray-200"
|
||||
placeholder="请输入提现理由"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm text-gray-600">
|
||||
提现支付宝账号(目前仅支持支付宝)
|
||||
</Label>
|
||||
<Input
|
||||
value={alipayAccount}
|
||||
onChange={e => setAlipayAccount(e.target.value)}
|
||||
className="h-11 bg-gray-50 border-gray-200"
|
||||
placeholder="请输入支付宝账号"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm text-gray-600">支付宝真实姓名</Label>
|
||||
<Input
|
||||
value={realName}
|
||||
onChange={e => setRealName(e.target.value)}
|
||||
className="h-11 bg-gray-50 border-gray-200"
|
||||
placeholder="请输入与支付宝关联的真实姓名"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => handleSubmit()}
|
||||
className="w-full h-11 mt-2 bg-blue-600 hover:bg-blue-700 text-white font-medium"
|
||||
>
|
||||
{submitting ? "提交中..." : "确认提现"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
170
src/components/composites/dialogs/changePwdDialog.tsx
Normal file
170
src/components/composites/dialogs/changePwdDialog.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Eye, EyeOff } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { Controller, useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
import { z } from "zod"
|
||||
import { updateUserPwd } from "@/api/user"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
type ChangePwdValues = {
|
||||
oldPwd: string
|
||||
newPwd: string
|
||||
confirmPwd: string
|
||||
}
|
||||
|
||||
const changePwdSchema = z
|
||||
.object({
|
||||
oldPwd: z.string().min(1, "请输入原密码"),
|
||||
newPwd: z.string().min(1, "请输入新密码"),
|
||||
confirmPwd: z.string().min(1, "请再次输入新密码"),
|
||||
})
|
||||
.refine(v => v.newPwd === v.confirmPwd, {
|
||||
message: "两次输入的密码不一致",
|
||||
path: ["confirmPwd"],
|
||||
})
|
||||
|
||||
function PasswordInput(props: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
}) {
|
||||
const [show, setShow] = useState(false)
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={props.value}
|
||||
onChange={e => props.onChange(e.target.value)}
|
||||
type={show ? "text" : "password"}
|
||||
placeholder={props.placeholder}
|
||||
className="h-11 bg-gray-50 border-gray-200 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShow(!show)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{show ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChangePwdDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const form = useForm<ChangePwdValues>({
|
||||
resolver: zodResolver(changePwdSchema),
|
||||
defaultValues: { oldPwd: "", newPwd: "", confirmPwd: "" },
|
||||
})
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
onOpenChange(next)
|
||||
if (!next) form.reset()
|
||||
}
|
||||
|
||||
const onSubmit = async (data: ChangePwdValues) => {
|
||||
setSubmitting(true)
|
||||
const res = await updateUserPwd({
|
||||
OldPwd: data.oldPwd,
|
||||
NewPwd: data.newPwd,
|
||||
ConfirmPwd: data.confirmPwd,
|
||||
})
|
||||
setSubmitting(false)
|
||||
|
||||
if (!res.success) {
|
||||
toast.error(res.message)
|
||||
return
|
||||
}
|
||||
toast.success("密码修改成功")
|
||||
handleOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-h-[85dvh] overflow-y-auto sm:max-w-120 p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-semibold text-center">
|
||||
修改密码
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="space-y-4 mt-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm text-gray-600">原密码</Label>
|
||||
<Controller
|
||||
name="oldPwd"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<>
|
||||
<PasswordInput
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
<FieldError>{fieldState.error?.message}</FieldError>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm text-gray-600">新密码</Label>
|
||||
<Controller
|
||||
name="newPwd"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<>
|
||||
<PasswordInput
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
<FieldError>{fieldState.error?.message}</FieldError>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm text-gray-600">确认新密码</Label>
|
||||
<Controller
|
||||
name="confirmPwd"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<>
|
||||
<PasswordInput
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
<FieldError>{fieldState.error?.message}</FieldError>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full h-11 mt-2 bg-blue-600 hover:bg-blue-700 text-white font-medium"
|
||||
>
|
||||
{submitting ? "提交中..." : "提交"}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Eye, EyeOff } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Controller, useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
import { z } from "zod"
|
||||
import { updateUserInfo } from "@/api/user"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
@@ -15,51 +16,45 @@ import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
export type EditInfoValues = {
|
||||
username: string
|
||||
taobao: string
|
||||
password: string
|
||||
email: string
|
||||
wechat: string
|
||||
qq: string
|
||||
wechat: string
|
||||
// taobao: string
|
||||
email: string
|
||||
}
|
||||
|
||||
const editInfoSchema = z.object({
|
||||
username: z.string().trim().min(1, "请输入用户名"),
|
||||
taobao: z.string(),
|
||||
password: z.string().min(1, "请输入密码"),
|
||||
qq: z.string(),
|
||||
wechat: z.string(),
|
||||
// taobao: z.string(),
|
||||
email: z
|
||||
.string()
|
||||
.refine(
|
||||
v => !v || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
|
||||
"请输入正确的邮箱",
|
||||
),
|
||||
wechat: z.string(),
|
||||
qq: z.string(),
|
||||
})
|
||||
|
||||
interface EditInfoDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
initialData?: EditInfoValues
|
||||
onSave?: (data: EditInfoValues) => void
|
||||
onSaved?: () => void
|
||||
}
|
||||
|
||||
const DEFAULT_INITIAL_DATA: EditInfoValues = {
|
||||
username: "191****1234",
|
||||
taobao: "191****1234",
|
||||
password: "********",
|
||||
email: "123456123@qq.com",
|
||||
wechat: "",
|
||||
qq: "",
|
||||
wechat: "",
|
||||
// taobao: "",
|
||||
email: "",
|
||||
}
|
||||
|
||||
export function EditInfoDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
initialData = DEFAULT_INITIAL_DATA,
|
||||
onSave,
|
||||
onSaved,
|
||||
}: EditInfoDialogProps) {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const form = useForm<EditInfoValues>({
|
||||
resolver: zodResolver(editInfoSchema),
|
||||
@@ -72,14 +67,28 @@ export function EditInfoDialog({
|
||||
}
|
||||
}, [open, initialData, form])
|
||||
|
||||
const onSubmit = (data: EditInfoValues) => {
|
||||
onSave?.(data)
|
||||
const onSubmit = async (data: EditInfoValues) => {
|
||||
setSubmitting(true)
|
||||
const res = await updateUserInfo({
|
||||
QQ: data.qq,
|
||||
Wx: data.wechat,
|
||||
// TaoBao: data.taobao,
|
||||
Email: data.email,
|
||||
})
|
||||
setSubmitting(false)
|
||||
|
||||
if (!res.success) {
|
||||
toast.error(res.message)
|
||||
return
|
||||
}
|
||||
toast.success("保存成功")
|
||||
onOpenChange(false)
|
||||
onSaved?.()
|
||||
}
|
||||
|
||||
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">
|
||||
编辑信息
|
||||
@@ -88,26 +97,9 @@ export function EditInfoDialog({
|
||||
|
||||
<form className="space-y-4 mt-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm text-gray-600">用户名</Label>
|
||||
<Label className="text-sm text-gray-600">QQ号</Label>
|
||||
<Controller
|
||||
name="username"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<>
|
||||
<Input
|
||||
{...field}
|
||||
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="taobao"
|
||||
name="qq"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Input {...field} className="h-11 bg-gray-50 border-gray-200" />
|
||||
@@ -116,36 +108,27 @@ export function EditInfoDialog({
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm text-gray-600">密码</Label>
|
||||
<Label className="text-sm text-gray-600">微信号</Label>
|
||||
<Controller
|
||||
name="password"
|
||||
name="wechat"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<>
|
||||
<div className="relative">
|
||||
<Input
|
||||
{...field}
|
||||
type={showPassword ? "text" : "password"}
|
||||
className="h-11 bg-gray-50 border-gray-200 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<FieldError>{fieldState.error?.message}</FieldError>
|
||||
</>
|
||||
render={({ field }) => (
|
||||
<Input {...field} className="h-11 bg-gray-50 border-gray-200" />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* <div className="space-y-1.5">
|
||||
<Label className="text-sm text-gray-600">淘宝会员名</Label>
|
||||
<Controller
|
||||
name="taobao"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Input {...field} className="h-11 bg-gray-50 border-gray-200" />
|
||||
)}
|
||||
/>
|
||||
</div> */}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm text-gray-600">邮箱</Label>
|
||||
<Controller
|
||||
@@ -163,41 +146,12 @@ export function EditInfoDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm text-gray-600">微信</Label>
|
||||
<Controller
|
||||
name="wechat"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="请输入"
|
||||
className="h-11 bg-gray-50 border-gray-200"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm text-gray-600">QQ</Label>
|
||||
<Controller
|
||||
name="qq"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="请输入"
|
||||
className="h-11 bg-gray-50 border-gray-200"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full h-11 mt-2 bg-blue-600 hover:bg-blue-700 text-white font-medium"
|
||||
>
|
||||
保存
|
||||
{submitting ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
|
||||
232
src/components/composites/dialogs/realNameDialog.tsx
Normal file
232
src/components/composites/dialogs/realNameDialog.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
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,
|
||||
mandatory = false,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onVerified: () => void
|
||||
/** 强制模式:不可关闭,直到完成实名认证(由外层 Gate 在认证成功后卸载) */
|
||||
mandatory?: boolean
|
||||
}) {
|
||||
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) => {
|
||||
if (!next && mandatory) return
|
||||
onOpenChange(next)
|
||||
if (!next) {
|
||||
form.reset({ name: "", idCode: "" })
|
||||
setStage("form")
|
||||
setQrUrl("")
|
||||
}
|
||||
}
|
||||
|
||||
const handleFinish = () => {
|
||||
onVerified()
|
||||
if (!mandatory) 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("该身份已经进行认证,无法再次进行认证,请联系在线客服处理")
|
||||
return
|
||||
}
|
||||
if (code === -10000) {
|
||||
toast.error("接口问题,请联系客服")
|
||||
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"
|
||||
showCloseButton={!mandatory}
|
||||
onEscapeKeyDown={mandatory ? e => e.preventDefault() : undefined}
|
||||
onPointerDownOutside={mandatory ? e => e.preventDefault() : undefined}
|
||||
>
|
||||
{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">
|
||||
{!mandatory && (
|
||||
<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">
|
||||
{!mandatory && (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
292
src/components/composites/dialogs/rechargeDialog.tsx
Normal file
292
src/components/composites/dialogs/rechargeDialog.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import { CheckCircle2, Loader2, XCircle } from "lucide-react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
checkRechargeStatus,
|
||||
createRechargeOrder,
|
||||
type RechargeOrderResult,
|
||||
} from "@/api/order"
|
||||
import { QrCodeImage } from "@/components/qrCodeImage"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import alipay from "@/home/_assets/alipay.svg"
|
||||
import wechat from "@/home/_assets/wechat.svg"
|
||||
|
||||
type PayChannel = "50" | "30"
|
||||
|
||||
const POLL_INTERVAL = 3000
|
||||
|
||||
interface RechargeDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
// 支付成功后回调(刷新余额等)
|
||||
onPaid?: () => void
|
||||
}
|
||||
|
||||
// 余额充值(与官网 juipnet User/Index.cshtml 一致):
|
||||
// - 下单 POST /user/CreateOrder
|
||||
// - 支付宝(50):PayData 为自动提交表单,渲染后自动跳转支付宝网关
|
||||
// - 微信(30):PayData 为扫码链接,直接渲染二维码,轮询 /user/IsPay
|
||||
export function RechargeDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onPaid,
|
||||
}: RechargeDialogProps) {
|
||||
const [amount, setAmount] = useState("100")
|
||||
const [payChannel, setPayChannel] = useState<PayChannel>("50")
|
||||
|
||||
const [order, setOrder] = useState<RechargeOrderResult | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [orderError, setOrderError] = useState("")
|
||||
const [paid, setPaid] = useState(false)
|
||||
const [failCount, setFailCount] = useState(0)
|
||||
|
||||
const alipayBoxRef = useRef<HTMLDivElement>(null)
|
||||
const alipaySubmittedRef = useRef(false)
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setAmount("100")
|
||||
setPayChannel("50")
|
||||
setOrder(null)
|
||||
setCreating(false)
|
||||
setOrderError("")
|
||||
setPaid(false)
|
||||
setFailCount(0)
|
||||
alipaySubmittedRef.current = false
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) reset()
|
||||
}, [open, reset])
|
||||
|
||||
const handleGoPay = async () => {
|
||||
const chargeAmount = Number(amount)
|
||||
if (!Number.isFinite(chargeAmount) || chargeAmount <= 0) {
|
||||
toast.error("请输入正确的充值金额")
|
||||
return
|
||||
}
|
||||
|
||||
setCreating(true)
|
||||
setOrderError("")
|
||||
const res = await createRechargeOrder({
|
||||
ChargeAmount: chargeAmount,
|
||||
PayChannel: Number(payChannel),
|
||||
})
|
||||
setCreating(false)
|
||||
if (!res.success) {
|
||||
setOrderError(res.message)
|
||||
return
|
||||
}
|
||||
if (!res.data?.PayData) {
|
||||
setOrderError("下单失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
setOrder(res.data)
|
||||
}
|
||||
|
||||
// 支付宝:渲染 PayData 表单并自动提交,跳转支付宝网关
|
||||
useEffect(() => {
|
||||
if (!open || payChannel !== "50" || !order || orderError) return
|
||||
if (alipaySubmittedRef.current) return
|
||||
alipaySubmittedRef.current = true
|
||||
const box = alipayBoxRef.current
|
||||
if (!box) return
|
||||
box.innerHTML = order.PayData
|
||||
box.querySelector("form")?.submit()
|
||||
}, [open, payChannel, order, orderError])
|
||||
|
||||
// 微信:轮询 /user/IsPay 直到支付成功
|
||||
useEffect(() => {
|
||||
if (!open || payChannel !== "30" || !order || paid) return
|
||||
let stopped = false
|
||||
|
||||
const poll = async () => {
|
||||
const res = await checkRechargeStatus(order.OrderInfo.OrderNo)
|
||||
if (stopped) return
|
||||
if (res.success && res.data) {
|
||||
setPaid(true)
|
||||
toast.success("充值成功,余额已更新")
|
||||
onPaid?.()
|
||||
} else if (!res.success) {
|
||||
setFailCount(count => count + 1)
|
||||
}
|
||||
}
|
||||
|
||||
poll()
|
||||
const timer = window.setInterval(poll, POLL_INTERVAL)
|
||||
return () => {
|
||||
stopped = true
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [open, payChannel, order, paid, onPaid])
|
||||
|
||||
const isWechat = payChannel === "30"
|
||||
|
||||
const renderBody = () => {
|
||||
if (creating) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-10">
|
||||
<Loader2 className="size-8 animate-spin text-blue-500" />
|
||||
<p className="text-sm text-gray-500">正在生成订单...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (orderError) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 py-6">
|
||||
<XCircle className="size-10 text-red-400" />
|
||||
<p className="text-sm text-red-500">{orderError}</p>
|
||||
<Button variant="outline" className="w-full" onClick={reset}>
|
||||
重新发起
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (order && !isWechat) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-10">
|
||||
<Loader2 className="size-8 animate-spin text-blue-500" />
|
||||
<p className="text-sm text-gray-500">正在跳转支付宝支付...</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
如未自动跳转,请检查浏览器是否拦截
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (order && isWechat) {
|
||||
if (paid) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 py-6">
|
||||
<CheckCircle2 className="size-12 text-emerald-500" />
|
||||
<p className="font-medium text-emerald-600">支付成功</p>
|
||||
<p className="text-xs text-gray-500">余额已更新</p>
|
||||
<Button className="w-full" onClick={() => onOpenChange(false)}>
|
||||
完成
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4 py-1">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<img src={wechat} alt="微信支付" className="size-6" />
|
||||
<span className="font-semibold">微信支付 | 收银台</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 rounded-lg bg-gray-50 p-4 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">应付金额</span>
|
||||
<span className="font-bold text-orange-500">
|
||||
¥{Number(order.OrderInfo.PaymentAmount ?? amount).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">收款方</span>
|
||||
<span>聚IP</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">订单号</span>
|
||||
<span className="font-mono text-xs leading-5">
|
||||
{order.OrderInfo.OrderNo}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="rounded-lg border border-gray-200 p-2">
|
||||
<QrCodeImage text={order.PayData} size={200} />
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
请使用微信扫码支付,支付完成后自动确认
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-400">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
正在等待支付结果
|
||||
{failCount > 2 && "(网络波动,请稍候)"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 mt-1">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm text-gray-600">充值金额</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={amount}
|
||||
onChange={e => setAmount(e.target.value)}
|
||||
className="h-11 bg-gray-50 border-gray-200"
|
||||
placeholder="请输入充值金额"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-gray-600">支付方式</Label>
|
||||
<RadioGroup
|
||||
value={payChannel}
|
||||
onValueChange={value => setPayChannel(value as PayChannel)}
|
||||
className="flex gap-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="50" id="recharge-alipay" />
|
||||
<Label
|
||||
htmlFor="recharge-alipay"
|
||||
className="cursor-pointer text-sm text-gray-700 flex items-center gap-1.5"
|
||||
>
|
||||
<img src={alipay} alt="支付宝" className="w-5 h-5" />
|
||||
支付宝
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="30" id="recharge-wechat" />
|
||||
<Label
|
||||
htmlFor="recharge-wechat"
|
||||
className="cursor-pointer text-sm text-gray-700 flex items-center gap-1.5"
|
||||
>
|
||||
<img src={wechat} alt="微信" className="w-5 h-5" />
|
||||
微信
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleGoPay}
|
||||
disabled={creating}
|
||||
className="w-full h-11 bg-blue-600 hover:bg-blue-700 text-white font-medium"
|
||||
>
|
||||
充值
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-center">账户充值</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{renderBody()}
|
||||
|
||||
<div ref={alipayBoxRef} className="hidden" />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
138
src/components/composites/orderFilterBar.tsx
Normal file
138
src/components/composites/orderFilterBar.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { SearchIcon } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
ORDER_TYPE_LABELS,
|
||||
type OrderFilters,
|
||||
PACKAGE_OPTIONS,
|
||||
} from "@/lib/models/order"
|
||||
import type { ProductItem } from "@/lib/models/product"
|
||||
|
||||
// 订单管理/退货订单共用的筛选栏
|
||||
export default function OrderFilterBar(props: {
|
||||
filters: OrderFilters
|
||||
products: ProductItem[]
|
||||
showOrderType?: boolean
|
||||
showPackageName?: boolean
|
||||
onPatch: (patch: Partial<OrderFilters>) => void
|
||||
onSearch: () => void
|
||||
onReset?: () => void
|
||||
}) {
|
||||
const {
|
||||
filters,
|
||||
products,
|
||||
showOrderType = true,
|
||||
showPackageName = true,
|
||||
onPatch,
|
||||
onSearch,
|
||||
onReset,
|
||||
} = props
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg p-4 flex flex-wrap items-end gap-x-4 gap-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500 whitespace-nowrap">
|
||||
日期筛选:
|
||||
</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.bTime}
|
||||
onChange={e => onPatch({ bTime: e.target.value })}
|
||||
className="w-36"
|
||||
aria-label="开始时间"
|
||||
/>
|
||||
<span className="text-sm text-gray-400">至</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.eTime}
|
||||
onChange={e => onPatch({ eTime: e.target.value })}
|
||||
className="w-36"
|
||||
aria-label="结束时间"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showOrderType && (
|
||||
<Select
|
||||
value={filters.orderType}
|
||||
onValueChange={value => onPatch({ orderType: value })}
|
||||
>
|
||||
<SelectTrigger className="w-30">
|
||||
<SelectValue placeholder="全部类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">全部类型</SelectItem>
|
||||
{Object.entries(ORDER_TYPE_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<Select
|
||||
value={filters.productId}
|
||||
onValueChange={value => onPatch({ productId: value })}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue placeholder="全部产品" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">全部产品</SelectItem>
|
||||
{products.map(item => (
|
||||
<SelectItem key={item.Product.Id} value={String(item.Product.Id)}>
|
||||
{item.Product.Name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{showPackageName && (
|
||||
<Select
|
||||
value={filters.packageName}
|
||||
onValueChange={value => onPatch({ packageName: value })}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue placeholder="全部套餐" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">全部套餐</SelectItem>
|
||||
{PACKAGE_OPTIONS.map(pkg => (
|
||||
<SelectItem key={pkg} value={pkg}>
|
||||
{pkg}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={filters.keyword}
|
||||
onChange={e => onPatch({ keyword: e.target.value })}
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Enter") onSearch()
|
||||
}}
|
||||
placeholder="账号"
|
||||
className="w-40"
|
||||
/>
|
||||
<Button onClick={onSearch}>
|
||||
<SearchIcon data-icon="inline-start" />
|
||||
查询
|
||||
</Button>
|
||||
{onReset && (
|
||||
<Button variant="outline" onClick={onReset}>
|
||||
重置
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -95,7 +95,8 @@ export default function DataTable<T extends Record<string, unknown>>(
|
||||
},
|
||||
state: {
|
||||
pagination: {
|
||||
pageIndex: props.pagination?.page ?? 0,
|
||||
// Pagination 组件页码为 1 起始,tanstack 内部为 0 起始
|
||||
pageIndex: (props.pagination?.page ?? 1) - 1,
|
||||
pageSize: props.pagination?.size ?? props.data.length,
|
||||
},
|
||||
columnFilters: [],
|
||||
|
||||
@@ -1,22 +1,11 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { NavLink, useNavigate } from "react-router-dom"
|
||||
import { logout } from "@/api/auth"
|
||||
import { AUTH_EVENT, isAuthed } from "@/api/token"
|
||||
import { useProfileStore } from "@/components/stores/profile"
|
||||
|
||||
export default function ProfileOrLogin() {
|
||||
const [authed, setAuthed] = useState(isAuthed)
|
||||
const authed = useProfileStore(state => state.token !== null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => setAuthed(isAuthed())
|
||||
window.addEventListener("focus", update)
|
||||
window.addEventListener(AUTH_EVENT, update)
|
||||
return () => {
|
||||
window.removeEventListener("focus", update)
|
||||
window.removeEventListener(AUTH_EVENT, update)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
}
|
||||
|
||||
48
src/components/qrCodeImage.tsx
Normal file
48
src/components/qrCodeImage.tsx
Normal 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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
31
src/components/realNameCheck.tsx
Normal file
31
src/components/realNameCheck.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useEffect } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { toast } from "sonner"
|
||||
import { useDashboardStore } from "@/components/stores/dashboard"
|
||||
import { useProfileStore } from "@/components/stores/profile"
|
||||
|
||||
// ==================== 购买/支付页面实名检查 ====================
|
||||
// 已登录但未实名的用户访问购买/支付页面时:提示并跳转个人中心完成实名认证。
|
||||
// 未登录用户不拦截(下单时走登录流程)。
|
||||
export default function RealNameCheck() {
|
||||
const navigate = useNavigate()
|
||||
const token = useProfileStore(state => state.token)
|
||||
const verified = useDashboardStore(state => state.verified)
|
||||
const data = useDashboardStore(state => state.data)
|
||||
const refresh = useDashboardStore(state => state.refresh)
|
||||
|
||||
// 无缓存时先拉取实名状态
|
||||
useEffect(() => {
|
||||
if (token && !data) refresh()
|
||||
}, [token, data, refresh])
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || verified !== false) return
|
||||
toast.error(
|
||||
"接主管部门要求,会员需要实名认证方可使用该产品,请先前往个人中心完成实名认证",
|
||||
)
|
||||
navigate("/admin", { replace: true })
|
||||
}, [token, verified, navigate])
|
||||
|
||||
return null
|
||||
}
|
||||
77
src/components/realNameGate.tsx
Normal file
77
src/components/realNameGate.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { type ReactNode, useEffect } from "react"
|
||||
import RealNameDialog from "@/components/composites/dialogs/realNameDialog"
|
||||
import { useDashboardStore } from "@/components/stores/dashboard"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
// ==================== 个人中心实名强制拦截 ====================
|
||||
// 未实名用户访问 /admin 任意页面时:屏蔽页面内容,强制弹出实名认证弹窗(不可关闭),
|
||||
// 认证成功后自动刷新状态并恢复页面展示。参考 juipnet 老项目做法。
|
||||
export default function RealNameGate({ children }: { children: ReactNode }) {
|
||||
const verified = useDashboardStore(state => state.verified)
|
||||
const data = useDashboardStore(state => state.data)
|
||||
const loading = useDashboardStore(state => state.loading)
|
||||
const error = useDashboardStore(state => state.error)
|
||||
const refresh = useDashboardStore(state => state.refresh)
|
||||
|
||||
// 无缓存数据时先拉取一次账户信息以获取实名状态
|
||||
useEffect(() => {
|
||||
if (!data) refresh()
|
||||
}, [data, refresh])
|
||||
|
||||
if (verified === true) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
if (verified === false) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<div className="w-full max-w-md rounded-xl bg-card p-6 text-center shadow-sm ring-1 ring-gray-100">
|
||||
<h3 className="text-lg font-semibold text-blue-600">
|
||||
尊敬的用户,您还未进行实名认证!
|
||||
</h3>
|
||||
<p className="mt-3 text-sm leading-6 text-gray-600">
|
||||
接主管部门要求,会员需要实名认证后方可访问个人中心及使用相关服务。
|
||||
请您在下方弹窗中完成实名认证,认证通过后将自动为您打开页面。
|
||||
</p>
|
||||
<p className="mt-2 text-xs leading-5 text-gray-400">
|
||||
您的个人信息仅与国家公安一所信息库比对,本公司不调用及储存您的实人认证数据。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<RealNameDialog
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
onVerified={refresh}
|
||||
mandatory
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// verified === null:状态未知(加载中或加载失败)
|
||||
if (loading || (!data && !error)) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-gray-400">
|
||||
<Loader2 className="size-8 animate-spin text-blue-500" />
|
||||
<p className="text-sm">正在加载账户信息...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<div className="w-full max-w-md rounded-xl bg-card p-6 text-center shadow-sm ring-1 ring-gray-100">
|
||||
<h3 className="text-lg font-semibold">账户信息加载失败</h3>
|
||||
<p className="mt-2 text-sm text-gray-500">{error || "请稍后重试"}</p>
|
||||
<Button
|
||||
className="mt-4 bg-blue-600 text-white hover:bg-blue-700"
|
||||
onClick={refresh}
|
||||
>
|
||||
重新加载
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
src/components/stores/app.ts
Normal file
11
src/components/stores/app.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { create } from "zustand"
|
||||
import { API_BASE_URL } from "@/lib/api"
|
||||
|
||||
// ==================== 应用配置 ====================
|
||||
// 后端地址等运行时配置统一从 store 读取,初始值来自 import.meta.env(见 lib/api.ts)
|
||||
|
||||
type AppState = {
|
||||
apiUrl: string
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>()(() => ({ apiUrl: API_BASE_URL }))
|
||||
53
src/components/stores/client.ts
Normal file
53
src/components/stores/client.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { create } from "zustand"
|
||||
|
||||
type ClientState = {
|
||||
breakpoint: {
|
||||
sm: boolean
|
||||
md: boolean
|
||||
lg: boolean
|
||||
xl: boolean
|
||||
}
|
||||
}
|
||||
|
||||
type ClientActions = {
|
||||
setBreakpoints: (breakpoints: Partial<ClientState["breakpoint"]>) => void
|
||||
}
|
||||
|
||||
export type ClientStore = ClientState & ClientActions
|
||||
|
||||
export const useClientStore = create<ClientStore>()(set => ({
|
||||
breakpoint: { sm: false, md: false, lg: false, xl: false },
|
||||
setBreakpoints: breakpoints =>
|
||||
set(state => ({
|
||||
breakpoint: { ...state.breakpoint, ...breakpoints },
|
||||
})),
|
||||
}))
|
||||
|
||||
let initialized = false
|
||||
|
||||
// 应用入口调用一次,开始监听窗口断点变化
|
||||
export function initClientStore() {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
|
||||
const queries = {
|
||||
sm: window.matchMedia("(width >= 40rem)"),
|
||||
md: window.matchMedia("(width >= 48rem)"),
|
||||
lg: window.matchMedia("(width >= 64rem)"),
|
||||
xl: window.matchMedia("(width >= 80rem)"),
|
||||
}
|
||||
const setBreakpoints = useClientStore.getState().setBreakpoints
|
||||
|
||||
setBreakpoints({
|
||||
sm: queries.sm.matches,
|
||||
md: queries.md.matches,
|
||||
lg: queries.lg.matches,
|
||||
xl: queries.xl.matches,
|
||||
})
|
||||
|
||||
for (const key of Object.keys(queries) as (keyof typeof queries)[]) {
|
||||
queries[key].addEventListener("change", e => {
|
||||
setBreakpoints({ [key]: e.matches })
|
||||
})
|
||||
}
|
||||
}
|
||||
48
src/components/stores/dashboard.ts
Normal file
48
src/components/stores/dashboard.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { create } from "zustand"
|
||||
import { fetchAgreementStatus } from "@/api/agreement"
|
||||
import { fetchApiIndex } from "@/api/dashboard"
|
||||
import type { ApiIndexItem } from "@/lib/models/dashboard"
|
||||
|
||||
// ==================== 个人中心账户总览 ====================
|
||||
// 数据源:.NET /user/ApiIndex;协议签署状态:php-api /tencent/index/get_is_upload
|
||||
|
||||
export type DashboardState = {
|
||||
data: ApiIndexItem | null
|
||||
agreementSigned: boolean | null
|
||||
verified: boolean | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
type DashboardActions = {
|
||||
refresh: () => Promise<void>
|
||||
refreshAgreement: () => Promise<void>
|
||||
}
|
||||
|
||||
export type DashboardStore = DashboardState & DashboardActions
|
||||
|
||||
export const useDashboardStore = create<DashboardStore>()(set => ({
|
||||
data: null,
|
||||
agreementSigned: null,
|
||||
verified: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: async () => {
|
||||
set({ loading: true })
|
||||
const res = await fetchApiIndex()
|
||||
if (res.success) {
|
||||
set({
|
||||
data: res.data,
|
||||
verified: res.data ? res.data.UserModel?.is_verify === 1 : null,
|
||||
error: null,
|
||||
loading: false,
|
||||
})
|
||||
} else {
|
||||
set({ error: res.message, loading: false })
|
||||
}
|
||||
},
|
||||
refreshAgreement: async () => {
|
||||
const res = await fetchAgreementStatus()
|
||||
if (res.success) set({ agreementSigned: res.data })
|
||||
},
|
||||
}))
|
||||
18
src/components/stores/layout.ts
Normal file
18
src/components/stores/layout.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { create } from "zustand"
|
||||
|
||||
type LayoutState = {
|
||||
navbar: boolean
|
||||
}
|
||||
|
||||
type LayoutActions = {
|
||||
setNavbar: (navbar: boolean) => void
|
||||
toggleNavbar: () => void
|
||||
}
|
||||
|
||||
export type LayoutStore = LayoutState & LayoutActions
|
||||
|
||||
export const useLayoutStore = create<LayoutStore>()(set => ({
|
||||
navbar: false,
|
||||
setNavbar: navbar => set({ navbar }),
|
||||
toggleNavbar: () => set(state => ({ navbar: !state.navbar })),
|
||||
}))
|
||||
33
src/components/stores/profile.ts
Normal file
33
src/components/stores/profile.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { create } from "zustand"
|
||||
import {
|
||||
AUTH_EVENT,
|
||||
getUserInfo,
|
||||
getUserToken,
|
||||
type UserInfoRecord,
|
||||
} from "@/api/token"
|
||||
|
||||
export type ProfileState = {
|
||||
token: string | null
|
||||
userInfo: UserInfoRecord | null
|
||||
}
|
||||
|
||||
type ProfileActions = {
|
||||
refreshFromStorage: () => void
|
||||
}
|
||||
|
||||
export type ProfileStore = ProfileState & ProfileActions
|
||||
|
||||
export const useProfileStore = create<ProfileStore>()(set => ({
|
||||
token: getUserToken(),
|
||||
userInfo: getUserInfo<UserInfoRecord>(),
|
||||
refreshFromStorage: () => {
|
||||
set({ token: getUserToken(), userInfo: getUserInfo<UserInfoRecord>() })
|
||||
},
|
||||
}))
|
||||
|
||||
// 应用入口调用一次,监听登录态变化(含跨标签页 focus 同步)
|
||||
export function initProfileStore() {
|
||||
const sync = () => useProfileStore.getState().refreshFromStorage()
|
||||
window.addEventListener(AUTH_EVENT, sync)
|
||||
window.addEventListener("focus", sync)
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
@@ -19,6 +17,7 @@ import {
|
||||
} from "./select"
|
||||
|
||||
export interface PaginationProps {
|
||||
/** 当前页码,1 起始;onPageChange 回调参数同样为 1 起始页码 */
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
@@ -38,7 +37,8 @@ function Pagination({
|
||||
className,
|
||||
}: PaginationProps) {
|
||||
const [currentPage, setCurrentPage] = useState(page)
|
||||
const totalPages = Math.ceil(total / size)
|
||||
// 无数据时兜底为 1 页,避免除零与空分页器
|
||||
const totalPages = size > 0 ? Math.max(Math.ceil(total / size), 1) : 1
|
||||
|
||||
// 同步外部 page 变化
|
||||
useEffect(() => {
|
||||
@@ -121,7 +121,7 @@ function Pagination({
|
||||
const paginationItems = generatePaginationItems()
|
||||
|
||||
return (
|
||||
<div className={`flex flex-wrap items-center gap-4 ${className || ""}`}>
|
||||
<div className={cn("flex flex-wrap items-center gap-4", className)}>
|
||||
<div className="flex-none flex items-center gap-2 text-sm text-muted-foreground">
|
||||
共 {total} 条记录,每页
|
||||
<Select value={size.toString()} onValueChange={handlePageSizeChange}>
|
||||
@@ -193,7 +193,6 @@ function PaginationLayout({
|
||||
}: React.ComponentProps<"nav">) {
|
||||
return (
|
||||
<nav
|
||||
// role="navigation"
|
||||
aria-label="pagination"
|
||||
data-slot="pagination"
|
||||
className={cn("flex-none", className)}
|
||||
|
||||
@@ -141,6 +141,14 @@ export default function Common() {
|
||||
{/* 拖拽条*/}
|
||||
<div
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setIsExpanded(!isExpanded);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="flex flex-col items-center justify-center py-2.5 cursor-pointer hover:bg-gray-50 transition-colors rounded-t-2xl"
|
||||
>
|
||||
<div className="w-10 h-1 bg-gray-300 rounded-full" />
|
||||
@@ -167,6 +175,14 @@ export default function Common() {
|
||||
{/* QQ */}
|
||||
<div
|
||||
onClick={handleQQClick}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
handleQQClick();
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="flex items-center gap-4 p-3 bg-blue-50 rounded-xl hover:bg-blue-100 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-blue-500 flex items-center justify-center">
|
||||
@@ -185,6 +201,14 @@ export default function Common() {
|
||||
{/* 微信 */}
|
||||
<div
|
||||
onClick={handleWxClick}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
handleWxClick();
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="flex items-center gap-4 p-3 bg-green-50 rounded-xl hover:bg-green-100 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-green-500 flex items-center justify-center">
|
||||
@@ -205,6 +229,14 @@ export default function Common() {
|
||||
onClick={() =>
|
||||
setActivePopup(activePopup === "gzh" ? null : "gzh")
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setActivePopup(activePopup === "gzh" ? null : "gzh");
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="flex items-center gap-4 p-3 bg-purple-50 rounded-xl hover:bg-purple-100 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-purple-500 flex items-center justify-center">
|
||||
@@ -248,6 +280,14 @@ export default function Common() {
|
||||
onClick={() =>
|
||||
setActivePopup(activePopup === "tel" ? null : "tel")
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setActivePopup(activePopup === "tel" ? null : "tel");
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="flex items-center gap-4 p-3 bg-orange-50 rounded-xl hover:bg-orange-100 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-orange-500 flex items-center justify-center">
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { Menu, User } from "lucide-react"
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react"
|
||||
import { useRef, useState, useSyncExternalStore } from "react"
|
||||
import { NavLink, useNavigate } from "react-router-dom"
|
||||
import { AUTH_EVENT, isAuthed } from "@/api/token"
|
||||
import ProductDropdown from "@/components/productDropdown"
|
||||
import ProfileOrLogin from "@/components/profileOrLogin"
|
||||
import { useClientStore } from "@/components/stores/client"
|
||||
import { useProfileStore } from "@/components/stores/profile"
|
||||
import Wrap from "@/components/wrap"
|
||||
import { useMediaQuery } from "@/lib/hooks/useMediaQuery"
|
||||
import { cn } from "@/lib/utils"
|
||||
import MobileMenu from "./_components/header/menu-mobile"
|
||||
|
||||
export default function Header() {
|
||||
const navigate = useNavigate()
|
||||
const isWideDesktop = useMediaQuery("(min-width: 80rem)")
|
||||
const authed = useProfileStore(state => state.token !== null)
|
||||
const isWideDesktop = useClientStore(state => state.breakpoint.xl)
|
||||
const scroll = useSyncExternalStore(
|
||||
callback => {
|
||||
window.addEventListener("scroll", callback)
|
||||
@@ -30,17 +31,6 @@ export default function Header() {
|
||||
|
||||
// 移动端状态
|
||||
const [mobileMenu, setMobileMenu] = useState(false)
|
||||
const [authed, setAuthed] = useState(isAuthed)
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => setAuthed(isAuthed())
|
||||
window.addEventListener("focus", update)
|
||||
window.addEventListener(AUTH_EVENT, update)
|
||||
return () => {
|
||||
window.removeEventListener("focus", update)
|
||||
window.removeEventListener(AUTH_EVENT, update)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<header className={cn("fixed top-0 left-0 w-screen z-50 flex flex-col")}>
|
||||
@@ -53,7 +43,7 @@ export default function Header() {
|
||||
: `bg-transparent shadow-none`,
|
||||
)}
|
||||
>
|
||||
<Wrap className="h-20 max-md:h-20 flex text-center justify-between max-md:pl-4 max-md:pr-4">
|
||||
<Wrap className="h-20 max-md:h-20 flex text-center justify-between max-md:pl-4 max-md:pr-4 md:bg-transparent bg-white ">
|
||||
<nav className="flex items-center gap-4 lg:gap-8">
|
||||
{/* logo + 网站名称 */}
|
||||
<NavLink to="/" className="flex items-center gap-2 shrink-0">
|
||||
@@ -156,7 +146,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) {
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function HelpPage() {
|
||||
}
|
||||
setArticles(transformArticles(res.data.List ?? []))
|
||||
}
|
||||
void load()
|
||||
load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import type { ReactNode } from "react"
|
||||
import { useRef, useState } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { useClientStore } from "@/components/stores/client"
|
||||
import Wrap from "@/components/wrap"
|
||||
import { useIsMobile } from "@/lib/hooks/useMediaQuery"
|
||||
import banner from "./_assets/banner.webp"
|
||||
import s1 from "./_assets/s1.webp"
|
||||
import s2 from "./_assets/s2.webp"
|
||||
@@ -22,14 +22,15 @@ import Common from "./_components/header/common"
|
||||
export default function HomePage() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
// 免费试用:无论是否登录都进入产品购买页(下单时才校验登录)
|
||||
const handleFreeTrial = () => {
|
||||
navigate("/login")
|
||||
navigate("/product")
|
||||
}
|
||||
|
||||
const scrollContainerRef = useRef<HTMLUListElement>(null)
|
||||
const [showLeftArrow, setShowLeftArrow] = useState(false)
|
||||
const [showRightArrow, setShowRightArrow] = useState(true)
|
||||
const isMobile = useIsMobile()
|
||||
const isMobile = useClientStore(state => !state.breakpoint.md)
|
||||
|
||||
const scroll = (direction: "left" | "right") => {
|
||||
if (!scrollContainerRef.current) return
|
||||
|
||||
@@ -1,37 +1,18 @@
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { Toaster, toast } from "sonner"
|
||||
import {
|
||||
fetchLineData,
|
||||
type LineItem,
|
||||
PHP_API_BASE_URL,
|
||||
searchLineData,
|
||||
} from "@/api/linedata"
|
||||
import { PHP_API_BASE_URL } from "@/api/http"
|
||||
import { fetchLineData, searchLineData } from "@/api/linedata"
|
||||
import DataTable from "@/components/data-table"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import Wrap from "@/components/wrap"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { LineItem } from "@/lib/models/line"
|
||||
import { PRODUCTS } from "@/lib/models/line"
|
||||
import { cn, downloadCsv } from "@/lib/utils"
|
||||
|
||||
const REFRESH_INTERVAL = 5 * 60 * 1000
|
||||
const PAGE_SIZE_OPTIONS = [100, 200, 500]
|
||||
|
||||
type ProductOption = {
|
||||
id: number
|
||||
name: string
|
||||
l2tp: string
|
||||
sstp: string
|
||||
}
|
||||
|
||||
const PRODUCTS: ProductOption[] = [
|
||||
{ id: 29, name: "极狐IP", l2tp: "8899", sstp: "4430" },
|
||||
{ id: 3, name: "极光IP", l2tp: "1234", sstp: "4430" },
|
||||
// { id: 18, name: "蘑菇IP", l2tp: "8899", sstp: "4430" },
|
||||
{ id: 27, name: "麒麟IP", l2tp: "123", sstp: "4432" },
|
||||
{ id: 26, name: "猎豹IP", l2tp: "1234", sstp: "4430" },
|
||||
{ id: 28, name: "水滴独享IP", l2tp: "123", sstp: "4432" },
|
||||
{ id: 22, name: "火狐静态IP", l2tp: "888888", sstp: "5908" },
|
||||
]
|
||||
|
||||
function toText(value: string | number): string {
|
||||
return value === null || value === undefined ? "" : String(value)
|
||||
}
|
||||
@@ -120,7 +101,6 @@ export default function IpLinePage() {
|
||||
setPageIndex(0)
|
||||
const seq = ++requestSeq.current
|
||||
const result = await searchLineData(productId ?? 0, info)
|
||||
console.log("搜索的result", result)
|
||||
|
||||
if (seq !== requestSeq.current) return
|
||||
if (result.success) {
|
||||
@@ -154,29 +134,15 @@ export default function IpLinePage() {
|
||||
"带宽",
|
||||
"服务器状态",
|
||||
]
|
||||
const lines = [
|
||||
header,
|
||||
...rows.map(r => [
|
||||
toText(r.name),
|
||||
toText(r.city),
|
||||
toText(r.supply),
|
||||
toText(r.nasname),
|
||||
toText(r.daikuan),
|
||||
toText(r.online),
|
||||
]),
|
||||
]
|
||||
const csv = lines
|
||||
.map(line => line.map(v => `"${v.replaceAll('"', '""')}"`).join(","))
|
||||
.join("\r\n")
|
||||
const blob = new Blob([`\uFEFF${csv}`], {
|
||||
type: "text/csv;charset=utf-8",
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `${currentName || "IP线路表"}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
const data = rows.map(r => [
|
||||
toText(r.name),
|
||||
toText(r.city),
|
||||
toText(r.supply),
|
||||
toText(r.nasname),
|
||||
toText(r.daikuan),
|
||||
toText(r.online),
|
||||
])
|
||||
downloadCsv(`${currentName || "IP线路表"}.csv`, header, data)
|
||||
toast.success("导出成功")
|
||||
}
|
||||
|
||||
@@ -193,13 +159,13 @@ export default function IpLinePage() {
|
||||
return (
|
||||
<>
|
||||
<Toaster position="top-center" richColors />
|
||||
<Wrap className="flex flex-col gap-4 mt-20 max-w-6xl mx-auto px-4 pb-20 bg-white">
|
||||
<div className="grid grid-cols-3 mt-3">
|
||||
<Wrap className="flex flex-col gap-4 mt-24 max-w-6xl mx-auto px-4 pb-20 bg-white">
|
||||
<div className="grid md:grid-cols-3 grid-cols-1 mt-3">
|
||||
<div></div>
|
||||
<h3 className="text-3xl font-bold text-gray-800 text-center">
|
||||
<h3 className="text-2xl md:text-3xl font-bold text-gray-800 text-center">
|
||||
IP线路表
|
||||
</h3>
|
||||
<div className="flex gap-4 text-sm justify-end items-end text-blue-500">
|
||||
<div className="flex gap-4 text-sm md:justify-end justify-center md:p-0 pt-3 items-end text-blue-500">
|
||||
<a
|
||||
href="/softDownload"
|
||||
className="no-underline hover:text-blue-700 active:no-underline focus:no-underline"
|
||||
@@ -215,7 +181,7 @@ export default function IpLinePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="flex justify-between gap-4 text-xs text-gray-600 px-2">
|
||||
<ul className="flex flex-col md:flex-row md:justify-between gap-2 md:gap-4 text-xs text-gray-600 px-2">
|
||||
<li className="flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
|
||||
线路表和账户必须为同一产品才能使用
|
||||
@@ -237,7 +203,7 @@ export default function IpLinePage() {
|
||||
variant={p.id === productId ? "default" : "outline"}
|
||||
onClick={() => handleSelectProduct(p.id)}
|
||||
className={cn(
|
||||
"px-6 py-1.5 rounded-full",
|
||||
"px-4 md:px-6 py-1.5 rounded-full",
|
||||
p.id === productId
|
||||
? "text-white bg-linear-to-r from-cyan-400 to-blue-500 shadow-md shadow-blue-200 hover:opacity-90"
|
||||
: "border-gray-200 bg-white text-gray-600 hover:border-blue-400 hover:text-blue-500",
|
||||
@@ -248,8 +214,8 @@ export default function IpLinePage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-4 w-full">
|
||||
<div className="relative w-1/2">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-center gap-3 md:gap-4 w-full">
|
||||
<div className="relative w-full md:w-1/2">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
@@ -276,35 +242,37 @@ export default function IpLinePage() {
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-full focus:outline-none focus:ring-2 focus:ring-cyan-400 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSearch}
|
||||
className="px-8 rounded-full bg-linear-to-r from-cyan-400 to-blue-500 text-white shadow-md shadow-blue-200 hover:opacity-90"
|
||||
>
|
||||
搜索当前线路表
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleExport}
|
||||
className="gap-1.5 px-4 rounded border-blue-400 text-blue-500 bg-white hover:bg-blue-50 hover:text-blue-500"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
<div className="flex gap-3 w-full md:w-auto">
|
||||
<Button
|
||||
onClick={handleSearch}
|
||||
className="flex-1 md:flex-none px-8 rounded-full bg-linear-to-r from-cyan-400 to-blue-500 text-white shadow-md shadow-blue-200 hover:opacity-90"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
导出
|
||||
</Button>
|
||||
搜索当前线路表
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleExport}
|
||||
className="flex-none gap-1.5 px-4 rounded border-blue-400 text-blue-500 bg-white hover:bg-blue-50 hover:text-blue-500"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
导出
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-orange-50 border border-orange-100 rounded-lg p-2 flex justify-center items-center gap-8 text-sm font-medium">
|
||||
<div className="bg-orange-50 border border-orange-100 rounded-lg p-2 md:p-3 flex flex-col md:flex-row md:justify-center md:items-center gap-1 md:gap-8 text-sm font-medium">
|
||||
<div className="flex items-center gap-2 text-orange-500">
|
||||
<span>L2TP密钥:</span>
|
||||
<span className="text-orange-600">
|
||||
@@ -319,7 +287,7 @@ export default function IpLinePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6 text-xs text-gray-600 px-1">
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-2 md:gap-6 text-xs text-gray-600 px-1">
|
||||
{searching ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
|
||||
|
||||
@@ -92,9 +92,17 @@ export default function LoginPage() {
|
||||
|
||||
function LoginForm() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// 登录成功后回跳来源页(仅接受站内路径)
|
||||
const rawRedirect = new URLSearchParams(location.search).get("redirect")
|
||||
const redirect =
|
||||
rawRedirect?.startsWith("/") && !rawRedirect.startsWith("//")
|
||||
? rawRedirect
|
||||
: "/"
|
||||
|
||||
const form = useForm<LoginValues>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: {
|
||||
@@ -116,7 +124,7 @@ function LoginForm() {
|
||||
toast.success("登录成功", {
|
||||
description: "欢迎回来!",
|
||||
})
|
||||
navigate("/")
|
||||
navigate(redirect, { replace: true })
|
||||
} else {
|
||||
toast.error(resp.message || "登录失败")
|
||||
}
|
||||
@@ -174,9 +182,9 @@ function LoginForm() {
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOffIcon className="size-4" />
|
||||
) : (
|
||||
<EyeIcon className="size-4" />
|
||||
) : (
|
||||
<EyeOffIcon className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
276
src/home/pay/index.tsx
Normal file
276
src/home/pay/index.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { CheckCircle2, Loader2, XCircle } from "lucide-react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { checkPayStatus } from "@/api/order"
|
||||
import { QrCodeImage } from "@/components/qrCodeImage"
|
||||
import RealNameCheck from "@/components/realNameCheck"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { clearOrderPay, notifyPayPaid, readOrderPay } from "@/lib/recharge"
|
||||
import alipay from "../_assets/alipay.svg"
|
||||
import wechat from "../_assets/wechat.svg"
|
||||
|
||||
const POLL_INTERVAL = 3000
|
||||
|
||||
// 购买收银台(独立窗口):
|
||||
// 主页面下单成功后把支付数据写入 localStorage,本页读取并轮询 /product/IsPay
|
||||
// 参考 juipnet Host/Views/Product/buy.cshtml(payCallback/isPay)
|
||||
// (余额充值已改为个人中心页面内弹层,见 rechargeDialog.tsx)
|
||||
|
||||
type PollState = "paid" | "pending" | "error"
|
||||
|
||||
function usePayPolling(
|
||||
orderNo: string | undefined,
|
||||
checkPaid: (orderNo: string) => Promise<PollState>,
|
||||
) {
|
||||
const [paid, setPaid] = useState(false)
|
||||
const [failCount, setFailCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!orderNo) return
|
||||
let stopped = false
|
||||
|
||||
const poll = async () => {
|
||||
const state = await checkPaid(orderNo)
|
||||
if (stopped) return
|
||||
if (state === "paid") {
|
||||
setPaid(true)
|
||||
notifyPayPaid(orderNo)
|
||||
} else if (state === "error") {
|
||||
setFailCount(count => count + 1)
|
||||
}
|
||||
}
|
||||
|
||||
poll()
|
||||
const timer = window.setInterval(() => poll(), POLL_INTERVAL)
|
||||
return () => {
|
||||
stopped = true
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [orderNo, checkPaid])
|
||||
|
||||
return { paid, failCount }
|
||||
}
|
||||
|
||||
export default function PayPage() {
|
||||
return (
|
||||
<>
|
||||
<RealNameCheck />
|
||||
<OrderPay />
|
||||
</>
|
||||
)
|
||||
}
|
||||
// ==================== 购买模式 ====================
|
||||
|
||||
function OrderPay() {
|
||||
const [pay] = useState(readOrderPay)
|
||||
|
||||
const checkPaid = useCallback(async (orderNo: string): Promise<PollState> => {
|
||||
const res = await checkPayStatus(orderNo)
|
||||
if (!res.success) return "error"
|
||||
return res.data === 1 ? "paid" : "pending"
|
||||
}, [])
|
||||
|
||||
const { paid, failCount } = usePayPolling(pay?.orderNo, checkPaid)
|
||||
|
||||
useEffect(() => {
|
||||
if (paid) clearOrderPay()
|
||||
}, [paid])
|
||||
|
||||
return (
|
||||
<CashierLayout
|
||||
isWechat={pay?.payChannel === 30}
|
||||
amount={pay?.amount ?? 0}
|
||||
orderNo={pay?.orderNo}
|
||||
payData={pay?.payData ?? ""}
|
||||
paid={paid}
|
||||
loading={false}
|
||||
error=""
|
||||
failCount={failCount}
|
||||
invalid={!pay}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== 收银台界面 ====================
|
||||
|
||||
function CashierLayout(props: {
|
||||
isWechat: boolean
|
||||
amount: number
|
||||
orderNo?: string
|
||||
payData: string
|
||||
paid: boolean
|
||||
loading: boolean
|
||||
error: string
|
||||
failCount: number
|
||||
invalid: boolean
|
||||
}) {
|
||||
const {
|
||||
isWechat,
|
||||
amount,
|
||||
orderNo,
|
||||
payData,
|
||||
paid,
|
||||
loading,
|
||||
error,
|
||||
failCount,
|
||||
invalid,
|
||||
} = props
|
||||
|
||||
const payDataIsUrl = /^https?:\/\//i.test(payData)
|
||||
const payDataIsHtml = /^\s*</.test(payData)
|
||||
// 微信支付:PayData 是支付链接或自动提交表单时直接跳转,扫码内容才展示二维码
|
||||
const wechatShouldRedirect = isWechat && (payDataIsUrl || payDataIsHtml)
|
||||
|
||||
useEffect(() => {
|
||||
if (!wechatShouldRedirect || paid || error || loading || invalid) return
|
||||
if (payDataIsUrl) {
|
||||
window.location.href = payData
|
||||
} else {
|
||||
document.open()
|
||||
document.write(payData)
|
||||
document.close()
|
||||
}
|
||||
}, [
|
||||
wechatShouldRedirect,
|
||||
payDataIsUrl,
|
||||
payData,
|
||||
paid,
|
||||
error,
|
||||
loading,
|
||||
invalid,
|
||||
])
|
||||
|
||||
if (invalid) {
|
||||
return (
|
||||
<div className="min-h-dvh flex items-center justify-center bg-gray-50 p-4">
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl bg-white p-10 shadow-sm">
|
||||
<XCircle className="size-10 text-gray-300" />
|
||||
<p className="text-sm text-gray-500">支付信息已失效,请重新发起</p>
|
||||
<Button onClick={() => window.close()}>关闭页面</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const openAlipayWindow = () => {
|
||||
if (/^https?:\/\//i.test(payData)) {
|
||||
window.open(payData, "_blank", "noopener,noreferrer")
|
||||
return
|
||||
}
|
||||
const win = window.open("", "_blank")
|
||||
if (win) {
|
||||
win.document.open()
|
||||
win.document.write(payData)
|
||||
win.document.close()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh flex items-center justify-center bg-gray-50 p-4">
|
||||
<div className="w-full max-w-sm rounded-xl bg-white p-6 shadow-sm">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<img
|
||||
src={isWechat ? wechat : alipay}
|
||||
alt={isWechat ? "微信支付" : "支付宝"}
|
||||
className="size-6"
|
||||
/>
|
||||
<h1 className="text-lg font-semibold">
|
||||
{isWechat ? "微信支付" : "支付宝"} | 收银台
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-1 rounded-lg bg-gray-50 p-4 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">应付金额</span>
|
||||
<span className="font-bold text-orange-500">
|
||||
¥{Number(amount).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">收款方</span>
|
||||
<span>聚IP</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">订单号</span>
|
||||
<span className="font-mono text-xs leading-5">
|
||||
{orderNo ?? "--"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{paid ? (
|
||||
<div className="mt-6 flex flex-col items-center gap-2">
|
||||
<CheckCircle2 className="size-12 text-emerald-500" />
|
||||
<p className="font-medium text-emerald-600">支付成功</p>
|
||||
<p className="text-xs text-gray-500">订单已生效,可以关闭本页面</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="mt-6 flex flex-col items-center gap-2">
|
||||
<XCircle className="size-10 text-red-400" />
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
<p className="text-xs text-gray-500">请回到原页面重新发起</p>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="mt-6 flex flex-col items-center gap-2 text-gray-400">
|
||||
<Loader2 className="size-8 animate-spin" />
|
||||
<p className="text-xs">正在生成订单...</p>
|
||||
</div>
|
||||
) : isWechat ? (
|
||||
wechatShouldRedirect ? (
|
||||
<div className="mt-6 flex flex-col items-center gap-2">
|
||||
<Loader2 className="size-8 animate-spin text-emerald-500" />
|
||||
<p className="text-sm text-gray-500">正在跳转微信支付...</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
如未自动跳转,请检查浏览器是否拦截
|
||||
</p>
|
||||
<PollingHint failCount={failCount} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 flex flex-col items-center gap-3">
|
||||
{payData ? (
|
||||
<>
|
||||
<div className="rounded-lg border border-gray-200 p-2">
|
||||
<QrCodeImage text={payData} size={200} />
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
请使用微信扫码支付,支付完成后本页将自动更新
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="size-10 text-red-400" />
|
||||
<p className="text-sm text-red-500">
|
||||
未获取到支付二维码,请回到原页面重新发起
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<PollingHint failCount={failCount} />
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="mt-6 flex flex-col items-center gap-3">
|
||||
<Button
|
||||
className="w-full bg-blue-600 hover:bg-blue-700"
|
||||
onClick={openAlipayWindow}
|
||||
>
|
||||
前往支付宝支付
|
||||
</Button>
|
||||
<p className="text-xs text-gray-500">
|
||||
请在打开的页面完成支付,完成后回到本页等待自动确认
|
||||
</p>
|
||||
<PollingHint failCount={failCount} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PollingHint({ failCount }: { failCount: number }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-400">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
正在等待支付结果
|
||||
{failCount > 2 && "(网络波动,请稍候)"}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useLocation, useNavigate } from "react-router-dom"
|
||||
import { toast } from "sonner"
|
||||
import { type CreateOrderRequest, createOrder } from "@/api/order"
|
||||
import { createOrder, createTestAccount } from "@/api/order"
|
||||
import RealNameCheck from "@/components/realNameCheck"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
@@ -14,8 +15,9 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import Wrap from "@/components/wrap"
|
||||
import type { CreateOrderRequest } from "@/lib/models/order"
|
||||
import type { Product } from "@/lib/models/product"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { clearPayPaid, PAY_PAID_KEY, saveOrderPay } from "@/lib/recharge"
|
||||
import alipay from "../_assets/alipay.svg"
|
||||
import balance from "../_assets/balance.svg"
|
||||
import wechat from "../_assets/wechat.svg"
|
||||
@@ -38,42 +40,6 @@ function randomDigits(length: number): string {
|
||||
return result
|
||||
}
|
||||
|
||||
// ============ 模式切换 Tab ============
|
||||
function ModeTabs({
|
||||
mode,
|
||||
onModeChange,
|
||||
}: {
|
||||
mode: "single" | "batch"
|
||||
onModeChange: (mode: "single" | "batch") => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex bg-gray-50 rounded-xl p-1 w-48">
|
||||
<button
|
||||
className={cn(
|
||||
"flex-1 py-2 text-sm font-medium rounded-lg transition-all cursor-pointer",
|
||||
mode === "single"
|
||||
? "bg-white text-blue-600 shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-700",
|
||||
)}
|
||||
onClick={() => onModeChange("single")}
|
||||
>
|
||||
单个注册
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"flex-1 py-2 text-sm font-medium rounded-lg transition-all cursor-pointer",
|
||||
mode === "batch"
|
||||
? "bg-white text-blue-600 shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-700",
|
||||
)}
|
||||
onClick={() => onModeChange("batch")}
|
||||
>
|
||||
批量注册
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ 连接设备数选择器 ============
|
||||
function ConnectCountSelector({
|
||||
value,
|
||||
@@ -89,11 +55,11 @@ function ConnectCountSelector({
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 whitespace-nowrap w-28">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
{label}:
|
||||
</Label>
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="flex items-center gap-4 w-full md:w-auto md:flex-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
@@ -156,44 +122,19 @@ function ConnectCountSelector({
|
||||
// ============ 优惠券选择器 ============
|
||||
function CouponSelector() {
|
||||
return (
|
||||
<div className="flex items-center gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 whitespace-nowrap w-28">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
选择优惠券:
|
||||
</Label>
|
||||
<Select>
|
||||
<SelectTrigger className="h-10 rounded-xl border-gray-200 flex-1 max-w-xs">
|
||||
<SelectTrigger className="h-10 rounded-xl border-gray-200 w-full md:w-auto md:flex-1 md:max-w-xs">
|
||||
<SelectValue placeholder="请选择优惠券" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">暂无可用优惠券</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-xs text-gray-400 whitespace-nowrap">
|
||||
淘宝每次下单获得优惠券
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ 批量注册说明 ============
|
||||
function BatchInfo() {
|
||||
return (
|
||||
<div className="flex items-start gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 whitespace-nowrap w-28 pt-1">
|
||||
说明:
|
||||
</Label>
|
||||
<div className="flex-1 text-sm text-gray-600 leading-relaxed p-2 bg-blue-50/70 rounded-xl border border-blue-100">
|
||||
<p>
|
||||
批量注册的账号会使用【账号前缀】+【开始数】+【个数】顺序进行注册,
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
如:注册账号前缀为【user】开始数为【2】个数为【10】,则注册的账号为:
|
||||
<span className="text-blue-600 font-medium">
|
||||
{" "}
|
||||
user2,user3,user4,....user11
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">淘宝每次下单获得优惠券</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -204,35 +145,31 @@ export default function BuyPage() {
|
||||
const navigate = useNavigate()
|
||||
const product = location.state?.product as Product | undefined
|
||||
|
||||
// 模式
|
||||
const [mode, setMode] = useState<"single" | "batch">("single")
|
||||
|
||||
// 单个注册
|
||||
const [account, setAccount] = useState("")
|
||||
const [pwd, setPwd] = useState("")
|
||||
const [connectCount, setConnectCount] = useState(1)
|
||||
|
||||
// 批量注册
|
||||
const [batchAccount, setBatchAccount] = useState("")
|
||||
const [batchPwd, setBatchPwd] = useState("")
|
||||
const [batchStart, setBatchStart] = useState(1)
|
||||
const [batchCount, setBatchCount] = useState(1)
|
||||
const [batchConnectCount, setBatchConnectCount] = useState(1)
|
||||
|
||||
const [payType, setPayType] = useState("100")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// 支付窗口完成后自动刷新并跳转订单管理
|
||||
useEffect(() => {
|
||||
const handler = (e: StorageEvent) => {
|
||||
if (e.key !== PAY_PAID_KEY || !e.newValue) return
|
||||
toast.success("支付成功")
|
||||
clearPayPaid()
|
||||
navigate("/admin/orders")
|
||||
}
|
||||
window.addEventListener("storage", handler)
|
||||
return () => window.removeEventListener("storage", handler)
|
||||
}, [navigate])
|
||||
|
||||
useEffect(() => {
|
||||
if (!product) {
|
||||
navigate("/product", { replace: true })
|
||||
return
|
||||
}
|
||||
const acct = randomChars(2) + randomDigits(4)
|
||||
const pw = randomDigits(3)
|
||||
setAccount(acct)
|
||||
setPwd(pw)
|
||||
setBatchAccount(randomChars(3))
|
||||
setBatchPwd(pw)
|
||||
setAccount(randomChars(2) + randomDigits(4))
|
||||
setPwd(randomDigits(3))
|
||||
}, [navigate, product])
|
||||
|
||||
if (!product) return null
|
||||
@@ -258,78 +195,102 @@ export default function BuyPage() {
|
||||
return total
|
||||
}
|
||||
|
||||
const singleTotal = clampMinPrice(
|
||||
const total = clampMinPrice(
|
||||
product.price * displayConnectCount,
|
||||
displayConnectCount,
|
||||
)
|
||||
const batchTotal = clampMinPrice(
|
||||
product.price * batchConnectCount * batchCount,
|
||||
batchConnectCount * batchCount,
|
||||
)
|
||||
const total = mode === "single" ? singleTotal : batchTotal
|
||||
|
||||
// 批量预览
|
||||
const batchPreview = (() => {
|
||||
if (!batchAccount) return ""
|
||||
const end = batchStart + batchCount - 1
|
||||
if (batchCount <= 4) {
|
||||
const items: string[] = []
|
||||
for (let i = batchStart; i <= end; i++) {
|
||||
items.push(`${batchAccount}${i}`)
|
||||
}
|
||||
return items.join(", ")
|
||||
}
|
||||
return `${batchAccount}${batchStart}, ${batchAccount}${batchStart + 1}, ${batchAccount}${batchStart + 2}...${batchAccount}${end}`
|
||||
})()
|
||||
|
||||
const handlePay = async () => {
|
||||
if (mode === "single") {
|
||||
if (!account.trim() || !pwd.trim()) {
|
||||
toast.error("账号和密码不能为空")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if (!batchAccount.trim() || !batchPwd.trim()) {
|
||||
toast.error("账号前缀和密码不能为空")
|
||||
return
|
||||
}
|
||||
if (batchCount > 500) {
|
||||
toast.error("一次最多注册500个")
|
||||
return
|
||||
}
|
||||
// 测试套餐:免费领取
|
||||
const handleClaim = async () => {
|
||||
if (!account.trim() || !pwd.trim()) {
|
||||
toast.error("账号和密码不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const result = await createTestAccount({
|
||||
ProductId: product.productId,
|
||||
PackageId: packageId,
|
||||
Account: account.trim(),
|
||||
Pwd: pwd.trim(),
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
toast.success("领取成功")
|
||||
navigate("/admin/channels")
|
||||
} else {
|
||||
toast.error(result.message || "领取失败")
|
||||
}
|
||||
} catch {
|
||||
toast.error("网络错误,请重试")
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePay = async () => {
|
||||
if (!account.trim() || !pwd.trim()) {
|
||||
toast.error("账号和密码不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
// 支付类型/通道映射:1余额 100支付宝 70微信;30微信 50支付宝 70余额
|
||||
const oPayType = Number(payType)
|
||||
const useBalance = oPayType === 1 ? 1 : 0
|
||||
const payChannel = oPayType === 70 ? 30 : 50
|
||||
const useBalance = oPayType === 1
|
||||
const payChannel = oPayType === 1 ? 70 : oPayType === 70 ? 30 : 50
|
||||
|
||||
const params: CreateOrderRequest = {
|
||||
Account: account.trim(),
|
||||
PackageId: packageId,
|
||||
OrderType: mode === "single" ? 1 : 2,
|
||||
Account: mode === "single" ? account.trim() : batchAccount.trim(),
|
||||
Pwd: mode === "single" ? pwd.trim() : batchPwd.trim(),
|
||||
ConnectCount:
|
||||
mode === "single" ? displayConnectCount : batchConnectCount,
|
||||
Pwd: pwd.trim(),
|
||||
ConnectCount: displayConnectCount,
|
||||
OrderType: 1,
|
||||
Price: product.price,
|
||||
CouponAmount: 0,
|
||||
CouponId: 0,
|
||||
UseAccountAmount: useBalance,
|
||||
OPayType: oPayType,
|
||||
PayChannel: payChannel,
|
||||
Price: product.price,
|
||||
...(mode === "batch" && {
|
||||
MinPostfix: batchStart,
|
||||
MaxPostfix: batchCount,
|
||||
}),
|
||||
UseAccountAmount: useBalance,
|
||||
}
|
||||
|
||||
const result = await createOrder(params)
|
||||
|
||||
if (result.success) {
|
||||
toast.success("购买成功!")
|
||||
} else {
|
||||
if (!result.success) {
|
||||
toast.error(result.message)
|
||||
return
|
||||
}
|
||||
|
||||
// Data == "00" 为余额支付直接成功;否则需要微信/支付宝支付
|
||||
const data = result.data
|
||||
if (typeof data === "string") {
|
||||
if (data === "00") {
|
||||
toast.success("购买成功!")
|
||||
} else {
|
||||
toast.error(data || "下单失败,请重试")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
saveOrderPay({
|
||||
orderNo: data.OrderInfo.OrderNo,
|
||||
amount: Number(data.OrderInfo.OtherPayAmount) || total,
|
||||
payChannel,
|
||||
payData: data.PayData,
|
||||
})
|
||||
const win = window.open(
|
||||
"/pay?mode=order",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
)
|
||||
if (!win) {
|
||||
// 弹窗被拦截时直接在当前页跳转收银台
|
||||
navigate("/pay?mode=order")
|
||||
return
|
||||
}
|
||||
toast.success("订单已生成,请在新窗口中完成支付")
|
||||
} catch {
|
||||
toast.error("网络错误,请重试")
|
||||
} finally {
|
||||
@@ -339,260 +300,170 @@ export default function BuyPage() {
|
||||
|
||||
const handleBack = () => navigate(-1)
|
||||
|
||||
// 天卡首单(0.1元)不显示批量注册;天卡>0.1元时显示(juipnet:应付款!=0.1时显示批量tab)
|
||||
const showTabs = !isTest && !isFirstOrderDayCard
|
||||
|
||||
return (
|
||||
<Wrap className="bg-white min-h-screen flex items-start justify-center pt-16">
|
||||
<div className="w-full max-w-2xl px-8">
|
||||
<Wrap className="bg-white min-h-screen flex items-start justify-center pt-24">
|
||||
<RealNameCheck />
|
||||
<div className="w-full max-w-2xl px-0 md:px-8">
|
||||
{/* 标题 */}
|
||||
<div className="text-center mb-6 space-y-2">
|
||||
<h4 className="text-2xl font-bold">
|
||||
<span className="text-gray-800">{product.title}</span>
|
||||
<span className="text-orange-500 ml-2">¥{total.toFixed(2)}</span>
|
||||
<span className="text-orange-500 ml-2">
|
||||
{isTest ? "免费领取" : `¥${total.toFixed(2)}`}
|
||||
</span>
|
||||
</h4>
|
||||
<p className="text-sm text-amber-600">
|
||||
请务必选好所需物品,换货会产生费用
|
||||
</p>
|
||||
{isDayCard && (
|
||||
<p className="text-sm text-amber-600">天卡不支持退款,请谨慎购买</p>
|
||||
{isTest ? (
|
||||
<p className="text-sm text-amber-600">
|
||||
测试套餐免费领取,领取成功后可在 IP账号管理 中查看
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-amber-600">
|
||||
请务必选好所需物品,换货会产生费用
|
||||
</p>
|
||||
{isDayCard && (
|
||||
<p className="text-sm text-amber-600">
|
||||
天卡不支持退款,请谨慎购买
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tab 切换 */}
|
||||
{showTabs && (
|
||||
<div className="flex justify-center mb-3">
|
||||
<ModeTabs mode={mode} onModeChange={setMode} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-5 mb-20">
|
||||
{mode === "single" ? (
|
||||
// ====== 单个注册 ======
|
||||
<>
|
||||
{/* IP产品账号 */}
|
||||
<div className="flex items-center gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 whitespace-nowrap w-28">
|
||||
IP产品账号:
|
||||
</Label>
|
||||
<Input
|
||||
value={account}
|
||||
onChange={e => setAccount(e.target.value)}
|
||||
placeholder="4-10位字母或数字"
|
||||
className="h-10 rounded-xl border-gray-200 flex-1"
|
||||
/>
|
||||
<span className="text-xs text-gray-400 whitespace-nowrap">
|
||||
4至10位字母或数字或组合
|
||||
</span>
|
||||
</div>
|
||||
{/* IP产品账号 */}
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
IP产品账号:
|
||||
</Label>
|
||||
<Input
|
||||
value={account}
|
||||
onChange={e => setAccount(e.target.value)}
|
||||
placeholder="4-10位字母或数字"
|
||||
className="h-10 rounded-xl border-gray-200 w-full md:w-auto md:flex-1"
|
||||
/>
|
||||
<span className="text-xs text-gray-400">
|
||||
4至10位字母或数字或组合
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* IP产品密码 */}
|
||||
<div className="flex items-center gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 whitespace-nowrap w-28">
|
||||
IP产品密码:
|
||||
</Label>
|
||||
<Input
|
||||
value={pwd}
|
||||
onChange={e => setPwd(e.target.value)}
|
||||
placeholder="1-10位字母或数字"
|
||||
className="h-10 rounded-xl border-gray-200 flex-1"
|
||||
/>
|
||||
<span className="text-xs text-gray-400 whitespace-nowrap">
|
||||
1至10位字母或数字或组合
|
||||
</span>
|
||||
</div>
|
||||
{/* IP产品密码 */}
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
IP产品密码:
|
||||
</Label>
|
||||
<Input
|
||||
value={pwd}
|
||||
onChange={e => setPwd(e.target.value)}
|
||||
placeholder="1-10位字母或数字"
|
||||
className="h-10 rounded-xl border-gray-200 w-full md:w-auto md:flex-1"
|
||||
/>
|
||||
<span className="text-xs text-gray-400">
|
||||
1至10位字母或数字或组合
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 连接设备数:测试卡不显示;天卡首单(0.1元)显示但禁用 */}
|
||||
{!isTest && (
|
||||
<ConnectCountSelector
|
||||
value={displayConnectCount}
|
||||
onChange={setConnectCount}
|
||||
label="连接设备数"
|
||||
hint="一个账号可同时在线设备数"
|
||||
disabled={isFirstOrderDayCard}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// ====== 批量注册 ======
|
||||
<>
|
||||
{/* 批量说明 */}
|
||||
<BatchInfo />
|
||||
|
||||
{/* IP账号前缀 */}
|
||||
<div className="flex items-center gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 whitespace-nowrap w-28">
|
||||
IP账号前缀:
|
||||
</Label>
|
||||
<Input
|
||||
value={batchAccount}
|
||||
onChange={e => setBatchAccount(e.target.value)}
|
||||
placeholder="3-8位字母或数字"
|
||||
className="h-10 rounded-xl border-gray-200 flex-1"
|
||||
/>
|
||||
<span className="text-xs text-gray-400 whitespace-nowrap">
|
||||
3至8位字母或数字或组合
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 开始号 */}
|
||||
<div className="flex items-center gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 whitespace-nowrap w-28">
|
||||
开始号:
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={batchStart}
|
||||
onChange={e =>
|
||||
setBatchStart(Number.parseInt(e.target.value, 10) || 1)
|
||||
}
|
||||
className="h-10 rounded-xl border-gray-200 flex-1 max-w-32"
|
||||
/>
|
||||
<span className="text-xs text-gray-400">
|
||||
本批次账号的起始账号
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 注册个数 */}
|
||||
<div className="flex items-center gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 whitespace-nowrap w-28">
|
||||
注册个数:
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={batchCount}
|
||||
onChange={e =>
|
||||
setBatchCount(
|
||||
Math.min(500, Number.parseInt(e.target.value, 10) || 1),
|
||||
)
|
||||
}
|
||||
className="h-10 rounded-xl border-gray-200 flex-1 max-w-32"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 账号预览 */}
|
||||
{batchPreview && (
|
||||
<div className="flex items-center gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 whitespace-nowrap w-28">
|
||||
即将生成的账号:
|
||||
</Label>
|
||||
<span className="text-sm text-gray-700 font-medium">
|
||||
{batchPreview}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* IP产品密码 */}
|
||||
<div className="flex items-center gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 whitespace-nowrap w-28">
|
||||
IP产品密码:
|
||||
</Label>
|
||||
<Input
|
||||
value={batchPwd}
|
||||
onChange={e => setBatchPwd(e.target.value)}
|
||||
placeholder="1-10位字母或数字"
|
||||
className="h-10 rounded-xl border-gray-200 flex-1"
|
||||
/>
|
||||
<span className="text-xs text-gray-400 whitespace-nowrap">
|
||||
1至10位字母或数字或组合
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 单账号连接设备数 */}
|
||||
<ConnectCountSelector
|
||||
value={batchConnectCount}
|
||||
onChange={setBatchConnectCount}
|
||||
label="单账号连接设备数"
|
||||
hint="每个账号可同时在线设备的数量"
|
||||
/>
|
||||
</>
|
||||
{!isTest && (
|
||||
<ConnectCountSelector
|
||||
value={displayConnectCount}
|
||||
onChange={setConnectCount}
|
||||
label="连接设备数"
|
||||
hint="一个账号可同时在线设备数"
|
||||
disabled={isFirstOrderDayCard}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 优惠券 */}
|
||||
{!isTest && <CouponSelector />}
|
||||
{!isTest && (
|
||||
<>
|
||||
{/* 优惠券 */}
|
||||
<CouponSelector />
|
||||
|
||||
{/* 余额 */}
|
||||
<div className="flex items-center gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 whitespace-nowrap w-28">
|
||||
余额:
|
||||
</Label>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<span className="text-sm text-gray-600">
|
||||
当前账户余额
|
||||
<span className="font-bold text-orange-500 ml-1">¥10.00</span>
|
||||
</span>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm text-blue-500 p-0 h-auto font-medium"
|
||||
onClick={() => navigate("/admin")}
|
||||
>
|
||||
去充值
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* 余额 */}
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
余额:
|
||||
</Label>
|
||||
<div className="flex items-center gap-2 w-full md:w-auto md:flex-1">
|
||||
<span className="text-sm text-gray-600">
|
||||
当前账户余额
|
||||
<span className="font-bold text-orange-500 ml-1">
|
||||
¥10.00
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm text-blue-500 p-0 h-auto font-medium"
|
||||
onClick={() => navigate("/admin")}
|
||||
>
|
||||
去充值
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 支付方式 */}
|
||||
<div className="flex items-center gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 whitespace-nowrap w-28">
|
||||
支付方式:
|
||||
</Label>
|
||||
<RadioGroup
|
||||
value={payType}
|
||||
onValueChange={setPayType}
|
||||
className="flex gap-8"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="100" id="alipay" />
|
||||
<Label
|
||||
htmlFor="alipay"
|
||||
className="cursor-pointer text-sm text-gray-700 flex items-center gap-1.5"
|
||||
>
|
||||
<img src={alipay} alt="支付宝" className="w-5 h-5" />
|
||||
支付宝
|
||||
{/* 支付方式 */}
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
支付方式:
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="70" id="wechat" />
|
||||
<Label
|
||||
htmlFor="wechat"
|
||||
className="cursor-pointer text-sm text-gray-700 flex items-center gap-1.5"
|
||||
<RadioGroup
|
||||
value={payType}
|
||||
onValueChange={setPayType}
|
||||
className="flex flex-wrap gap-x-8 gap-y-3 md:gap-8"
|
||||
>
|
||||
<img src={wechat} alt="微信" className="w-5 h-5" />
|
||||
微信
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="100" id="alipay" />
|
||||
<Label
|
||||
htmlFor="alipay"
|
||||
className="cursor-pointer text-sm text-gray-700 flex items-center gap-1.5"
|
||||
>
|
||||
<img src={alipay} alt="支付宝" className="w-5 h-5" />
|
||||
支付宝
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="70" id="wechat" />
|
||||
<Label
|
||||
htmlFor="wechat"
|
||||
className="cursor-pointer text-sm text-gray-700 flex items-center gap-1.5"
|
||||
>
|
||||
<img src={wechat} alt="微信" className="w-5 h-5" />
|
||||
微信
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="1" id="balance" />
|
||||
<Label
|
||||
htmlFor="balance"
|
||||
className="cursor-pointer text-sm text-gray-700 flex items-center gap-1.5"
|
||||
>
|
||||
<img src={balance} alt="余额" className="w-5 h-5" />
|
||||
余额
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="1" id="balance" />
|
||||
<Label
|
||||
htmlFor="balance"
|
||||
className="cursor-pointer text-sm text-gray-700 flex items-center gap-1.5"
|
||||
>
|
||||
<img src={balance} alt="余额" className="w-5 h-5" />
|
||||
余额
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* 总金额 / 应付款 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 whitespace-nowrap w-28">
|
||||
总金额:
|
||||
</Label>
|
||||
<span className="font-medium text-gray-800">
|
||||
¥{total.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 whitespace-nowrap w-28">
|
||||
应付款:
|
||||
</Label>
|
||||
<span className="text-xl font-bold text-orange-500">
|
||||
¥{total.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* 总金额 / 应付款 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between md:justify-start md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
总金额:
|
||||
</Label>
|
||||
<span className="font-medium text-gray-800">
|
||||
¥{total.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between md:justify-start md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
应付款:
|
||||
</Label>
|
||||
<span className="text-xl font-bold text-orange-500">
|
||||
¥{total.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
@@ -602,13 +473,23 @@ export default function BuyPage() {
|
||||
>
|
||||
上一步
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 h-12 rounded-xl text-base bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90 transition-all"
|
||||
onClick={handlePay}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? "提交中..." : "确认支付"}
|
||||
</Button>
|
||||
{isTest ? (
|
||||
<Button
|
||||
className="flex-1 h-12 rounded-xl text-base bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90 transition-all"
|
||||
onClick={handleClaim}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? "领取中..." : "免费领取"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="flex-1 h-12 rounded-xl text-base bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90 transition-all"
|
||||
onClick={handlePay}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? "提交中..." : "确认支付"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
1172
src/home/product/buyStatic.tsx
Normal file
1172
src/home/product/buyStatic.tsx
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import { useNavigate, useSearchParams } from "react-router-dom"
|
||||
import {
|
||||
fetchProducts,
|
||||
fetchStaticProducts,
|
||||
@@ -14,6 +14,7 @@ import ProductGrid from "./productGrid"
|
||||
import ProductSidebar from "./productSidebar"
|
||||
import ProductTabs from "./productTabs"
|
||||
import PurchaseInfo from "./purchaseInfo"
|
||||
import ShuidiPools, { type PoolModule } from "./shuidiPools"
|
||||
|
||||
const TAB_ALIAS: Record<string, string> = {
|
||||
short: "dynamic",
|
||||
@@ -41,6 +42,7 @@ export default function ProductPage() {
|
||||
const [activeBrandId, setActiveBrandId] = useState("")
|
||||
const [selectedVersionId, setSelectedVersionId] = useState("")
|
||||
const initializedRef = useRef(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -49,7 +51,6 @@ export default function ProductPage() {
|
||||
const results = await Promise.allSettled(
|
||||
TAB_SOURCES.map(source => source.fetch()),
|
||||
)
|
||||
console.log(results, "resultsresultsresults")
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
@@ -128,6 +129,12 @@ export default function ProductPage() {
|
||||
})()
|
||||
|
||||
const isPurchaseInfo = currentBrand?.id === "info"
|
||||
const isShuidi = currentBrand?.id === "水滴独享IP"
|
||||
|
||||
const handlePoolBuy = (pool: PoolModule) => {
|
||||
if (!pool.canBuy) return
|
||||
navigate("/product/buy-static", { state: { pool } })
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -201,35 +208,44 @@ export default function ProductPage() {
|
||||
<div className="bg-orange-50 border gap-3 border-orange-100 hidden md:block text-orange-400 p-3 text-center font-bold mt-3 mb-3 text-sm w-full">
|
||||
需求5个以上,可以联系客服设置优惠价
|
||||
</div>
|
||||
{currentBrand?.versions && currentBrand.versions.length > 0 && (
|
||||
<div className="w-full mt-2 px-3 md:pl-6 md:pr-0">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{currentBrand.versions.map(v => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => setSelectedVersionId(v.id)}
|
||||
className={cn(
|
||||
"w-full py-2 rounded-full text-sm font-medium transition-all cursor-pointer text-center",
|
||||
effectiveVersionId === v.id
|
||||
? "bg-linear-to-r from-blue-500 to-cyan-400 text-white shadow-sm"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200",
|
||||
)}
|
||||
>
|
||||
{v.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{isShuidi ? (
|
||||
<main className="pl-3 md:pl-6">
|
||||
<ShuidiPools onBuy={handlePoolBuy} />
|
||||
</main>
|
||||
) : (
|
||||
<>
|
||||
{currentBrand?.versions &&
|
||||
currentBrand.versions.length > 0 && (
|
||||
<div className="w-full mt-2 px-3 md:pl-6 md:pr-0">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{currentBrand.versions.map(v => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => setSelectedVersionId(v.id)}
|
||||
className={cn(
|
||||
"w-full py-2 rounded-full text-sm font-medium transition-all cursor-pointer text-center",
|
||||
effectiveVersionId === v.id
|
||||
? "bg-linear-to-r from-blue-500 to-cyan-400 text-white shadow-sm"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200",
|
||||
)}
|
||||
>
|
||||
{v.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<main className="pl-3 md:pl-6">
|
||||
{displayProducts.length > 0 ? (
|
||||
<ProductGrid products={displayProducts} />
|
||||
) : (
|
||||
<div className="text-center py-20 text-gray-400">
|
||||
该版本暂无产品数据
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
)}
|
||||
<main className="pl-3 md:pl-6">
|
||||
{displayProducts.length > 0 ? (
|
||||
<ProductGrid products={displayProducts} />
|
||||
) : (
|
||||
<div className="text-center py-20 text-gray-400">
|
||||
该版本暂无产品数据
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
) : (
|
||||
<div className="px-3 md:pl-6 md:pr-6">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { useProfileStore } from "@/components/stores/profile"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import type { Product } from "@/lib/models/product"
|
||||
import vector from "../_assets/vector.webp"
|
||||
@@ -9,6 +10,16 @@ type ProductGridProps = {
|
||||
|
||||
export default function ProductGrid({ products }: ProductGridProps) {
|
||||
const navigate = useNavigate()
|
||||
const authed = useProfileStore(state => state.token !== null)
|
||||
|
||||
const handleBuy = (product: Product) => {
|
||||
// 未登录先登录,已登录进入购买确认页
|
||||
if (!authed) {
|
||||
navigate("/login")
|
||||
return
|
||||
}
|
||||
navigate("/product/buy", { state: { product } })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 mt-6 gap-6">
|
||||
@@ -75,9 +86,9 @@ export default function ProductGrid({ products }: ProductGridProps) {
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full text-blue-500 border-blue-400 hover:bg-blue-50"
|
||||
onClick={() => navigate("/product/buy", { state: { product } })}
|
||||
onClick={() => handleBuy(product)}
|
||||
>
|
||||
立即购买
|
||||
{product.isTest ? "免费领取" : "立即购买"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,10 +13,18 @@ export default function ProductTabs({
|
||||
activeId,
|
||||
onTabChange,
|
||||
}: ProductTabsProps) {
|
||||
|
||||
const visibleTabs = tabs.filter(tab => tab.id !== "window")
|
||||
|
||||
return (
|
||||
<Tabs value={activeId} onValueChange={onTabChange} className="w-full">
|
||||
<TabsList className="grid w-full h-auto bg-transparent p-0 grid-cols-3">
|
||||
{tabs.map(tab => (
|
||||
<TabsList
|
||||
className={cn(
|
||||
"grid w-full h-auto bg-transparent p-0",
|
||||
visibleTabs.length === 2 ? "grid-cols-2" : "grid-cols-3",
|
||||
)}
|
||||
>
|
||||
{visibleTabs.map(tab => (
|
||||
<TabsTrigger
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
@@ -33,4 +41,4 @@ export default function ProductTabs({
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import { useState } from "react"
|
||||
|
||||
export default function PurchaseInfo() {
|
||||
const [isImageOpen, setIsImageOpen] = useState(false)
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row w-full max-w-6xl mx-auto">
|
||||
<div className="flex-1 space-y-3 md:pr-3 md:border-r">
|
||||
@@ -36,8 +39,52 @@ export default function PurchaseInfo() {
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-orange-400 mb-3">产品对比:</h3>
|
||||
{/* 这里可以放入产品对比表格 */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="w-full overflow-hidden rounded-lg border border-gray-200 shadow-sm cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => setIsImageOpen(true)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
setIsImageOpen(true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/img/product_compare.png?id=99"
|
||||
alt="产品对比图,点击可放大查看"
|
||||
className="w-full h-auto object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isImageOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4 backdrop-blur-sm"
|
||||
onClick={() => setIsImageOpen(false)}
|
||||
>
|
||||
<div className="relative max-w-5xl w-full max-h-[90vh] pointer-events-none">
|
||||
<img
|
||||
src="/img/product_compare.png?id=99"
|
||||
alt="产品对比图"
|
||||
className="w-full h-full object-contain pointer-events-none"
|
||||
/>
|
||||
{/* 关闭按钮需要 pointer-events-auto 让它可点击 */}
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-4 right-4 text-white bg-black/50 rounded-full w-10 h-10 text-2xl hover:bg-black/70 transition-colors pointer-events-auto"
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
setIsImageOpen(false)
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-none md:w-90 md:pl-6 space-y-6 mt-6 md:mt-0">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react"
|
||||
import RealNameCheck from "@/components/realNameCheck"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
@@ -120,6 +121,7 @@ export default function RouterosPage() {
|
||||
|
||||
return (
|
||||
<Wrap className="flex flex-col gap-10 pt-30 pb-16 bg-white">
|
||||
<RealNameCheck />
|
||||
<div className="flex flex-col lg:flex-row gap-10">
|
||||
<div className="lg:w-5/12">
|
||||
<Card className="overflow-hidden border-0 shadow-md">
|
||||
|
||||
127
src/home/product/shuidiPools.tsx
Normal file
127
src/home/product/shuidiPools.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
|
||||
export type PoolModule = {
|
||||
id: string
|
||||
mainTitle: string
|
||||
leftTitle: string
|
||||
rightTitle: string
|
||||
features: {
|
||||
title: string
|
||||
desc: string
|
||||
}[]
|
||||
canBuy?: boolean
|
||||
productType?: "static" | "solid"
|
||||
}
|
||||
|
||||
const POOLS: PoolModule[] = [
|
||||
{
|
||||
id: "dial",
|
||||
mainTitle: "拨号线路(静态)",
|
||||
leftTitle: "静态专线介绍",
|
||||
rightTitle: "传统socks5",
|
||||
features: [
|
||||
{ title: "运营商专线宽带,自营机房", desc: "天翼云,各种云线路" },
|
||||
{ title: "大出口流量,直播/项目首选", desc: "低带宽出口,共享严重,线路拥挤" },
|
||||
{ title: "全新vpn隧道,安全可靠", desc: "传统协议,波动大,延迟大" },
|
||||
{ title: "全局代理不易发现", desc: "系统局部代理,易发现,易封号" },
|
||||
{ title: "IP周期性变化", desc: "-" },
|
||||
{ title: "精选超20000条不同线路", desc: "容量一般只有2-3000线路" },
|
||||
{ title: "Ros/爱快/路由器/手机/电脑", desc: "电脑/手机" },
|
||||
{ title: "华为级维护专员7*24小时服务", desc: "售后困难,处理时间漫长" },
|
||||
],
|
||||
canBuy: true,
|
||||
productType: "static",
|
||||
},
|
||||
{
|
||||
id: "fixed",
|
||||
mainTitle: "水滴专线(固态)",
|
||||
leftTitle: "固态专线介绍",
|
||||
rightTitle: "传统socks5",
|
||||
features: [
|
||||
{ title: "运营商专线宽带,更稳定", desc: "天翼云,各种云线路" },
|
||||
{ title: "全新vpn隧道,安全可靠", desc: "传统socks5协议,波动大,延迟大" },
|
||||
{ title: "全局代理,为项目而生", desc: "系统局部代理,易发现,易封号" },
|
||||
{ title: "IP永久固定不变", desc: "-" },
|
||||
{ title: "精选超10000条不同线路", desc: "容量一般只有2-3000线路" },
|
||||
{ title: "Ros/爱快/路由器/手机/电脑", desc: "电脑/手机" },
|
||||
{ title: "华为级维护专员7*24小时服务", desc: "售后困难,处理时间漫长" },
|
||||
],
|
||||
canBuy: true,
|
||||
productType: "solid",
|
||||
},
|
||||
]
|
||||
|
||||
type ShuidiPoolsProps = {
|
||||
onBuy: (pool: PoolModule) => void
|
||||
}
|
||||
|
||||
export default function ShuidiPools({ onBuy }: ShuidiPoolsProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 mt-6 gap-8">
|
||||
{POOLS.map(pool => (
|
||||
<div
|
||||
key={pool.id}
|
||||
className="rounded-xl shadow-xl shadow-slate-200/50 bg-white flex flex-col overflow-hidden border border-slate-200"
|
||||
>
|
||||
<div className="bg-[#e6f0fa] py-4">
|
||||
<div className="text-lg sm:text-xl font-bold text-center text-gray-800">
|
||||
{pool.mainTitle}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex grow">
|
||||
<div className="w-1/2 bg-white py-6 pl-4 pr-1 flex flex-col">
|
||||
<div className="text-center font-bold text-emerald-600 text-base sm:text-lg h-8 flex items-center justify-center">
|
||||
{pool.leftTitle}
|
||||
</div>
|
||||
<div className="flex flex-col grow">
|
||||
{pool.features.map((feature, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="grid grid-cols-[1fr_auto] gap-2 items-center py-2"
|
||||
>
|
||||
<div className="text-sm text-gray-800 font-medium text-center">
|
||||
{feature.title}
|
||||
</div>
|
||||
<div className="text-base font-bold leading-none text-[#10b981]">
|
||||
✓
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-1/2 bg-[#e5e7eb] py-6 pr-4 pl-1 flex flex-col">
|
||||
<div className="text-center font-bold text-emerald-600 text-base sm:text-lg h-8 flex items-center justify-center">
|
||||
{pool.rightTitle}
|
||||
</div>
|
||||
<div className="flex flex-col grow">
|
||||
{pool.features.map((feature, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="grid grid-cols-[auto_1fr] gap-2 items-center py-2"
|
||||
>
|
||||
<div className="text-base font-bold leading-none text-[#ef4444]">
|
||||
✗
|
||||
</div>
|
||||
<div className="text-sm text-gray-800 font-medium text-center ">
|
||||
{feature.desc}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="w-full bg-[#119a49] hover:bg-[#0d7a39] text-white font-bold text-lg py-4 transition-colors"
|
||||
onClick={() => pool.canBuy && onBuy(pool)}
|
||||
disabled={!pool.canBuy}
|
||||
>
|
||||
立即购买
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -23,13 +23,13 @@ export default function SoftDownloadPage() {
|
||||
)
|
||||
|
||||
return (
|
||||
<Wrap className="flex flex-col gap-6 mt-20 bg-white">
|
||||
<div className="grid grid-cols-3 mt-3">
|
||||
<Wrap className="flex flex-col gap-6 mt-24 bg-white">
|
||||
<div className="grid md:grid-cols-3 grid-cols-1 text-center justify-center mt-3">
|
||||
<div></div>
|
||||
<h3 className="text-3xl font-bold text-gray-800 text-center">
|
||||
软件下载
|
||||
</h3>
|
||||
<div className="flex gap-4 text-sm justify-end items-end text-blue-500">
|
||||
<div className="flex gap-4 text-sm md:justify-end justify-center md:p-0 pt-3 items-end text-blue-500">
|
||||
<a
|
||||
href="/ipline"
|
||||
className="no-underline hover:text-blue-700 active:no-underline focus:no-underline"
|
||||
@@ -60,7 +60,7 @@ export default function SoftDownloadPage() {
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="grid grid-cols-3 gap-8">
|
||||
<div className="grid md:grid-cols-3 grid-cols-1 gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h4 className="text-center font-bold text-gray-700 text-base mb-1">
|
||||
极狐IP
|
||||
@@ -131,7 +131,7 @@ export default function SoftDownloadPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-8">
|
||||
<div className="grid md:grid-cols-3 grid-cols-1 gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h4 className="text-center font-bold text-gray-700 text-base mb-1">
|
||||
麒麟IP
|
||||
@@ -183,7 +183,7 @@ export default function SoftDownloadPage() {
|
||||
<h4 className="text-center font-bold text-gray-700 text-base">
|
||||
代理商专用客户端
|
||||
</h4>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid md:grid-cols-3 grid-cols-1 gap-4">
|
||||
<DownloadBtn
|
||||
href="https://mp4.juip.com/jh-dl.zip"
|
||||
icon={<Monitor className="w-4 h-4" />}
|
||||
|
||||
@@ -13,12 +13,28 @@ type ApiResponse<T = undefined> =
|
||||
success: false
|
||||
status?: number
|
||||
message: string
|
||||
// 后端业务码(非 10000 时透传),如 /user/IsPay 的 Code 0/1 即支付状态
|
||||
code?: number
|
||||
}
|
||||
| {
|
||||
success: true
|
||||
data: T
|
||||
// 响应头(key 统一小写),用于登录等场景从 header 取数据
|
||||
headers?: Record<string, string>
|
||||
// .NET 分页接口(ApiResultPaged)携带的总条数
|
||||
totalCount?: number
|
||||
}
|
||||
|
||||
export { API_BASE_URL, type ApiResponse, CLIENT_ID, CLIENT_SECRET }
|
||||
// 服务端分页结果
|
||||
type PageResult<T> = {
|
||||
total: number
|
||||
list: T[]
|
||||
}
|
||||
|
||||
export {
|
||||
API_BASE_URL,
|
||||
type ApiResponse,
|
||||
CLIENT_ID,
|
||||
CLIENT_SECRET,
|
||||
type PageResult,
|
||||
}
|
||||
|
||||
30
src/lib/hooks/useList.ts
Normal file
30
src/lib/hooks/useList.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
|
||||
export type ListStatus = "load" | "done" | "fail"
|
||||
|
||||
// 挂载时加载一次列表(rosorder / cashOut 等无筛选列表页共用)
|
||||
export function useList<T>(fetcher: () => Promise<ApiResponse<T[]>>) {
|
||||
const [data, setData] = useState<T[]>([])
|
||||
const [status, setStatus] = useState<ListStatus>("load")
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetcher().then(res => {
|
||||
if (cancelled) return
|
||||
if (res.success) {
|
||||
setData(res.data)
|
||||
setStatus("done")
|
||||
} else {
|
||||
setStatus("fail")
|
||||
toast.error(res.message)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [fetcher])
|
||||
|
||||
return { data, status }
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useSyncExternalStore } from "react"
|
||||
|
||||
const subscribeMediaQuery = (query: string) => (callback: () => void) => {
|
||||
if (typeof window === "undefined") return () => {}
|
||||
const mql = window.matchMedia(query)
|
||||
mql.addEventListener("change", callback)
|
||||
return () => mql.removeEventListener("change", callback)
|
||||
}
|
||||
|
||||
const getMediaQuerySnapshot = (query: string) => () => {
|
||||
if (typeof window === "undefined") return false
|
||||
return window.matchMedia(query).matches
|
||||
}
|
||||
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
return useSyncExternalStore(
|
||||
subscribeMediaQuery(query),
|
||||
getMediaQuerySnapshot(query),
|
||||
() => false,
|
||||
)
|
||||
}
|
||||
|
||||
// 移动端:< 768px(对应 Tailwind md 断点以下)
|
||||
export function useIsMobile(): boolean {
|
||||
return useMediaQuery("(max-width: 47.999rem)")
|
||||
}
|
||||
|
||||
// 桌面端:>= 768px
|
||||
export function useIsDesktop(): boolean {
|
||||
return useMediaQuery("(min-width: 48rem)")
|
||||
}
|
||||
|
||||
// 窄桌面/平板横屏:< 1280px(对应 Tailwind xl 断点以下)
|
||||
export function useIsNarrowDesktop(): boolean {
|
||||
return useMediaQuery("(max-width: 79.999rem)")
|
||||
}
|
||||
93
src/lib/hooks/useMyOrderPage.ts
Normal file
93
src/lib/hooks/useMyOrderPage.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import {
|
||||
DEFAULT_FILTERS,
|
||||
type MyOrderListResult,
|
||||
type MyOrderQueryParams,
|
||||
type OrderFilters,
|
||||
type OrderItem,
|
||||
} from "@/lib/models/order"
|
||||
import type { ListStatus } from "./useList"
|
||||
|
||||
const DEFAULT_SIZE = 10
|
||||
|
||||
// 订单管理/退货订单共用
|
||||
// 接口返回全量列表({ RowCount, List }),本地做前端分页
|
||||
export function useMyOrderPage(
|
||||
fetchPage: (
|
||||
params: MyOrderQueryParams,
|
||||
) => Promise<ApiResponse<MyOrderListResult>>,
|
||||
mapFilters: (filters: OrderFilters) => Partial<MyOrderQueryParams>,
|
||||
) {
|
||||
const [filters, setFilters] = useState<OrderFilters>(DEFAULT_FILTERS)
|
||||
const [list, setList] = useState<OrderItem[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [status, setStatus] = useState<ListStatus>("load")
|
||||
const [page, setPage] = useState(1)
|
||||
const [size, setSize] = useState(DEFAULT_SIZE)
|
||||
|
||||
const patch = useCallback(
|
||||
(partial: Partial<OrderFilters>) =>
|
||||
setFilters(prev => ({ ...prev, ...partial })),
|
||||
[],
|
||||
)
|
||||
|
||||
const fetchWith = useCallback(
|
||||
async (filterValues: OrderFilters) => {
|
||||
setStatus("load")
|
||||
const res = await fetchPage(mapFilters(filterValues))
|
||||
if (res.success) {
|
||||
setList(res.data.List)
|
||||
setTotal(
|
||||
res.data.RowCount > 0 ? res.data.RowCount : res.data.List.length,
|
||||
)
|
||||
setStatus("done")
|
||||
} else {
|
||||
setStatus("fail")
|
||||
toast.error(res.message)
|
||||
}
|
||||
setPage(1)
|
||||
},
|
||||
[fetchPage, mapFilters],
|
||||
)
|
||||
|
||||
const load = useCallback(() => fetchWith(filters), [fetchWith, filters])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setFilters(DEFAULT_FILTERS)
|
||||
fetchWith(DEFAULT_FILTERS)
|
||||
}, [fetchWith])
|
||||
|
||||
const loadedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (loadedRef.current) return
|
||||
loadedRef.current = true
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const paged = useMemo(
|
||||
() => list.slice((page - 1) * size, page * size),
|
||||
[list, page, size],
|
||||
)
|
||||
|
||||
const changeSize = useCallback((next: number) => {
|
||||
setSize(next)
|
||||
setPage(1)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
filters,
|
||||
patch,
|
||||
load,
|
||||
reset,
|
||||
list,
|
||||
total,
|
||||
status,
|
||||
page,
|
||||
size,
|
||||
setPage,
|
||||
changeSize,
|
||||
paged,
|
||||
}
|
||||
}
|
||||
77
src/lib/hooks/useOrderPage.ts
Normal file
77
src/lib/hooks/useOrderPage.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import {
|
||||
DEFAULT_FILTERS,
|
||||
type OrderFilters,
|
||||
type OrderItem,
|
||||
type OrderPageData,
|
||||
type OrderQueryParams,
|
||||
} from "@/lib/models/order"
|
||||
import type { ListStatus } from "./useList"
|
||||
|
||||
// 订单管理/退货订单共用的服务端分页逻辑
|
||||
// (.NET /api/course/v1/order/Page、RefundOrders,返回 Data + TotalCount)
|
||||
type FetchPage = (params: OrderQueryParams) => Promise<ApiResponse<OrderItem[]>>
|
||||
|
||||
export function useOrderPage(
|
||||
fetchPage: FetchPage,
|
||||
mapFilters: (filters: OrderFilters) => Partial<OrderQueryParams>,
|
||||
) {
|
||||
const [filters, setFilters] = useState<OrderFilters>(DEFAULT_FILTERS)
|
||||
const [data, setData] = useState<OrderPageData>({
|
||||
page: 1,
|
||||
size: 10,
|
||||
total: 0,
|
||||
list: [],
|
||||
})
|
||||
const [status, setStatus] = useState<ListStatus>("load")
|
||||
|
||||
const patch = useCallback(
|
||||
(partial: Partial<OrderFilters>) =>
|
||||
setFilters(prev => ({ ...prev, ...partial })),
|
||||
[],
|
||||
)
|
||||
|
||||
const refresh = useCallback(
|
||||
async (page: number, size: number) => {
|
||||
setStatus("load")
|
||||
const res = await fetchPage({
|
||||
...mapFilters(filters),
|
||||
PageIndex: page,
|
||||
PageSize: size,
|
||||
})
|
||||
if (res.success) {
|
||||
const totalPages = Math.max(Math.ceil((res.totalCount ?? 0) / size), 1)
|
||||
// 数据变少导致当前页越界:回退到最后一页重新拉取
|
||||
if (res.data.length === 0 && page > totalPages) {
|
||||
await refresh(totalPages, size)
|
||||
return
|
||||
}
|
||||
setData({
|
||||
page,
|
||||
size,
|
||||
total: res.totalCount ?? 0,
|
||||
list: res.data,
|
||||
})
|
||||
setStatus("done")
|
||||
} else {
|
||||
setStatus("fail")
|
||||
toast.error(res.message)
|
||||
}
|
||||
},
|
||||
[fetchPage, mapFilters, filters],
|
||||
)
|
||||
|
||||
// 筛选查询:回到第 1 页
|
||||
const load = useCallback(() => refresh(1, data.size), [refresh, data.size])
|
||||
|
||||
const loadedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (loadedRef.current) return
|
||||
loadedRef.current = true
|
||||
refresh(1, data.size)
|
||||
}, [refresh, data.size])
|
||||
|
||||
return { filters, patch, refresh, load, data, status }
|
||||
}
|
||||
26
src/lib/hooks/useProducts.ts
Normal file
26
src/lib/hooks/useProducts.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { fetchProductWithPackage } from "@/api/product"
|
||||
import type { ProductItem } from "@/lib/models/product"
|
||||
|
||||
// 加载「产品+套餐」下拉数据(账号管理/订单筛选共用)
|
||||
export function useProducts() {
|
||||
const [products, setProducts] = useState<ProductItem[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetchProductWithPackage().then(res => {
|
||||
if (cancelled) return
|
||||
if (res.success) {
|
||||
setProducts(res.data)
|
||||
} else {
|
||||
toast.error(res.message)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return products
|
||||
}
|
||||
46
src/lib/models/account.ts
Normal file
46
src/lib/models/account.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export type MyAccountItem = {
|
||||
TenantId: number
|
||||
UserId: number
|
||||
ProductId: number
|
||||
PackageId: number
|
||||
UserCode: string
|
||||
ProductName: string
|
||||
PackageName: string
|
||||
AccountType: number
|
||||
Account: string
|
||||
Pwd: string
|
||||
ConnectCount: number
|
||||
Status: number
|
||||
ChargeStatus: number
|
||||
StartTime: string
|
||||
EndTime: string
|
||||
RestTime: string
|
||||
RestDay: number
|
||||
agent_id: number
|
||||
im: number
|
||||
CreateTime: string
|
||||
UpdateTime: string
|
||||
DeleteTag: number
|
||||
Id: number
|
||||
}
|
||||
|
||||
export type MyAccountQueryParams = {
|
||||
ExpiredDay?: number
|
||||
ProductId?: number
|
||||
PackageId?: string
|
||||
Keyword?: string
|
||||
}
|
||||
|
||||
// 到期时间筛选枚举(-100:全部)
|
||||
export const EXPIRED_DAY_OPTIONS: { value: number; label: string }[] = [
|
||||
{ value: -100, label: "全部" },
|
||||
{ value: 1, label: "1天内到期" },
|
||||
{ value: 3, label: "三天内到期" },
|
||||
{ value: 7, label: "一周内到期" },
|
||||
{ value: 10, label: "10天内到期" },
|
||||
{ value: -1, label: "已经到期1天内" },
|
||||
{ value: -3, label: "已经到期3天内" },
|
||||
{ value: -4, label: "已经到期3天以上" },
|
||||
{ value: -5, label: "全部未过期" },
|
||||
{ value: -6, label: "全部已过期" },
|
||||
]
|
||||
2
src/lib/models/auth.ts
Normal file
2
src/lib/models/auth.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// 短信验证码用途
|
||||
export type SMSPurpose = "User_Code" | "FindUser_Code" | "User_Login"
|
||||
16
src/lib/models/cashOut.ts
Normal file
16
src/lib/models/cashOut.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export type CashOutItem = {
|
||||
username: string
|
||||
money: string
|
||||
remark: string | null
|
||||
apply_reason: string
|
||||
alipay_account: string
|
||||
status: string
|
||||
create_time: string
|
||||
}
|
||||
|
||||
export type CashOutSubmitData = {
|
||||
cash_out_money: string
|
||||
reason: string
|
||||
alipay_account: string
|
||||
real_name: string
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
export type ChannelRecord = {
|
||||
id: string
|
||||
startAt: string
|
||||
product: string
|
||||
plan: string
|
||||
account: string
|
||||
password: string
|
||||
ip: string
|
||||
server: string
|
||||
connections: number
|
||||
expiredAt: string
|
||||
remaining: string
|
||||
online: string
|
||||
download: string
|
||||
status: string
|
||||
}
|
||||
76
src/lib/models/coupon.ts
Normal file
76
src/lib/models/coupon.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
export type CouponItem = {
|
||||
Id: number
|
||||
Name: string
|
||||
Remark: string
|
||||
CouponType: number
|
||||
TotalCount?: number
|
||||
GrantCount?: number
|
||||
UsedCount?: number
|
||||
GetLimitCount?: number
|
||||
UseRange: number
|
||||
AllowMinAmount: number
|
||||
CouponValue: number
|
||||
DateRule: number
|
||||
ValidDay: number
|
||||
StartDate: string
|
||||
EndDate: string
|
||||
CreateDate: string
|
||||
IsOverlay: number
|
||||
IsOpen: number
|
||||
Disabled?: number
|
||||
}
|
||||
|
||||
export type CouponQueryParams = {
|
||||
PageIndex?: number
|
||||
PageSize?: number
|
||||
KeyWord?: string
|
||||
}
|
||||
|
||||
export type MyCouponOrgin = {
|
||||
TenantId: number
|
||||
From: string
|
||||
ToUser: number
|
||||
ToUserRef: string
|
||||
CouponId: number
|
||||
CouponCount: number
|
||||
OriginType: number
|
||||
Remark: string
|
||||
CreateDate: string
|
||||
StartTime: string
|
||||
EndTime: string
|
||||
Status: number
|
||||
DeleteTag: number
|
||||
Id: number
|
||||
}
|
||||
|
||||
export type MyCouponInfo = {
|
||||
TenantId: number
|
||||
StoreId: number
|
||||
Name: string
|
||||
CouponType: number
|
||||
GrantCount: number
|
||||
UsedCount: number
|
||||
UseRange: number
|
||||
AllowMinAmount: number
|
||||
CouponValue: number
|
||||
DateRule: number
|
||||
ValidDay: number
|
||||
StartDate: string
|
||||
EndDate: string
|
||||
CreateDate: string
|
||||
IsOverlay: number
|
||||
IsOpen: number
|
||||
Disabled: number
|
||||
DeleteTag: number
|
||||
Id: number
|
||||
}
|
||||
|
||||
export type MyCouponItem = {
|
||||
Count: number
|
||||
UserId: number
|
||||
Orgin: MyCouponOrgin
|
||||
Coupon: MyCouponInfo
|
||||
ValidDays: number
|
||||
IsUsed: boolean
|
||||
IsExpired: boolean
|
||||
}
|
||||
56
src/lib/models/dashboard.ts
Normal file
56
src/lib/models/dashboard.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { PhpUserModel } from "@/lib/models/user"
|
||||
|
||||
// ==================== 账户总览接口返回结构 ====================
|
||||
|
||||
export type ApiIndexUserModel = PhpUserModel
|
||||
|
||||
export type ApiIndexAccountModel = {
|
||||
TotalCount: number
|
||||
ExpriedCount: number
|
||||
}
|
||||
|
||||
export type ApiIndexStatistic = {
|
||||
TodayExpend: number
|
||||
TodayRefund: number
|
||||
TodayCharege: number
|
||||
MonthExpend: number
|
||||
MonthRefund: number
|
||||
MonthCharege: number
|
||||
YearExpend: number
|
||||
}
|
||||
|
||||
export type ApiIndexItem = {
|
||||
UserModel: ApiIndexUserModel
|
||||
AccountModel: ApiIndexAccountModel
|
||||
Statistic: ApiIndexStatistic
|
||||
}
|
||||
|
||||
export type DashboardBalance = {
|
||||
balance: number
|
||||
hCoin: number
|
||||
}
|
||||
|
||||
export type AccountInfo = {
|
||||
phone: string
|
||||
qq: string
|
||||
wechat: string
|
||||
email: string
|
||||
taobao: string
|
||||
}
|
||||
|
||||
export type ConsumptionStats = {
|
||||
today: number
|
||||
month: number
|
||||
todayRefund: number
|
||||
year: number
|
||||
todayRecharge: number
|
||||
monthRecharge: number
|
||||
monthRefund: number
|
||||
}
|
||||
|
||||
export type IpAccountStats = {
|
||||
using: number
|
||||
total: number
|
||||
expired: number
|
||||
yearConsumption: number
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user