登录页面与组件样式调整

This commit is contained in:
2025-03-19 15:49:18 +08:00
parent eaae095d0e
commit 906693be10
28 changed files with 1405 additions and 206 deletions

1
.gitignore vendored
View File

@@ -42,3 +42,4 @@ next-env.d.ts
# editor
.idea/
.vscode/

View File

@@ -1,3 +1,3 @@
## TODO
首页页头菜单导航的 bug
客户端令牌保存到缓存中

View File

@@ -15,6 +15,7 @@ const eslintConfig = [
rules: {
'@typescript-eslint/no-empty-object-type': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'semi': ['error', 'never'],
},
},
];

View File

@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
import type { NextConfig } from "next"
const nextConfig: NextConfig = {
/* config options here */
};
}
export default nextConfig;
export default nextConfig

View File

@@ -9,27 +9,27 @@
"lint": "next lint"
},
"dependencies": {
"next": "15.2.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@hookform/resolvers": "^4.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-radio-group": "^1.2.3",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-slot": "^1.1.2",
"lucide-react": "^0.479.0",
"canvas": "^3.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"tailwind-merge": "^3.0.2",
"lucide-react": "^0.479.0",
"motion": "^12.5.0",
"next": "15.2.1",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"@hookform/resolvers": "^4.1.3",
"zod": "^3.24.2",
"sonner": "^2.0.1",
"tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7",
"motion": "^12.5.0"
"zod": "^3.24.2"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@@ -44,5 +44,10 @@
"tailwindcss": "^4",
"typescript": "^5"
},
"packageManager": "pnpm@10.5.2+sha512.da9dc28cd3ff40d0592188235ab25d3202add8a207afbedc682220e4a0029ffbff4562102b9e6e46b4e3f9e8bd53e6d05de48544b0c57d4b0179e22c76d1199b"
"packageManager": "pnpm@10.5.2+sha512.da9dc28cd3ff40d0592188235ab25d3202add8a207afbedc682220e4a0029ffbff4562102b9e6e46b4e3f9e8bd53e6d05de48544b0c57d4b0179e22c76d1199b",
"pnpm": {
"onlyBuiltDependencies": [
"canvas"
]
}
}

316
pnpm-lock.yaml generated
View File

