Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01f4f5343d | ||
|
|
7b7ba7d01d | ||
|
|
8e50a7264b | ||
|
|
d9790c6d15 | ||
|
|
d2c7846a91 | ||
|
|
9f74483345 | ||
|
|
e0bdcabbfe | ||
|
|
05c927111b | ||
|
|
fc25858e72 | ||
|
|
bca2f96bf3 | ||
|
|
616901acdd | ||
|
|
284b0d6afe | ||
|
|
bc29a025b0 | ||
|
|
85f9e68e32 | ||
|
|
7cd1a7cbe7 | ||
|
|
66ee6bb9fd | ||
|
|
b041bd3b01 | ||
|
|
385869604a | ||
|
|
dca32c435a | ||
|
|
4b1d87bb14 |
143
bun.lock
143
bun.lock
@@ -15,11 +15,26 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tiptap/extension-code-block-lowlight": "^3.24.0",
|
||||||
|
"@tiptap/extension-image": "^3.24.0",
|
||||||
|
"@tiptap/extension-table": "^3.24.0",
|
||||||
|
"@tiptap/extension-table-cell": "^3.24.0",
|
||||||
|
"@tiptap/extension-table-header": "^3.24.0",
|
||||||
|
"@tiptap/extension-table-row": "^3.24.0",
|
||||||
|
"@tiptap/extension-task-item": "^3.24.0",
|
||||||
|
"@tiptap/extension-task-list": "^3.24.0",
|
||||||
|
"@tiptap/extension-text-align": "^3.24.0",
|
||||||
|
"@tiptap/extension-youtube": "^3.24.0",
|
||||||
|
"@tiptap/pm": "^3.24.0",
|
||||||
|
"@tiptap/react": "^3.24.0",
|
||||||
|
"@tiptap/starter-kit": "^3.24.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"jotai": "^2.19.0",
|
"jotai": "^2.19.0",
|
||||||
|
"lowlight": "^3.3.0",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
|
"marked": "^18.0.4",
|
||||||
"next": "^16.0.10",
|
"next": "^16.0.10",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
@@ -320,14 +335,96 @@
|
|||||||
|
|
||||||
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "https://registry.npmmirror.com/@tanstack/table-core/-/table-core-8.21.3.tgz", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
|
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "https://registry.npmmirror.com/@tanstack/table-core/-/table-core-8.21.3.tgz", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
|
||||||
|
|
||||||
|
"@tiptap/core": ["@tiptap/core@3.24.0", "https://registry.npmmirror.com/@tiptap/core/-/core-3.24.0.tgz", { "peerDependencies": { "@tiptap/pm": "3.24.0" } }, "sha512-GTAsXAI32p4hEZgPzvUv2RPrObxamy9AFhmhG10fXSvN/cDUs8naEYVIqDV3Sh99jMwQEbTFKW1E1mcspsY6ow=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-blockquote": ["@tiptap/extension-blockquote@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-blockquote/-/extension-blockquote-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-DgwEEJ1GbDQcT054ynxoaZGmB9apGeUklPrinq9o6xdLHpdg+bO9HCQzggdB8n21VLLglb8jfAEWsVNwh3eASQ=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-bold": ["@tiptap/extension-bold@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-bold/-/extension-bold-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-CujogYaynasklFKHADUseuvj8X2FnWktTCCo3Hl+nlyRvBTmm5TK2aqiamg3v2P4dBh3O6a70mo8BfRJPuiR1g=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-bubble-menu": ["@tiptap/extension-bubble-menu@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.24.0.tgz", { "dependencies": { "@floating-ui/dom": "^1.0.0" }, "peerDependencies": { "@tiptap/core": "3.24.0", "@tiptap/pm": "3.24.0" } }, "sha512-jRXD+JPu9ayvq78g8hsCxx4q/qUFtrdfIYirRSf5YUseuuUbtfrq83AsGabcygpUTefjJkMQoXNITkh6294Ggw=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-bullet-list": ["@tiptap/extension-bullet-list@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-bullet-list/-/extension-bullet-list-3.24.0.tgz", { "peerDependencies": { "@tiptap/extension-list": "3.24.0" } }, "sha512-IOpAm5c4XVVVvkOef+V9XYMVpea+3MgBpCQgn83UQRlwO9eIMwmcyxOznu7gQPQVShTEpkt4T6uK+ZN9o8meIA=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-code": ["@tiptap/extension-code@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-code/-/extension-code-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-MAQtrPRQ+HRmcGotWbksdIGeH1gqayFAdvi4lNGeFT7taHXP1o1XD7CQp7iYIKmg8IU4/MQ+RdetSfuC1A9edQ=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-code-block": ["@tiptap/extension-code-block@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-code-block/-/extension-code-block-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0", "@tiptap/pm": "3.24.0" } }, "sha512-NZglw4oHoH6oJ5+HvxxQCYk+wODJmsxzUpRQdsOmje08sekQH+Zt9i4UKimBhg4urpd5r+dKXTslab9a5eQ86w=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-code-block-lowlight": ["@tiptap/extension-code-block-lowlight@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0", "@tiptap/extension-code-block": "3.24.0", "@tiptap/pm": "3.24.0", "highlight.js": "^11", "lowlight": "^2 || ^3" } }, "sha512-DwXmsymZj1xbfKm92wvCf4rq3XuF2akVlYbxu2HBXedZaUIDMz0+kxaFRlsMxbsZUTmjW3wYChPqeFUrZ4cq8g=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-document": ["@tiptap/extension-document@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-document/-/extension-document-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-yxgM3+yXy2XZzEwH43y2Kp8D1BkblxEWLXqo0YCoAKtxyKCcEaT8kdlf70kS7D0+VSzYU4D0iN7VdQIYHcL2mA=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-dropcursor": ["@tiptap/extension-dropcursor@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-dropcursor/-/extension-dropcursor-3.24.0.tgz", { "peerDependencies": { "@tiptap/extensions": "3.24.0" } }, "sha512-Dbv1c5LnvG3PT+yEbCNroyOeeUkHq9wcir2pbC7wri7g7d2sCi0+HvKH0MAxLwY3j5NJJSiSyG2ypMaXOAs4sg=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-floating-menu": ["@tiptap/extension-floating-menu@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-floating-menu/-/extension-floating-menu-3.24.0.tgz", { "peerDependencies": { "@floating-ui/dom": "^1.0.0", "@tiptap/core": "3.24.0", "@tiptap/pm": "3.24.0" } }, "sha512-7QEbf3mUzFAkejjQGX9f0L507oMtnOBRwHt2skUTR+9yXgudsN8zaDBSSRHLeMWGk9b7L293ZMA6zCRrZaHrfA=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-gapcursor": ["@tiptap/extension-gapcursor@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-gapcursor/-/extension-gapcursor-3.24.0.tgz", { "peerDependencies": { "@tiptap/extensions": "3.24.0" } }, "sha512-CzCP5/jni5RFwW9jCfBO6auh83GbaioMTpSk6tyR3sd+CbwlBcUdsJFGJkbaRdiSS9dgIyi+6hRbhjpYdHcp+w=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-hard-break": ["@tiptap/extension-hard-break@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-hard-break/-/extension-hard-break-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-T/ZEBiHQPMyTqDvXG0tiqBToNeuSemIPmNtdoGSgBN/degVl7VJZqQIrLIvOUHfjf3QkRs7TE/mcqTJsIboO/g=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-heading": ["@tiptap/extension-heading@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-heading/-/extension-heading-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-GCSgapIzQPqEGNcVGE0/Pcjg5wITMLYJlrS3GGVw7BPmECJwgexcoOsEwkxtzJnXT/HpFXbvOFW43sM0KeHSjg=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-horizontal-rule": ["@tiptap/extension-horizontal-rule@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0", "@tiptap/pm": "3.24.0" } }, "sha512-DFzWJTrb23x+qssLLs85vEyho8ItUGp3RY9XUsVTIAGZn5IsoUw8wMsvIBlH1ux4Ch7gLchtcD6kpTdMdrL9kw=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-image": ["@tiptap/extension-image@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-image/-/extension-image-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-mH+bvsX2cPKuZzV7YMQi4FV2YbDP+Kmq36bY+Bwi/x4mYUc8u0cjQxcu8RzLO7GtsgUJPxGMwfkQxmDqXFLZvw=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-italic": ["@tiptap/extension-italic@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-italic/-/extension-italic-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-mf3cbNlbMPUNj3IyUkIke+o3ZpOUrtVeY5Yqs5IM/VhkUUh/PdIzqw74VuqEAJ0Z4oZ6nNDHeYLrl3Be1j99lQ=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-link": ["@tiptap/extension-link@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-link/-/extension-link-3.24.0.tgz", { "dependencies": { "linkifyjs": "^4.3.3" }, "peerDependencies": { "@tiptap/core": "3.24.0", "@tiptap/pm": "3.24.0" } }, "sha512-MwMoNGG2mL5XGFV1tEGunBRglwsIbW+ZOB2QnKiv+Mcbi2JCWMrorndJZBqpVPR5nM+Bef2KnpchEJmYlQLvKQ=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-list": ["@tiptap/extension-list@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-list/-/extension-list-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0", "@tiptap/pm": "3.24.0" } }, "sha512-GcxDVMMmDGj7OFTBrV7JpVgr5wxlr2vmjwH7U8QxZX7OJI5vrsMYl/U6KRTvUpG8wP+Zmo5jRlLM+BbL+a/W3g=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-list-item": ["@tiptap/extension-list-item@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-list-item/-/extension-list-item-3.24.0.tgz", { "peerDependencies": { "@tiptap/extension-list": "3.24.0" } }, "sha512-zl/U3viJiV9OzkKM37AHIUN1af1TSLrcbHUUoNLkfJ33Nq+NlpaXpCVK0rKRqiLFJf7zk/a5KWG5CrOy9TxjKA=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-list-keymap": ["@tiptap/extension-list-keymap@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-list-keymap/-/extension-list-keymap-3.24.0.tgz", { "peerDependencies": { "@tiptap/extension-list": "3.24.0" } }, "sha512-69fKcrngYGEKWNn4R5oLwl0YuV3FY4kufEValVcjnihUmqJTE1vx+fwctYoTsOGnIuNGpUIQ7f9YDD/0w34qBw=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-ordered-list": ["@tiptap/extension-ordered-list@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-ordered-list/-/extension-ordered-list-3.24.0.tgz", { "peerDependencies": { "@tiptap/extension-list": "3.24.0" } }, "sha512-buRa6bmBDw0TztH+rAcusIye14DiLDS+yGheo6GiNCTD7kKJnksXagBdxvip3jhW5sx7gyAKvoBmvGSg1BbsGA=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-paragraph": ["@tiptap/extension-paragraph@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-paragraph/-/extension-paragraph-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-wD06aB6hO7LgcrlhGiw7I64k2tus9kNoICX5R+UecBSB1DVJdzKvXoXL2kPNv4DqYvljHdkIeK/OpuOTQd6MJA=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-strike": ["@tiptap/extension-strike@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-strike/-/extension-strike-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-sfN1iQs6Fdlorrfe8wipDkTPwu/Egx3s2fkY7TAWusTGFHwlovuRUGFKqCL9dI4N3u6uqUMpEuWmQNgv+aQGjQ=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-table": ["@tiptap/extension-table@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-table/-/extension-table-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0", "@tiptap/pm": "3.24.0" } }, "sha512-lr5elob3uJnB+ltgqPDEeVQmIPRx6JoS0I6z93tOgKsI2mIsaw5ErghteeiCTpExdyax7aWR0fn5pZzLVDQL8A=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-table-cell": ["@tiptap/extension-table-cell@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-table-cell/-/extension-table-cell-3.24.0.tgz", { "peerDependencies": { "@tiptap/extension-table": "3.24.0" } }, "sha512-jg62iVc1L8tOycSuwZ5i82EGKGuvelCIkpFX7wvHMl9EiPX9fv70enmb4ua9DyLgRtYg/MNrGOdUATRpxWsFRA=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-table-header": ["@tiptap/extension-table-header@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-table-header/-/extension-table-header-3.24.0.tgz", { "peerDependencies": { "@tiptap/extension-table": "3.24.0" } }, "sha512-z1TGVoBD5lVX48hpKcrQY5vETB8aCR61Enk5wGl45zNO/sMEMNxYfDKTkMydyOFUWfGqvOPnjDHJqJuT36/19w=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-table-row": ["@tiptap/extension-table-row@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-table-row/-/extension-table-row-3.24.0.tgz", { "peerDependencies": { "@tiptap/extension-table": "3.24.0" } }, "sha512-kASEYMWZF9ZqO6DRGz0LiD11c3LPfzOlFiJQVDrOyLP7xkJpHzIAKH0K+8u4aohOziDLn8nz+8I+3xjRS46oGg=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-task-item": ["@tiptap/extension-task-item@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-task-item/-/extension-task-item-3.24.0.tgz", { "peerDependencies": { "@tiptap/extension-list": "3.24.0" } }, "sha512-2VtX6VOp4SJk5E/VHeSC2Acfbge6h69TUBhF7hFebbiCDyB73xCaPKcuClIyzOlWJgpWz2ZPXwVB+Y3meXw+yw=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-task-list": ["@tiptap/extension-task-list@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-task-list/-/extension-task-list-3.24.0.tgz", { "peerDependencies": { "@tiptap/extension-list": "3.24.0" } }, "sha512-EIbB/WQ7L92WfaY5AF9upH93nIgBHjGt5zwPYoX+ZTpBncebr1AEDF7rc+LHNuSa1g1ToOKbFB88ep02IIXOjA=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-text": ["@tiptap/extension-text@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-text/-/extension-text-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-Im7keLPEihxm3+LyF+drYCoaOY5hlq35lvHAp/el6M8pJ/scts88HrYpdR1Yc4BtpZBIhfHSyWgPaupI4qwdeg=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-text-align": ["@tiptap/extension-text-align@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-text-align/-/extension-text-align-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-WKFtYXGthtkUc+Cwy2fItSr+9FKwLZjkJVAY1GhkRdcq35qTuVhkb4Q4wR2Rhkb6QRqtlxF1NDuTf2vxiQmfBQ=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-underline": ["@tiptap/extension-underline@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-underline/-/extension-underline-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-D4W4X3UMq9dLVIOfPB9+UodQ4eAJ8yDcm8qFWAwq0a15YWH6bnwulCuIdV+U5dEG+yaRxN8haB9GrrID9jmrSA=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-youtube": ["@tiptap/extension-youtube@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-youtube/-/extension-youtube-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-/irhKRjUgUV1qGlCld9fbMLZfrjkjJq2Kr5RwJLctafkBRprzn80vyTsxQmi+b65wOJ9Wnsy/v3hnD5OrrbiyA=="],
|
||||||
|
|
||||||
|
"@tiptap/extensions": ["@tiptap/extensions@3.24.0", "https://registry.npmmirror.com/@tiptap/extensions/-/extensions-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0", "@tiptap/pm": "3.24.0" } }, "sha512-z6gRYzy2ucJp07OQ0F2W07NxyhMTxPYH1ia2eGiQkWax1i56oExpjMsDHP8THWlg8Tb7NnbfKpkfh881EsmofA=="],
|
||||||
|
|
||||||
|
"@tiptap/pm": ["@tiptap/pm@3.24.0", "https://registry.npmmirror.com/@tiptap/pm/-/pm-3.24.0.tgz", { "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-commands": "^1.6.2", "prosemirror-dropcursor": "^1.8.1", "prosemirror-gapcursor": "^1.3.2", "prosemirror-history": "^1.4.1", "prosemirror-inputrules": "^1.4.0", "prosemirror-keymap": "^1.2.2", "prosemirror-model": "^1.24.1", "prosemirror-schema-list": "^1.5.0", "prosemirror-state": "^1.4.3", "prosemirror-tables": "^1.6.4", "prosemirror-transform": "^1.10.2", "prosemirror-view": "^1.38.1" } }, "sha512-QQP/78ryOZDN99gNBV7dgh69/8AYaOYQYFklq/iR+ZRFaaL3+qqHFvPVJapGkzPdymBgNJ34xjFM8n5pJ4QmMg=="],
|
||||||
|
|
||||||
|
"@tiptap/react": ["@tiptap/react@3.24.0", "https://registry.npmmirror.com/@tiptap/react/-/react-3.24.0.tgz", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "fast-equals": "^5.3.3", "use-sync-external-store": "^1.4.0" }, "optionalDependencies": { "@tiptap/extension-bubble-menu": "^3.24.0", "@tiptap/extension-floating-menu": "^3.24.0" }, "peerDependencies": { "@tiptap/core": "3.24.0", "@tiptap/pm": "3.24.0", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-KxnrlQbzOgA02EMsfuGGHtNhfkJQGqVlQttmQctI9DOl/F3gcaRqg+wNTBY1Fof8yDaZ8Z1LL1F0C05W0o3vUw=="],
|
||||||
|
|
||||||
|
"@tiptap/starter-kit": ["@tiptap/starter-kit@3.24.0", "https://registry.npmmirror.com/@tiptap/starter-kit/-/starter-kit-3.24.0.tgz", { "dependencies": { "@tiptap/core": "^3.24.0", "@tiptap/extension-blockquote": "^3.24.0", "@tiptap/extension-bold": "^3.24.0", "@tiptap/extension-bullet-list": "^3.24.0", "@tiptap/extension-code": "^3.24.0", "@tiptap/extension-code-block": "^3.24.0", "@tiptap/extension-document": "^3.24.0", "@tiptap/extension-dropcursor": "^3.24.0", "@tiptap/extension-gapcursor": "^3.24.0", "@tiptap/extension-hard-break": "^3.24.0", "@tiptap/extension-heading": "^3.24.0", "@tiptap/extension-horizontal-rule": "^3.24.0", "@tiptap/extension-italic": "^3.24.0", "@tiptap/extension-link": "^3.24.0", "@tiptap/extension-list": "^3.24.0", "@tiptap/extension-list-item": "^3.24.0", "@tiptap/extension-list-keymap": "^3.24.0", "@tiptap/extension-ordered-list": "^3.24.0", "@tiptap/extension-paragraph": "^3.24.0", "@tiptap/extension-strike": "^3.24.0", "@tiptap/extension-text": "^3.24.0", "@tiptap/extension-underline": "^3.24.0", "@tiptap/extensions": "^3.24.0", "@tiptap/pm": "^3.24.0" } }, "sha512-Ef4PCP96vcY2GonXN9J0M8iC6zvxPTmQlL/QZiCwuYqqnH/hNpYIjNSQdTndiDpxRKofa32Sr2HWktgEnL32Bg=="],
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.3.5", "https://registry.npmmirror.com/@types/bun/-/bun-1.3.5.tgz", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
|
"@types/bun": ["@types/bun@1.3.5", "https://registry.npmmirror.com/@types/bun/-/bun-1.3.5.tgz", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
|
||||||
|
|
||||||
|
"@types/hast": ["@types/hast@3.0.4", "https://registry.npmmirror.com/@types/hast/-/hast-3.0.4.tgz", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@20.17.24", "https://registry.npmmirror.com/@types/node/-/node-20.17.24.tgz", { "dependencies": { "undici-types": "6.19.8" } }, "sha512-d7fGCyB96w9BnWQrOsJtpyiSaBcAYYr75bnK6ZRjDbql2cGLj/3GsL5OYmLPNq76l7Gf2q4Rv9J2o6h5CrD9sA=="],
|
"@types/node": ["@types/node@20.17.24", "https://registry.npmmirror.com/@types/node/-/node-20.17.24.tgz", { "dependencies": { "undici-types": "6.19.8" } }, "sha512-d7fGCyB96w9BnWQrOsJtpyiSaBcAYYr75bnK6ZRjDbql2cGLj/3GsL5OYmLPNq76l7Gf2q4Rv9J2o6h5CrD9sA=="],
|
||||||
|
|
||||||
"@types/react": ["@types/react@19.2.7", "https://registry.npmmirror.com/@types/react/-/react-19.2.7.tgz", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
|
"@types/react": ["@types/react@19.2.7", "https://registry.npmmirror.com/@types/react/-/react-19.2.7.tgz", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
|
||||||
|
|
||||||
"@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=="],
|
"@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=="],
|
||||||
|
|
||||||
|
"@types/unist": ["@types/unist@3.0.3", "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||||
|
|
||||||
|
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "https://registry.npmmirror.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
|
||||||
|
|
||||||
"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=="],
|
"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=="],
|
||||||
|
|
||||||
"babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "https://registry.npmmirror.com/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="],
|
"babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "https://registry.npmmirror.com/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="],
|
||||||
@@ -348,16 +445,24 @@
|
|||||||
|
|
||||||
"date-fns": ["date-fns@4.1.0", "https://registry.npmmirror.com/date-fns/-/date-fns-4.1.0.tgz", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
|
"date-fns": ["date-fns@4.1.0", "https://registry.npmmirror.com/date-fns/-/date-fns-4.1.0.tgz", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
|
||||||
|
|
||||||
|
"dequal": ["dequal@2.0.3", "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||||
|
|
||||||
"detect-libc": ["detect-libc@2.1.2", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
"detect-libc": ["detect-libc@2.1.2", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
"detect-node-es": ["detect-node-es@1.1.0", "https://registry.npmmirror.com/detect-node-es/-/detect-node-es-1.1.0.tgz", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
"detect-node-es": ["detect-node-es@1.1.0", "https://registry.npmmirror.com/detect-node-es/-/detect-node-es-1.1.0.tgz", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||||
|
|
||||||
|
"devlop": ["devlop@1.1.0", "https://registry.npmmirror.com/devlop/-/devlop-1.1.0.tgz", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||||
|
|
||||||
"enhanced-resolve": ["enhanced-resolve@5.18.4", "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="],
|
"enhanced-resolve": ["enhanced-resolve@5.18.4", "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="],
|
||||||
|
|
||||||
|
"fast-equals": ["fast-equals@5.4.0", "https://registry.npmmirror.com/fast-equals/-/fast-equals-5.4.0.tgz", {}, "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw=="],
|
||||||
|
|
||||||
"get-nonce": ["get-nonce@1.0.1", "https://registry.npmmirror.com/get-nonce/-/get-nonce-1.0.1.tgz", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
"get-nonce": ["get-nonce@1.0.1", "https://registry.npmmirror.com/get-nonce/-/get-nonce-1.0.1.tgz", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||||
|
|
||||||
"graceful-fs": ["graceful-fs@4.2.11", "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
"graceful-fs": ["graceful-fs@4.2.11", "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||||
|
|
||||||
|
"highlight.js": ["highlight.js@11.11.1", "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
|
||||||
|
|
||||||
"jiti": ["jiti@2.6.1", "https://registry.npmmirror.com/jiti/-/jiti-2.6.1.tgz", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
"jiti": ["jiti@2.6.1", "https://registry.npmmirror.com/jiti/-/jiti-2.6.1.tgz", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||||
|
|
||||||
"jotai": ["jotai@2.19.0", "https://registry.npmmirror.com/jotai/-/jotai-2.19.0.tgz", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-r2wwxEXP1F2JteDLZEOPoIpAHhV89paKsN5GWVYndPNMMP/uVZDcC+fNj0A8NjKgaPWzdyO8Vp8YcYKe0uCEqQ=="],
|
"jotai": ["jotai@2.19.0", "https://registry.npmmirror.com/jotai/-/jotai-2.19.0.tgz", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-r2wwxEXP1F2JteDLZEOPoIpAHhV89paKsN5GWVYndPNMMP/uVZDcC+fNj0A8NjKgaPWzdyO8Vp8YcYKe0uCEqQ=="],
|
||||||
@@ -386,20 +491,54 @@
|
|||||||
|
|
||||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
|
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
|
||||||
|
|
||||||
|
"linkifyjs": ["linkifyjs@4.3.3", "https://registry.npmmirror.com/linkifyjs/-/linkifyjs-4.3.3.tgz", {}, "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg=="],
|
||||||
|
|
||||||
|
"lowlight": ["lowlight@3.3.0", "https://registry.npmmirror.com/lowlight/-/lowlight-3.3.0.tgz", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.11.0" } }, "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ=="],
|
||||||
|
|
||||||
"lucide-react": ["lucide-react@0.562.0", "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.562.0.tgz", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="],
|
"lucide-react": ["lucide-react@0.562.0", "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.562.0.tgz", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="],
|
||||||
|
|
||||||
"magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
"magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
|
"marked": ["marked@18.0.4", "https://registry.npmmirror.com/marked/-/marked-18.0.4.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA=="],
|
||||||
|
|
||||||
"nanoid": ["nanoid@3.3.9", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.9.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-SppoicMGpZvbF1l3z4x7No3OlIjP7QJvC9XR7AhZr1kL133KHnKPztkKDc+Ir4aJ/1VhTySrtKhrsycmrMQfvg=="],
|
"nanoid": ["nanoid@3.3.9", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.9.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-SppoicMGpZvbF1l3z4x7No3OlIjP7QJvC9XR7AhZr1kL133KHnKPztkKDc+Ir4aJ/1VhTySrtKhrsycmrMQfvg=="],
|
||||||
|
|
||||||
"next": ["next@16.1.1", "https://registry.npmmirror.com/next/-/next-16.1.1.tgz", { "dependencies": { "@next/env": "16.1.1", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.1", "@next/swc-darwin-x64": "16.1.1", "@next/swc-linux-arm64-gnu": "16.1.1", "@next/swc-linux-arm64-musl": "16.1.1", "@next/swc-linux-x64-gnu": "16.1.1", "@next/swc-linux-x64-musl": "16.1.1", "@next/swc-win32-arm64-msvc": "16.1.1", "@next/swc-win32-x64-msvc": "16.1.1", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w=="],
|
"next": ["next@16.1.1", "https://registry.npmmirror.com/next/-/next-16.1.1.tgz", { "dependencies": { "@next/env": "16.1.1", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.1", "@next/swc-darwin-x64": "16.1.1", "@next/swc-linux-arm64-gnu": "16.1.1", "@next/swc-linux-arm64-musl": "16.1.1", "@next/swc-linux-x64-gnu": "16.1.1", "@next/swc-linux-x64-musl": "16.1.1", "@next/swc-win32-arm64-msvc": "16.1.1", "@next/swc-win32-x64-msvc": "16.1.1", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w=="],
|
||||||
|
|
||||||
"next-themes": ["next-themes@0.4.6", "https://registry.npmmirror.com/next-themes/-/next-themes-0.4.6.tgz", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
|
"next-themes": ["next-themes@0.4.6", "https://registry.npmmirror.com/next-themes/-/next-themes-0.4.6.tgz", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
|
||||||
|
|
||||||
|
"orderedmap": ["orderedmap@2.1.1", "https://registry.npmmirror.com/orderedmap/-/orderedmap-2.1.1.tgz", {}, "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="],
|
||||||
|
|
||||||
"picocolors": ["picocolors@1.1.1", "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
"picocolors": ["picocolors@1.1.1", "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
"postcss": ["postcss@8.5.3", "https://registry.npmmirror.com/postcss/-/postcss-8.5.3.tgz", { "dependencies": { "nanoid": "3.3.9", "picocolors": "1.1.1", "source-map-js": "1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="],
|
"postcss": ["postcss@8.5.3", "https://registry.npmmirror.com/postcss/-/postcss-8.5.3.tgz", { "dependencies": { "nanoid": "3.3.9", "picocolors": "1.1.1", "source-map-js": "1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="],
|
||||||
|
|
||||||
|
"prosemirror-changeset": ["prosemirror-changeset@2.4.1", "https://registry.npmmirror.com/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz", { "dependencies": { "prosemirror-transform": "^1.0.0" } }, "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw=="],
|
||||||
|
|
||||||
|
"prosemirror-commands": ["prosemirror-commands@1.7.1", "https://registry.npmmirror.com/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.10.2" } }, "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w=="],
|
||||||
|
|
||||||
|
"prosemirror-dropcursor": ["prosemirror-dropcursor@1.8.2", "https://registry.npmmirror.com/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz", { "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0", "prosemirror-view": "^1.1.0" } }, "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw=="],
|
||||||
|
|
||||||
|
"prosemirror-gapcursor": ["prosemirror-gapcursor@1.4.1", "https://registry.npmmirror.com/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", { "dependencies": { "prosemirror-keymap": "^1.0.0", "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-view": "^1.0.0" } }, "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw=="],
|
||||||
|
|
||||||
|
"prosemirror-history": ["prosemirror-history@1.5.0", "https://registry.npmmirror.com/prosemirror-history/-/prosemirror-history-1.5.0.tgz", { "dependencies": { "prosemirror-state": "^1.2.2", "prosemirror-transform": "^1.0.0", "prosemirror-view": "^1.31.0", "rope-sequence": "^1.3.0" } }, "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg=="],
|
||||||
|
|
||||||
|
"prosemirror-inputrules": ["prosemirror-inputrules@1.5.1", "https://registry.npmmirror.com/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", { "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.0.0" } }, "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw=="],
|
||||||
|
|
||||||
|
"prosemirror-keymap": ["prosemirror-keymap@1.2.3", "https://registry.npmmirror.com/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", { "dependencies": { "prosemirror-state": "^1.0.0", "w3c-keyname": "^2.2.0" } }, "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw=="],
|
||||||
|
|
||||||
|
"prosemirror-model": ["prosemirror-model@1.25.7", "https://registry.npmmirror.com/prosemirror-model/-/prosemirror-model-1.25.7.tgz", { "dependencies": { "orderedmap": "^2.0.0" } }, "sha512-A79aN8QEFUwI6cax8Yq4Rpcx1TJZ3Kagn+ii7qLo4/V8H3mMiHrhFyhTyHHvpSnOgMPpWiDGSwM3etwrxE50ug=="],
|
||||||
|
|
||||||
|
"prosemirror-schema-list": ["prosemirror-schema-list@1.5.1", "https://registry.npmmirror.com/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.7.3" } }, "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q=="],
|
||||||
|
|
||||||
|
"prosemirror-state": ["prosemirror-state@1.4.4", "https://registry.npmmirror.com/prosemirror-state/-/prosemirror-state-1.4.4.tgz", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", "prosemirror-view": "^1.27.0" } }, "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw=="],
|
||||||
|
|
||||||
|
"prosemirror-tables": ["prosemirror-tables@1.8.5", "https://registry.npmmirror.com/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", { "dependencies": { "prosemirror-keymap": "^1.2.3", "prosemirror-model": "^1.25.4", "prosemirror-state": "^1.4.4", "prosemirror-transform": "^1.10.5", "prosemirror-view": "^1.41.4" } }, "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw=="],
|
||||||
|
|
||||||
|
"prosemirror-transform": ["prosemirror-transform@1.12.0", "https://registry.npmmirror.com/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", { "dependencies": { "prosemirror-model": "^1.21.0" } }, "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w=="],
|
||||||
|
|
||||||
|
"prosemirror-view": ["prosemirror-view@1.41.8", "https://registry.npmmirror.com/prosemirror-view/-/prosemirror-view-1.41.8.tgz", { "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0" } }, "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA=="],
|
||||||
|
|
||||||
"radix-ui": ["radix-ui@1.4.3", "https://registry.npmmirror.com/radix-ui/-/radix-ui-1.4.3.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="],
|
"radix-ui": ["radix-ui@1.4.3", "https://registry.npmmirror.com/radix-ui/-/radix-ui-1.4.3.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="],
|
||||||
|
|
||||||
"react": ["react@19.2.3", "https://registry.npmmirror.com/react/-/react-19.2.3.tgz", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
|
"react": ["react@19.2.3", "https://registry.npmmirror.com/react/-/react-19.2.3.tgz", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
|
||||||
@@ -414,6 +553,8 @@
|
|||||||
|
|
||||||
"react-style-singleton": ["react-style-singleton@2.2.3", "https://registry.npmmirror.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
"react-style-singleton": ["react-style-singleton@2.2.3", "https://registry.npmmirror.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||||
|
|
||||||
|
"rope-sequence": ["rope-sequence@1.3.4", "https://registry.npmmirror.com/rope-sequence/-/rope-sequence-1.3.4.tgz", {}, "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="],
|
||||||
|
|
||||||
"scheduler": ["scheduler@0.27.0", "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
"scheduler": ["scheduler@0.27.0", "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||||
|
|
||||||
"semver": ["semver@7.7.3", "https://registry.npmmirror.com/semver/-/semver-7.7.3.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
"semver": ["semver@7.7.3", "https://registry.npmmirror.com/semver/-/semver-7.7.3.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||||
@@ -448,6 +589,8 @@
|
|||||||
|
|
||||||
"use-sync-external-store": ["use-sync-external-store@1.6.0", "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
"use-sync-external-store": ["use-sync-external-store@1.6.0", "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||||
|
|
||||||
|
"w3c-keyname": ["w3c-keyname@2.2.8", "https://registry.npmmirror.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
|
||||||
|
|
||||||
"zod": ["zod@3.25.76", "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
"zod": ["zod@3.25.76", "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||||
|
|
||||||
"zustand": ["zustand@5.0.9", "https://registry.npmmirror.com/zustand/-/zustand-5.0.9.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-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg=="],
|
"zustand": ["zustand@5.0.9", "https://registry.npmmirror.com/zustand/-/zustand-5.0.9.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-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg=="],
|
||||||
|
|||||||
17
package.json
17
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lanhu-admin",
|
"name": "lanhu-admin",
|
||||||
"version": "1.5.0",
|
"version": "1.9.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -H 0.0.0.0 -p 3001 --turbopack",
|
"dev": "next dev -H 0.0.0.0 -p 3001 --turbopack",
|
||||||
@@ -18,11 +18,26 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tiptap/extension-code-block-lowlight": "^3.24.0",
|
||||||
|
"@tiptap/extension-image": "^3.24.0",
|
||||||
|
"@tiptap/extension-table": "^3.24.0",
|
||||||
|
"@tiptap/extension-table-cell": "^3.24.0",
|
||||||
|
"@tiptap/extension-table-header": "^3.24.0",
|
||||||
|
"@tiptap/extension-table-row": "^3.24.0",
|
||||||
|
"@tiptap/extension-task-item": "^3.24.0",
|
||||||
|
"@tiptap/extension-task-list": "^3.24.0",
|
||||||
|
"@tiptap/extension-text-align": "^3.24.0",
|
||||||
|
"@tiptap/extension-youtube": "^3.24.0",
|
||||||
|
"@tiptap/pm": "^3.24.0",
|
||||||
|
"@tiptap/react": "^3.24.0",
|
||||||
|
"@tiptap/starter-kit": "^3.24.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"jotai": "^2.19.0",
|
"jotai": "^2.19.0",
|
||||||
|
"lowlight": "^3.3.0",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
|
"marked": "^18.0.4",
|
||||||
"next": "^16.0.10",
|
"next": "^16.0.10",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ if ($confrim -ne "y") {
|
|||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
docker build -t repo.lanhuip.com:8554/lanhu/admin:latest .
|
docker build -t repo.lanhuip.com/lanhu/admin:latest .
|
||||||
docker build -t repo.lanhuip.com:8554/lanhu/admin:$($args[0]) .
|
docker build -t repo.lanhuip.com/lanhu/admin:$($args[0]) .
|
||||||
|
|
||||||
docker push repo.lanhuip.com:8554/lanhu/admin:latest
|
docker push repo.lanhuip.com/lanhu/admin:latest
|
||||||
docker push repo.lanhuip.com:8554/lanhu/admin:$($args[0])
|
docker push repo.lanhuip.com/lanhu/admin:$($args[0])
|
||||||
|
|||||||
44
src/actions/article-group.ts
Normal file
44
src/actions/article-group.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
import type { PageRecord } from "@/lib/api"
|
||||||
|
import type { ArticleGroup } from "@/models/article-group"
|
||||||
|
import { callByUser } from "./base"
|
||||||
|
|
||||||
|
export async function getArticleGroupList() {
|
||||||
|
return callByUser<ArticleGroup[]>("/api/admin/article-group/list")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getArticleGroupPage(params?: {
|
||||||
|
page?: number
|
||||||
|
size?: number
|
||||||
|
keyword?: string
|
||||||
|
status?: number
|
||||||
|
}) {
|
||||||
|
return callByUser<PageRecord<ArticleGroup>>(
|
||||||
|
"/api/admin/article-group/page",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createArticleGroup(data: {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
sort?: number
|
||||||
|
status?: number
|
||||||
|
}) {
|
||||||
|
return callByUser<ArticleGroup>("/api/admin/article-group/create", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateArticleGroup(data: {
|
||||||
|
id: number
|
||||||
|
code?: string
|
||||||
|
name?: string
|
||||||
|
sort?: number
|
||||||
|
status?: number
|
||||||
|
}) {
|
||||||
|
return callByUser<ArticleGroup>("/api/admin/article-group/update", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeArticleGroup(id: number) {
|
||||||
|
return callByUser<void>("/api/admin/article-group/remove", { id })
|
||||||
|
}
|
||||||
47
src/actions/article.ts
Normal file
47
src/actions/article.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
import type { PageRecord } from "@/lib/api"
|
||||||
|
import type { Article, UploadImageResult } from "@/models/article"
|
||||||
|
import { callByUser, callByUserUpload } from "./base"
|
||||||
|
|
||||||
|
export async function getArticlePage(params: { page: number; size: number }) {
|
||||||
|
return callByUser<PageRecord<Article>>("/api/admin/article/page", params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getArticle(id: number) {
|
||||||
|
return callByUser<Article>("/api/admin/article/get", { id })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createArticle(data: {
|
||||||
|
title: string
|
||||||
|
group_id: number
|
||||||
|
content?: string
|
||||||
|
sort?: number
|
||||||
|
status?: number
|
||||||
|
}) {
|
||||||
|
return callByUser<Article>("/api/admin/article/create", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateArticle(data: {
|
||||||
|
id: number
|
||||||
|
title?: string
|
||||||
|
content?: string
|
||||||
|
group_id?: number
|
||||||
|
sort?: number
|
||||||
|
status?: number
|
||||||
|
}) {
|
||||||
|
return callByUser<Article>("/api/admin/article/update", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeArticle(id: number) {
|
||||||
|
return callByUser<void>("/api/admin/article/remove", { id })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadArticleImage(file: File) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append("file", file)
|
||||||
|
return callByUserUpload<UploadImageResult>(
|
||||||
|
"/api/admin/article/upload",
|
||||||
|
formData,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use server"
|
"use server"
|
||||||
import { cookies, headers } from "next/headers"
|
import { cookies, headers } from "next/headers"
|
||||||
import { redirect } from "next/navigation"
|
// import { redirect } from "next/navigation"
|
||||||
import { cache } from "react"
|
import { cache } from "react"
|
||||||
import {
|
import {
|
||||||
API_BASE_URL,
|
API_BASE_URL,
|
||||||
@@ -164,21 +164,108 @@ async function call<R = undefined>(
|
|||||||
throw new Error(`无法解析响应数据,未处理的 Content-Type: ${type}`)
|
throw new Error(`无法解析响应数据,未处理的 Content-Type: ${type}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postCall<R = undefined>(rawResp: Promise<ApiResponse<R>>) {
|
// async function postCall<R = undefined>(rawResp: Promise<ApiResponse<R>>) {
|
||||||
const header = await headers()
|
// const header = await headers()
|
||||||
const pathname = header.get("x-pathname") || "/"
|
// const pathname = header.get("x-pathname") || "/"
|
||||||
const resp = await rawResp
|
// const resp = await rawResp
|
||||||
|
|
||||||
// 重定向到登录页
|
// // 重定向到登录页
|
||||||
const match = [/^\/admin.*/].some(item => item.test(pathname))
|
// const match = [/^\/admin.*/].some(item => item.test(pathname))
|
||||||
|
|
||||||
if (match && !resp.success && resp.status === 401) {
|
// if (match && !resp.success && resp.status === 401) {
|
||||||
console.log("🚗🚗🚗🚗🚗 非正常重定向 🚗🚗🚗🚗🚗")
|
// console.log("🚗🚗🚗🚗🚗 非正常重定向 🚗🚗🚗🚗🚗")
|
||||||
redirect("/login?force=true")
|
// redirect("/login?force=true")
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return resp
|
||||||
|
// }
|
||||||
|
|
||||||
|
// ======================
|
||||||
|
// upload
|
||||||
|
// ======================
|
||||||
|
|
||||||
|
async function callByUserUpload<R = undefined>(
|
||||||
|
endpoint: string,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<ApiResponse<R>> {
|
||||||
|
const cookie = await cookies()
|
||||||
|
const token = cookie.get("admin/auth_token")?.value
|
||||||
|
if (!token) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
status: 401,
|
||||||
|
message: "会话已失效",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return resp
|
return callUpload<R>(
|
||||||
|
`${API_BASE_URL}${endpoint}`,
|
||||||
|
formData,
|
||||||
|
`Bearer ${token}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callUpload<R = undefined>(
|
||||||
|
url: string,
|
||||||
|
body: FormData,
|
||||||
|
auth?: string,
|
||||||
|
): Promise<ApiResponse<R>> {
|
||||||
|
let response: Response
|
||||||
|
try {
|
||||||
|
const reqHeaders = await headers()
|
||||||
|
const reqIP = reqHeaders.get("x-forwarded-for")
|
||||||
|
const reqUA = reqHeaders.get("user-agent")
|
||||||
|
const callHeaders: RequestInit["headers"] = {}
|
||||||
|
if (auth) callHeaders["Authorization"] = auth
|
||||||
|
if (reqIP) callHeaders["X-Forwarded-For"] = reqIP
|
||||||
|
if (reqUA) callHeaders["User-Agent"] = reqUA
|
||||||
|
|
||||||
|
response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: callHeaders,
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error("后端请求失败", url, (e as Error).message)
|
||||||
|
throw new Error(`请求失败,网络错误`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = response.headers.get("Content-Type") ?? "text/plain"
|
||||||
|
if (type.indexOf("text/plain") !== -1) {
|
||||||
|
const text = await response.text()
|
||||||
|
if (!response.ok) {
|
||||||
|
console.log("后端请求失败", url, `status=${response.status}`, text)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
status: response.status,
|
||||||
|
message: text || "请求失败",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (text?.trim()?.length) {
|
||||||
|
console.log("未处理的响应成功", `type=text`, `text=${text}`)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: undefined as R,
|
||||||
|
}
|
||||||
|
} else if (type.indexOf("application/json") !== -1) {
|
||||||
|
const json = await response.json()
|
||||||
|
if (!response.ok) {
|
||||||
|
console.log("后端请求失败", url, `status=${response.status}`, json)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
status: response.status,
|
||||||
|
message: json.message || json.error_description || "请求失败",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: json,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`无法解析响应数据,未处理的 Content-Type: ${type}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 导出
|
// 导出
|
||||||
export { callPublic, callByDevice, callByUser }
|
export { callPublic, callByDevice, callByUser, callByUserUpload }
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export async function getPageChannel(params: {
|
|||||||
node_ip?: string
|
node_ip?: string
|
||||||
expired_at_start?: Date
|
expired_at_start?: Date
|
||||||
expired_at_end?: Date
|
expired_at_end?: Date
|
||||||
|
expired?: boolean
|
||||||
}) {
|
}) {
|
||||||
return callByUser<PageRecord<Channel>>("/api/admin/channel/page", params)
|
return callByUser<PageRecord<Channel>>("/api/admin/channel/page", params)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,22 +7,28 @@ export async function getPagCoupon(params: { page: number; size: number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createCoupon(data: {
|
export async function createCoupon(data: {
|
||||||
code: string
|
name: string
|
||||||
amount: number
|
amount: number
|
||||||
remark?: string
|
count: number
|
||||||
min_amount?: number
|
status: number
|
||||||
|
min_amount: number
|
||||||
expire_at?: Date
|
expire_at?: Date
|
||||||
|
expire_in?: number
|
||||||
|
expire_type: number
|
||||||
}) {
|
}) {
|
||||||
return callByUser<Coupon>("/api/admin/coupon/create", data)
|
return callByUser<Coupon>("/api/admin/coupon/create", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateCoupon(data: {
|
export async function updateCoupon(data: {
|
||||||
code: string
|
id: number
|
||||||
|
name: string
|
||||||
amount: number
|
amount: number
|
||||||
remark?: string
|
min_amount: number
|
||||||
min_amount?: number
|
count: number
|
||||||
|
status: number
|
||||||
|
expire_type: number
|
||||||
expire_at?: Date
|
expire_at?: Date
|
||||||
status?: number
|
expire_in?: number
|
||||||
}) {
|
}) {
|
||||||
return callByUser<Coupon>("/api/admin/coupon/update", data)
|
return callByUser<Coupon>("/api/admin/coupon/update", data)
|
||||||
}
|
}
|
||||||
@@ -32,3 +38,25 @@ export async function deleteCoupon(id: number) {
|
|||||||
id,
|
id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getReleaseCoupon(data: {
|
||||||
|
coupon_id: number
|
||||||
|
user_id: number
|
||||||
|
}) {
|
||||||
|
return callByUser<Coupon>("/api/admin/coupon/update/assign", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserCoupon(data: {
|
||||||
|
page: number
|
||||||
|
size: number
|
||||||
|
user_id: number
|
||||||
|
}) {
|
||||||
|
return callByUser<PageRecord<Coupon>>(
|
||||||
|
"/api/admin/coupon-user/page/of-user",
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCouponList(data: { page: number; size: number }) {
|
||||||
|
return callByUser<PageRecord<Coupon>>("/api/admin/coupon-user/page ", data)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,16 @@ import type { PageRecord } from "@/lib/api"
|
|||||||
import type { User } from "@/models/user"
|
import type { User } from "@/models/user"
|
||||||
import { callByUser } from "./base"
|
import { callByUser } from "./base"
|
||||||
|
|
||||||
export async function getPageCusts(params: { page: number; size: number }) {
|
export async function getPageCusts(params: {
|
||||||
|
page: number
|
||||||
|
size: number
|
||||||
|
account?: string
|
||||||
|
name?: string
|
||||||
|
identified?: boolean
|
||||||
|
enabled?: boolean
|
||||||
|
created_at_start?: Date
|
||||||
|
created_at_end?: Date
|
||||||
|
}) {
|
||||||
return callByUser<PageRecord<User>>("/api/admin/user/page", params)
|
return callByUser<PageRecord<User>>("/api/admin/user/page", params)
|
||||||
}
|
}
|
||||||
export async function updateCust(data: {
|
export async function updateCust(data: {
|
||||||
|
|||||||
10
src/actions/env.ts
Normal file
10
src/actions/env.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
const NODE_ENV = process.env.NODE_ENV
|
||||||
|
|
||||||
|
export async function getNodeEnv() {
|
||||||
|
if (!NODE_ENV) {
|
||||||
|
toast.error(`接口请求错误:NODE_ENV为空`)
|
||||||
|
}
|
||||||
|
return NODE_ENV
|
||||||
|
}
|
||||||
@@ -8,19 +8,28 @@ export async function getGatewayPage(params: { page: number; size: number }) {
|
|||||||
return callByUser<PageRecord<Gateway>>("/api/admin/proxy/page", params)
|
return callByUser<PageRecord<Gateway>>("/api/admin/proxy/page", params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function createGateway(data: {
|
export async function createGateway(data: {
|
||||||
mac: string
|
mac: string
|
||||||
ip: string
|
ip: string
|
||||||
host?: string
|
host?: string
|
||||||
type: number
|
type: number
|
||||||
status: number
|
status: number
|
||||||
|
secret?: string
|
||||||
|
port?: number
|
||||||
}) {
|
}) {
|
||||||
return callByUser<Gateway>("/api/admin/proxy/create", data)
|
return callByUser<Gateway>("/api/admin/proxy/create", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deletegateway(id: number) {
|
export async function updateGateway(data: { id: number; status: number }) {
|
||||||
return callByUser<Gateway>("/api/admin/proxy/remove", {
|
return callByUser<Gateway>("/api/admin/proxy/update/status", data)
|
||||||
id,
|
}
|
||||||
})
|
|
||||||
|
export async function clear(data: { proxy_id: number }) {
|
||||||
|
return callByUser<PageRecord>("/api/admin/channel/sync/clear-expired", data)
|
||||||
|
}
|
||||||
|
export async function createProxyPort(data: { id: number }) {
|
||||||
|
return callByUser<PageRecord>("/api/admin/proxy/sync/ports", data)
|
||||||
|
}
|
||||||
|
export async function createProxyChains(data: { id: number }) {
|
||||||
|
return callByUser<PageRecord>("/api/admin/proxy/sync/chains", data)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,11 @@ export async function listResourceShort(params: ResourceListParams) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateResource(data: { id: number; active?: boolean }) {
|
export async function updateResource(data: {
|
||||||
|
id: number
|
||||||
|
active?: boolean
|
||||||
|
checkip?: boolean
|
||||||
|
}) {
|
||||||
return callByUser<Resources>("/api/admin/resource/update", data)
|
return callByUser<Resources>("/api/admin/resource/update", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,514 +1,438 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { format, subDays } from "date-fns"
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
ArrowDownRight,
|
||||||
|
ArrowUpRight,
|
||||||
|
BarChart3,
|
||||||
|
Calendar,
|
||||||
|
DoorOpenIcon,
|
||||||
|
Server,
|
||||||
|
TrendingUp,
|
||||||
|
UserPlus,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import {
|
||||||
|
SimpleBarChart,
|
||||||
|
SimpleHorizontalBarChart,
|
||||||
|
SimpleLineChart,
|
||||||
|
} from "@/components/charts"
|
||||||
|
import { Page } from "@/components/page"
|
||||||
|
|
||||||
export type DashboardPageProps = {}
|
type TimeRange = "today" | "7d" | "30d" | "90d" | "custom"
|
||||||
|
|
||||||
|
const timeRangeLabels: Record<TimeRange, string> = {
|
||||||
|
today: "今日",
|
||||||
|
"7d": "近7天",
|
||||||
|
"30d": "近30天",
|
||||||
|
"90d": "近90天",
|
||||||
|
custom: "自定义",
|
||||||
|
}
|
||||||
|
|
||||||
|
function mulberry32(seed: number) {
|
||||||
|
let t = seed
|
||||||
|
return () => {
|
||||||
|
t |= 0
|
||||||
|
t = (t + 0x6d2b79f5) | 0
|
||||||
|
let n = Math.imul(t ^ (t >>> 15), 1 | t)
|
||||||
|
n = (n + Math.imul(n ^ (n >>> 7), 61 | n)) ^ n
|
||||||
|
return ((n ^ (n >>> 14)) >>> 0) / 4294967296
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const [timeRange, setTimeRange] = useState<TimeRange>("7d")
|
||||||
|
const [showCustomPicker, setShowCustomPicker] = useState(false)
|
||||||
|
const [startDate, setStartDate] = useState("")
|
||||||
|
const [endDate, setEndDate] = useState("")
|
||||||
|
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
const days = 14
|
||||||
|
const seed = 42
|
||||||
|
const rng = mulberry32(seed)
|
||||||
|
const proxyData = []
|
||||||
|
const tradeData = []
|
||||||
|
const customerData = []
|
||||||
|
const extractData = []
|
||||||
|
|
||||||
|
for (let i = days - 1; i >= 0; i--) {
|
||||||
|
const date = subDays(new Date(), i)
|
||||||
|
const dateStr = format(date, "MM-dd")
|
||||||
|
proxyData.push({
|
||||||
|
date: dateStr,
|
||||||
|
value: Math.floor(rng() * 50) + 80,
|
||||||
|
})
|
||||||
|
tradeData.push({
|
||||||
|
date: dateStr,
|
||||||
|
value: Math.floor(rng() * 3000) + 5000,
|
||||||
|
})
|
||||||
|
customerData.push({
|
||||||
|
date: dateStr,
|
||||||
|
value: Math.floor(rng() * 20) + 5,
|
||||||
|
})
|
||||||
|
extractData.push({
|
||||||
|
date: dateStr,
|
||||||
|
value: Math.floor(rng() * 800) + 1200,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const userData = [
|
||||||
|
{ name: "张三", activity: 287 },
|
||||||
|
{ name: "李四", activity: 245 },
|
||||||
|
{ name: "王五", activity: 198 },
|
||||||
|
{ name: "赵六", activity: 156 },
|
||||||
|
{ name: "孙七", activity: 132 },
|
||||||
|
{ name: "周八", activity: 98 },
|
||||||
|
{ name: "吴九", activity: 76 },
|
||||||
|
]
|
||||||
|
|
||||||
|
return { proxyData, tradeData, customerData, extractData, userData }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 处理自定义日期按钮点击
|
||||||
|
const handleCustomClick = () => {
|
||||||
|
setShowCustomPicker(true)
|
||||||
|
setTimeRange("custom")
|
||||||
|
// 清空之前的日期
|
||||||
|
setStartDate("")
|
||||||
|
setEndDate("")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理日期确认
|
||||||
|
const handleDateConfirm = () => {
|
||||||
|
if (startDate && endDate) {
|
||||||
|
console.log("自定义日期范围:", startDate, "~", endDate)
|
||||||
|
setShowCustomPicker(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理取消
|
||||||
|
const handleCancel = () => {
|
||||||
|
setShowCustomPicker(false)
|
||||||
|
setTimeRange("7d")
|
||||||
|
}
|
||||||
|
|
||||||
export default function DashboardPage(props: DashboardPageProps) {
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<Page className="overflow-auto">
|
||||||
{/* 欢迎区域 - 全宽 */}
|
{/* 欢迎栏 */}
|
||||||
<div className="bg-white border border-gray-200 rounded-md">
|
<div className="bg-card rounded-lg border p-2 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
<div className="flex items-center justify-between p-5">
|
<div>
|
||||||
<div>
|
<h1 className="text-lg font-bold">IP代理管理控制台</h1>
|
||||||
<h1 className="text-xl font-bold text-gray-800">
|
<p className="text-sm text-muted-foreground mt-1">上次更新: --</p>
|
||||||
IP代理管理控制台
|
</div>
|
||||||
</h1>
|
<div className="flex gap-2 flex-wrap">
|
||||||
<p className="text-gray-500 mt-1">上次更新: -</p>
|
<Link
|
||||||
</div>
|
href="/cust"
|
||||||
<div className="flex space-x-3">
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md border text-sm text-muted-foreground hover:bg-accent transition-colors"
|
||||||
<button className="px-4 py-2 bg-gray-100 text-gray-700 border border-gray-200 rounded-md hover:bg-gray-200 transition-colors text-sm font-medium">
|
>
|
||||||
查看使用报告
|
<Users className="h-4 w-4" />
|
||||||
</button>
|
客户管理
|
||||||
<button className="px-4 py-2 bg-blue-600 text-white border border-blue-700 rounded-md hover:bg-blue-700 transition-colors text-sm font-medium">
|
</Link>
|
||||||
添加新代理
|
<Link
|
||||||
</button>
|
href="/trade"
|
||||||
</div>
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md border text-sm text-muted-foreground hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<Activity className="h-4 w-4" />
|
||||||
|
交易明细
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/gateway"
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md border text-sm text-muted-foreground hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<DoorOpenIcon className="h-4 w-4" />
|
||||||
|
网关列表
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 主体内容 - 双栏布局 */}
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
<div className="flex flex-col lg:flex-row space-y-5 lg:space-y-0 lg:space-x-5">
|
{/* 指标卡片 */}
|
||||||
{/* 左侧栏 - 占比较大 */}
|
<div className="lg:col-span-2 space-y-4">
|
||||||
<div className="w-full lg:w-8/12 space-y-5">
|
<div className="space-y-3">
|
||||||
{/* 代理资源统计卡片组 */}
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-5">
|
<StatCard
|
||||||
<DataCard
|
title="待认领客户数量"
|
||||||
title="在线代理"
|
value="--"
|
||||||
value="14,283"
|
change="--"
|
||||||
change="+12.5%"
|
trend="up"
|
||||||
isIncrease={true}
|
icon={Users}
|
||||||
icon={
|
iconColor="text-cyan-600"
|
||||||
<svg
|
iconBg="bg-cyan-50 border-cyan-100"
|
||||||
className="h-6 w-6"
|
/>
|
||||||
fill="none"
|
<StatCard
|
||||||
viewBox="0 0 24 24"
|
title="活跃代理数"
|
||||||
stroke="currentColor"
|
value="--"
|
||||||
>
|
change="--"
|
||||||
<path
|
trend="up"
|
||||||
strokeLinecap="round"
|
icon={Server}
|
||||||
strokeLinejoin="round"
|
iconColor="text-blue-600"
|
||||||
strokeWidth={2}
|
iconBg="bg-blue-50 border-blue-100"
|
||||||
d="M5 12h14M12 5l7 7-7 7"
|
/>
|
||||||
/>
|
<StatCard
|
||||||
</svg>
|
title="今日交易额"
|
||||||
}
|
value="--"
|
||||||
/>
|
change="--"
|
||||||
<DataCard
|
trend="up"
|
||||||
title="总请求数"
|
icon={TrendingUp}
|
||||||
value="851,492"
|
iconColor="text-amber-600"
|
||||||
change="+8.2%"
|
iconBg="bg-amber-50 border-amber-100"
|
||||||
isIncrease={true}
|
/>
|
||||||
icon={
|
<StatCard
|
||||||
<svg
|
title="今日提取数量"
|
||||||
className="h-6 w-6"
|
value="--"
|
||||||
fill="none"
|
change="--"
|
||||||
viewBox="0 0 24 24"
|
trend="up"
|
||||||
stroke="currentColor"
|
icon={BarChart3}
|
||||||
>
|
iconColor="text-rose-600"
|
||||||
<path
|
iconBg="bg-rose-50 border-rose-100"
|
||||||
strokeLinecap="round"
|
/>
|
||||||
strokeLinejoin="round"
|
<StatCard
|
||||||
strokeWidth={2}
|
title="今日新增客户"
|
||||||
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
|
value="--"
|
||||||
/>
|
change="--"
|
||||||
</svg>
|
trend="up"
|
||||||
}
|
icon={UserPlus}
|
||||||
/>
|
iconColor="text-blue-600"
|
||||||
<DataCard
|
iconBg="bg-blue-50 border-blue-100"
|
||||||
title="成功率"
|
/>
|
||||||
value="98.5%"
|
</div>
|
||||||
change="+2.4%"
|
|
||||||
isIncrease={true}
|
|
||||||
icon={
|
|
||||||
<svg
|
|
||||||
className="h-6 w-6"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<DataCard
|
|
||||||
title="平均响应时间"
|
|
||||||
value="0.82s"
|
|
||||||
change="-12.3%"
|
|
||||||
isIncrease={true}
|
|
||||||
icon={
|
|
||||||
<svg
|
|
||||||
className="h-6 w-6"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 代理使用图表 */}
|
{/* 时间筛选栏 */}
|
||||||
<div className="bg-white border border-gray-200 rounded-md">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-end gap-3">
|
||||||
<div className="flex justify-between items-center p-5 border-b border-gray-200">
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="font-bold text-gray-800">代理使用趋势</h2>
|
<span className="text-sm text-muted-foreground">数据周期</span>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="h-4 w-px bg-border" />
|
||||||
<select className="text-sm border border-gray-200 rounded-md px-3 py-1 bg-white">
|
<div className="flex rounded-md border overflow-hidden">
|
||||||
<option>今日</option>
|
{(Object.keys(timeRangeLabels) as TimeRange[]).map(key => (
|
||||||
<option>本周</option>
|
<button
|
||||||
<option>本月</option>
|
key={key}
|
||||||
<option>全年</option>
|
onClick={() => {
|
||||||
</select>
|
if (key === "custom") {
|
||||||
</div>
|
handleCustomClick()
|
||||||
</div>
|
} else {
|
||||||
<div className="h-80 bg-white p-5 flex items-center justify-center text-gray-400 border-b border-gray-200">
|
setTimeRange(key)
|
||||||
请求量与响应时间统计图表
|
setShowCustomPicker(false)
|
||||||
</div>
|
}
|
||||||
<div className="flex justify-between p-5 text-sm">
|
}}
|
||||||
<div className="text-gray-500">
|
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||||
<span className="inline-block w-3 h-3 rounded-full bg-blue-500 mr-1"></span>
|
timeRange === key && !showCustomPicker
|
||||||
请求数量
|
? "bg-primary text-primary-foreground"
|
||||||
</div>
|
: "text-muted-foreground hover:bg-accent"
|
||||||
<div className="text-gray-500">
|
}`}
|
||||||
<span className="inline-block w-3 h-3 rounded-full bg-green-500 mr-1"></span>
|
>
|
||||||
成功率
|
{timeRangeLabels[key]}
|
||||||
</div>
|
</button>
|
||||||
<div className="text-gray-500">
|
|
||||||
<span className="inline-block w-3 h-3 rounded-full bg-orange-500 mr-1"></span>
|
|
||||||
响应时间
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* IP代理列表 */}
|
|
||||||
<div className="bg-white border border-gray-200 rounded-md">
|
|
||||||
<div className="p-5 border-b border-gray-200">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<h2 className="font-bold text-gray-800">活跃代理IP</h2>
|
|
||||||
<Link
|
|
||||||
href="/proxies"
|
|
||||||
className="text-blue-600 text-sm hover:underline"
|
|
||||||
>
|
|
||||||
查看全部
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-gray-50 text-left text-xs text-gray-500 uppercase tracking-wider border-b border-gray-200">
|
|
||||||
<th className="px-5 py-3">IP地址</th>
|
|
||||||
<th className="px-5 py-3">位置</th>
|
|
||||||
<th className="px-5 py-3">状态</th>
|
|
||||||
<th className="px-5 py-3">请求次数</th>
|
|
||||||
<th className="px-5 py-3">成功率</th>
|
|
||||||
<th className="px-5 py-3">操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-200">
|
|
||||||
{[1, 2, 3, 4, 5].map(item => (
|
|
||||||
<ContentRow
|
|
||||||
key={item}
|
|
||||||
ip={`192.168.${item}.${item * 10}`}
|
|
||||||
location={item % 2 === 0 ? "中国" : "美国"}
|
|
||||||
status={
|
|
||||||
item % 3 === 0
|
|
||||||
? "error"
|
|
||||||
: item % 2 === 0
|
|
||||||
? "warning"
|
|
||||||
: "active"
|
|
||||||
}
|
|
||||||
requests={1}
|
|
||||||
successRate={`2%`}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
</div>
|
||||||
|
|
||||||
|
{/* 点击自定义时显示 */}
|
||||||
|
{showCustomPicker && (
|
||||||
|
<div className="flex gap-2 items-center p-1 rounded-md">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="px-2 py-1 border rounded text-sm bg-background"
|
||||||
|
value={startDate}
|
||||||
|
onChange={e => setStartDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">~</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="px-2 py-1 border rounded text-sm bg-background"
|
||||||
|
value={endDate}
|
||||||
|
onChange={e => setEndDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleDateConfirm}
|
||||||
|
disabled={!startDate || !endDate}
|
||||||
|
className="px-3 py-1 bg-primary text-primary-foreground rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
确定
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="px-3 py-1 border rounded text-sm hover:bg-accent"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-gray-50 border-t border-gray-200 flex justify-end">
|
</div>
|
||||||
<button className="px-3 py-1 bg-white border border-gray-200 rounded-md text-sm mr-2">
|
|
||||||
上一页
|
{/* 图表区域 */}
|
||||||
</button>
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
<button className="px-3 py-1 bg-blue-600 text-white rounded-md text-sm">
|
{/* 每日活跃代理 */}
|
||||||
下一页
|
<div className="bg-card rounded-lg border p-4">
|
||||||
</button>
|
<h3 className="font-semibold text-sm mb-4">每日活跃代理</h3>
|
||||||
|
<SimpleLineChart
|
||||||
|
data={chartData.proxyData}
|
||||||
|
dataKey="value"
|
||||||
|
xAxisKey="date"
|
||||||
|
stroke="#3b82f6"
|
||||||
|
height={250}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 每日交易额 */}
|
||||||
|
<div className="bg-card rounded-lg border p-4">
|
||||||
|
<h3 className="font-semibold text-sm mb-4">每日交易额</h3>
|
||||||
|
<SimpleBarChart
|
||||||
|
data={chartData.tradeData}
|
||||||
|
dataKey="value"
|
||||||
|
xAxisKey="date"
|
||||||
|
fill="#f59e0b"
|
||||||
|
height={250}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 每日新增客户 */}
|
||||||
|
<div className="bg-card rounded-lg border p-4">
|
||||||
|
<h3 className="font-semibold text-sm mb-4">每日新增客户</h3>
|
||||||
|
<SimpleLineChart
|
||||||
|
data={chartData.customerData}
|
||||||
|
dataKey="value"
|
||||||
|
xAxisKey="date"
|
||||||
|
stroke="#10b981"
|
||||||
|
height={250}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 每日提取量 */}
|
||||||
|
<div className="bg-card rounded-lg border p-4">
|
||||||
|
<h3 className="font-semibold text-sm mb-4">每日提取量</h3>
|
||||||
|
<SimpleBarChart
|
||||||
|
data={chartData.extractData}
|
||||||
|
dataKey="value"
|
||||||
|
xAxisKey="date"
|
||||||
|
fill="#ef4444"
|
||||||
|
height={250}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 右侧栏 - 占比较小 */}
|
{/*套餐统计 */}
|
||||||
<div className="w-full lg:w-4/12 space-y-5">
|
<div className="lg:col-span-1 space-y-4">
|
||||||
{/* 代理资源分布 */}
|
<div className="bg-card rounded-lg border">
|
||||||
<div className="bg-white border border-gray-200 rounded-md">
|
<div className="p-5 border-b">
|
||||||
<div className="p-5 border-b border-gray-200">
|
<h2 className="font-semibold">套餐统计</h2>
|
||||||
<h2 className="font-bold text-gray-800">资源分布</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="h-64 bg-white p-5 flex items-center justify-center text-gray-400">
|
<div className="p-5">
|
||||||
IP地区分布饼图
|
<div className="flex flex-col gap-4">
|
||||||
</div>
|
<ResourceItem
|
||||||
<div className="grid grid-cols-2 gap-3 p-5 border-t border-gray-200">
|
label="长效套餐"
|
||||||
<div className="flex justify-between p-2 bg-gray-50 rounded-md border border-gray-200">
|
active="--"
|
||||||
<span className="text-xs text-gray-600">中国</span>
|
total="--"
|
||||||
<span className="text-xs font-medium">42%</span>
|
color="bg-blue-500"
|
||||||
</div>
|
/>
|
||||||
<div className="flex justify-between p-2 bg-gray-50 rounded-md border border-gray-200">
|
<ResourceItem
|
||||||
<span className="text-xs text-gray-600">美国</span>
|
label="短效套餐"
|
||||||
<span className="text-xs font-medium">28%</span>
|
active="--"
|
||||||
</div>
|
total="--"
|
||||||
<div className="flex justify-between p-2 bg-gray-50 rounded-md border border-gray-200">
|
color="bg-emerald-500"
|
||||||
<span className="text-xs text-gray-600">欧洲</span>
|
/>
|
||||||
<span className="text-xs font-medium">16%</span>
|
<div className="pt-3 border-t flex justify-between text-sm">
|
||||||
</div>
|
<span className="text-muted-foreground">总可用IP</span>
|
||||||
<div className="flex justify-between p-2 bg-gray-50 rounded-md border border-gray-200">
|
<span className="font-semibold">--</span>
|
||||||
<span className="text-xs text-gray-600">其他</span>
|
</div>
|
||||||
<span className="text-xs font-medium">14%</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 系统告警 */}
|
{/* 最近活跃用户 */}
|
||||||
<div className="bg-white border border-gray-200 rounded-md">
|
<div className="bg-card rounded-lg border">
|
||||||
<div className="p-5 border-b border-gray-200">
|
<div className="p-5 border-b">
|
||||||
<div className="flex justify-between items-center">
|
<h2 className="font-semibold">最近活跃用户</h2>
|
||||||
<h2 className="font-bold text-gray-800">告警通知</h2>
|
|
||||||
<span className="px-2 py-1 bg-red-100 text-red-800 text-xs rounded-full">
|
|
||||||
3 个新告警
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 space-y-3">
|
<div className="p-4">
|
||||||
<AlertItem
|
<SimpleHorizontalBarChart
|
||||||
title="IP不足告警"
|
data={chartData.userData}
|
||||||
severity="high"
|
dataKey="activity"
|
||||||
time="10分钟前"
|
labelKey="name"
|
||||||
message="特定地区(美国加州)代理IP资源不足,影响用户请求。"
|
fill="#6366f1"
|
||||||
|
width={300}
|
||||||
/>
|
/>
|
||||||
<AlertItem
|
|
||||||
title="响应延迟"
|
|
||||||
severity="medium"
|
|
||||||
time="30分钟前"
|
|
||||||
message="欧洲区域代理响应时间超过阈值(1.5s),请检查网络状况。"
|
|
||||||
/>
|
|
||||||
<AlertItem
|
|
||||||
title="异常请求"
|
|
||||||
severity="low"
|
|
||||||
time="2小时前"
|
|
||||||
message="检测到异常请求模式,可能存在爬虫攻击行为。"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 border-t border-gray-200">
|
|
||||||
<button className="w-full py-2 bg-gray-100 text-gray-600 border border-gray-200 rounded-md text-sm hover:bg-gray-200 transition-colors">
|
|
||||||
查看全部告警
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 系统状态 */}
|
|
||||||
<div className="bg-white border border-gray-200 rounded-md">
|
|
||||||
<div className="p-5 border-b border-gray-200">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<h2 className="font-bold text-gray-800">系统状态</h2>
|
|
||||||
<span className="px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full font-medium">
|
|
||||||
运行正常
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-3 space-y-3">
|
|
||||||
<StatusBar title="代理服务器负载" value={28} status="normal" />
|
|
||||||
<StatusBar title="带宽使用率" value={65} status="normal" />
|
|
||||||
<StatusBar title="存储空间" value={82} status="warning" />
|
|
||||||
<StatusBar title="API请求队列" value={45} status="normal" />
|
|
||||||
</div>
|
|
||||||
<div className="p-4 border-t border-gray-200">
|
|
||||||
<Link
|
|
||||||
href="/system/status"
|
|
||||||
className="block w-full py-2 text-center bg-gray-100 text-gray-600 border border-gray-200 rounded-md text-sm hover:bg-gray-200 transition-colors"
|
|
||||||
>
|
|
||||||
查看详细状态
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Page>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 数据卡片组件 - 显示关键指标
|
function StatCard({
|
||||||
function DataCard({
|
|
||||||
title,
|
title,
|
||||||
value,
|
value,
|
||||||
change,
|
change,
|
||||||
isIncrease,
|
trend,
|
||||||
icon,
|
icon: Icon,
|
||||||
|
iconColor,
|
||||||
|
iconBg,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
value: string
|
value: string
|
||||||
change: string
|
change: string
|
||||||
isIncrease: boolean
|
trend: "up" | "down"
|
||||||
icon: React.ReactNode
|
icon: React.ComponentType<{ className?: string }>
|
||||||
|
iconColor: string
|
||||||
|
iconBg: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white border border-gray-200 rounded-md p-5">
|
<div className="bg-card rounded-lg border p-4">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="text-sm text-gray-500">{title}</h3>
|
<p className="text-xs text-muted-foreground truncate">{title}</p>
|
||||||
<p className="text-xl font-bold mt-1 text-gray-800">{value}</p>
|
<p className="text-xl font-bold mt-1 truncate">{value}</p>
|
||||||
<div className="flex items-center mt-2">
|
<div className="flex items-center gap-1 mt-1.5">
|
||||||
|
{trend === "up" ? (
|
||||||
|
<ArrowUpRight className="h-3 w-3 text-green-600 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ArrowDownRight className="h-3 w-3 text-red-600 shrink-0" />
|
||||||
|
)}
|
||||||
<span
|
<span
|
||||||
className={`text-xs font-medium ${isIncrease ? "text-green-600" : "text-red-600"}`}
|
className={`text-xs font-medium ${trend === "up" ? "text-green-600" : "text-red-600"}`}
|
||||||
>
|
>
|
||||||
{change}
|
{change}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-400 ml-1">相比上周期</span>
|
<span className="text-xs text-muted-foreground">较昨日</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className={`p-2 rounded-md border shrink-0 ${iconBg}`}>
|
||||||
className={`p-3 rounded-md ${isIncrease ? "bg-blue-50 text-blue-600" : "bg-orange-50 text-orange-500"} border ${isIncrease ? "border-blue-100" : "border-orange-100"}`}
|
<Icon className={`h-5 w-5 ${iconColor}`} />
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 代理IP内容行组件
|
function ResourceItem({
|
||||||
function ContentRow({
|
label,
|
||||||
ip,
|
active,
|
||||||
location,
|
total,
|
||||||
status,
|
color,
|
||||||
requests,
|
|
||||||
successRate,
|
|
||||||
}: {
|
}: {
|
||||||
ip: string
|
label: string
|
||||||
location: string
|
active: string
|
||||||
status: "active" | "warning" | "error"
|
total: string
|
||||||
requests: number
|
color: string
|
||||||
successRate: string
|
|
||||||
}) {
|
}) {
|
||||||
const statusConfig = {
|
|
||||||
active: {
|
|
||||||
color: "bg-green-100 text-green-800 border-green-200",
|
|
||||||
label: "在线",
|
|
||||||
},
|
|
||||||
warning: {
|
|
||||||
color: "bg-yellow-100 text-yellow-800 border-yellow-200",
|
|
||||||
label: "不稳定",
|
|
||||||
},
|
|
||||||
error: { color: "bg-red-100 text-red-800 border-red-200", label: "离线" },
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className="hover:bg-gray-50">
|
<div>
|
||||||
<td className="px-5 py-4">
|
<div className="flex justify-between text-sm mb-1.5">
|
||||||
<div className="font-mono text-sm">{ip}</div>
|
<span className="text-muted-foreground">{label}</span>
|
||||||
</td>
|
<span className="font-medium">
|
||||||
<td className="px-5 py-4">
|
{active} / {total}
|
||||||
<div className="text-sm text-gray-700">{location}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-4">
|
|
||||||
<span
|
|
||||||
className={`px-2 py-1 text-xs rounded-md ${statusConfig[status].color} border`}
|
|
||||||
>
|
|
||||||
{statusConfig[status].label}
|
|
||||||
</span>
|
</span>
|
||||||
</td>
|
|
||||||
<td className="px-5 py-4 text-sm text-gray-700">
|
|
||||||
{requests.toLocaleString()}
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-4 text-sm text-gray-700">{successRate}</td>
|
|
||||||
<td className="px-5 py-4">
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<button className="p-1 border border-gray-200 rounded-md hover:bg-gray-50">
|
|
||||||
<svg
|
|
||||||
className="h-4 w-4 text-gray-500"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button className="p-1 border border-gray-200 rounded-md hover:bg-gray-50">
|
|
||||||
<svg
|
|
||||||
className="h-4 w-4 text-gray-500"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button className="p-1 border border-red-200 rounded-md hover:bg-red-50">
|
|
||||||
<svg
|
|
||||||
className="h-4 w-4 text-red-500"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 告警通知组件
|
|
||||||
function AlertItem({
|
|
||||||
title,
|
|
||||||
severity,
|
|
||||||
time,
|
|
||||||
message,
|
|
||||||
}: {
|
|
||||||
title: string
|
|
||||||
severity: "high" | "medium" | "low"
|
|
||||||
time: string
|
|
||||||
message: string
|
|
||||||
}) {
|
|
||||||
const severityConfig = {
|
|
||||||
high: { color: "bg-red-50 border-red-200", dot: "bg-red-500" },
|
|
||||||
medium: { color: "bg-yellow-50 border-yellow-200", dot: "bg-yellow-500" },
|
|
||||||
low: { color: "bg-blue-50 border-blue-200", dot: "bg-blue-500" },
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`p-3 rounded-md ${severityConfig[severity].color} border`}>
|
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span
|
|
||||||
className={`h-2 w-2 rounded-full ${severityConfig[severity].dot} mr-2`}
|
|
||||||
></span>
|
|
||||||
<span className="font-medium text-gray-800 text-sm">{title}</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-gray-500">{time}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-xs text-gray-600">{message}</p>
|
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 系统状态条组件
|
|
||||||
function StatusBar({
|
|
||||||
title,
|
|
||||||
value,
|
|
||||||
status,
|
|
||||||
}: {
|
|
||||||
title: string
|
|
||||||
value: number
|
|
||||||
status: "normal" | "warning" | "error"
|
|
||||||
}) {
|
|
||||||
const statusConfig = {
|
|
||||||
normal: { color: "bg-green-500", bgColor: "bg-green-100" },
|
|
||||||
warning: { color: "bg-yellow-500", bgColor: "bg-yellow-100" },
|
|
||||||
error: { color: "bg-red-500", bgColor: "bg-red-100" },
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-3 border border-gray-200 rounded-md">
|
|
||||||
<div className="flex justify-between mb-2">
|
|
||||||
<span className="text-sm text-gray-700">{title}</span>
|
|
||||||
<span className="text-sm font-medium">{value}%</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`w-full h-2 ${statusConfig[status].bgColor} rounded-full`}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className={`h-2 ${statusConfig[status].color} rounded-full`}
|
className={`h-full ${color} rounded-full transition-all`}
|
||||||
style={{ width: `${value}%` }}
|
style={{ width: "0%" }}
|
||||||
></div>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,19 +6,21 @@ import {
|
|||||||
ChevronsRight,
|
ChevronsRight,
|
||||||
CircleDollarSign,
|
CircleDollarSign,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
ComputerIcon,
|
|
||||||
ContactRound,
|
ContactRound,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
DoorClosedIcon,
|
DoorClosedIcon,
|
||||||
FolderCode,
|
FolderCode,
|
||||||
|
FolderTree,
|
||||||
Home,
|
Home,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
|
Newspaper,
|
||||||
Package,
|
Package,
|
||||||
ScanSearch,
|
ScanSearch,
|
||||||
Shield,
|
Shield,
|
||||||
ShoppingBag,
|
ShoppingBag,
|
||||||
SquarePercent,
|
SquarePercent,
|
||||||
|
TicketCheck,
|
||||||
TicketPercent,
|
TicketPercent,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
@@ -54,6 +56,7 @@ import {
|
|||||||
ScopeUserReadNotBind,
|
ScopeUserReadNotBind,
|
||||||
ScopeUserReadOne,
|
ScopeUserReadOne,
|
||||||
} from "@/lib/scopes"
|
} from "@/lib/scopes"
|
||||||
|
import Logo from "./logo"
|
||||||
|
|
||||||
// Navigation Context
|
// Navigation Context
|
||||||
interface NavigationContextType {
|
interface NavigationContextType {
|
||||||
@@ -163,7 +166,7 @@ const menuSections: { title: string; items: NavItemProps[] }[] = [
|
|||||||
title: "概览",
|
title: "概览",
|
||||||
items: [
|
items: [
|
||||||
{ href: "/", icon: Home, label: "首页" },
|
{ href: "/", icon: Home, label: "首页" },
|
||||||
{ href: "/statistics", icon: BarChart3, label: "数据统计" },
|
// { href: "/statistics", icon: BarChart3, label: "数据统计" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -216,6 +219,16 @@ const menuSections: { title: string; items: NavItemProps[] }[] = [
|
|||||||
label: "产品管理",
|
label: "产品管理",
|
||||||
requiredScope: ScopeProductRead,
|
requiredScope: ScopeProductRead,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: "/articles",
|
||||||
|
icon: Newspaper,
|
||||||
|
label: "文章管理",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/article-groups",
|
||||||
|
icon: FolderTree,
|
||||||
|
label: "文章分组",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: "/discount",
|
href: "/discount",
|
||||||
icon: SquarePercent,
|
icon: SquarePercent,
|
||||||
@@ -228,6 +241,11 @@ const menuSections: { title: string; items: NavItemProps[] }[] = [
|
|||||||
label: "优惠券",
|
label: "优惠券",
|
||||||
requiredScope: ScopeCouponRead,
|
requiredScope: ScopeCouponRead,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: "/couponList",
|
||||||
|
icon: TicketCheck,
|
||||||
|
label: "已发放优惠券",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: "/resources",
|
href: "/resources",
|
||||||
icon: Package,
|
icon: Package,
|
||||||
@@ -255,7 +273,7 @@ const menuSections: { title: string; items: NavItemProps[] }[] = [
|
|||||||
href: "/gateway",
|
href: "/gateway",
|
||||||
icon: DoorClosedIcon,
|
icon: DoorClosedIcon,
|
||||||
label: "网关列表",
|
label: "网关列表",
|
||||||
requiredScope:ScopeProxyRead
|
requiredScope: ScopeProxyRead,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/admin",
|
href: "/admin",
|
||||||
@@ -301,22 +319,13 @@ export default function Navigation() {
|
|||||||
<NavigationContext.Provider value={contextValue}>
|
<NavigationContext.Provider value={contextValue}>
|
||||||
<aside
|
<aside
|
||||||
className={twJoin(
|
className={twJoin(
|
||||||
"bg-background border-r border-border transition-all duration-300 ease-in-out flex flex-col h-full",
|
"bg-white border-r border-slate-200 transition-all duration-300 ease-in-out flex flex-col h-full",
|
||||||
collapsed ? "w-16" : "w-64",
|
collapsed ? "w-16" : "w-64",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/*Logo 区域 */}
|
{/*Logo 区域 */}
|
||||||
<div className="h-16 flex items-center justify-center border-b border-border p-4 shrink-0">
|
<Logo collapsed={collapsed} />
|
||||||
{!collapsed ? (
|
|
||||||
<span className="text-xl font-bold tracking-wide text-foreground">
|
|
||||||
管理系统
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-xl font-bold mx-auto text-foreground">
|
|
||||||
<ComputerIcon />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* Navigation Menu */}
|
{/* Navigation Menu */}
|
||||||
<ScrollArea className="flex-1 py-3 overflow-hidden">
|
<ScrollArea className="flex-1 py-3 overflow-hidden">
|
||||||
<nav className="space-y-3">
|
<nav className="space-y-3">
|
||||||
34
src/app/(root)/_navigation/logo.tsx
Normal file
34
src/app/(root)/_navigation/logo.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"use client"
|
||||||
|
import { ComputerIcon } from "lucide-react"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { getNodeEnv } from "@/actions/env"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export default function Logo(props: { collapsed: boolean }) {
|
||||||
|
const [env, setEnv] = useState<string>("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getNodeEnv().then(setEnv)
|
||||||
|
}, [])
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
`h-16 flex items-center justify-between border-b px-4 shrink-0`,
|
||||||
|
env === "production" && "bg-amber-100",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!props.collapsed ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||||
|
<ComputerIcon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-bold tracking-wide">管理系统</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full flex justify-center">
|
||||||
|
<div className="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -18,11 +18,9 @@ import type { Admin } from "@/models/admin"
|
|||||||
export default function Appbar(props: { admin: Admin }) {
|
export default function Appbar(props: { admin: Admin }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [showDropdown, setShowDropdown] = useState(false)
|
const [showDropdown, setShowDropdown] = useState(false)
|
||||||
const [showNotifications, setShowNotifications] = useState(false)
|
|
||||||
|
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||||
const notificationRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
// 处理点击外部关闭下拉菜单
|
// 处理点击外部关闭下拉菜单
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -33,12 +31,6 @@ export default function Appbar(props: { admin: Admin }) {
|
|||||||
) {
|
) {
|
||||||
setShowDropdown(false)
|
setShowDropdown(false)
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
notificationRef.current &&
|
|
||||||
!notificationRef.current.contains(event.target as Node)
|
|
||||||
) {
|
|
||||||
setShowNotifications(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("mousedown", handleClickOutside)
|
document.addEventListener("mousedown", handleClickOutside)
|
||||||
@@ -51,7 +43,7 @@ export default function Appbar(props: { admin: Admin }) {
|
|||||||
const filteredPaths = paths.filter(path => !hiddenSegments.includes(path))
|
const filteredPaths = paths.filter(path => !hiddenSegments.includes(path))
|
||||||
const breadcrumbs = [
|
const breadcrumbs = [
|
||||||
{ path: "/", label: "首页" },
|
{ path: "/", label: "首页" },
|
||||||
...filteredPaths.map((path, index) => {
|
...filteredPaths.map(path => {
|
||||||
const originalIndex = paths.findIndex(p => p === path)
|
const originalIndex = paths.findIndex(p => p === path)
|
||||||
const url = `/${paths.slice(0, originalIndex + 1).join("/")}`
|
const url = `/${paths.slice(0, originalIndex + 1).join("/")}`
|
||||||
const label = getBreadcrumbLabel(path)
|
const label = getBreadcrumbLabel(path)
|
||||||
@@ -67,6 +59,7 @@ export default function Appbar(props: { admin: Admin }) {
|
|||||||
dashboard: "控制台",
|
dashboard: "控制台",
|
||||||
content: "内容管理",
|
content: "内容管理",
|
||||||
articles: "文章管理",
|
articles: "文章管理",
|
||||||
|
"article-groups": "文章分组",
|
||||||
media: "媒体库",
|
media: "媒体库",
|
||||||
user: "客户认领",
|
user: "客户认领",
|
||||||
roles: "角色权限",
|
roles: "角色权限",
|
||||||
@@ -89,9 +82,13 @@ export default function Appbar(props: { admin: Admin }) {
|
|||||||
statistics: "数据统计",
|
statistics: "数据统计",
|
||||||
balance: "余额明细",
|
balance: "余额明细",
|
||||||
gateway: "网关列表",
|
gateway: "网关列表",
|
||||||
|
couponList: "已发放优惠券",
|
||||||
|
new: "新建文章",
|
||||||
}
|
}
|
||||||
|
|
||||||
return labels[path] || path
|
if (labels[path]) return labels[path]
|
||||||
|
if (/^\d+$/.test(path)) return "编辑文章"
|
||||||
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
const breadcrumbs = generateBreadcrumbs()
|
const breadcrumbs = generateBreadcrumbs()
|
||||||
@@ -104,7 +101,7 @@ export default function Appbar(props: { admin: Admin }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="bg-white flex-none basis-16 border-b border-gray-200 flex items-center justify-between px-6">
|
<header className="bg-white flex-none basis-16 border-b border-gray-200 flex items-center justify-between px-6 z-40">
|
||||||
{/* 面包屑导航 */}
|
{/* 面包屑导航 */}
|
||||||
<div className="flex items-center text-sm">
|
<div className="flex items-center text-sm">
|
||||||
{breadcrumbs.map((crumb, index) => (
|
{breadcrumbs.map((crumb, index) => (
|
||||||
@@ -194,7 +191,7 @@ export default function Appbar(props: { admin: Admin }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="py-1">
|
<div className="py-1 ">
|
||||||
<Link
|
<Link
|
||||||
href="/profile"
|
href="/profile"
|
||||||
className="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
className="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||||
|
|||||||
208
src/app/(root)/article-groups/page.tsx
Normal file
208
src/app/(root)/article-groups/page.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Loader2 } from "lucide-react"
|
||||||
|
import { Suspense, useCallback, useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import {
|
||||||
|
createArticleGroup,
|
||||||
|
getArticleGroupPage,
|
||||||
|
removeArticleGroup,
|
||||||
|
updateArticleGroup,
|
||||||
|
} from "@/actions/article-group"
|
||||||
|
import { DataTable, useDataTable } from "@/components/data-table"
|
||||||
|
import { Page } from "@/components/page"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import type { ArticleGroup } from "@/models/article-group"
|
||||||
|
import { formatDate } from "@/models/formatDate"
|
||||||
|
|
||||||
|
export default function ArticleGroupPage() {
|
||||||
|
const fetchFn = useCallback(
|
||||||
|
(page: number, size: number) => getArticleGroupPage({ page, size }),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const table = useDataTable(fetchFn)
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [editing, setEditing] = useState<ArticleGroup | null>(null)
|
||||||
|
const [code, setCode] = useState("")
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [sort, setSort] = useState("0")
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditing(null)
|
||||||
|
setCode("")
|
||||||
|
setName("")
|
||||||
|
setSort("0")
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEdit = (group: ArticleGroup) => {
|
||||||
|
setEditing(group)
|
||||||
|
setCode(group.code)
|
||||||
|
setName(group.name)
|
||||||
|
setSort(String(group.sort))
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!code.trim() || !name.trim()) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
const resp = await updateArticleGroup({
|
||||||
|
id: editing.id,
|
||||||
|
code: code.trim(),
|
||||||
|
name: name.trim(),
|
||||||
|
sort: Number(sort),
|
||||||
|
})
|
||||||
|
if (resp.success) {
|
||||||
|
toast.success("分组更新成功")
|
||||||
|
setDialogOpen(false)
|
||||||
|
table.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(resp.message || "更新失败")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const resp = await createArticleGroup({
|
||||||
|
code: code.trim(),
|
||||||
|
name: name.trim(),
|
||||||
|
sort: Number(sort),
|
||||||
|
})
|
||||||
|
if (resp.success) {
|
||||||
|
toast.success("分组创建成功")
|
||||||
|
setDialogOpen(false)
|
||||||
|
table.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(resp.message || "创建失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}, [code, name, sort, editing, table])
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
async (id: number) => {
|
||||||
|
if (!confirm("确定要删除该分组吗?")) return
|
||||||
|
const resp = await removeArticleGroup(id)
|
||||||
|
if (resp.success) {
|
||||||
|
toast.success("分组已删除")
|
||||||
|
table.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(resp.message || "删除失败")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[table],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">文章分组</h1>
|
||||||
|
<Button onClick={openCreate}>新建分组</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Suspense>
|
||||||
|
<DataTable<ArticleGroup>
|
||||||
|
{...table}
|
||||||
|
columns={[
|
||||||
|
{ header: "编码", accessorKey: "code" },
|
||||||
|
{ header: "名称", accessorKey: "name" },
|
||||||
|
{ header: "排序", accessorKey: "sort" },
|
||||||
|
{
|
||||||
|
header: "创建时间",
|
||||||
|
accessorKey: "created_at",
|
||||||
|
cell: ({ row }) => formatDate(row.original.created_at),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "操作",
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => openEdit(row.original)}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => handleDelete(row.original.id)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editing ? "编辑分组" : "新建分组"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label>编码</Label>
|
||||||
|
<Input
|
||||||
|
value={code}
|
||||||
|
onChange={e => setCode(e.target.value)}
|
||||||
|
placeholder="分组编码,如 news"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>名称</Label>
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
placeholder="分组名称"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>排序</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={sort}
|
||||||
|
onChange={e => setSort(e.target.value)}
|
||||||
|
placeholder="0"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
保存中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"保存"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</Page>
|
||||||
|
)
|
||||||
|
}
|
||||||
135
src/app/(root)/articles/[id]/article-editor.tsx
Normal file
135
src/app/(root)/articles/[id]/article-editor.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Loader2 } from "lucide-react"
|
||||||
|
import dynamic from "next/dynamic"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { createArticle, getArticle, updateArticle } from "@/actions/article"
|
||||||
|
import type { ArticleGroup } from "@/models/article-group"
|
||||||
|
|
||||||
|
const Editor = dynamic(() => import("@/components/editor/richTextEditor"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
||||||
|
编辑器加载中...
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
interface ArticleEditorProps {
|
||||||
|
articleId: string
|
||||||
|
groups: ArticleGroup[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ArticleEditor({
|
||||||
|
articleId,
|
||||||
|
groups,
|
||||||
|
}: ArticleEditorProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
const isNew = articleId === "new"
|
||||||
|
const [loading, setLoading] = useState(!isNew)
|
||||||
|
const [title, setTitle] = useState("")
|
||||||
|
const [initialContent, setInitialContent] = useState("")
|
||||||
|
const [groupId, setGroupId] = useState<string>(
|
||||||
|
groups.length > 0 ? String(groups[0].id) : "",
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isNew) {
|
||||||
|
const id = Number(articleId)
|
||||||
|
if (Number.isNaN(id)) {
|
||||||
|
toast.error("无效的文章 ID")
|
||||||
|
router.push("/articles")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
getArticle(id).then(resp => {
|
||||||
|
if (resp.success && resp.data) {
|
||||||
|
setTitle(resp.data.title || "")
|
||||||
|
setInitialContent(resp.data.content || "")
|
||||||
|
if (resp.data.group_id) {
|
||||||
|
setGroupId(String(resp.data.group_id))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error(
|
||||||
|
resp.success ? "文章数据为空" : resp.message || "文章不存在",
|
||||||
|
)
|
||||||
|
router.push("/articles")
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [articleId, isNew, router])
|
||||||
|
|
||||||
|
const handleSave = useCallback(
|
||||||
|
async (data: { title: string; content: string }) => {
|
||||||
|
if (!groupId) {
|
||||||
|
toast.error("请选择文章分组")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isNew) {
|
||||||
|
const resp = await createArticle({
|
||||||
|
title: data.title,
|
||||||
|
content: data.content,
|
||||||
|
group_id: Number(groupId),
|
||||||
|
status: 1,
|
||||||
|
})
|
||||||
|
if (resp.success) {
|
||||||
|
toast.success("文章创建成功")
|
||||||
|
router.push("/articles")
|
||||||
|
} else {
|
||||||
|
toast.error(resp.message || "创建失败")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const id = Number(articleId)
|
||||||
|
const resp = await updateArticle({
|
||||||
|
id,
|
||||||
|
title: data.title,
|
||||||
|
content: data.content,
|
||||||
|
group_id: Number(groupId),
|
||||||
|
status: 1,
|
||||||
|
})
|
||||||
|
if (resp.success) {
|
||||||
|
toast.success("文章保存成功")
|
||||||
|
router.push("/articles")
|
||||||
|
} else {
|
||||||
|
toast.error(resp.message || "保存失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isNew, articleId, groupId, router],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groups.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full gap-4 text-muted-foreground">
|
||||||
|
<p>还没有文章分组,请先创建分组</p>
|
||||||
|
<Link href="/article-groups">
|
||||||
|
<span className="text-primary hover:underline">前往创建分组</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 h-full min-h-0">
|
||||||
|
<Editor
|
||||||
|
content={initialContent}
|
||||||
|
title={title}
|
||||||
|
groups={groups}
|
||||||
|
groupId={groupId}
|
||||||
|
onGroupChange={setGroupId}
|
||||||
|
onSave={handleSave}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
src/app/(root)/articles/[id]/page.tsx
Normal file
22
src/app/(root)/articles/[id]/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { getArticleGroupList } from "@/actions/article-group"
|
||||||
|
import { Page } from "@/components/page"
|
||||||
|
import ArticleEditor from "./article-editor"
|
||||||
|
|
||||||
|
export default async function ArticleEditPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}) {
|
||||||
|
const { id } = await params
|
||||||
|
const groupsResp = await getArticleGroupList()
|
||||||
|
console.log(groupsResp, "groupsResp")
|
||||||
|
|
||||||
|
const groups = groupsResp.success ? groupsResp.data : []
|
||||||
|
console.log(groups, "groups")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<ArticleEditor articleId={id} groups={groups} />
|
||||||
|
</Page>
|
||||||
|
)
|
||||||
|
}
|
||||||
105
src/app/(root)/articles/page.tsx
Normal file
105
src/app/(root)/articles/page.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { Suspense, useCallback } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { getArticlePage, removeArticle } from "@/actions/article"
|
||||||
|
import { DataTable, useDataTable } from "@/components/data-table"
|
||||||
|
import { Page } from "@/components/page"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import type { Article } from "@/models/article"
|
||||||
|
import { formatDate } from "@/models/formatDate"
|
||||||
|
|
||||||
|
export default function ArticleListPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const fetchFn = useCallback(
|
||||||
|
(page: number, size: number) => getArticlePage({ page, size }),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const table = useDataTable(fetchFn)
|
||||||
|
console.log(table, "tabletabletable")
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
async (id: number, title: string) => {
|
||||||
|
if (!confirm(`确定要删除文章《${title}》吗?此操作不可恢复!`)) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await removeArticle(id)
|
||||||
|
console.log(resp, "resprespresp")
|
||||||
|
|
||||||
|
if (resp.success) {
|
||||||
|
toast.success("文章已删除")
|
||||||
|
table.refresh()
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(resp.message || "删除失败")
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("删除失败")
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[table, router],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">文章管理</h1>
|
||||||
|
<Link href="/articles/new">
|
||||||
|
<Button>新建文章</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Suspense>
|
||||||
|
<DataTable<Article>
|
||||||
|
{...table}
|
||||||
|
columns={[
|
||||||
|
{ header: "标题", accessorKey: "title" },
|
||||||
|
{
|
||||||
|
header: "状态",
|
||||||
|
accessorKey: "status",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge
|
||||||
|
variant={row.original.status === 1 ? "default" : "secondary"}
|
||||||
|
>
|
||||||
|
{row.original.status === 1 ? "已发布" : "草稿"}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "更新时间",
|
||||||
|
accessorKey: "updated_at",
|
||||||
|
cell: ({ row }) => formatDate(row.original.updated_at),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "操作",
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/articles/${row.original.id}`}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
handleDelete(row.original.id, row.original.title)
|
||||||
|
}
|
||||||
|
className="text-destructive hover:underline"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</Page>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,19 +1,16 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
import { Suspense, useCallback, useState } from "react"
|
import Link from "next/link"
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
|
import { Suspense, useCallback } from "react"
|
||||||
import { Controller, useForm } from "react-hook-form"
|
import { Controller, useForm } from "react-hook-form"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { getPageBalance } from "@/actions/balance"
|
import { getPageBalance } from "@/actions/balance"
|
||||||
import { DataTable, useDataTable } from "@/components/data-table"
|
import { DataTable, useDataTable } from "@/components/data-table"
|
||||||
import { Page } from "@/components/page"
|
import { Page } from "@/components/page"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
|
||||||
Field,
|
|
||||||
FieldError,
|
|
||||||
FieldGroup,
|
|
||||||
FieldLabel,
|
|
||||||
} from "@/components/ui/field"
|
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import type { Balance } from "@/models/balance"
|
import type { Balance } from "@/models/balance"
|
||||||
|
|
||||||
@@ -51,12 +48,14 @@ const filterSchema = z
|
|||||||
type FormValues = z.infer<typeof filterSchema>
|
type FormValues = z.infer<typeof filterSchema>
|
||||||
|
|
||||||
export default function BalancePage() {
|
export default function BalancePage() {
|
||||||
const [filters, setFilters] = useState<FilterValues>({})
|
const searchParams = useSearchParams()
|
||||||
const { control, handleSubmit, reset } = useForm<FormValues>({
|
const billNo = searchParams.get("bill_no")
|
||||||
|
const router = useRouter()
|
||||||
|
const { control, handleSubmit, reset, getValues } = useForm<FormValues>({
|
||||||
resolver: zodResolver(filterSchema),
|
resolver: zodResolver(filterSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
phone: "",
|
phone: "",
|
||||||
bill_no: "",
|
bill_no: billNo || "",
|
||||||
admin_id: "",
|
admin_id: "",
|
||||||
created_at_start: "",
|
created_at_start: "",
|
||||||
created_at_end: "",
|
created_at_end: "",
|
||||||
@@ -64,28 +63,30 @@ export default function BalancePage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const fetchUsers = useCallback(
|
const fetchUsers = useCallback(
|
||||||
(page: number, size: number) => getPageBalance({ page, size, ...filters }),
|
(page: number, size: number) => {
|
||||||
[filters],
|
const result: FilterValues = {}
|
||||||
|
const filters = getValues()
|
||||||
|
if (filters.phone?.trim()) result.user_phone = filters.phone.trim()
|
||||||
|
if (filters.bill_no?.trim()) result.bill_no = filters.bill_no.trim()
|
||||||
|
if (filters.created_at_start)
|
||||||
|
result.created_at_start = new Date(filters.created_at_start)
|
||||||
|
if (filters.created_at_end)
|
||||||
|
result.created_at_end = new Date(filters.created_at_end)
|
||||||
|
return getPageBalance({ page, size, ...result })
|
||||||
|
},
|
||||||
|
[getValues],
|
||||||
)
|
)
|
||||||
|
|
||||||
const table = useDataTable<Balance>(fetchUsers)
|
const table = useDataTable<Balance>(fetchUsers)
|
||||||
|
|
||||||
const onFilter = handleSubmit(data => {
|
const onFilter = handleSubmit(() => {
|
||||||
const result: FilterValues = {}
|
|
||||||
if (data.phone?.trim()) result.user_phone = data.phone.trim()
|
|
||||||
if (data.bill_no?.trim()) result.bill_no = data.bill_no.trim()
|
|
||||||
if (data.created_at_start)
|
|
||||||
result.created_at_start = new Date(data.created_at_start)
|
|
||||||
if (data.created_at_end)
|
|
||||||
result.created_at_end = new Date(data.created_at_end)
|
|
||||||
setFilters(result)
|
|
||||||
table.pagination.onPageChange(1)
|
table.pagination.onPageChange(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<form onSubmit={onFilter} className="bg-card p-4 rounded-lg">
|
<form onSubmit={onFilter} className="bg-card p-4 rounded-lg">
|
||||||
<div className="flex flex-wrap items-end gap-4">
|
<div className="flex items-end gap-4">
|
||||||
<Controller
|
<Controller
|
||||||
name="phone"
|
name="phone"
|
||||||
control={control}
|
control={control}
|
||||||
@@ -95,7 +96,7 @@ export default function BalancePage() {
|
|||||||
className="w-40 flex-none"
|
className="w-40 flex-none"
|
||||||
>
|
>
|
||||||
<FieldLabel>会员号</FieldLabel>
|
<FieldLabel>会员号</FieldLabel>
|
||||||
<Input {...field} placeholder="请输入会员号" />
|
<Input {...field} placeholder="请输入会员号" clearable />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
@@ -109,7 +110,7 @@ export default function BalancePage() {
|
|||||||
className="w-40 flex-none"
|
className="w-40 flex-none"
|
||||||
>
|
>
|
||||||
<FieldLabel>账单号</FieldLabel>
|
<FieldLabel>账单号</FieldLabel>
|
||||||
<Input {...field} placeholder="请输入账单号" />
|
<Input {...field} placeholder="请输入账单号" clearable />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
@@ -142,22 +143,26 @@ export default function BalancePage() {
|
|||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<FieldGroup className="flex-row justify-start mt-4 gap-2">
|
|
||||||
<Button type="submit">搜索</Button>
|
<Button type="submit">搜索</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
reset()
|
reset({
|
||||||
setFilters({})
|
phone: "",
|
||||||
|
bill_no: "",
|
||||||
|
admin_id: "",
|
||||||
|
created_at_start: "",
|
||||||
|
created_at_end: "",
|
||||||
|
})
|
||||||
|
router.replace("./balance")
|
||||||
table.pagination.onPageChange(1)
|
table.pagination.onPageChange(1)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
重置
|
重置
|
||||||
</Button>
|
</Button>
|
||||||
</FieldGroup>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<Suspense>
|
<Suspense>
|
||||||
@@ -169,8 +174,21 @@ export default function BalancePage() {
|
|||||||
accessorFn: row => row.user?.phone || "",
|
accessorFn: row => row.user?.phone || "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "账单号",
|
header: "账单编号",
|
||||||
accessorFn: row => row.bill?.bill_no || "",
|
accessorKey: "bill?.bill_no",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const bill_no = row.original.bill?.bill_no
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/billing?bill_no=${bill_no}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600"
|
||||||
|
>
|
||||||
|
{bill_no}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "管理员",
|
header: "管理员",
|
||||||
@@ -187,7 +205,7 @@ export default function BalancePage() {
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span
|
<span
|
||||||
className={`font-semibold ${
|
className={`font-semibold ${
|
||||||
isPositive ? "text-green-600" : "text-red-600"
|
isPositive ? "text-red-600" : "text-green-600"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isPositive ? "+" : ""}
|
{isPositive ? "+" : ""}
|
||||||
@@ -219,8 +237,15 @@ export default function BalancePage() {
|
|||||||
{
|
{
|
||||||
header: "创建时间",
|
header: "创建时间",
|
||||||
accessorKey: "created_at",
|
accessorKey: "created_at",
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) => {
|
||||||
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
|
const createdAt = row.original.created_at
|
||||||
|
if (!createdAt) return <span>-</span>
|
||||||
|
|
||||||
|
const date = new Date(createdAt)
|
||||||
|
if (isNaN(date.getTime())) return <span>-</span>
|
||||||
|
|
||||||
|
return format(date, "yyyy-MM-dd HH:mm:ss")
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
import { Suspense, useState } from "react"
|
import Link from "next/link"
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
|
import { Suspense } from "react"
|
||||||
import { Controller, useForm } from "react-hook-form"
|
import { Controller, useForm } from "react-hook-form"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { getPageBatch } from "@/actions/batch"
|
import { getPageBatch } from "@/actions/batch"
|
||||||
@@ -64,14 +66,17 @@ const filterSchema = z
|
|||||||
type FilterSchema = z.infer<typeof filterSchema>
|
type FilterSchema = z.infer<typeof filterSchema>
|
||||||
|
|
||||||
export default function BatchPage() {
|
export default function BatchPage() {
|
||||||
const [filters, setFilters] = useState<APIFilterParams>({})
|
const searchParams = useSearchParams()
|
||||||
|
const resourceNo = searchParams.get("resource_no")
|
||||||
const { control, handleSubmit, reset } = useForm<FilterSchema>({
|
const batchNo = searchParams.get("batch_no")
|
||||||
|
const router = useRouter()
|
||||||
|
const { control, handleSubmit, reset, getValues } = useForm<FilterSchema>({
|
||||||
resolver: zodResolver(filterSchema),
|
resolver: zodResolver(filterSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
user_phone: "",
|
user_phone: "",
|
||||||
batch_no: "",
|
batch_no: batchNo || "",
|
||||||
prov: "",
|
prov: "",
|
||||||
|
resource_no: resourceNo || "",
|
||||||
city: "",
|
city: "",
|
||||||
isp: "all",
|
isp: "all",
|
||||||
created_at_start: "",
|
created_at_start: "",
|
||||||
@@ -79,24 +84,25 @@ export default function BatchPage() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const table = useDataTable<Batch>((page, size) =>
|
const table = useDataTable<Batch>((page, size) => {
|
||||||
getPageBatch({ page, size, ...filters }),
|
|
||||||
)
|
|
||||||
|
|
||||||
const onFilter = handleSubmit(data => {
|
|
||||||
const result: APIFilterParams = {}
|
const result: APIFilterParams = {}
|
||||||
if (data.user_phone?.trim()) result.user_phone = data.user_phone.trim()
|
const filters = getValues()
|
||||||
if (data.batch_no?.trim()) result.batch_no = data.batch_no.trim()
|
if (filters.user_phone?.trim())
|
||||||
if (data.resource_no?.trim()) result.resource_no = data.resource_no.trim()
|
result.user_phone = filters.user_phone.trim()
|
||||||
if (data.prov?.trim()) result.prov = data.prov.trim()
|
if (filters.batch_no?.trim()) result.batch_no = filters.batch_no.trim()
|
||||||
if (data.city?.trim()) result.city = data.city.trim()
|
if (filters.resource_no?.trim())
|
||||||
if (data.isp && data.isp !== "all") result.isp = data.isp
|
result.resource_no = filters.resource_no.trim()
|
||||||
if (data.created_at_start)
|
if (filters.prov?.trim()) result.prov = filters.prov.trim()
|
||||||
result.created_at_start = new Date(data.created_at_start)
|
if (filters.city?.trim()) result.city = filters.city.trim()
|
||||||
if (data.created_at_end)
|
if (filters.isp && filters.isp !== "all") result.isp = filters.isp
|
||||||
result.created_at_end = new Date(data.created_at_end)
|
if (filters.created_at_start)
|
||||||
|
result.created_at_start = new Date(filters.created_at_start)
|
||||||
|
if (filters.created_at_end)
|
||||||
|
result.created_at_end = new Date(filters.created_at_end)
|
||||||
|
return getPageBatch({ page, size, ...result })
|
||||||
|
})
|
||||||
|
|
||||||
setFilters(result)
|
const onFilter = handleSubmit(() => {
|
||||||
table.pagination.onPageChange(1)
|
table.pagination.onPageChange(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -113,8 +119,8 @@ export default function BatchPage() {
|
|||||||
data-invalid={fieldState.invalid}
|
data-invalid={fieldState.invalid}
|
||||||
className="w-40 flex-none"
|
className="w-40 flex-none"
|
||||||
>
|
>
|
||||||
<FieldLabel>批次号</FieldLabel>
|
<FieldLabel>提取编号</FieldLabel>
|
||||||
<Input {...field} placeholder="请输入批次号" />
|
<Input {...field} placeholder="请输入提取编号" clearable />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
@@ -128,7 +134,7 @@ export default function BatchPage() {
|
|||||||
className="w-40 flex-none"
|
className="w-40 flex-none"
|
||||||
>
|
>
|
||||||
<FieldLabel>套餐号</FieldLabel>
|
<FieldLabel>套餐号</FieldLabel>
|
||||||
<Input {...field} placeholder="请输入套餐号" />
|
<Input {...field} placeholder="请输入套餐号" clearable />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
@@ -142,7 +148,7 @@ export default function BatchPage() {
|
|||||||
className="w-40 flex-none"
|
className="w-40 flex-none"
|
||||||
>
|
>
|
||||||
<FieldLabel>会员号</FieldLabel>
|
<FieldLabel>会员号</FieldLabel>
|
||||||
<Input {...field} placeholder="请输入会员号" />
|
<Input {...field} placeholder="请输入会员号" clearable />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
@@ -157,7 +163,7 @@ export default function BatchPage() {
|
|||||||
className="w-32 flex-none"
|
className="w-32 flex-none"
|
||||||
>
|
>
|
||||||
<FieldLabel>省份</FieldLabel>
|
<FieldLabel>省份</FieldLabel>
|
||||||
<Input {...field} placeholder="请输入省份" />
|
<Input {...field} placeholder="请输入省份" clearable />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
@@ -172,7 +178,7 @@ export default function BatchPage() {
|
|||||||
className="w-32 flex-none"
|
className="w-32 flex-none"
|
||||||
>
|
>
|
||||||
<FieldLabel>城市</FieldLabel>
|
<FieldLabel>城市</FieldLabel>
|
||||||
<Input {...field} placeholder="请输入城市" />
|
<Input {...field} placeholder="请输入城市" clearable />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
@@ -237,8 +243,17 @@ export default function BatchPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
reset()
|
reset({
|
||||||
setFilters({})
|
user_phone: "",
|
||||||
|
batch_no: "",
|
||||||
|
prov: "",
|
||||||
|
resource_no: "",
|
||||||
|
city: "",
|
||||||
|
isp: "all",
|
||||||
|
created_at_start: "",
|
||||||
|
created_at_end: "",
|
||||||
|
})
|
||||||
|
router.replace("./batch")
|
||||||
table.pagination.onPageChange(1)
|
table.pagination.onPageChange(1)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -255,8 +270,41 @@ export default function BatchPage() {
|
|||||||
header: "会员号",
|
header: "会员号",
|
||||||
accessorFn: row => row.user?.phone || "",
|
accessorFn: row => row.user?.phone || "",
|
||||||
},
|
},
|
||||||
{ header: "套餐号", accessorKey: "resource.resource_no" },
|
{
|
||||||
{ header: "批次号", accessorKey: "batch_no" },
|
header: "套餐号",
|
||||||
|
accessorKey: "resource.resource_no",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const resourceNo = row.original.resource?.resource_no
|
||||||
|
const type = row.original.resource?.type
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/resources?resource_no=${resourceNo}&type=${type}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600"
|
||||||
|
>
|
||||||
|
{resourceNo}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "提取编号",
|
||||||
|
accessorKey: "batch_no",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const batch_no = row.original.batch_no
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`./channel?batch_no=${batch_no}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600"
|
||||||
|
>
|
||||||
|
{batch_no}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
{ header: "省份", accessorKey: "prov" },
|
{ header: "省份", accessorKey: "prov" },
|
||||||
{ header: "城市", accessorKey: "city" },
|
{ header: "城市", accessorKey: "city" },
|
||||||
{ header: "用户IP", accessorKey: "ip" },
|
{ header: "用户IP", accessorKey: "ip" },
|
||||||
@@ -266,7 +314,7 @@ export default function BatchPage() {
|
|||||||
header: "提取时间",
|
header: "提取时间",
|
||||||
accessorKey: "time",
|
accessorKey: "time",
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
format(new Date(row.original.time), "yyyy-MM-dd HH:mm"),
|
format(new Date(row.original.time), "yyyy-MM-dd HH:mm:ss"),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
import { CreditCard, Wallet } from "lucide-react"
|
import { CreditCard, Wallet } from "lucide-react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
import { Suspense, useEffect, useState } from "react"
|
import { Suspense, useEffect, useState } from "react"
|
||||||
import { Controller, useForm } from "react-hook-form"
|
import { Controller, useForm } from "react-hook-form"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
@@ -77,21 +79,26 @@ const filterSchema = z
|
|||||||
type FilterSchema = z.infer<typeof filterSchema>
|
type FilterSchema = z.infer<typeof filterSchema>
|
||||||
|
|
||||||
export default function BillingPage() {
|
export default function BillingPage() {
|
||||||
const [filters, setFilters] = useState<FilterValues>({})
|
const searchParams = useSearchParams()
|
||||||
|
const innerNo = searchParams.get("inner_no")
|
||||||
|
const billNo = searchParams.get("bill_no")
|
||||||
|
const resourceNo = searchParams.get("resource_no")
|
||||||
const [skuOptions, setSkuOptions] = useState<SkuOption[]>([])
|
const [skuOptions, setSkuOptions] = useState<SkuOption[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [skuProductCode, setSkuProductCode] = useState<ProductCode>(
|
const [skuProductCode, setSkuProductCode] = useState<ProductCode>(
|
||||||
ProductCode.All,
|
ProductCode.All,
|
||||||
)
|
)
|
||||||
const { control, handleSubmit, reset } = useForm<FilterSchema>({
|
const router = useRouter()
|
||||||
|
|
||||||
|
const { control, handleSubmit, reset, getValues } = useForm<FilterSchema>({
|
||||||
resolver: zodResolver(filterSchema),
|
resolver: zodResolver(filterSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
bill_no: "",
|
bill_no: billNo || "",
|
||||||
inner_no: "",
|
inner_no: innerNo || "",
|
||||||
created_at_start: "",
|
created_at_start: "",
|
||||||
created_at_end: "",
|
created_at_end: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
resource_no: "",
|
resource_no: resourceNo || "",
|
||||||
sku_code: "all",
|
sku_code: "all",
|
||||||
product_code: "",
|
product_code: "",
|
||||||
},
|
},
|
||||||
@@ -125,31 +132,54 @@ export default function BillingPage() {
|
|||||||
})
|
})
|
||||||
}, [skuProductCode])
|
}, [skuProductCode])
|
||||||
|
|
||||||
const table = useDataTable<Billing>((page, size) =>
|
const loadData = (page: number, size: number) => {
|
||||||
getPageBill({ page, size, ...filters }),
|
|
||||||
)
|
|
||||||
|
|
||||||
const onFilter = handleSubmit(data => {
|
|
||||||
const result: FilterValues = {}
|
const result: FilterValues = {}
|
||||||
if (data.phone?.trim()) result.user_phone = data.phone.trim()
|
const filters = getValues()
|
||||||
if (data.inner_no?.trim()) result.trade_inner_no = data.inner_no.trim()
|
if (filters.phone?.trim()) result.user_phone = filters.phone.trim()
|
||||||
if (data.bill_no?.trim()) result.bill_no = data.bill_no.trim()
|
if (filters.inner_no?.trim())
|
||||||
if (data.resource_no?.trim()) result.resource_no = data.resource_no.trim()
|
result.trade_inner_no = filters.inner_no.trim()
|
||||||
if (data.product_code && data.product_code !== ProductCode.All) {
|
if (filters.bill_no?.trim()) result.bill_no = filters.bill_no.trim()
|
||||||
result.product_code = data.product_code
|
if (filters.resource_no?.trim())
|
||||||
|
result.resource_no = filters.resource_no.trim()
|
||||||
|
if (filters.product_code && filters.product_code !== ProductCode.All) {
|
||||||
|
result.product_code = filters.product_code
|
||||||
}
|
}
|
||||||
if (data.sku_code && data.sku_code !== "all") {
|
if (filters.sku_code && filters.sku_code !== "all") {
|
||||||
result.sku_code = data.sku_code
|
result.sku_code = filters.sku_code
|
||||||
}
|
}
|
||||||
if (data.created_at_start)
|
if (filters.created_at_start)
|
||||||
result.created_at_start = new Date(data.created_at_start)
|
result.created_at_start = new Date(filters.created_at_start)
|
||||||
if (data.created_at_end)
|
if (filters.created_at_end)
|
||||||
result.created_at_end = new Date(data.created_at_end)
|
result.created_at_end = new Date(filters.created_at_end)
|
||||||
|
return getPageBill({
|
||||||
|
page,
|
||||||
|
size,
|
||||||
|
...result,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
setFilters(result)
|
const clearFilter = () => {
|
||||||
|
router.replace("/billing")
|
||||||
|
reset({
|
||||||
|
bill_no: "",
|
||||||
|
inner_no: "",
|
||||||
|
created_at_start: "",
|
||||||
|
created_at_end: "",
|
||||||
|
phone: "",
|
||||||
|
resource_no: "",
|
||||||
|
sku_code: "all",
|
||||||
|
product_code: "",
|
||||||
|
})
|
||||||
|
setSkuProductCode(ProductCode.All)
|
||||||
|
table.pagination.onPageChange(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = useDataTable<Billing>(loadData)
|
||||||
|
console.log(table, "table")
|
||||||
|
|
||||||
|
const onFilter = handleSubmit(() => {
|
||||||
table.pagination.onPageChange(1)
|
table.pagination.onPageChange(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<form onSubmit={onFilter} className="bg-card p-4 rounded-lg">
|
<form onSubmit={onFilter} className="bg-card p-4 rounded-lg">
|
||||||
@@ -163,7 +193,7 @@ export default function BillingPage() {
|
|||||||
className="w-40 flex-none"
|
className="w-40 flex-none"
|
||||||
>
|
>
|
||||||
<FieldLabel>会员号</FieldLabel>
|
<FieldLabel>会员号</FieldLabel>
|
||||||
<Input {...field} placeholder="请输入会员号" />
|
<Input {...field} placeholder="请输入会员号" clearable />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
@@ -177,7 +207,7 @@ export default function BillingPage() {
|
|||||||
className="w-40 flex-none"
|
className="w-40 flex-none"
|
||||||
>
|
>
|
||||||
<FieldLabel>套餐号</FieldLabel>
|
<FieldLabel>套餐号</FieldLabel>
|
||||||
<Input {...field} placeholder="请输入套餐号" />
|
<Input {...field} placeholder="请输入套餐号" clearable />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
@@ -191,7 +221,7 @@ export default function BillingPage() {
|
|||||||
className="w-40 flex-none"
|
className="w-40 flex-none"
|
||||||
>
|
>
|
||||||
<FieldLabel>账单号</FieldLabel>
|
<FieldLabel>账单号</FieldLabel>
|
||||||
<Input {...field} placeholder="请输入账单号" />
|
<Input {...field} placeholder="请输入账单号" clearable />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
@@ -205,7 +235,7 @@ export default function BillingPage() {
|
|||||||
className="w-40 flex-none"
|
className="w-40 flex-none"
|
||||||
>
|
>
|
||||||
<FieldLabel>订单号</FieldLabel>
|
<FieldLabel>订单号</FieldLabel>
|
||||||
<Input {...field} placeholder="请输入订单号" />
|
<Input {...field} placeholder="请输入订单号" clearable />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
@@ -310,16 +340,7 @@ export default function BillingPage() {
|
|||||||
|
|
||||||
<FieldGroup className="flex-row justify-start mt-4 gap-2">
|
<FieldGroup className="flex-row justify-start mt-4 gap-2">
|
||||||
<Button type="submit">搜索</Button>
|
<Button type="submit">搜索</Button>
|
||||||
<Button
|
<Button type="button" variant="outline" onClick={clearFilter}>
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
reset()
|
|
||||||
setSkuProductCode(ProductCode.All)
|
|
||||||
setFilters({})
|
|
||||||
table.pagination.onPageChange(1)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
重置
|
重置
|
||||||
</Button>
|
</Button>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
@@ -332,10 +353,36 @@ export default function BillingPage() {
|
|||||||
{
|
{
|
||||||
header: "创建时间",
|
header: "创建时间",
|
||||||
accessorKey: "created_at",
|
accessorKey: "created_at",
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) => {
|
||||||
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
|
const createdAt = row.original.created_at
|
||||||
|
if (!createdAt) return <span>-</span>
|
||||||
|
|
||||||
|
const date = new Date(createdAt)
|
||||||
|
if (isNaN(date.getTime())) return <span>-</span>
|
||||||
|
|
||||||
|
return format(date, "yyyy-MM-dd HH:mm:ss")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "套餐号",
|
||||||
|
accessorKey: "resource.resource_no",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const resource_no = row.original.resource?.resource_no
|
||||||
|
const type = row.original.resource?.type
|
||||||
|
return resource_no ? (
|
||||||
|
<Link
|
||||||
|
href={`/resources?resource_no=${resource_no}&type=${type}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600"
|
||||||
|
>
|
||||||
|
{resource_no}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span></span>
|
||||||
|
)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{ header: "套餐号", accessorKey: "resource.resource_no" },
|
|
||||||
{ header: "会员号", accessorFn: row => row.user?.phone || "" },
|
{ header: "会员号", accessorFn: row => row.user?.phone || "" },
|
||||||
{
|
{
|
||||||
header: "账单详情",
|
header: "账单详情",
|
||||||
@@ -412,7 +459,23 @@ export default function BillingPage() {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ header: "账单号", accessorKey: "bill_no" },
|
{
|
||||||
|
header: "账单号",
|
||||||
|
accessorKey: "bill_no",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const billNo = row.original.bill_no
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`./balance?bill_no=${billNo}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600"
|
||||||
|
>
|
||||||
|
{billNo}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
header: "订单号",
|
header: "订单号",
|
||||||
accessorKey: "trade.inner_no",
|
accessorKey: "trade.inner_no",
|
||||||
@@ -447,7 +510,16 @@ export default function BillingPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm">{bill.trade?.inner_no}</div>
|
<div className="text-sm">
|
||||||
|
<Link
|
||||||
|
href={`/trade?inner_no=${bill.trade?.inner_no}`}
|
||||||
|
target="_blak"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600"
|
||||||
|
>
|
||||||
|
{bill.trade?.inner_no}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
import { Suspense, useState } from "react"
|
import Link from "next/link"
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
|
import { Suspense } from "react"
|
||||||
import { Controller, useForm } from "react-hook-form"
|
import { Controller, useForm } from "react-hook-form"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { getPageChannel } from "@/actions/channel"
|
import { getPageChannel } from "@/actions/channel"
|
||||||
@@ -9,13 +11,15 @@ import { DataTable, useDataTable } from "@/components/data-table"
|
|||||||
import { Page } from "@/components/page"
|
import { Page } from "@/components/page"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
|
||||||
Field,
|
|
||||||
FieldError,
|
|
||||||
FieldGroup,
|
|
||||||
FieldLabel,
|
|
||||||
} from "@/components/ui/field"
|
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
import type { Channel } from "@/models/channel"
|
import type { Channel } from "@/models/channel"
|
||||||
|
|
||||||
type FilterValues = {
|
type FilterValues = {
|
||||||
@@ -27,6 +31,7 @@ type FilterValues = {
|
|||||||
node_ip?: string
|
node_ip?: string
|
||||||
expired_at_start?: Date
|
expired_at_start?: Date
|
||||||
expired_at_end?: Date
|
expired_at_end?: Date
|
||||||
|
expired?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const filterSchema = z
|
const filterSchema = z
|
||||||
@@ -39,6 +44,7 @@ const filterSchema = z
|
|||||||
node_ip: z.string().optional(),
|
node_ip: z.string().optional(),
|
||||||
expired_at_start: z.string().optional(),
|
expired_at_start: z.string().optional(),
|
||||||
expired_at_end: z.string().optional(),
|
expired_at_end: z.string().optional(),
|
||||||
|
expired: z.enum(["all", "expired", "not_expired"]).optional(),
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
if (data.expired_at_start && data.expired_at_end) {
|
if (data.expired_at_start && data.expired_at_end) {
|
||||||
@@ -64,47 +70,60 @@ const ispMap: Record<number, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ChannelPage() {
|
export default function ChannelPage() {
|
||||||
const [filters, setFilters] = useState<FilterValues>({})
|
const searchParams = useSearchParams()
|
||||||
|
const resourceNo = searchParams.get("resource_no")
|
||||||
const { control, handleSubmit, reset } = useForm<FilterSchema>({
|
const batch_no = searchParams.get("batch_no")
|
||||||
|
const router = useRouter()
|
||||||
|
const { control, handleSubmit, reset, getValues } = useForm<FilterSchema>({
|
||||||
resolver: zodResolver(filterSchema),
|
resolver: zodResolver(filterSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
batch_no: "",
|
batch_no: batch_no || "",
|
||||||
user_phone: "",
|
user_phone: "",
|
||||||
resource_no: "",
|
resource_no: resourceNo || "",
|
||||||
proxy_port: "",
|
proxy_port: "",
|
||||||
proxy_host: "",
|
proxy_host: "",
|
||||||
node_ip: "",
|
node_ip: "",
|
||||||
expired_at_start: "",
|
expired_at_start: "",
|
||||||
expired_at_end: "",
|
expired_at_end: "",
|
||||||
|
expired: "all",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const table = useDataTable<Channel>((page, size) =>
|
const table = useDataTable<Channel>((page, size) => {
|
||||||
getPageChannel({ page, size, ...filters }),
|
|
||||||
)
|
|
||||||
|
|
||||||
const onFilter = handleSubmit(data => {
|
|
||||||
const result: FilterValues = {}
|
const result: FilterValues = {}
|
||||||
if (data.batch_no?.trim()) result.batch_no = data.batch_no.trim()
|
const filters = getValues()
|
||||||
if (data.user_phone?.trim()) result.user_phone = data.user_phone.trim()
|
if (filters.batch_no?.trim()) result.batch_no = filters.batch_no.trim()
|
||||||
if (data.resource_no?.trim()) result.resource_no = data.resource_no.trim()
|
if (filters.user_phone?.trim())
|
||||||
if (data.proxy_host?.trim()) result.proxy_host = data.proxy_host.trim()
|
result.user_phone = filters.user_phone.trim()
|
||||||
if (data.proxy_port?.trim())
|
if (filters.resource_no?.trim())
|
||||||
result.proxy_port = Number(data.proxy_port.trim())
|
result.resource_no = filters.resource_no.trim()
|
||||||
if (data.node_ip?.trim()) result.node_ip = data.node_ip.trim()
|
if (filters.proxy_host?.trim())
|
||||||
if (data.expired_at_start)
|
result.proxy_host = filters.proxy_host.trim()
|
||||||
result.expired_at_start = new Date(data.expired_at_start)
|
if (filters.proxy_port) result.proxy_port = Number(filters.proxy_port)
|
||||||
if (data.expired_at_end)
|
if (filters.node_ip?.trim()) result.node_ip = filters.node_ip.trim()
|
||||||
result.expired_at_end = new Date(data.expired_at_end)
|
if (filters.expired_at_start)
|
||||||
setFilters(result)
|
result.expired_at_start = new Date(filters.expired_at_start)
|
||||||
|
if (filters.expired_at_end)
|
||||||
|
result.expired_at_end = new Date(filters.expired_at_end)
|
||||||
|
if (filters.expired) {
|
||||||
|
if (filters.expired === "expired") {
|
||||||
|
result.expired = true
|
||||||
|
} else if (filters.expired === "not_expired") {
|
||||||
|
result.expired = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return getPageChannel({ page, size, ...result })
|
||||||
|
})
|
||||||
|
|
||||||
|
const onFilter = handleSubmit(() => {
|
||||||
table.pagination.onPageChange(1)
|
table.pagination.onPageChange(1)
|
||||||
})
|
})
|
||||||
|
console.log(table, "table")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<form onSubmit={onFilter} className="bg-card p-4 rounded-lg">
|
<form onSubmit={onFilter} className="bg-card p-4 rounded-lg">
|
||||||
<div className="flex flex-wrap items-end gap-4">
|
<div className="flex items-end flex-wrap gap-4">
|
||||||
<Controller
|
<Controller
|
||||||
name="batch_no"
|
name="batch_no"
|
||||||
control={control}
|
control={control}
|
||||||
@@ -113,8 +132,8 @@ export default function ChannelPage() {
|
|||||||
data-invalid={fieldState.invalid}
|
data-invalid={fieldState.invalid}
|
||||||
className="w-40 flex-none"
|
className="w-40 flex-none"
|
||||||
>
|
>
|
||||||
<FieldLabel>批次号</FieldLabel>
|
<FieldLabel>提取编号</FieldLabel>
|
||||||
<Input {...field} placeholder="请输入批次号" />
|
<Input {...field} placeholder="请输入提取编号" clearable />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
@@ -128,7 +147,7 @@ export default function ChannelPage() {
|
|||||||
className="w-40 flex-none"
|
className="w-40 flex-none"
|
||||||
>
|
>
|
||||||
<FieldLabel>会员号</FieldLabel>
|
<FieldLabel>会员号</FieldLabel>
|
||||||
<Input {...field} placeholder="请输入会员号" />
|
<Input {...field} placeholder="请输入会员号" clearable />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
@@ -142,7 +161,7 @@ export default function ChannelPage() {
|
|||||||
className="w-40 flex-none"
|
className="w-40 flex-none"
|
||||||
>
|
>
|
||||||
<FieldLabel>套餐号</FieldLabel>
|
<FieldLabel>套餐号</FieldLabel>
|
||||||
<Input {...field} placeholder="请输入套餐号" />
|
<Input {...field} placeholder="请输入套餐号" clearable />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
@@ -156,7 +175,7 @@ export default function ChannelPage() {
|
|||||||
className="w-40 flex-none"
|
className="w-40 flex-none"
|
||||||
>
|
>
|
||||||
<FieldLabel>代理IP</FieldLabel>
|
<FieldLabel>代理IP</FieldLabel>
|
||||||
<Input {...field} placeholder="请输入代理IP" />
|
<Input {...field} placeholder="请输入代理IP" clearable />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
@@ -170,7 +189,7 @@ export default function ChannelPage() {
|
|||||||
className="w-32 flex-none"
|
className="w-32 flex-none"
|
||||||
>
|
>
|
||||||
<FieldLabel>代理端口</FieldLabel>
|
<FieldLabel>代理端口</FieldLabel>
|
||||||
<Input {...field} placeholder="请输入代理端口" />
|
<Input {...field} placeholder="请输入代理端口" clearable />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
@@ -184,7 +203,7 @@ export default function ChannelPage() {
|
|||||||
className="w-40 flex-none"
|
className="w-40 flex-none"
|
||||||
>
|
>
|
||||||
<FieldLabel>节点</FieldLabel>
|
<FieldLabel>节点</FieldLabel>
|
||||||
<Input {...field} placeholder="请输入节点" />
|
<Input {...field} placeholder="请输入节点" clearable />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
@@ -195,10 +214,10 @@ export default function ChannelPage() {
|
|||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<Field
|
<Field
|
||||||
data-invalid={fieldState.invalid}
|
data-invalid={fieldState.invalid}
|
||||||
className="w-40 flex-none"
|
className="w-48 flex-none"
|
||||||
>
|
>
|
||||||
<FieldLabel>开始时间</FieldLabel>
|
<FieldLabel>开始时间</FieldLabel>
|
||||||
<Input type="date" {...field} />
|
<Input type="datetime-local" step="1" {...field} />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
@@ -209,30 +228,59 @@ export default function ChannelPage() {
|
|||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<Field
|
<Field
|
||||||
data-invalid={fieldState.invalid}
|
data-invalid={fieldState.invalid}
|
||||||
className="w-40 flex-none"
|
className="w-48 flex-none"
|
||||||
>
|
>
|
||||||
<FieldLabel>结束时间</FieldLabel>
|
<FieldLabel>结束时间</FieldLabel>
|
||||||
<Input type="date" {...field} />
|
<Input type="datetime-local" step="1" {...field} />
|
||||||
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="expired"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Field
|
||||||
|
data-invalid={fieldState.invalid}
|
||||||
|
className="w-32 flex-none"
|
||||||
|
>
|
||||||
|
<FieldLabel>是否过期</FieldLabel>
|
||||||
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="请选择" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部</SelectItem>
|
||||||
|
<SelectItem value="not_expired">未过期</SelectItem>
|
||||||
|
<SelectItem value="expired">已过期</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<FieldGroup className="flex-row justify-start mt-4 gap-2">
|
|
||||||
<Button type="submit">搜索</Button>
|
<Button type="submit">搜索</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
reset()
|
reset({
|
||||||
setFilters({})
|
batch_no: "",
|
||||||
|
user_phone: "",
|
||||||
|
resource_no: "",
|
||||||
|
proxy_port: "",
|
||||||
|
proxy_host: "",
|
||||||
|
node_ip: "",
|
||||||
|
expired_at_start: "",
|
||||||
|
expired_at_end: "",
|
||||||
|
})
|
||||||
|
router.replace("./channel")
|
||||||
table.pagination.onPageChange(1)
|
table.pagination.onPageChange(1)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
重置
|
重置
|
||||||
</Button>
|
</Button>
|
||||||
</FieldGroup>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<Suspense>
|
<Suspense>
|
||||||
@@ -243,25 +291,64 @@ export default function ChannelPage() {
|
|||||||
header: "会员号",
|
header: "会员号",
|
||||||
accessorFn: row => row.user?.phone || "-",
|
accessorFn: row => row.user?.phone || "-",
|
||||||
},
|
},
|
||||||
{ header: "套餐号", accessorKey: "resource.resource_no" },
|
{
|
||||||
{ header: "批次号", accessorKey: "batch_no" },
|
header: "套餐号/提取编号",
|
||||||
|
accessorKey: "resource.resource_no",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const resource_no = row.original.resource?.resource_no
|
||||||
|
const batchNo = row.original.batch_no
|
||||||
|
const type = row.original.resource?.type
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col font-mono text-xs">
|
||||||
|
<span>
|
||||||
|
套餐号  :
|
||||||
|
<Link
|
||||||
|
href={`./resources?resource_no=${resource_no}&type=${type}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600"
|
||||||
|
>
|
||||||
|
{resource_no}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
提取编号:
|
||||||
|
<Link
|
||||||
|
href={`./batch?batch_no=${batchNo}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600"
|
||||||
|
>
|
||||||
|
{batchNo}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "网关",
|
||||||
|
accessorKey: "host",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{row.original.host}:{row.original.port}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
header: "节点",
|
header: "节点",
|
||||||
accessorFn: row => row.ip || row.edge_ref || row.edge_id,
|
accessorFn: row => row.edge_ref || row.ip || row.edge_id,
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
header: "自动配置",
|
header: "地区",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const prov = row.original.filter_prov
|
const prov = row.original.filter_prov
|
||||||
const city = row.original.filter_city
|
const city = row.original.filter_city
|
||||||
const isp = row.original.filter_isp
|
|
||||||
const parts = []
|
const parts = []
|
||||||
if (prov && prov !== "all") parts.push(prov)
|
if (prov && prov !== "all") parts.push(prov)
|
||||||
if (city && city !== "all") parts.push(city)
|
if (city && city !== "all") parts.push(city)
|
||||||
if (isp && isp !== "all") {
|
|
||||||
parts.push(ispMap[Number(isp)] || isp)
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
{parts.length > 0 ? parts.join(" / ") : "不限"}
|
{parts.length > 0 ? parts.join(" / ") : "不限"}
|
||||||
@@ -270,14 +357,11 @@ export default function ChannelPage() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "网关地址",
|
header: "运营商",
|
||||||
accessorKey: "host",
|
// accessorFn: row => row.filter_isp || "",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return (
|
const isp = row.original.filter_isp
|
||||||
<span>
|
return ispMap[Number(isp)] || isp
|
||||||
{row.original.host}:{row.original.port}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -314,11 +398,31 @@ export default function ChannelPage() {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
header: "提取时间",
|
||||||
|
accessorKey: "created_at",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const createdAt = row.original.created_at
|
||||||
|
if (!createdAt) return <span>-</span>
|
||||||
|
|
||||||
|
const date = new Date(createdAt)
|
||||||
|
if (isNaN(date.getTime())) return <span>-</span>
|
||||||
|
|
||||||
|
return format(date, "yyyy-MM-dd HH:mm:ss")
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
header: "过期时间",
|
header: "过期时间",
|
||||||
accessorKey: "expired_at",
|
accessorKey: "expired_at",
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) => {
|
||||||
format(new Date(row.original.expired_at), "yyyy-MM-dd HH:mm"),
|
const expiredAt = row.original.expired_at
|
||||||
|
if (!expiredAt) return <span>-</span>
|
||||||
|
|
||||||
|
const date = new Date(expiredAt)
|
||||||
|
if (isNaN(date.getTime())) return <span>-</span>
|
||||||
|
|
||||||
|
return format(date, "yyyy-MM-dd HH:mm:ss")
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export default function BalancePage() {
|
|||||||
<span className="ml-2 text-gray-600">用户会员号: {userPhone}</span>
|
<span className="ml-2 text-gray-600">用户会员号: {userPhone}</span>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={onFilter} className="bg-card p-4 rounded-lg">
|
<form onSubmit={onFilter} className="bg-card p-4 rounded-lg">
|
||||||
<div className="flex flex-wrap items-end gap-4">
|
<div className="flex items-end gap-4">
|
||||||
<Controller
|
<Controller
|
||||||
name="bill_no"
|
name="bill_no"
|
||||||
control={control}
|
control={control}
|
||||||
@@ -141,9 +141,7 @@ export default function BalancePage() {
|
|||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<FieldGroup className="flex-row justify-start mt-4 gap-2">
|
|
||||||
<Button type="submit">搜索</Button>
|
<Button type="submit">搜索</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -156,7 +154,7 @@ export default function BalancePage() {
|
|||||||
>
|
>
|
||||||
重置
|
重置
|
||||||
</Button>
|
</Button>
|
||||||
</FieldGroup>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<Suspense>
|
<Suspense>
|
||||||
@@ -215,7 +213,10 @@ export default function BalancePage() {
|
|||||||
header: "创建时间",
|
header: "创建时间",
|
||||||
accessorKey: "created_at",
|
accessorKey: "created_at",
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
|
format(
|
||||||
|
new Date(row.original.created_at),
|
||||||
|
"yyyy-MM-dd HH:mm:ss",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
|
import Link from "next/link"
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
import { Suspense, useState } from "react"
|
import { Suspense, useState } from "react"
|
||||||
import { Controller, useForm } from "react-hook-form"
|
import { Controller, useForm } from "react-hook-form"
|
||||||
@@ -250,7 +251,24 @@ export default function BatchPage() {
|
|||||||
header: "会员号",
|
header: "会员号",
|
||||||
accessorFn: row => row.user?.phone || "",
|
accessorFn: row => row.user?.phone || "",
|
||||||
},
|
},
|
||||||
{ header: "套餐号", accessorKey: "resource.resource_no" },
|
{
|
||||||
|
header: "套餐号",
|
||||||
|
accessorKey: "resource.resource_no",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const resourceNo = row.original.resource?.resource_no
|
||||||
|
const type = row.original.resource?.type
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/resources?resource_no=${resourceNo}&type=${type}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600"
|
||||||
|
>
|
||||||
|
{resourceNo}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
{ header: "批次号", accessorKey: "batch_no" },
|
{ header: "批次号", accessorKey: "batch_no" },
|
||||||
{ header: "省份", accessorKey: "prov" },
|
{ header: "省份", accessorKey: "prov" },
|
||||||
{ header: "城市", accessorKey: "city" },
|
{ header: "城市", accessorKey: "city" },
|
||||||
@@ -261,7 +279,7 @@ export default function BatchPage() {
|
|||||||
header: "提取时间",
|
header: "提取时间",
|
||||||
accessorKey: "time",
|
accessorKey: "time",
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
format(new Date(row.original.time), "yyyy-MM-dd HH:mm"),
|
format(new Date(row.original.time), "yyyy-MM-dd HH:mm:ss"),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
import { CreditCard, Wallet } from "lucide-react"
|
import { CreditCard, Wallet } from "lucide-react"
|
||||||
|
import Link from "next/link"
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
import { Suspense, useEffect, useState } from "react"
|
import { Suspense, useEffect, useState } from "react"
|
||||||
import { Controller, useForm } from "react-hook-form"
|
import { Controller, useForm } from "react-hook-form"
|
||||||
@@ -335,15 +336,36 @@ export default function BillingPage() {
|
|||||||
header: "创建时间",
|
header: "创建时间",
|
||||||
accessorKey: "created_at",
|
accessorKey: "created_at",
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
|
format(
|
||||||
|
new Date(row.original.created_at),
|
||||||
|
"yyyy-MM-dd HH:mm:ss",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "套餐号",
|
||||||
|
accessorKey: "resource.resource_no",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const resource_no = row.original.resource?.resource_no
|
||||||
|
const type = row.original.resource?.type
|
||||||
|
return resource_no ? (
|
||||||
|
<Link
|
||||||
|
href={`/resources?resource_no=${resource_no}&type=${type}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600"
|
||||||
|
>
|
||||||
|
{resource_no}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span></span>
|
||||||
|
)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{ header: "套餐号", accessorKey: "resource.resource_no" },
|
|
||||||
{
|
{
|
||||||
header: "账单详情",
|
header: "账单详情",
|
||||||
accessorKey: "info",
|
accessorKey: "info",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const bill = row.original
|
const bill = row.original
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
|
import Link from "next/link"
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
import { Suspense, useState } from "react"
|
import { Suspense, useState } from "react"
|
||||||
import { Controller, useForm } from "react-hook-form"
|
import { Controller, useForm } from "react-hook-form"
|
||||||
@@ -244,7 +245,24 @@ export default function ChannelPage() {
|
|||||||
header: "会员号",
|
header: "会员号",
|
||||||
accessorFn: row => row.user?.phone || "",
|
accessorFn: row => row.user?.phone || "",
|
||||||
},
|
},
|
||||||
{ header: "套餐号", accessorKey: "resource.resource_no" },
|
{
|
||||||
|
header: "套餐号",
|
||||||
|
accessorKey: "resource.resource_no",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const resource_no = row.original.resource?.resource_no
|
||||||
|
const type = row.original.resource?.type
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`./resources?resource_no=${resource_no}&type=${type}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600"
|
||||||
|
>
|
||||||
|
{resource_no}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
{ header: "批次号", accessorKey: "batch_no" },
|
{ header: "批次号", accessorKey: "batch_no" },
|
||||||
{
|
{
|
||||||
header: "节点",
|
header: "节点",
|
||||||
@@ -319,7 +337,10 @@ export default function ChannelPage() {
|
|||||||
header: "过期时间",
|
header: "过期时间",
|
||||||
accessorKey: "expired_at",
|
accessorKey: "expired_at",
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
format(new Date(row.original.expired_at), "yyyy-MM-dd HH:mm"),
|
format(
|
||||||
|
new Date(row.original.expired_at),
|
||||||
|
"yyyy-MM-dd HH:mm:ss",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
75
src/app/(root)/client/coupon/page.tsx
Normal file
75
src/app/(root)/client/coupon/page.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"use client"
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
|
import { Suspense } from "react"
|
||||||
|
import { getUserCoupon } from "@/actions/coupon"
|
||||||
|
import { DataTable, useDataTable } from "@/components/data-table"
|
||||||
|
import { Page } from "@/components/page"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { type Coupon, getStatus } from "@/models/coupon"
|
||||||
|
import { formatDate } from "@/models/formatDate"
|
||||||
|
|
||||||
|
export default function CouponPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const userId = searchParams.get("userId")
|
||||||
|
const userPhone = searchParams.get("phone")
|
||||||
|
|
||||||
|
const table = useDataTable<Coupon>((page, size) => {
|
||||||
|
return getUserCoupon({
|
||||||
|
user_id: Number(userId),
|
||||||
|
page,
|
||||||
|
size,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button onClick={() => router.back()} className="gap-2">
|
||||||
|
返回上一级
|
||||||
|
</Button>
|
||||||
|
<span className="text-gray-600">用户手机号: {userPhone}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Suspense>
|
||||||
|
<DataTable<Coupon>
|
||||||
|
{...table}
|
||||||
|
columns={[
|
||||||
|
{ header: "优惠券名称", accessorKey: "coupon.name" },
|
||||||
|
{ header: "金额", accessorKey: "coupon.amount" },
|
||||||
|
{ header: "最低消费", accessorKey: "coupon.min_amount" },
|
||||||
|
{
|
||||||
|
header: "优惠券使用状态",
|
||||||
|
accessorKey: "coupon.status",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const { text, color } = getStatus(row.original.status, "use")
|
||||||
|
return <span className={color}>{text}</span>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "过期信息",
|
||||||
|
accessorKey: "expire_at",
|
||||||
|
cell: ({ row }) => formatDate(row.original.expire_at),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "发放时间",
|
||||||
|
accessorKey: "coupon.created_at",
|
||||||
|
cell: ({ row }) => formatDate(row.original.coupon.created_at),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "备注",
|
||||||
|
accessorKey: "remark",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-gray-500">
|
||||||
|
{row.original.remark || ""}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</Page>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -19,12 +19,7 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import {
|
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
|
||||||
Field,
|
|
||||||
FieldError,
|
|
||||||
FieldGroup,
|
|
||||||
FieldLabel,
|
|
||||||
} from "@/components/ui/field"
|
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import {
|
import {
|
||||||
ScopeBalanceActivityReadOfUser,
|
ScopeBalanceActivityReadOfUser,
|
||||||
@@ -111,17 +106,14 @@ export default function UserQueryPage() {
|
|||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<form onSubmit={onFilter} className="bg-card p-4 rounded-lg">
|
<form onSubmit={onFilter} className="bg-card p-4 rounded-lg">
|
||||||
<div className="flex flex-wrap items-end gap-4">
|
<div className="flex items-end gap-4">
|
||||||
<Controller
|
<Controller
|
||||||
name="phone"
|
name="phone"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<Field
|
<Field data-invalid={fieldState.invalid} className="w-40 flex">
|
||||||
data-invalid={fieldState.invalid}
|
|
||||||
className="w-40 flex-none"
|
|
||||||
>
|
|
||||||
<FieldLabel>手机号</FieldLabel>
|
<FieldLabel>手机号</FieldLabel>
|
||||||
<Input {...field} placeholder="请输入手机号" />
|
<Input {...field} placeholder="请输入手机号" clearable />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
@@ -131,27 +123,21 @@ export default function UserQueryPage() {
|
|||||||
name="name"
|
name="name"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<Field
|
<Field data-invalid={fieldState.invalid} className="w-40 flex">
|
||||||
data-invalid={fieldState.invalid}
|
|
||||||
className="w-40 flex-none"
|
|
||||||
>
|
|
||||||
<FieldLabel>姓名</FieldLabel>
|
<FieldLabel>姓名</FieldLabel>
|
||||||
<Input {...field} placeholder="请输入姓名" />
|
<Input {...field} placeholder="请输入姓名" clearable />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
<Button type="submit">搜索</Button>
|
||||||
|
|
||||||
<FieldGroup className="flex-row justify-start mt-4 gap-2">
|
|
||||||
<Auth scope={ScopeUserWrite}>
|
|
||||||
<AddUserDialog onSuccess={refreshTable} />
|
|
||||||
</Auth>
|
|
||||||
<Button type="button" variant="outline" onClick={handleReset}>
|
<Button type="button" variant="outline" onClick={handleReset}>
|
||||||
重置
|
重置
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit">搜索</Button>
|
<Auth scope={ScopeUserWrite}>
|
||||||
</FieldGroup>
|
<AddUserDialog onSuccess={refreshTable} />
|
||||||
|
</Auth>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<Suspense>
|
<Suspense>
|
||||||
@@ -164,7 +150,10 @@ export default function UserQueryPage() {
|
|||||||
header: "创建时间",
|
header: "创建时间",
|
||||||
accessorKey: "created_at",
|
accessorKey: "created_at",
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
|
format(
|
||||||
|
new Date(row.original.created_at),
|
||||||
|
"yyyy-MM-dd HH:mm:ss",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "客户来源",
|
header: "客户来源",
|
||||||
@@ -229,7 +218,7 @@ export default function UserQueryPage() {
|
|||||||
row.original.last_login
|
row.original.last_login
|
||||||
? format(
|
? format(
|
||||||
new Date(row.original.last_login),
|
new Date(row.original.last_login),
|
||||||
"yyyy-MM-dd HH:mm",
|
"yyyy-MM-dd HH:mm:ss",
|
||||||
)
|
)
|
||||||
: "",
|
: "",
|
||||||
},
|
},
|
||||||
@@ -263,7 +252,7 @@ export default function UserQueryPage() {
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push(
|
router.push(
|
||||||
`/client/trade?userId=${row.original.id}`,
|
`/client/trade?userId=${row.original.id}&phone=${row.original.phone}`,
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -274,7 +263,7 @@ export default function UserQueryPage() {
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push(
|
router.push(
|
||||||
`/client/billing?userId=${row.original.id}`,
|
`/client/billing?userId=${row.original.id}&phone=${row.original.phone}`,
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -285,7 +274,7 @@ export default function UserQueryPage() {
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push(
|
router.push(
|
||||||
`/client/resources?userId=${row.original.id}`,
|
`/client/resources?userId=${row.original.id}&phone=${row.original.phone}`,
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -296,7 +285,7 @@ export default function UserQueryPage() {
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push(
|
router.push(
|
||||||
`/client/batch?userId=${row.original.id}`,
|
`/client/batch?userId=${row.original.id}&phone=${row.original.phone}`,
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -307,7 +296,7 @@ export default function UserQueryPage() {
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push(
|
router.push(
|
||||||
`/client/channel?userId=${row.original.id}`,
|
`/client/channel?userId=${row.original.id}&phone=${row.original.phone}`,
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ import { DataTable, useDataTable } from "@/components/data-table"
|
|||||||
import { Page } from "@/components/page"
|
import { Page } from "@/components/page"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
import {
|
import {
|
||||||
Field,
|
Field,
|
||||||
FieldError,
|
FieldError,
|
||||||
@@ -158,7 +164,7 @@ function ExpireBadge({ expireAt }: { expireAt: Date | null | undefined }) {
|
|||||||
// 格式化日期
|
// 格式化日期
|
||||||
function formatDateTime(date: Date | null | undefined) {
|
function formatDateTime(date: Date | null | undefined) {
|
||||||
if (!date) return "-"
|
if (!date) return "-"
|
||||||
return format(date, "yyyy-MM-dd HH:mm")
|
return format(date, "yyyy-MM-dd HH:mm:ss")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算今日使用量
|
// 计算今日使用量
|
||||||
@@ -224,6 +230,7 @@ function ResourceList({ resourceType }: ResourceListProps) {
|
|||||||
const isLong = resourceType === "long"
|
const isLong = resourceType === "long"
|
||||||
const listFn = isLong ? ResourceLong : ResourceShort
|
const listFn = isLong ? ResourceLong : ResourceShort
|
||||||
const [updatingId, setUpdatingId] = useState<number | null>(null)
|
const [updatingId, setUpdatingId] = useState<number | null>(null)
|
||||||
|
const router = useRouter()
|
||||||
const { control, handleSubmit, reset } = useForm<FormValues>({
|
const { control, handleSubmit, reset } = useForm<FormValues>({
|
||||||
resolver: zodResolver(filterSchema),
|
resolver: zodResolver(filterSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -313,7 +320,34 @@ function ResourceList({ resourceType }: ResourceListProps) {
|
|||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div>{name}</div>
|
<div>{name}</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-gray-500">{resourceNo}</span>
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<span className="text-xs text-gray-500">{resourceNo}</span>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-40">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
router.push(`/billing?resource_no=${resourceNo}`)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
账单详情
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
router.push(`/batch?resource_no=${resourceNo}`)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
提取记录
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
router.push(`/channel?resource_no=${resourceNo}`)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
IP管理
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
<ExpireBadge expireAt={expireAt} />
|
<ExpireBadge expireAt={expireAt} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -435,7 +469,7 @@ function ResourceList({ resourceType }: ResourceListProps) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[isLong, updatingId, handleStatusChange],
|
[isLong, updatingId, handleStatusChange, router],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export default function TradePage() {
|
|||||||
<span className="ml-2 text-gray-600">用户会员号: {userPhone}</span>
|
<span className="ml-2 text-gray-600">用户会员号: {userPhone}</span>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={onFilter} className="bg-card p-4 rounded-lg">
|
<form onSubmit={onFilter} className="bg-card p-4 rounded-lg">
|
||||||
<div className="flex flex-wrap items-end gap-4">
|
<div className="flex items-end gap-4">
|
||||||
<Controller
|
<Controller
|
||||||
name="inner_no"
|
name="inner_no"
|
||||||
control={control}
|
control={control}
|
||||||
@@ -137,7 +137,6 @@ export default function TradePage() {
|
|||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
name="method"
|
name="method"
|
||||||
control={control}
|
control={control}
|
||||||
@@ -161,7 +160,6 @@ export default function TradePage() {
|
|||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
name="platform"
|
name="platform"
|
||||||
control={control}
|
control={control}
|
||||||
@@ -182,7 +180,6 @@ export default function TradePage() {
|
|||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
name="status"
|
name="status"
|
||||||
control={control}
|
control={control}
|
||||||
@@ -204,7 +201,6 @@ export default function TradePage() {
|
|||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
name="created_at_start"
|
name="created_at_start"
|
||||||
control={control}
|
control={control}
|
||||||
@@ -219,7 +215,6 @@ export default function TradePage() {
|
|||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
name="created_at_end"
|
name="created_at_end"
|
||||||
control={control}
|
control={control}
|
||||||
@@ -234,9 +229,6 @@ export default function TradePage() {
|
|||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<FieldGroup className="flex-row justify-start mt-4 gap-2">
|
|
||||||
<Button type="submit">搜索</Button>
|
<Button type="submit">搜索</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -249,7 +241,7 @@ export default function TradePage() {
|
|||||||
>
|
>
|
||||||
重置
|
重置
|
||||||
</Button>
|
</Button>
|
||||||
</FieldGroup>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<Suspense>
|
<Suspense>
|
||||||
@@ -260,7 +252,10 @@ export default function TradePage() {
|
|||||||
header: "创建时间",
|
header: "创建时间",
|
||||||
accessorKey: "created_at",
|
accessorKey: "created_at",
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
|
format(
|
||||||
|
new Date(row.original.created_at),
|
||||||
|
"yyyy-MM-dd HH:mm:ss",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "订单号",
|
header: "订单号",
|
||||||
@@ -330,7 +325,7 @@ export default function TradePage() {
|
|||||||
? "电脑网站"
|
? "电脑网站"
|
||||||
: platform === 2
|
: platform === 2
|
||||||
? "手机网站"
|
? "手机网站"
|
||||||
: "-"
|
: ""
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,14 +17,55 @@ import {
|
|||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z
|
||||||
code: z.string().min(1, "请输入优惠券名称"),
|
.object({
|
||||||
amount: z.string().min(1, "请输入优惠券金额"),
|
name: z.string().min(1, "请输入优惠券名称"),
|
||||||
remark: z.string().optional(),
|
amount: z
|
||||||
min_amount: z.string().optional(),
|
.string()
|
||||||
expire_at: z.string().optional(),
|
.min(1, "请输入优惠券金额")
|
||||||
})
|
.regex(/^\d+(\.\d+)?$/, "请输入有效的金额数字")
|
||||||
|
.refine(val => Number(val) > 0, "优惠券金额必须大于0"),
|
||||||
|
count: z
|
||||||
|
.string()
|
||||||
|
.min(1, "请输入优惠券数量")
|
||||||
|
.regex(/^\d+$/, "请输入正整数")
|
||||||
|
.refine(val => Number(val) >= 1, "优惠券数量至少为1"),
|
||||||
|
min_amount: z
|
||||||
|
.string()
|
||||||
|
.min(1, "请输入最低消费金额")
|
||||||
|
.regex(/^\d+(\.\d+)?$/, "请输入有效的金额数字")
|
||||||
|
.refine(val => Number(val) >= 0, "最低消费金额不能为负数"),
|
||||||
|
expire_at: z.string().optional(),
|
||||||
|
expire_type: z.string().min(1, "请选择过期类型"),
|
||||||
|
expire_in: z.string().optional(),
|
||||||
|
status: z.string().optional(),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
const expireType = Number(data.expire_type)
|
||||||
|
if (!data.expire_type) return
|
||||||
|
if (expireType === 1 && !data.expire_at) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "请选择过期时间",
|
||||||
|
path: ["expire_at"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (expireType === 2 && !data.expire_in) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "请输入过期时长天数",
|
||||||
|
path: ["expire_in"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export function CreateDiscount(props: { onSuccess?: () => void }) {
|
export function CreateDiscount(props: { onSuccess?: () => void }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
@@ -32,25 +73,33 @@ export function CreateDiscount(props: { onSuccess?: () => void }) {
|
|||||||
const form = useForm({
|
const form = useForm({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
code: "",
|
name: "",
|
||||||
remark: "",
|
count: "",
|
||||||
amount: "0",
|
amount: "",
|
||||||
min_amount: "0",
|
min_amount: "",
|
||||||
expire_at: "",
|
expire_at: "",
|
||||||
|
expire_in: "",
|
||||||
|
expire_type: "",
|
||||||
|
status: "0",
|
||||||
},
|
},
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
})
|
})
|
||||||
|
|
||||||
const { control, handleSubmit, reset } = form
|
const { control, handleSubmit, reset, watch } = form
|
||||||
|
const watchExpireType = watch("expire_type")
|
||||||
|
|
||||||
const onSubmit = async (data: z.infer<typeof schema>) => {
|
const onSubmit = async (data: z.infer<typeof schema>) => {
|
||||||
try {
|
try {
|
||||||
|
const expireType = Number(data.expire_type)
|
||||||
const payload = {
|
const payload = {
|
||||||
code: data.code,
|
name: data.name,
|
||||||
amount: Number(data.amount),
|
amount: Number(data.amount),
|
||||||
remark: data?.remark,
|
count: Number(data?.count),
|
||||||
|
status: Number(data.status),
|
||||||
min_amount: Number(data?.min_amount),
|
min_amount: Number(data?.min_amount),
|
||||||
expire_at: data?.expire_at ? new Date(data.expire_at) : undefined,
|
expire_at: data?.expire_at ? new Date(data.expire_at) : undefined,
|
||||||
|
expire_in: expireType === 2 ? Number(data.expire_in) : undefined,
|
||||||
|
expire_type: expireType,
|
||||||
}
|
}
|
||||||
const resp = await createCoupon(payload)
|
const resp = await createCoupon(payload)
|
||||||
if (resp.success) {
|
if (resp.success) {
|
||||||
@@ -93,10 +142,10 @@ export function CreateDiscount(props: { onSuccess?: () => void }) {
|
|||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="code"
|
name="name"
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<FieldLabel className="w-28 pt-2">名称:</FieldLabel>
|
<FieldLabel className="w-28 pt-2">优惠券名称:</FieldLabel>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
@@ -107,10 +156,10 @@ export function CreateDiscount(props: { onSuccess?: () => void }) {
|
|||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="remark"
|
name="count"
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<FieldLabel className="w-28 pt-2">备注:</FieldLabel>
|
<FieldLabel className="w-28 pt-2">优惠券数量:</FieldLabel>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
@@ -124,20 +173,9 @@ export function CreateDiscount(props: { onSuccess?: () => void }) {
|
|||||||
name="amount"
|
name="amount"
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<FieldLabel className="w-28 pt-2">金额:</FieldLabel>
|
<FieldLabel className="w-28 pt-2">优惠券金额:</FieldLabel>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Input
|
<Input {...field} />
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
step={5}
|
|
||||||
{...field}
|
|
||||||
onChange={e => {
|
|
||||||
const value = e.target.value
|
|
||||||
if (value === "" || parseFloat(value) >= 0) {
|
|
||||||
field.onChange(value)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,43 +186,99 @@ export function CreateDiscount(props: { onSuccess?: () => void }) {
|
|||||||
name="min_amount"
|
name="min_amount"
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<FieldLabel className="w-28 pt-2">最低消费:</FieldLabel>
|
<FieldLabel className="w-28 pt-2">最低消费金额:</FieldLabel>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Input
|
<Input {...field} />
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
step={5}
|
|
||||||
{...field}
|
|
||||||
onChange={e => {
|
|
||||||
const value = e.target.value
|
|
||||||
if (value === "" || parseFloat(value) >= 0) {
|
|
||||||
field.onChange(value)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="expire_at"
|
name="status"
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<FieldLabel className="w-30 pt-2">过期时间:</FieldLabel>
|
<FieldLabel className="w-28 pt-2">优惠券状态:</FieldLabel>
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
min={new Date().toISOString().split("T")[0]}
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="请选择状态" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="0">禁用</SelectItem>
|
||||||
|
<SelectItem value="1">正常</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="expire_type"
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<FieldLabel className="w-28 pt-2">过期类型:</FieldLabel>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="请选择过期类型" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="0">不过期</SelectItem>
|
||||||
|
<SelectItem value="1">固定日期</SelectItem>
|
||||||
|
<SelectItem value="2">相对日期</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{watchExpireType === "1" && (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="expire_at"
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<FieldLabel className="w-28 pt-2">过期时间:</FieldLabel>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
min={new Date().toISOString().split("T")[0]}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{watchExpireType === "2" && (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="expire_in"
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<FieldLabel className="w-28 pt-2">
|
||||||
|
过期时长(天):
|
||||||
|
</FieldLabel>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
placeholder="请输入过期天数"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { format } from "date-fns"
|
|
||||||
import { Suspense, useState } from "react"
|
import { Suspense, useState } from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { deleteCoupon, getPagCoupon } from "@/actions/coupon"
|
import { deleteCoupon, getPagCoupon } from "@/actions/coupon"
|
||||||
|
import { Auth } from "@/components/auth"
|
||||||
import { DataTable, useDataTable } from "@/components/data-table"
|
import { DataTable, useDataTable } from "@/components/data-table"
|
||||||
import { Page } from "@/components/page"
|
import { Page } from "@/components/page"
|
||||||
import {
|
import {
|
||||||
@@ -17,8 +17,11 @@ import {
|
|||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "@/components/ui/alert-dialog"
|
} from "@/components/ui/alert-dialog"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import type { Coupon } from "@/models/coupon"
|
import { ScopeCouponWriteAssign } from "@/lib/scopes"
|
||||||
|
import { type Coupon, getExpireType, getStatus } from "@/models/coupon"
|
||||||
|
import { formatDate } from "@/models/formatDate"
|
||||||
import { CreateDiscount } from "./create"
|
import { CreateDiscount } from "./create"
|
||||||
|
import { ReleaseCoupon } from "./release"
|
||||||
import { UpdateCoupon } from "./update"
|
import { UpdateCoupon } from "./update"
|
||||||
|
|
||||||
export default function CouponPage() {
|
export default function CouponPage() {
|
||||||
@@ -36,42 +39,41 @@ export default function CouponPage() {
|
|||||||
<DataTable<Coupon>
|
<DataTable<Coupon>
|
||||||
{...table}
|
{...table}
|
||||||
columns={[
|
columns={[
|
||||||
{ header: "所属用户", accessorKey: "user_id" },
|
{ header: "优惠券名称", accessorKey: "name" },
|
||||||
{ header: "代码", accessorKey: "code" },
|
{ header: "优惠券数量", accessorKey: "count" },
|
||||||
{ header: "备注", accessorKey: "remark" },
|
{ header: "优惠券金额", accessorKey: "amount" },
|
||||||
{ header: "金额", accessorKey: "amount" },
|
|
||||||
{ header: "最低消费金额", accessorKey: "min_amount" },
|
{ header: "最低消费金额", accessorKey: "min_amount" },
|
||||||
{
|
{
|
||||||
header: "状态",
|
header: "优惠券状态",
|
||||||
accessorKey: "status",
|
accessorKey: "status",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const status = row.original.status
|
const { text, color } = getStatus(row.original.status, "coupon")
|
||||||
if (status === 0) {
|
return <span className={color}>{text}</span>
|
||||||
return <span className="text-yellow-600">未使用</span>
|
|
||||||
}
|
|
||||||
if (status === 1) {
|
|
||||||
return <span className="text-green-600">已使用</span>
|
|
||||||
}
|
|
||||||
return <span>-</span>
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
header: "过期类型",
|
||||||
|
cell: ({ row }) => getExpireType(row.original.expire_type),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
header: "过期时间",
|
header: "过期时间",
|
||||||
accessorKey: "expire_at",
|
accessorKey: "expire_at",
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) => {
|
||||||
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
|
const coupon = row.original
|
||||||
|
if (coupon.expire_type === 2 && coupon.expire_in) {
|
||||||
|
return `${coupon.expire_in}天`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coupon.expire_type === 1 && coupon.expire_at) {
|
||||||
|
return formatDate(row.original.expire_at)
|
||||||
|
}
|
||||||
|
return <span>永久有效</span>
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "创建时间",
|
header: "创建时间",
|
||||||
accessorKey: "created_at",
|
accessorKey: "created_at",
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) => formatDate(row.original.created_at),
|
||||||
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: "更新时间",
|
|
||||||
accessorKey: "updated_at",
|
|
||||||
cell: ({ row }) =>
|
|
||||||
format(new Date(row.original.updated_at), "yyyy-MM-dd HH:mm"),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "action",
|
id: "action",
|
||||||
@@ -83,6 +85,12 @@ export default function CouponPage() {
|
|||||||
coupon={row.original}
|
coupon={row.original}
|
||||||
onSuccess={table.refresh}
|
onSuccess={table.refresh}
|
||||||
/>
|
/>
|
||||||
|
<Auth scope={ScopeCouponWriteAssign}>
|
||||||
|
<ReleaseCoupon
|
||||||
|
coupon={row.original}
|
||||||
|
onSuccess={table.refresh}
|
||||||
|
/>
|
||||||
|
</Auth>
|
||||||
<DeleteCoupon
|
<DeleteCoupon
|
||||||
coupon={row.original}
|
coupon={row.original}
|
||||||
onSuccess={table.refresh}
|
onSuccess={table.refresh}
|
||||||
@@ -135,7 +143,7 @@ function DeleteCoupon({
|
|||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
确定要删除折扣「{coupon.code}」吗?此操作不可撤销。
|
确定要删除折扣「{coupon.name}」吗?此操作不可撤销。
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
|
|||||||
349
src/app/(root)/coupon/release.tsx
Normal file
349
src/app/(root)/coupon/release.tsx
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { format } from "date-fns"
|
||||||
|
import { Suspense, useCallback, useState } from "react"
|
||||||
|
import { Controller, useForm } from "react-hook-form"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import z from "zod"
|
||||||
|
import { getReleaseCoupon } from "@/actions/coupon"
|
||||||
|
import { getPageUser } from "@/actions/user"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import type { Coupon } from "@/models/coupon"
|
||||||
|
import type { User } from "@/models/user"
|
||||||
|
|
||||||
|
interface UserQueryParams {
|
||||||
|
account?: string
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterSchema = z.object({
|
||||||
|
phone: z.string().optional(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
})
|
||||||
|
type FormValues = z.infer<typeof filterSchema>
|
||||||
|
|
||||||
|
export function ReleaseCoupon(props: {
|
||||||
|
coupon: Coupon
|
||||||
|
onSuccess?: () => void
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [userList, setUserList] = useState<User[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [currentFilters, setCurrentFilters] = useState<UserQueryParams>({})
|
||||||
|
const { control, handleSubmit, reset } = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(filterSchema),
|
||||||
|
defaultValues: {
|
||||||
|
phone: "",
|
||||||
|
name: "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchUsers = useCallback(async (filters: UserQueryParams = {}) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
setOpen(true)
|
||||||
|
const res = await getPageUser(filters)
|
||||||
|
if (res.success) {
|
||||||
|
const data = Array.isArray(res.data) ? res.data : [res.data]
|
||||||
|
setUserList(data)
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || "获取用户失败")
|
||||||
|
setUserList([])
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : error
|
||||||
|
toast.error(`获取用户失败: ${message}`)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onFilter = handleSubmit((data: FormValues) => {
|
||||||
|
const params: UserQueryParams = {}
|
||||||
|
if (data.phone?.trim()) params.account = data.phone.trim()
|
||||||
|
if (data.name?.trim()) params.name = data.name.trim()
|
||||||
|
if (Object.keys(params).length === 0) {
|
||||||
|
toast.info("请至少输入一个搜索条件")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCurrentFilters(params)
|
||||||
|
fetchUsers(params)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
reset()
|
||||||
|
setCurrentFilters({})
|
||||||
|
setUserList([])
|
||||||
|
}, [reset])
|
||||||
|
|
||||||
|
const handleIssueCoupon = useCallback(
|
||||||
|
async (users: User[], coupon: Coupon) => {
|
||||||
|
console.log(coupon, "couponcouponcoupon")
|
||||||
|
|
||||||
|
const targetUser = users[0]
|
||||||
|
|
||||||
|
if (!targetUser || !targetUser.id) {
|
||||||
|
toast.error("用户信息无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!coupon || !coupon.id) {
|
||||||
|
toast.error("优惠券信息无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (coupon.status !== 1) {
|
||||||
|
toast.error("优惠券不可用,请检查优惠券状态")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await getReleaseCoupon({
|
||||||
|
coupon_id: coupon.id,
|
||||||
|
user_id: targetUser.id,
|
||||||
|
})
|
||||||
|
console.log({
|
||||||
|
coupon_id: coupon.id,
|
||||||
|
user_id: targetUser.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(result, "resultresultresultresultresult")
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(
|
||||||
|
`成功发放优惠券给用户 ${targetUser.phone || targetUser.username}`,
|
||||||
|
)
|
||||||
|
setOpen(false)
|
||||||
|
handleReset()
|
||||||
|
props.onSuccess?.()
|
||||||
|
} else {
|
||||||
|
toast.error("发放失败,请稍后重试")
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "发放失败"
|
||||||
|
toast.error(`发放优惠券失败: ${message}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[props.onSuccess, handleReset],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={newOpen => {
|
||||||
|
setOpen(newOpen)
|
||||||
|
if (!newOpen) {
|
||||||
|
handleReset()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="sm" variant="secondary">
|
||||||
|
发放
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent
|
||||||
|
className="max-h-[85vh] overflow-y-auto"
|
||||||
|
style={{ width: "auto", minWidth: "800px", maxWidth: "90vw" }}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>发放优惠券</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={onFilter} className="bg-card rounded-lg">
|
||||||
|
<div className="flex items-end gap-4">
|
||||||
|
<Controller
|
||||||
|
name="phone"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Field data-invalid={fieldState.invalid} className="w-40 flex">
|
||||||
|
<FieldLabel>手机号</FieldLabel>
|
||||||
|
<Input {...field} placeholder="请输入手机号" />
|
||||||
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="outline" onClick={handleReset}>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">搜索</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<Suspense fallback={<div className="py-8 text-center">加载中...</div>}>
|
||||||
|
{loading ? (
|
||||||
|
<div className="py-8 text-center">加载中...</div>
|
||||||
|
) : userList.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-gray-500">暂无用户数据</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-1">
|
||||||
|
{userList.map(user => (
|
||||||
|
<div
|
||||||
|
key={user.id}
|
||||||
|
className="border rounded-lg overflow-hidden hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<div className="bg-gray-50 px-4 py-3 border-b flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="font-medium text-base">
|
||||||
|
{user.phone || "未绑定手机"}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Badge
|
||||||
|
variant={user.id_type === 1 ? "default" : "secondary"}
|
||||||
|
className={
|
||||||
|
user.id_type === 1
|
||||||
|
? "bg-green-100 text-green-700 hover:bg-green-100"
|
||||||
|
: "bg-gray-100 text-gray-600 hover:bg-gray-100"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{user.id_type === 1 ? "✓ 已认证" : "○ 未认证"}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
user.status === 1 ? "default" : "destructive"
|
||||||
|
}
|
||||||
|
className={
|
||||||
|
user.status === 1
|
||||||
|
? "bg-green-100 text-green-700 hover:bg-green-100"
|
||||||
|
: "bg-red-100 text-red-700 hover:bg-red-100"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{user.status === 1 ? "正常" : "禁用"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
余额:
|
||||||
|
<span
|
||||||
|
className={`ml-1 font-semibold ${
|
||||||
|
Number(user.balance) > 0
|
||||||
|
? "text-green-600"
|
||||||
|
: "text-orange-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
¥{(Number(user.balance) || 0).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-gray-500 w-20 inline-block">
|
||||||
|
姓名:
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-900">
|
||||||
|
{user.name || ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-gray-500 w-20 inline-block">
|
||||||
|
账号:
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-900">
|
||||||
|
{user.username || ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-gray-500 w-20 inline-block">
|
||||||
|
客户经理:
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-900">
|
||||||
|
{user.admin?.name || ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-gray-500 w-20 inline-block">
|
||||||
|
客户来源:
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-900">
|
||||||
|
{(() => {
|
||||||
|
const sourceMap: Record<number, string> = {
|
||||||
|
0: "官网注册",
|
||||||
|
1: "管理员添加",
|
||||||
|
2: "代理商注册",
|
||||||
|
3: "代理商添加",
|
||||||
|
}
|
||||||
|
return sourceMap[user.source] ?? "官网注册"
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-gray-500 w-20 inline-block">
|
||||||
|
联系方式:
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-900">
|
||||||
|
{user.contact_wechat || ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-gray-500 w-20 inline-block">
|
||||||
|
创建时间:
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-900">
|
||||||
|
{user.created_at
|
||||||
|
? format(new Date(user.created_at), "yyyy-MM-dd")
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-gray-500 w-20 inline-block">
|
||||||
|
最后登录:
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-900">
|
||||||
|
{user.last_login
|
||||||
|
? format(
|
||||||
|
new Date(user.last_login),
|
||||||
|
"yyyy-MM-dd HH:mm:ss",
|
||||||
|
)
|
||||||
|
: "-"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-gray-500 w-20 inline-block">
|
||||||
|
登录IP:
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-900">
|
||||||
|
{user.last_login_ip || "-"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Suspense>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="ghost" onClick={handleReset}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleIssueCoupon(userList, props.coupon)}
|
||||||
|
disabled={!userList || userList.length === 0}
|
||||||
|
>
|
||||||
|
发放优惠券
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -25,14 +25,47 @@ import {
|
|||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import type { Coupon } from "@/models/coupon"
|
import type { Coupon } from "@/models/coupon"
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z
|
||||||
code: z.string().min(1, "请输入优惠券名称"),
|
.object({
|
||||||
amount: z.string().min(1, "请输入优惠券金额"),
|
name: z.string().min(1, "请输入优惠券名称"),
|
||||||
remark: z.string().optional(),
|
amount: z
|
||||||
min_amount: z.string().optional(),
|
.string()
|
||||||
expire_at: z.string().optional(),
|
.min(1, "请输入优惠券金额")
|
||||||
status: z.string().optional(),
|
.regex(/^\d+(\.\d+)?$/, "请输入有效的金额数字")
|
||||||
})
|
.refine(val => Number(val) > 0, "优惠券金额必须大于0"),
|
||||||
|
count: z
|
||||||
|
.string()
|
||||||
|
.min(1, "请输入优惠券数量")
|
||||||
|
.regex(/^\d+$/, "请输入正整数")
|
||||||
|
.refine(val => Number(val) >= 1, "优惠券数量至少为1"),
|
||||||
|
min_amount: z
|
||||||
|
.string()
|
||||||
|
.min(1, "请输入最低消费金额")
|
||||||
|
.regex(/^\d+(\.\d+)?$/, "请输入有效的金额数字")
|
||||||
|
.refine(val => Number(val) >= 0, "最低消费金额不能为负数"),
|
||||||
|
expire_at: z.string().optional(),
|
||||||
|
expire_type: z.string().min(1, "请选择过期类型"),
|
||||||
|
expire_in: z.string().optional(),
|
||||||
|
status: z.string().optional(),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
const expireType = Number(data.expire_type)
|
||||||
|
if (!data.expire_type) return
|
||||||
|
if (expireType === 1 && !data.expire_at) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "请选择过期时间",
|
||||||
|
path: ["expire_at"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (expireType === 2 && !data.expire_in) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "请输入过期时长天数",
|
||||||
|
path: ["expire_in"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export function UpdateCoupon(props: {
|
export function UpdateCoupon(props: {
|
||||||
coupon: Coupon
|
coupon: Coupon
|
||||||
@@ -43,30 +76,34 @@ export function UpdateCoupon(props: {
|
|||||||
const form = useForm({
|
const form = useForm({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
code: props.coupon.code || "",
|
name: props.coupon.name,
|
||||||
remark: props.coupon.remark || "",
|
amount: String(props.coupon.amount),
|
||||||
amount: String(props.coupon.amount || 0),
|
min_amount: String(props.coupon.min_amount),
|
||||||
min_amount: String(props.coupon.min_amount || 0),
|
expire_at: props?.coupon.expire_at
|
||||||
expire_at: props.coupon.expire_at
|
? new Date(props?.coupon.expire_at).toISOString().split("T")[0]
|
||||||
? new Date(props.coupon.expire_at).toISOString().split("T")[0]
|
|
||||||
: "",
|
: "",
|
||||||
status: String(props.coupon.status || "0"),
|
status: String(props.coupon.status),
|
||||||
|
count: String(props.coupon.count),
|
||||||
|
expire_in: String(props?.coupon.expire_in),
|
||||||
},
|
},
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
})
|
})
|
||||||
|
|
||||||
const { control, handleSubmit, reset } = form
|
const { control, handleSubmit, reset, watch } = form
|
||||||
|
const watchExpireType = watch("expire_type")
|
||||||
const onSubmit = async (data: z.infer<typeof schema>) => {
|
const onSubmit = async (data: z.infer<typeof schema>) => {
|
||||||
try {
|
try {
|
||||||
|
const expireType = Number(data.expire_type)
|
||||||
const payload = {
|
const payload = {
|
||||||
id: props.coupon.id,
|
id: props.coupon.id,
|
||||||
code: data.code,
|
name: data.name,
|
||||||
amount: Number(data.amount),
|
amount: Number(data.amount),
|
||||||
remark: data.remark,
|
|
||||||
min_amount: Number(data.min_amount),
|
min_amount: Number(data.min_amount),
|
||||||
expire_at: data.expire_at ? new Date(data.expire_at) : undefined,
|
count: Number(data.count),
|
||||||
status: Number(data.status),
|
status: Number(data.status),
|
||||||
|
expire_type: expireType,
|
||||||
|
expire_at: data.expire_at ? new Date(data.expire_at) : undefined,
|
||||||
|
expire_in: expireType === 2 ? Number(data.expire_in) : undefined,
|
||||||
}
|
}
|
||||||
const resp = await updateCoupon(payload)
|
const resp = await updateCoupon(payload)
|
||||||
if (resp.success) {
|
if (resp.success) {
|
||||||
@@ -85,14 +122,16 @@ export function UpdateCoupon(props: {
|
|||||||
const handleOpenChange = (value: boolean) => {
|
const handleOpenChange = (value: boolean) => {
|
||||||
if (value) {
|
if (value) {
|
||||||
reset({
|
reset({
|
||||||
code: props.coupon.code || "",
|
name: props.coupon.name,
|
||||||
remark: props.coupon.remark || "",
|
count: String(props.coupon.count),
|
||||||
amount: String(props.coupon.amount || 0),
|
amount: String(props.coupon.amount),
|
||||||
min_amount: String(props.coupon.min_amount || 0),
|
min_amount: String(props.coupon.min_amount),
|
||||||
expire_at: props.coupon.expire_at
|
expire_at: props.coupon.expire_at
|
||||||
? new Date(props.coupon.expire_at).toISOString().split("T")[0]
|
? new Date(props.coupon.expire_at).toISOString().split("T")[0]
|
||||||
: "",
|
: "",
|
||||||
status: String(props.coupon.status || "0"),
|
status: String(props.coupon.status),
|
||||||
|
expire_type: String(props.coupon.expire_type),
|
||||||
|
expire_in: String(props.coupon.expire_in),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
setOpen(value)
|
setOpen(value)
|
||||||
@@ -115,10 +154,10 @@ export function UpdateCoupon(props: {
|
|||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="code"
|
name="name"
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<FieldLabel className="w-28 pt-2">名称:</FieldLabel>
|
<FieldLabel className="w-28 pt-2">优惠券名称:</FieldLabel>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
@@ -129,10 +168,10 @@ export function UpdateCoupon(props: {
|
|||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="remark"
|
name="count"
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<FieldLabel className="w-28 pt-2">备注:</FieldLabel>
|
<FieldLabel className="w-28 pt-2">优惠券数量:</FieldLabel>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
@@ -146,20 +185,9 @@ export function UpdateCoupon(props: {
|
|||||||
name="amount"
|
name="amount"
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<FieldLabel className="w-28 pt-2">金额:</FieldLabel>
|
<FieldLabel className="w-28 pt-2">优惠券金额:</FieldLabel>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Input
|
<Input {...field} />
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
step={5}
|
|
||||||
{...field}
|
|
||||||
onChange={e => {
|
|
||||||
const value = e.target.value
|
|
||||||
if (value === "" || parseFloat(value) >= 0) {
|
|
||||||
field.onChange(value)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,58 +199,28 @@ export function UpdateCoupon(props: {
|
|||||||
name="min_amount"
|
name="min_amount"
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<FieldLabel className="w-28 pt-2">最低消费:</FieldLabel>
|
<FieldLabel className="w-28 pt-2">最低消费金额:</FieldLabel>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Input
|
<Input {...field} />
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
step={5}
|
|
||||||
{...field}
|
|
||||||
onChange={e => {
|
|
||||||
const value = e.target.value
|
|
||||||
if (value === "" || parseFloat(value) >= 0) {
|
|
||||||
field.onChange(value)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="expire_at"
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<FieldLabel className="w-28 pt-2">过期时间:</FieldLabel>
|
|
||||||
<div className="flex-1">
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
min={new Date().toISOString().split("T")[0]}
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="status"
|
name="status"
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<FieldLabel className="w-28 pt-2">状态:</FieldLabel>
|
<FieldLabel className="w-28 pt-2">优惠券状态:</FieldLabel>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Select value={field.value} onValueChange={field.onChange}>
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="请选择状态" />
|
<SelectValue placeholder="请选择状态" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="0">未使用</SelectItem>
|
<SelectItem value="0">禁用</SelectItem>
|
||||||
<SelectItem value="1">已使用</SelectItem>
|
<SelectItem value="1">正常</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
@@ -230,6 +228,70 @@ export function UpdateCoupon(props: {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="expire_type"
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<FieldLabel className="w-28 pt-2">过期类型:</FieldLabel>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="请选择过期类型" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="0">不过期</SelectItem>
|
||||||
|
<SelectItem value="1">固定日期</SelectItem>
|
||||||
|
<SelectItem value="2">相对日期</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{watchExpireType === "1" && (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="expire_at"
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<FieldLabel className="w-28 pt-2">过期时间:</FieldLabel>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
min={new Date().toISOString().split("T")[0]}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{watchExpireType === "2" && (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="expire_in"
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<FieldLabel className="w-28 pt-2">
|
||||||
|
过期时长(天):
|
||||||
|
</FieldLabel>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
placeholder="请输入过期天数"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
64
src/app/(root)/couponList/page.tsx
Normal file
64
src/app/(root)/couponList/page.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"use client"
|
||||||
|
import { Suspense } from "react"
|
||||||
|
import { getCouponList } from "@/actions/coupon"
|
||||||
|
import { DataTable, useDataTable } from "@/components/data-table"
|
||||||
|
import { Page } from "@/components/page"
|
||||||
|
import { type Coupon, getExpireTypeText, getStatus } from "@/models/coupon"
|
||||||
|
import { formatDate } from "@/models/formatDate"
|
||||||
|
|
||||||
|
export default function CouponList() {
|
||||||
|
const table = useDataTable((page, size) => getCouponList({ page, size }))
|
||||||
|
console.log(table, "table")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<Suspense>
|
||||||
|
<DataTable<Coupon>
|
||||||
|
{...table}
|
||||||
|
columns={[
|
||||||
|
{ header: "优惠券名称", accessorKey: "coupon.name" },
|
||||||
|
// { header: "优惠券数量", accessorKey: "coupon.count" },
|
||||||
|
{ header: "优惠券金额", accessorKey: "coupon.amount" },
|
||||||
|
{ header: "最低消费金额", accessorKey: "coupon.min_amount" },
|
||||||
|
{ header: "用户", accessorKey: "user.name" },
|
||||||
|
// {
|
||||||
|
// header: "优惠券状态",
|
||||||
|
// cell: ({ row }) => {
|
||||||
|
// const { text, color } = getStatus(
|
||||||
|
// row.original.coupon.status,
|
||||||
|
// "coupon",
|
||||||
|
// )
|
||||||
|
// return <span className={color}>{text}</span>
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
header: "使用状态",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const { text, color } = getStatus(row.original.status, "use")
|
||||||
|
return <span className={color}>{text}</span>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "过期类型",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const expireType = row.original.coupon.expire_type
|
||||||
|
const expireAt = row.original.expire_at
|
||||||
|
return getExpireTypeText(expireType, expireAt)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "过期时间",
|
||||||
|
accessorKey: "expire_at",
|
||||||
|
cell: ({ row }) => formatDate(row.original.expire_at),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "创建时间",
|
||||||
|
accessorKey: "created_at",
|
||||||
|
cell: ({ row }) => formatDate(row.original.created_at),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</Page>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -214,6 +214,7 @@ export function AddUserDialog({ onSuccess }: AddUserDialogProps) {
|
|||||||
{...field}
|
{...field}
|
||||||
placeholder="请输入用户名"
|
placeholder="请输入用户名"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
|
clearable
|
||||||
/>
|
/>
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
@@ -225,7 +226,7 @@ export function AddUserDialog({ onSuccess }: AddUserDialogProps) {
|
|||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<Field data-invalid={fieldState.invalid}>
|
<Field data-invalid={fieldState.invalid}>
|
||||||
<FieldLabel>手机号 *</FieldLabel>
|
<FieldLabel>手机号 *</FieldLabel>
|
||||||
<Input {...field} placeholder="请输入手机号" />
|
<Input {...field} placeholder="请输入手机号" clearable />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
@@ -246,6 +247,7 @@ export function AddUserDialog({ onSuccess }: AddUserDialogProps) {
|
|||||||
{...field}
|
{...field}
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="请输入密码(至少6位)"
|
placeholder="请输入密码(至少6位)"
|
||||||
|
clearable
|
||||||
/>
|
/>
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
@@ -261,6 +263,7 @@ export function AddUserDialog({ onSuccess }: AddUserDialogProps) {
|
|||||||
{...field}
|
{...field}
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="请再次输入密码"
|
placeholder="请再次输入密码"
|
||||||
|
clearable
|
||||||
/>
|
/>
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
@@ -275,7 +278,7 @@ export function AddUserDialog({ onSuccess }: AddUserDialogProps) {
|
|||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<Field data-invalid={fieldState.invalid}>
|
<Field data-invalid={fieldState.invalid}>
|
||||||
<FieldLabel>邮箱</FieldLabel>
|
<FieldLabel>邮箱</FieldLabel>
|
||||||
<Input {...field} placeholder="请输入邮箱" />
|
<Input {...field} placeholder="请输入邮箱" clearable />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
@@ -311,7 +314,7 @@ export function AddUserDialog({ onSuccess }: AddUserDialogProps) {
|
|||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<Field data-invalid={fieldState.invalid}>
|
<Field data-invalid={fieldState.invalid}>
|
||||||
<FieldLabel>QQ联系方式</FieldLabel>
|
<FieldLabel>QQ联系方式</FieldLabel>
|
||||||
<Input {...field} placeholder="请输入QQ联系方式" />
|
<Input {...field} placeholder="请输入QQ联系方式" clearable />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
@@ -322,7 +325,11 @@ export function AddUserDialog({ onSuccess }: AddUserDialogProps) {
|
|||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<Field data-invalid={fieldState.invalid}>
|
<Field data-invalid={fieldState.invalid}>
|
||||||
<FieldLabel>微信/联系方式</FieldLabel>
|
<FieldLabel>微信/联系方式</FieldLabel>
|
||||||
<Input {...field} placeholder="请输入微信或联系方式" />
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder="请输入微信或联系方式"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { Suspense, useCallback, useState } from "react"
|
import { Suspense, useCallback } from "react"
|
||||||
import { Controller, useForm } from "react-hook-form"
|
import { Controller, useForm } from "react-hook-form"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { getPageCusts } from "@/actions/cust"
|
import { getPageCusts } from "@/actions/cust"
|
||||||
@@ -18,12 +18,7 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import {
|
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
|
||||||
Field,
|
|
||||||
FieldError,
|
|
||||||
FieldGroup,
|
|
||||||
FieldLabel,
|
|
||||||
} from "@/components/ui/field"
|
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -37,6 +32,7 @@ import {
|
|||||||
ScopeBatchReadOfUser,
|
ScopeBatchReadOfUser,
|
||||||
ScopeBillReadOfUser,
|
ScopeBillReadOfUser,
|
||||||
ScopeChannelReadOfUser,
|
ScopeChannelReadOfUser,
|
||||||
|
ScopeCouponWriteAssign,
|
||||||
ScopeResourceRead,
|
ScopeResourceRead,
|
||||||
ScopeTradeReadOfUser,
|
ScopeTradeReadOfUser,
|
||||||
ScopeUserWrite,
|
ScopeUserWrite,
|
||||||
@@ -86,9 +82,8 @@ const filterSchema = z
|
|||||||
type FormValues = z.infer<typeof filterSchema>
|
type FormValues = z.infer<typeof filterSchema>
|
||||||
|
|
||||||
export default function CustPage() {
|
export default function CustPage() {
|
||||||
const [filters, setFilters] = useState<FilterValues>({})
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { control, handleSubmit, reset } = useForm<FormValues>({
|
const { control, handleSubmit, reset, getValues } = useForm<FormValues>({
|
||||||
resolver: zodResolver(filterSchema),
|
resolver: zodResolver(filterSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
account: "",
|
account: "",
|
||||||
@@ -101,21 +96,28 @@ export default function CustPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const fetchUsers = useCallback(
|
const fetchUsers = useCallback(
|
||||||
(page: number, size: number) => getPageCusts({ page, size, ...filters }),
|
(page: number, size: number) => {
|
||||||
[filters],
|
const result: FilterValues = {}
|
||||||
|
const filters = getValues()
|
||||||
|
|
||||||
|
if (filters.account?.trim()) result.account = filters.account.trim()
|
||||||
|
if (filters.name?.trim()) result.name = filters.name.trim()
|
||||||
|
if (filters.identified && filters.identified !== "all")
|
||||||
|
result.identified = filters.identified === "1"
|
||||||
|
if (filters.enabled && filters.enabled !== "all")
|
||||||
|
result.enabled = filters.enabled === "1"
|
||||||
|
if (filters.created_at_start)
|
||||||
|
result.created_at_start = new Date(filters.created_at_start)
|
||||||
|
if (filters.created_at_end)
|
||||||
|
result.created_at_end = new Date(filters.created_at_end)
|
||||||
|
return getPageCusts({ page, size, ...result })
|
||||||
|
},
|
||||||
|
[getValues],
|
||||||
)
|
)
|
||||||
|
|
||||||
const table = useDataTable<User>(fetchUsers)
|
const table = useDataTable<User>(fetchUsers)
|
||||||
|
|
||||||
const onFilter = handleSubmit(data => {
|
const onFilter = handleSubmit(() => {
|
||||||
const result: FilterValues = {}
|
|
||||||
if (data.account?.trim()) result.account = data.account.trim()
|
|
||||||
if (data.name?.trim()) result.name = data.name.trim()
|
|
||||||
if (data.identified && data.identified !== "all")
|
|
||||||
result.identified = data.identified === "1"
|
|
||||||
if (data.enabled && data.enabled !== "all")
|
|
||||||
result.enabled = data.enabled === "1"
|
|
||||||
setFilters(result)
|
|
||||||
table.pagination.onPageChange(1)
|
table.pagination.onPageChange(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -126,17 +128,18 @@ export default function CustPage() {
|
|||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<form onSubmit={onFilter} className="bg-card p-4 rounded-lg">
|
<form onSubmit={onFilter} className="bg-card p-4 rounded-lg">
|
||||||
<div className="flex flex-wrap items-end gap-4">
|
<div className="flex items-end gap-4">
|
||||||
<Controller
|
<Controller
|
||||||
name="account"
|
name="account"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<Field
|
<Field data-invalid={fieldState.invalid} className="w-80 flex">
|
||||||
data-invalid={fieldState.invalid}
|
|
||||||
className="w-80 flex-none"
|
|
||||||
>
|
|
||||||
<FieldLabel>账号/手机号/邮箱</FieldLabel>
|
<FieldLabel>账号/手机号/邮箱</FieldLabel>
|
||||||
<Input {...field} placeholder="请输入账号/手机号/邮箱" />
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder="请输入账号/手机号/邮箱"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
@@ -146,12 +149,9 @@ export default function CustPage() {
|
|||||||
name="name"
|
name="name"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<Field
|
<Field data-invalid={fieldState.invalid} className="w-40 flex">
|
||||||
data-invalid={fieldState.invalid}
|
|
||||||
className="w-40 flex-none"
|
|
||||||
>
|
|
||||||
<FieldLabel>姓名</FieldLabel>
|
<FieldLabel>姓名</FieldLabel>
|
||||||
<Input {...field} placeholder="请输入姓名" />
|
<Input {...field} placeholder="请输入姓名" clearable />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
@@ -203,10 +203,7 @@ export default function CustPage() {
|
|||||||
name="created_at_start"
|
name="created_at_start"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<Field
|
<Field data-invalid={fieldState.invalid} className="w-40 flex">
|
||||||
data-invalid={fieldState.invalid}
|
|
||||||
className="w-40 flex-none"
|
|
||||||
>
|
|
||||||
<FieldLabel>开始时间</FieldLabel>
|
<FieldLabel>开始时间</FieldLabel>
|
||||||
<Input type="date" {...field} />
|
<Input type="date" {...field} />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
@@ -218,35 +215,35 @@ export default function CustPage() {
|
|||||||
name="created_at_end"
|
name="created_at_end"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<Field
|
<Field data-invalid={fieldState.invalid} className="w-40 flex">
|
||||||
data-invalid={fieldState.invalid}
|
|
||||||
className="w-40 flex-none"
|
|
||||||
>
|
|
||||||
<FieldLabel>结束时间</FieldLabel>
|
<FieldLabel>结束时间</FieldLabel>
|
||||||
<Input type="date" {...field} />
|
<Input type="date" {...field} />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
<Button type="submit">搜索</Button>
|
||||||
|
|
||||||
<FieldGroup className="flex-row justify-start mt-4 gap-2">
|
|
||||||
<Auth scope={ScopeUserWrite}>
|
|
||||||
<AddUserDialog onSuccess={refreshTable} />
|
|
||||||
</Auth>
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
reset()
|
reset({
|
||||||
setFilters({})
|
account: "",
|
||||||
|
name: "",
|
||||||
|
identified: "all",
|
||||||
|
enabled: "all",
|
||||||
|
created_at_start: "",
|
||||||
|
created_at_end: "",
|
||||||
|
})
|
||||||
table.pagination.onPageChange(1)
|
table.pagination.onPageChange(1)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
重置
|
重置
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit">搜索</Button>
|
<Auth scope={ScopeUserWrite}>
|
||||||
</FieldGroup>
|
<AddUserDialog onSuccess={refreshTable} />
|
||||||
|
</Auth>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<Suspense>
|
<Suspense>
|
||||||
@@ -259,9 +256,11 @@ export default function CustPage() {
|
|||||||
header: "创建时间",
|
header: "创建时间",
|
||||||
accessorKey: "created_at",
|
accessorKey: "created_at",
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
|
format(
|
||||||
|
new Date(row.original.created_at),
|
||||||
|
"yyyy-MM-dd HH:mm:ss",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
// { header: "邮箱", accessorKey: "email" },
|
|
||||||
{
|
{
|
||||||
header: "客户来源",
|
header: "客户来源",
|
||||||
accessorKey: "source",
|
accessorKey: "source",
|
||||||
@@ -330,7 +329,7 @@ export default function CustPage() {
|
|||||||
row.original.last_login
|
row.original.last_login
|
||||||
? format(
|
? format(
|
||||||
new Date(row.original.last_login),
|
new Date(row.original.last_login),
|
||||||
"yyyy-MM-dd HH:mm",
|
"yyyy-MM-dd HH:mm:ss",
|
||||||
)
|
)
|
||||||
: "",
|
: "",
|
||||||
},
|
},
|
||||||
@@ -462,6 +461,17 @@ export default function CustPage() {
|
|||||||
余额明细
|
余额明细
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</Auth>
|
</Auth>
|
||||||
|
<Auth scope={ScopeCouponWriteAssign}>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
router.push(
|
||||||
|
`/client/coupon?userId=${user.id}&phone=${user.phone}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
优惠券
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</Auth>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ export function UpdateDialog({ user, onSuccess }: EditUserDialogProps) {
|
|||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<Field data-invalid={fieldState.invalid}>
|
<Field data-invalid={fieldState.invalid}>
|
||||||
<FieldLabel>用户名</FieldLabel>
|
<FieldLabel>用户名</FieldLabel>
|
||||||
<Input {...field} placeholder="请输入用户名" />
|
<Input {...field} placeholder="请输入用户名" clearable />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
@@ -238,7 +238,7 @@ export function UpdateDialog({ user, onSuccess }: EditUserDialogProps) {
|
|||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<Field data-invalid={fieldState.invalid}>
|
<Field data-invalid={fieldState.invalid}>
|
||||||
<FieldLabel>邮箱</FieldLabel>
|
<FieldLabel>邮箱</FieldLabel>
|
||||||
<Input {...field} placeholder="请输入邮箱" />
|
<Input {...field} placeholder="请输入邮箱" clearable />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
@@ -254,6 +254,7 @@ export function UpdateDialog({ user, onSuccess }: EditUserDialogProps) {
|
|||||||
{...field}
|
{...field}
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="选填,修改请输入新密码(至少6位)"
|
placeholder="选填,修改请输入新密码(至少6位)"
|
||||||
|
clearable
|
||||||
/>
|
/>
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
@@ -270,6 +271,7 @@ export function UpdateDialog({ user, onSuccess }: EditUserDialogProps) {
|
|||||||
{...field}
|
{...field}
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="请再次输入密码"
|
placeholder="请再次输入密码"
|
||||||
|
clearable
|
||||||
/>
|
/>
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
@@ -348,7 +350,7 @@ export function UpdateDialog({ user, onSuccess }: EditUserDialogProps) {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel>QQ</FieldLabel>
|
<FieldLabel>QQ</FieldLabel>
|
||||||
<Input {...field} placeholder="QQ号" />
|
<Input {...field} placeholder="QQ号" clearable />
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -359,7 +361,7 @@ export function UpdateDialog({ user, onSuccess }: EditUserDialogProps) {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel>微信</FieldLabel>
|
<FieldLabel>微信</FieldLabel>
|
||||||
<Input {...field} placeholder="微信号" />
|
<Input {...field} placeholder="微信号" clearable />
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -49,13 +49,19 @@ export default function DiscountPage() {
|
|||||||
header: "创建时间",
|
header: "创建时间",
|
||||||
accessorKey: "created_at",
|
accessorKey: "created_at",
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
|
format(
|
||||||
|
new Date(row.original.created_at),
|
||||||
|
"yyyy-MM-dd HH:mm:ss",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "更新时间",
|
header: "更新时间",
|
||||||
accessorKey: "updated_at",
|
accessorKey: "updated_at",
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
format(new Date(row.original.updated_at), "yyyy-MM-dd HH:mm"),
|
format(
|
||||||
|
new Date(row.original.updated_at),
|
||||||
|
"yyyy-MM-dd HH:mm:ss",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "action",
|
id: "action",
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Controller, useForm } from "react-hook-form"
|
import { Controller, useForm } from "react-hook-form"
|
||||||
|
import { toast } from "sonner"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
import { createGateway } from "@/actions/gateway"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -27,21 +29,27 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import { toast } from "sonner"
|
|
||||||
import { createGateway } from "@/actions/gateway"
|
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
mac: z.string().regex(/^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$/, {
|
mac: z.string().min(1, "请填写mac地址"),
|
||||||
message: "MAC地址格式不正确,请使用如 00:11:22:AA:BB:CC 或 00-11-22-AA-BB-CC 的格式"
|
ip: z
|
||||||
}),
|
.string()
|
||||||
ip: z.string().regex(/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/, {
|
.regex(
|
||||||
message: "IP地址格式不正确,请使用如 192.168.1.1 的格式"
|
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/,
|
||||||
}),
|
{
|
||||||
host: z.string().regex(/^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/, {
|
message: "IP地址格式不正确,请使用如 192.168.1.1 的格式",
|
||||||
message: "域名格式不正确,请使用如 example.com 的格式"
|
},
|
||||||
}).or(z.literal("")),
|
),
|
||||||
|
host: z
|
||||||
|
.string()
|
||||||
|
.regex(/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/, {
|
||||||
|
message: "域名格式不正确,请使用如 example.com 的格式",
|
||||||
|
})
|
||||||
|
.or(z.literal("")),
|
||||||
type: z.string().optional(),
|
type: z.string().optional(),
|
||||||
status: z.string().optional(),
|
status: z.string().optional(),
|
||||||
|
secret: z.string().optional(),
|
||||||
|
proxy_port: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export default function CreatePage(props: { onSuccess?: () => void }) {
|
export default function CreatePage(props: { onSuccess?: () => void }) {
|
||||||
@@ -55,6 +63,8 @@ export default function CreatePage(props: { onSuccess?: () => void }) {
|
|||||||
host: "",
|
host: "",
|
||||||
type: "1",
|
type: "1",
|
||||||
status: "0",
|
status: "0",
|
||||||
|
secret: "",
|
||||||
|
proxy_port: "",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -67,6 +77,8 @@ export default function CreatePage(props: { onSuccess?: () => void }) {
|
|||||||
host: data?.host.trim(),
|
host: data?.host.trim(),
|
||||||
type: data.type ? Number(data.type) : 0,
|
type: data.type ? Number(data.type) : 0,
|
||||||
status: data.status ? Number(data.status) : 0,
|
status: data.status ? Number(data.status) : 0,
|
||||||
|
secret: data.secret?.trim() ?? "",
|
||||||
|
port: Number(data.proxy_port?.trim()) ?? "",
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await createGateway(payload)
|
const res = await createGateway(payload)
|
||||||
@@ -76,9 +88,9 @@ export default function CreatePage(props: { onSuccess?: () => void }) {
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
props.onSuccess?.()
|
props.onSuccess?.()
|
||||||
form.reset()
|
form.reset()
|
||||||
}else {
|
} else {
|
||||||
toast.error(res.message || "添加失败")
|
toast.error(res.message || "添加失败")
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("添加网关失败:", error)
|
console.error("添加网关失败:", error)
|
||||||
const message = error instanceof Error ? error.message : error
|
const message = error instanceof Error ? error.message : error
|
||||||
@@ -101,6 +113,7 @@ export default function CreatePage(props: { onSuccess?: () => void }) {
|
|||||||
const typeOptions = [
|
const typeOptions = [
|
||||||
{ value: "1", label: "自有" },
|
{ value: "1", label: "自有" },
|
||||||
{ value: "2", label: "白银" },
|
{ value: "2", label: "白银" },
|
||||||
|
{ value: "3", label: "GOST" },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -121,17 +134,14 @@ export default function CreatePage(props: { onSuccess?: () => void }) {
|
|||||||
<DialogTitle>添加网关</DialogTitle>
|
<DialogTitle>添加网关</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form
|
<form id="gateway-create" onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
id="gateway-create"
|
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
|
||||||
>
|
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<Controller
|
<Controller
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="mac"
|
name="mac"
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel htmlFor="gateway-create-mac">MAC地址:</FieldLabel>
|
<FieldLabel htmlFor="gateway-create-mac">标识:</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
id="gateway-create-mac"
|
id="gateway-create-mac"
|
||||||
placeholder="请输入MAC地址,如:00:11:22:33:44:55"
|
placeholder="请输入MAC地址,如:00:11:22:33:44:55"
|
||||||
@@ -180,6 +190,42 @@ export default function CreatePage(props: { onSuccess?: () => void }) {
|
|||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="proxy_port"
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="gateway-create-host">端口:</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="gateway-create-host"
|
||||||
|
placeholder="请输入端口,如:8080"
|
||||||
|
{...field}
|
||||||
|
aria-invalid={fieldState.invalid}
|
||||||
|
/>
|
||||||
|
{fieldState.invalid && fieldState.error && (
|
||||||
|
<FieldError errors={[fieldState.error]} />
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="secret"
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="gateway-create-secret">密匙:</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="gateway-create-secret"
|
||||||
|
placeholder="请输入密匙"
|
||||||
|
{...field}
|
||||||
|
aria-invalid={fieldState.invalid}
|
||||||
|
/>
|
||||||
|
{fieldState.invalid && fieldState.error && (
|
||||||
|
<FieldError errors={[fieldState.error]} />
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<Controller
|
<Controller
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="type"
|
name="type"
|
||||||
|
|||||||
@@ -1,31 +1,124 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
import { Suspense, useState } from "react"
|
import { Suspense, useCallback, useState } from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { getGatewayPage } from "@/actions/gateway"
|
import {
|
||||||
|
clear,
|
||||||
|
createProxyChains,
|
||||||
|
createProxyPort,
|
||||||
|
getGatewayPage,
|
||||||
|
updateGateway,
|
||||||
|
} from "@/actions/gateway"
|
||||||
import { Auth } from "@/components/auth"
|
import { Auth } from "@/components/auth"
|
||||||
import { DataTable, useDataTable } from "@/components/data-table"
|
import { DataTable, useDataTable } from "@/components/data-table"
|
||||||
import { Page } from "@/components/page"
|
import { Page } from "@/components/page"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { ScopeProxyWrite } from "@/lib/scopes"
|
import { ScopeChannelWriteClearExpired, ScopeProxyWrite } from "@/lib/scopes"
|
||||||
import type { Gateway } from "@/models/gateway"
|
import type { Gateway } from "@/models/gateway"
|
||||||
import CreatePage from "./create"
|
import CreatePage from "./create"
|
||||||
|
|
||||||
export default function GatewayPage() {
|
export default function GatewayPage() {
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
const table = useDataTable((page, size) => getGatewayPage({ page, size }))
|
const getGatewayPageWrapper = useCallback((page: number, size: number) => {
|
||||||
|
return getGatewayPage({ page, size })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const table = useDataTable(getGatewayPageWrapper)
|
||||||
|
|
||||||
|
const handleToggle = async (id: number, status: number) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await updateGateway({
|
||||||
|
id: id,
|
||||||
|
status: status === 0 ? 1 : 0,
|
||||||
|
})
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(status === 0 ? "启用成功" : "停用成功")
|
||||||
|
table.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(result.message || "操作失败")
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : error
|
||||||
|
toast.error(`操作失败: ${message}`)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearExpired = async (val: Gateway) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await clear({
|
||||||
|
proxy_id: val.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const count =
|
||||||
|
result.data && "count" in result.data ? result.data.count : 0
|
||||||
|
toast.success(`清理过期连接成功,共 ${count} 条`)
|
||||||
|
} else {
|
||||||
|
toast.error(result.message || "清理过期连接失败,请稍后重试一下~")
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : error
|
||||||
|
toast.error(`清理过期连接失败: ${message}`)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createPort = async (val: Gateway) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await createProxyPort({
|
||||||
|
id: val.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(`重建端口池成功!`)
|
||||||
|
} else {
|
||||||
|
toast.error(result.message || "请稍后重试一下~")
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : error
|
||||||
|
toast.error(`清理过期连接失败: ${message}`)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createChains = async (val: Gateway) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await createProxyChains({
|
||||||
|
id: val.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(`重建代理链成功!!!`)
|
||||||
|
} else {
|
||||||
|
toast.error(result.message || "请稍后重试一下~")
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : error
|
||||||
|
toast.error(`清理过期连接失败: ${message}`)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<Auth scope={ScopeProxyWrite}>
|
<div className="flex justify-between items-stretch">
|
||||||
<div className="flex justify-between items-stretch">
|
<div className="flex gap-3">
|
||||||
<div className="flex gap-3">
|
<Auth scope={ScopeProxyWrite}>
|
||||||
<CreatePage onSuccess={table.refresh} />
|
<CreatePage onSuccess={table.refresh} />
|
||||||
</div>
|
</Auth>
|
||||||
</div>
|
</div>
|
||||||
</Auth>
|
</div>
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<DataTable<Gateway>
|
<DataTable<Gateway>
|
||||||
{...table}
|
{...table}
|
||||||
@@ -36,14 +129,26 @@ export default function GatewayPage() {
|
|||||||
accessorKey: "host",
|
accessorKey: "host",
|
||||||
},
|
},
|
||||||
{ header: "IP地址", accessorKey: "ip" },
|
{ header: "IP地址", accessorKey: "ip" },
|
||||||
|
{ header: "端口", accessorKey: "port" },
|
||||||
{
|
{
|
||||||
header: "MAC地址",
|
header: "MAC地址",
|
||||||
accessorKey: "mac",
|
accessorKey: "mac",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
header: "密钥",
|
||||||
|
accessorKey: "secret",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
header: "类型",
|
header: "类型",
|
||||||
accessorKey: "type",
|
accessorKey: "type",
|
||||||
cell: ({ row }) => (row.original.type === 1 ? "自有" : "白银"),
|
cell: ({ row }) => {
|
||||||
|
const typeMap: Record<number, string> = {
|
||||||
|
1: "自有",
|
||||||
|
2: "白银",
|
||||||
|
3: "GOST",
|
||||||
|
}
|
||||||
|
return typeMap[row.original.type] ?? ""
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "状态",
|
header: "状态",
|
||||||
@@ -54,18 +159,41 @@ export default function GatewayPage() {
|
|||||||
header: "创建时间",
|
header: "创建时间",
|
||||||
accessorKey: "created_at",
|
accessorKey: "created_at",
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
|
format(
|
||||||
|
new Date(row.original.created_at),
|
||||||
|
"yyyy-MM-dd HH:mm:ss",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "action",
|
id: "action",
|
||||||
meta: { pin: "right" },
|
meta: { pin: "right" },
|
||||||
header: "操作",
|
header: "操作",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 ">
|
||||||
<Button className="bg-green-600/60 hover:bg-green-600/60 active:bg-green-600/60">
|
<Button
|
||||||
启用
|
onClick={() =>
|
||||||
|
handleToggle(row.original.id, row.original.status)
|
||||||
|
}
|
||||||
|
disabled={loading}
|
||||||
|
variant={
|
||||||
|
row.original.status === 0 ? "default" : "destructive"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{row.original.status === 0 ? "启用" : "停用"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button>停用</Button>
|
<Auth scope={ScopeChannelWriteClearExpired}>
|
||||||
|
<Button onClick={() => clearExpired(row.original)}>
|
||||||
|
清理过期连接
|
||||||
|
</Button>
|
||||||
|
</Auth>
|
||||||
|
<Auth scope={ScopeProxyWrite}>
|
||||||
|
<Button onClick={() => createPort(row.original)}>
|
||||||
|
重建端口池
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => createChains(row.original)}>
|
||||||
|
重建代理链
|
||||||
|
</Button>
|
||||||
|
</Auth>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { type ReactNode, Suspense } from "react"
|
import { type ReactNode, Suspense } from "react"
|
||||||
import { getProfile } from "@/actions/auth"
|
import { getProfile } from "@/actions/auth"
|
||||||
|
import Navigation from "@/app/(root)/_navigation"
|
||||||
import Appbar from "@/app/(root)/appbar"
|
import Appbar from "@/app/(root)/appbar"
|
||||||
import Navigation from "@/app/(root)/navigation"
|
|
||||||
import SetScopes from "./scopes"
|
import SetScopes from "./scopes"
|
||||||
|
|
||||||
export type RootLayoutProps = {
|
export type RootLayoutProps = {
|
||||||
|
|||||||
@@ -144,11 +144,11 @@ function ProductSkus(props: {
|
|||||||
{ header: "最低购买数量", accessorKey: "count_min" },
|
{ header: "最低购买数量", accessorKey: "count_min" },
|
||||||
{
|
{
|
||||||
header: "创建时间",
|
header: "创建时间",
|
||||||
accessorFn: row => format(row.created_at, "yyyy-MM-dd HH:mm"),
|
accessorFn: row => format(row.created_at, "yyyy-MM-dd HH:mm:ss"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "更新时间",
|
header: "更新时间",
|
||||||
accessorFn: row => format(row.updated_at, "yyyy-MM-dd HH:mm"),
|
accessorFn: row => format(row.updated_at, "yyyy-MM-dd HH:mm:ss"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "action",
|
id: "action",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { format, isBefore, isSameDay } from "date-fns"
|
import { format, isBefore, isSameDay } from "date-fns"
|
||||||
import { Box, Loader2, Timer } from "lucide-react"
|
import { Box, Loader2, Timer } from "lucide-react"
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
import { Suspense, useCallback, useMemo, useState } from "react"
|
import { Suspense, useCallback, useMemo, useState } from "react"
|
||||||
import { Controller, useForm } from "react-hook-form"
|
import { Controller, useForm } from "react-hook-form"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
@@ -15,6 +16,12 @@ import { DataTable, useDataTable } from "@/components/data-table"
|
|||||||
import { Page } from "@/components/page"
|
import { Page } from "@/components/page"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
import {
|
import {
|
||||||
Field,
|
Field,
|
||||||
FieldError,
|
FieldError,
|
||||||
@@ -156,8 +163,8 @@ function ExpireBadge({ expireAt }: { expireAt: Date | null | undefined }) {
|
|||||||
|
|
||||||
// 格式化日期
|
// 格式化日期
|
||||||
function formatDateTime(date: Date | null | undefined) {
|
function formatDateTime(date: Date | null | undefined) {
|
||||||
if (!date) return "-"
|
if (!date) return ""
|
||||||
return format(date, "yyyy-MM-dd HH:mm")
|
return format(date, "yyyy-MM-dd HH:mm:ss")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算今日使用量
|
// 计算今日使用量
|
||||||
@@ -169,9 +176,24 @@ function getTodayUsage(lastAt: Date | null | undefined, daily: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ResourcesPage() {
|
export default function ResourcesPage() {
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const typeParam = searchParams.get("type")
|
||||||
|
|
||||||
|
const defaultTab = useMemo(() => {
|
||||||
|
if (typeParam === "1") return "short"
|
||||||
|
if (typeParam === "2") return "long"
|
||||||
|
return "short"
|
||||||
|
}, [typeParam])
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState(defaultTab)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<Tabs defaultValue="short" className="overflow-hidden">
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={setActiveTab}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
<TabsList className="bg-card">
|
<TabsList className="bg-card">
|
||||||
<TabsTrigger value="short" className="h-10 px-4 shadow-none">
|
<TabsTrigger value="short" className="h-10 px-4 shadow-none">
|
||||||
短效套餐
|
短效套餐
|
||||||
@@ -200,15 +222,17 @@ interface ResourceListProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ResourceList({ resourceType }: ResourceListProps) {
|
function ResourceList({ resourceType }: ResourceListProps) {
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const resourceNo = searchParams.get("resource_no")
|
||||||
const isLong = resourceType === "long"
|
const isLong = resourceType === "long"
|
||||||
const listFn = isLong ? listResourceLong : listResourceShort
|
const listFn = isLong ? listResourceLong : listResourceShort
|
||||||
const [filters, setFilters] = useState<FilterParams>({})
|
|
||||||
const [updatingId, setUpdatingId] = useState<number | null>(null)
|
const [updatingId, setUpdatingId] = useState<number | null>(null)
|
||||||
const { control, handleSubmit, reset } = useForm<FormValues>({
|
const router = useRouter()
|
||||||
|
const { control, handleSubmit, reset, getValues } = useForm<FormValues>({
|
||||||
resolver: zodResolver(filterSchema),
|
resolver: zodResolver(filterSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
user_phone: "",
|
user_phone: "",
|
||||||
resource_no: "",
|
resource_no: resourceNo || "",
|
||||||
status: "all",
|
status: "all",
|
||||||
type: "all",
|
type: "all",
|
||||||
created_at_start: "",
|
created_at_start: "",
|
||||||
@@ -219,16 +243,32 @@ function ResourceList({ resourceType }: ResourceListProps) {
|
|||||||
|
|
||||||
const fetchResources = useCallback(
|
const fetchResources = useCallback(
|
||||||
(page: number, size: number) => {
|
(page: number, size: number) => {
|
||||||
return listFn({ page, size, ...filters })
|
const result: FilterParams = {}
|
||||||
|
const filters = getValues()
|
||||||
|
if (filters.user_phone?.trim())
|
||||||
|
result.user_phone = filters.user_phone.trim()
|
||||||
|
if (filters.resource_no?.trim())
|
||||||
|
result.resource_no = filters.resource_no.trim()
|
||||||
|
if (filters.status && filters.status !== "all") {
|
||||||
|
result.active = filters.status === "0"
|
||||||
|
}
|
||||||
|
if (filters.type && filters.type !== "all") {
|
||||||
|
result.mode = Number(filters.type)
|
||||||
|
}
|
||||||
|
if (filters.expired && filters.expired !== "all") {
|
||||||
|
result.expired = filters.expired === "1"
|
||||||
|
}
|
||||||
|
if (filters.created_at_start)
|
||||||
|
result.created_at_start = new Date(filters.created_at_start)
|
||||||
|
if (filters.created_at_end)
|
||||||
|
result.created_at_end = new Date(filters.created_at_end)
|
||||||
|
return listFn({ page, size, ...result })
|
||||||
},
|
},
|
||||||
[listFn, filters],
|
[listFn, getValues],
|
||||||
)
|
)
|
||||||
|
|
||||||
const table = useDataTable<Resources>(fetchResources)
|
const table = useDataTable<Resources>(fetchResources)
|
||||||
|
console.log(table, "table")
|
||||||
const refreshTable = useCallback(() => {
|
|
||||||
setFilters(prev => ({ ...prev }))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleStatusChange = useCallback(
|
const handleStatusChange = useCallback(
|
||||||
async (resource: Resources, newStatusValue: string) => {
|
async (resource: Resources, newStatusValue: string) => {
|
||||||
@@ -243,7 +283,7 @@ function ResourceList({ resourceType }: ResourceListProps) {
|
|||||||
toast.success("更新成功", {
|
toast.success("更新成功", {
|
||||||
description: `资源状态已更新为${newActive ? "启用" : "禁用"}`,
|
description: `资源状态已更新为${newActive ? "启用" : "禁用"}`,
|
||||||
})
|
})
|
||||||
refreshTable()
|
table.refresh()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("更新状态失败:", error)
|
console.error("更新状态失败:", error)
|
||||||
toast.error("更新失败", {
|
toast.error("更新失败", {
|
||||||
@@ -253,28 +293,33 @@ function ResourceList({ resourceType }: ResourceListProps) {
|
|||||||
setUpdatingId(null)
|
setUpdatingId(null)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[refreshTable],
|
[table],
|
||||||
)
|
)
|
||||||
|
const handleCheckipChange = useCallback(
|
||||||
const onFilter = handleSubmit(data => {
|
async (resource: Resources) => {
|
||||||
const result: FilterParams = {}
|
const newCheckip = !resource.checkip
|
||||||
if (data.user_phone?.trim()) result.user_phone = data.user_phone.trim()
|
setUpdatingId(resource.id)
|
||||||
if (data.resource_no?.trim()) result.resource_no = data.resource_no.trim()
|
try {
|
||||||
if (data.status && data.status !== "all") {
|
await updateResource({
|
||||||
result.active = data.status === "0"
|
id: resource.id,
|
||||||
}
|
checkip: newCheckip,
|
||||||
if (data.type && data.type !== "all") {
|
})
|
||||||
result.mode = Number(data.type)
|
toast.success("更新成功", {
|
||||||
}
|
description: `IP检查已${newCheckip ? "启用IP检查" : "停用IP检查"}`,
|
||||||
if (data.expired && data.expired !== "all") {
|
})
|
||||||
result.expired = data.expired === "1"
|
table.refresh()
|
||||||
}
|
} catch (error) {
|
||||||
if (data.created_at_start)
|
console.error("更新IP检查状态失败:", error)
|
||||||
result.created_at_start = new Date(data.created_at_start)
|
toast.error("更新失败", {
|
||||||
if (data.created_at_end)
|
description: error instanceof Error ? error.message : "请稍后重试",
|
||||||
result.created_at_end = new Date(data.created_at_end)
|
})
|
||||||
|
} finally {
|
||||||
setFilters(result)
|
setUpdatingId(null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[table],
|
||||||
|
)
|
||||||
|
const onFilter = handleSubmit(() => {
|
||||||
table.pagination.onPageChange(1)
|
table.pagination.onPageChange(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -294,7 +339,34 @@ function ResourceList({ resourceType }: ResourceListProps) {
|
|||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div>{name}</div>
|
<div>{name}</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-gray-500">{resourceNo}</span>
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<span className="text-xs text-gray-500">{resourceNo}</span>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-40">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
router.push(`/billing?resource_no=${resourceNo}`)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
账单详情
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
router.push(`/batch?resource_no=${resourceNo}`)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
提取记录
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
router.push(`/channel?resource_no=${resourceNo}`)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
IP管理
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
<ExpireBadge expireAt={expireAt} />
|
<ExpireBadge expireAt={expireAt} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -313,7 +385,7 @@ function ResourceList({ resourceType }: ResourceListProps) {
|
|||||||
const detail = getResourceDetail(row.original)
|
const detail = getResourceDetail(row.original)
|
||||||
const live = detail?.live
|
const live = detail?.live
|
||||||
if (live === undefined) return "-"
|
if (live === undefined) return "-"
|
||||||
return <span>{isLong ? `${live}小时` : `${live}分钟`}</span>
|
return <span>{isLong ? `${live}分钟` : `${live}分钟`}</span>
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -388,7 +460,7 @@ function ResourceList({ resourceType }: ResourceListProps) {
|
|||||||
{
|
{
|
||||||
id: "action",
|
id: "action",
|
||||||
meta: { pin: "right" },
|
meta: { pin: "right" },
|
||||||
header: "状态",
|
header: "操作",
|
||||||
cell: ({ row }: { row: { original: Resources } }) => {
|
cell: ({ row }: { row: { original: Resources } }) => {
|
||||||
const resource = row.original
|
const resource = row.original
|
||||||
const isLoading = updatingId === resource.id
|
const isLoading = updatingId === resource.id
|
||||||
@@ -411,12 +483,19 @@ function ResourceList({ resourceType }: ResourceListProps) {
|
|||||||
{isLoading && (
|
{isLoading && (
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={() => handleCheckipChange(resource)}
|
||||||
|
variant={resource.checkip ? "destructive" : "default"}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{resource.checkip ? "停用IP检查" : "启用IP检查"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[isLong, updatingId, handleStatusChange],
|
[isLong, updatingId, handleStatusChange, handleCheckipChange, router],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -432,7 +511,7 @@ function ResourceList({ resourceType }: ResourceListProps) {
|
|||||||
className="w-40 flex-none"
|
className="w-40 flex-none"
|
||||||
>
|
>
|
||||||
<FieldLabel>会员号</FieldLabel>
|
<FieldLabel>会员号</FieldLabel>
|
||||||
<Input {...field} placeholder="请输入会员号" />
|
<Input {...field} placeholder="请输入会员号" clearable />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
@@ -446,7 +525,7 @@ function ResourceList({ resourceType }: ResourceListProps) {
|
|||||||
className="w-40 flex-none"
|
className="w-40 flex-none"
|
||||||
>
|
>
|
||||||
<FieldLabel>套餐号</FieldLabel>
|
<FieldLabel>套餐号</FieldLabel>
|
||||||
<Input {...field} placeholder="请输入套餐号" />
|
<Input {...field} placeholder="请输入套餐号" clearable />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
@@ -520,7 +599,7 @@ function ResourceList({ resourceType }: ResourceListProps) {
|
|||||||
className="w-40 flex-none"
|
className="w-40 flex-none"
|
||||||
>
|
>
|
||||||
<FieldLabel>开始时间</FieldLabel>
|
<FieldLabel>开始时间</FieldLabel>
|
||||||
<Input type="date" {...field} />
|
<Input type="date" {...field} clearable />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
@@ -534,7 +613,7 @@ function ResourceList({ resourceType }: ResourceListProps) {
|
|||||||
className="w-40 flex-none"
|
className="w-40 flex-none"
|
||||||
>
|
>
|
||||||
<FieldLabel>结束时间</FieldLabel>
|
<FieldLabel>结束时间</FieldLabel>
|
||||||
<Input type="date" {...field} />
|
<Input type="date" {...field} clearable />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
@@ -546,8 +625,16 @@ function ResourceList({ resourceType }: ResourceListProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
reset()
|
router.replace("./resources")
|
||||||
setFilters({})
|
reset({
|
||||||
|
user_phone: "",
|
||||||
|
resource_no: "",
|
||||||
|
status: "all",
|
||||||
|
type: "all",
|
||||||
|
created_at_start: "",
|
||||||
|
created_at_end: "",
|
||||||
|
expired: "all",
|
||||||
|
})
|
||||||
table.pagination.onPageChange(1)
|
table.pagination.onPageChange(1)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { useSetAtom } from "jotai"
|
import { useSetAtom } from "jotai"
|
||||||
|
import { useEffect } from "react"
|
||||||
import { scopesAtom } from "@/lib/stores/scopes"
|
import { scopesAtom } from "@/lib/stores/scopes"
|
||||||
import type { Admin } from "@/models/admin"
|
import type { Admin } from "@/models/admin"
|
||||||
|
|
||||||
@@ -8,7 +9,9 @@ export default function SetScopes(props: {
|
|||||||
}) {
|
}) {
|
||||||
const setScopes = useSetAtom(scopesAtom)
|
const setScopes = useSetAtom(scopesAtom)
|
||||||
|
|
||||||
console.log("用户权限", props.admin.scopes)
|
useEffect(() => {
|
||||||
setScopes(props.admin.scopes)
|
setScopes(props.admin.scopes)
|
||||||
|
}, [props.admin.scopes, setScopes])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
import { CheckCircle, Clock, XCircle } from "lucide-react"
|
import { CheckCircle, Clock, XCircle } from "lucide-react"
|
||||||
|
import Link from "next/link"
|
||||||
import { Suspense, useCallback, useState } from "react"
|
import { Suspense, useCallback, useState } from "react"
|
||||||
import { Controller, useForm } from "react-hook-form"
|
import { Controller, useForm } from "react-hook-form"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
@@ -9,12 +10,7 @@ import { getPageTrade } from "@/actions/trade"
|
|||||||
import { DataTable, useDataTable } from "@/components/data-table"
|
import { DataTable, useDataTable } from "@/components/data-table"
|
||||||
import { Page } from "@/components/page"
|
import { Page } from "@/components/page"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
|
||||||
Field,
|
|
||||||
FieldError,
|
|
||||||
FieldGroup,
|
|
||||||
FieldLabel,
|
|
||||||
} from "@/components/ui/field"
|
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -69,7 +65,6 @@ type FilterSchema = z.infer<typeof filterSchema>
|
|||||||
|
|
||||||
export default function TradePage() {
|
export default function TradePage() {
|
||||||
const [filters, setFilters] = useState<FilterValues>({})
|
const [filters, setFilters] = useState<FilterValues>({})
|
||||||
|
|
||||||
const { control, handleSubmit, reset } = useForm<FilterSchema>({
|
const { control, handleSubmit, reset } = useForm<FilterSchema>({
|
||||||
resolver: zodResolver(filterSchema),
|
resolver: zodResolver(filterSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -115,17 +110,14 @@ export default function TradePage() {
|
|||||||
<Page>
|
<Page>
|
||||||
{/* 筛选表单 */}
|
{/* 筛选表单 */}
|
||||||
<form onSubmit={onFilter} className="bg-card p-4 rounded-lg">
|
<form onSubmit={onFilter} className="bg-card p-4 rounded-lg">
|
||||||
<div className="flex flex-wrap items-end gap-4">
|
<div className="flex items-end gap-4">
|
||||||
<Controller
|
<Controller
|
||||||
name="user_phone"
|
name="user_phone"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<Field
|
<Field data-invalid={fieldState.invalid} className="w-40 flex">
|
||||||
data-invalid={fieldState.invalid}
|
|
||||||
className="w-40 flex-none"
|
|
||||||
>
|
|
||||||
<FieldLabel>会员号</FieldLabel>
|
<FieldLabel>会员号</FieldLabel>
|
||||||
<Input {...field} placeholder="请输入会员号" />
|
<Input {...field} placeholder="请输入会员号" clearable />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
@@ -135,12 +127,9 @@ export default function TradePage() {
|
|||||||
name="inner_no"
|
name="inner_no"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<Field
|
<Field data-invalid={fieldState.invalid} className="w-40 flex">
|
||||||
data-invalid={fieldState.invalid}
|
|
||||||
className="w-40 flex-none"
|
|
||||||
>
|
|
||||||
<FieldLabel>订单号</FieldLabel>
|
<FieldLabel>订单号</FieldLabel>
|
||||||
<Input {...field} placeholder="请输入订单号" />
|
<Input {...field} placeholder="请输入订单号" clearable />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
@@ -217,10 +206,7 @@ export default function TradePage() {
|
|||||||
name="created_at_start"
|
name="created_at_start"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<Field
|
<Field data-invalid={fieldState.invalid} className="w-40 flex">
|
||||||
data-invalid={fieldState.invalid}
|
|
||||||
className="w-40 flex-none"
|
|
||||||
>
|
|
||||||
<FieldLabel>开始时间</FieldLabel>
|
<FieldLabel>开始时间</FieldLabel>
|
||||||
<Input type="date" {...field} />
|
<Input type="date" {...field} />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
@@ -232,19 +218,14 @@ export default function TradePage() {
|
|||||||
name="created_at_end"
|
name="created_at_end"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<Field
|
<Field data-invalid={fieldState.invalid} className="w-40 flex">
|
||||||
data-invalid={fieldState.invalid}
|
|
||||||
className="w-40 flex-none"
|
|
||||||
>
|
|
||||||
<FieldLabel>结束时间</FieldLabel>
|
<FieldLabel>结束时间</FieldLabel>
|
||||||
<Input type="date" {...field} />
|
<Input type="date" {...field} />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<FieldGroup className="flex-row justify-start mt-4 gap-2">
|
|
||||||
<Button type="submit">搜索</Button>
|
<Button type="submit">搜索</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -257,7 +238,7 @@ export default function TradePage() {
|
|||||||
>
|
>
|
||||||
重置
|
重置
|
||||||
</Button>
|
</Button>
|
||||||
</FieldGroup>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<Suspense>
|
<Suspense>
|
||||||
@@ -268,10 +249,29 @@ export default function TradePage() {
|
|||||||
header: "创建时间",
|
header: "创建时间",
|
||||||
accessorKey: "created_at",
|
accessorKey: "created_at",
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
|
format(
|
||||||
|
new Date(row.original.created_at),
|
||||||
|
"yyyy-MM-dd HH:mm:ss",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{ header: "会员号", accessorFn: row => row.user?.phone || "" },
|
{ header: "会员号", accessorFn: row => row.user?.phone || "" },
|
||||||
{ header: "订单号", accessorKey: "inner_no" },
|
{
|
||||||
|
header: "订单号",
|
||||||
|
accessorKey: "inner_no",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const innerNo = row.original.inner_no
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/billing?inner_no=${innerNo}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600"
|
||||||
|
>
|
||||||
|
{innerNo}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
{ header: "购买套餐", accessorKey: "subject" },
|
{ header: "购买套餐", accessorKey: "subject" },
|
||||||
{
|
{
|
||||||
header: "支付金额",
|
header: "支付金额",
|
||||||
|
|||||||
@@ -10,12 +10,7 @@ import { DataTable, useDataTable } from "@/components/data-table"
|
|||||||
import { Page } from "@/components/page"
|
import { Page } from "@/components/page"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
|
||||||
Field,
|
|
||||||
FieldError,
|
|
||||||
FieldGroup,
|
|
||||||
FieldLabel,
|
|
||||||
} from "@/components/ui/field"
|
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { useFetch } from "@/hooks/data"
|
import { useFetch } from "@/hooks/data"
|
||||||
import { ScopeUserWriteBind } from "@/lib/scopes"
|
import { ScopeUserWriteBind } from "@/lib/scopes"
|
||||||
@@ -63,24 +58,19 @@ export default function UserPage() {
|
|||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<form onSubmit={onFilter} className="bg-card p-4 rounded-lg">
|
<form onSubmit={onFilter} className="bg-card p-4 rounded-lg">
|
||||||
<div className="flex flex-wrap items-end gap-4">
|
<div className="flex items-end gap-4">
|
||||||
<Controller
|
<Controller
|
||||||
name="phone"
|
name="phone"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<Field
|
<Field data-invalid={fieldState.invalid} className="w-40 flex">
|
||||||
data-invalid={fieldState.invalid}
|
|
||||||
className="w-40 flex-none"
|
|
||||||
>
|
|
||||||
<FieldLabel>手机号</FieldLabel>
|
<FieldLabel>手机号</FieldLabel>
|
||||||
<Input {...field} placeholder="请输入手机号" />
|
<Input {...field} placeholder="请输入手机号" clearable />
|
||||||
<FieldError>{fieldState.error?.message}</FieldError>
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<FieldGroup className="flex-row justify-start mt-4 gap-2">
|
|
||||||
<Button type="submit">搜索</Button>
|
<Button type="submit">搜索</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -89,11 +79,12 @@ export default function UserPage() {
|
|||||||
reset()
|
reset()
|
||||||
setFilters({})
|
setFilters({})
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
table.pagination.onPageChange(1)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
重置
|
重置
|
||||||
</Button>
|
</Button>
|
||||||
</FieldGroup>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<Suspense>
|
<Suspense>
|
||||||
@@ -163,7 +154,7 @@ export default function UserPage() {
|
|||||||
row.original.last_login
|
row.original.last_login
|
||||||
? format(
|
? format(
|
||||||
new Date(row.original.last_login),
|
new Date(row.original.last_login),
|
||||||
"yyyy-MM-dd HH:mm",
|
"yyyy-MM-dd HH:mm:ss",
|
||||||
)
|
)
|
||||||
: "",
|
: "",
|
||||||
},
|
},
|
||||||
@@ -176,7 +167,10 @@ export default function UserPage() {
|
|||||||
header: "创建时间",
|
header: "创建时间",
|
||||||
accessorKey: "created_at",
|
accessorKey: "created_at",
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
|
format(
|
||||||
|
new Date(row.original.created_at),
|
||||||
|
"yyyy-MM-dd HH:mm:ss",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "action",
|
id: "action",
|
||||||
|
|||||||
@@ -122,3 +122,127 @@
|
|||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tiptap-editor {
|
||||||
|
.ProseMirror {
|
||||||
|
@apply w-full outline-none p-4;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
@apply text-2xl font-bold mt-4 mb-2;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
@apply text-xl font-semibold mt-3 mb-2;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
@apply text-lg font-medium mt-2 mb-1;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
@apply my-2 leading-7;
|
||||||
|
}
|
||||||
|
ul {
|
||||||
|
@apply list-disc pl-6 my-2;
|
||||||
|
}
|
||||||
|
ol {
|
||||||
|
@apply list-decimal pl-6 my-2;
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
@apply my-1;
|
||||||
|
}
|
||||||
|
blockquote {
|
||||||
|
@apply border-l-4 border-muted pl-4 italic text-muted-foreground my-3;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
@apply bg-muted px-1.5 py-0.5 rounded text-sm font-mono text-rose-600;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
@apply bg-slate-900 text-slate-100 rounded-lg p-4 my-3 overflow-x-auto;
|
||||||
|
code {
|
||||||
|
@apply bg-transparent p-0 text-sm text-inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hr {
|
||||||
|
@apply my-4 border-border;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
@apply text-blue-600 underline;
|
||||||
|
}
|
||||||
|
strong {
|
||||||
|
@apply font-semibold;
|
||||||
|
}
|
||||||
|
em {
|
||||||
|
@apply italic;
|
||||||
|
}
|
||||||
|
s {
|
||||||
|
@apply line-through;
|
||||||
|
}
|
||||||
|
ul[data-type="taskList"] {
|
||||||
|
@apply list-none pl-0;
|
||||||
|
li {
|
||||||
|
@apply flex items-start gap-2 my-1;
|
||||||
|
label {
|
||||||
|
@apply mt-1;
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
@apply flex-1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
@apply border-collapse w-full my-3;
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
@apply border border-border px-3 py-2 text-sm;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
@apply bg-muted font-semibold text-left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code block syntax highlighting */
|
||||||
|
.tiptap-editor pre code .hljs-keyword,
|
||||||
|
.tiptap-editor pre code .hljs-selector-tag,
|
||||||
|
.tiptap-editor pre code .hljs-type {
|
||||||
|
color: #c792ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor pre code .hljs-string,
|
||||||
|
.tiptap-editor pre code .hljs-attr {
|
||||||
|
color: #c3e88d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor pre code .hljs-number,
|
||||||
|
.tiptap-editor pre code .hljs-literal {
|
||||||
|
color: #f78c6c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor pre code .hljs-comment {
|
||||||
|
color: #676e95;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor pre code .hljs-title,
|
||||||
|
.tiptap-editor pre code .hljs-function,
|
||||||
|
.tiptap-editor pre code .hljs-name {
|
||||||
|
color: #82aaff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor pre code .hljs-built_in,
|
||||||
|
.tiptap-editor pre code .hljs-symbol {
|
||||||
|
color: #ffcb6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor pre code .hljs-variable,
|
||||||
|
.tiptap-editor pre code .hljs-params {
|
||||||
|
color: #f07178;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor pre code .hljs-meta,
|
||||||
|
.tiptap-editor pre code .hljs-tag {
|
||||||
|
color: #89ddff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor pre code .hljs-attr {
|
||||||
|
color: #c3e88d;
|
||||||
|
}
|
||||||
|
|||||||
175
src/components/charts/SimpleBarChart.tsx
Normal file
175
src/components/charts/SimpleBarChart.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
|
||||||
|
interface SimpleBarChartProps {
|
||||||
|
data: Record<string, string | number>[]
|
||||||
|
dataKey: string
|
||||||
|
xAxisKey: string
|
||||||
|
fill?: string
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SimpleBarChart({
|
||||||
|
data,
|
||||||
|
dataKey,
|
||||||
|
xAxisKey,
|
||||||
|
fill = "#f59e0b",
|
||||||
|
height = 250,
|
||||||
|
}: SimpleBarChartProps) {
|
||||||
|
const [tooltip, setTooltip] = useState<{
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
const padding = { top: 20, right: 20, bottom: 40, left: 50 }
|
||||||
|
const chartWidth = 600
|
||||||
|
const chartHeight = height
|
||||||
|
const innerWidth = chartWidth - padding.left - padding.right
|
||||||
|
const innerHeight = chartHeight - padding.top - padding.bottom
|
||||||
|
|
||||||
|
const { bars, xLabels, yLabels } = useMemo(() => {
|
||||||
|
if (data.length === 0) return { bars: [], xLabels: [], yLabels: [] }
|
||||||
|
|
||||||
|
const values = data.map(d => Number(d[dataKey]) || 0)
|
||||||
|
const maxVal = Math.max(...values, 1)
|
||||||
|
const yMax = Math.ceil(maxVal * 1.2)
|
||||||
|
|
||||||
|
const barCount = data.length
|
||||||
|
const barGap = barCount > 1 ? (innerWidth * 0.1) / (barCount - 1) : 0
|
||||||
|
const totalGap = barGap * (barCount - 1)
|
||||||
|
const barWidth = Math.min((innerWidth - totalGap) / barCount, 50)
|
||||||
|
|
||||||
|
const startX =
|
||||||
|
padding.left + (innerWidth - (barWidth * barCount + totalGap)) / 2
|
||||||
|
|
||||||
|
const brs = data.map((d, i) => {
|
||||||
|
const val = Number(d[dataKey]) || 0
|
||||||
|
const barHeight = (val / yMax) * innerHeight
|
||||||
|
return {
|
||||||
|
x: startX + i * (barWidth + barGap),
|
||||||
|
y: padding.top + innerHeight - barHeight,
|
||||||
|
width: barWidth,
|
||||||
|
height: barHeight,
|
||||||
|
label: String(d[xAxisKey] ?? ""),
|
||||||
|
value: val,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const xLbls = brs.map(b => ({
|
||||||
|
x: b.x + b.width / 2,
|
||||||
|
label: b.label,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const yTickCount = 5
|
||||||
|
const yLbls = Array.from({ length: yTickCount + 1 }, (_, i) => {
|
||||||
|
const val = Math.round((yMax / yTickCount) * i)
|
||||||
|
return {
|
||||||
|
y: padding.top + innerHeight - (val / yMax) * innerHeight,
|
||||||
|
label: String(val),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { bars: brs, xLabels: xLbls, yLabels: yLbls }
|
||||||
|
}, [data, dataKey, xAxisKey, innerWidth, innerHeight])
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center text-muted-foreground text-sm"
|
||||||
|
style={{ height }}
|
||||||
|
>
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" style={{ height }}>
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
|
||||||
|
className="w-full h-full"
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
>
|
||||||
|
{yLabels.map(yl => (
|
||||||
|
<g key={yl.label}>
|
||||||
|
<line
|
||||||
|
x1={padding.left}
|
||||||
|
y1={yl.y}
|
||||||
|
x2={padding.left + innerWidth}
|
||||||
|
y2={yl.y}
|
||||||
|
stroke="hsl(var(--border))"
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
strokeWidth={0.5}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={padding.left - 8}
|
||||||
|
y={yl.y + 4}
|
||||||
|
textAnchor="end"
|
||||||
|
className="fill-muted-foreground"
|
||||||
|
fontSize={11}
|
||||||
|
>
|
||||||
|
{yl.label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{bars.map((bar, i) => (
|
||||||
|
<rect
|
||||||
|
key={i}
|
||||||
|
x={bar.x}
|
||||||
|
y={bar.y}
|
||||||
|
width={bar.width}
|
||||||
|
height={bar.height}
|
||||||
|
rx={3}
|
||||||
|
fill={fill}
|
||||||
|
role="button"
|
||||||
|
aria-label={`${bar.label}: ${bar.value}`}
|
||||||
|
className="cursor-pointer opacity-90 hover:opacity-100 transition-opacity"
|
||||||
|
onMouseEnter={() =>
|
||||||
|
setTooltip({
|
||||||
|
x: bar.x + bar.width / 2,
|
||||||
|
y: bar.y,
|
||||||
|
label: bar.label,
|
||||||
|
value: bar.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onMouseLeave={() => setTooltip(null)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{xLabels.map((xl, i) =>
|
||||||
|
i % Math.ceil(xLabels.length / 10) === 0 ||
|
||||||
|
i === xLabels.length - 1 ? (
|
||||||
|
<text
|
||||||
|
key={i}
|
||||||
|
x={xl.x}
|
||||||
|
y={padding.top + innerHeight + 20}
|
||||||
|
textAnchor="middle"
|
||||||
|
className="fill-muted-foreground"
|
||||||
|
fontSize={11}
|
||||||
|
>
|
||||||
|
{xl.label}
|
||||||
|
</text>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{tooltip && (
|
||||||
|
<div
|
||||||
|
className="absolute pointer-events-none bg-popover border rounded-md px-2 py-1 text-xs shadow-sm z-10"
|
||||||
|
style={{
|
||||||
|
left: `${((tooltip.x / chartWidth) * 100).toFixed(1)}%`,
|
||||||
|
top: tooltip.y - 30,
|
||||||
|
transform: "translate(-50%, -100%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-muted-foreground">{tooltip.label}</div>
|
||||||
|
<div className="font-semibold">{tooltip.value}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
101
src/components/charts/SimpleHorizontalBarChart.tsx
Normal file
101
src/components/charts/SimpleHorizontalBarChart.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
|
||||||
|
interface SimpleHorizontalBarChartProps {
|
||||||
|
data: Record<string, string | number>[]
|
||||||
|
dataKey: string
|
||||||
|
labelKey: string
|
||||||
|
fill?: string
|
||||||
|
width?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SimpleHorizontalBarChart({
|
||||||
|
data,
|
||||||
|
dataKey,
|
||||||
|
labelKey,
|
||||||
|
fill = "#6366f1",
|
||||||
|
width = 300,
|
||||||
|
}: SimpleHorizontalBarChartProps) {
|
||||||
|
const chartWidth = width
|
||||||
|
const chartHeight = Math.max(data.length * 40 + 20, 120)
|
||||||
|
const padding = { top: 10, right: 10, bottom: 10, left: 80 }
|
||||||
|
const innerWidth = chartWidth - padding.left - padding.right
|
||||||
|
|
||||||
|
const bars = useMemo(() => {
|
||||||
|
if (data.length === 0) return []
|
||||||
|
|
||||||
|
const values = data.map(d => Number(d[dataKey]) || 0)
|
||||||
|
const maxVal = Math.max(...values, 1)
|
||||||
|
|
||||||
|
return data.map((d, i) => {
|
||||||
|
const val = Number(d[dataKey]) || 0
|
||||||
|
const barWidth = (val / maxVal) * innerWidth * 0.9
|
||||||
|
const y =
|
||||||
|
padding.top +
|
||||||
|
(i * (chartHeight - padding.top - padding.bottom)) / data.length
|
||||||
|
return {
|
||||||
|
x: padding.left,
|
||||||
|
y,
|
||||||
|
width: barWidth,
|
||||||
|
height: Math.min(
|
||||||
|
24,
|
||||||
|
(chartHeight - padding.top - padding.bottom) / data.length - 4,
|
||||||
|
),
|
||||||
|
label: String(d[labelKey] ?? ""),
|
||||||
|
value: val,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [data, dataKey, labelKey, innerWidth, chartHeight])
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center text-muted-foreground text-sm"
|
||||||
|
style={{ height: 120 }}
|
||||||
|
>
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
|
||||||
|
className="w-full"
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
style={{ height: chartHeight }}
|
||||||
|
>
|
||||||
|
{bars.map((bar, i) => (
|
||||||
|
<g key={i}>
|
||||||
|
<text
|
||||||
|
x={padding.left - 8}
|
||||||
|
y={bar.y + bar.height / 2 + 4}
|
||||||
|
textAnchor="end"
|
||||||
|
className="fill-muted-foreground"
|
||||||
|
fontSize={12}
|
||||||
|
>
|
||||||
|
{bar.label.length > 6 ? `${bar.label.slice(0, 6)}...` : bar.label}
|
||||||
|
</text>
|
||||||
|
<rect
|
||||||
|
x={bar.x}
|
||||||
|
y={bar.y}
|
||||||
|
width={bar.width}
|
||||||
|
height={bar.height}
|
||||||
|
rx={4}
|
||||||
|
fill={fill}
|
||||||
|
opacity={0.8}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={bar.x + bar.width + 6}
|
||||||
|
y={bar.y + bar.height / 2 + 4}
|
||||||
|
className="fill-muted-foreground"
|
||||||
|
fontSize={11}
|
||||||
|
>
|
||||||
|
{bar.value}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
193
src/components/charts/SimpleLineChart.tsx
Normal file
193
src/components/charts/SimpleLineChart.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
|
||||||
|
interface SimpleLineChartProps {
|
||||||
|
data: Record<string, string | number>[]
|
||||||
|
dataKey: string
|
||||||
|
xAxisKey: string
|
||||||
|
stroke?: string
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SimpleLineChart({
|
||||||
|
data,
|
||||||
|
dataKey,
|
||||||
|
xAxisKey,
|
||||||
|
stroke = "#3b82f6",
|
||||||
|
height = 250,
|
||||||
|
}: SimpleLineChartProps) {
|
||||||
|
const [tooltip, setTooltip] = useState<{
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
const padding = { top: 20, right: 20, bottom: 40, left: 50 }
|
||||||
|
const chartWidth = 600
|
||||||
|
const chartHeight = height
|
||||||
|
const innerWidth = chartWidth - padding.left - padding.right
|
||||||
|
const innerHeight = chartHeight - padding.top - padding.bottom
|
||||||
|
|
||||||
|
const { points, xLabels, yLabels } = useMemo(() => {
|
||||||
|
if (data.length === 0) return { points: [], xLabels: [], yLabels: [] }
|
||||||
|
|
||||||
|
const values = data.map(d => Number(d[dataKey]) || 0)
|
||||||
|
const maxVal = Math.max(...values, 1)
|
||||||
|
const yMax = Math.ceil(maxVal * 1.2)
|
||||||
|
|
||||||
|
const step = innerWidth / Math.max(data.length - 1, 1)
|
||||||
|
|
||||||
|
const pts = data.map((d, i) => ({
|
||||||
|
x: padding.left + i * step,
|
||||||
|
y: padding.top + innerHeight - (Number(d[dataKey]) / yMax) * innerHeight,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const xLbls = data.map((d, i) => ({
|
||||||
|
x: padding.left + i * step,
|
||||||
|
label: String(d[xAxisKey] ?? ""),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const yTickCount = 5
|
||||||
|
const yLbls = Array.from({ length: yTickCount + 1 }, (_, i) => {
|
||||||
|
const val = Math.round((yMax / yTickCount) * i)
|
||||||
|
return {
|
||||||
|
y: padding.top + innerHeight - (val / yMax) * innerHeight,
|
||||||
|
label: String(val),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { points: pts, xLabels: xLbls, yLabels: yLbls }
|
||||||
|
}, [data, dataKey, xAxisKey, innerWidth, innerHeight])
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center text-muted-foreground text-sm"
|
||||||
|
style={{ height }}
|
||||||
|
>
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const linePath = points
|
||||||
|
.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`)
|
||||||
|
.join(" ")
|
||||||
|
const areaPath =
|
||||||
|
points.length > 0
|
||||||
|
? `${linePath} L ${points[points.length - 1].x} ${padding.top + innerHeight} L ${points[0].x} ${padding.top + innerHeight} Z`
|
||||||
|
: ""
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" style={{ height }}>
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
|
||||||
|
className="w-full h-full"
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient
|
||||||
|
id={`area-${stroke.replace("#", "")}`}
|
||||||
|
x1="0"
|
||||||
|
y1="0"
|
||||||
|
x2="0"
|
||||||
|
y2="1"
|
||||||
|
>
|
||||||
|
<stop offset="0%" stopColor={stroke} stopOpacity={0.2} />
|
||||||
|
<stop offset="100%" stopColor={stroke} stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{yLabels.map(yl => (
|
||||||
|
<g key={yl.label}>
|
||||||
|
<line
|
||||||
|
x1={padding.left}
|
||||||
|
y1={yl.y}
|
||||||
|
x2={padding.left + innerWidth}
|
||||||
|
y2={yl.y}
|
||||||
|
stroke="hsl(var(--border))"
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
strokeWidth={0.5}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={padding.left - 8}
|
||||||
|
y={yl.y + 4}
|
||||||
|
textAnchor="end"
|
||||||
|
className="fill-muted-foreground"
|
||||||
|
fontSize={11}
|
||||||
|
>
|
||||||
|
{yl.label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<path d={areaPath} fill={`url(#area-${stroke.replace("#", "")})`} />
|
||||||
|
|
||||||
|
<path
|
||||||
|
d={linePath}
|
||||||
|
fill="none"
|
||||||
|
stroke={stroke}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{points.map((p, i) => (
|
||||||
|
<circle
|
||||||
|
key={i}
|
||||||
|
cx={p.x}
|
||||||
|
cy={p.y}
|
||||||
|
r={3}
|
||||||
|
fill="#fff"
|
||||||
|
stroke={stroke}
|
||||||
|
strokeWidth={2}
|
||||||
|
role="button"
|
||||||
|
aria-label={`${String(data[i]?.[xAxisKey] ?? "")}: ${Number(data[i]?.[dataKey]) || 0}`}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onMouseEnter={() =>
|
||||||
|
setTooltip({
|
||||||
|
x: p.x,
|
||||||
|
y: p.y,
|
||||||
|
label: String(data[i]?.[xAxisKey] ?? ""),
|
||||||
|
value: Number(data[i]?.[dataKey]) || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onMouseLeave={() => setTooltip(null)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{xLabels.map((xl, i) =>
|
||||||
|
i % Math.ceil(xLabels.length / 7) === 0 ||
|
||||||
|
i === xLabels.length - 1 ? (
|
||||||
|
<text
|
||||||
|
key={i}
|
||||||
|
x={xl.x}
|
||||||
|
y={padding.top + innerHeight + 20}
|
||||||
|
textAnchor="middle"
|
||||||
|
className="fill-muted-foreground"
|
||||||
|
fontSize={11}
|
||||||
|
>
|
||||||
|
{xl.label}
|
||||||
|
</text>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{tooltip && (
|
||||||
|
<div
|
||||||
|
className="absolute pointer-events-none bg-popover border rounded-md px-2 py-1 text-xs shadow-sm"
|
||||||
|
style={{
|
||||||
|
left: `${((tooltip.x / chartWidth) * 100).toFixed(1)}%`,
|
||||||
|
top: tooltip.y - 30,
|
||||||
|
transform: "translate(-50%, -100%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-muted-foreground">{tooltip.label}</div>
|
||||||
|
<div className="font-semibold">{tooltip.value}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
3
src/components/charts/index.ts
Normal file
3
src/components/charts/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { SimpleBarChart } from "./SimpleBarChart"
|
||||||
|
export { SimpleHorizontalBarChart } from "./SimpleHorizontalBarChart"
|
||||||
|
export { SimpleLineChart } from "./SimpleLineChart"
|
||||||
60
src/components/editor/editorExtensions.ts
Normal file
60
src/components/editor/editorExtensions.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"
|
||||||
|
import Image from "@tiptap/extension-image"
|
||||||
|
import Link from "@tiptap/extension-link"
|
||||||
|
import { Table } from "@tiptap/extension-table"
|
||||||
|
import { TableCell } from "@tiptap/extension-table-cell"
|
||||||
|
import { TableHeader } from "@tiptap/extension-table-header"
|
||||||
|
import { TableRow } from "@tiptap/extension-table-row"
|
||||||
|
import TaskItem from "@tiptap/extension-task-item"
|
||||||
|
import TaskList from "@tiptap/extension-task-list"
|
||||||
|
import TextAlign from "@tiptap/extension-text-align"
|
||||||
|
import Underline from "@tiptap/extension-underline"
|
||||||
|
import Youtube from "@tiptap/extension-youtube"
|
||||||
|
import StarterKit from "@tiptap/starter-kit"
|
||||||
|
import { common, createLowlight } from "lowlight"
|
||||||
|
import { MdxPasteHandler } from "./extensions/mdx-paste-handler"
|
||||||
|
|
||||||
|
const lowlight = createLowlight(common)
|
||||||
|
|
||||||
|
export const editorExtensions = [
|
||||||
|
MdxPasteHandler,
|
||||||
|
StarterKit.configure({
|
||||||
|
heading: {
|
||||||
|
levels: [1, 2, 3],
|
||||||
|
},
|
||||||
|
codeBlock: false,
|
||||||
|
}),
|
||||||
|
Underline,
|
||||||
|
Link.configure({
|
||||||
|
openOnClick: false,
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: "text-blue-600 underline cursor-pointer",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
CodeBlockLowlight.configure({
|
||||||
|
lowlight,
|
||||||
|
}),
|
||||||
|
Image.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: "rounded-lg max-w-full",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
TextAlign.configure({
|
||||||
|
types: ["heading", "paragraph"],
|
||||||
|
}),
|
||||||
|
TaskList,
|
||||||
|
TaskItem.configure({
|
||||||
|
nested: true,
|
||||||
|
}),
|
||||||
|
Youtube.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: "w-full aspect-video rounded-lg",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Table.configure({
|
||||||
|
resizable: true,
|
||||||
|
}),
|
||||||
|
TableRow,
|
||||||
|
TableHeader,
|
||||||
|
TableCell,
|
||||||
|
]
|
||||||
453
src/components/editor/editorMenuBar.tsx
Normal file
453
src/components/editor/editorMenuBar.tsx
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { Editor } from "@tiptap/react"
|
||||||
|
import {
|
||||||
|
AlignCenter,
|
||||||
|
AlignJustify,
|
||||||
|
AlignLeft,
|
||||||
|
AlignRight,
|
||||||
|
Bold,
|
||||||
|
Code,
|
||||||
|
Code2,
|
||||||
|
FileCode2,
|
||||||
|
Heading1,
|
||||||
|
Heading2,
|
||||||
|
Heading3,
|
||||||
|
ImageIcon,
|
||||||
|
Italic,
|
||||||
|
LinkIcon,
|
||||||
|
List,
|
||||||
|
ListChecks,
|
||||||
|
ListOrdered,
|
||||||
|
Minus,
|
||||||
|
Quote,
|
||||||
|
Redo,
|
||||||
|
RemoveFormatting,
|
||||||
|
Strikethrough,
|
||||||
|
Table as TableIcon,
|
||||||
|
Trash2,
|
||||||
|
Underline as UnderlineIcon,
|
||||||
|
Undo,
|
||||||
|
UploadIcon,
|
||||||
|
YoutubeIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { useCallback, useRef, useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { uploadArticleImage } from "@/actions/article"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface EditorMenuBarProps {
|
||||||
|
editor: Editor | null
|
||||||
|
onSave?: () => void
|
||||||
|
saving?: boolean
|
||||||
|
onImportMdx?: (content: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditorMenuBar({
|
||||||
|
editor,
|
||||||
|
onSave,
|
||||||
|
saving,
|
||||||
|
onImportMdx,
|
||||||
|
}: EditorMenuBarProps) {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const imageInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [imageUploading, setImageUploading] = useState(false)
|
||||||
|
|
||||||
|
const handleImageUpload = useCallback(
|
||||||
|
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (!editor) return
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
setImageUploading(true)
|
||||||
|
try {
|
||||||
|
const resp = await uploadArticleImage(file)
|
||||||
|
if (resp.success) {
|
||||||
|
editor.chain().focus().setImage({ src: resp.data.url }).run()
|
||||||
|
toast.success("图片上传成功")
|
||||||
|
} else {
|
||||||
|
toast.error(resp.message || "上传失败")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("上传失败,网络错误")
|
||||||
|
} finally {
|
||||||
|
setImageUploading(false)
|
||||||
|
e.target.value = ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[editor],
|
||||||
|
)
|
||||||
|
|
||||||
|
const addImageByUrl = useCallback(() => {
|
||||||
|
if (!editor) return
|
||||||
|
const url = window.prompt("请输入图片链接")
|
||||||
|
if (url) {
|
||||||
|
editor.chain().focus().setImage({ src: url }).run()
|
||||||
|
}
|
||||||
|
}, [editor])
|
||||||
|
|
||||||
|
const addYoutube = useCallback(() => {
|
||||||
|
if (!editor) return
|
||||||
|
const url = window.prompt("请输入 YouTube 链接")
|
||||||
|
if (url) {
|
||||||
|
editor.chain().focus().setYoutubeVideo({ src: url }).run()
|
||||||
|
}
|
||||||
|
}, [editor])
|
||||||
|
|
||||||
|
const insertTable = useCallback(() => {
|
||||||
|
if (!editor) return
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
||||||
|
.run()
|
||||||
|
}, [editor])
|
||||||
|
|
||||||
|
const deleteTable = useCallback(() => {
|
||||||
|
if (!editor) return
|
||||||
|
editor.chain().focus().deleteTable().run()
|
||||||
|
}, [editor])
|
||||||
|
|
||||||
|
const clearFormatting = useCallback(() => {
|
||||||
|
if (!editor) return
|
||||||
|
editor.chain().focus().clearNodes().unsetAllMarks().run()
|
||||||
|
}, [editor])
|
||||||
|
|
||||||
|
if (!editor) return null
|
||||||
|
|
||||||
|
const ToolButton = ({
|
||||||
|
onClick,
|
||||||
|
isActive,
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
onClick: () => void
|
||||||
|
isActive: boolean
|
||||||
|
icon: React.ComponentType<{ className?: string }>
|
||||||
|
label: string
|
||||||
|
}) => (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className={cn(isActive && "bg-accent text-accent-foreground")}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{label}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b bg-card flex items-center gap-0.5 px-2 py-1 flex-wrap">
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<ToolButton
|
||||||
|
onClick={() =>
|
||||||
|
editor.chain().focus().toggleHeading({ level: 1 }).run()
|
||||||
|
}
|
||||||
|
isActive={editor.isActive("heading", { level: 1 })}
|
||||||
|
icon={Heading1}
|
||||||
|
label="标题 1"
|
||||||
|
/>
|
||||||
|
<ToolButton
|
||||||
|
onClick={() =>
|
||||||
|
editor.chain().focus().toggleHeading({ level: 2 }).run()
|
||||||
|
}
|
||||||
|
isActive={editor.isActive("heading", { level: 2 })}
|
||||||
|
icon={Heading2}
|
||||||
|
label="标题 2"
|
||||||
|
/>
|
||||||
|
<ToolButton
|
||||||
|
onClick={() =>
|
||||||
|
editor.chain().focus().toggleHeading({ level: 3 }).run()
|
||||||
|
}
|
||||||
|
isActive={editor.isActive("heading", { level: 3 })}
|
||||||
|
icon={Heading3}
|
||||||
|
label="标题 3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-border mx-1" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<ToolButton
|
||||||
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||||
|
isActive={editor.isActive("bold")}
|
||||||
|
icon={Bold}
|
||||||
|
label="加粗"
|
||||||
|
/>
|
||||||
|
<ToolButton
|
||||||
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||||
|
isActive={editor.isActive("italic")}
|
||||||
|
icon={Italic}
|
||||||
|
label="斜体"
|
||||||
|
/>
|
||||||
|
<ToolButton
|
||||||
|
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||||
|
isActive={editor.isActive("underline")}
|
||||||
|
icon={UnderlineIcon}
|
||||||
|
label="下划线"
|
||||||
|
/>
|
||||||
|
<ToolButton
|
||||||
|
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||||
|
isActive={editor.isActive("strike")}
|
||||||
|
icon={Strikethrough}
|
||||||
|
label="删除线"
|
||||||
|
/>
|
||||||
|
<ToolButton
|
||||||
|
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||||
|
isActive={editor.isActive("code")}
|
||||||
|
icon={Code}
|
||||||
|
label="行内代码"
|
||||||
|
/>
|
||||||
|
<ToolButton
|
||||||
|
onClick={clearFormatting}
|
||||||
|
isActive={false}
|
||||||
|
icon={RemoveFormatting}
|
||||||
|
label="清除格式"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-border mx-1" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<ToolButton
|
||||||
|
onClick={() => editor.chain().focus().setTextAlign("left").run()}
|
||||||
|
isActive={editor.isActive({ textAlign: "left" })}
|
||||||
|
icon={AlignLeft}
|
||||||
|
label="左对齐"
|
||||||
|
/>
|
||||||
|
<ToolButton
|
||||||
|
onClick={() => editor.chain().focus().setTextAlign("center").run()}
|
||||||
|
isActive={editor.isActive({ textAlign: "center" })}
|
||||||
|
icon={AlignCenter}
|
||||||
|
label="居中对齐"
|
||||||
|
/>
|
||||||
|
<ToolButton
|
||||||
|
onClick={() => editor.chain().focus().setTextAlign("right").run()}
|
||||||
|
isActive={editor.isActive({ textAlign: "right" })}
|
||||||
|
icon={AlignRight}
|
||||||
|
label="右对齐"
|
||||||
|
/>
|
||||||
|
<ToolButton
|
||||||
|
onClick={() => editor.chain().focus().setTextAlign("justify").run()}
|
||||||
|
isActive={editor.isActive({ textAlign: "justify" })}
|
||||||
|
icon={AlignJustify}
|
||||||
|
label="两端对齐"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-border mx-1" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<ToolButton
|
||||||
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||||
|
isActive={editor.isActive("bulletList")}
|
||||||
|
icon={List}
|
||||||
|
label="无序列表"
|
||||||
|
/>
|
||||||
|
<ToolButton
|
||||||
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||||
|
isActive={editor.isActive("orderedList")}
|
||||||
|
icon={ListOrdered}
|
||||||
|
label="有序列表"
|
||||||
|
/>
|
||||||
|
<ToolButton
|
||||||
|
onClick={() => editor.chain().focus().toggleTaskList().run()}
|
||||||
|
isActive={editor.isActive("taskList")}
|
||||||
|
icon={ListChecks}
|
||||||
|
label="任务列表"
|
||||||
|
/>
|
||||||
|
<ToolButton
|
||||||
|
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||||
|
isActive={editor.isActive("blockquote")}
|
||||||
|
icon={Quote}
|
||||||
|
label="引用"
|
||||||
|
/>
|
||||||
|
<ToolButton
|
||||||
|
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||||
|
isActive={editor.isActive("codeBlock")}
|
||||||
|
icon={Code2}
|
||||||
|
label="代码块"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-border mx-1" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<ToolButton
|
||||||
|
onClick={() => editor.chain().focus().setHorizontalRule().run()}
|
||||||
|
isActive={false}
|
||||||
|
icon={Minus}
|
||||||
|
label="分割线"
|
||||||
|
/>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
disabled={imageUploading}
|
||||||
|
title="图片"
|
||||||
|
>
|
||||||
|
{imageUploading ? (
|
||||||
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
|
) : (
|
||||||
|
<ImageIcon className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => imageInputRef.current?.click()}
|
||||||
|
disabled={imageUploading}
|
||||||
|
>
|
||||||
|
<UploadIcon className="h-4 w-4" />
|
||||||
|
<span>本地上传</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={addImageByUrl}>
|
||||||
|
<LinkIcon className="h-4 w-4" />
|
||||||
|
<span>输入链接</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<ToolButton
|
||||||
|
onClick={addYoutube}
|
||||||
|
isActive={false}
|
||||||
|
icon={YoutubeIcon}
|
||||||
|
label="视频"
|
||||||
|
/>
|
||||||
|
<ToolButton
|
||||||
|
onClick={insertTable}
|
||||||
|
isActive={editor.isActive("table")}
|
||||||
|
icon={TableIcon}
|
||||||
|
label="插入表格"
|
||||||
|
/>
|
||||||
|
{editor.isActive("table") && (
|
||||||
|
<ToolButton
|
||||||
|
onClick={deleteTable}
|
||||||
|
isActive={false}
|
||||||
|
icon={Trash2}
|
||||||
|
label="删除表格"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-border mx-1" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<ToolButton
|
||||||
|
onClick={() => editor.chain().focus().undo().run()}
|
||||||
|
isActive={false}
|
||||||
|
icon={Undo}
|
||||||
|
label="撤销"
|
||||||
|
/>
|
||||||
|
<ToolButton
|
||||||
|
onClick={() => editor.chain().focus().redo().run()}
|
||||||
|
isActive={false}
|
||||||
|
icon={Redo}
|
||||||
|
label="重做"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{onImportMdx && (
|
||||||
|
<>
|
||||||
|
<div className="w-px h-6 bg-border mx-1" />
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => {
|
||||||
|
const text = window.prompt("请粘贴 MDX / Markdown 内容")
|
||||||
|
if (text) {
|
||||||
|
onImportMdx(text)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileCode2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>导入 MDX</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".md,.mdx,.markdown"
|
||||||
|
className="hidden"
|
||||||
|
onChange={e => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
const text = reader.result as string
|
||||||
|
if (text && onImportMdx) {
|
||||||
|
onImportMdx(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.readAsText(file)
|
||||||
|
e.target.value = ""
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-mono font-bold">.MDX</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>打开 MDX 文件</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={imageInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleImageUpload}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{onSave && (
|
||||||
|
<>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<Button type="button" size="sm" onClick={onSave} disabled={saving}>
|
||||||
|
{saving ? "保存中..." : "保存"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
117
src/components/editor/editorSidebar.tsx
Normal file
117
src/components/editor/editorSidebar.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Globe, ImageIcon } from "lucide-react"
|
||||||
|
import type { ReactNode } from "react"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import type { ArticleGroup } from "@/models/article-group"
|
||||||
|
|
||||||
|
interface EditorSidebarProps {
|
||||||
|
title: string
|
||||||
|
coverImage: string
|
||||||
|
groups: ArticleGroup[]
|
||||||
|
groupId: string
|
||||||
|
onTitleChange: (title: string) => void
|
||||||
|
onCoverImageChange: (url: string) => void
|
||||||
|
onGroupChange: (groupId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarSection({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
icon: React.ComponentType<{ className?: string }>
|
||||||
|
title: string
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="border-b pb-4 last:border-b-0">
|
||||||
|
<div className="flex items-center gap-2 mb-3 text-sm font-medium text-muted-foreground">
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
<span>{title}</span>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditorSidebar({
|
||||||
|
coverImage,
|
||||||
|
groups,
|
||||||
|
groupId,
|
||||||
|
onCoverImageChange,
|
||||||
|
onGroupChange,
|
||||||
|
}: EditorSidebarProps) {
|
||||||
|
return (
|
||||||
|
<div className="w-72 shrink-0 border-r bg-card h-full overflow-auto p-4 flex flex-col gap-4">
|
||||||
|
<SidebarSection icon={Globe} title="页面信息">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">所属分组</Label>
|
||||||
|
<Select value={groupId} onValueChange={onGroupChange}>
|
||||||
|
<SelectTrigger className="mt-1 h-8 text-sm w-full">
|
||||||
|
<SelectValue placeholder="请选择分组" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{groups.map(g => (
|
||||||
|
<SelectItem key={g.id} value={String(g.id)}>
|
||||||
|
{g.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SidebarSection>
|
||||||
|
|
||||||
|
<SidebarSection icon={ImageIcon} title="缩略图">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">封面图链接</Label>
|
||||||
|
<Input
|
||||||
|
value={coverImage}
|
||||||
|
onChange={e => onCoverImageChange(e.target.value)}
|
||||||
|
placeholder="https://..."
|
||||||
|
className="mt-1 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{coverImage && (
|
||||||
|
<div
|
||||||
|
className="rounded-lg border overflow-hidden w-full h-32 bg-cover bg-center"
|
||||||
|
style={{ backgroundImage: `url(${coverImage})` }}
|
||||||
|
role="img"
|
||||||
|
aria-label="封面图预览"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SidebarSection>
|
||||||
|
|
||||||
|
<SidebarSection icon={Globe} title="SEO">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">SEO 标题</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="留空则使用页面标题"
|
||||||
|
className="mt-1 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">SEO 关键词</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="多个关键词用逗号分隔"
|
||||||
|
className="mt-1 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SidebarSection>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
42
src/components/editor/extensions/mdx-paste-handler.ts
Normal file
42
src/components/editor/extensions/mdx-paste-handler.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { Extension } from "@tiptap/core"
|
||||||
|
import { DOMParser } from "@tiptap/pm/model"
|
||||||
|
import { Plugin, PluginKey } from "@tiptap/pm/state"
|
||||||
|
import { isMdxLike, mdxToHtml } from "@/lib/mdx-converter"
|
||||||
|
|
||||||
|
export const MdxPasteHandler = Extension.create({
|
||||||
|
name: "mdxPasteHandler",
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
new Plugin({
|
||||||
|
key: new PluginKey("mdxPasteHandler"),
|
||||||
|
props: {
|
||||||
|
handlePaste: (_view, event) => {
|
||||||
|
const text = event.clipboardData?.getData("text/plain")
|
||||||
|
if (!text || !isMdxLike(text)) return false
|
||||||
|
|
||||||
|
const html = mdxToHtml(text)
|
||||||
|
if (!html) return false
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
const { view } = this.editor
|
||||||
|
const { state, dispatch } = view
|
||||||
|
|
||||||
|
const dom = document.createElement("div")
|
||||||
|
dom.innerHTML = html
|
||||||
|
const slice = DOMParser.fromSchema(state.schema).parseSlice(dom, {
|
||||||
|
preserveWhitespace: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (slice.content.size > 0) {
|
||||||
|
const tr = state.tr.replaceSelection(slice)
|
||||||
|
dispatch(tr)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
5
src/components/editor/index.ts
Normal file
5
src/components/editor/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { editorExtensions } from "./editorExtensions"
|
||||||
|
export { default as EditorMenuBar } from "./editorMenuBar"
|
||||||
|
export { default as EditorSidebar } from "./editorSidebar"
|
||||||
|
export { MdxPasteHandler } from "./extensions/mdx-paste-handler"
|
||||||
|
export { default as RichTextEditor } from "./richTextEditor"
|
||||||
130
src/components/editor/richTextEditor.tsx
Normal file
130
src/components/editor/richTextEditor.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { EditorContent, useEditor } from "@tiptap/react"
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import { mdxToHtml } from "@/lib/mdx-converter"
|
||||||
|
import type { ArticleGroup } from "@/models/article-group"
|
||||||
|
import { editorExtensions } from "./editorExtensions"
|
||||||
|
import EditorMenuBar from "./editorMenuBar"
|
||||||
|
import EditorSidebar from "./editorSidebar"
|
||||||
|
|
||||||
|
interface RichTextEditorProps {
|
||||||
|
content?: string
|
||||||
|
title?: string
|
||||||
|
coverImage?: string
|
||||||
|
groups: ArticleGroup[]
|
||||||
|
groupId: string
|
||||||
|
onCoverImageChange?: (url: string) => void
|
||||||
|
onGroupChange: (groupId: string) => void
|
||||||
|
onSave: (data: { title: string; content: string }) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RichTextEditor({
|
||||||
|
content = "",
|
||||||
|
title = "",
|
||||||
|
coverImage = "",
|
||||||
|
groups,
|
||||||
|
groupId,
|
||||||
|
onCoverImageChange,
|
||||||
|
onGroupChange,
|
||||||
|
onSave,
|
||||||
|
}: RichTextEditorProps) {
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [docTitle, setDocTitle] = useState(title)
|
||||||
|
const [sectionTitle, setSectionTitle] = useState("")
|
||||||
|
const [docCoverImage, setDocCoverImage] = useState(coverImage)
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: editorExtensions,
|
||||||
|
content,
|
||||||
|
immediatelyRender: false,
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
class: "focus:outline-none w-full p-4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDocTitle(title)
|
||||||
|
}, [title])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDocCoverImage(coverImage)
|
||||||
|
}, [coverImage])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor || editor.isDestroyed) return
|
||||||
|
const current = editor.getHTML()
|
||||||
|
const target = content || ""
|
||||||
|
if (target && current !== target) {
|
||||||
|
editor.commands.setContent(target)
|
||||||
|
}
|
||||||
|
}, [content, editor])
|
||||||
|
|
||||||
|
const handleImportMdx = useCallback(
|
||||||
|
(mdxContent: string) => {
|
||||||
|
if (!editor || editor.isDestroyed) return
|
||||||
|
const html = mdxToHtml(mdxContent)
|
||||||
|
if (html) {
|
||||||
|
editor.commands.setContent(html)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[editor],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!editor || editor.isDestroyed) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await onSave({
|
||||||
|
title: docTitle,
|
||||||
|
content: editor.getHTML(),
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}, [editor, docTitle, onSave])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full rounded-lg border bg-white overflow-hidden">
|
||||||
|
<EditorSidebar
|
||||||
|
title={sectionTitle}
|
||||||
|
coverImage={docCoverImage}
|
||||||
|
groups={groups}
|
||||||
|
groupId={groupId}
|
||||||
|
onTitleChange={setSectionTitle}
|
||||||
|
onCoverImageChange={onCoverImageChange || (() => {})}
|
||||||
|
onGroupChange={onGroupChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col min-w-0 min-h-0 overflow-hidden">
|
||||||
|
{/* 固定标题区 */}
|
||||||
|
<div className="px-4 pt-3 pb-2 border-b bg-white shrink-0">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={docTitle}
|
||||||
|
onChange={e => setDocTitle(e.target.value)}
|
||||||
|
placeholder="输入文档标题..."
|
||||||
|
className="w-full text-xl font-semibold bg-transparent border-none outline-none placeholder:text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 固定工具栏 */}
|
||||||
|
<div className="bg-white shrink-0">
|
||||||
|
<EditorMenuBar
|
||||||
|
editor={editor}
|
||||||
|
onSave={handleSave}
|
||||||
|
saving={saving}
|
||||||
|
onImportMdx={handleImportMdx}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 可滚动的内容区 */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto tiptap-editor">
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||||
|
import type * as React from "react"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ function DropdownMenuContent({
|
|||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -75,7 +75,7 @@ function DropdownMenuItem({
|
|||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
|
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -93,7 +93,7 @@ function DropdownMenuCheckboxItem({
|
|||||||
data-slot="dropdown-menu-checkbox-item"
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -129,7 +129,7 @@ function DropdownMenuRadioItem({
|
|||||||
data-slot="dropdown-menu-radio-item"
|
data-slot="dropdown-menu-radio-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -156,7 +156,7 @@ function DropdownMenuLabel({
|
|||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -185,7 +185,7 @@ function DropdownMenuShortcut({
|
|||||||
data-slot="dropdown-menu-shortcut"
|
data-slot="dropdown-menu-shortcut"
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -212,7 +212,7 @@ function DropdownMenuSubTrigger({
|
|||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -231,7 +231,7 @@ function DropdownMenuSubContent({
|
|||||||
data-slot="dropdown-menu-sub-content"
|
data-slot="dropdown-menu-sub-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,20 +1,77 @@
|
|||||||
import type * as React from "react"
|
import { X } from "lucide-react"
|
||||||
|
import * as React from "react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
interface InputProps extends React.ComponentProps<"input"> {
|
||||||
|
clearable?: boolean
|
||||||
|
onClear?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function Input({
|
||||||
|
className,
|
||||||
|
type,
|
||||||
|
clearable,
|
||||||
|
onClear,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
...props
|
||||||
|
}: InputProps) {
|
||||||
|
const [showClear, setShowClear] = React.useState(false)
|
||||||
|
const inputRef = React.useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const hasValue =
|
||||||
|
value !== undefined && value !== null && String(value).length > 0
|
||||||
|
|
||||||
|
// 监听输入框焦点状态
|
||||||
|
const handleFocus = () => setShowClear(true)
|
||||||
|
const handleBlur = () => setShowClear(false)
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
if (onClear) {
|
||||||
|
onClear()
|
||||||
|
} else if (onChange) {
|
||||||
|
// 触发 React 的 change 事件
|
||||||
|
const event = {
|
||||||
|
target: { value: "" },
|
||||||
|
currentTarget: { value: "" },
|
||||||
|
} as React.ChangeEvent<HTMLInputElement>
|
||||||
|
onChange(event)
|
||||||
|
}
|
||||||
|
// 清空后重新聚焦
|
||||||
|
inputRef.current?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<input
|
<div className="relative inline-block w-full">
|
||||||
type={type}
|
<input
|
||||||
data-slot="input"
|
ref={inputRef}
|
||||||
className={cn(
|
type={type}
|
||||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
data-slot="input"
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
value={value}
|
||||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
onChange={onChange}
|
||||||
className,
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
className={cn(
|
||||||
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
// 当有清空按钮时,右侧留出空间
|
||||||
|
clearable && hasValue && showClear && "pr-7",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{clearable && hasValue && showClear && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClear}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label="清空"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
{...props}
|
</div>
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
92
src/lib/mdx-converter.ts
Normal file
92
src/lib/mdx-converter.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { marked } from "marked"
|
||||||
|
|
||||||
|
marked.setOptions({
|
||||||
|
gfm: true,
|
||||||
|
breaks: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
function stripFrontmatter(content: string): {
|
||||||
|
frontmatter: Record<string, string>
|
||||||
|
body: string
|
||||||
|
} {
|
||||||
|
const frontmatter: Record<string, string> = {}
|
||||||
|
if (!content.startsWith("---")) {
|
||||||
|
return { frontmatter, body: content }
|
||||||
|
}
|
||||||
|
const end = content.indexOf("---", 3)
|
||||||
|
if (end === -1) {
|
||||||
|
return { frontmatter, body: content }
|
||||||
|
}
|
||||||
|
const fm = content.substring(3, end).trim()
|
||||||
|
const body = content.substring(end + 3).trim()
|
||||||
|
for (const line of fm.split("\n")) {
|
||||||
|
const colonIdx = line.indexOf(":")
|
||||||
|
if (colonIdx === -1) continue
|
||||||
|
const key = line.substring(0, colonIdx).trim()
|
||||||
|
const value = line.substring(colonIdx + 1).trim()
|
||||||
|
if (key) {
|
||||||
|
frontmatter[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { frontmatter, body }
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformMdxJsx(content: string): string {
|
||||||
|
let result = content
|
||||||
|
result = result.replace(
|
||||||
|
/<([A-Z][a-zA-Z0-9]*)\b[^>]*\/\s*>/g,
|
||||||
|
(_match, tagName) => {
|
||||||
|
return `<code><${tagName} /></code>`
|
||||||
|
},
|
||||||
|
)
|
||||||
|
result = result.replace(
|
||||||
|
/<([A-Z][a-zA-Z0-9]*)\b([^>]*)>([\s\S]*?)<\/\1>/g,
|
||||||
|
(_match, tagName, attrs, inner) => {
|
||||||
|
const encodedAttrs = attrs
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
const encodedInner = inner
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
return `<pre><code class="language-jsx"><${tagName}${encodedAttrs}>\n${encodedInner}\n</${tagName}></code></pre>`
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mdxToHtml(mdxContent: string): string {
|
||||||
|
if (!mdxContent || typeof mdxContent !== "string") return ""
|
||||||
|
|
||||||
|
const { body } = stripFrontmatter(mdxContent)
|
||||||
|
const cleaned = transformMdxJsx(body)
|
||||||
|
|
||||||
|
const html = marked.parse(cleaned) as string | Promise<string>
|
||||||
|
|
||||||
|
if (typeof html === "string") {
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMdxLike(text: string): boolean {
|
||||||
|
if (!text || typeof text !== "string") return false
|
||||||
|
const patterns = [
|
||||||
|
/^#{1,6}\s/m,
|
||||||
|
/^>\s/m,
|
||||||
|
/^[-*+]\s/m,
|
||||||
|
/^```/m,
|
||||||
|
/^---\s*$/m,
|
||||||
|
/\[.+\]\(.+\)/,
|
||||||
|
/\*\*.*\*\*/,
|
||||||
|
/__.*__/,
|
||||||
|
/^`[^`]+`$/m,
|
||||||
|
/^<[A-Z][a-zA-Z]*[\s>]/m,
|
||||||
|
/^\d+\.\s/m,
|
||||||
|
/^\|\s.*\|\s*$/m,
|
||||||
|
/^-\s\[[ x]\]\s/m,
|
||||||
|
]
|
||||||
|
return patterns.some(p => p.test(text))
|
||||||
|
}
|
||||||
@@ -55,6 +55,7 @@ export const ScopeUserWriteBind = "user:write:bind" // 用户认领
|
|||||||
export const ScopeCoupon = "coupon"
|
export const ScopeCoupon = "coupon"
|
||||||
export const ScopeCouponRead = "coupon:read" // 读取优惠券列表
|
export const ScopeCouponRead = "coupon:read" // 读取优惠券列表
|
||||||
export const ScopeCouponWrite = "coupon:write" // 写入优惠券
|
export const ScopeCouponWrite = "coupon:write" // 写入优惠券
|
||||||
|
export const ScopeCouponWriteAssign = "coupon:write:assign" // 发放优惠券
|
||||||
|
|
||||||
// 批次
|
// 批次
|
||||||
export const ScopeBatch = "batch"
|
export const ScopeBatch = "batch"
|
||||||
@@ -90,3 +91,4 @@ export const ScopeProxy = "proxy"
|
|||||||
export const ScopeProxyRead = "proxy:read" // 读取代理列表
|
export const ScopeProxyRead = "proxy:read" // 读取代理列表
|
||||||
export const ScopeProxyWrite = "proxy:write" // 写入代理
|
export const ScopeProxyWrite = "proxy:write" // 写入代理
|
||||||
export const ScopeProxyWriteStatus = "proxy:write:status" // 更改代理状态
|
export const ScopeProxyWriteStatus = "proxy:write:status" // 更改代理状态
|
||||||
|
export const ScopeChannelWriteClearExpired = "channel:write:clear_expired" //清理过期连接
|
||||||
|
|||||||
8
src/models/article-group.ts
Normal file
8
src/models/article-group.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { Model } from "./base/model"
|
||||||
|
|
||||||
|
export type ArticleGroup = Model & {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
sort: number
|
||||||
|
status: number
|
||||||
|
}
|
||||||
20
src/models/article.ts
Normal file
20
src/models/article.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Model } from "./base/model"
|
||||||
|
|
||||||
|
export type Article = Model & {
|
||||||
|
title: string
|
||||||
|
section_title: string
|
||||||
|
content: string
|
||||||
|
group_id: number
|
||||||
|
description?: string
|
||||||
|
cover_image?: string
|
||||||
|
sort: number
|
||||||
|
status: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UploadImageResult = {
|
||||||
|
url: string
|
||||||
|
path: string
|
||||||
|
original_name: string
|
||||||
|
size: number
|
||||||
|
mime_type: string
|
||||||
|
}
|
||||||
@@ -20,4 +20,5 @@ export type Channel = {
|
|||||||
ip: string
|
ip: string
|
||||||
user?: User
|
user?: User
|
||||||
resource?: Resource
|
resource?: Resource
|
||||||
|
time?: Date
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,111 @@
|
|||||||
|
import type { User } from "./user"
|
||||||
|
|
||||||
export type Coupon = {
|
export type Coupon = {
|
||||||
id: number
|
id: number
|
||||||
created_at: Date
|
name: string
|
||||||
updated_at: Date
|
|
||||||
user_id: number
|
user_id: number
|
||||||
code: string
|
code: string
|
||||||
remark: string
|
remark: string
|
||||||
amount: number
|
amount: number
|
||||||
min_amount: number
|
min_amount: number
|
||||||
|
count: number
|
||||||
status: number
|
status: number
|
||||||
|
expire_type: number
|
||||||
|
created_at: Date
|
||||||
|
updated_at: Date
|
||||||
expire_at: Date
|
expire_at: Date
|
||||||
|
expire_in: number
|
||||||
|
coupon: useCoupon
|
||||||
|
user: User
|
||||||
|
}
|
||||||
|
type useCoupon = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
expire_type: number
|
||||||
|
status: number
|
||||||
|
created_at: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优惠券使用状态
|
||||||
|
export const couponUseStatusMap = {
|
||||||
|
0: { text: "未使用", color: "text-green-600" },
|
||||||
|
1: { text: "已使用", color: "text-blue-600" },
|
||||||
|
2: { text: "已禁用", color: "text-green-600" },
|
||||||
|
} as const
|
||||||
|
|
||||||
|
// 优惠券状态
|
||||||
|
export const couponStatusMap = {
|
||||||
|
0: { text: "禁用", color: "text-yellow-600" },
|
||||||
|
1: { text: "正常", color: "text-green-600" },
|
||||||
|
} as const
|
||||||
|
|
||||||
|
// 优惠券过期类型
|
||||||
|
export const expireTypeMap = {
|
||||||
|
0: "不过期",
|
||||||
|
1: "固定日期",
|
||||||
|
2: "相对日期",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
// 优惠券状态 & 使用状态
|
||||||
|
export const getStatus = (status: number, type: "coupon" | "use") => {
|
||||||
|
if (type === "coupon") {
|
||||||
|
return (
|
||||||
|
couponStatusMap[status as keyof typeof couponStatusMap] || {
|
||||||
|
text: "",
|
||||||
|
color: "text-gray-400",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
couponUseStatusMap[status as keyof typeof couponUseStatusMap] || {
|
||||||
|
text: "",
|
||||||
|
color: "text-gray-400",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getDaysToExpire = (expireAt: Date | string): number => {
|
||||||
|
if (!expireAt) return 0
|
||||||
|
|
||||||
|
const targetDate = new Date(expireAt)
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
const targetDay = new Date(
|
||||||
|
targetDate.getFullYear(),
|
||||||
|
targetDate.getMonth(),
|
||||||
|
targetDate.getDate(),
|
||||||
|
)
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
|
|
||||||
|
const diffTime = targetDay.getTime() - today.getTime()
|
||||||
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||||
|
|
||||||
|
return diffDays
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过期类型
|
||||||
|
export const getExpireType = (expireType: number): string => {
|
||||||
|
return expireTypeMap[expireType as keyof typeof expireTypeMap] || ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取过期类型显示(带天数)
|
||||||
|
export const getExpireTypeText = (
|
||||||
|
expireType: number,
|
||||||
|
expireAt: Date,
|
||||||
|
): string => {
|
||||||
|
const typeText = getExpireType(expireType)
|
||||||
|
console.log(typeText, "typeText")
|
||||||
|
|
||||||
|
if (expireType === 0) return typeText
|
||||||
|
|
||||||
|
const days = getDaysToExpire(expireAt)
|
||||||
|
console.log(days, "days")
|
||||||
|
|
||||||
|
if (days === 0) {
|
||||||
|
return `${typeText}`
|
||||||
|
} else if (days > 0) {
|
||||||
|
return `${typeText}(${days}天后)`
|
||||||
|
} else {
|
||||||
|
return `${typeText}(已过期${Math.abs(days)}天)`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11
src/models/formatDate.ts
Normal file
11
src/models/formatDate.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { format } from "date-fns"
|
||||||
|
|
||||||
|
export const formatDate = (
|
||||||
|
date: Date | string | null | undefined,
|
||||||
|
formatStr: string = "yyyy-MM-dd HH:mm:ss",
|
||||||
|
): string => {
|
||||||
|
if (!date) return ""
|
||||||
|
const d = new Date(date)
|
||||||
|
if (!Number.isNaN(d.getTime())) return format(d, formatStr)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ type ResourceBase = {
|
|||||||
updated_at: Date
|
updated_at: Date
|
||||||
deleted_at: Date | null
|
deleted_at: Date | null
|
||||||
user: User
|
user: User
|
||||||
|
checkip: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type ResourceShort = {
|
type ResourceShort = {
|
||||||
|
|||||||
Reference in New Issue
Block a user