diff --git a/.gitignore b/.gitignore index 3284e2e..948f45b 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ next-env.d.ts # editor .idea/ +.vscode/ \ No newline at end of file diff --git a/README.md b/README.md index fe0604e..3ac25e2 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ ## TODO -首页页头菜单导航的 bug +客户端令牌保存到缓存中 diff --git a/eslint.config.mjs b/eslint.config.mjs index 76ed475..3c31c93 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -15,6 +15,7 @@ const eslintConfig = [ rules: { '@typescript-eslint/no-empty-object-type': 'off', '@typescript-eslint/no-unused-vars': 'off', + 'semi': ['error', 'never'], }, }, ]; diff --git a/next.config.ts b/next.config.ts index e9ffa30..3b84f68 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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 diff --git a/package.json b/package.json index eb8af66..8947085 100644 --- a/package.json +++ b/package.json @@ -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" + ] + } } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 51337c2..3c71989 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/actions/auth/login.ts b/src/actions/auth/login.ts new file mode 100644 index 0000000..313b4fa --- /dev/null +++ b/src/actions/auth/login.ts @@ -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 { + try { + // 尝试登录 + const result = await call('/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}) + } +} diff --git a/src/actions/auth/verify.ts b/src/actions/auth/verify.ts new file mode 100644 index 0000000..d1c052f --- /dev/null +++ b/src/actions/auth/verify.ts @@ -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 { + 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 { + 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 +} diff --git a/src/app/(auth)/captcha/route.ts b/src/app/(auth)/captcha/route.ts new file mode 100644 index 0000000..bfd29e7 --- /dev/null +++ b/src/app/(auth)/captcha/route.ts @@ -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}`, + }, + }) +} diff --git a/src/app/(auth)/login/captcha.tsx b/src/app/(auth)/login/captcha.tsx new file mode 100644 index 0000000..f00833c --- /dev/null +++ b/src/app/(auth)/login/captcha.tsx @@ -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 +} + +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 ( + + + + 请完成图形验证 + +
+
+ 验证码 + +
+ setCaptchaCode(e.target.value)} + className="w-full" + /> +
+ + + + +
+
+ ) +} diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 125f02b..541d3c2 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -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 - 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(undefined) + + const form = useForm({ + 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) => { + + } + + // 处理表单提交 + 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 ( - -
+
+ {`logo`} {/* 登录表单 */} -
-
-
-

- 登录/注册 -

-
+ + + 登录/注册 + -
-
-
- - + + + + ( + + 手机号码 + + + + + + )} + /> + + ( + + 验证码 +
+ + + + +
+ +
+ )} + /> + + ( + + + + +
+ 保持登录 +
+
+ )} + /> + +
+ + +

+ 登录即表示您同意《用户协议》《隐私政策》 +

+ + +
+ -
- -
- - -
-
-
+ {/* 图形验证码弹窗 */} + -
-
- - -
-
- -
- - -

- 登录即表示您同意《用户协议》《隐私政策》 -

-
- -
-
) } diff --git a/src/app/(root)/@header/page.tsx b/src/app/(root)/@header/page.tsx index adc757c..0687456 100644 --- a/src/app/(root)/@header/page.tsx +++ b/src/app/(root)/@header/page.tsx @@ -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(() => [ - , - , - , + , + , + , ], []) // ====================== @@ -53,7 +53,6 @@ export default function Header(props: HeaderProps) { `fixed top-0 w-full z-10`, ].join(' ')}>
{/* logo */} - {`logo`} + {`logo`} {/* 菜单 */}
@@ -156,4 +155,3 @@ export default function Header(props: HeaderProps) { - diff --git a/src/app/SourceHanSansSC-VF.otf.woff2 b/src/app/NotoSansSC-VariableFont_wght.ttf similarity index 54% rename from src/app/SourceHanSansSC-VF.otf.woff2 rename to src/app/NotoSansSC-VariableFont_wght.ttf index b1d8881..4e0c62e 100644 Binary files a/src/app/SourceHanSansSC-VF.otf.woff2 and b/src/app/NotoSansSC-VariableFont_wght.ttf differ diff --git a/src/app/globals.css b/src/app/globals.css index d2549f7..2bf68c3 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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%); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 8a018e5..f7e31d6 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 ( - + {children} + ) diff --git a/src/app/test/route.ts b/src/app/test/route.ts new file mode 100644 index 0000000..11e1a9f --- /dev/null +++ b/src/app/test/route.ts @@ -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', + })) +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 278bdfc..9f4945c 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -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 & { - asChild?: boolean - }) { - const Comp = asChild ? Slot : "button" +function Button(rawProps: ButtonProps) { + const {className, variant, ...props} = rawProps return ( - ) } -export { Button, buttonVariants } +export {Button} diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..c3cc7b4 --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react" + +import { merge } from "@/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx index fa0e4b5..6364831 100644 --- a/src/components/ui/checkbox.tsx +++ b/src/components/ui/checkbox.tsx @@ -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 ( ) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + {children} + + + Close + + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx index 524b986..121f2aa 100644 --- a/src/components/ui/form.tsx +++ b/src/components/ui/form.tsx @@ -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">) {
@@ -97,7 +97,7 @@ function FormLabel({