@@ -14,6 +14,9 @@ importers:
'@radix-ui/react-checkbox':
specifier: ^1.1.4
version: 1.1.4(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-dialog':
specifier: ^1.1.6
version: 1.1.6(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-label':
specifier: ^2.1.2
version: 2.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@@ -26,6 +29,9 @@ importers:
'@radix-ui/react-slot':
specifier: ^1.1.2
version: 1.1.2(@types/react@19.0.10)(react@19.0.0)
canvas:
specifier: ^3.1.0
version: 3.1.0
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -41,6 +47,9 @@ importers:
next:
specifier: 15.2.1
version: 15.2.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react:
specifier: ^19.0.0
version: 19.0.0
@@ -50,6 +59,9 @@ importers:
react-hook-form:
specifier: ^7.54.2
version: 7.54.2(react@19.0.0)
sonner:
specifier: ^2.0.1
version: 2.0.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
tailwind-merge:
specifier: ^3.0.2
version: 3.0.2
@@ -431,6 +443,19 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-dialog@1.1.6':
resolution: {integrity: sha512-/IVhJV5AceX620DUJ4uYVMymzsipdKBzo3edo+omeskCKGm9FRHM0ebIdbPnlQVJqyuHbuBltQUOG2mOTq2IYw==}
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
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-direction@1.1.0':
resolution: {integrity: sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==}
peerDependencies:
@@ -920,6 +945,12 @@ packages:
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
brace-expansion@1.1.11:
resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==}
@@ -930,6 +961,9 @@ packages:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
busboy@1.6.0:
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
engines: {node: '>=10.16.0'}
@@ -953,10 +987,17 @@ packages:
caniuse-lite@1.0.30001701:
resolution: {integrity: sha512-faRs/AW3jA9nTwmJBSO1PQ6L/EOgsB5HMQQq4iCu5zhPgVVgO/pZRHlmatwijZKetFw8/Pr4q6dEN8sJuq8qTw==}
canvas@3.1.0:
resolution: {integrity: sha512-tTj3CqqukVJ9NgSahykNwtGda7V33VLObwrHfzT0vqJXu7J4d4C/7kQQW3fOEGDfZZoILPut5H00gOjyttPGyg==}
engines: {node: ^18.12.0 || >= 20.9.0}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
chownr@1.1.4:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
@@ -1023,6 +1064,14 @@ packages:
supports-color:
optional: true
decompress-response@6.0.0:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
engines: {node: '>=10'}
deep-extend@0.6.0:
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
engines: {node: '>=4.0.0'}
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@@ -1057,6 +1106,9 @@ packages:
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
end-of-stream@1.4.4:
resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==}
enhanced-resolve@5.18.1:
resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==}
engines: {node: '>=10.13.0'}
@@ -1213,6 +1265,10 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
expand-template@2.0.3:
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
engines: {node: '>=6'}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -1278,6 +1334,9 @@ packages:
react-dom:
optional: true
fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
@@ -1307,6 +1366,9 @@ packages:
get-tsconfig@4.10.0:
resolution: {integrity: sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A==}
github-from-package@0.0.0:
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
@@ -1360,6 +1422,9 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@@ -1372,6 +1437,12 @@ packages:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'}
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
ini@1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
internal-slot@1.1.0:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
@@ -1626,6 +1697,10 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
mimic-response@3.1.0:
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
engines: {node: '>=10'}
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
@@ -1636,6 +1711,9 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
mkdirp-classic@0.5.3:
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
motion-dom@12.5.0:
resolution: {integrity: sha512-uH2PETDh7m+Hjd1UQQ56yHqwn83SAwNjimNPE/kC+Kds0t4Yh7+29rfo5wezVFpPOv57U4IuWved5d1x0kNhbQ==}
@@ -1664,9 +1742,18 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
napi-build-utils@2.0.0:
resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
next-themes@0.4.6:
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
peerDependencies:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
next@15.2.1:
resolution: {integrity: sha512-zxbsdQv3OqWXybK5tMkPCBKyhIz63RstJ+NvlfkaLMc/m5MwXgz2e92k+hSKcyBpyADhMk2C31RIiaDjUZae7g==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
@@ -1688,6 +1775,13 @@ packages:
sass:
optional: true
node-abi@3.74.0:
resolution: {integrity: sha512-c5XK0MjkGBrQPGYG24GBADZud0NCbznxNx0ZkS+ebUTrmV1qTDxPxSL8zEAPURXSbLRWVexxmP4986BziahL5w==}
engines: {node: '>=10'}
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -1720,6 +1814,9 @@ packages:
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
engines: {node: '>= 0.4'}
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -1774,6 +1871,11 @@ packages:
resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==}
engines: {node: ^10 || ^12 || >=14}
prebuild-install@7.1.3:
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
engines: {node: '>=10'}
hasBin: true
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -1781,6 +1883,9 @@ packages:
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
pump@3.0.2:
resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -1788,6 +1893,10 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
rc@1.2.8:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
react-dom@19.0.0:
resolution: {integrity: sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==}
peerDependencies:
@@ -1836,6 +1945,10 @@ packages:
resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==}
engines: {node: '>=0.10.0'}
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
@@ -1871,6 +1984,9 @@ packages:
resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
engines: {node: '>=0.4'}
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
safe-push-apply@1.0.0:
resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==}
engines: {node: '>= 0.4'}
@@ -1931,9 +2047,21 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
simple-concat@1.0.1:
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
simple-get@4.0.1:
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
simple-swizzle@0.2.2:
resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==}
sonner@2.0.1:
resolution: {integrity: sha512-FRBphaehZ5tLdLcQ8g2WOIRE+Y7BCfWi5Zyd8bCvBjiW8TxxAyoWZIxS661Yz6TGPqFQ4VLzOF89WEYhfynSFQ==}
peerDependencies:
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -1968,10 +2096,17 @@ packages:
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
engines: {node: '>= 0.4'}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
strip-bom@3.0.0:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'}
strip-json-comments@2.0.1:
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
engines: {node: '>=0.10.0'}
strip-json-comments@3.1.1:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
@@ -2012,6 +2147,13 @@ packages:
resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==}
engines: {node: '>=6'}
tar-fs@2.1.2:
resolution: {integrity: sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==}
tar-stream@2.2.0:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'}
tinyglobby@0.2.12:
resolution: {integrity: sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww==}
engines: {node: '>=12.0.0'}
@@ -2032,6 +2174,9 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
tunnel-agent@0.6.0:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -2087,6 +2232,9 @@ packages:
'@types/react':
optional: true
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@@ -2112,6 +2260,9 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@@ -2377,6 +2528,28 @@ snapshots:
optionalDependencies:
'@types/react': 19.0.10
'@radix-ui/react-dialog@1.1.6(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/primitive': 1.1.1
'@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.10)(react@19.0.0)
'@radix-ui/react-context': 1.1.1(@types/react@19.0.10)(react@19.0.0)
'@radix-ui/react-dismissable-layer': 1.1.5(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-focus-guards': 1.1.1(@types/react@19.0.10)(react@19.0.0)
'@radix-ui/react-focus-scope': 1.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-id': 1.1.0(@types/react@19.0.10)(react@19.0.0)
'@radix-ui/react-portal': 1.1.4(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-presence': 1.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-slot': 1.1.2(@types/react@19.0.10)(react@19.0.0)
'@radix-ui/react-use-controllable-state': 1.1.0(@types/react@19.0.10)(react@19.0.0)
aria-hidden: 1.2.4
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
react-remove-scroll: 2.6.3(@types/react@19.0.10)(react@19.0.0)
optionalDependencies:
'@types/react': 19.0.10
'@types/react-dom': 19.0.4(@types/react@19.0.10)
'@radix-ui/react-direction@1.1.0(@types/react@19.0.10)(react@19.0.0)':
dependencies:
react: 19.0.0
@@ -2876,6 +3049,14 @@ snapshots:
balanced-match@1.0.2: {}
base64-js@1.5.1: {}
bl@4.1.0:
dependencies:
buffer: 5.7.1
inherits: 2.0.4
readable-stream: 3.6.2
brace-expansion@1.1.11:
dependencies:
balanced-match: 1.0.2
@@ -2889,6 +3070,11 @@ snapshots:
dependencies:
fill-range: 7.1.1
buffer@5.7.1:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
busboy@1.6.0:
dependencies:
streamsearch: 1.1.0
@@ -2914,11 +3100,18 @@ snapshots:
caniuse-lite@1.0.30001701: {}
canvas@3.1.0:
dependencies:
node-addon-api: 7.1.1
prebuild-install: 7.1.3
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
supports-color: 7.2.0
chownr@1.1.4: {}
class-variance-authority@0.7.1:
dependencies:
clsx: 2.1.1
@@ -2983,6 +3176,12 @@ snapshots:
dependencies:
ms: 2.1.3
decompress-response@6.0.0:
dependencies:
mimic-response: 3.1.0
deep-extend@0.6.0: {}
deep-is@0.1.4: {}
define-data-property@1.1.4:
@@ -2999,8 +3198,7 @@ snapshots:
detect-libc@1.0.3: {}
detect-libc@2.0.3:
optional: true
detect-libc@2.0.3: {}
detect-node-es@1.1.0: {}
@@ -3016,6 +3214,10 @@ snapshots:
emoji-regex@9.2.2: {}
end-of-stream@1.4.4:
dependencies:
once: 1.4.0
enhanced-resolve@5.18.1:
dependencies:
graceful-fs: 4.2.11
@@ -3317,6 +3519,8 @@ snapshots:
esutils@2.0.3: {}
expand-template@2.0.3: {}
fast-deep-equal@3.1.3: {}
fast-glob@3.3.1:
@@ -3380,6 +3584,8 @@ snapshots:
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
fs-constants@1.0.0: {}
function-bind@1.1.2: {}
function.prototype.name@1.1.8:
@@ -3423,6 +3629,8 @@ snapshots:
dependencies:
resolve-pkg-maps: 1.0.0
github-from-package@0.0.0: {}
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
@@ -3466,6 +3674,8 @@ snapshots:
dependencies:
function-bind: 1.1.2
ieee754@1.2.1: {}
ignore@5.3.2: {}
import-fresh@3.3.1:
@@ -3475,6 +3685,10 @@ snapshots:
imurmurhash@0.1.4: {}
inherits@2.0.4: {}
ini@1.3.8: {}
internal-slot@1.1.0:
dependencies:
es-errors: 1.3.0
@@ -3714,6 +3928,8 @@ snapshots:
braces: 3.0.3
picomatch: 2.3.1
mimic-response@3.1.0: {}
minimatch@3.1.2:
dependencies:
brace-expansion: 1.1.11
@@ -3724,6 +3940,8 @@ snapshots:
minimist@1.2.8: {}
mkdirp-classic@0.5.3: {}
motion-dom@12.5.0:
dependencies:
motion-utils: 12.5.0
@@ -3742,8 +3960,15 @@ snapshots:
nanoid@3.3.8: {}
napi-build-utils@2.0.0: {}
natural-compare@1.4.0: {}
next-themes@0.4.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
next@15.2.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
'@next/env': 15.2.1
@@ -3769,6 +3994,12 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
node-abi@3.74.0:
dependencies:
semver: 7.7.1
node-addon-api@7.1.1: {}
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
@@ -3810,6 +4041,10 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.1
once@1.4.0:
dependencies:
wrappy: 1.0.2
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -3863,6 +4098,21 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
prebuild-install@7.1.3:
dependencies:
detect-libc: 2.0.3
expand-template: 2.0.3
github-from-package: 0.0.0
minimist: 1.2.8
mkdirp-classic: 0.5.3
napi-build-utils: 2.0.0
node-abi: 3.74.0
pump: 3.0.2
rc: 1.2.8
simple-get: 4.0.1
tar-fs: 2.1.2
tunnel-agent: 0.6.0
prelude-ls@1.2.1: {}
prop-types@15.8.1:
@@ -3871,10 +4121,22 @@ snapshots:
object-assign: 4.1.1
react-is: 16.13.1
pump@3.0.2:
dependencies:
end-of-stream: 1.4.4
once: 1.4.0
punycode@2.3.1: {}
queue-microtask@1.2.3: {}
rc@1.2.8:
dependencies:
deep-extend: 0.6.0
ini: 1.3.8
minimist: 1.2.8
strip-json-comments: 2.0.1
react-dom@19.0.0(react@19.0.0):
dependencies:
react: 19.0.0
@@ -3915,6 +4177,12 @@ snapshots:
react@19.0.0: {}
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
reflect.getprototypeof@1.0.10:
dependencies:
call-bind: 1.0.8
@@ -3965,6 +4233,8 @@ snapshots:
has-symbols: 1.1.0
isarray: 2.0.5
safe-buffer@5.2.1: {}
safe-push-apply@1.0.0:
dependencies:
es-errors: 1.3.0
@@ -4065,11 +4335,24 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
simple-concat@1.0.1: {}
simple-get@4.0.1:
dependencies:
decompress-response: 6.0.0
once: 1.4.0
simple-concat: 1.0.1
simple-swizzle@0.2.2:
dependencies:
is-arrayish: 0.3.2
optional: true
sonner@2.0.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
source-map-js@1.2.1: {}
stable-hash@0.0.4: {}
@@ -4126,8 +4409,14 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.1
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
strip-bom@3.0.0: {}
strip-json-comments@2.0.1: {}
strip-json-comments@3.1.1: {}
styled-jsx@5.1.6(react@19.0.0):
@@ -4151,6 +4440,21 @@ snapshots:
tapable@2.2.1: {}
tar-fs@2.1.2:
dependencies:
chownr: 1.1.4
mkdirp-classic: 0.5.3
pump: 3.0.2
tar-stream: 2.2.0
tar-stream@2.2.0:
dependencies:
bl: 4.1.0
end-of-stream: 1.4.4
fs-constants: 1.0.0
inherits: 2.0.4
readable-stream: 3.6.2
tinyglobby@0.2.12:
dependencies:
fdir: 6.4.3(picomatch@4.0.2)
@@ -4173,6 +4477,10 @@ snapshots:
tslib@2.8.1: {}
tunnel-agent@0.6.0:
dependencies:
safe-buffer: 5.2.1
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
@@ -4240,6 +4548,8 @@ snapshots:
optionalDependencies:
'@types/react': 19.0.10
util-deprecate@1.0.2: {}
which-boxed-primitive@1.1.1:
dependencies:
is-bigint: 1.1.0
@@ -4286,6 +4596,8 @@ snapshots:
word-wrap@1.2.5: {}
wrappy@1.0.2: {}
yocto-queue@0.1.0: {}
zod@3.24.2: {}

52
src/actions/auth/login.ts Normal file
View File

@@ -0,0 +1,52 @@
'use server'
import {cookies} from 'next/headers'
import {ApiResponse, call} from '@/lib/api'
export interface LoginParams {
username: string;
password: string;
remember?: boolean;
}
type LoginResp = {
token: string;
expires: number;
}
export async function login(props: LoginParams): Promise<ApiResponse> {
try {
// 尝试登录
const result = await call<LoginResp>('/api/auth/login/sms', {
username: props.username,
password: props.password,
remember: props.remember ?? false,
})
if (!result.success) {
return result
}
const data = result.data
console.log('login', data)
// 计算过期时间
const current = Math.floor(Date.now() / 1000)
const future = data.expires - current
// 保存到 cookies
const cookieStore = await cookies()
cookieStore.set('auth_token', data.token, {
httpOnly: true,
sameSite: 'strict',
secure: process.env.NODE_ENV === 'production',
maxAge: Math.max(future, 0),
})
return {
success: true,
data: undefined,
}
}
catch (e) {
throw new Error('请求登陆失败', {cause: e})
}
}

View File

@@ -0,0 +1,69 @@
'use server'
// 验证验证码函数
import {cookies} from 'next/headers'
import crypto from 'crypto'
import {ApiResponse, call} from '@/lib/api'
export interface VerifyParams {
phone: string;
captcha: string; // 添加验证码字段
}
export default async function verify(props: VerifyParams): Promise<ApiResponse> {
try {
// 人机验证
if (!props.captcha?.length) {
return {
success: false,
status: 400,
message: '请输入验证码',
}
}
const valid = await verifyCaptcha(props.captcha)
if (!valid) {
return {
success: false,
status: 400,
message: '验证码错误或已过期',
}
}
// 请求发送短信
return await call('/api/auth/verify/sms', {
phone: props.phone,
purpose: 0,
})
}
catch (error) {
throw new Error('验证码验证失败', {cause: error})
}
}
async function verifyCaptcha(userInput: string): Promise<boolean> {
const cookieStore = await cookies()
const hash = cookieStore.get('captcha_hash')?.value
const salt = cookieStore.get('captcha_salt')?.value
// 如果没有找到验证码cookie验证失败
if (!hash || !salt) {
return false
}
// 使用相同的方法哈希用户输入的验证码
const userInputHash = crypto
.createHmac('sha256', salt)
.update(userInput.toLowerCase())
.digest('hex')
// 比较哈希值
const isValid = hash === userInputHash
// 验证后删除验证码cookie防止重复使用
if (isValid) {
cookieStore.delete('captcha_hash')
cookieStore.delete('captcha_salt')
}
return isValid
}

View File

@@ -0,0 +1,92 @@
'use server'
import {createCanvas} from 'canvas'
import crypto from 'crypto'
import {cookies} from 'next/headers'
// 生成随机验证码
function generateCaptchaText(length: number = 4): string {
const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'
let result = ''
for (let i = 0; i < length; i++) {
result += chars[Math.floor(Math.random() * chars.length)]
}
return result
}
// 哈希验证码文本并使用随机盐值
function hashCaptcha(text: string): { hash: string; salt: string } {
const salt = crypto.randomBytes(16).toString('hex')
const hash = crypto
.createHmac('sha256', salt)
.update(text.toLowerCase())
.digest('hex')
return {hash, salt}
}
// 生成验证码图片
function generateCaptchaImage(text: string) {
const canvas = createCanvas(180, 50)
const ctx = canvas.getContext('2d')
// 设置背景色
ctx.fillStyle = '#f3f4f6'
ctx.fillRect(0, 0, 180, 50)
// 绘制干扰线
for (let i = 0; i < 3; i++) {
ctx.strokeStyle = `rgb(${Math.random() * 255}, ${Math.random() * 255}, ${Math.random() * 255})`
ctx.beginPath()
ctx.moveTo(Math.random() * 180, Math.random() * 50)
ctx.lineTo(Math.random() * 180, Math.random() * 50)
ctx.stroke()
}
// 绘制文本
ctx.font = '28px Arial'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
// 随机文本颜色和位置
for (let i = 0; i < text.length; i++) {
ctx.fillStyle = `rgb(${Math.random() * 100}, ${Math.random() * 100}, ${Math.random() * 100})`
ctx.fillText(
text[i],
(180 / text.length) * (i + 0.5), // 均匀分布
25 + Math.random() * 10 - 5, // 中间位置上下浮动
)
}
return canvas.toBuffer('image/png')
}
export async function GET(request: Request) {
const captchaText = generateCaptchaText()
// 生成验证码图像
const captchaImage = generateCaptchaImage(captchaText)
// 生成验证码哈希和盐值
const {hash, salt} = hashCaptcha(captchaText)
const store = await cookies()
const coo = store
.set('captcha_hash', hash, {
httpOnly: true,
sameSite: 'strict',
secure: process.env.NODE_ENV === 'production',
maxAge: 60,
})
.set('captcha_salt', salt, {
httpOnly: true,
sameSite: 'strict',
secure: process.env.NODE_ENV === 'production',
maxAge: 60,
})
return new Response(captchaImage, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'no-store',
'Set-Cookie': `${coo}`,
},
})
}

View File

@@ -0,0 +1,87 @@
import {useCallback, useEffect, useState} from 'react'
import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog'
import {Button} from '@/components/ui/button'
import {Input} from '@/components/ui/input'
export type CaptchaProps = {
showCaptcha: boolean
setShowCaptcha: (show: boolean) => void
handleSendCode: (captchaCode: string) => boolean | Promise<boolean>
}
export default function Captcha(props: CaptchaProps) {
const {showCaptcha, setShowCaptcha, handleSendCode} = props
const [captchaImage, setCaptchaImage] = useState('/captcha?t=' + Date.now())
const [captchaCode, setCaptchaCode] = useState('')
// 刷新图形验证码
const refreshCaptcha = useCallback(() => {
setCaptchaImage('/captcha?t=' + Date.now())
setCaptchaCode('')
}, [])
const handleVerifyCaptcha = useCallback(async () => {
let refresh = handleSendCode(captchaCode)
if (refresh instanceof Promise) {
refresh = await refresh
}
if (refresh) {
refreshCaptcha()
}
}, [captchaCode, handleSendCode, refreshCaptcha])
useEffect(() => {
if (showCaptcha) {
refreshCaptcha()
}
}, [showCaptcha, refreshCaptcha])
return (
<Dialog open={showCaptcha} onOpenChange={setShowCaptcha}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="flex justify-between items-center">
<img
src={captchaImage}
alt="验证码"
width={180}
height={50}
className="border cursor-pointer"
onClick={refreshCaptcha}
/>
<Button
variant="outline"
onClick={refreshCaptcha}
className="text-sm"
>
</Button>
</div>
<Input
placeholder="请输入图形验证码"
value={captchaCode}
onChange={(e) => setCaptchaCode(e.target.value)}
className="w-full"
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowCaptcha(false)}
className="mr-2"
>
</Button>
<Button
onClick={() => handleVerifyCaptcha()}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,101 +1,301 @@
'use client'
import { ReactNode, useState } from 'react'
import {useState, useCallback, useRef} from 'react'
import {Input} from '@/components/ui/input'
import {Button} from '@/components/ui/button'
import {Checkbox} from '@/components/ui/checkbox'
import {merge} from '@/lib/utils'
import Image from 'next/image'
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import logo from '@/assets/logo.webp'
import {
Card,
CardHeader,
CardContent,
CardTitle,
} from '@/components/ui/card'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import {zodResolver} from '@hookform/resolvers/zod'
import {useForm} from 'react-hook-form'
import zod from 'zod'
import Captcha from './captcha'
import verify from '@/actions/auth/verify'
import {login} from '@/actions/auth/login'
import {useRouter} from 'next/navigation'
import {toast} from 'sonner'
import {ApiResponse} from '@/lib/api'
export type LoginPageProps = {}
export default function LoginPage(props: LoginPageProps) {
const [countdown, setCountdown] = useState(0);
// 定义表单验证模式
const formSchema = zod.object({
username: zod.string().min(11, '请输入正确的手机号码').max(11, '请输入正确的手机号码'),
password: zod.string().min(1, '请输入验证码'),
remember: zod.boolean().default(false),
})
type FormValues = zod.infer<typeof formSchema>
const handleSendCode = () => {
// 这里实现发送验证码的逻辑
setCountdown(60);
const timer = setInterval(() => {
export default function LoginPage(props: LoginPageProps) {
const router = useRouter()
const [submitting, setSubmitting] = useState(false)
const [countdown, setCountdown] = useState(0)
const [showCaptcha, setShowCaptcha] = useState(false)
const timerRef = useRef<NodeJS.Timeout>(undefined)
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
username: '',
password: '',
remember: false,
},
})
// 获取表单值的快捷方式
const username = form.watch('username')
// 处理短信验证码发送前的验证
const checkUsername = useCallback(() => {
if (!username || username.length !== 11) {
form.setError('username', {
type: 'manual',
message: '请输入正确的手机号码',
})
return
}
// 显示图形验证码
setShowCaptcha(true)
}, [username, form])
// 验证图形验证码并发送短信验证码
const sendCode = useCallback(async (captchaCode: string) => {
if (!captchaCode) {
toast.error('请输入图形验证码')
return false
}
// 发送验证码
const resp = await verify({
phone: username,
captcha: captchaCode,
})
// 处理验证码发送结果
let waiting = 60
if (!resp.success) {
if (resp.status == 429) {
setShowCaptcha(false)
waiting = parseInt(resp.message)
console.log(resp.message)
toast.error('发送频率过快', {
description: '请稍后再试',
})
}
else {
toast.error(resp.message)
return true
}
}
else {
setShowCaptcha(false)
toast.success('验证码已发送', {
description: '请注意查收短信',
})
}
// 开始倒计时
setCountdown(waiting)
if (timerRef.current) {
clearInterval(timerRef.current)
}
timerRef.current = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
clearInterval(timer);
return 0;
clearInterval(timerRef.current)
return 0
}
return prev - 1;
});
}, 1000);
};
return prev - 1
})
}, 1000)
return false
}, [username])
const setWaiting = (resp: ApiResponse<undefined>) => {
}
// 处理表单提交
const onSubmit = async (values: FormValues) => {
try {
setSubmitting(true)
// 验证表单数据
if (values.username?.length !== 11) {
form.setError('username', {
type: 'manual',
message: '请输入有效的手机号码',
})
return
}
if (!values.password) {
form.setError('password', {
type: 'manual',
message: '请输入验证码',
})
return
}
// 调用登录函数
const result = await login({
username: values.username,
password: values.password, // 使用验证码作为密码
remember: values.remember,
})
if (result.success) {
// 登录成功
toast.success('登陆成功', {
description: '欢迎回来!',
})
// 跳转到首页或用户仪表板
router.push('/')
router.refresh() // 刷新页面状态
}
else {
// 登录失败
toast.error(result.message, {
description: '请检查您的手机号码和验证码',
})
}
}
catch (e) {
toast.error('服务器错误', {
description: '请稍后再试',
})
}
finally {
setSubmitting(false)
}
}
return (
<main className="h-screen w-screen lg:pr-80 bg-[url(/login/bg.webp)] bg-cover bg-left flex justify-center lg:justify-end items-center">
<main className={merge(
`relative`,
`h-screen w-screen xl:pr-64 bg-[url(/login/bg.webp)] bg-cover bg-left`,
`flex justify-center xl:justify-end items-center`,
)}>
<Image src={logo} alt={`logo`} height={64} className={`absolute top-8 left-8`}/>
{/* 登录表单 */}
<div className="w-96 mx-4 p-8 lg:p-12 bg-white rounded-lg flex items-center justify-center">
<div className="w-full space-y-8">
<div className="text-center">
<h2 className="text-2xl text-gray-900">
/
</h2>
</div>
<Card className="w-96 mx-4 shadow-lg">
<CardHeader className="text-center">
<CardTitle className="text-2xl">/</CardTitle>
</CardHeader>
<form className="mt-8 space-y-6">
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="phone"></Label>
<Input
id="phone"
name="phone"
type="tel"
placeholder="请输入手机号码"
autoComplete="tel"
required
/>
<CardContent className={`px-8`}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="username"
render={({field}) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input
{...field}
type="tel"
placeholder="请输入手机号码"
autoComplete="tel-national"
/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({field}) => (
<FormItem>
<FormLabel></FormLabel>
<div className="flex space-x-4">
<FormControl>
<Input
{...field}
className="h-12"
placeholder="请输入验证码"
/>
</FormControl>
<Button
type="button"
variant="outline"
className="whitespace-nowrap h-12"
onClick={checkUsername}
disabled={countdown > 0}
>
{countdown > 0 ? `${countdown}秒后重发` : '获取验证码'}
</Button>
</div>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="remember"
render={({field}) => (
<FormItem className="flex flex-row items-start space-x-2 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel></FormLabel>
</div>
</FormItem>
)}
/>
<div className="flex flex-col gap-3">
<Button
className="w-full h-12 text-lg"
type="submit"
variant="gradient"
disabled={submitting}
>
{submitting ? '登录中...' : '注册 / 登录'}
</Button>
<p className="text-xs text-center text-gray-500">
<a href="#" className="text-blue-600 hover:text-blue-500"></a><a href="#" className="text-blue-600 hover:text-blue-500"></a>
</p>
</div>
</form>
</Form>
</CardContent>
</Card>
<div className="space-y-2">
<Label htmlFor="verificationCode"></Label>
<div className="flex space-x-2">
<Input
id="verificationCode"
name="verificationCode"
type="text"
placeholder="请输入验证码"
required
/>
<Button
type="button"
variant="outline"
className="whitespace-nowrap"
onClick={handleSendCode}
disabled={countdown > 0}
>
{countdown > 0 ? `${countdown}秒后重发` : '获取验证码'}
</Button>
</div>
</div>
</div>
{/* 图形验证码弹窗 */}
<Captcha
showCaptcha={showCaptcha}
setShowCaptcha={setShowCaptcha}
handleSendCode={sendCode}
/>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Checkbox id="remember-me" name="remember-me" />
<label htmlFor="remember-me" className="text-sm text-gray-900">
</label>
</div>
</div>
<div className={`flex flex-col gap-2`}>
<Button type="submit" className="w-full">
/
</Button>
<p className="text-xs text-center text-gray-500">
<a href="#" className="text-blue-600 hover:text-blue-500"></a><a href="#" className="text-blue-600 hover:text-blue-500"></a>
</p>
</div>
</form>
</div>
</div>
</main>
)
}

View File

@@ -1,8 +1,8 @@
'use client'
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
import {useCallback, useEffect, useMemo, useState} from 'react'
import Link from 'next/link'
import Image from 'next/image'
import { LinkItem, MenuItem } from './_server/navs'
import {LinkItem, MenuItem} from './_server/navs'
import SolutionMenu from './_client/solution'
import ProductMenu from './_client/product'
import HelpMenu from './_client/help'
@@ -39,9 +39,9 @@ export default function Header(props: HeaderProps) {
const [menu, setMenu] = useState(false)
const [page, setPage] = useState(0)
const pages = useMemo(() => [
<ProductMenu key={`product`} />,
<SolutionMenu key={`solution`} />,
<HelpMenu key={`help`} />,
<ProductMenu key={`product`}/>,
<SolutionMenu key={`solution`}/>,
<HelpMenu key={`help`}/>,
], [])
// ======================
@@ -53,7 +53,6 @@ export default function Header(props: HeaderProps) {
`fixed top-0 w-full z-10`,
].join(' ')}>
<div className={[
``,
`transition-[background, shadow] duration-200 ease-in-out`,
menu
? `bg-[#fffe] backdrop-blur-sm`
@@ -65,13 +64,13 @@ export default function Header(props: HeaderProps) {
<div className="flex justify-between gap-8">
{/* logo */}
<Link href="/public" className={`flex items-center`}>
<Image src={logo} alt={`logo`} className={`w-16 max-md:w-12 h-16 max-md:h-12 rounded-full`} />
<Image src={logo} alt={`logo`} className={`w-16 max-md:w-12 h-16 max-md:h-12 rounded-full`}/>
</Link>
{/* 菜单 */}
<nav>
<ul className="h-full flex items-stretch max-lg:hidden">
<LinkItem text={`首页`} href={`/`} />
<LinkItem text={`首页`} href={`/`}/>
<MenuItem
text={`产品订购`}
active={menu && page === 0}
@@ -106,9 +105,9 @@ export default function Header(props: HeaderProps) {
}}
/>
<LinkItem
text={`企业服务`} href={`#`} />
text={`企业服务`} href={`#`}/>
<LinkItem
text={`推广返利`} href={`#`} />
text={`推广返利`} href={`#`}/>
</ul>
</nav>
</div>
@@ -156,4 +155,3 @@ export default function Header(props: HeaderProps) {

View File

@@ -4,10 +4,6 @@
@custom-variant dark (&:is(.dark *));
body {
color: hsl(0, 0%, 10%);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
@@ -124,3 +120,7 @@ body {
@apply bg-background text-foreground;
}
}
body {
color: hsl(0, 0%, 10%);
}

View File

@@ -2,6 +2,11 @@ import {ReactNode} from 'react'
import {Metadata} from 'next'
import './globals.css'
import localFont from 'next/font/local'
import {Toaster} from '@/components/ui/sonner'
const font = localFont({
src: './NotoSansSC-VariableFont_wght.ttf',
})
export const metadata: Metadata = {
title: 'Create Next App',
@@ -15,8 +20,9 @@ export default function RootLayout({
}>) {
return (
<html lang="zh-Cn">
<body className={`bg-blue-50`}>
<body className={`${font.className} bg-blue-50`}>
{children}
<Toaster position={'top-center'}/>
</body>
</html>
)

12
src/app/test/route.ts Normal file
View File

@@ -0,0 +1,12 @@
import {NextRequest, NextResponse} from 'next/server'
import {cookies} from 'next/headers'
export async function GET(req: NextRequest) {
const store = await cookies()
store.set('test','test')
return NextResponse.json(JSON.stringify({
'test': 'value',
}))
}

View File

@@ -1,64 +1,35 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from 'react'
import {Slot} from '@radix-ui/react-slot'
import {merge} from '@/lib/utils'
import { cn } from "@/lib/utils"
type ButtonProps = React.ComponentProps<'button'> & {
variant?: 'default' | 'outline' | 'gradient'
}
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 " +
"whitespace-nowrap rounded-md text-sm transition-all " +
"disabled:pointer-events-none disabled:opacity-50 " +
"[&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 " +
"outline-none 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",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
function Button(rawProps: ButtonProps) {
const {className, variant, ...props} = rawProps
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
<button
className={merge(
`transition-all duration-200 ease-in-out`,
`h-10 px-4 rounded-md cursor-pointer`,
'whitespace-nowrap',
'inline-flex items-center justify-center gap-2',
'disabled:pointer-events-none disabled:opacity-50 ',
'[&_svg]:pointer-events-none [&_svg:not([class*=\'size-\'])]:size-4 shrink-0 [&_svg]:shrink-0 ',
'outline-none focus-visible:ring-4 ring-blue-200',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
{
gradient: 'bg-gradient-to-r from-blue-400 to-cyan-300 text-white ring-offset-2',
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
outline: 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
}[variant ?? 'default'],
className,
)}
{...props}
/>
)
}
export { Button, buttonVariants }
export {Button}

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { merge } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={merge(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={merge(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-[data-slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={merge("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={merge("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={merge(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={merge("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={merge("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -4,7 +4,7 @@ import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { merge } from "@/lib/utils"
function Checkbox({
className,
@@ -13,7 +13,7 @@ function Checkbox({
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
className={merge(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}

View File

@@ -0,0 +1,135 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { merge } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={merge(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={merge(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={merge("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={merge(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={merge("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={merge("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -13,7 +13,7 @@ import {
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { merge } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
@@ -80,7 +80,7 @@ function FormItem({ className, ...props }: React.ComponentProps<"div">) {
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
className={merge("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
@@ -97,7 +97,7 @@ function FormLabel({
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
className={merge("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
@@ -129,7 +129,7 @@ function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
className={merge("text-muted-foreground text-sm", className)}
{...props}
/>
)
@@ -147,7 +147,7 @@ function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
className={merge("text-destructive text-sm", className)}
{...props}
>
{body}

View File

@@ -1,21 +1,27 @@
import * as React from "react"
import * as React from 'react'
import { cn } from "@/lib/utils"
import {merge} from '@/lib/utils'
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
function Input({className, type, ...props}: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex 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: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",
className
className={merge(
`transition-[color,box-shadow] duration-200 ease-in-out`,
`h-10 min-w-0 w-full`,
' placeholder:text-muted-foreground',
'selection:bg-primary selection:text-primary-foreground',
'flex rounded-md border bg-transparent px-3 py-1 text-base shadow-xs',
'outline-none focus-visible:ring-4 ring-blue-200',
'disabled:cursor-not-allowed disabled:opacity-50',
'aria-invalid:ring-destructive/20 aria-invalid:border-destructive dark:aria-invalid:ring-destructive/40 dark:bg-input/30',
'file:text-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:disabled:pointer-events-none',
className,
)}
{...props}
/>
)
}
export { Input }
export {Input}

View File

@@ -3,7 +3,7 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
import { merge } from "@/lib/utils"
function Label({
className,
@@ -12,8 +12,8 @@ function Label({
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className={merge(
"flex items-center gap-2 leading-none select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}

View File

@@ -4,7 +4,7 @@ import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { merge } from "@/lib/utils"
function RadioGroup({
className,
@@ -13,7 +13,7 @@ function RadioGroup({
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
className={merge("grid gap-3", className)}
{...props}
/>
)
@@ -26,7 +26,7 @@ function RadioGroupItem({
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
className={merge(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}

View File

@@ -4,7 +4,7 @@ import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { merge } from "@/lib/utils"
function Select({
...props
@@ -36,7 +36,7 @@ function SelectTrigger({
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
className={merge(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
@@ -60,7 +60,7 @@ function SelectContent({
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
className={merge(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
@@ -71,7 +71,7 @@ function SelectContent({
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
className={merge(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
@@ -92,7 +92,7 @@ function SelectLabel({
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-sm", className)}
className={merge("px-2 py-1.5 text-sm", className)}
{...props}
/>
)
@@ -106,7 +106,7 @@ function SelectItem({
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
className={merge(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
@@ -129,7 +129,7 @@ function SelectSeparator({
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
className={merge("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
@@ -142,7 +142,7 @@ function SelectScrollUpButton({
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
className={merge(
"flex cursor-default items-center justify-center py-1",
className
)}
@@ -160,7 +160,7 @@ function SelectScrollDownButton({
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
className={merge(
"flex cursor-default items-center justify-center py-1",
className
)}

View File

@@ -0,0 +1,25 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

135
src/lib/api.ts Normal file
View File

@@ -0,0 +1,135 @@
// API工具函数
// 定义后端服务URL和OAuth2配置
const API_BASE_URL = process.env.API_BASE_URL
const CLIENT_ID = process.env.CLIENT_ID
const CLIENT_SECRET = process.env.CLIENT_SECRET
// OAuth令牌缓存
interface TokenCache {
token: string
expires: number // 过期时间戳
}
let tokenCache: TokenCache | null = null
// 获取OAuth2访问令牌
export async function getAccessToken(forceRefresh = false): Promise<string> {
try {
// 检查缓存的令牌是否可用
if (!forceRefresh && tokenCache && tokenCache.expires > Date.now()) {
return tokenCache.token
}
const addr = `http://${API_BASE_URL}/api/auth/token`
const body = {
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
grant_type: 'client_credentials',
}
const response = await fetch(addr, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
if (!response.ok) {
throw new Error(`OAuth token request failed: ${response.status} ${await response.text()}`)
}
const data = await response.json()
// 缓存令牌和过期时间
// 通常后端会返回expires_in秒为单位
tokenCache = {
token: data.access_token,
expires: Date.now() + data.expires_in * 1000,
}
return tokenCache.token
}
catch (error) {
console.error('Failed to get access token:', error)
throw new Error('认证服务暂时不可用')
}
}
// 通用的API调用函数
export async function call<R = undefined>(endpoint: string, data: unknown): Promise<ApiResponse<R>> {
try {
// 发送请求
let accessToken = getAccessToken()
const requestOptions = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${await accessToken}`,
},
body: JSON.stringify(data),
}
let response = await fetch(`http://${API_BASE_URL}${endpoint}`, requestOptions)
// 如果返回401未授权尝试刷新令牌并重试一次
if (response.status === 401) {
accessToken = getAccessToken(true) // 强制刷新令牌
// 使用新令牌重试请求
requestOptions.headers['Authorization'] = `Bearer ${await accessToken}`
response = await fetch(`http://${API_BASE_URL}${endpoint}`, requestOptions)
}
// 解析响应数据
const type = response.headers.get('Content-Type') ?? 'text/plain'
if (type.indexOf('application/json') !== -1) {
const json = await response.json()
if (!response.ok) {
console.log('响应不成功', `status=${response.status}`, json)
return {
success: false,
status: response.status,
message: json.message || '请求失败',
}
}
return {
success: true,
data: json,
}
}
else if (type.indexOf('text/plain') !== -1) {
const text = await response.text()
if (!response.ok) {
console.log('响应不成功', `status=${response.status}`, text)
return {
success: false,
status: response.status,
message: text || '请求失败',
}
}
return {
success: true,
data: undefined as unknown as R, // 强转类型,考虑优化
}
}
else {
throw new Error(`无法解析响应数据,未处理的 Content-Type: ${type}`)
}
}
catch (e) {
console.error('API call failed:', e)
throw new Error('服务调用失败', {cause: e})
}
}
// 统一的API响应类型
export type ApiResponse<T = undefined> = {
success: false
status: number
message: string
} | {
success: true
data: T
}

View File

@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import {ClassNameValue, twMerge} from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
export function merge(...inputs: ClassNameValue[]) {
return twMerge(inputs)
}