feat: 增加了响应式

This commit is contained in:
liuyonghe0111 2025-12-11 19:31:56 +08:00
parent 4f028ed72b
commit 506e702040
102 changed files with 2008 additions and 5284 deletions

12
.env
View File

@ -1,20 +1,16 @@
NEXT_PUBLIC_AUTH_API_URL=https://localhost:3000/api/mock
NEXT_PUBLIC_FROG_API_URL=https://test-frog.crushlevel.ai
NEXT_PUBLIC_FROG_API_URL=http://35.82.37.117:8082/frog
NEXT_PUBLIC_BEAR_API_URL=https://test-bear.crushlevel.ai
NEXT_PUBLIC_LION_API_URL=https://test-lion.crushlevel.ai
NEXT_PUBLIC_SHARK_API_URL=https://test-shark.crushlevel.ai
NEXT_PUBLIC_COW_API_URL=https://test-cow.crushlevel.ai
NEXT_PUBLIC_PIGEON_API_URL=https://test-pigeon.crushlevel.ai
# 自建服务
NEXT_PUBLIC_EDIT_API_URL=http://54.223.196.180
NEXT_PUBLIC_CHAT_API_URL=http://54.223.196.180
# A18 服务
NEXT_PUBLIC_EDITOR_API_URL=http://35.82.37.117
# 三方登录
NEXT_PUBLIC_DISCORD_CLIENT_ID=1396735872459866233
# yunxin IM
NEXT_PUBLIC_NIM_APP_KEY=2d6abc076f434fc52320c7118de5fead
NEXT_PUBLIC_DISCORD_CLIENT_ID=1448143535609217076
# S3
NEXT_PUBLIC_S3_URI=https://hhb.crushlevel.ai

View File

@ -1,5 +1,5 @@
{
"semi": false,
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",

View File

@ -38,6 +38,7 @@
"@tanstack/react-query-devtools": "^5.83.0",
"@types/crypto-js": "^4.2.2",
"@types/react-stickynode": "^4.0.3",
"ahooks": "^3.9.6",
"axios": "^1.10.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -61,6 +62,7 @@
"react-stickynode": "^5.0.2",
"react-virtuoso": "^4.17.0",
"sonner": "^2.0.6",
"stream-chat": "^9.27.0",
"swiper": "^12.0.3",
"tailwind-merge": "^3.3.1",
"vaul": "^1.1.2",

View File

@ -83,6 +83,9 @@ importers:
'@types/react-stickynode':
specifier: ^4.0.3
version: 4.0.3
ahooks:
specifier: ^3.9.6
version: 3.9.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
axios:
specifier: ^1.10.0
version: 1.10.0
@ -152,6 +155,9 @@ importers:
sonner:
specifier: ^2.0.6
version: 2.0.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
stream-chat:
specifier: ^9.27.0
version: 9.27.0
swiper:
specifier: ^12.0.3
version: 12.0.3
@ -1927,6 +1933,12 @@ packages:
'@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
'@types/jsonwebtoken@9.0.10':
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/node@20.19.8':
resolution: {integrity: sha512-HzbgCY53T6bfu4tT7Aq3TvViJyHjLjPNaAS3HOuMc9pw97KHsUtXNX4L+wu59g1WnjsZSko35MbEqnO58rihhw==}
@ -1956,6 +1968,9 @@ packages:
'@types/uuid@9.0.8':
resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@typescript-eslint/eslint-plugin@8.49.0':
resolution: {integrity: sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -2163,6 +2178,12 @@ packages:
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
engines: {node: '>=0.8'}
ahooks@3.9.6:
resolution: {integrity: sha512-Mr7f05swd5SmKlR9SZo5U6M0LsL4ErweLzpdgXjA1JPmnZ78Vr6wzx0jUtvoxrcqGKYnX0Yjc02iEASVxHFPjQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
ajv@6.12.6:
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
@ -2249,6 +2270,9 @@ packages:
axios@1.10.0:
resolution: {integrity: sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==}
axios@1.13.2:
resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==}
axobject-query@4.1.0:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'}
@ -2300,6 +2324,9 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
@ -2492,6 +2519,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
electron-to-chromium@1.5.267:
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
@ -2969,6 +2999,10 @@ packages:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
intersection-observer@0.12.2:
resolution: {integrity: sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==}
deprecated: The Intersection Observer polyfill is no longer needed and can safely be removed. Intersection Observer has been Baseline since 2019.
is-array-buffer@3.0.5:
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
engines: {node: '>= 0.4'}
@ -3104,6 +3138,11 @@ packages:
resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==}
engines: {node: '>=0.10.0'}
isomorphic-ws@5.0.0:
resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==}
peerDependencies:
ws: '*'
iterator.prototype@1.1.5:
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
engines: {node: '>= 0.4'}
@ -3164,10 +3203,20 @@ packages:
engines: {node: '>=6'}
hasBin: true
jsonwebtoken@9.0.3:
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
engines: {node: '>=12', npm: '>=6'}
jsx-ast-utils@3.3.5:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
jwa@2.0.1:
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
jws@4.0.1:
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@ -3257,13 +3306,37 @@ packages:
resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==}
engines: {node: '>= 12.0.0'}
linkifyjs@4.3.2:
resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==}
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
lodash.includes@4.3.0:
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
lodash.isboolean@3.0.3:
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
lodash.isinteger@4.0.4:
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
lodash.isnumber@3.0.3:
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
lodash.isstring@4.0.1:
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
lodash.once@4.1.1:
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
@ -3622,6 +3695,9 @@ packages:
react: '>=16.4.0'
react-dom: '>=16.4.0'
react-fast-compare@3.2.2:
resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==}
react-hook-form@7.60.0:
resolution: {integrity: sha512-SBrYOvMbDB7cV8ZfNpaiLcgjH/a1c7aK0lK+aNigpf4xWLO8q+o4tcvVurv3c4EOyzn/3dCsYt4GKD42VvJ/+A==}
engines: {node: '>=18.0.0'}
@ -3717,6 +3793,9 @@ packages:
requires-port@1.0.0:
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
resize-observer-polyfill@1.5.1:
resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@ -3768,6 +3847,10 @@ packages:
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
screenfull@5.2.0:
resolution: {integrity: sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==}
engines: {node: '>=0.10.0'}
semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
@ -3861,6 +3944,10 @@ packages:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
stream-chat@9.27.0:
resolution: {integrity: sha512-MG1Jo0eu1Z7W/wkytsGZlqvwh1YEACxAr/momrfd+g0rJA/wQ6vyC42edm91p47AVICL2c4IBK3CSVDGKOOoJQ==}
engines: {node: '>=18'}
stream-composer@1.0.2:
resolution: {integrity: sha512-bnBselmwfX5K10AH6L4c8+S5lgZMWI7ZYrz2rvYjCPB2DIMC4Ig8OpxGpNJSxRZ58oti7y1IcNvjBAz9vW5m4w==}
@ -4207,6 +4294,18 @@ packages:
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
ws@8.18.3:
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
xlsx@0.18.5:
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
engines: {node: '>=0.8'}
@ -6319,6 +6418,13 @@ snapshots:
'@types/json5@0.0.29': {}
'@types/jsonwebtoken@9.0.10':
dependencies:
'@types/ms': 2.1.0
'@types/node': 20.19.8
'@types/ms@2.1.0': {}
'@types/node@20.19.8':
dependencies:
undici-types: 6.21.0
@ -6345,6 +6451,10 @@ snapshots:
'@types/uuid@9.0.8': {}
'@types/ws@8.18.1':
dependencies:
'@types/node': 20.19.8
'@typescript-eslint/eslint-plugin@8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3)':
dependencies:
'@eslint-community/regexpp': 4.12.1
@ -6537,6 +6647,21 @@ snapshots:
adler-32@1.3.1: {}
ahooks@3.9.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
dependencies:
'@babel/runtime': 7.28.4
'@types/js-cookie': 3.0.6
dayjs: 1.11.13
intersection-observer: 0.12.2
js-cookie: 3.0.5
lodash: 4.17.21
react: 19.2.1
react-dom: 19.2.1(react@19.2.1)
react-fast-compare: 3.2.2
resize-observer-polyfill: 1.5.1
screenfull: 5.2.0
tslib: 2.8.1
ajv@6.12.6:
dependencies:
fast-deep-equal: 3.1.3
@ -6656,6 +6781,14 @@ snapshots:
transitivePeerDependencies:
- debug
axios@1.13.2:
dependencies:
follow-redirects: 1.15.9
form-data: 4.0.4
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
axobject-query@4.1.0: {}
b4a@1.7.3: {}
@ -6697,6 +6830,8 @@ snapshots:
node-releases: 2.0.27
update-browserslist-db: 1.2.2(browserslist@4.28.1)
buffer-equal-constant-time@1.0.1: {}
buffer@6.0.3:
dependencies:
base64-js: 1.5.1
@ -6865,6 +7000,10 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
ecdsa-sig-formatter@1.0.11:
dependencies:
safe-buffer: 5.2.1
electron-to-chromium@1.5.267: {}
embla-carousel-react@8.6.0(react@19.2.1):
@ -7528,6 +7667,8 @@ snapshots:
hasown: 2.0.2
side-channel: 1.1.0
intersection-observer@0.12.2: {}
is-array-buffer@3.0.5:
dependencies:
call-bind: 1.0.8
@ -7659,6 +7800,10 @@ snapshots:
isobject@3.0.1: {}
isomorphic-ws@5.0.0(ws@8.18.3):
dependencies:
ws: 8.18.3
iterator.prototype@1.1.5:
dependencies:
define-data-property: 1.1.4
@ -7699,6 +7844,19 @@ snapshots:
json5@2.2.3: {}
jsonwebtoken@9.0.3:
dependencies:
jws: 4.0.1
lodash.includes: 4.3.0
lodash.isboolean: 3.0.3
lodash.isinteger: 4.0.4
lodash.isnumber: 3.0.3
lodash.isplainobject: 4.0.6
lodash.isstring: 4.0.1
lodash.once: 4.1.1
ms: 2.1.3
semver: 7.7.3
jsx-ast-utils@3.3.5:
dependencies:
array-includes: 3.1.9
@ -7706,6 +7864,17 @@ snapshots:
object.assign: 4.1.7
object.values: 1.2.1
jwa@2.0.1:
dependencies:
buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1
jws@4.0.1:
dependencies:
jwa: 2.0.1
safe-buffer: 5.2.1
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@ -7774,12 +7943,28 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.30.1
lightningcss-win32-x64-msvc: 1.30.1
linkifyjs@4.3.2: {}
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
lodash.includes@4.3.0: {}
lodash.isboolean@3.0.3: {}
lodash.isinteger@4.0.4: {}
lodash.isnumber@3.0.3: {}
lodash.isplainobject@4.0.6: {}
lodash.isstring@4.0.1: {}
lodash.merge@4.6.2: {}
lodash.once@4.1.1: {}
lodash@4.17.21: {}
loose-envify@1.4.0:
@ -8075,6 +8260,8 @@ snapshots:
react-dom: 19.2.1(react@19.2.1)
tslib: 2.8.1
react-fast-compare@3.2.2: {}
react-hook-form@7.60.0(react@19.2.1):
dependencies:
react: 19.2.1
@ -8175,6 +8362,8 @@ snapshots:
requires-port@1.0.0: {}
resize-observer-polyfill@1.5.1: {}
resolve-from@4.0.0: {}
resolve-options@2.0.0:
@ -8228,6 +8417,8 @@ snapshots:
scheduler@0.27.0: {}
screenfull@5.2.0: {}
semver@6.3.1: {}
semver@7.7.3: {}
@ -8352,6 +8543,22 @@ snapshots:
es-errors: 1.3.0
internal-slot: 1.1.0
stream-chat@9.27.0:
dependencies:
'@types/jsonwebtoken': 9.0.10
'@types/ws': 8.18.1
axios: 1.13.2
base64-js: 1.5.1
form-data: 4.0.4
isomorphic-ws: 5.0.0(ws@8.18.3)
jsonwebtoken: 9.0.3
linkifyjs: 4.3.2
ws: 8.18.3
transitivePeerDependencies:
- bufferutil
- debug
- utf-8-validate
stream-composer@1.0.2:
dependencies:
streamx: 2.23.0
@ -8832,6 +9039,8 @@ snapshots:
wrappy@1.0.2: {}
ws@8.18.3: {}
xlsx@0.18.5:
dependencies:
adler-32: 1.3.1

View File

@ -0,0 +1,7 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 25C11.7909 25 10 23.2091 10 21" stroke="#505473" stroke-width="2" stroke-linecap="round"/>
<path d="M34 25C36.2091 25 38 26.7909 38 29" stroke="#505473" stroke-width="2" stroke-linecap="round"/>
<path d="M24.0001 12C30.6275 12 36.0001 16.9249 36.0001 23C36.0001 28.3086 31.8977 32.738 26.4406 33.7715L24.8663 36.5C24.4814 37.1667 23.5188 37.1667 23.1339 36.5L21.5587 33.7715C16.102 32.7376 12.0001 28.3082 12.0001 23C12.0001 16.9249 17.3727 12 24.0001 12Z" fill="#505473"/>
<circle cx="20" cy="22" r="2" fill="#7D82AA"/>
<circle cx="28" cy="22" r="2" fill="#7D82AA"/>
</svg>

After

Width:  |  Height:  |  Size: 684 B

View File

@ -0,0 +1,22 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 24C0 10.7452 10.7452 0 24 0C37.2548 0 48 10.7452 48 24C48 37.2548 37.2548 48 24 48C10.7452 48 0 37.2548 0 24Z" fill="#B9ECFF" fill-opacity="0.08"/>
<path d="M14 25C11.7909 25 10 23.2091 10 21" stroke="url(#paint0_linear_157_16887)" stroke-width="2" stroke-linecap="round"/>
<path d="M34 25C36.2091 25 38 26.7909 38 29" stroke="url(#paint1_linear_157_16887)" stroke-width="2" stroke-linecap="round"/>
<path d="M24 12C30.6274 12 36 16.9249 36 23C36 28.3086 31.8976 32.738 26.4404 33.7715L24.8662 36.5C24.4813 37.1667 23.5187 37.1667 23.1338 36.5L21.5586 33.7715C16.1019 32.7376 12 28.3082 12 23C12 16.9249 17.3726 12 24 12Z" fill="url(#paint2_linear_157_16887)"/>
<circle cx="20" cy="22" r="2" fill="white"/>
<circle cx="28" cy="22" r="2" fill="white"/>
<defs>
<linearGradient id="paint0_linear_157_16887" x1="12" y1="21" x2="12" y2="25" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFD051"/>
<stop offset="1" stop-color="#4F3CFF"/>
</linearGradient>
<linearGradient id="paint1_linear_157_16887" x1="36" y1="29" x2="36" y2="25" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFD051"/>
<stop offset="1" stop-color="#4F3CFF"/>
</linearGradient>
<linearGradient id="paint2_linear_157_16887" x1="35.9989" y1="12" x2="23.1959" y2="36.5818" gradientUnits="userSpaceOnUse">
<stop offset="0.00534234" stop-color="#F157FF"/>
<stop offset="1" stop-color="#3337FF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,6 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M38.0001 24C38.0001 27.3137 31.7321 30 24.0001 30C16.2682 30 10.0001 27.3137 10.0001 24" stroke="#505473" stroke-width="2" stroke-linecap="round"/>
<circle cx="24.0001" cy="24" r="12" fill="#505473"/>
<path d="M19.0001 19L21.0001 21L19.0001 23" stroke="#7D82AA" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M29.0001 19L27.0001 21L29.0001 23" stroke="#7D82AA" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 567 B

View File

@ -0,0 +1,17 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 24C0 10.7452 10.7452 0 24 0C37.2548 0 48 10.7452 48 24C48 37.2548 37.2548 48 24 48C10.7452 48 0 37.2548 0 24Z" fill="#B9ECFF" fill-opacity="0.08"/>
<path d="M38.0001 24C38.0001 27.3137 31.7321 30 24.0001 30C16.2682 30 10.0001 27.3137 10.0001 24" stroke="url(#paint0_linear_157_16931)" stroke-width="2" stroke-linecap="round"/>
<circle cx="24.0001" cy="24" r="12" fill="url(#paint1_linear_157_16931)"/>
<path d="M19.0001 19L21.0001 21L19.0001 23" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M29.0001 19L27.0001 21L29.0001 23" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<defs>
<linearGradient id="paint0_linear_157_16931" x1="24.0001" y1="24" x2="24.0001" y2="30" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFD051"/>
<stop offset="1" stop-color="#4F3CFF"/>
</linearGradient>
<linearGradient id="paint1_linear_157_16931" x1="35.9991" y1="12" x2="23.9991" y2="36" gradientUnits="userSpaceOnUse">
<stop offset="0.00534234" stop-color="#F157FF"/>
<stop offset="1" stop-color="#3337FF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,6 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M30 11C30 14.3137 27.3137 17 24 17C20.6863 17 18 14.3137 18 11" stroke="#505473" stroke-width="2" stroke-linecap="round"/>
<path d="M24.75 34C25.9926 34 27 35.0074 27 36.25C27 36.6642 26.6642 37 26.25 37H21.75C21.3358 37 21 36.6642 21 36.25C21 35.0074 22.0074 34 23.25 34H24.75ZM24 12C30.0751 12 35 16.9249 35 23C35 29.0751 30.0751 34 24 34C17.9249 34 13 29.0751 13 23C13 16.9249 17.9249 12 24 12Z" fill="#505473"/>
<path d="M19 23L21 21.5L19 20" stroke="#7D82AA" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M28 23L28 20" stroke="#7D82AA" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 724 B

View File

@ -0,0 +1,17 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 24C0 10.7452 10.7452 0 24 0C37.2548 0 48 10.7452 48 24C48 37.2548 37.2548 48 24 48C10.7452 48 0 37.2548 0 24Z" fill="#B9ECFF" fill-opacity="0.08"/>
<path d="M30 11C30 14.3137 27.3137 17 24 17C20.6863 17 18 14.3137 18 11" stroke="url(#paint0_linear_157_16875)" stroke-width="2" stroke-linecap="round"/>
<path d="M24.75 34C25.9926 34 27 35.0074 27 36.25C27 36.6642 26.6642 37 26.25 37H21.75C21.3358 37 21 36.6642 21 36.25C21 35.0074 22.0074 34 23.25 34H24.75ZM24 12C30.0751 12 35 16.9249 35 23C35 29.0751 30.0751 34 24 34C17.9249 34 13 29.0751 13 23C13 16.9249 17.9249 12 24 12Z" fill="url(#paint1_linear_157_16875)"/>
<path d="M19 23L21 21.5L19 20" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M28 23L28 20" stroke="white" stroke-width="2" stroke-linecap="round"/>
<defs>
<linearGradient id="paint0_linear_157_16875" x1="24" y1="17" x2="24" y2="11" gradientUnits="userSpaceOnUse">
<stop stop-color="#4F3CFF"/>
<stop offset="1" stop-color="#FFD051"/>
</linearGradient>
<linearGradient id="paint1_linear_157_16875" x1="34.999" y1="12" x2="21.5765" y2="35.6236" gradientUnits="userSpaceOnUse">
<stop offset="0.00534234" stop-color="#F157FF"/>
<stop offset="1" stop-color="#3337FF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,7 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="22.667" cy="22.667" r="12" fill="#505473"/>
<path d="M17 20V23" stroke="#7D82AA" stroke-width="2" stroke-linecap="round"/>
<path d="M22 20V23" stroke="#7D82AA" stroke-width="2" stroke-linecap="round"/>
<path d="M22 29C24.2091 29 26 27.2091 26 25" stroke="#7D82AA" stroke-width="2" stroke-linecap="round"/>
<path d="M30.667 30.667L34.667 34.667" stroke="#505473" stroke-width="2.66667" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 525 B

View File

@ -0,0 +1,13 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 24C0 10.7452 10.7452 0 24 0C37.2548 0 48 10.7452 48 24C48 37.2548 37.2548 48 24 48C10.7452 48 0 37.2548 0 24Z" fill="#B9ECFF" fill-opacity="0.08"/>
<path d="M22.667 10.667C29.2944 10.667 34.667 16.0396 34.667 22.667C34.667 25.5004 33.6823 28.1024 32.04 30.1553L35.6094 33.7246C36.1301 34.2453 36.1301 35.0887 35.6094 35.6094C35.0887 36.1301 34.2453 36.1301 33.7246 35.6094L30.1553 32.04C28.1024 33.6823 25.5004 34.667 22.667 34.667C16.0396 34.667 10.667 29.2944 10.667 22.667C10.667 16.0396 16.0396 10.667 22.667 10.667Z" fill="url(#paint0_linear_157_16836)"/>
<path d="M17 20V23" stroke="white" stroke-width="2" stroke-linecap="round"/>
<path d="M22 20V23" stroke="white" stroke-width="2" stroke-linecap="round"/>
<path d="M22 29C24.2091 29 26 27.2091 26 25" stroke="white" stroke-width="2" stroke-linecap="round"/>
<defs>
<linearGradient id="paint0_linear_157_16836" x1="35.9988" y1="10.667" x2="23.3323" y2="35.9999" gradientUnits="userSpaceOnUse">
<stop offset="0.00534234" stop-color="#F157FF"/>
<stop offset="1" stop-color="#3337FF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -1,8 +1,18 @@
'use client';
import { usePolicyLayout } from '../layout';
import { useEffect } from 'react';
const AboutPage = () => {
const { setTitle } = usePolicyLayout();
useEffect(() => {
setTitle('Crushlevel About');
}, [setTitle]);
return (
<div className="relative flex size-full flex-col items-center justify-start">
<div className="relative flex min-h-px w-full min-w-px shrink-0 grow content-stretch items-start justify-center gap-1 px-6 pt-28 pb-0 md:px-12">
<div className="max-w-[752px]">
<div className="relative flex h-full size-full flex-col items-center justify-start">
<div className="relative flex h-full w-full min-w-px shrink-0 grow content-stretch items-start justify-center gap-1 px-6 pt-6 pb-0 md:px-12">
<div className="h-full max-w-[752px]">
<div className="relative w-full pb-[27.26%]">
<img
src="/images/about/banner.png"
@ -31,7 +41,7 @@ const AboutPage = () => {
</div>
</div>
</div>
)
}
);
};
export default AboutPage
export default AboutPage;

View File

@ -1,11 +1,78 @@
import { ReactNode } from 'react'
'use client';
const AuthLayout = ({ children }: { children: ReactNode }) => {
return (
<div className="relative flex items-center justify-center bg-[url('/common-bg.png')] bg-cover bg-fixed bg-top bg-no-repeat">
<div className="min-h-screen w-full">{children}</div>
</div>
)
import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
import { IconButton } from '@/components/ui/button';
import { useRouter } from 'next/navigation';
const PolicyLayoutContext = createContext<{
setTitle: (title: string) => void;
} | null>(null);
export const usePolicyLayout = () => {
const context = useContext(PolicyLayoutContext);
if (!context) {
throw new Error('usePolicyLayout must be used within PolicyLayout');
}
return context;
};
export default AuthLayout
const PolicyLayout = ({ children }: { children: React.ReactNode }) => {
const [isBlur, setIsBlur] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const [title, setTitle] = useState('');
const router = useRouter();
useEffect(() => {
function handleScroll(event: Event) {
const dom = event.target as HTMLElement;
setIsBlur(dom.scrollTop > 0);
}
if (containerRef.current) {
containerRef.current.addEventListener('scroll', handleScroll, { passive: true });
}
return () => {
if (containerRef.current) {
containerRef.current.removeEventListener('scroll', handleScroll);
}
};
}, []);
return (
<PolicyLayoutContext.Provider value={{ setTitle }}>
<div className="flex h-screen overflow-hidden bg-[url('/common-bg.png')] bg-cover bg-fixed bg-top bg-no-repeat">
<div className="relative flex min-w-0 flex-1 flex-col">
<header
className={cn(
'absolute z-40 flex h-16 w-full items-center justify-between px-8 transition-all',
{
'backdrop-blur-[10px]': isBlur,
}
)}
>
{isBlur && <div className="bg-background-default absolute inset-0 opacity-85" />}
<div className="relative inset-0 flex w-full items-center justify-between">
<div className="items-center justify-between inset-0 h-16 flex">
<IconButton
variant="ghost"
size="large"
onClick={() => router.back()}
iconfont="icon-arrow-left"
/>
<div className="txt-title-m">{title}</div>
<div></div>
</div>
</div>
</header>
<main ref={containerRef} className="overflow-auto flex-1">
<div className="min-h-full pt-16 w-full justify-center bg-[url('/common-bg.png')] bg-cover bg-fixed bg-top bg-no-repeat">
{children}
</div>
</main>
</div>
</div>
</PolicyLayoutContext.Provider>
);
};
export default PolicyLayout;

View File

@ -1,12 +1,22 @@
'use client';
import { usePolicyLayout } from '../../layout';
import { useEffect } from 'react';
export default function PrivacyPolicyPage() {
const { setTitle } = usePolicyLayout();
useEffect(() => {
setTitle('Privacy Policy');
}, [setTitle]);
return (
<div className="relative flex size-full min-h-screen flex-col items-center justify-start">
<div className="relative flex min-h-px w-full max-w-[1232px] min-w-px shrink-0 grow items-start justify-center gap-1 px-6 pt-28 pb-0 md:px-12">
<div className="relative flex min-h-px w-full max-w-[1232px] min-w-px shrink-0 grow items-start justify-center gap-1 px-6 pt-6 pb-0 md:px-12">
<div className="relative flex min-h-px w-full max-w-[752px] min-w-px shrink-0 grow flex-col items-center justify-start gap-12">
{/* 主标题 */}
<div className="txt-headline-s w-full text-center text-white">
{/* <div className="txt-headline-s w-full text-center text-white">
<p className="whitespace-pre-wrap">Crushlevel Privacy Policy</p>
</div>
</div> */}
{/* 前言 */}
<div className="txt-body-l w-full text-white">
@ -273,5 +283,5 @@ export default function PrivacyPolicyPage() {
</div>
</div>
</div>
)
);
}

View File

@ -1,92 +0,0 @@
Crushlevel Privacy Policy
Welcome to the Crushlevel application ("this App") and related website (Crushlevel.ai, "this Website"). We recognize the importance of your personal information and are committed to protecting your privacy rights and information security. This Privacy Policy ("Policy") explains the principles and practices we follow when collecting, using, storing, and safeguarding your personal information. Please read this Policy carefully before using our services. By registering or using our services, you acknowledge full acceptance of this Policy. For any inquiries, contact us through the provided channels.
1\. Scope of Application
This Policy applies to all personal information processing activities (including collection, use, storage, and protection) during your use of this App and Website. We comply with applicable laws in your country/region and international data protection standards.
2\. Collection of Personal Information
a) Registration Information
When registering an account, we collect your mobile number or email address to create your account, facilitate login, and deliver service notifications.
b) Service Usage Information
Virtual Character Creation: Descriptions you provide for AI characters (excluding non-personal information).
Chat Interactions: Text, images, voice messages, and other content exchanged with AI characters to enable service functionality, store chat history, and optimize features.
Feature Engagement: Records related to relationship upgrades, unlocked features, and rewards to monitor service usage and ensure performance.
c) Payment Information
For transactions (pay-per-message, subscriptions, virtual currency purchases), we collect payment methods, amounts, and timestamps to complete transactions and deliver paid services.
d) Device & Log Information
To ensure service stability and security, we may collect device model, OS version, IP address, browser type, timestamps, and access records.
3\. Use of Personal Information
We use your personal information to:
a) Provide services (account management, character creation, chat interactions, paid features, etc.).
b) Optimize services by analyzing usage patterns to enhance functionality.
c) Conduct promotional activities (with your consent or where permitted by law), without disclosing sensitive data.
d) Troubleshoot issues, maintain service integrity, and protect your rights.
4\. Storage of Personal Information
a) Your data is stored on secure servers with robust technical/administrative measures to prevent loss, leakage, tampering, or misuse.
b) Retention periods align with service needs and legal requirements. Post-expiry, data is deleted or anonymized.
c) Data is primarily stored in your country/region. Cross-border transfers (if any) comply with applicable laws and implement safeguards (e.g., standard contractual clauses).
5\. Protection of Personal Information
a) We implement strict security protocols, including encryption and access controls, to prevent unauthorized access or disclosure.
b) Only authorized personnel bound by confidentiality obligations may access your data.
c) In case of a data breach, we will take remedial actions and notify you/regulators as required by law.
6\. Sharing, Transfer, and Disclosure
a) We do not share your data with third parties without your explicit consent, except where mandated by law or to protect public/personal interests.
b) We do not transfer your data unless: (i) you consent; or (ii) during corporate restructuring (mergers, acquisitions), where recipients must adhere to this Policy.
c) Public disclosure occurs only when legally required or requested by authorities, with efforts to minimize exposure.
7\. Your Rights
a) Access & Correction: Request to view or correct inaccurate/incomplete data.
b) Deletion: Request deletion where legally permissible or upon service termination.
c) Consent Withdrawal: Withdraw consent for data processing (note: may affect service functionality).
d) Account Deactivation: Deactivate your account; data will be processed per relevant policies.
8\. Minor Protection
Users under 18 must obtain parental/guardian consent before using our services. We prioritize minors data protection. Parents/guardians may contact us to review or delete minors data improperly collected. We strictly comply with minor protection laws in your jurisdiction.
9\. Policy Updates
We may revise this Policy due to legal changes or operational needs. Revised versions will be published on this App/Website and take effect after the notice period. Continued use constitutes acceptance of changes. Revisions will comply with local legal requirements.
10\. Contact Us
For questions or to exercise your rights, contact us via provided channels. We will respond within a reasonable timeframe.
Thank you for trusting Crushlevel. We strive to protect your information security!
\
\

View File

@ -1,131 +0,0 @@
# **Crushlevel Recharge Service Agreement**
October 2025
## **Preamble**
Welcome to use the recharge-related services of "Crushlevel"!
This Recharge Service Agreement (hereinafter referred to as "this Agreement") is entered into between you and the operator of Crushlevel (hereinafter referred to as the "Platform") and/or its affiliates (hereinafter referred to as the "Company"). The Platform shall provide services to you in accordance with the provisions of this Agreement and the operating rules issued from time to time (hereinafter referred to as the "Services"). For the purpose of providing better services to users, you, as the service user (i.e., the account user who places an order to purchase the Platform's virtual currency, hereinafter referred to as "you"), shall carefully read and fully understand this Agreement before starting to use the Services. Among them, clauses that exempt or limit the Platform's liability, dispute resolution methods, jurisdiction and other important contents will be highlighted in **bold** to draw your attention, and you shall focus on reading these parts. If you do not agree to this Agreement, please do not take any further actions (including but not limited to clicking the operation buttons such as purchasing virtual currency, making payments) or use the Services.
Minors are prohibited from using the recharge services. The Platform hereby kindly reminds that if you are the guardian of a minor, you shall assume guardianship responsibilities for the minor under your guardianship. When the minor uses the relevant products and services of this Platform, you shall enable the youth mode and/or other minor protection tools, supervise and guide the minor to use the relevant products and services correctly, and at the same time strengthen the restriction and management of online payment methods to jointly create a sound environment for the healthy growth of minors. This Agreement also complies with the provisions on the protection of minors in the U.S. Children's Online Privacy Protection Act (COPPA) and the European General Data Protection Regulation (GDPR) to ensure that the rights and interests of minors are not infringed.
## **I. Service Content**
### **1.1 Definition and Purpose of Virtual Currency**
The virtual currency provided by the Platform to you (hereinafter referred to as "Virtual Currency") is a virtual tool limited to relevant consumption within the Crushlevel Platform. It is not a token, legal tender or advance payment certificate, and does not have the circulation and advance payment value of legal tender. After purchasing the Virtual Currency, you may, in accordance with the instructions and guidelines on the relevant pages of the Platform, use it for the following consumption scenarios, including but not limited to:
- Paid chat with AI virtual characters;
- Unlocking pictures related to AI virtual characters;
- Purchasing "Affection Points" to increase the interaction level with AI virtual characters;
- Recharging for Platform membership to enjoy exclusive membership benefits;
- Sending virtual gifts to AI virtual characters;
- Unlocking more different types of virtual lovers (AI virtual characters).
### **1.2 Restrictions on the Use of Virtual Currency**
After purchasing the Virtual Currency, you may only use it for the consumption scenarios stipulated in Clause 1.1 of this Agreement. You shall not use it beyond the scope of products/services provided by the Company, nor transfer, trade, sell or gift it between different Crushlevel accounts.
### **1.3 Official Purchase Channels**
You shall purchase the Virtual Currency through the official channels designated by the Platform, including but not limited to the Platform's official website, official mobile application (APP) and third-party payment cooperation channels authorized by the Platform. The Platform does not recognize any third-party channels not authorized by the Company (such as unofficial purchasing agents, private transactions, etc.). If you purchase the Virtual Currency through unauthorized channels, the Platform cannot guarantee that such Virtual Currency can be successfully credited to your account. Moreover, such acts may be accompanied by risks such as fraud, money laundering and account theft, causing irreparable losses or damages to you, the Platform and relevant third parties. Therefore, purchasing through unauthorized channels shall be deemed as a violation. The Platform has the right to deduct or clear the Virtual Currency in your account, restrict all or part of the functions of your account, or temporarily or permanently ban your account. You shall bear all losses caused thereby; if your violation of the aforementioned provisions causes losses to the Platform or other third parties, you shall be liable for full compensation.
### **1.4 Fee Collection and Channel Differences**
The fees for your purchase of the Virtual Currency shall be collected by the Company or a cooperating party designated by the Company. The Platform specially reminds you that relevant service providers of different purchase channels (such as third-party payment institutions, app stores, etc.) may charge channel service fees when you make payments in accordance with their own operating strategies. This may result in differences in the amount of fees required to purchase the same amount of Virtual Currency through different channels, or differences in the amount of Virtual Currency that can be purchased with the same amount of fees. The specific details shall be subject to the page display when you purchase the Virtual Currency. Please carefully confirm the relevant page information (including but not limited to price, quantity, service fee description, etc.) and choose the Virtual Currency purchase channel reasonably.
### **1.5 Provisions on Proxy Recharge Services**
The Platform does not provide any proxy recharge services. If you intend to purchase the Virtual Currency for another person's account, you shall confirm the identity and will of the account user by yourself. Any disputes arising from proxy recharge (including but not limited to the account user denying receipt of the Virtual Currency, requesting a refund, etc.) shall be resolved through negotiation between you and the account user. The Platform shall not bear any liability to you or the account user in this regard.
## **II. Rational Consumption**
### **2.1 Advocacy of Rational Consumption**
The Platform advocates rational consumption and spending within one's means. You must purchase and use the Virtual Currency and relevant services reasonably according to your own consumption capacity and actual needs to avoid excessive consumption. When the amount of Virtual Currency you purchase is relatively large or the purchase frequency is abnormal, the Platform has the right to remind you of rational consumption through pop-up prompts, SMS notifications, etc. You shall attach importance to such reminders and make prudent decisions.
### **2.2 Requirements for the Legitimacy of Funds**
The funds you use to purchase the Virtual Currency shall be legally obtained and you shall have the right to use such funds (in compliance with relevant laws, regulations and tax provisions); if you violate the provisions of this Clause, any disputes or controversies arising therefrom (including but not limited to account freezing, tax penalties due to illegal source of funds, etc.) shall be resolved by yourself and you shall bear all legal consequences. If your acts cause losses to the Platform or third parties, you shall also make full compensation. If the Platform discovers (including but not limited to active discovery, receipt of third-party complaints, notifications from regulatory authorities or judicial organs, etc.) that you are suspected of violating the aforementioned provisions, the Platform has the right to deduct or clear the Virtual Currency in your account, restrict all or part of the functions of your account, or even permanently ban your account; at the same time, the Platform has the right to keep relevant information and report to relevant regulatory authorities and judicial organs.
### **2.3 Resistance to Irregular Consumption Behaviors**
The Platform strictly resists behaviors that induce, stimulate or incite users to consume irrationally (including but not limited to excessive recharge, frequent purchase of virtual gifts, etc.) and behaviors that induce or instigate minors to recharge with false identity information. If you discover the aforementioned irregular behaviors, you may report to the Platform through the publicized channels of the Platform (such as the official customer service email, the report entrance in the APP, etc.). The Platform will take disciplinary measures in accordance with laws and regulations (including but not limited to warning the irregular account, restricting the account functions, banning the account, etc.). We look forward to working with you to build a healthy and orderly Platform ecosystem.
## **III. Your Rights and Obligations**
### **3.1 Obligation of Authenticity of Information and Cooperation in Investigations**
The personal information or materials you provide in the process of using the Services (including but not limited to name, email, payment account information, etc.) shall be true, accurate and complete, and shall comply with the requirements of relevant laws and regulations on personal information protection, such as the U.S. Fair Credit Reporting Act (FCRA) and the European General Data Protection Regulation (GDPR). If laws, regulations or regulatory authorities require you to cooperate in investigations, you shall provide relevant materials and assist in the investigations in accordance with the Platform's requirements.
### **3.2 Responsibility for Purchase Operations**
When purchasing the Virtual Currency, you shall carefully select and/or enter key information such as your account information (e.g., account ID, bound email/mobile phone number) and the quantity of Virtual Currency to be purchased. If due to factors such as your own input errors, improper operations, insufficient understanding of the charging method or failure to confirm the purchase information, there are purchase errors such as wrong account, wrong quantity of Virtual Currency, repeated purchases, etc., resulting in your losses or additional expenses, the Platform has the right not to make compensation or indemnification.
### **3.3 Responsibility for Account Safekeeping**
You shall properly keep your Crushlevel account (including account ID, password, bound email/mobile phone number and verification code, etc.) and be responsible for all operation behaviors and consequences under this account. If the Platform is unable to provide the Services or makes errors in providing the Services due to the following circumstances of yours, resulting in your losses, the Platform shall not bear legal liability unless otherwise explicitly required by laws and regulations:
- Your account becomes invalid, lost, stolen or banned;
- The third-party payment institution account or bank account bound to your account is frozen, sealed up or has other abnormalities, or you use an uncertified account or an account that does not belong to you;
- You disclose your account password to others or allow others to log in and use your account in other ways;
- Other circumstances where you have intent or gross negligence (such as failure to update account security settings in a timely manner, ignoring account abnormal login reminders, etc.).
### **3.4 Obligation of Compliant Use**
You shall use the Services in a legal and compliant manner, and shall not use the Services for any purposes that are illegal or criminal, violate public order and good customs, harm social ethics (in line with the standards of public order and good customs in the United States and Europe), interfere with the normal operation of the Platform or infringe the legitimate rights and interests of third parties. Your use of the Services shall also not violate any documents or other requirements that are binding on you (if any). The Platform specially reminds you not to lend, transfer or provide your account to others for use in other ways, and to reasonably prevent others from committing acts that violate the aforementioned provisions through your account, so as to protect the security of your account and property.
### **3.5 Specifications for Minor Refund Services**
The Platform provides minor consumption refund services in accordance with laws and regulations to protect the legitimate rights and interests of minors and their guardians (in compliance with the provisions on the protection of minor consumption in the U.S. COPPA and the European GDPR); you shall not use this service for illegal purposes or in improper ways, including but not limited to adults pretending to be minors to defraud refunds, inducing minors to consume and then applying for refunds, etc. The aforementioned acts shall constitute a serious violation of this Agreement. After reasonable confirmation, the Platform has the right to refuse the refund and reserve the right to further pursue your legal liability in accordance with the law (including but not limited to reporting to regulatory authorities, filing a lawsuit, etc.).
### **3.6 Provisions on Third-Party Services**
If the use of the Services involves relevant services provided by third parties (such as payment services, third-party login services, etc.), in addition to complying with the provisions of this Agreement, you shall also agree to and comply with the service agreements and relevant rules of such third parties. Under no circumstances shall any disputes arising from such third parties and their provided relevant services (including but not limited to payment failures, account security issues, etc.) be resolved by you and the third party on your own. The Platform shall not bear any liability to you or the third party in this regard.
## **IV. Rights and Obligations of the Platform**
### **4.1 Right to Adjust Service Rules**
Based on factors such as revisions to laws and regulations, requirements of regulatory authorities in the United States and Europe, transaction security guarantees, updates to operating strategies, and changes in market environment, the Platform has the right to set relevant restrictions and reminders on the Virtual Currency services from time to time, including but not limited to restricting the transaction limit and/or transaction frequency of all or part of the users, prohibiting specific users from using the Services, and adding transaction verification steps (such as identity verification, SMS verification, etc.). The Platform will notify you of the aforementioned adjustments through reasonable methods such as APP pop-ups, official website announcements, and email notifications. If you do not agree to the adjustments, you may stop using the Services; if you continue to use the Services, it shall be deemed that you agree to such adjustments.
### **4.2 Right to Risk Monitoring and Account Management**
To ensure transaction security and the stability of the Platform ecosystem, the Platform has the right to monitor your use of the Services (in compliance with relevant laws and regulations on data security and privacy protection in the United States and Europe). For users or accounts that are reasonably identified as high-risk (including but not limited to those suspected of money laundering, fraud, abnormal account login, large-scale purchase of Virtual Currency followed by rapid consumption, etc.), the Platform may take necessary measures to prevent the expansion of risks and protect the property of users and the ecological security of the Platform. Such necessary measures include deducting or clearing the Virtual Currency in your account, restricting all or part of the functions of your account, or temporarily or permanently banning your account. Before taking the aforementioned measures, the Platform will notify you through reasonable methods as much as possible, unless it is impossible to notify due to emergency situations (such as suspected illegal crimes requiring immediate handling).
### **4.3 Right to Correct Errors**
When the Platform discovers errors in the processing of Virtual Currency (including but not limited to errors in the quantity of Virtual Currency issued or deducted) caused by system failures, network problems, human operation errors or any other reasons, whether the error is beneficial to the Platform or you, the Platform has the right to correct the error. In this case, if the actual quantity of Virtual Currency you receive is less than the quantity you should receive, the Platform will make up the difference to your account as soon as possible after confirming the processing error; if the actual quantity of Virtual Currency you receive is more than the quantity you should receive, the Platform has the right to directly deduct the difference from your account without prior notice. If the Virtual Currency in your account is insufficient to offset the difference, the Platform has the right to require you to make up the difference. You shall fulfill this obligation within the reasonable time limit notified by the Platform; otherwise, the Platform has the right to take measures such as restricting account functions and banning the account.
### **4.4 Right to Change, Suspend or Terminate Services**
The Platform has the right to change, interrupt, suspend or terminate the Services based on specific circumstances such as transaction security, operation plans, national laws and regulations or the requirements of regulatory authorities in the United States and Europe. If the Platform decides to change, interrupt, suspend or terminate the Services, it will notify you in advance through reasonable methods such as APP pop-ups, official website announcements, and email notifications (except for emergency situations such as force majeure and sudden system failures where advance notification is impossible), and handle the unused Virtual Currency balance in your account (excluding the membership recharge amount; for the refund rules of membership recharge amount, please refer to Chapter V of this Agreement) in accordance with the provisions of this Agreement. The Platform shall not bear any tort liability to you due to the change, interruption, suspension or termination of the Services for the aforementioned reasons, unless otherwise stipulated by laws and regulations.
## **V. Refund Rules**
### **5.1 Restrictions on Refunds After Consumption of Virtual Currency**
After you use the Virtual Currency for consumption (including but not limited to paid chat, unlocking pictures, purchasing Affection Points, sending virtual gifts, unlocking virtual lovers, etc.), since the Virtual Currency has been converted into the corresponding services or rights provided by the Platform, and the services related to AI virtual characters are instantaneous and irreversible, **the Platform does not provide refund services for this part of the Virtual Currency**. You shall carefully confirm your consumption needs before consumption.
## **VI. Disclaimer**
### **6.1 Provision of Services in Current State and Risk Warning**
You understand and agree that the Services are provided in accordance with the current state achievable under existing technologies and conditions. The Platform will make its best efforts to provide the Services to you and ensure the security and stability of the Services. However, you also know and acknowledge that the Platform cannot foresee and prevent technical and other risks at all times or at all times, including but not limited to service interruptions, delays, errors or data loss caused by force majeure (such as natural disasters, wars, public health emergencies, etc.), network reasons (such as network congestion, hacker attacks, server failures, etc.), third-party service defects (such as failures of third-party payment institutions, changes in app store policies, etc.), revisions to laws and regulations or adjustments to regulatory policies, etc. In the event of such circumstances, the Platform will make its best commercial efforts to improve the situation, but shall not be obligated to bear any legal liability to you or other third parties, unless such losses are caused by the intentional acts or gross negligence of the Platform.
### **6.2 Disclaimer for System Maintenance and Upgrades**
The Platform may conduct downtime maintenance, system upgrades and function adjustments on its own. If you are unable to use the Services normally due to this, the Platform will notify you of the maintenance/upgrade time and the scope of impact in advance through reasonable methods (except for emergency maintenance), and you agree that the Platform shall not bear legal liability for this. Any losses caused by your attempt to use the Services during the maintenance/upgrade period shall be borne by yourself.
### **6.3 Limitation of Liability**
Under no circumstances shall the Platform be liable for any indirect, punitive, incidental or special damages (including but not limited to loss of profits, loss of expected benefits, loss of data, etc.). Moreover, the total liability of the Platform to you, regardless of the cause or manner (including but not limited to breach of contract, tort, etc.), shall not exceed the total amount of fees you actually paid for using the recharge services.
## **VII. Liability for Breach of Contract**
### **7.1 Handling of Your Breach of Contract**
If you violate any provisions of this Agreement (including but not limited to purchasing Virtual Currency through unauthorized channels, using funds from
(注:文档部分内容可能由 AI 生成)

View File

@ -1,12 +1,22 @@
'use client';
import { useEffect } from 'react';
import { usePolicyLayout } from '../../layout';
export default function TermsOfServicePage() {
const { setTitle } = usePolicyLayout();
useEffect(() => {
setTitle('Crushlevel User Agreement');
}, [setTitle]);
return (
<div className="relative flex size-full min-h-screen flex-col items-center justify-start">
<div className="relative flex min-h-px w-full max-w-[1232px] min-w-px shrink-0 grow items-start justify-center gap-1 px-6 pt-28 pb-0 md:px-12">
<div className="relative flex min-h-px w-full max-w-[1232px] min-w-px shrink-0 grow items-start justify-center gap-1 px-6 pt-6 pb-0 md:px-12">
<div className="relative flex min-h-px w-full max-w-[752px] min-w-px shrink-0 grow flex-col items-center justify-start gap-12">
{/* 主标题 */}
<div className="txt-headline-s w-full text-center text-white">
{/* <div className="txt-headline-s w-full text-center text-white">
<p className="whitespace-pre-wrap">Crushlevel User Agreement</p>
</div>
</div> */}
{/* 前言 */}
<div className="txt-body-l w-full text-white">
@ -419,5 +429,5 @@ export default function TermsOfServicePage() {
</div>
</div>
</div>
)
);
}

View File

@ -1,125 +0,0 @@
Crushlevel User Agreement
Welcome to the Crushlevel application (hereinafter referred to as "this App") and related website (Crushlevel.ai, hereinafter referred to as "this Website"). This User Agreement (hereinafter referred to as "this Agreement") is a legally binding agreement between you (hereinafter referred to as the "User") and the operator of Crushlevel (hereinafter referred to as "We," "Us," or "Our") regarding your use of this App and this Website. Before registering for or using this App and this Website, please read this Agreement carefully and understand its contents in full. If you have any questions regarding this Agreement, you should consult Us. If you do not agree to any part of this Agreement, you should immediately cease registration or use of this App and this Website. Once you register for or use this App and this Website, it means that you have fully understood and agreed to all the terms of this Agreement.
Article 1: User Eligibility
You declare and warrant that at the time of registering an account for this App and this Website, you are at least 18 years old, possess full civil rights capacity and civil capacity for conduct, and are able to independently bear civil liability. If you are under 18 years old, you should read this Agreement under the supervision of your legal guardian and only use this App and this Website with the consent of your legal guardian.
You shall ensure that the registration information provided is true, accurate, and complete, and promptly update your registration information to ensure its validity. If, due to registration information provided by you being untrue, inaccurate, incomplete, or not updated in a timely manner, We are unable to provide you with corresponding services or any other losses arise, you shall bear full responsibility.
Each user may register only one account. Registering multiple accounts in any form, including but not limited to using different identity information, phone numbers, etc., is strictly prohibited. If We discover that you have registered multiple accounts, We have the right to restrict, freeze, or terminate such accounts without any liability to you.
Article 2: Account Management
You are responsible for the security of your account and password and shall not disclose your account and password to any third party. If your account and password are used illegally by others due to your own reasons, you shall bear all consequences arising therefrom, and We assume no liability.
If you discover that your account and password are being used illegally by others or that other security risks exist, you shall immediately notify Us and take corresponding security measures. Upon receiving your notice, We will take reasonable measures based on the actual circumstances, but We assume no responsibility for the outcome of such measures.
Without Our prior written consent, you may not transfer, gift, lease, or sell your account to any third party. If you violate this provision, you shall bear all consequences arising therefrom, and We have the right to restrict, freeze, or terminate the relevant account(s).
Article 3: Service Content and Usage Norms
(1) Service Content
You can create AI virtual characters ("Characters") on this App and this Website. Created Characters fall into two categories: Original and Derivative (based on existing fictional works).
Other users can chat with the AI virtual Characters you create. Chat methods include text, images, voice, etc.
Chatting with Characters allows users to level up their relationship with the Character, unlocking related features and rewards.
(2) Usage Norms
When using this App and this Website, you shall comply with applicable laws and regulations, public order and good morals, and the provisions of this Agreement. You may not use this App and this Website to engage in any illegal or non-compliant activities.
The AI virtual Characters you create and the content you publish during chats (including but not limited to text, images, voice, etc.) must not contain the following:
(1) Content that violates laws and regulations, such as content endangering national security, undermining ethnic unity, promoting terrorism, extremism, obscenity, pornography, gambling, etc.;
(2) Content that infringes upon the lawful rights and interests of others, such as infringing upon others' portrait rights, reputation rights, privacy rights, intellectual property rights, etc.;
(3) Content that is false, fraudulent, or misleading;
(4) Content that insults, slanders, intimidates, or harasses others;
(5) Other content that violates public order, good morals, or the provisions of this Agreement.
You may not use this App and this Website to engage in any form of network attacks, virus dissemination, spam distribution, or other activities that disrupt the normal operation of this App and this Website.
You shall respect the lawful rights and interests of other users and must not maliciously harass or attack other users or infringe upon other users' private information.
Article 4: Intellectual Property Rights
We own all intellectual property rights in this App and this Website, including but not limited to copyrights, trademarks, patents, trade secrets, etc. All content of this App and this Website, including but not limited to text, images, audio, video, software, programs, interface design, etc., is protected by laws and regulations.
The intellectual property rights in the AI virtual Characters you create and the content you publish on this App and this Website belong to you. However, you grant Us a worldwide, royalty-free, non-exclusive, transferable, and sub-licensable license to use such content for the operation, promotion, marketing, and related activities of this App and this Website.
When creating Derivative AI virtual Characters, you shall ensure that the Character does not infringe upon the intellectual property rights of the original work. If any dispute arises due to your creation of a Derivative Character infringing upon others' intellectual property rights, you shall bear full responsibility. If losses are caused to Us, you shall compensate Us accordingly.
Without Our prior written permission, you may not use, copy, modify, disseminate, or display any intellectual property content of this App and this Website.
Article 5: Payments and Transactions
(1) Chat Payments
Users have a daily limit on free chats with virtual Characters. The specific number of free chats is subject to the actual display within this App and this Website.
After the free quota is exhausted, users need to pay per message to continue chatting with virtual Characters. Specific payment standards are subject to the actual display within this App and this Website.
(2) Creation Payments
Creators must pay corresponding fees to create Characters or generate derivative AI images. Fees can be paid via membership subscription or virtual currency.
The specific content, pricing, and validity period of membership services are subject to the actual display within this App and this Website. After purchasing membership, the member benefits will be effective for the corresponding validity period.
Virtual currency is a type of virtual item within this App and this Website, obtainable by users through purchase using fiat currency. The purchase price of virtual currency is subject to the actual display within this App and this Website. Virtual currency may not be exchanged for fiat currency or transferred/gifted to other users.
(3) Transaction Rules
Before making any payment, users should carefully confirm the payment information, including but not limited to the amount, content, and payment method. Once payment is successfully completed, no refunds will be issued unless otherwise stipulated by applicable law or as mutually agreed upon by both parties.
If payment failure or incorrect payment amount occurs due to force majeure such as system failures or network issues, We will handle the situation accordingly upon verification, including but not limited to refunding or supplementing payment.
We reserve the right to adjust payment standards, membership service content, virtual currency prices, etc., based on market conditions and business development needs. Adjusted content will be announced on this App and this Website and will become effective after the announcement.
Article 6: Privacy Protection
We value user privacy protection and will collect, use, store, and protect your personal information in accordance with the provisions of the ["Privacy Policy"](insert link here). The "Privacy Policy" is an integral part of this Agreement and has the same legal effect as this Agreement.
You should read the "Privacy Policy" carefully to understand how We process personal information. If you do not agree to any part of the "Privacy Policy," you should immediately stop using this App and this Website.
Article 7: Disclaimer
The services of this App and this Website are provided according to the level of technology and conditions currently available. We will make every effort to ensure the stability and security of the services, but We cannot guarantee that services will be uninterrupted, timely, secure, or error-free. We shall not be liable for any service interruption or malfunction caused by force majeure or third-party reasons.
Any losses or risks incurred by you during your use of this App and this Website resulting from the use of third-party services or links shall be borne solely by you, and We assume no liability.
You shall bear full responsibility for any losses or legal liabilities arising from your violation of the provisions of this Agreement or applicable laws and regulations. If losses are caused to Us or other users, you shall compensate accordingly.
We assume no responsibility for the content on this App and this Website. Regarding the AI virtual Characters created by users and the content published, We only provide a platform service and do not assume responsibility for the authenticity, legality, or accuracy of such content.
Article 8: Agreement Modification and Termination
We reserve the right to modify and update this Agreement based on changes in laws and regulations and business development needs. The modified agreement will be announced on this App and this Website and will become effective after the announcement period. If you object to the modified agreement, you should immediately stop using this App and this Website. If you continue to use this App and this Website, it means you have accepted the modified agreement.
If you violate the provisions of this Agreement, We have the right, based on the severity of the violation, to take actions such as warning you, restricting features, freezing, or terminating your account, and reserve the right to pursue your legal liability.
You may apply to Us to deregister your account at any time. After account deregistration, you will no longer be able to use the services of this App and this Website, and your relevant information will be processed in accordance with the "Privacy Policy."
If due to legal provisions, government requirements, or other force majeure events this App and this Website cannot continue to provide services, We have the right to terminate this Agreement and will notify you within a reasonable period.
Article 9: Governing Law and Dispute Resolution
The conclusion, validity, interpretation, performance, and dispute resolution of this Agreement shall be governed by the laws of the jurisdiction where the user registered their account. If the laws of the registration jurisdiction contain no relevant provisions, internationally accepted commercial practices shall apply.
Any dispute arising from or in connection with this Agreement shall first be resolved through friendly negotiation between the parties. If no settlement is reached through negotiation, either party shall have the right to file a lawsuit with the competent people's court in the location where We are based.
Article 10: Miscellaneous
This Agreement constitutes the entire agreement between you and Us regarding the use of this App and this Website, superseding any prior agreements or understandings, whether oral or written, concerning the subject matter hereof.
If any term of this Agreement is found to be invalid or unenforceable, it shall not affect the validity of the remaining terms.
We reserve the right of final interpretation of this Agreement. If you have any questions during your use of this App and this Website, you may contact Us through the contact methods provided within this App and this Website.
Users are required to carefully read and strictly comply with the above agreement. Thank you for your support and trust in Crushlevel. We hope you enjoy using it!

View File

@ -1,14 +1,14 @@
'use client'
'use client';
import { IconButton } from '@/components/ui/button'
import Input from './Input'
import MessageList from './MessageList'
import { useChatStore } from './store'
import Sider from './Sider'
import { IconButton } from '@/components/ui/button';
import Input from './Input';
import MessageList from './MessageList';
import { useChatStore } from './store';
import Sider from './Sider';
export default function ChatPage() {
const isSidebarOpen = useChatStore((store) => store.isSidebarOpen)
const setIsSidebarOpen = useChatStore((store) => store.setIsSidebarOpen)
const isSidebarOpen = useChatStore((store) => store.isSidebarOpen);
const setIsSidebarOpen = useChatStore((store) => store.setIsSidebarOpen);
return (
<div className="flex h-full">
@ -29,5 +29,5 @@ export default function ChatPage() {
{isSidebarOpen && <Sider />}
</div>
)
);
}

View File

@ -0,0 +1,9 @@
'use client';
export default function ChatPage() {
return (
<div>
<h1>Chat</h1>
</div>
);
}

View File

@ -1,11 +1,43 @@
'use client'
'use client';
import { InfiniteScrollList } from '@/components/ui/infinite-scroll-list';
import AIStandardCard from '@/components/features/ai-standard-card';
import useSmartInfiniteQuery from '../../useSmartInfiniteQuery';
import { fetchCharacters } from '@/services/editor';
import { useHomeStore } from '../../store';
import { useEffect } from 'react';
const Character = () => {
return (
<div>
<div className="h-1000">Character</div>
</div>
)
}
const selectedTags = useHomeStore((state) => state.selectedTags);
export default Character
const { dataSource, isFirstLoading, isLoadingMore, noMoreData, onLoadMore, onSearch } =
useSmartInfiniteQuery<any, any>(fetchCharacters, {
queryKey: 'characters',
defaultQuery: { tagIds: selectedTags },
});
useEffect(() => {
onSearch({ tagIds: selectedTags });
}, [selectedTags]);
return (
<div className="mt-8">
<InfiniteScrollList<any>
items={dataSource}
columns={{
xs: 2,
sm: 3,
md: 4,
lg: 5,
xl: 6,
}}
renderItem={(character) => <AIStandardCard character={character} />}
getItemKey={(character) => character.id}
hasNextPage={!noMoreData}
isLoading={isFirstLoading || isLoadingMore}
fetchNextPage={onLoadMore}
/>
</div>
);
};
export default Character;

View File

@ -1,25 +1,28 @@
'use client'
'use client';
import { cn } from '@/lib/utils'
import Image from 'next/image'
import { Chip } from '@/components/ui/chip'
import { useHomeStore } from '../store'
import { useQuery } from '@tanstack/react-query'
import { fetchTags } from '@/services/editor'
import { cn } from '@/lib/utils';
import Image from 'next/image';
import { Chip } from '@/components/ui/chip';
import { useHomeStore } from '../store';
import { useQuery } from '@tanstack/react-query';
import { fetchCharacterTags } from '@/services/editor';
const Filter = () => {
const tab = useHomeStore((state) => state.tab)
const setTab = useHomeStore((state) => state.setTab)
const selectedTags = useHomeStore((state) => state.selectedTags)
const setSelectedTags = useHomeStore((state) => state.setSelectedTags)
const tab = useHomeStore((state) => state.tab);
const setTab = useHomeStore((state) => state.setTab);
const selectedTags = useHomeStore((state) => state.selectedTags);
const setSelectedTags = useHomeStore((state) => state.setSelectedTags);
const { data: tags = [] } = useQuery({
queryKey: ['tags'],
queryKey: ['tags', tab],
queryFn: async () => {
const { data } = await fetchTags({})
console.log('data', data)
return data.rows
if (tab === 'character') {
const { data } = await fetchCharacterTags({ limit: 10 });
return data.rows;
}
return [];
},
})
});
const tabs = [
{
@ -34,13 +37,13 @@ const Filter = () => {
icon: 'icon-character',
activeIcon: 'icon-character-active',
},
] as const
] as const;
return (
<div className="sticky top-20 z-10 bg-bg-primary-normal">
<div className="sticky top-0 z-10">
<div className="flex mb-6 gap-12">
{tabs.map((item) => {
const active = tab === item.value
const active = tab === item.value;
return (
<div
key={item.value}
@ -58,24 +61,24 @@ const Filter = () => {
/>
<span className="txt-headline-s">{item.label}</span>
</div>
)
);
})}
</div>
<div className="flex gap-2">
{tags?.map((tag) => (
<div className="flex flex-wrap gap-2">
{tags?.map((tag: any) => (
<Chip
key={tag.value}
key={tag.id}
size="small"
className="px-4"
state={selectedTags.includes(tag.value) ? 'active' : 'inactive'}
onClick={() => setSelectedTags([tag.value])}
state={selectedTags.includes(tag.id) ? 'active' : 'inactive'}
onClick={() => setSelectedTags([tag.id])}
>
# {tag.label}
# {tag.name}
</Chip>
))}
</div>
</div>
)
}
);
};
export default Filter
export default Filter;

View File

@ -1,21 +1,23 @@
'use client'
'use client';
import Image from 'next/image'
import { IconButton } from '@/components/ui/button'
import Link from 'next/link'
import React from 'react'
import Image from 'next/image';
import { IconButton } from '@/components/ui/button';
import Link from 'next/link';
import React from 'react';
import { useMedia } from '@/hooks/tools';
const Header = React.memo(() => {
const response = useMedia();
return (
<div
className="flex items-center justify-center flex-col lg:flex-row"
className="flex items-center justify-center"
style={{
backgroundImage: 'url(/images/home/bg-star.png)',
backgroundSize: 'contain',
backgroundPosition: 'center',
}}
>
<div className="flex-1 pt-12 pb-8 text-center lg:text-left">
<div className="flex-1 pt-12 pb-8 text-center">
<Image
src="/images/home/left-star.png"
className="h-12 w-12 object-cover"
@ -57,9 +59,11 @@ const Header = React.memo(() => {
</div>
</Link>
</div>
{response?.sm && (
<Image src="/images/home/banner-header.png" alt="banner-header" width={400} height={400} />
)}
</div>
)
})
);
});
export default Header
export default Header;

View File

@ -1,23 +1,16 @@
'use client'
'use client';
import { useState } from 'react'
import Link from 'next/link'
import Image from 'next/image'
import { useMainLayout } from '@/context/mainLayout'
import { cn } from '@/lib/utils'
import useCreatorNavigation from '@/hooks/useCreatorNavigation'
import { useState } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { cn } from '@/lib/utils';
const HomePageFooter = () => {
const [isExpanded, setIsExpanded] = useState(false)
const { isSidebarExpanded } = useMainLayout()
const { routerToCreate } = useCreatorNavigation()
// 根据侧边栏状态计算左侧边距
const leftMargin = isSidebarExpanded ? 'ml-80' : 'ml-20'
const [isExpanded, setIsExpanded] = useState(false);
return (
<footer
className={`fixed right-0 bottom-0 left-0 z-40 transition-all duration-300 ease-in-out ${leftMargin}`}
className={`absolute bottom-0 w-full z-40 transition-all duration-300 ease-in-out`}
onMouseEnter={() => setIsExpanded(true)}
onMouseLeave={() => setIsExpanded(false)}
>
@ -49,12 +42,6 @@ const HomePageFooter = () => {
<div>
<h3 className="txt-title-s mb-4">Features</h3>
<div className="space-y-3">
<div
onClick={routerToCreate}
className="txt-label-m text-txt-secondary-normal hover:text-txt-primary-normal block cursor-pointer transition-colors"
>
Create a Character
</div>
<Link
href="/wallet"
className="txt-label-m text-txt-secondary-normal hover:text-txt-primary-normal block transition-colors"
@ -67,12 +54,6 @@ const HomePageFooter = () => {
>
CrushLevel VIP
</Link>
<Link
href="/leaderboard"
className="txt-label-m text-txt-secondary-normal hover:text-txt-primary-normal block transition-colors"
>
Character Leaderboard
</Link>
</div>
</div>
@ -86,14 +67,6 @@ const HomePageFooter = () => {
>
Daily Free CrushCoins
</Link>
{/* <a
href="https://crushlevel.ai"
target="_blank"
rel="noopener noreferrer"
className="block txt-label-m text-txt-secondary-normal hover:text-txt-primary-normal transition-colors"
>
Get App
</a> */}
<Link
href="/about"
className="txt-label-m text-txt-secondary-normal hover:text-txt-primary-normal block transition-colors"
@ -149,7 +122,7 @@ const HomePageFooter = () => {
</div>
</div>
</footer>
)
}
);
};
export default HomePageFooter
export default HomePageFooter;

View File

@ -1,241 +0,0 @@
'use client'
import * as React from 'react'
import { useState } from 'react'
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from '@/components/ui/drawer'
import { Button, IconButton } from '@/components/ui/button'
import { Chip } from '@/components/ui/chip'
import { cn } from '@/lib/utils'
import { Gender } from '@/types/user'
import Image from 'next/image'
import { Age } from '@/services/home'
// 筛选选项类型定义
export interface FilterOptions {
gender: Gender[] // 多选
age: string[] // 多选
type: string[] // 多选
}
interface FilterDrawerProps {
open: boolean
onOpenChange: (open: boolean) => void
filters: FilterOptions
onFiltersChange: (filters: FilterOptions) => void
onReset: () => void
onConfirm: () => void
}
// 筛选选项数据
const GENDER_OPTIONS = [
{ code: Gender.MALE, name: 'Male' },
{ code: Gender.FEMALE, name: 'Female' },
{ code: Gender.OTHER, name: 'Other' },
]
const AGE_OPTIONS = [
{ code: Age.Age1, name: '18-24' },
{ code: Age.Age2, name: '25-34' },
{ code: Age.Age3, name: '35-44' },
{ code: Age.Age4, name: '45-54' },
{ code: Age.Age5, name: '>54' },
]
const TYPE_OPTIONS = [
{
code: 'R00002',
name: 'Original',
image: '/images/home/icon-original.png',
background: 'linear-gradient(263deg, #62E1F2 0%, #6296F2 100%), #211A2B',
},
{
code: 'R00004',
name: 'Anime',
image: '/images/home/icon-anime.png',
background:
'linear-gradient(263deg, rgba(224, 60, 230, 0.50) 0%, rgba(230, 60, 139, 0.50) 100%), #211A2B',
},
{
code: 'R00005',
name: 'Game',
image: '/images/home/icon-game.png',
background:
'linear-gradient(263deg, rgba(71, 92, 255, 0.50) 0%, rgba(123, 71, 255, 0.50) 100%), #211A2B',
},
{
code: 'R00006',
name: 'Film&TV',
image: '/images/home/icon-film.png',
background:
'linear-gradient(263deg, rgba(255, 100, 100, 0.50) 0%, rgba(255, 162, 100, 0.50) 100%), #211A2B',
},
]
const FilterDrawer: React.FC<FilterDrawerProps> = ({
open,
onOpenChange,
filters,
onFiltersChange,
onReset,
onConfirm,
}) => {
const [localFilters, setLocalFilters] = useState<FilterOptions>(filters)
// 同步外部 filters 到本地状态
React.useEffect(() => {
if (open) {
setLocalFilters(filters)
}
}, [filters, open])
const handleGenderToggle = (gender: string) => {
// 多选逻辑:包含则移除,否则添加
const genderValue = Number(gender) as Gender
const newGender = localFilters.gender.includes(genderValue)
? localFilters.gender.filter((g) => g !== genderValue)
: [...localFilters.gender, genderValue]
setLocalFilters((prev) => ({ ...prev, gender: newGender }))
}
const handleAgeToggle = (age: string) => {
// 多选逻辑:包含则移除,否则添加
const newAge = localFilters.age.includes(age)
? localFilters.age.filter((a) => a !== age)
: [...localFilters.age, age]
setLocalFilters((prev) => ({ ...prev, age: newAge }))
}
const handleTypeToggle = (type: string) => {
const newType = localFilters.type.includes(type)
? localFilters.type.filter((t) => t !== type)
: [...localFilters.type, type]
setLocalFilters((prev) => ({ ...prev, type: newType }))
}
const handleReset = () => {
const resetFilters = { gender: [], age: [], type: [] }
setLocalFilters(resetFilters)
onReset()
onOpenChange(false)
}
const handleConfirm = () => {
console.log('localFilters', localFilters)
onFiltersChange(localFilters)
onConfirm()
onOpenChange(false)
}
return (
<Drawer open={open} onOpenChange={onOpenChange} direction="right">
<DrawerContent className="bg-background-default rounded-none">
{/* Header */}
<DrawerHeader className="flex items-center justify-between p-6">
<div className="flex items-center gap-2">
<DrawerClose asChild>
<IconButton
variant="ghost"
size="small"
className="p-2"
iconfont="icon-arrow-right"
/>
</DrawerClose>
<DrawerTitle className="txt-title-m text-txt-primary-normal">Filter</DrawerTitle>
</div>
</DrawerHeader>
{/* Content */}
<div className="flex-1 space-y-6 overflow-y-auto px-6 pb-6">
{/* Gender Section */}
<div className="space-y-4">
<h3 className="txt-title-s text-txt-primary-normal">Gender</h3>
<div className="flex flex-wrap gap-3">
{GENDER_OPTIONS.map((option) => (
<Chip
key={option.code}
size="small"
state={localFilters.gender.includes(option.code) ? 'active' : 'inactive'}
onClick={() => handleGenderToggle(option.code.toString())}
className="px-4 py-1.5"
>
<span className="txt-label-m">{option.name}</span>
</Chip>
))}
</div>
</div>
{/* Age Section */}
<div className="space-y-4">
<h3 className="txt-title-s text-txt-primary-normal">Age</h3>
<div className="flex flex-wrap gap-4">
{AGE_OPTIONS.map((option) => (
<Chip
key={option.code}
size="small"
state={localFilters.age.includes(option.code) ? 'active' : 'inactive'}
onClick={() => handleAgeToggle(option.code)}
className="px-4 py-1.5"
>
<span className="txt-label-m">{option.name}</span>
</Chip>
))}
</div>
</div>
{/* Type Section */}
<div className="space-y-4">
<h3 className="txt-title-s text-txt-primary-normal">Type</h3>
<div className="grid grid-cols-3 gap-4">
{TYPE_OPTIONS.map((option, index) => {
const isSelected = localFilters.type.includes(option.code)
return (
<div
key={option.code}
className={cn(
'bg-surface-element-normal aspect-square cursor-pointer overflow-hidden rounded-lg transition-all',
'border-outline-normal border'
)}
onClick={() => handleTypeToggle(option.code)}
style={{
background: isSelected ? option.background : 'rgba(251, 222, 255, 0.08',
}}
>
<div className="relative h-full w-full">
<div className="absolute right-0 bottom-0 left-0 h-[71px]">
<Image className="object-cover" src={option.image} alt={option.name} fill />
</div>
<div className="txt-label-m text-txt-primary-normal absolute top-2 left-3">
{option.name}
</div>
</div>
</div>
)
})}
</div>
</div>
</div>
{/* Footer */}
<div className="bg-background-default/65 p-6 backdrop-blur-md">
<div className="flex gap-4">
<Button variant="tertiary" size="large" onClick={handleReset} className="flex-1">
Reset
</Button>
<Button variant="primary" size="large" onClick={handleConfirm} className="flex-1">
Confirm
</Button>
</div>
</div>
</DrawerContent>
</Drawer>
)
}
export default FilterDrawer

View File

@ -1,230 +0,0 @@
'use client'
import * as React from 'react'
import { useState } from 'react'
import { useSticky } from '@/hooks/useSticky'
import { cn } from '@/lib/utils'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Chip } from '@/components/ui/chip'
import FilterDrawer, { FilterOptions } from './FilterDrawer'
import { useMainLayout } from '@/context/mainLayout'
interface Role {
code?: string
name?: string
childDictList?: Tag[]
}
interface Tag {
code?: string
name?: string
}
interface MeetHeaderProps {
selectedRole: string
selectedTags: string[]
roles: Role[]
quickTags: Tag[]
onRoleChange: (roleCode: string) => void
onTagsChange: (tagCodes: string[]) => void
onFilterClick: () => void
filters?: FilterOptions
onFiltersChange?: (filters: FilterOptions) => void
}
const MeetHeader = ({
selectedRole,
selectedTags,
roles,
quickTags,
onRoleChange,
onTagsChange,
onFilterClick,
filters = { gender: [], age: [], type: [] },
onFiltersChange,
}: MeetHeaderProps) => {
const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false)
const { isSidebarExpanded } = useMainLayout()
// 计算筛选器数量
const filterCount =
(filters.gender?.length || 0) + (filters.age?.length || 0) + (filters.type?.length || 0)
// 使用 sticky hook 检测粘性状态
const [stickyRef, isSticky] = useSticky<HTMLDivElement>({
offset: 64, // offset 为 top-16 (64px)
hysteresis: 15, // 增加滞后容差,避免边界抖动和快速滚动时的状态闪烁
})
// 计算左侧偏移量:侧边栏宽度
const leftOffset = isSidebarExpanded ? 320 : 80 // w-80 = 320px, w-20 = 80px
const handleRoleSelect = (roleCode: string) => {
onRoleChange?.(roleCode)
}
const handleTagToggle = (tagCode: string) => {
const newSelectedTags = selectedTags.includes(tagCode)
? selectedTags.filter((code) => code !== tagCode)
: [...selectedTags, tagCode]
onTagsChange?.(newSelectedTags)
}
const handleFilterClick = () => {
setIsFilterDrawerOpen(true)
onFilterClick?.()
}
const handleFiltersReset = () => {
const resetFilters = { gender: [], age: [], type: [] }
onFiltersChange?.(resetFilters)
}
const handleFiltersConfirm = () => {
// 筛选确认逻辑可以在父组件处理
}
// 渲染筛选区域内容
const renderFilterContent = () => (
<div className="flex w-full flex-col gap-4">
{/* 角色类型标签页和筛选按钮 */}
<div className="flex w-full items-start justify-between gap-6">
{/* 使用 Tabs 组件 */}
<Tabs value={selectedRole} onValueChange={handleRoleSelect} className="flex-1">
<TabsList className="h-auto justify-start overflow-x-auto rounded-none bg-transparent p-0">
{/* ALL 选项 */}
<TabsTrigger
key="ALL"
value="ALL"
className={cn(
'relative h-8 border-0 bg-transparent pr-3 shadow-none',
'txt-title-s text-txt-secondary-normal',
'data-[state=active]:text-txt-primary-normal',
'data-[state=active]:bg-transparent',
'data-[state=active]:shadow-none',
'hover:text-txt-primary-normal',
'focus-visible:ring-0 focus-visible:ring-offset-0',
'flex cursor-pointer items-center justify-center'
)}
>
<span className="whitespace-nowrap">All</span>
{/* 活跃状态指示器 */}
<div
className={cn(
'bg-primary-normal absolute bottom-0 left-1/2 -ml-1.5 h-1 w-4 -translate-x-1/2 rounded-xs transition-opacity',
selectedRole === 'ALL' ? 'opacity-100' : 'opacity-0'
)}
/>
</TabsTrigger>
{roles.map((role) => (
<TabsTrigger
key={role.code}
value={role.code || ''}
className={cn(
'relative h-8 border-0 bg-transparent pr-3 shadow-none',
'txt-title-s text-txt-secondary-normal',
'data-[state=active]:text-txt-primary-normal',
'data-[state=active]:bg-transparent',
'data-[state=active]:shadow-none',
'hover:text-txt-primary-normal',
'focus-visible:ring-0 focus-visible:ring-offset-0',
'flex cursor-pointer items-center justify-center'
)}
>
<span className="whitespace-nowrap">{role.name}</span>
{/* 活跃状态指示器 */}
<div
className={cn(
'bg-primary-normal absolute bottom-0 left-1/2 -ml-1.5 h-1 w-4 -translate-x-1/2 rounded-xs transition-opacity',
selectedRole === role.code ? 'opacity-100' : 'opacity-0'
)}
/>
</TabsTrigger>
))}
</TabsList>
</Tabs>
{/* Filter 按钮 */}
<Chip className="px-4" onClick={handleFilterClick}>
<div className="flex items-center gap-1">
<i className="iconfont icon-filter-fill" />
<div className="txt-label-m">
{filterCount > 0 ? `Filter(${filterCount})` : 'Filter'}
</div>
</div>
</Chip>
</div>
{/* 快速筛选标签 */}
<div className="flex flex-wrap items-start justify-start gap-2">
{quickTags.map((tag) => (
<Chip
key={tag.code}
size="small"
className="px-4"
state={selectedTags.includes(tag.code || '') ? 'active' : 'inactive'}
onClick={() => handleTagToggle(tag.code || '')}
>
#{tag.name}
</Chip>
))}
</div>
</div>
)
return (
<>
{/* 原始容器 - 用于检测 sticky 触发点 */}
<div ref={stickyRef} className="mt-12 w-full">
{/* 占位元素 - 当 fixed 时保持布局不跳动 */}
{isSticky && <div className="w-full py-6" style={{ height: '104px' }} />}
{!isSticky && (
<div className="w-full py-6">
<div className="w-full">
{/* Meet 标题 */}
<h1 className="txt-title-l text-txt-primary-normal mb-6">🔮 More Characters</h1>
{/* 筛选区域 */}
{renderFilterContent()}
</div>
</div>
)}
</div>
{/* Fixed 定位的筛选栏 - sticky 时显示 */}
{isSticky && (
<div
className="border-outline-normal fixed top-16 right-0 z-50 border-b py-6 backdrop-blur-[10px] transition-opacity duration-200 ease-in-out"
style={{
left: `${leftOffset}px`,
right: 0,
opacity: isSticky ? 1 : 0,
willChange: 'opacity',
}}
>
{/* 半透明背景 */}
<div className="bg-background-default absolute inset-0 opacity-85" />
{/* 内容区域 - 与 HomePage 保持一致的 padding 和 max-width */}
<div className="relative px-16">
<div className="mx-auto max-w-[1136px]">{renderFilterContent()}</div>
</div>
</div>
)}
{/* 筛选抽屉 */}
<FilterDrawer
open={isFilterDrawerOpen}
onOpenChange={setIsFilterDrawerOpen}
filters={filters}
onFiltersChange={onFiltersChange || (() => {})}
onReset={handleFiltersReset}
onConfirm={handleFiltersConfirm}
/>
</>
)
}
export default MeetHeader

View File

@ -1,123 +0,0 @@
'use client'
import React from 'react'
import { GetMeetListResponse } from '@/services/home/types'
import { InfiniteScrollList } from '@/components/ui/infinite-scroll-list'
import { cn } from '@/lib/utils'
import Empty from '@/components/ui/empty'
import AIStandardCard from '@/components/features/ai-standard-card'
interface MeetListProps {
items: GetMeetListResponse[]
hasNextPage: boolean
isLoading: boolean
fetchNextPage: () => void
onCharacterClick?: (character: GetMeetListResponse) => void
className?: string
hasError?: boolean
onRetry?: () => void
}
// 骨架屏组件
const MeetCardSkeleton = () => (
<div className="relative flex min-h-px max-w-[272px] min-w-60 shrink-0 grow basis-0 flex-col content-stretch items-start justify-start gap-3">
<div className="bg-surface-nest-normal relative aspect-[240/320] w-full shrink-0 animate-pulse rounded-[16px]">
<div className="relative box-border flex aspect-[240/320] size-full flex-col content-stretch items-end justify-end overflow-clip p-[12px]">
{/* 底部信息区域骨架 */}
<div className="from-surface-nest-normal/80 absolute right-0 bottom-0 left-0 rounded-b-[16px] bg-gradient-to-t to-transparent p-3">
<div className="space-y-2">
{/* 名称骨架 */}
<div className="bg-surface-nest-normal h-7 w-3/4 animate-pulse rounded"></div>
{/* 标签骨架 */}
<div className="flex gap-1">
<div className="bg-surface-nest-normal h-6 w-16 animate-pulse rounded"></div>
<div className="bg-surface-nest-normal h-6 w-20 animate-pulse rounded"></div>
</div>
{/* 点赞数骨架 */}
<div className="bg-surface-nest-normal h-6 w-12 animate-pulse rounded"></div>
</div>
</div>
</div>
</div>
</div>
)
// 空状态组件
const EmptyState = () => (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Empty title="Oops, theres nothing here…" />
</div>
)
// 错误状态组件
const ErrorState: React.FC<{ onRetry: () => void }> = ({ onRetry }) => (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="mb-4 h-16 w-16 opacity-40">
<svg
viewBox="0 0 64 64"
fill="none"
className="h-full w-full text-red-400"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M32 4C16.536 4 4 16.536 4 32s12.536 28 28 28 28-12.536 28-28S47.464 4 32 4zm0 52c-13.255 0-24-10.745-24-24S18.745 8 32 8s24 10.745 24 24-10.745 24-24 24z"
fill="currentColor"
/>
<path
d="M26 22l12 12m0-12L26 34"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
/>
</svg>
</div>
<h3 className="mb-2 text-lg font-semibold text-white">Load failed</h3>
<p className="mb-4 text-sm text-white/60">
The network connection is abnormal. Please try again later
</p>
</div>
)
const MeetList: React.FC<MeetListProps> = ({
items,
hasNextPage,
isLoading,
fetchNextPage,
onCharacterClick,
className,
hasError = false,
onRetry,
}) => {
return (
<div className={cn('w-full', className)}>
<InfiniteScrollList<GetMeetListResponse>
items={items}
renderItem={(character) => <AIStandardCard character={character} />}
getItemKey={(character) => character.aiId?.toString() || 'unknown'}
hasNextPage={hasNextPage}
isLoading={isLoading}
fetchNextPage={fetchNextPage}
columns={{
default: 1,
sm: 2,
md: 3,
lg: 4,
xl: 4,
}}
gap={4}
LoadingSkeleton={MeetCardSkeleton}
EmptyComponent={EmptyState}
ErrorComponent={ErrorState}
hasError={hasError}
onRetry={onRetry}
threshold={200}
enabled={true}
/>
</div>
)
}
export default MeetList

View File

@ -1,180 +0,0 @@
'use client'
import * as React from 'react'
import MeetHeader from './MeetHeader'
import MeetList from './MeetList'
import { useState } from 'react'
import { useCreateDict } from '@/hooks/create'
import { useGetMeetList } from '@/hooks/useHome'
import { useHomeData } from '../../context/HomeDataContext'
import { GetMeetListRequest, GetMeetListResponse } from '@/services/home/types'
import { useRouter } from 'next/navigation'
import { FilterOptions } from './FilterDrawer'
import { usePrefetchRoutes } from '@/hooks/useGlobalPrefetchRoutes'
const MoreType = () => {
const router = useRouter()
const { data: dictData } = useCreateDict()
const { recommendExcludeList, isRecommendDataReady } = useHomeData()
const [selectedRole, setSelectedRole] = useState<string>('')
const [selectedTags, setSelectedTags] = useState<string[]>([])
const [filters, setFilters] = useState<FilterOptions>({
gender: [],
age: [],
type: [],
})
const { characterDictList } = dictData || {}
// 构建 useGetMeetList 的参数
const meetListParams = React.useMemo(() => {
// 判断是否有任何筛选条件
const hasAnyFilter =
selectedTags.length > 0 ||
filters.gender.length > 0 ||
filters.age.length > 0 ||
filters.type.length > 0
const result: Record<string, unknown> = {
characterCodeList: selectedRole && selectedRole !== 'ALL' ? [selectedRole] : [],
tagCodeList: selectedTags,
roleCodeList: filters.type, // 使用类型筛选作为 tagCodeList
ps: 20, // 每页20条数据
...(filters.gender.length > 0 ? { sexList: filters.gender } : {}),
...(filters.age.length > 0 ? { ageList: filters.age } : {}),
// 只有当选择 ALL 且没有任何筛选条件时,才添加排除列表
...(selectedRole === 'ALL' && !hasAnyFilter && recommendExcludeList.length > 0
? { exList: recommendExcludeList }
: {}),
}
// 过滤掉空字符串和空数组字段
const filteredResult = Object.fromEntries(
Object.entries(result).filter(([, value]) => {
if (Array.isArray(value)) {
return value.length > 0
}
if (typeof value === 'string') {
return value !== ''
}
return value !== undefined && value !== null
})
)
return filteredResult
}, [selectedRole, selectedTags, filters, recommendExcludeList])
// 使用 useGetMeetList hook只有在 selectedRole 有值且满足条件时才启用查询
const shouldEnableQuery = React.useMemo(() => {
if (!selectedRole) return false
// 如果不是 ALL直接启用查询
if (selectedRole !== 'ALL') return true
// 判断是否有任何筛选条件
const hasAnyFilter =
selectedTags.length > 0 ||
filters.gender.length > 0 ||
filters.age.length > 0 ||
filters.type.length > 0
// 如果是 ALL 且有筛选条件,直接启用查询(不需要等待 Recommend 数据)
if (hasAnyFilter) return true
// 如果是 ALL 且没有筛选条件,需要等待 Recommend 数据准备好
return isRecommendDataReady
}, [selectedRole, selectedTags, filters, isRecommendDataReady])
const {
data: meetData,
fetchNextPage,
hasNextPage,
isLoading,
isFetchingNextPage,
error,
refetch,
} = useGetMeetList(meetListParams as unknown as Omit<GetMeetListRequest, 'pn'>, shouldEnableQuery)
// 扁平化所有页面的数据
const allMeetItems = React.useMemo(() => {
if (!meetData?.pages) return []
return meetData.pages.flat()
}, [meetData])
const meetChatRoutes = React.useMemo(
() => allMeetItems.map((character) => (character?.aiId ? `/chat/${character.aiId}` : null)),
[allMeetItems]
)
usePrefetchRoutes(meetChatRoutes, { limit: 16 })
// 当角色数据加载后默认选中ALL
React.useEffect(() => {
if (characterDictList && characterDictList.length > 0 && !selectedRole) {
setSelectedRole('ALL')
}
}, [characterDictList, selectedRole])
// 计算当前选中角色的快速标签
const quickTags = React.useMemo(() => {
if (selectedRole === 'ALL') {
return [] // ALL选项不显示快速标签
}
const findCharacter = characterDictList?.find((item) => item.code === selectedRole)
if (findCharacter) {
return findCharacter.childDictList || []
}
return []
}, [characterDictList, selectedRole])
const handleRoleChange = (roleCode: string) => {
setSelectedRole(roleCode)
// 切换角色时清空已选标签
setSelectedTags([])
}
const handleTagsChange = (tagCodes: string[]) => {
setSelectedTags(tagCodes)
}
const handleFilterClick = () => {
console.log('Filter clicked')
}
const handleFiltersChange = (newFilters: FilterOptions) => {
setFilters(newFilters)
}
// 处理角色卡片点击
const handleCharacterClick = (character: GetMeetListResponse) => {
if (character.aiId) {
router.push(`/chat/${character.aiId}`)
}
}
return (
<div className="pb-[200px]">
<MeetHeader
selectedRole={selectedRole}
selectedTags={selectedTags}
roles={characterDictList || []}
quickTags={quickTags}
onRoleChange={handleRoleChange}
onTagsChange={handleTagsChange}
onFilterClick={handleFilterClick}
filters={filters}
onFiltersChange={handleFiltersChange}
/>
<div className="w-full">
<MeetList
items={allMeetItems}
hasNextPage={!!hasNextPage}
isLoading={isLoading || isFetchingNextPage}
fetchNextPage={fetchNextPage}
onCharacterClick={handleCharacterClick}
hasError={!!error}
onRetry={refetch}
/>
</div>
</div>
)
}
export default MoreType

View File

@ -1,27 +0,0 @@
'use client'
import Header from './components/Header'
import HomePageFooter from './components/HomePageFooter'
import Story from './components/Story'
import Character from './components/Character'
import Filter from './components/Filter'
import { useHomeStore } from './store'
const HomePage = () => {
const tab = useHomeStore((state) => state.tab)
return (
<>
<div className="text-txt-primary-normal relative px-16 pb-32">
<div className="mx-auto max-w-[1136px]">
<Header />
<Filter />
{tab === 'story' ? <Story /> : <Character />}
</div>
</div>
<HomePageFooter />
</>
)
}
export default HomePage

View File

@ -0,0 +1,29 @@
'use client';
import Header from './components/Header';
import HomePageFooter from './components/HomePageFooter';
import Story from './components/Story';
import Character from './components/Character';
import Filter from './components/Filter';
import { useHomeStore } from './store';
import { useMedia } from '@/hooks/tools';
const HomePage = () => {
const tab = useHomeStore((state) => state.tab);
const response = useMedia();
return (
<div className="h-full">
<div className="text-txt-primary-normal relative px-4 sm:px-16 pb-32">
<div className="mx-auto max-w-[1136px]">
<Header />
<Filter />
{tab === 'story' ? <Story /> : <Character />}
</div>
</div>
{response?.sm && <HomePageFooter />}
</div>
);
};
export default HomePage;

View File

@ -0,0 +1,142 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useDebounceFn, useMemoizedFn } from 'ahooks';
import { useState, useRef } from 'react';
type ParamsType<Q = any> = {
index: number;
limit: number;
query: Q;
};
type PropsType<T = any, Q = any> = {
queryKey: string;
defaultQuery?: Q;
defaultIndex?: number;
limit?: number;
isRowSame?: (d: T, n: T) => boolean;
};
type RequestType<T = any, Q = any> = (
params: ParamsType<Q>
) => Promise<{ rows: T[]; total: number } | undefined>;
type UseInfiniteScrollValue<T = any, Q = any> = {
query: Q;
onLoadMore: () => void;
onSearch: (query: Q) => void;
dataSource: T[];
total: number;
// 是否正在加载第一页,包括 初始化加载 和 参数改变时加载
isFirstLoading: boolean;
isLoadingMore: boolean;
noMoreData: boolean;
};
type DataType<T = any> = {
rows: T[];
total: number;
index: number;
};
const useSmartInfiniteQuery = <T = any, Q = any>(
request: RequestType<T, Q>,
props: PropsType<T, Q>
): UseInfiniteScrollValue<T, Q> => {
const {
queryKey,
defaultQuery,
defaultIndex = 1,
limit = 20,
isRowSame = (d, n) => {
return (d as any)?.id === (n as any)?.id;
},
} = props;
const [query, setQuery] = useState<Q>(defaultQuery as Q);
const index = useRef<number>(defaultIndex);
// 判断第一页数据和缓存中的第一页数据是否相等
const isSameData = useMemoizedFn((prevData: DataType<T>, result: Omit<DataType<T>, 'index'>) => {
// 没有缓存,必不相等
if (prevData.total <= 0 || !prevData.rows?.length) {
return false;
}
// 如果第一个元素相等,则认为数据相等(实际上并不一定)
if (!isRowSame(prevData.rows[0], result?.rows[0])) {
return false;
}
return true;
});
const { data, refetch, isFetching } = useQuery({
queryKey: [queryKey, query],
placeholderData: keepPreviousData,
queryFn: async ({ client }) => {
const params = {
index: index.current,
limit,
query,
};
const result = await request(params);
const prevData = (client.getQueryData([queryKey, query]) as DataType<T>) ?? {
rows: [],
total: 0,
index: 1,
};
// 如果是第一页
if (params.index === defaultIndex) {
// 第一页数据和缓存中相等
if (isSameData(prevData, result!)) {
// 更新索引
index.current = prevData.index;
// 直接返回缓存里的
return prevData;
} else {
// 第一页数据和缓存中不等, 返回新的数据
return {
total: result?.total || 0,
rows: result?.rows || [],
index: params.index,
};
}
}
// 不是第一页, 返回合并后的数据
return {
total: result?.total || 0,
rows: [...(prevData?.rows || []), ...(result?.rows || [])],
index: params.index,
};
},
});
const { run: onLoadMore } = useDebounceFn(
() => {
if (isFetching) return;
index.current = index.current + 1;
refetch();
},
{
wait: 300,
maxWait: 500,
}
);
const onSearch = useMemoizedFn((query: Q) => {
index.current = defaultIndex;
setQuery(query);
});
return {
query,
onLoadMore,
onSearch,
dataSource: data?.rows || [],
total: data?.total || 0,
isFirstLoading: isFetching && index.current === defaultIndex,
isLoadingMore: isFetching && index.current > defaultIndex,
noMoreData: data?.total === data?.rows?.length && !isFetching,
};
};
export default useSmartInfiniteQuery;

View File

@ -1,5 +1,4 @@
import React from 'react'
'use client';
import ConditionalLayout from '@/layout/BasicLayout';
export default async function MainLayout({ children }: { children: React.ReactNode }) {
return children
}
export default ConditionalLayout;

View File

@ -1,7 +0,0 @@
import Home from './home'
const MainPage = () => {
return <Home />
}
export default MainPage

View File

@ -7,10 +7,10 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { useLogout } from '@/hooks/auth'
import { useNimChat, useNimConversation } from '@/context/NimChat/useNimChat'
import { useSetAtom } from 'jotai'
} from '@/components/ui/alert-dialog';
import { useLogout } from '@/hooks/auth';
import { useNimChat, useNimConversation } from '@/context/NimChat/useNimChat';
import { useSetAtom } from 'jotai';
import {
conversationListAtom,
msgListAtom,
@ -19,20 +19,20 @@ import {
imReconnectStatusAtom,
IMReconnectStatus,
selectedConversationIdAtom,
} from '@/atoms/im'
import { QueueMap } from '@/lib/queue'
import Link from 'next/link'
import { useState } from 'react'
import { useMainLayout } from '@/context/mainLayout'
} from '@/atoms/im';
import { QueueMap } from '@/lib/queue';
import Link from 'next/link';
import { useState } from 'react';
import { useLayoutStore } from '@/stores';
const ProfileDropdownItem = ({
icon,
children,
onClick,
}: {
icon: string
children: React.ReactNode
onClick?: () => void
icon: string;
children: React.ReactNode;
onClick?: () => void;
}) => {
return (
<div
@ -45,87 +45,82 @@ const ProfileDropdownItem = ({
</div>
<i className="iconfont icon-arrow-right-border text-white/60 text-lg" />
</div>
)
}
);
};
const ProfileDropdown = () => {
const { mutateAsync: logout } = useLogout()
const { nim } = useNimChat()
const { clearAllConversations } = useNimConversation()
const [isLogoutDialogOpen, setIsLogoutDialogOpen] = useState(false)
const [isLogoutDialogLoading, setIsLogoutDialogLoading] = useState(false)
const { isSidebarExpanded, dispatch } = useMainLayout()
const { mutateAsync: logout } = useLogout();
const { nim } = useNimChat();
const { clearAllConversations } = useNimConversation();
const [isLogoutDialogOpen, setIsLogoutDialogOpen] = useState(false);
const [isLogoutDialogLoading, setIsLogoutDialogLoading] = useState(false);
const { isSidebarExpanded, setSidebarExpanded } = useLayoutStore();
// IM相关状态重置
const setConversationList = useSetAtom(conversationListAtom)
const setMsgList = useSetAtom(msgListAtom)
const setUserList = useSetAtom(userListAtom)
const setImSynced = useSetAtom(imSyncedAtom)
const setImReconnectStatus = useSetAtom(imReconnectStatusAtom)
const setSelectedConversationId = useSetAtom(selectedConversationIdAtom)
const setConversationList = useSetAtom(conversationListAtom);
const setMsgList = useSetAtom(msgListAtom);
const setUserList = useSetAtom(userListAtom);
const setImSynced = useSetAtom(imSyncedAtom);
const setImReconnectStatus = useSetAtom(imReconnectStatusAtom);
const setSelectedConversationId = useSetAtom(selectedConversationIdAtom);
const handleLogout = async () => {
try {
setIsLogoutDialogLoading(true)
setIsLogoutDialogLoading(true);
// 1. 断开IM连接
try {
console.log('开始断开IM连接...')
await nim.V2NIMLoginService.logout()
console.log('IM连接已断开')
console.log('开始断开IM连接...');
await nim.V2NIMLoginService.logout();
console.log('IM连接已断开');
} catch (imError) {
console.error('断开IM连接失败:', imError)
console.error('断开IM连接失败:', imError);
// 即使IM断开失败也继续执行后续步骤
}
// 2. 清除所有聊天数据
try {
console.log('开始清除聊天历史数据...')
await clearAllConversations()
console.log('聊天历史数据已清除')
console.log('开始清除聊天历史数据...');
await clearAllConversations();
console.log('聊天历史数据已清除');
} catch (clearError) {
console.error('清除聊天数据失败:', clearError)
console.error('清除聊天数据失败:', clearError);
// 即使清除失败,也继续执行后续步骤
}
// 3. 重置所有IM相关的本地状态
setConversationList(new Map())
setMsgList(new QueueMap(20, 'rightToLeft'))
setUserList(new Map())
setImSynced(false)
setImReconnectStatus(IMReconnectStatus.DISCONNECTED)
setSelectedConversationId(null)
setConversationList(new Map());
setMsgList(new QueueMap(20, 'rightToLeft'));
setUserList(new Map());
setImSynced(false);
setImReconnectStatus(IMReconnectStatus.DISCONNECTED);
setSelectedConversationId(null);
if (isSidebarExpanded) {
dispatch({
type: 'updateState',
payload: {
isSidebarExpanded: false,
},
})
setSidebarExpanded(false);
}
// 4. 执行用户登出
await logout()
await logout();
setIsLogoutDialogOpen(false)
setIsLogoutDialogLoading(false)
setIsLogoutDialogOpen(false);
setIsLogoutDialogLoading(false);
} catch (error) {
console.error('登出过程中发生错误:', error)
setIsLogoutDialogLoading(false)
}
console.error('登出过程中发生错误:', error);
setIsLogoutDialogLoading(false);
}
};
// 菜单项配置
const items: Array<
| { type: 'separator' }
| {
type: 'item'
label: string
icon: string
href?: string
target?: string
onClick?: () => void
type: 'item';
label: string;
icon: string;
href?: string;
target?: string;
onClick?: () => void;
}
> = [
{
@ -146,21 +141,18 @@ const ProfileDropdown = () => {
label: 'About Us',
icon: 'icon-info',
href: '/about',
target: '_blank',
},
{
type: 'item',
label: 'Terms of Services',
icon: 'icon-audits',
href: '/policy/tos',
target: '_blank',
},
{
type: 'item',
label: 'Privacy Policy',
icon: 'icon-shield',
href: '/policy/privacy',
target: '_blank',
},
{ type: 'separator' },
{
@ -169,7 +161,7 @@ const ProfileDropdown = () => {
icon: 'icon-icon_exit',
onClick: () => setIsLogoutDialogOpen(true),
},
]
];
return (
<>
@ -180,24 +172,24 @@ const ProfileDropdown = () => {
<div key={`separator-${index}`} className="my-2 px-4">
<div className="h-px bg-white/10" />
</div>
)
);
}
const menuItem = (
<ProfileDropdownItem icon={item.icon} onClick={item.onClick}>
{item.label}
</ProfileDropdownItem>
)
);
if (item.href) {
return (
<Link key={item.label} href={item.href} target={item.target}>
{menuItem}
</Link>
)
);
}
return <div key={item.label}>{menuItem}</div>
return <div key={item.label}>{menuItem}</div>;
})}
</div>
@ -220,7 +212,7 @@ const ProfileDropdown = () => {
</AlertDialogContent>
</AlertDialog>
</>
)
}
);
};
export default ProfileDropdown
export default ProfileDropdown;

View File

@ -0,0 +1,9 @@
'use client';
export default function SearchPage() {
return (
<div>
<h1>Search</h1>
</div>
);
}

View File

@ -1,53 +1,37 @@
import { NextRequest, NextResponse } from 'next/server'
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
console.log('request', request);
const url = 'http://localhost:3000';
try {
const { searchParams } = new URL(request.url)
const code = searchParams.get('code')
const error = searchParams.get('error')
const state = searchParams.get('state')
const { searchParams } = new URL(request.url);
const code = searchParams.get('code');
const error = searchParams.get('error');
const state = searchParams.get('state');
// 处理用户拒绝授权的情况
if (error) {
console.error('Discord OAuth error:', error)
return NextResponse.redirect(
new URL(
`${process.env.NEXT_PUBLIC_APP_URL || 'https://test.crushlevel.ai'}/login?error=discord_denied`,
request.url
)
)
console.error('Discord OAuth error:', error);
return NextResponse.redirect(new URL(`${url}/login?error=discord_denied`, request.url));
}
// 检查授权码
if (!code) {
console.error('No authorization code received from Discord')
return NextResponse.redirect(
new URL(
`${process.env.NEXT_PUBLIC_APP_URL || 'https://test.crushlevel.ai'}/login?error=discord_no_code`,
request.url
)
)
console.error('No authorization code received from Discord');
return NextResponse.redirect(new URL(`${url}/login?error=discord_no_code`, request.url));
}
// 将code作为URL参数传递给前端登录页面处理
const loginUrl = new URL(
`${process.env.NEXT_PUBLIC_APP_URL || 'https://test.crushlevel.ai'}/login`,
request.url
)
loginUrl.searchParams.set('discord_code', code)
const loginUrl = new URL(`${url}/login`, request.url);
loginUrl.searchParams.set('discord_code', code);
if (state) {
loginUrl.searchParams.set('discord_state', state)
loginUrl.searchParams.set('discord_state', state);
}
console.log('Discord OAuth callback successful, redirecting to login page with code')
return NextResponse.redirect(loginUrl)
console.log('Discord OAuth callback successful, redirecting to login page with code');
return NextResponse.redirect(loginUrl);
} catch (error) {
console.error('Discord OAuth callback error:', error)
return NextResponse.redirect(
new URL(
`${process.env.NEXT_PUBLIC_APP_URL || 'https://test.crushlevel.ai'}/login?error=discord_callback_error`,
request.url
)
)
console.error('Discord OAuth callback error:', error);
return NextResponse.redirect(new URL(`${url}/login?error=discord_callback_error`, request.url));
}
}

View File

@ -1,147 +0,0 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
export default function DebugMockPage() {
const [testResults, setTestResults] = useState<string[]>([])
const addResult = (message: string) => {
setTestResults((prev) => [...prev, `${new Date().toLocaleTimeString()}: ${message}`])
}
const testDirectRequest = async () => {
try {
addResult('🔥 开始测试直接请求...')
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: 'user@example.com',
password: 'password',
}),
})
const result = await response.json()
addResult(`✅ 直接请求成功: ${JSON.stringify(result)}`)
} catch (error) {
addResult(`❌ 直接请求失败: ${error}`)
}
}
const testMockRequest = async () => {
try {
addResult('🎭 开始测试 Mock 请求...')
// 这里使用你定义的服务 URL
const baseUrl = process.env.NEXT_PUBLIC_AUTH_API_URL || 'http://localhost:3000/api/auth'
const response = await fetch(`${baseUrl}/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: 'user@example.com',
password: 'password',
}),
})
const result = await response.json()
addResult(`✅ Mock 请求成功: ${JSON.stringify(result)}`)
} catch (error) {
addResult(`❌ Mock 请求失败: ${error}`)
}
}
const testServiceWorker = () => {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then((registrations) => {
const mswWorker = registrations.find((reg) =>
reg.active?.scriptURL.includes('mockServiceWorker')
)
if (mswWorker) {
addResult('✅ MSW Service Worker 已注册')
addResult(`📍 Worker URL: ${mswWorker.active?.scriptURL}`)
} else {
addResult('❌ MSW Service Worker 未找到')
}
})
} else {
addResult('❌ 浏览器不支持 Service Worker')
}
}
const clearResults = () => {
setTestResults([])
}
return (
<div className="mx-auto max-w-4xl p-6">
<h1 className="mb-6 text-2xl font-bold">🔧 Mock API </h1>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{/* 测试按钮 */}
<div className="space-y-4">
<h2 className="text-lg font-semibold">test options</h2>
<Button onClick={testServiceWorker} className="w-full">
Service Worker
</Button>
<Button onClick={testDirectRequest} className="w-full">
Testing direct API requests Testing direct API requests Testing direct API requests
</Button>
<Button onClick={testMockRequest} className="w-full">
Testing Mock API Requests Testing Mock API Requests Testing Mock API Requests Testing
Mock API Requests
</Button>
<Button onClick={clearResults} variant="secondary" className="w-full">
Clear result
</Button>
</div>
{/* 环境信息 */}
<div className="space-y-4">
<h2 className="text-lg font-semibold">Environmental information</h2>
<div className="space-y-2 rounded bg-gray-100 p-4 text-sm">
<div>
<strong>NODE_ENV:</strong> {process.env.NODE_ENV}
</div>
<div>
<strong>ENABLE_MOCK:</strong> {process.env.NEXT_PUBLIC_ENABLE_MOCK}
</div>
<div>
<strong>AUTH_API_URL:</strong> {process.env.NEXT_PUBLIC_AUTH_API_URL || '未设置'}
</div>
<div>
<strong>Service Worker支持:</strong> {'serviceWorker' in navigator ? '是' : '否'}
</div>
</div>
</div>
</div>
{/* 测试结果 */}
<div className="mt-8">
<h2 className="mb-4 text-lg font-semibold">Test Results</h2>
<div className="h-96 overflow-y-auto rounded bg-black p-4 font-mono text-sm text-green-400">
{testResults.length === 0 ? (
<div className="text-gray-500">Waiting for the test results...</div>
) : (
testResults.map((result, index) => (
<div key={index} className="mb-1">
{result}
</div>
))
)}
</div>
</div>
</div>
)
}

View File

@ -1,298 +0,0 @@
'use client'
import { useState } from 'react'
import { Button, IconButton } from '@/components/ui/button'
import Sidebar from '@/components/layout/Sidebar'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogIcon,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
// CrushLevel Logo 图标组件
const CrushLevelIcon = () => (
<div className="relative h-[148px] w-[148px]">
{/* 渐变背景圆 */}
<div className="absolute inset-[19.531%] flex items-center justify-center">
<div className="h-[90.188px] w-[90.188px] rounded-full bg-gradient-to-br from-purple-400 via-pink-300 to-cyan-300 opacity-60"></div>
</div>
{/* 主要的 C 形状 */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="border-gradient-to-r h-16 w-16 rotate-45 transform rounded-full border-8 border-b-transparent border-l-transparent from-purple-400 to-pink-400"></div>
</div>
{/* 装饰性轨道 */}
<div className="absolute top-[25.9%] right-[-1.406%] bottom-[15.064%] left-[-0.337%] flex items-center justify-center">
<div className="h-[40.839px] w-[145.312px] rotate-[340.328deg] transform rounded-full border-2 border-purple-300 opacity-40"></div>
</div>
</div>
)
export default function DemoPage() {
const [loading1, setLoading1] = useState(false)
const [loading2, setLoading2] = useState(false)
const [loading3, setLoading3] = useState(false)
const [loading4, setLoading4] = useState(false)
const handleAsyncAction = (setLoading: (loading: boolean) => void) => {
setLoading(true)
// 模拟异步操作
setTimeout(() => {
setLoading(false)
}, 3000)
}
return (
<div className="bg-background-default flex h-screen">
{/* 侧边栏演示 */}
<Sidebar />
{/* 主内容区域 */}
<div className="flex-1 overflow-auto">
<div className="container mx-auto space-y-8 p-8">
<h1 className="text-txt-primary-normal mb-8 text-center text-3xl font-bold">
Component demo
</h1>
{/* 侧边栏演示说明 */}
<section className="space-y-4">
<h2 className="text-txt-primary-normal text-2xl font-semibold">Collapsible Sidebar</h2>
<div className="bg-surface-base-normal rounded-2xl p-6">
<p className="txt-body-l text-txt-primary-normal mb-4">
The left sidebar supports the following functions:
</p>
<ul className="txt-body-l text-txt-secondary-normal list-inside list-disc space-y-2">
<li>Click the top fold/expand button to toggle the sidebar width</li>
<li>Navigation menu supports selection status and icon switching</li>
<li>Chat list shows user avatar, message preview, time, and unread count</li>
<li>Support user label and temperature display</li>
<li>The bottom notification function comes with a quantity badge.</li>
<li>Smooth animation transitions</li>
</ul>
</div>
</section>
{/* Alert Dialog 演示 */}
<section className="space-y-4">
<h2 className="text-txt-primary-normal text-2xl font-semibold">
Alert Dialog dialog box Alert Dialog dialog box Alert Dialog dialog box
</h2>
<div className="flex flex-wrap gap-4">
{/* 基础警告对话框 */}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">Remove warning</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Warning</AlertDialogTitle>
<AlertDialogDescription>
Using AI generation will overwrite the content you have already entered. Do
you want to continue?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction>Continue</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 带图标的对话框 */}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="primary">
AI generated confirmation AI generated confirmation
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogIcon>
<CrushLevelIcon />
</AlertDialogIcon>
<AlertDialogDescription className="text-center">
Using AI generation will overwrite the content you have already entered. Do
you want to continue?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter variant="vertical">
<AlertDialogAction>Continue</AlertDialogAction>
<AlertDialogCancel>Cancel</AlertDialogCancel>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 带 Loading 的对话框 */}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="tertiary">Loading dialog box Loading dialog box</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>confirm operation</AlertDialogTitle>
<AlertDialogDescription>
This operation will permanently delete your data. Are you sure you want to
continue?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel loading={loading1}>
{loading1 ? '取消中...' : '取消'}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
loading={loading2}
onClick={() => handleAsyncAction(setLoading2)}
>
{loading2 ? '删除中...' : '确认删除'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 无关闭按钮的对话框 */}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="secondary">No close button</Button>
</AlertDialogTrigger>
<AlertDialogContent showCloseButton={false}>
<AlertDialogHeader>
<AlertDialogTitle>Important Note</AlertDialogTitle>
<AlertDialogDescription>
This is an important reminder that you must choose an option to proceed.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Oh, I see.</AlertDialogCancel>
<AlertDialogAction>Continue operation</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</section>
{/* 普通按钮 Loading 演示 */}
<section className="space-y-4">
<h2 className="text-txt-primary-normal text-2xl font-semibold">
Normal Button Loading Effect Normal Button Loading Effect Normal Button Loading Effect
</h2>
<div className="flex flex-wrap gap-4">
<Button
variant="primary"
loading={loading1}
onClick={() => handleAsyncAction(setLoading1)}
>
{loading1 ? '加载中...' : '主要按钮'}
</Button>
<Button
variant="secondary"
loading={loading2}
onClick={() => handleAsyncAction(setLoading2)}
>
{loading2 ? '处理中...' : '次要按钮'}
</Button>
<Button
variant="destructive"
loading={loading3}
onClick={() => handleAsyncAction(setLoading3)}
>
{loading3 ? '删除中...' : '删除按钮'}
</Button>
<Button
variant="tertiary"
size="small"
loading={loading4}
onClick={() => handleAsyncAction(setLoading4)}
>
Small button
</Button>
</div>
</section>
{/* 图标按钮 Loading 演示 */}
<section className="space-y-4">
<h2 className="text-txt-primary-normal text-2xl font-semibold">
Icon button Loading effect Icon button Loading effect Icon button Loading effect
</h2>
<div className="flex flex-wrap gap-4">
<IconButton
variant="default"
size="large"
loading={loading1}
onClick={() => handleAsyncAction(setLoading1)}
>
{!loading1 && <span>🔄</span>}
</IconButton>
<IconButton
variant="tertiary"
size="small"
loading={loading2}
onClick={() => handleAsyncAction(setLoading2)}
>
{!loading2 && <span></span>}
</IconButton>
<IconButton
variant="destructive"
size="xs"
loading={loading3}
onClick={() => handleAsyncAction(setLoading3)}
>
{!loading3 && <span>🗑</span>}
</IconButton>
</div>
</section>
{/* 块级按钮 Loading 演示 */}
<section className="space-y-4">
<h2 className="text-txt-primary-normal text-2xl font-semibold">
Block Button Loading Effect Block Button Loading Effect Block Button Loading Effect
</h2>
<Button
variant="primary"
block
loading={loading1}
onClick={() => handleAsyncAction(setLoading1)}
>
{loading1 ? '正在提交表单...' : '提交表单'}
</Button>
</section>
{/* 控制按钮 */}
<section className="space-y-4">
<h2 className="text-txt-primary-normal text-2xl font-semibold">
Manually Control Loading Status Manually Control Loading Status Manually Control
Loading Status
</h2>
<div className="flex gap-4">
<Button variant="secondary" onClick={() => setLoading1(!loading1)}>
Switch Loading 1 Switch Loading 1 Switch Loading 1
</Button>
<Button variant="secondary" onClick={() => setLoading2(!loading2)}>
Toggle Loading 2 Toggle Loading 2 Toggle Loading 2
</Button>
<Button variant="secondary" onClick={() => setLoading3(!loading3)}>
Toggle Loading 3 Toggle Loading 3 Toggle Loading 3
</Button>
<Button variant="secondary" onClick={() => setLoading4(!loading4)}>
Toggle Loading 4 Toggle Loading 4 Toggle Loading 4
</Button>
</div>
</section>
</div>
</div>
</div>
)
}

View File

@ -1,28 +1,26 @@
import type { Metadata } from 'next'
import { Poppins, Oleo_Script_Swash_Caps } from 'next/font/google'
import localFont from 'next/font/local'
import '../css/iconfont.css'
import '../css/iconfont-v2.css'
import './globals.css'
import { Providers } from '@/lib/providers'
import { DeviceIdProvider } from '@/components/device-id-provider'
import ProgressBar from '@/context/progress'
import { MainLayoutProvider } from '@/context/mainLayout'
import ConditionalLayout from '@/components/layout/ConditionalLayout'
import type { Metadata } from 'next';
import { Poppins, Oleo_Script_Swash_Caps } from 'next/font/google';
import localFont from 'next/font/local';
import '../css/iconfont.css';
import '../css/iconfont-v2.css';
import './globals.css';
import { Providers } from '@/lib/providers';
import { DeviceIdProvider } from '@/components/device-id-provider';
import ProgressBar from '@/context/progress';
const poppins = Poppins({
variable: '--font-poppins',
weight: ['400', '500', '600', '700'],
display: 'swap',
subsets: ['latin'],
})
});
const oleoScriptSwashCaps = Oleo_Script_Swash_Caps({
variable: '--font-oleo-script-swash-caps',
weight: ['400'],
display: 'swap',
subsets: ['latin'],
})
});
const NumDisplay = localFont({
src: [
@ -34,18 +32,18 @@ const NumDisplay = localFont({
],
variable: '--font-display-num',
display: 'swap',
})
});
export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'),
title: 'CrushLevel',
description: 'CrushLevel - Next Generation Social Platform',
}
};
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
children: React.ReactNode;
}>) {
return (
<html lang="en">
@ -54,14 +52,10 @@ export default async function RootLayout({
>
<DeviceIdProvider>
<Providers>
<ProgressBar>
<MainLayoutProvider>
<ConditionalLayout>{children}</ConditionalLayout>
</MainLayoutProvider>
</ProgressBar>
<ProgressBar>{children}</ProgressBar>
</Providers>
</DeviceIdProvider>
</body>
</html>
)
);
}

View File

@ -1,16 +1,16 @@
'use client'
'use client';
// import { SocialButton } from './SocialButton'
import Link from 'next/link'
import { toast } from 'sonner'
import DiscordButton from './DiscordButton'
import GoogleButton from './GoogleButton'
import Link from 'next/link';
import { toast } from 'sonner';
import DiscordButton from './DiscordButton';
import GoogleButton from './GoogleButton';
export function LoginForm() {
const handleAppleLogin = () => {
toast.info('Apple Sign In', {
description: 'Apple登录功能正在开发中...',
})
}
});
};
return (
<div className="w-full space-y-3 sm:space-y-4">
@ -34,15 +34,15 @@ export function LoginForm() {
<div className="mt-4 text-center sm:mt-6">
<p className="txt-body-s sm:txt-body-m text-txt-secondary-normal">
By continuing, you agree to Crush Level's{' '}
<Link href="/policy/tos" target="_blank" className="text-primary-variant-normal">
<Link href="/policy/tos" className="text-primary-variant-normal">
Terms of Service
</Link>{' '}
and{' '}
<Link href="/policy/privacy" target="_blank" className="text-primary-variant-normal">
<Link href="/policy/privacy" className="text-primary-variant-normal">
Privacy Policy
</Link>
</p>
</div>
</div>
)
);
}

View File

@ -1,5 +1,5 @@
import type { Metadata } from 'next'
import MainPage from './(main)/home'
import type { Metadata } from 'next';
import { redirect } from 'next/navigation';
export const metadata: Metadata = {
title: 'CrushLevel AI - Grow Your Love Story',
@ -56,8 +56,8 @@ export const metadata: Metadata = {
alternates: {
canonical: 'https://www.crushlevel.com',
},
}
};
export default function HomePage() {
return <MainPage />
redirect('/home');
}

View File

@ -1,192 +0,0 @@
import { headers } from 'next/headers'
import { getServerDeviceId } from '@/lib/http/server'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
export default async function ServerDeviceTestPage() {
// 获取请求头信息
const headersList = await headers()
const userAgent = headersList.get('user-agent')
const xDeviceId = headersList.get('x-device-id')
// 获取当前设备ID
const currentDeviceId = await getServerDeviceId()
return (
<div className="container mx-auto space-y-6 p-6">
<div className="mb-8 text-center">
<h1 className="mb-2 text-3xl font-bold">🖥 ID测试</h1>
<p className="text-gray-600">
Device ID generation and management when testing server-side rendering
</p>
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{/* 设备ID信息 */}
<Card>
<CardHeader>
<CardTitle>📱 ID信息</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h4 className="mb-2 text-sm font-medium text-gray-700">
Current Device ID (Cookie) Current Device ID (Cookie)
</h4>
<div
className={`rounded border p-3 font-mono text-sm break-all ${
currentDeviceId ? 'bg-green-50' : 'bg-yellow-50'
}`}
>
{currentDeviceId || '等待中间件生成'}
</div>
</div>
<div>
<h4 className="mb-2 text-sm font-medium text-gray-700">
Device ID passed by middleware (Header) Device ID passed by middleware (Header)
</h4>
<div
className={`rounded border p-3 font-mono text-sm break-all ${
xDeviceId ? 'bg-blue-50' : 'bg-red-50'
}`}
>
{xDeviceId || '未传递'}
</div>
</div>
<div className="rounded bg-gray-50 p-3 text-sm">
<p className="mb-1 font-medium">Status description:</p>
<ul className="space-y-1 text-xs">
<li>
First visit: Middleware generates device ID and sets cookies First visit:
Middleware generates device ID and sets cookies First visit: Middleware
generates device ID and sets cookies
</li>
<li>
Subsequent visits: Read the existing device ID from the cookie Subsequent
visits: Read the existing device ID from the cookie Subsequent visits: Read the
existing device ID from the cookie
</li>
<li>
Device ID is passed to server level component via header Device ID is passed
to server level component via header
</li>
</ul>
</div>
</CardContent>
</Card>
{/* 请求信息 */}
<Card>
<CardHeader>
<CardTitle>🌐 </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h4 className="mb-2 text-sm font-medium text-gray-700">User-Agent</h4>
<div className="rounded border bg-gray-50 p-3 text-xs break-all">
{userAgent || '未知'}
</div>
</div>
<div>
<h4 className="mb-2 text-sm font-medium text-gray-700">render time</h4>
<div className="rounded border bg-gray-50 p-3 text-sm">
{new Date().toISOString()}
</div>
</div>
<div>
<h4 className="mb-2 text-sm font-medium text-gray-700">rendering environment</h4>
<div className="rounded border bg-gray-50 p-3 text-sm">
Server-side rendering (SSR) Server-side rendering (SSR)
</div>
</div>
</CardContent>
</Card>
</div>
{/* 功能说明 */}
<Card>
<CardHeader>
<CardTitle>📋 ID处理流程</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="rounded border bg-blue-50 p-4">
<h5 className="mb-2 font-medium text-blue-800">
1. Middleware processing (middleware.ts) 1. Middleware processing (middleware.ts) 1.
Middleware processing (middleware.ts)
</h5>
<p className="text-sm text-blue-700">
Check if there is a device ID cookie in the request Check if there is a device
ID cookie in the request Check if there is a device ID cookie in the request
<br />
If not, use User-Agent to generate a new device ID If not, use User-Agent to
generate a new device ID If not, use User-Agent to generate a new device ID If
not, use User-Agent to generate a new device ID
<br />
Set the device ID to respond to cookies Set the device ID to respond to cookies
<br />
Pass to server level component via x-device-id header Pass to server level component
via x-device-id header Pass to server level component via x-device-id header Pass to
server level component via x-device-id header Pass to server level component via
x-device-id header
</p>
</div>
<div className="rounded border bg-green-50 p-4">
<h5 className="mb-2 font-medium text-green-800">2. DeviceIdProvider </h5>
<p className="text-sm text-green-700">
Check device ID status in root layout Check device ID status in root layout
<br />
Get the device ID from both cookies and headers Get the device ID from both cookies
and headers
<br /> Record device ID status for debugging Record device ID status for
debugging
</p>
</div>
<div className="rounded border bg-yellow-50 p-4">
<h5 className="mb-2 font-medium text-yellow-800">
3. API calls at the server level 3. API calls at the server level
</h5>
<p className="text-sm text-yellow-700">
All server level API requests attempt to carry the device ID All server level API
requests attempt to carry the device ID
<br />
Send via AUTH_DID request header Send via AUTH_DID request header Send via
AUTH_DID request header Send via AUTH_DID request header
<br /> Pre-request function that supports server-side rendering Pre-request
function that supports server-side rendering
</p>
</div>
<div className="rounded border bg-purple-50 p-4">
<h5 className="mb-2 font-medium text-purple-800">4. Restrictions 4. Restrictions</h5>
<p className="text-sm text-purple-700">
App Router can only modify cookies in middleware or Route Handler App Router can
only modify cookies in middleware or Route Handler App Router can only modify
cookies in middleware or Route Handler App Router can only modify cookies in
middleware or Route Handler App Router can only modify cookies in middleware or
Route Handler App Router can only modify cookies in middleware or Route Handler
App Router can only modify cookies in middleware or Route Handler App Router can
only modify cookies in middleware or Route Handler
<br />
Server level components can only read cookies and cannot be modified Server
level components can only read cookies and cannot be modified
<br />
The device ID may not be available on subsequent requests until the first visit The
device ID may not be available on subsequent requests until the first visit
<br />
This is an architectural limitation of Next.js 13 + This is an architectural
limitation of Next.js 13 + This is an architectural limitation of Next.js 13 + This
is an architectural limitation of Next.js 13 + This is an architectural limitation
of Next.js 13 +
</p>
</div>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@ -1,241 +0,0 @@
'use client'
import { useState, useRef } from 'react'
import { AvatarCropModal } from '@/components/ui/avatar-crop-modal'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import Image from 'next/image'
export default function TestAvatarCropPage() {
const [selectedImage, setSelectedImage] = useState<File | null>(null)
const [imageUrl, setImageUrl] = useState<string>('')
const [showCropModal, setShowCropModal] = useState(false)
const [croppedAvatarUrl, setCroppedAvatarUrl] = useState<string>('')
const fileInputRef = useRef<HTMLInputElement>(null)
// 处理文件选择
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (file) {
setSelectedImage(file)
const url = URL.createObjectURL(file)
setImageUrl(url)
}
}
// 处理裁剪确认
const handleCropConfirm = (croppedImageUrl: string) => {
setCroppedAvatarUrl(croppedImageUrl)
setShowCropModal(false)
}
// 重置
const handleReset = () => {
setSelectedImage(null)
setImageUrl('')
setCroppedAvatarUrl('')
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
// 下载头像
const handleDownload = async () => {
if (!croppedAvatarUrl) return
try {
const response = await fetch(croppedAvatarUrl)
const blob = await response.blob()
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = 'avatar.jpg'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
} catch (error) {
console.error('Error downloading avatar:', error)
}
}
return (
<div className="bg-surface-bg-normal min-h-screen p-8">
<div className="mx-auto max-w-4xl space-y-8">
{/* 标题 */}
<div className="text-center">
<h1 className="text-txt-primary-normal mb-2 text-3xl font-bold">
Avatar Crop Component Test
</h1>
<p className="text-txt-secondary-normal">
Avatar clipping pop-up window based on design draft restoration
</p>
</div>
{/* 文件上传 */}
<Card className="p-6">
<h2 className="mb-4 text-xl font-semibold"></h2>
<div className="flex items-center gap-4">
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileSelect}
className="hidden"
/>
<Button onClick={() => fileInputRef.current?.click()} variant="primary">
</Button>
{selectedImage && (
<Button onClick={handleReset} variant="secondary">
reset
</Button>
)}
{imageUrl && (
<span className="text-txt-secondary-normal">Selected: {selectedImage?.name}</span>
)}
</div>
</Card>
{/* 裁剪操作 */}
{imageUrl && (
<Card className="p-6">
<h2 className="mb-4 text-xl font-semibold">crop avatar</h2>
<div className="flex items-center gap-4">
<Button onClick={() => setShowCropModal(true)} variant="primary" size="large">
Open avatar clipper
</Button>
<div className="text-txt-secondary-normal">
Click the button to open the cropping pop-up window and adjust the position and size
of the picture.
</div>
</div>
</Card>
)}
{/* 裁剪结果 */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{/* 原图预览 */}
{imageUrl && (
<Card className="p-6">
<h2 className="mb-4 text-xl font-semibold">original image</h2>
<div className="relative mx-auto aspect-square w-full max-w-sm">
<Image
src={imageUrl}
alt="original image"
fill
className="border-outline-normal rounded-lg border object-cover"
/>
</div>
</Card>
)}
{/* 裁剪后的头像 */}
{croppedAvatarUrl && (
<Card className="p-6">
<h2 className="mb-4 text-xl font-semibold">Cropped avatar</h2>
<div className="space-y-4">
<div className="relative mx-auto h-48 w-48">
<Image
src={croppedAvatarUrl}
alt="Cropped avatar"
fill
className="border-outline-normal rounded-full border-2 object-cover"
/>
</div>
<div className="text-center">
<Button onClick={handleDownload} variant="secondary" size="small">
Download avatar
</Button>
</div>
</div>
</Card>
)}
</div>
{/* 功能说明 */}
<Card className="p-6">
<h2 className="mb-4 text-xl font-semibold">Function Description</h2>
<div className="text-txt-secondary-normal space-y-2">
<div>
Support drag and drop to adjust the position of the picture Support drag and drop
to adjust the position of the picture
</div>
<div>
Use sliders or +/- buttons to adjust zoom • Use sliders or +/- buttons to adjust
zoom Use sliders or +/- buttons to adjust zoom • Use sliders or +/- buttons to
adjust zoom
</div>
<div> Automatically generate round avatars Automatically generate round avatars</div>
<div>
Click the background or X button to close the pop-up window Click the background
or X button to close the pop-up window Click the background or X button to close the
pop-up window Click the background or X button to close the pop-up window
</div>
<div>
Cancel Cancel operation, Confirm crop Cancel Cancel operation, Confirm crop
Cancel Cancel operation, Confirm crop Cancel Cancel operation, Confirm crop
</div>
</div>
</Card>
{/* 设计还原度对比 */}
<Card className="p-6">
<h2 className="mb-4 text-xl font-semibold">Design Restore</h2>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<h3 className="mb-2 font-medium">
Implemented design elements Implemented design elements
</h3>
<div className="text-txt-secondary-normal space-y-1 text-sm">
<div> Dark translucent background mask Dark translucent background mask</div>
<div>
Highlight the circular cropping area Highlight the circular cropping area
</div>
<div> Bottom zoom control slider Bottom zoom control slider</div>
<div> +/- zoom button • +/- zoom button +/- zoom button</div>
<div> Cancel Confirm </div>
<div>
Close button in the upper left corner Close button in the upper left corner
</div>
<div>
Confirm button for gradual color change Confirm button for gradual color
change Confirm button for gradual color change Confirm button for gradual
color change
</div>
</div>
</div>
<div>
<h3 className="mb-2 font-medium">🎨 </h3>
<div className="text-txt-secondary-normal space-y-1 text-sm">
<div>
The button adopts frosted glass effect The button adopts frosted glass effect
</div>
<div> Slider uses white round buttons Slider uses white round buttons</div>
<div>
A full-screen pop-up window that completely covers the screen A full-screen
pop-up window that completely covers the screen
</div>
<div> Responsive layout for mobile end Responsive layout for mobile end</div>
<div>
Smooth interactive animation effects Smooth interactive animation effects
</div>
</div>
</div>
</div>
</Card>
</div>
{/* 头像裁剪弹窗 */}
{selectedImage && (
<AvatarCropModal
isOpen={showCropModal}
onClose={() => setShowCropModal(false)}
image={selectedImage}
onConfirm={handleCropConfirm}
onCancel={() => setShowCropModal(false)}
/>
)}
</div>
)
}

View File

@ -1,62 +0,0 @@
'use client'
import { useState } from 'react'
import AvatarSetting from '@/app/(main)/profile/components/AvatarSetting'
import { Button } from '@/components/ui/button'
export default function TestAvatarSettingPage() {
const [isAvatarSettingOpen, setIsAvatarSettingOpen] = useState(false)
const [currentAvatar, setCurrentAvatar] = useState<string>('')
const handleAvatarChange = (avatarUrl: string) => {
setCurrentAvatar(avatarUrl)
console.log('Avatar changed:', avatarUrl)
}
const handleAvatarDelete = () => {
setCurrentAvatar('')
console.log('Avatar deleted')
}
const openAvatarSetting = () => {
setIsAvatarSettingOpen(true)
}
const closeAvatarSetting = () => {
setIsAvatarSettingOpen(false)
}
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-8 bg-[#211a2b]">
{/* 当前头像显示 */}
<div className="text-center">
<h1 className="mb-4 text-2xl font-semibold text-white">avatar setup test</h1>
<div className="mx-auto mb-4 flex h-32 w-32 items-center justify-center overflow-hidden rounded-full bg-[#352e3e]">
{currentAvatar ? (
<img src={currentAvatar} alt="Current Avatar" className="h-full w-full object-cover" />
) : (
<i className="iconfont icon-user text-4xl text-white opacity-50" />
)}
</div>
<p className="text-sm text-white opacity-70">
{currentAvatar ? '已设置头像' : '未设置头像'}
</p>
</div>
{/* 打开头像设置按钮 */}
<Button
onClick={openAvatarSetting}
className="h-12 rounded-full bg-gradient-to-l from-[#f264a4] to-[#c241e6] px-8 py-3 text-base leading-6 font-medium text-white transition-opacity hover:opacity-90"
>
Open avatar settings
</Button>
{/* 头像设置模态框 */}
<AvatarSetting
isOpen={isAvatarSettingOpen}
onClose={closeAvatarSetting}
currentAvatar={currentAvatar}
/>
</div>
)
}

View File

@ -1,284 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { discordOAuth } from '@/lib/oauth/discord'
import { useLogin, useCurrentUser, useLogout } from '@/hooks/auth'
import { AppClient, ThirdType } from '@/services/auth/types'
import { tokenManager } from '@/lib/auth/token'
import { toast } from 'sonner'
export default function TestDiscordPage() {
const [logs, setLogs] = useState<string[]>([])
const login = useLogin()
const logout = useLogout()
const { data: currentUser, isLoading, error } = useCurrentUser()
const addLog = (message: string) => {
const timestamp = new Date().toLocaleTimeString()
setLogs((prev) => [...prev, `[${timestamp}] ${message}`])
}
useEffect(() => {
// 检查URL中是否有错误参数
const urlParams = new URLSearchParams(window.location.search)
const errorParam = urlParams.get('error')
if (errorParam) {
const errorMessages: Record<string, string> = {
discord_denied: 'Discord授权被用户拒绝',
discord_no_code: 'Discord授权码缺失',
discord_auth_failed: 'Discord认证失败',
discord_callback_error: 'Discord回调处理错误',
}
const errorMessage = errorMessages[errorParam] || `未知错误: ${errorParam}`
addLog(`${errorMessage}`)
toast.error('Discord login failed', { description: errorMessage })
}
}, [])
const handleDiscordOAuthFlow = () => {
addLog('🚀 开始Discord OAuth流程...')
addLog('📋 新流程: 前端获取code -> 调用后端API')
try {
const state = Math.random().toString(36).substring(2, 15)
const authUrl = discordOAuth.getAuthUrl(state)
sessionStorage.setItem('discord_oauth_state', state)
addLog(`📱 生成授权URL: ${authUrl}`)
addLog('🔄 即将跳转到Discord授权页面...')
addLog('💡 授权后将返回/login页面并自动处理登录')
// 延迟跳转,让用户看到日志
setTimeout(() => {
window.location.href = authUrl
}, 1000)
} catch (error) {
addLog(`❌ Discord OAuth流程失败: ${error}`)
toast.error('Discord OAuth失败', { description: String(error) })
}
}
const handleMockDiscordLogin = () => {
addLog('🎭 开始Mock Discord登录...')
try {
const deviceId = tokenManager.getDeviceId()
const loginData = {
appClient: AppClient.Web,
deviceCode: deviceId,
thirdToken: 'mock_discord_code_12345',
thirdType: ThirdType.Discord,
}
addLog(`📤 发送登录请求: ${JSON.stringify(loginData, null, 2)}`)
login.mutate(loginData)
} catch (error) {
addLog(`❌ Mock登录失败: ${error}`)
toast.error('Mock login failed', { description: String(error) })
}
}
const handleLogout = () => {
addLog('🚪 开始退出登录...')
logout.mutate()
}
const clearLogs = () => {
setLogs([])
}
const testEnvironmentVariables = () => {
addLog('🔍 检查环境变量...')
const discordClientId = process.env.NEXT_PUBLIC_DISCORD_CLIENT_ID
const appUrl = process.env.NEXT_PUBLIC_APP_URL
const mockEnabled = process.env.NEXT_PUBLIC_ENABLE_MOCK
addLog(`NEXT_PUBLIC_DISCORD_CLIENT_ID: ${discordClientId ? '✅ 已设置' : '❌ 未设置'}`)
addLog(`NEXT_PUBLIC_APP_URL: ${appUrl || 'http://localhost:3000 (默认)'}`)
addLog(`NEXT_PUBLIC_ENABLE_MOCK: ${mockEnabled || 'false (默认)'}`)
if (!discordClientId) {
addLog('⚠️ Discord Client ID未配置请检查环境变量')
toast.warning('Missing configuration', { description: 'Discord Client ID未配置' })
}
}
useEffect(() => {
if (login.isSuccess) {
addLog('✅ 登录成功!')
toast.success('Login successful')
}
if (login.error) {
addLog(`❌ 登录失败: ${login.error.message}`)
}
}, [login.isSuccess, login.error])
useEffect(() => {
if (logout.isSuccess) {
addLog('✅ 退出成功!')
toast.success('Exit successfully')
}
if (logout.error) {
addLog(`❌ 退出失败: ${logout.error.message}`)
}
}, [logout.isSuccess, logout.error])
return (
<div className="container mx-auto space-y-6 p-6">
<div className="text-center">
<h1 className="mb-2 text-3xl font-bold">Discord login test page</h1>
<p className="text-gray-600">
Testing Discord OAuth Login Function and Mock Interface Testing Discord OAuth Login
Function and Mock Interface
</p>
</div>
{/* 用户状态 */}
<Card>
<CardHeader>
<CardTitle>user status</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<p>Loading...</p>
) : currentUser ? (
<div className="space-y-2">
<p>
<strong>User ID:</strong> {currentUser.userId}
</p>
<p>
<strong>Nickname:</strong> {currentUser.nickname}
</p>
<p>
<strong>Avatar:</strong> {currentUser.headImage || '无'}
</p>
<p>
<strong>Gender:</strong>{' '}
{currentUser.sex === 1 ? '男' : currentUser.sex === 2 ? '女' : '未知'}
</p>
<p>
<strong>Birthday:</strong> {currentUser.birthday}
</p>
<p>
<strong>Need to improve information:</strong> {currentUser.cpUserInfo ? '是' : '否'}
</p>
</div>
) : (
<p>Not logged in</p>
)}
</CardContent>
</Card>
{/* 操作按钮 */}
<Card>
<CardHeader>
<CardTitle>test operation</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<Button onClick={testEnvironmentVariables} variant="secondary">
🔍
</Button>
<Button onClick={handleMockDiscordLogin} variant="tertiary">
🎭 Mock Discord登录
</Button>
<Button onClick={handleDiscordOAuthFlow} className="col-span-2">
🎮 Discord OAuth登录
</Button>
{currentUser && (
<Button onClick={handleLogout} variant="secondary" className="col-span-2">
🚪 退
</Button>
)}
</div>
</CardContent>
</Card>
{/* 操作日志 */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>operation log</CardTitle>
<Button onClick={clearLogs} variant="tertiary" size="small">
clear log
</Button>
</CardHeader>
<CardContent>
<div className="max-h-96 overflow-y-auto rounded-lg bg-gray-100 p-4">
{logs.length === 0 ? (
<p className="text-gray-500">No log yet</p>
) : (
<pre className="text-sm">
{logs.map((log, index) => (
<div key={index} className="mb-1">
{log}
</div>
))}
</pre>
)}
</div>
</CardContent>
</Card>
{/* 配置说明 */}
<Card>
<CardHeader>
<CardTitle>configuration instructions</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4 text-sm">
<div>
<h4 className="font-semibold">New process description:</h4>
<div className="mt-2 rounded bg-blue-50 p-3">
<ol className="list-inside list-decimal space-y-1">
<li>Users click on Discord to log in</li>
<li>Go to the Discord license page</li>
<li>Discord回调到 /api/auth/discord/callback</li>
<li>
Callback route gets code and redirects to /login? discord_code = xxx Callback
route gets code and redirects to /login? discord_code = xxx
</li>
<li>
The front-end login page detects the code, and calls the back-end API to
complete the login.
</li>
</ol>
</div>
</div>
<div>
<h4 className="font-semibold">Environment variables:</h4>
<pre className="mt-2 rounded bg-gray-100 p-2">
{`NEXT_PUBLIC_DISCORD_CLIENT_ID=your_discord_client_id
DISCORD_CLIENT_SECRET=your_discord_client_secret # 使
NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_ENABLE_MOCK=true`}
</pre>
</div>
<div>
<h4 className="font-semibold">Discord回调URL:</h4>
<p className="mt-2 rounded bg-gray-100 p-2">
Http://localhost:3000/api/auth/discord/callback
</p>
</div>
<div>
<h4 className="font-semibold">API interface:</h4>
<pre className="mt-2 rounded bg-gray-100 p-2">
{`POST /web/third/login
{
"appClient": "WEB",
"deviceCode": "设备ID",
"thirdToken": "discord_code",
"thirdType": "DISCORD"
}`}
</pre>
</div>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@ -1,220 +0,0 @@
'use client'
import { useState, useRef } from 'react'
import {
ImageCropModal,
SimpleImageCropModal,
InlineImageCrop,
} from '@/components/ui/image-crop-modal'
import { ImageCrop, CropPresets, downloadCroppedImage } from '@/components/ui/image-crop'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import Image from 'next/image'
export default function TestImageCropPage() {
const [selectedImage, setSelectedImage] = useState<File | null>(null)
const [imageUrl, setImageUrl] = useState<string>('')
const [showCropModal, setShowCropModal] = useState(false)
const [showSimpleCropModal, setShowSimpleCropModal] = useState(false)
const [croppedImageUrls, setCroppedImageUrls] = useState<string[]>([])
const [currentPreset, setCurrentPreset] = useState<'avatar' | 'cover' | 'thumbnail' | 'free'>(
'avatar'
)
const fileInputRef = useRef<HTMLInputElement>(null)
// 处理文件选择
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (file) {
setSelectedImage(file)
const url = URL.createObjectURL(file)
setImageUrl(url)
}
}
// 处理裁剪完成
const handleCropSave = (croppedImageUrl: string) => {
setCroppedImageUrls((prev) => [...prev, croppedImageUrl])
}
// 重置
const handleReset = () => {
setSelectedImage(null)
setImageUrl('')
setCroppedImageUrls([])
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
// 下载裁剪后的图片
const handleDownload = async (imageUrl: string, index: number) => {
try {
const response = await fetch(imageUrl)
const blob = await response.blob()
downloadCroppedImage(blob, `cropped-image-${index + 1}`)
} catch (error) {
console.error('Error downloading image:', error)
}
}
return (
<div className="bg-surface-bg-normal min-h-screen p-8">
<div className="mx-auto max-w-6xl space-y-8">
{/* 标题 */}
<div className="text-center">
<h1 className="text-txt-primary-normal mb-2 text-3xl font-bold">Image cropping test</h1>
<p className="text-txt-secondary-normal">
Test various image cropping features and preset configurations
</p>
</div>
{/* 文件上传 */}
<Card className="p-6">
<h2 className="mb-4 text-xl font-semibold"></h2>
<div className="flex items-center gap-4">
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileSelect}
className="hidden"
/>
<Button onClick={() => fileInputRef.current?.click()} variant="primary">
</Button>
{selectedImage && (
<Button onClick={handleReset} variant="secondary">
reset
</Button>
)}
{imageUrl && (
<span className="text-txt-secondary-normal">Selected: {selectedImage?.name}</span>
)}
</div>
</Card>
{/* 预设选择 */}
{imageUrl && (
<Card className="p-6">
<h2 className="mb-4 text-xl font-semibold">Select crop preset</h2>
<div className="flex flex-wrap gap-3">
{Object.keys(CropPresets).map((preset) => (
<Button
key={preset}
variant={currentPreset === preset ? 'primary' : 'tertiary'}
size="small"
onClick={() => setCurrentPreset(preset as any)}
>
{preset === 'avatar' && '头像 (圆形)'}
{preset === 'cover' && '封面 (16:9)'}
{preset === 'thumbnail' && '缩略图 (4:3)'}
{preset === 'free' && '自由裁剪'}
</Button>
))}
</div>
</Card>
)}
{/* 裁剪操作 */}
{imageUrl && (
<Card className="p-6">
<h2 className="mb-4 text-xl font-semibold">cropping operation</h2>
<div className="flex flex-wrap gap-4">
<Button onClick={() => setShowCropModal(true)} variant="primary">
Open the advanced cropping pop-up
</Button>
<Button onClick={() => setShowSimpleCropModal(true)} variant="secondary">
Open the simple crop pop-up window
</Button>
</div>
</Card>
)}
{/* 内联裁剪器 */}
{imageUrl && (
<Card className="p-6">
<h2 className="mb-4 text-xl font-semibold">Internal connection clipper</h2>
<InlineImageCrop
image={imageUrl}
onSave={(croppedImageUrl: string) => handleCropSave(croppedImageUrl)}
preset={currentPreset}
className="h-96"
/>
</Card>
)}
{/* 裁剪结果 */}
{croppedImageUrls.length > 0 && (
<Card className="p-6">
<h2 className="mb-4 text-xl font-semibold">crop result</h2>
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
{croppedImageUrls.map((url, index) => (
<div key={index} className="space-y-2">
<div className="relative aspect-square">
<Image
src={url}
alt={`裁剪结果 ${index + 1}`}
fill
className="border-outline-normal rounded-lg border object-cover"
/>
</div>
<Button
size="small"
variant="tertiary"
onClick={() => handleDownload(url, index)}
className="w-full"
>
download
</Button>
</div>
))}
</div>
</Card>
)}
{/* 原图预览 */}
{imageUrl && (
<Card className="p-6">
<h2 className="mb-4 text-xl font-semibold">Original image preview</h2>
<div className="relative mx-auto aspect-video w-full max-w-md">
<Image
src={imageUrl}
alt="original image"
fill
className="border-outline-normal rounded-lg border object-contain"
/>
</div>
</Card>
)}
</div>
{/* 高级裁剪弹窗 */}
{selectedImage && (
<ImageCropModal
isOpen={showCropModal}
onClose={() => setShowCropModal(false)}
image={selectedImage}
onSave={handleCropSave}
title="Advanced image crop"
preset={currentPreset}
cropConfig={{
showGrid: true,
showControls: true,
}}
/>
)}
{/* 简单裁剪弹窗 */}
{selectedImage && (
<SimpleImageCropModal
isOpen={showSimpleCropModal}
onClose={() => setShowSimpleCropModal(false)}
image={selectedImage}
onSave={handleCropSave}
title="Simple image cropping"
/>
)}
</div>
)
}

View File

@ -1,67 +0,0 @@
'use client'
import React, { useState } from 'react'
export default function TestLamejs() {
const [status, setStatus] = useState<string>('未测试')
const [error, setError] = useState<string>('')
const testLamejs = async () => {
try {
setStatus('正在加载 lamejs...')
setError('')
// 动态导入 lamejs
const lamejsModule = await import('lamejs')
const lamejs = lamejsModule.default || lamejsModule
console.log('lamejs 模块:', lamejs)
console.log('Mp3Encoder:', lamejs.Mp3Encoder)
console.log('MPEGMode:', lamejs.MPEGMode)
// 测试创建编码器
if (lamejs.Mp3Encoder) {
const encoder = new lamejs.Mp3Encoder(1, 44100, 128)
console.log('编码器创建成功:', encoder)
setStatus('✅ lamejs 加载成功!')
} else {
setStatus('❌ Mp3Encoder 未找到')
}
} catch (err) {
console.error('lamejs 测试失败:', err)
setError(err instanceof Error ? err.message : '未知错误')
setStatus('❌ 加载失败')
}
}
return (
<div className="min-h-screen bg-gray-100 p-8">
<div className="mx-auto max-w-2xl rounded-lg bg-white p-6 shadow-lg">
<h1 className="mb-6 text-center text-2xl font-bold">lamejs </h1>
<div className="space-y-4">
<button
onClick={testLamejs}
className="w-full rounded-lg bg-blue-500 px-6 py-3 text-white transition-colors hover:bg-blue-600"
>
lamejs
</button>
<div className="rounded-lg bg-gray-50 p-4">
<h3 className="mb-2 font-medium"></h3>
<p className="text-sm">{status}</p>
{error && <p className="mt-2 text-sm text-red-500">: {error}</p>}
</div>
<div className="rounded-lg bg-blue-50 p-4">
<h3 className="mb-2 font-medium"></h3>
<p className="text-sm text-gray-600">
lamejs
</p>
</div>
</div>
</div>
</div>
)
}

View File

@ -1,109 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
export default function TestMiddlewarePage() {
const [mswStatus, setMswStatus] = useState<string>('检查中...')
const [pageInfo, setPageInfo] = useState<any>({})
useEffect(() => {
// 检查 Service Worker 状态
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then((registrations) => {
const mswWorker = registrations.find((reg) =>
reg.active?.scriptURL.includes('mockServiceWorker')
)
if (mswWorker) {
setMswStatus(`✅ MSW Service Worker 已激活: ${mswWorker.active?.scriptURL}`)
} else {
setMswStatus('❌ MSW Service Worker 未找到')
}
})
} else {
setMswStatus('❌ 浏览器不支持 Service Worker')
}
// 获取页面信息
setPageInfo({
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: new Date().toISOString(),
env: {
NODE_ENV: process.env.NODE_ENV,
ENABLE_MOCK: process.env.NEXT_PUBLIC_ENABLE_MOCK,
},
})
}, [])
const testDirectNavigation = () => {
// 直接导航测试
window.location.href = '/profile'
}
const testProgrammaticNavigation = () => {
// 编程式导航测试
window.history.pushState({}, '', '/profile')
window.location.reload()
}
return (
<div className="mx-auto max-w-4xl space-y-6 p-6 text-black">
<h1 className="text-2xl font-bold">🔧 Middleware </h1>
<div className="rounded bg-gray-100 p-4">
<h2 className="mb-2 text-lg font-semibold">MSW status MSW status</h2>
<p>{mswStatus}</p>
</div>
<div className="rounded bg-gray-100 p-4">
<h2 className="mb-2 text-lg font-semibold">page information</h2>
<pre className="text-sm">{JSON.stringify(pageInfo, null, 2)}</pre>
</div>
<div className="space-y-4">
<h2 className="text-lg font-semibold">Navigation test</h2>
<div className="space-x-4">
<button
onClick={testDirectNavigation}
className="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
>
Navigate directly to /profile Navigate directly to /profile
</button>
<button
onClick={testProgrammaticNavigation}
className="rounded bg-green-500 px-4 py-2 text-white hover:bg-green-600"
>
Programmatically navigate to /profile Programmatically navigate to /profile
</button>
</div>
</div>
<div className="rounded bg-yellow-100 p-4">
<h3 className="font-semibold">explain</h3>
<p className="text-sm">
If you click the above button to navigate to /profile and do not see the middleware log in
the console, MSW or something else is preventing middleware from executing. If you click
the above button to navigate to /profile and do not see the middleware log in the console,
MSW or something else is preventing middleware from executing. If you click the above
button to navigate to /profile and do not see the middleware log in the console, MSW or
something else is preventing middleware from executing. If you click the above button to
navigate to /profile and do not see the middleware log in the console, MSW or something
else is preventing middleware from executing. If you click the above button to navigate to
/profile and do not see the middleware log in the console, MSW or something else is
preventing middleware from executing. If you click the above button to navigate to
/profile and do not see the middleware log in the console, MSW or something else is
preventing middleware from executing. If you click the above button to navigate to
/profile and do not see the middleware log in the console, MSW or something else is
preventing middleware from executing. If you click the above button to navigate to
/profile and do not see the middleware log in the console, MSW or something else is
preventing middleware from executing. If you click the above button to navigate to
/profile and do not see the middleware log in the console, MSW or something else is
preventing middleware from executing. If you click the above button to navigate to
/profile and do not see the middleware log in the console, MSW or something else is
preventing middleware from executing.
</p>
</div>
</div>
)
}

View File

@ -1,173 +0,0 @@
'use client'
import React, { useState, useRef } from 'react'
import { convertToMp3, createAudioConverter } from '@/lib/audio/audio-converter'
import { toast } from 'sonner'
export default function TestMp3Conversion() {
const [isRecording, setIsRecording] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
const [mp3Blob, setMp3Blob] = useState<Blob | null>(null)
const [originalBlob, setOriginalBlob] = useState<Blob | null>(null)
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
const audioChunksRef = useRef<Blob[]>([])
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
const mediaRecorder = new MediaRecorder(stream)
mediaRecorderRef.current = mediaRecorder
audioChunksRef.current = []
mediaRecorder.ondataavailable = (event) => {
audioChunksRef.current.push(event.data)
}
mediaRecorder.onstop = async () => {
const originalAudioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' })
setOriginalBlob(originalAudioBlob)
setIsProcessing(true)
try {
const mp3Blob = await convertToMp3(originalAudioBlob, {
sampleRate: 44100,
bitRate: 128,
channels: 1,
})
setMp3Blob(mp3Blob)
toast.success('MP3转换完成')
} catch (error) {
console.error('MP3转换失败:', error)
toast.error('MP3转换失败')
} finally {
setIsProcessing(false)
}
stream.getTracks().forEach((track) => track.stop())
}
mediaRecorder.start()
setIsRecording(true)
} catch (error) {
console.error('录音失败:', error)
toast.error('录音失败')
}
}
const stopRecording = () => {
if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop()
setIsRecording(false)
}
}
const downloadMp3 = () => {
if (mp3Blob) {
const url = URL.createObjectURL(mp3Blob)
const a = document.createElement('a')
a.href = url
a.download = `test-recording-${Date.now()}.mp3`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast.success('MP3文件已下载')
}
}
const downloadOriginal = () => {
if (originalBlob) {
const url = URL.createObjectURL(originalBlob)
const a = document.createElement('a')
a.href = url
a.download = `original-recording-${Date.now()}.webm`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast.success('原始文件已下载')
}
}
return (
<div className="min-h-screen bg-gray-100 p-8">
<div className="mx-auto max-w-2xl rounded-lg bg-white p-6 shadow-lg">
<h1 className="mb-6 text-center text-2xl font-bold">MP3转换测试</h1>
<div className="space-y-4">
{/* 录音控制 */}
<div className="flex justify-center space-x-4">
{!isRecording ? (
<button
onClick={startRecording}
className="rounded-lg bg-red-500 px-6 py-3 text-white transition-colors hover:bg-red-600"
>
</button>
) : (
<button
onClick={stopRecording}
className="rounded-lg bg-gray-500 px-6 py-3 text-white transition-colors hover:bg-gray-600"
>
</button>
)}
</div>
{/* 状态显示 */}
<div className="text-center">
{isRecording && <p className="font-medium text-red-500">🔴 ...</p>}
{isProcessing && <p className="font-medium text-blue-500"> MP3...</p>}
</div>
{/* 文件信息 */}
{originalBlob && (
<div className="rounded-lg bg-gray-50 p-4">
<h3 className="mb-2 font-medium"></h3>
<p>: {(originalBlob.size / 1024).toFixed(1)} KB</p>
<p>: {originalBlob.type}</p>
<button
onClick={downloadOriginal}
className="mt-2 rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
>
</button>
</div>
)}
{mp3Blob && (
<div className="rounded-lg bg-green-50 p-4">
<h3 className="mb-2 font-medium">MP3文件信息</h3>
<p>: {(mp3Blob.size / 1024).toFixed(1)} KB</p>
<p>: {mp3Blob.type}</p>
<p>
:{' '}
{originalBlob
? `${((1 - mp3Blob.size / originalBlob.size) * 100).toFixed(1)}%`
: 'N/A'}
</p>
<button
onClick={downloadMp3}
className="mt-2 rounded bg-green-500 px-4 py-2 text-white hover:bg-green-600"
>
MP3文件
</button>
</div>
)}
{/* 音频播放测试 */}
{mp3Blob && (
<div className="rounded-lg bg-yellow-50 p-4">
<h3 className="mb-2 font-medium"></h3>
<audio controls className="w-full">
<source src={URL.createObjectURL(mp3Blob)} type="audio/mp3" />
</audio>
</div>
)}
</div>
</div>
</div>
)
}

View File

@ -1,45 +0,0 @@
import { S3UploadDemo } from '@/components/features/S3UploadDemo'
export default function TestS3UploadPage() {
return (
<div className="container mx-auto py-8">
<div className="flex flex-col items-center space-y-8">
<div className="text-center">
<h1 className="text-txt-primary-normal mb-4 text-3xl font-bold">
AWS S3 Upload Test AWS S3 Upload Test AWS S3 Upload Test
</h1>
<p className="text-txt-secondary-normal">
Testing the file upload feature using the AWS S3 SDK Testing the file upload feature
using the AWS S3 SDK Testing the file upload feature using the AWS S3 SDK Testing the
file upload feature using the AWS S3 SDK Testing the file upload feature using the AWS
S3 SDK
</p>
</div>
<S3UploadDemo />
<div className="bg-surface-element-normal max-w-2xl rounded-lg p-4">
<h2 className="text-txt-primary-normal mb-2 text-lg font-semibold">
How to use Hook How to use Hook
</h2>
<pre className="text-txt-secondary-normal overflow-x-auto text-xs">
{`import { useS3Upload } from '@/hooks/useS3Upload'
import { BizTypeEnum } from '@/services/common/types'
const { uploading, progress, error, uploadFile } = useS3Upload({
bizType: BizTypeEnum.Role,
maxRetries: 3,
retryDelay: 2000,
onSuccess: (url) => console.log('上传成功:', url),
onError: (error) => console.error('上传失败:', error),
onProgress: (progress) => console.log('进度:', progress.percentage + '%')
})
// 使用
await uploadFile(file)`}
</pre>
</div>
</div>
</div>
)
}

View File

@ -1,64 +1,68 @@
'use client'
'use client';
import React, { useRef, useEffect, useState } from 'react'
import { GetMeetListResponse } from '@/services/home/types'
import { formatNumberToKMB } from '@/lib/utils'
import { Tag } from '@/components/ui/tag'
import { Avatar, AvatarImage } from '@/components/ui/avatar'
import Link from 'next/link'
import React, { useRef, useEffect, useState } from 'react';
import { formatNumberToKMB } from '@/lib/utils';
import { Tag } from '@/components/ui/tag';
import { Avatar, AvatarImage } from '@/components/ui/avatar';
import Link from 'next/link';
import { CharacterType } from '@/services/editor/type';
interface AIStandardCardProps {
character: GetMeetListResponse
disableHover?: boolean
character: CharacterType;
disableHover?: boolean;
}
const AIStandardCard: React.FC<AIStandardCardProps> = React.memo(
({ character, disableHover = false }) => {
const {
aiId,
nickname,
characterName,
tagName,
headImg,
homeImageUrl,
introduction,
likedNum,
} = character
id,
name,
description,
coverImage,
sourceId,
sourceType,
headPortrait,
basicInfo,
exampleDialogue,
note,
firstSentence,
characterStand,
tagId,
greeting,
depth,
tags,
chatTarget,
} = character;
const introContainerRef = useRef<HTMLDivElement>(null)
const introTextRef = useRef<HTMLParagraphElement>(null)
const [maxLines, setMaxLines] = useState<number>(6)
const introContainerRef = useRef<HTMLDivElement>(null);
const introTextRef = useRef<HTMLParagraphElement>(null);
const [maxLines, setMaxLines] = useState<number>(6);
// 动态计算可用空间的行数
useEffect(() => {
const calculateMaxLines = () => {
if (introContainerRef.current) {
const containerHeight = introContainerRef.current.offsetHeight
const lineHeight = 20 // 对应 leading-[20px]
const calculatedLines = Math.floor(containerHeight / lineHeight)
const containerHeight = introContainerRef.current.offsetHeight;
const lineHeight = 20; // 对应 leading-[20px]
const calculatedLines = Math.floor(containerHeight / lineHeight);
// 确保至少显示 1 行,最多不超过合理的行数
const finalLines = Math.max(1, Math.min(calculatedLines, 12))
setMaxLines(finalLines)
}
const finalLines = Math.max(1, Math.min(calculatedLines, 12));
setMaxLines(finalLines);
}
};
calculateMaxLines()
calculateMaxLines();
// 监听窗口大小变化
window.addEventListener('resize', calculateMaxLines)
return () => window.removeEventListener('resize', calculateMaxLines)
}, [])
// 解析标签(假设是逗号分隔的字符串)
const tags = tagName ? tagName.split(',').filter((tag) => tag.trim()) : []
window.addEventListener('resize', calculateMaxLines);
return () => window.removeEventListener('resize', calculateMaxLines);
}, []);
// 获取显示的背景图片
const displayImage = homeImageUrl || headImg
const displayName = `${nickname}`
const displayImage = coverImage;
return (
<Link href={`/chat/${aiId}`} className="h-full w-full" prefetch={false}>
<Link href={`/character/${id}`} className="h-full w-full" prefetch={false}>
<div
className={`relative flex shrink-0 grow basis-0 cursor-pointer flex-col content-stretch items-start justify-start gap-3 ${disableHover ? '' : 'group'}`}
>
@ -87,41 +91,27 @@ const AIStandardCard: React.FC<AIStandardCardProps> = React.memo(
className="txt-headline-s text-txt-primary-normal"
style={{ wordBreak: 'break-word' }}
>
{displayName}
{name}
</div>
<div className="flex flex-wrap gap-1">
{/* 性格标签 */}
{characterName && <Tag size="small">{characterName}</Tag>}
{tags.length > 0 && (
{!!tags?.length && (
<div className="relative flex shrink-0 flex-wrap content-start items-start justify-start gap-1">
{tags.slice(0, 2).map((tag, index) => (
{tags?.slice(0, 2).map((tag, index) => (
<Tag key={index} size="small" className="backdrop-blur-[8px]">
{tag}
{tag.name}
</Tag>
))}
</div>
)}
</div>
{/*
{tags.length > 0 && (
<div className="content-start flex flex-wrap gap-1 items-start justify-start relative shrink-0 w-full">
{tags.slice(0, 2).map((tag, index) => (
<Tag key={index} size="small" className="backdrop-blur-[8px]">
{tag}
</Tag>
))}
</div>
)} */}
</div>
{/* 点赞数 */}
<div className="flex items-center gap-1 rounded-xs px-1 py-0.5">
<i className="iconfont icon-Like-fill" />
<span className="txt-label-s text-txt-primary-specialmap-normal">
{formatNumberToKMB(likedNum ?? 0)}
{formatNumberToKMB(1000)}
</span>
</div>
</div>
@ -133,9 +123,9 @@ const AIStandardCard: React.FC<AIStandardCardProps> = React.memo(
{/* 头像和名称 */}
<div className="flex w-full items-center gap-2">
<Avatar className="sh size-12">
<AvatarImage src={headImg} alt={nickname} />
<AvatarImage src={headPortrait} alt={name} />
</Avatar>
<div className="txt-title-s min-w-0 flex-1 truncate">{nickname}</div>
<div className="txt-title-s min-w-0 flex-1 truncate">{name}</div>
</div>
{/* 简介文本 */}
@ -152,7 +142,7 @@ const AIStandardCard: React.FC<AIStandardCardProps> = React.memo(
}}
>
<p ref={introTextRef} className="leading-[20px]">
{introduction || 'No introduction'}
{description || 'No description'}
</p>
</div>
</div>
@ -161,7 +151,7 @@ const AIStandardCard: React.FC<AIStandardCardProps> = React.memo(
<div className="flex items-center gap-1 rounded-xs px-1 py-0.5">
<i className="iconfont icon-Like-fill" />
<span className="txt-label-s text-txt-primary-specialmap-normal">
{formatNumberToKMB(likedNum ?? 0)}
{formatNumberToKMB(100)}
</span>
</div>
</div>
@ -176,8 +166,8 @@ const AIStandardCard: React.FC<AIStandardCardProps> = React.memo(
</div>
</div>
</Link>
)
);
}
)
);
export default AIStandardCard
export default AIStandardCard;

View File

@ -1,80 +0,0 @@
'use client'
import { usePathname } from 'next/navigation'
import { useEffect, useRef } from 'react'
import Sidebar from './Sidebar'
import Topbar from './Topbar'
import TopbarWithoutLogin from './TopBarWithoutLogin'
import ChargeDrawer from '../features/charge-drawer'
import SubscribeVipDrawer from '@/app/(main)/vip/components/SubscribeVipDrawer'
import { cn } from '@/lib/utils'
import CreateReachedLimitDialog from '../features/create-reached-limit-dialog'
import { useGlobalPrefetchRoutes } from '@/hooks/useGlobalPrefetchRoutes'
interface ConditionalLayoutProps {
children: React.ReactNode
}
export default function ConditionalLayout({ children }: ConditionalLayoutProps) {
const pathname = usePathname()
const mainContentRef = useRef<HTMLDivElement>(null)
const prevPathnameRef = useRef<string>(pathname)
useGlobalPrefetchRoutes()
// 路由切换时重置滚动位置
useEffect(() => {
if (prevPathnameRef.current !== pathname) {
if (mainContentRef.current) {
mainContentRef.current.scrollTop = 0
}
prevPathnameRef.current = pathname
}
}, [pathname])
// 定义不需要主布局的路由(认证相关页面)
const authRoutes = ['/login', '/register', '/auth', '/share']
const isAuthRoute = authRoutes.some((route) => pathname.startsWith(route))
const isWithLoginRoute = ['/about', '/policy'].some((route) => pathname.startsWith(route))
// 如果是认证路由,直接返回 children会使用各自的布局
if (isAuthRoute) {
return <>{children}</>
}
if (isWithLoginRoute) {
return (
<div className="flex h-screen overflow-hidden bg-[url('/common-bg.png')] bg-cover bg-fixed bg-top bg-no-repeat">
<div
ref={mainContentRef}
id="main-content"
className="relative flex min-w-0 flex-1 flex-col overflow-auto"
>
<TopbarWithoutLogin />
<main className="relative min-h-0 flex-1">{children}</main>
</div>
</div>
)
}
// 其他路由使用主布局包括首页、main路由组等
return (
<div className="flex h-screen bg-[url('/common-bg.png')] bg-[length:100%_30%] bg-top bg-no-repeat">
<Sidebar />
<div
ref={mainContentRef}
id="main-content"
className={cn(
'relative flex min-w-0 flex-1 flex-col overflow-auto',
pathname.startsWith('/chat') && 'overflow-hidden'
)}
>
<Topbar />
<main className="min-h-0 flex-1 pt-16">{children}</main>
</div>
<ChargeDrawer />
<SubscribeVipDrawer />
<CreateReachedLimitDialog />
</div>
)
}

View File

@ -1,185 +0,0 @@
'use client'
import React, { useState, useEffect, useMemo } from 'react'
import { MenuItem } from '@/types/global'
import Image from 'next/image'
import { cn } from '@/lib/utils'
// import ChatSidebar from './components/ChatSidebar'
import { Badge } from '../ui/badge'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { useMainLayout } from '@/context/mainLayout'
import { useCurrentUser } from '@/hooks/auth'
import Notice from './components/Notice'
import useCreatorNavigation from '@/hooks/useCreatorNavigation'
// 菜单项接口
interface IMenuItem {
id: MenuItem
icon: string
selectedIcon: string
label: string
link: string
isSelected: boolean
}
// 主侧边栏组件
function Sidebar() {
const pathname = usePathname()
const router = useRouter()
const { isSidebarExpanded, dispatch } = useMainLayout()
const { data: user } = useCurrentUser()
const { routerToCreate } = useCreatorNavigation()
// 在 /create/image 页面时强制收起侧边栏
const isImageCreatePage = [
'/generate/image',
'/generate/image-2-image',
'/generate/image-edit',
'/generate/image-2-background',
].includes(pathname)
const actualIsExpanded = isImageCreatePage ? false : isSidebarExpanded
const menuItems: IMenuItem[] = useMemo(
() => [
{
id: MenuItem.FOR_YOU,
icon: '/icons/explore.svg',
selectedIcon: '/icons/explore_selected.svg',
label: 'Home',
link: '/',
isSelected: pathname === '/',
},
// {
// id: MenuItem.CREATE,
// icon: '/icons/create.svg',
// selectedIcon: '/icons/create_selected.svg',
// label: 'Create a Character',
// link: '/create',
// isSelected: pathname.startsWith('/create'),
// },
// {
// id: MenuItem.EXPLORE,
// icon: "/icons/explore.svg",
// selectedIcon: "/icons/explore_selected.svg",
// label: "Explore",
// link: '/explore',
// isSelected: pathname.startsWith('/explore'),
// },
{
id: MenuItem.CHAT,
icon: '/icons/contact.svg',
selectedIcon: '/icons/contact_selected.svg',
label: 'My Crushes',
link: '/contact',
isSelected: pathname.startsWith('/contact'),
},
],
[pathname]
)
useEffect(() => {
menuItems.forEach((item) => {
// 跳过 /create 路由的 prefetch(使用自定义导航逻辑)
if (item.link.startsWith('/create')) {
return
}
// /contact 路由只在登录时 prefetch
if (item.link.startsWith('/contact') && !user) {
return
}
router.prefetch(item.link)
})
}, [router, menuItems, user])
const toggleExpanded = () => {
// 在 /create/image 页面时禁用展开功能
if (isImageCreatePage) return
dispatch({
type: 'updateState',
payload: {
isSidebarExpanded: !isSidebarExpanded,
},
})
}
return (
<aside
className={cn(
'bg-background-default border-outline-normal sticky top-0 bottom-0 left-0 z-10 h-screen flex-shrink-0 border-r transition-all duration-300 ease-in-out',
isImageCreatePage ? 'w-0 overflow-hidden' : actualIsExpanded ? 'w-80' : 'w-20'
)}
>
<div className="flex h-full flex-col py-4">
{/* 顶部导航 */}
<div className="flex flex-col gap-2 px-4">
{/* 展开/收起按钮 */}
<div
className={cn(
'flex items-center justify-start p-2 select-none',
isImageCreatePage ? 'cursor-default' : 'cursor-pointer'
)}
onClick={toggleExpanded}
>
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center">
{actualIsExpanded ? (
<Image src="/icons/fold.svg" alt="Fold" width={32} height={32} />
) : (
<Image src="/icons/expand.svg" alt="Expand" width={32} height={32} />
)}
</div>
</div>
{/* 菜单项 */}
{menuItems.map((item) => {
const menuItemContent = (
<div
className={cn(
'hover:bg-surface-element-hover flex cursor-pointer items-center gap-2 rounded-full p-2 transition-colors',
item.isSelected && 'bg-surface-element-normal'
)}
>
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center">
<Image
src={item.isSelected ? item.selectedIcon : item.icon}
alt={item.label}
width={32}
height={32}
className="h-full w-full"
/>
</div>
{actualIsExpanded && (
<span className="txt-label-m text-txt-primary-normal flex-1 truncate">
{item.label}
</span>
)}
</div>
)
// 对于 create 链接,使用 div + onClick 避免触发 ProgressProvider
if (item.link.startsWith('/create')) {
return (
<div key={item.id} onClick={routerToCreate}>
{menuItemContent}
</div>
)
}
// 其他链接使用 Link 组件以便 SEO
// 受保护路由(如 /contact只在登录时启用 prefetch避免缓存重定向响应
const isProtectedRoute = item.link.startsWith('/contact')
return (
<Link href={item.link} key={item.id} prefetch={!isProtectedRoute || !!user}>
{menuItemContent}
</Link>
)
})}
</div>
{/* <ChatSidebar isExpanded={actualIsExpanded} /> */}
<Notice actualIsExpanded={actualIsExpanded} />
</div>
</aside>
)
}
export default Sidebar

View File

@ -1,48 +0,0 @@
'use client'
import React, { useEffect, useState } from 'react'
import { cn } from '@/lib/utils'
import Image from 'next/image'
import Link from 'next/link'
function TopbarWithoutLogin() {
const [isBlur, setIsBlur] = useState(false)
useEffect(() => {
function handleScroll(event: Event) {
const dom = event.target as HTMLElement
setIsBlur(dom.scrollTop > 0)
}
const dom = document.getElementById('main-content')
if (dom) {
dom.addEventListener('scroll', handleScroll, { passive: true })
}
return () => {
if (dom) {
dom.removeEventListener('scroll', handleScroll)
}
}
}, [])
return (
<header
className={cn(
'fixed top-0 right-0 left-0 z-40 flex h-16 items-center justify-between px-8 transition-all',
{
'backdrop-blur-[10px]': isBlur,
}
)}
>
{isBlur && <div className="bg-background-default absolute inset-0 opacity-85" />}
<div className="relative inset-0 flex w-full items-center justify-between">
<div className="h-8 w-[103.6px]">
<Link href="/">
<Image src="/logo.svg" alt="Logo" width={103.6} height={32} />
</Link>
</div>
<div />
</div>
</header>
)
}
export default TopbarWithoutLogin

View File

@ -1,57 +0,0 @@
'use client'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { useNimConversation } from '@/context/NimChat/useNimChat'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
const ChatConversationsDeleteDialog = ({
open,
onOpenChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
}) => {
const { clearAllConversations } = useNimConversation()
const [loading, setLoading] = useState(false)
const router = useRouter()
const handleClick = async () => {
setLoading(true)
await clearAllConversations()
onOpenChange(false)
setLoading(false)
router.replace('/')
}
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Clear Chat List</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
This will clear your chat list. Your individual messages will remain intact.
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction variant="destructive" loading={loading} onClick={handleClick}>
Clear
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
export default ChatConversationsDeleteDialog

View File

@ -1,77 +0,0 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { IconButton } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { useNimChat } from '@/context/NimChat/useNimChat'
import ChatConversationsDeleteDialog from './ChatConversationsDeleteDialog'
import { useState } from 'react'
interface ChatSidebarActionProps {
onSearchClick?: () => void
onCancelSearch?: () => void
isSearchActive?: boolean
}
const ChatSidebarAction = ({
onSearchClick,
onCancelSearch,
isSearchActive = false,
}: ChatSidebarActionProps) => {
const { nim } = useNimChat()
const [isDeleteMessageDialogOpen, setIsDeleteMessageDialogOpen] = useState(false)
const handleClearAll = () => {
nim?.V2NIMConversationService.clearTotalUnreadCount()
}
const handleSearchClick = () => {
if (isSearchActive) {
onCancelSearch?.()
} else {
onSearchClick?.()
}
}
const handleDeleteAll = () => {
setIsDeleteMessageDialogOpen(true)
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton size="small" variant="ghost">
<i className="iconfont icon-More" />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end">
<DropdownMenuItem onClick={handleClearAll}>
<i className="iconfont icon-clear" />
<span>Mark All</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleSearchClick}>
<i className={`iconfont icon-Search`} />
<span>{isSearchActive ? 'Cancel' : 'Search'}</span>
</DropdownMenuItem>
<div className="my-3 px-2">
<Separator className="bg-outline-normal" />
</div>
<DropdownMenuItem onClick={handleDeleteAll}>
<i className="iconfont icon-trashcan" />
<span>Clear Chat List</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ChatConversationsDeleteDialog
open={isDeleteMessageDialogOpen}
onOpenChange={setIsDeleteMessageDialogOpen}
/>
</>
)
}
export default ChatSidebarAction

View File

@ -1,84 +1,84 @@
import React, { ReactNode } from 'react'
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'
import { cn } from '@/lib/utils'
import React, { ReactNode, useMemo } from 'react';
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll';
import { cn } from '@/lib/utils';
interface InfiniteScrollListProps<T> {
/**
*
*/
items: T[]
items: T[];
/**
*
*/
renderItem: (item: T, index: number) => ReactNode
renderItem: (item: T, index: number) => ReactNode;
/**
* key
*/
getItemKey: (item: T, index: number) => string | number
getItemKey: (item: T, index: number) => string | number;
/**
*
*/
hasNextPage: boolean
hasNextPage: boolean;
/**
*
*/
isLoading: boolean
isLoading: boolean;
/**
*
*/
fetchNextPage: () => void
fetchNextPage: () => void;
/**
* className
*/
className?: string
className?: string;
/**
*
*/
columns?:
| {
default: number
sm?: number
md?: number
lg?: number
xl?: number
xs?: number;
sm?: number;
md?: number;
lg?: number;
xl?: number;
}
| number
| number;
/**
*
*/
gap?: number
gap?: number;
/**
*
*/
LoadingSkeleton?: React.ComponentType
LoadingSkeleton?: React.ComponentType;
/**
*
*/
LoadingMore?: React.ComponentType
LoadingMore?: React.ComponentType;
/**
*
*/
EmptyComponent?: React.ComponentType
EmptyComponent?: React.ComponentType;
/**
*
*/
ErrorComponent?: React.ComponentType<{ onRetry: () => void }>
ErrorComponent?: React.ComponentType<{ onRetry: () => void }>;
/**
*
*/
hasError?: boolean
hasError?: boolean;
/**
*
*/
onRetry?: () => void
onRetry?: () => void;
/**
* (px)
*/
threshold?: number
threshold?: number;
/**
*
*/
enabled?: boolean
enabled?: boolean;
}
/**
@ -111,10 +111,10 @@ export function InfiniteScrollList<T>({
threshold,
enabled,
isError: hasError,
})
});
// 生成网格列数的CSS类名映射
const getGridColsClass = () => {
const gridColsClass = useMemo(() => {
if (typeof columns === 'number') {
const gridClassMap: Record<number, string> = {
1: 'grid-cols-1',
@ -123,31 +123,58 @@ export function InfiniteScrollList<T>({
4: 'grid-cols-4',
5: 'grid-cols-5',
6: 'grid-cols-6',
}
return gridClassMap[columns] || 'grid-cols-4'
};
return gridClassMap[columns] || 'grid-cols-4';
}
const classes = []
const colsClassMap: Record<number, string> = {
1: 'grid-cols-1',
2: 'grid-cols-2',
3: 'grid-cols-3',
4: 'grid-cols-4',
5: 'grid-cols-5',
6: 'grid-cols-6',
}
// 使用完整的类名字符串,让 Tailwind 能够正确识别
const classes: string[] = [];
classes.push(colsClassMap[columns.default] || 'grid-cols-4')
if (columns.sm) classes.push(`sm:${colsClassMap[columns.sm]}`)
if (columns.md) classes.push(`md:${colsClassMap[columns.md]}`)
if (columns.lg) classes.push(`lg:${colsClassMap[columns.lg]}`)
if (columns.xl) classes.push(`xl:${colsClassMap[columns.xl]}`)
// xs 断点
if (columns.xs === 1) classes.push('xs:grid-cols-1');
if (columns.xs === 2) classes.push('xs:grid-cols-2');
if (columns.xs === 3) classes.push('xs:grid-cols-3');
if (columns.xs === 4) classes.push('xs:grid-cols-4');
if (columns.xs === 5) classes.push('xs:grid-cols-5');
if (columns.xs === 6) classes.push('xs:grid-cols-6');
return classes.join(' ')
}
// sm 断点
if (columns.sm === 1) classes.push('sm:grid-cols-1');
if (columns.sm === 2) classes.push('sm:grid-cols-2');
if (columns.sm === 3) classes.push('sm:grid-cols-3');
if (columns.sm === 4) classes.push('sm:grid-cols-4');
if (columns.sm === 5) classes.push('sm:grid-cols-5');
if (columns.sm === 6) classes.push('sm:grid-cols-6');
// md 断点
if (columns.md === 1) classes.push('md:grid-cols-1');
if (columns.md === 2) classes.push('md:grid-cols-2');
if (columns.md === 3) classes.push('md:grid-cols-3');
if (columns.md === 4) classes.push('md:grid-cols-4');
if (columns.md === 5) classes.push('md:grid-cols-5');
if (columns.md === 6) classes.push('md:grid-cols-6');
// lg 断点
if (columns.lg === 1) classes.push('lg:grid-cols-1');
if (columns.lg === 2) classes.push('lg:grid-cols-2');
if (columns.lg === 3) classes.push('lg:grid-cols-3');
if (columns.lg === 4) classes.push('lg:grid-cols-4');
if (columns.lg === 5) classes.push('lg:grid-cols-5');
if (columns.lg === 6) classes.push('lg:grid-cols-6');
// xl 断点
if (columns.xl === 1) classes.push('xl:grid-cols-1');
if (columns.xl === 2) classes.push('xl:grid-cols-2');
if (columns.xl === 3) classes.push('xl:grid-cols-3');
if (columns.xl === 4) classes.push('xl:grid-cols-4');
if (columns.xl === 5) classes.push('xl:grid-cols-5');
if (columns.xl === 6) classes.push('xl:grid-cols-6');
return classes.join(' ');
}, [columns]);
// 生成间距类名
const getGapClass = () => {
const gapClass = useMemo(() => {
const gapClassMap: Record<number, string> = {
1: 'gap-1',
2: 'gap-2',
@ -156,29 +183,29 @@ export function InfiniteScrollList<T>({
5: 'gap-5',
6: 'gap-6',
8: 'gap-8',
}
return gapClassMap[gap] || 'gap-4'
}
};
return gapClassMap[gap] || 'gap-4';
}, [gap]);
// 错误状态
if (hasError && ErrorComponent && onRetry) {
return <ErrorComponent onRetry={onRetry} />
return <ErrorComponent onRetry={onRetry} />;
}
// 首次加载状态
if (isLoading && items.length === 0) {
if (LoadingSkeleton) {
return (
<div className={cn('grid', getGridColsClass(), getGapClass(), className)}>
<div className={cn('grid', gridColsClass, gapClass, className)}>
{Array.from({ length: 8 }).map((_, index) => (
<LoadingSkeleton key={index} />
))}
</div>
)
);
}
return (
<div className={cn('grid', getGridColsClass(), getGapClass(), className)}>
<div className={cn('grid', gridColsClass, gapClass, className)}>
{Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
@ -186,18 +213,18 @@ export function InfiniteScrollList<T>({
/>
))}
</div>
)
);
}
// 空状态
if (items.length === 0 && EmptyComponent) {
return <EmptyComponent />
return <EmptyComponent />;
}
return (
<div className="w-full">
{/* 主要内容 */}
<div className={cn('grid', getGridColsClass(), getGapClass(), className)}>
<div className={cn('grid', gridColsClass, gapClass, className)}>
{items.map((item, index) => (
<React.Fragment key={getItemKey(item, index)}>{renderItem(item, index)}</React.Fragment>
))}
@ -221,5 +248,5 @@ export function InfiniteScrollList<T>({
</div>
)}
</div>
)
);
}

View File

@ -1,64 +0,0 @@
'use client'
import { createContext, useReducer, useEffect, useState } from 'react'
export * from './useMainLayout'
const MainLayoutContext = createContext<{
isSidebarExpanded: boolean
dispatch: (action: { type: 'updateState'; payload: Partial<typeof initialState> }) => void
}>({
isSidebarExpanded: false,
dispatch: () => {},
})
const initialState = {
isSidebarExpanded: false,
}
const reducer = (
state: typeof initialState,
action: { type: 'updateState'; payload: Partial<typeof initialState> }
) => {
switch (action.type) {
case 'updateState':
return {
...state,
...action.payload,
}
}
return state
}
export const MainLayoutProvider = ({ children }: { children: React.ReactNode }) => {
const [state, dispatch] = useReducer(reducer, initialState)
const [isHydrated, setIsHydrated] = useState(false)
// 客户端 hydration 后从 localStorage 恢复状态
useEffect(() => {
setIsHydrated(true)
const saved = localStorage.getItem('sidebarExpanded')
if (saved !== null) {
dispatch({
type: 'updateState',
payload: {
isSidebarExpanded: JSON.parse(saved),
},
})
}
}, [])
// 当状态改变时保存到 localStorage
useEffect(() => {
if (isHydrated) {
localStorage.setItem('sidebarExpanded', JSON.stringify(state.isSidebarExpanded))
}
}, [state.isSidebarExpanded, isHydrated])
return (
<MainLayoutContext.Provider value={{ isSidebarExpanded: state.isSidebarExpanded, dispatch }}>
{children}
</MainLayoutContext.Provider>
)
}
export default MainLayoutContext

View File

@ -1,10 +0,0 @@
import { useContext } from 'react'
import MainLayoutContext from '.'
export const useMainLayout = () => {
const context = useContext(MainLayoutContext)
if (!context) {
throw new Error('useMainLayout must be used within a MainLayoutProvider')
}
return context
}

View File

@ -572,6 +572,12 @@
var(--glo-color-violet-20) 50%,
var(--glo-color-mint-20) 100%
);
--breakpoint-xs: 375px;
--breakpoint-sm: 768px;
--breakpoint-md: 1024px;
--breakpoint-lg: 1280px;
--breakpoint-xl: 1440px;
}
/* Typography 工具类 */

71
src/hooks/tools/index.ts Normal file
View File

@ -0,0 +1,71 @@
'use client';
import { useMemoizedFn } from 'ahooks';
import { useEffect, useState } from 'react';
const ResponsiveConfig = {
xs: 375,
// 从这里开始变为移动端试图
sm: 768,
md: 1024,
lg: 1280,
xl: 1440,
};
export const useMedia = (config: Record<string, number> = ResponsiveConfig) => {
// 追踪客户端是否已挂载
const [mounted, setMounted] = useState(false);
const [response, setResponse] = useState<Record<keyof typeof config, boolean>>();
useEffect(() => {
setMounted(true);
const onResize = () => {
let hasChanged = false;
const newResponse = Object.fromEntries(
Object.entries(config).map(([key, value]) => {
if (window.innerWidth > value) {
hasChanged = true;
return [key, true];
}
return [key, false];
})
) as Record<keyof typeof config, boolean>;
setResponse(newResponse);
};
window.addEventListener('resize', onResize);
onResize();
return () => window.removeEventListener('resize', onResize);
}, []);
// 在服务端渲染和客户端首次渲染时返回 undefined避免 hydration 不匹配
if (!mounted) return undefined;
return response;
};
/**
* hook, loading状态
* @param fn
* @returns
*/
export const useAsyncFn = <T extends (...args: any[]) => Promise<any>>(
fn: T
): {
loading: boolean;
run: T;
} => {
const [loading, setLoading] = useState(false);
const run = useMemoizedFn(async (...args: Parameters<T>) => {
setLoading(true);
try {
const result = await fn(...args);
return result;
} finally {
setLoading(false);
}
}) as T;
return {
loading,
run,
};
};

View File

@ -0,0 +1 @@
'use client';

View File

@ -1,109 +0,0 @@
'use client'
import { useEffect, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { useCurrentUser } from '@/hooks/auth'
import { useToken } from '@/hooks/auth'
const PUBLIC_PREFETCH_TARGETS = ['/']
const AUTH_PREFETCH_TARGETS = [
'/contact',
'/profile',
'/profile/edit',
'/profile/account',
'/vip',
'/wallet',
'/wallet/charge',
'/wallet/charge/result',
'/wallet/transactions',
'/leaderboard',
'/crushcoin',
'/generate/image',
'/generate/image-2-image',
'/generate/image-edit',
'/generate/image-2-background',
'/explore',
'/creator',
'/create/type',
'/create/dialogue',
'/create/character',
'/create/image',
]
// 受保护路由前缀列表(需要登录才能访问的路由)
const PROTECTED_ROUTE_PREFIXES = [
'/profile',
'/create',
'/settings',
'/login/fields',
'/chat',
'/contact',
'/vip',
'/wallet',
'/crushcoin',
'/leaderboard',
'/generate',
'/explore',
'/creator',
]
// 检查路由是否是受保护路由
const isProtectedRoute = (href: string): boolean => {
return PROTECTED_ROUTE_PREFIXES.some((prefix) => href.startsWith(prefix))
}
const DEFAULT_PREFETCH_TARGETS = [...PUBLIC_PREFETCH_TARGETS, ...AUTH_PREFETCH_TARGETS]
type NullableRoute = string | null | undefined
const prefetchedRouteCache = new Set<string>()
export function usePrefetchRoutes(
routes?: NullableRoute[],
options?: {
limit?: number
}
) {
const router = useRouter()
const { isLogin } = useToken()
const normalizedRoutes = useMemo(() => {
if (!routes || routes.length === 0) return []
return routes.filter(Boolean) as string[]
}, [routes])
const limit = options?.limit ?? Infinity
useEffect(() => {
if (!normalizedRoutes.length) return
let count = 0
for (const href of normalizedRoutes) {
if (!href || prefetchedRouteCache.has(href)) continue
// 如果未登录且是受保护路由,跳过 prefetch避免缓存重定向响应
if (!isLogin && isProtectedRoute(href)) continue
prefetchedRouteCache.add(href)
router.prefetch(href)
count += 1
if (count >= limit) break
}
}, [limit, normalizedRoutes, router, isLogin])
}
export function useGlobalPrefetchRoutes(extraRoutes?: NullableRoute[]) {
const { data: user } = useCurrentUser()
const isAuthenticated = !!user
const protectedTargets = useMemo(() => {
const routes = new Set(AUTH_PREFETCH_TARGETS)
extraRoutes?.forEach((href) => {
if (href && href !== '/') {
routes.add(href)
}
})
return Array.from(routes)
}, [extraRoutes])
usePrefetchRoutes(PUBLIC_PREFETCH_TARGETS)
usePrefetchRoutes(isAuthenticated ? protectedTargets : [])
}
export const GLOBAL_PREFETCH_ROUTES = DEFAULT_PREFETCH_TARGETS

View File

@ -1,105 +0,0 @@
import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query'
import { homeKeys } from '@/lib/query-keys'
import { GetMeetListRequest, homeService } from '@/services/home'
type PageParam = number | { page: number; exList: number[] }
export function useGetMeetList(params: Omit<GetMeetListRequest, 'pn'>, enabled: boolean = true) {
return useInfiniteQuery({
queryKey: homeKeys.getMeetList(params),
queryFn: ({ pageParam }: { pageParam: PageParam }) => {
// pageParam 可能是数字(第一页)或对象(后续页面包含 exList
const page = typeof pageParam === 'number' ? pageParam : pageParam.page
const exList =
typeof pageParam === 'object' && pageParam !== null ? pageParam.exList : undefined
return homeService.getMeetList({
...params,
pn: page,
...(exList && { exList }),
})
},
initialPageParam: 1 as PageParam,
getNextPageParam: (lastPage, allPages) => {
// 如果最后一页的数据数量少于每页大小,说明没有更多数据了
if (lastPage.length < params.ps) {
return undefined
}
// 收集所有已获取的 aiId 作为下一页的排除列表
const exList: number[] = []
// 首先添加初始传入的 exList如果有的话
if (params.exList && Array.isArray(params.exList)) {
exList.push(...params.exList)
}
// 然后添加所有已获取页面的 aiId
for (const page of allPages) {
for (const item of page) {
if (item.aiId) {
exList.push(item.aiId)
}
}
}
return { page: allPages.length + 1, exList } as PageParam
},
enabled, // 控制是否启用查询
})
}
export function useGetChatRank() {
return useQuery({
queryKey: homeKeys.getChatRank(),
queryFn: homeService.getChatRank,
})
}
export function useGetHeartbeatRank() {
return useQuery({
queryKey: homeKeys.getHeartbeatRank(),
queryFn: homeService.getHeartbeatRank,
})
}
export function useGetGiftRank() {
return useQuery({
queryKey: homeKeys.getGiftRank(),
queryFn: homeService.getGiftRank,
})
}
export function useGetSevenDaysSignList() {
return useQuery({
queryKey: homeKeys.getSevenDaysSignList(),
queryFn: homeService.getSevenDaysSignList,
})
}
export function useSignIn() {
return useMutation({
mutationFn: homeService.signIn,
})
}
export function useGetExplore() {
return useQuery({
queryKey: homeKeys.getExplore(),
queryFn: homeService.getExplore,
})
}
export function useGetHomeAiCarouselList() {
return useQuery({
queryKey: homeKeys.getHomeAiCarouselList(),
queryFn: homeService.getHomeAiCarouselList,
})
}
export function useGetHomeAggregateRecommend() {
return useQuery({
queryKey: homeKeys.getHomeAggregateRecommend(),
queryFn: homeService.getHomeAggregateRecommend,
})
}

View File

@ -1,30 +1,31 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useMemoizedFn } from 'ahooks';
import { useEffect, useRef, useState } from 'react';
interface UseInfiniteScrollOptions {
/**
*
*/
hasNextPage: boolean
hasNextPage: boolean;
/**
*
*/
isLoading: boolean
isLoading: boolean;
/**
*
*/
fetchNextPage: () => void
fetchNextPage: () => void;
/**
* (px)
*/
threshold?: number
threshold?: number;
/**
* true
*/
enabled?: boolean
enabled?: boolean;
/**
*
*/
isError?: boolean
isError?: boolean;
}
/**
@ -39,67 +40,67 @@ export function useInfiniteScroll({
enabled = true,
isError = false,
}: UseInfiniteScrollOptions) {
const [isFetching, setIsFetching] = useState(false)
const observerRef = useRef<IntersectionObserver | null>(null)
const loadMoreRef = useRef<HTMLDivElement>(null)
const [isFetching, setIsFetching] = useState(false);
const observerRef = useRef<IntersectionObserver | null>(null);
const loadMoreRef = useRef<HTMLDivElement>(null);
// 加载更多数据
const loadMore = useCallback(async () => {
const loadMore = useMemoizedFn(async () => {
// 如果有错误,不继续加载
if (!hasNextPage || isLoading || isFetching || isError) return
if (!hasNextPage || isLoading || isFetching || isError) return;
setIsFetching(true)
setIsFetching(true);
try {
fetchNextPage()
fetchNextPage();
} finally {
// 延迟重置状态,避免快速重复触发
setTimeout(() => {
setIsFetching(false)
}, 100)
setIsFetching(false);
}, 100);
}
}, [hasNextPage, isLoading, isFetching, isError, fetchNextPage])
});
// 设置Intersection Observer
useEffect(() => {
if (!enabled || !loadMoreRef.current) return
if (!enabled || !loadMoreRef.current) return;
const options = {
root: null,
rootMargin: `${threshold}px`,
threshold: 0.1,
}
};
observerRef.current = new IntersectionObserver((entries) => {
const [entry] = entries
const [entry] = entries;
if (entry.isIntersecting) {
loadMore()
loadMore();
}
}, options)
}, options);
const currentRef = loadMoreRef.current
const currentRef = loadMoreRef.current;
if (currentRef) {
observerRef.current.observe(currentRef)
observerRef.current.observe(currentRef);
}
return () => {
if (observerRef.current && currentRef) {
observerRef.current.unobserve(currentRef)
observerRef.current.unobserve(currentRef);
}
}
}, [enabled, threshold, loadMore])
};
}, [enabled, threshold, loadMore]);
// 清理observer
useEffect(() => {
return () => {
if (observerRef.current) {
observerRef.current.disconnect()
observerRef.current.disconnect();
}
}
}, [])
};
}, []);
return {
loadMoreRef,
isFetching,
loadMore,
}
};
}

View File

@ -0,0 +1,49 @@
'use client';
import { usePathname } from 'next/navigation';
import { useEffect, useRef } from 'react';
import Sidebar from './Sidebar';
import Topbar from './Topbar';
import ChargeDrawer from '../components/features/charge-drawer';
import SubscribeVipDrawer from '@/app/(main)/vip/components/SubscribeVipDrawer';
import { cn } from '@/lib/utils';
import CreateReachedLimitDialog from '../components/features/create-reached-limit-dialog';
import { useMedia } from '@/hooks/tools';
import BottomBar from './BottomBar';
interface ConditionalLayoutProps {
children: React.ReactNode;
}
export default function ConditionalLayout({ children }: ConditionalLayoutProps) {
const pathname = usePathname();
const mainContentRef = useRef<HTMLDivElement>(null);
const prevPathnameRef = useRef<string>(pathname);
const response = useMedia();
// 路由切换时重置滚动位置
useEffect(() => {
if (prevPathnameRef.current !== pathname) {
if (mainContentRef.current) {
mainContentRef.current.scrollTop = 0;
}
prevPathnameRef.current = pathname;
}
}, [pathname]);
return (
<div className="flex h-screen bg-[url('/common-bg.png')] bg-[length:100%_30%] bg-top bg-no-repeat">
{response?.sm && <Sidebar />}
<div ref={mainContentRef} className={cn('relative flex flex-1 flex-col')}>
<Topbar />
<main id="main-content" className="overflow-auto flex-1 pt-16">
{children}
</main>
{response && !response.sm && <BottomBar />}
</div>
<ChargeDrawer />
<SubscribeVipDrawer />
<CreateReachedLimitDialog />
</div>
);
}

65
src/layout/BottomBar.tsx Normal file
View File

@ -0,0 +1,65 @@
'use client';
import { usePathname } from 'next/navigation';
import Link from 'next/link';
import Image from 'next/image';
import { cn } from '@/lib/utils';
import { useMedia } from '@/hooks/tools';
export default function BottomBar() {
const pathname = usePathname();
const response = useMedia({ hide: 500 });
const items = [
{
label: 'Explore',
path: '/home',
icon: '/images/layout/explore.svg',
selectedIcon: '/images/layout/explore_active.svg',
},
{
label: 'Search',
path: '/search',
icon: '/images/layout/search.svg',
selectedIcon: '/images/layout/search_active.svg',
},
{
label: 'Chat',
path: '/chat-history',
icon: '/images/layout/chat.svg',
selectedIcon: '/images/layout/chat_active.svg',
},
{
label: 'Me',
path: '/profile',
icon: '/images/layout/me.svg',
selectedIcon: '/images/layout/me_active.svg',
},
];
return (
<div className="h-20 z-100 flex items-center justify-between">
{items.map((item) => {
const isSelected = pathname === item.path;
return (
<Link
className={cn(
'flex w-full items-center justify-center gap-1',
isSelected && 'text-txt-primary-normal'
)}
href={item.path}
key={item.path}
>
<Image
src={isSelected ? item.selectedIcon : item.icon}
alt={item.label}
width={48}
height={48}
/>
{response?.hide && <span>{item.label}</span>}
</Link>
);
})}
</div>
);
}

109
src/layout/Sidebar.tsx Normal file
View File

@ -0,0 +1,109 @@
'use client';
import { useEffect } from 'react';
import { MenuItem } from '@/types/global';
import Image from 'next/image';
import { cn } from '@/lib/utils';
// import ChatSidebar from './components/ChatSidebar'
import { Badge } from '../components/ui/badge';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useLayoutStore } from '@/stores';
import { useCurrentUser } from '@/hooks/auth';
import Notice from './components/Notice';
// 菜单项接口
interface IMenuItem {
id: MenuItem;
icon: string;
selectedIcon: string;
label: string;
link: string;
isSelected: boolean;
}
// 主侧边栏组件
function Sidebar() {
const pathname = usePathname();
const isSidebarExpanded = useLayoutStore((s) => s.isSidebarExpanded);
const setSidebarExpanded = useLayoutStore((s) => s.setSidebarExpanded);
const setHydrated = useLayoutStore((s) => s.setHydrated);
const { data: user } = useCurrentUser();
// 在客户端挂载后,从 localStorage 恢复侧边栏状态
useEffect(() => {
setHydrated();
}, [setHydrated]);
const menuItems: IMenuItem[] = [
{
id: MenuItem.FOR_YOU,
icon: '/icons/explore.svg',
selectedIcon: '/icons/explore_selected.svg',
label: 'Home',
link: '/home',
isSelected: pathname === '/home',
},
];
return (
<aside
className={cn(
'bg-background-default border-outline-normal sticky top-0 bottom-0 left-0 z-10 h-screen flex-shrink-0 border-r transition-all duration-300 ease-in-out',
isSidebarExpanded ? 'w-80' : 'w-20'
)}
>
<div className="flex h-full flex-col py-4">
{/* 顶部导航 */}
<div className="flex flex-col gap-2 px-4">
{/* 展开/收起按钮 */}
<div
className={cn('flex items-center justify-start p-2 select-none', 'cursor-pointer')}
onClick={() => setSidebarExpanded(!isSidebarExpanded)}
>
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center">
{isSidebarExpanded ? (
<Image src="/icons/fold.svg" alt="Fold" width={32} height={32} />
) : (
<Image src="/icons/expand.svg" alt="Expand" width={32} height={32} />
)}
</div>
</div>
{/* 菜单项 */}
{menuItems.map((item) => {
return (
<Link href={item.link} key={item.id}>
<div
className={cn(
'hover:bg-surface-element-hover flex cursor-pointer items-center gap-2 rounded-full p-2 transition-colors',
item.isSelected && 'bg-surface-element-normal'
)}
>
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center">
<Image
src={item.isSelected ? item.selectedIcon : item.icon}
alt={item.label}
width={32}
height={32}
className="h-full w-full"
/>
</div>
{isSidebarExpanded && (
<span className="txt-label-m text-txt-primary-normal flex-1 truncate">
{item.label}
</span>
)}
</div>
</Link>
);
})}
</div>
{/* <ChatSidebar /> */}
<Notice actualIsExpanded={isSidebarExpanded} />
</div>
</aside>
);
}
export default Sidebar;

View File

@ -1,60 +1,57 @@
'use client'
import React, { useEffect, useState } from 'react'
import { cn } from '@/lib/utils'
import Image from 'next/image'
import { Button } from '../ui/button'
import { useCurrentUser } from '@/hooks/auth'
import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar'
import Link from 'next/link'
import { usePathname, useSearchParams, useRouter } from 'next/navigation'
import { useMainLayout } from '@/context/mainLayout'
'use client';
import React, { useEffect, useState } from 'react';
import { cn } from '@/lib/utils';
import Image from 'next/image';
import { Button } from '../components/ui/button';
import { useCurrentUser } from '@/hooks/auth';
import { Avatar, AvatarFallback, AvatarImage } from '../components/ui/avatar';
import Link from 'next/link';
import { usePathname, useSearchParams, useRouter } from 'next/navigation';
function Topbar() {
const [isBlur, setIsBlur] = useState(false)
const { data: user } = useCurrentUser()
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const { isSidebarExpanded } = useMainLayout()
const [isBlur, setIsBlur] = useState(false);
const { data: user } = useCurrentUser();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const searchParamsString = searchParams.toString()
const redirectURL = `${pathname}${searchParamsString ? `?${searchParamsString}` : ''}`
const loginHref = `/login?redirect=${encodeURIComponent(redirectURL)}`
const searchParamsString = searchParams.toString();
const redirectURL = `${pathname}${searchParamsString ? `?${searchParamsString}` : ''}`;
const loginHref = `/login?redirect=${encodeURIComponent(redirectURL)}`;
useEffect(() => {
function handleScroll(event: Event) {
const dom = event.target as HTMLElement
setIsBlur(dom.scrollTop > 0)
const dom = event.target as HTMLElement;
setIsBlur(dom.scrollTop > 0);
}
const dom = document.getElementById('main-content')
const dom = document.getElementById('main-content');
if (dom) {
dom.addEventListener('scroll', handleScroll, { passive: true })
dom.addEventListener('scroll', handleScroll, { passive: true });
}
return () => {
if (dom) {
dom.removeEventListener('scroll', handleScroll)
dom.removeEventListener('scroll', handleScroll);
}
}
}, [])
};
}, []);
useEffect(() => {
if (!user) {
router.prefetch(loginHref)
router.prefetch(loginHref);
} else {
router.prefetch('/profile')
router.prefetch('/profile');
if (user.cpUserInfo) {
router.push('/login/fields?redirect=' + encodeURIComponent(redirectURL))
router.push('/login/fields?redirect=' + encodeURIComponent(redirectURL));
}
}
}, [user])
}, [user]);
return (
<header
className={cn(
'fixed top-0 right-0 left-20 z-40 flex h-16 items-center justify-between px-8 transition-all',
'absolute z-40 flex h-16 w-full items-center justify-between px-8 transition-all',
{
'backdrop-blur-[10px]': isBlur,
'left-80': isSidebarExpanded,
}
)}
>
@ -85,7 +82,7 @@ function Topbar() {
)}
</div>
</header>
)
);
}
export default Topbar
export default Topbar;

View File

@ -1,73 +1,46 @@
import ChatSidebarItem from './ChatSidebarItem'
import { IconButton } from '@/components/ui/button'
import { useAtomValue } from 'jotai'
import { conversationListAtom, selectedConversationIdAtom } from '@/atoms/im'
import ChatSidebarAction from './ChatSidebarAction'
import ChatSearchResults from './ChatSearchResults'
import { Input } from '@/components/ui/input'
import { useState, useEffect, useCallback } from 'react'
import ChatSidebarItem from './ChatSidebarItem';
import { IconButton } from '@/components/ui/button';
import ChatSidebarAction from './ChatSidebarAction';
import ChatSearchResults from './ChatSearchResults';
import { Input } from '@/components/ui/input';
import { useState, useEffect, useCallback } from 'react';
import { useStreamChatStore } from '@/stores/stream-chat';
import { useLayoutStore } from '@/stores';
const ChatSidebar = ({ isExpanded }: { isExpanded: boolean }) => {
const selectedChat = useAtomValue(selectedConversationIdAtom)
const conversationList = useAtomValue(conversationListAtom)
const [searchKeyword, setSearchKeyword] = useState('')
const [showSearchInput, setShowSearchInput] = useState(false)
const ChatSidebar = () => {
const isSidebarExpanded = useLayoutStore((s) => s.isSidebarExpanded);
const currentChannel = useStreamChatStore((state) => state.currentChannel);
const channels = useStreamChatStore((state) => state.channels);
const [search, setSearch] = useState('');
const [inSearching, setIsSearching] = useState(false);
const datas = Array.from(conversationList.values()).sort((a, b) => {
// 获取会话的最后活跃时间(优先使用最后一条消息的时间,否则使用会话更新时间)
const aTime = a.lastMessage?.messageRefer?.createTime || a.updateTime || 0
const bTime = b.lastMessage?.messageRefer?.createTime || b.updateTime || 0
// 按时间倒序排列(最新的在前面)
return bTime - aTime
})
const datas = Array.from(channels.values()).sort((a, b) => {
return false;
// // 获取会话的最后活跃时间(优先使用最后一条消息的时间,否则使用会话更新时间)
// const aTime = a.lastMessage?.messageRefer?.createTime || a.updateTime || 0;
// const bTime = b.lastMessage?.messageRefer?.createTime || b.updateTime || 0;
// // 按时间倒序排列(最新的在前面)
// return bTime - aTime;
});
// 当侧边栏收缩时,取消搜索功能
useEffect(() => {
if (!isExpanded) {
setShowSearchInput(false)
setSearchKeyword('')
}
}, [isExpanded])
const handleSearchClick = () => {
setShowSearchInput(true)
}
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchKeyword(e.target.value)
}
const handleClearSearch = () => {
setSearchKeyword('')
if (!isSidebarExpanded) {
setIsSearching(false);
setSearch('');
}
}, [isSidebarExpanded]);
const handleCloseSearch = useCallback(() => {
setSearchKeyword('')
setShowSearchInput(false)
}, [])
// ESC键关闭搜索
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && showSearchInput) {
handleCloseSearch()
}
}
if (showSearchInput) {
document.addEventListener('keydown', handleKeyDown)
}
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [showSearchInput, handleCloseSearch])
setSearch('');
setIsSearching(false);
}, []);
// 如果有搜索关键词,显示搜索结果
const isShowingSearchResults = searchKeyword.trim().length > 0
const isShowingSearchResults = search.trim().length > 0;
if (!datas.length && !isShowingSearchResults) {
return <div className="flex-1"></div>
return <div className="flex-1"></div>;
}
return (
@ -79,13 +52,13 @@ const ChatSidebar = ({ isExpanded }: { isExpanded: boolean }) => {
<div className="flex min-h-0 flex-1 flex-col px-4">
{/* 聊天标题 */}
<div className="mb-2 flex h-10 items-center justify-between px-2 py-1">
{isExpanded ? (
{isSidebarExpanded ? (
<>
<span className="txt-label-s text-txt-secondary-normal">Chats</span>
<ChatSidebarAction
onSearchClick={handleSearchClick}
onSearchClick={() => setIsSearching(true)}
onCancelSearch={handleCloseSearch}
isSearchActive={showSearchInput}
isSearchActive={inSearching}
/>
</>
) : (
@ -94,13 +67,13 @@ const ChatSidebar = ({ isExpanded }: { isExpanded: boolean }) => {
</div>
{/* 搜索框 - 根据设计稿实现 */}
{showSearchInput && isExpanded && (
{inSearching && isSidebarExpanded && (
<div className="relative mb-2 flex items-center gap-1 px-2 py-1">
<div className="relative flex-1">
<Input
type="text"
value={searchKeyword}
onChange={handleSearchChange}
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search"
size="small"
autoFocus
@ -112,7 +85,7 @@ const ChatSidebar = ({ isExpanded }: { isExpanded: boolean }) => {
/>
{isShowingSearchResults && (
<IconButton
onClick={handleClearSearch}
onClick={() => setSearch('')}
size="mini"
variant="tertiary"
className="absolute top-1/2 right-3 shrink-0 -translate-y-1/2 transform"
@ -133,11 +106,11 @@ const ChatSidebar = ({ isExpanded }: { isExpanded: boolean }) => {
)}
{/* 根据搜索状态显示不同内容 */}
{showSearchInput ? (
{inSearching ? (
isShowingSearchResults ? (
<ChatSearchResults
searchKeyword={searchKeyword}
isExpanded={isExpanded}
searchKeyword={search}
isExpanded={isSidebarExpanded}
onCloseSearch={handleCloseSearch}
/>
) : (
@ -153,7 +126,7 @@ const ChatSidebar = ({ isExpanded }: { isExpanded: boolean }) => {
<ChatSidebarItem
key={chat.conversationId}
conversation={chat}
isExpanded={isExpanded}
isExpanded={isSidebarExpanded}
isSelected={selectedChat === chat.conversationId}
/>
))}
@ -171,7 +144,7 @@ const ChatSidebar = ({ isExpanded }: { isExpanded: boolean }) => {
)}
</div>
</>
)
}
);
};
export default ChatSidebar
export default ChatSidebar;

View File

@ -0,0 +1,98 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { IconButton } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useStreamChatStore } from '@/stores/stream-chat';
import { useAsyncFn } from '@/hooks/tools';
interface ChatSidebarActionProps {
onSearchClick?: () => void;
onCancelSearch?: () => void;
isSearchActive?: boolean;
}
const ChatSidebarAction = ({
onSearchClick,
onCancelSearch,
isSearchActive = false,
}: ChatSidebarActionProps) => {
const clearNotifications = useStreamChatStore((state) => state.clearNotifications);
const clearChannels = useStreamChatStore((state) => state.clearChannels);
const [isDeleteMessageDialogOpen, setIsDeleteMessageDialogOpen] = useState(false);
const router = useRouter();
const { run: handleClearChannels, loading } = useAsyncFn(async () => {
await clearChannels();
setIsDeleteMessageDialogOpen(false);
router.replace('/');
});
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton size="small" variant="ghost">
<i className="iconfont icon-More" />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end">
<DropdownMenuItem onClick={clearNotifications}>
<i className="iconfont icon-clear" />
<span>Mark All</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={isSearchActive ? onCancelSearch : onSearchClick}>
<i className={`iconfont icon-Search`} />
<span>{isSearchActive ? 'Cancel' : 'Search'}</span>
</DropdownMenuItem>
<div className="my-3 px-2">
<Separator className="bg-outline-normal" />
</div>
<DropdownMenuItem onClick={() => setIsDeleteMessageDialogOpen(true)}>
<i className="iconfont icon-trashcan" />
<span>Clear Chat List</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* 删除全部 确认modal */}
<AlertDialog open={isDeleteMessageDialogOpen} onOpenChange={setIsDeleteMessageDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Clear Chat List</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
This will clear your chat list. Your individual messages will remain intact.
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
loading={loading}
onClick={handleClearChannels}
>
Clear
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};
export default ChatSidebarAction;

View File

@ -1,21 +1,18 @@
'use client'
import { useMemo } from 'react'
import AIRelationTag from '@/components/features/AIRelationTag'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { useNimChat } from '@/context/NimChat/useNimChat'
import { cn, durationText, getConversationTime } from '@/lib/utils'
import { CustomMessageType } from '@/types/im'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
import { V2NIMConversation } from 'nim-web-sdk-ng/dist/v2/NIM_BROWSER_SDK/V2NIMConversationService'
import { usePrefetchRoutes } from '@/hooks/useGlobalPrefetchRoutes'
'use client';
import { useMemo } from 'react';
import AIRelationTag from '@/components/features/AIRelationTag';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { cn, durationText, getConversationTime } from '@/lib/utils';
import { CustomMessageType } from '@/types/im';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
// 高亮搜索关键词的组件
const HighlightText = ({ text, keyword }: { text: string; keyword?: string }) => {
if (!keyword || !text) return <span>{text}</span>
if (!keyword || !text) return <span>{text}</span>;
const parts = text.split(new RegExp(`(${keyword})`, 'gi'))
const parts = text.split(new RegExp(`(${keyword})`, 'gi'));
return (
<span>
{parts.map((part, index) =>
@ -28,8 +25,8 @@ const HighlightText = ({ text, keyword }: { text: string; keyword?: string }) =>
)
)}
</span>
)
}
);
};
// 聊天项组件
export default function ChatSidebarItem({
@ -38,52 +35,33 @@ export default function ChatSidebarItem({
isSelected = false,
searchKeyword,
}: {
conversation: V2NIMConversation
isExpanded: boolean
isSelected?: boolean
searchKeyword?: string
isExpanded: boolean;
isSelected?: boolean;
searchKeyword?: string;
}) {
const { avatar, name, lastMessage, unreadCount, updateTime, serverExtension } = conversation
const { text, attachment } = lastMessage || {}
const router = useRouter()
const { nim } = useNimChat()
const chatHref = useMemo(() => {
if (!nim?.V2NIMConversationIdUtil || !conversation?.conversationId) {
return null
}
try {
const targetId = nim.V2NIMConversationIdUtil.parseConversationTargetId(
conversation.conversationId
)
const aiid = targetId.split('@')[0]
return `/chat/${aiid}`
} catch {
return null
}
}, [conversation?.conversationId, nim])
usePrefetchRoutes(chatHref ? [chatHref] : undefined)
const { avatar, name, lastMessage, unreadCount, updateTime, serverExtension } = conversation;
const { text, attachment } = lastMessage || {};
const router = useRouter();
const { heartbeatVal, heartbeatLevel, isShow } = JSON.parse(serverExtension || '{}') || {}
const { heartbeatVal, heartbeatLevel, isShow } = JSON.parse(serverExtension || '{}') || {};
const handleChat = () => {
if (chatHref) {
router.push(chatHref)
}
}
router.push('/');
};
const renderText = () => {
const { raw } = attachment || {}
const customData = JSON.parse(raw || '{}')
const { type, duration } = customData || {}
const { raw } = attachment || {};
const customData = JSON.parse(raw || '{}');
const { type, duration } = customData || {};
if (type === CustomMessageType.CALL_CANCEL) {
return 'Call Canceled'
return 'Call Canceled';
} else if (type === CustomMessageType.CALL) {
return `Call duration ${durationText(duration)}`
return `Call duration ${durationText(duration)}`;
} else if (type == CustomMessageType.IMAGE) {
return '[Image]'
}
return text
return '[Image]';
}
return text;
};
return (
<div
@ -143,5 +121,5 @@ export default function ChatSidebarItem({
</div>
)}
</div>
)
);
}

13
src/lib/client/auth.ts Normal file
View File

@ -0,0 +1,13 @@
const authInfoKey = 'auth';
export const saveAuthInfo = (authInfo: any) => {
localStorage.setItem(authInfoKey, JSON.stringify(authInfo));
};
export const getAuthInfo = () => {
return JSON.parse(localStorage.getItem(authInfoKey) || 'null');
};
export const getToken = () => {
return getAuthInfo()?.token || null;
};

3
src/lib/client/index.ts Normal file
View File

@ -0,0 +1,3 @@
import createClient from './request'
export const editorRequest = createClient({ serviceName: 'editor' })

98
src/lib/client/request.ts Normal file
View File

@ -0,0 +1,98 @@
import type { AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
import axios from 'axios'
import Cookies from 'js-cookie'
import { getToken, saveAuthInfo } from './auth'
const endpoints = {
editor: process.env.NEXT_PUBLIC_EDITOR_API_URL,
}
export default function createClient({ serviceName }: { serviceName: keyof typeof endpoints }) {
const baseURL = endpoints[serviceName] || '/'
const instance = axios.create({
withCredentials: false,
baseURL,
validateStatus: (status) => {
return status >= 200 && status < 500
},
})
instance.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
const token = await getToken()
if (token) {
config.headers.setAuthorization(`Bearer ${token}`)
}
// 从 cookie 中读取语言设置,并添加到请求头
// 这样后端 API 可以从请求头中获取语言信息
// 使用 X-Locale 避免与浏览器自动发送的 Accept-Language 冲突
// if (typeof window !== 'undefined') {
// const locale = Cookies.get('locale')
// if (locale) {
// config.headers.set('X-Locale', locale)
// }
// }
return config
})
instance.interceptors.response.use(
async (response: AxiosResponse): Promise<AxiosResponse> => {
if (response.status >= 400 && response.status < 500) {
if (response.status === 401) {
// message.error('401登录过期');
// saveAuthInfo(null);
// setTimeout(() => {
// window.location.href = '/login';
// }, 3000);
} else {
// message.error(`${response.status}${response.statusText}`);
}
}
if (response.status >= 500) {
// notification.error({
// message: '网络出现了错误',
// description: response.statusText,
// });
}
return response
},
(error) => {
console.log('error', error)
if (axios.isCancel(error)) {
return Promise.resolve('请求取消')
}
// notification.error({
// message: '网络出现了错误',
// description: error,
// });
return Promise.reject(error)
}
)
type ResponseType<T = any> = {
code: number
message: string
data: T
}
return async function request<T = any>(
url: string,
config?: AxiosRequestConfig
): Promise<ResponseType<T>> {
let data: any
if (config && config?.params) {
const { params } = config
data = Object.fromEntries(Object.entries(params).filter(([, value]) => value !== ''))
}
const response = await instance<ResponseType<T>>(url, {
...config,
params: data,
})
return response.data
}
}

View File

@ -51,9 +51,6 @@ const endpoints = {
shark: process.env.NEXT_PUBLIC_SHARK_API_URL,
cow: process.env.NEXT_PUBLIC_COW_API_URL,
pigeon: process.env.NEXT_PUBLIC_PIGEON_API_URL,
// 自建
edit: process.env.NEXT_PUBLIC_EDIT_API_URL,
chat: process.env.NEXT_PUBLIC_CHAT_API_URL,
}
export function createHttpClient(config: CreateHttpClientConfig): ExtendedAxiosInstance {
@ -138,7 +135,6 @@ export function createHttpClient(config: CreateHttpClientConfig): ExtendedAxiosI
instance.interceptors.response.use(
(response) => {
const apiResponse = response.data as ApiResponse
console.log('apiResponse', apiResponse)
// 检查业务状态
if (apiResponse.status === API_STATUS.OK) {

View File

@ -54,14 +54,3 @@ export const lionHttp = createHttpClient({
encryptHeader: 'encrypt',
},
})
// 自扩展服务 ------------ //
// 编辑器主服务
export const editHttp = createHttpClient({
serviceName: 'edit',
})
// 聊天
export const chatHttp = createHttpClient({
serviceName: 'chat',
})

View File

@ -1,40 +1,30 @@
// Discord OAuth配置
const DISCORD_CLIENT_ID = process.env.NEXT_PUBLIC_DISCORD_CLIENT_ID!
const DISCORD_REDIRECT_URI = `${process.env.NEXT_PUBLIC_APP_URL || 'https://test.crushlevel.ai'}/api/auth/discord/callback`
// Discord OAuth scopes
const DISCORD_SCOPES = ['identify', 'email']
export interface DiscordUser {
id: string
username: string
email: string
avatar?: string
discriminator: string
id: string;
username: string;
email: string;
avatar?: string;
discriminator: string;
}
export interface DiscordTokenResponse {
access_token: string
token_type: string
expires_in: number
refresh_token: string
scope: string
access_token: string;
token_type: string;
expires_in: number;
refresh_token: string;
scope: string;
}
export const discordOAuth = {
// 获取Discord授权URL
getAuthUrl: (state?: string): string => {
const params = new URLSearchParams({
client_id: DISCORD_CLIENT_ID,
redirect_uri: DISCORD_REDIRECT_URI,
client_id: process.env.NEXT_PUBLIC_DISCORD_CLIENT_ID!,
redirect_uri: `${window.location.origin}/api/auth/discord/callback`,
response_type: 'code',
scope: DISCORD_SCOPES.join(' '),
scope: ['identify', 'email'].join(' '),
...(state && { state }),
})
});
return `https://discord.com/api/oauth2/authorize?${params.toString()}`
return `https://discord.com/api/oauth2/authorize?${params.toString()}`;
},
// 注意token交换和用户信息获取将在后端处理
// 前端只负责获取授权码并传递给后端API
}
};

View File

@ -1,31 +1,37 @@
'use client'
import { ApiError } from '@/types/api'
import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import React, { useState, useRef, type ReactNode } from 'react'
import { toast, Toaster } from 'sonner'
import { tokenManager } from './auth/token'
import { COIN_INSUFFICIENT_ERROR_CODE } from '@/hooks/useWallet'
import { walletKeys } from './query-keys'
'use client';
import { ApiError } from '@/types/api';
import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import React, { useState, useRef, type ReactNode } from 'react';
import { toast, Toaster } from 'sonner';
import { tokenManager } from './auth/token';
import { COIN_INSUFFICIENT_ERROR_CODE } from '@/hooks/useWallet';
import { walletKeys } from './query-keys';
interface ProvidersProps {
children: ReactNode
children: ReactNode;
}
const ReactQueryDevtoolsProduction = React.lazy(() =>
import('@tanstack/react-query-devtools/build/modern/production.js').then((d) => ({
default: d.ReactQueryDevtools,
}))
)
// const ReactQueryDevtoolsProduction = React.lazy(() =>
// import('@tanstack/react-query-devtools/build/modern/production.js').then((d) => ({
// default: d.ReactQueryDevtools,
// }))
// );
const EXPIRED_ERROR_CODES = ['10050001', '10050002', '10050003', '10050004', '10050005', '10050006']
const EXPIRED_ERROR_CODES = [
'10050001',
'10050002',
'10050003',
'10050004',
'10050005',
'10050006',
];
export function Providers({ children }: ProvidersProps) {
const router = useRouter()
const router = useRouter();
// 用于错误去重的引用
const errorTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const lastErrorRef = useRef<string>('')
const [showDevtools, setShowDevtools] = React.useState(false)
const errorTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastErrorRef = useRef<string>('');
const [queryClient] = useState(
() =>
@ -45,60 +51,55 @@ export function Providers({ children }: ProvidersProps) {
},
queryCache: new QueryCache({
onError: (error) => {
handleError(error as ApiError)
handleError(error as ApiError);
},
}),
mutationCache: new MutationCache({
onError: (error) => {
handleError(error as ApiError)
handleError(error as ApiError);
},
}),
})
)
const pathname = usePathname()
const searchParams = useSearchParams()
);
const pathname = usePathname();
const searchParams = useSearchParams();
const searchParamsString = searchParams.toString()
const redirectURL = `${pathname}${searchParamsString ? `?${searchParamsString}` : ''}`
const searchParamsString = searchParams.toString();
const redirectURL = `${pathname}${searchParamsString ? `?${searchParamsString}` : ''}`;
const handleError = (error: ApiError) => {
if (error.errorCode === COIN_INSUFFICIENT_ERROR_CODE) {
queryClient.invalidateQueries({ queryKey: walletKeys.getWalletBalance() })
queryClient.invalidateQueries({ queryKey: walletKeys.getWalletBalance() });
}
if (EXPIRED_ERROR_CODES.includes(error.errorCode)) {
// 清除 cookie 中的 st
tokenManager.removeToken()
router.push('/login?redirect=' + encodeURIComponent(redirectURL))
return // 对于登录过期错误不显示错误toast直接跳转
tokenManager.removeToken();
router.push('/login?redirect=' + encodeURIComponent(redirectURL));
return; // 对于登录过期错误不显示错误toast直接跳转
}
if (error.ignoreError) {
return
return;
}
// 错误去重逻辑:只显示最后一次的错误
const errorKey = `${error.errorCode}:${error.errorMsg}`
const errorKey = `${error.errorCode}:${error.errorMsg}`;
// 清除之前的定时器
if (errorTimeoutRef.current) {
clearTimeout(errorTimeoutRef.current)
clearTimeout(errorTimeoutRef.current);
}
// 更新最后一次错误信息
lastErrorRef.current = errorKey
lastErrorRef.current = errorKey;
// 设置新的定时器,延迟显示错误
errorTimeoutRef.current = setTimeout(() => {
// 只有当前错误仍然是最后一次错误时才显示
if (lastErrorRef.current === errorKey) {
toast.error(error.errorMsg)
toast.error(error.errorMsg);
}
}, 100) // 100ms 延迟,确保能捕获到快速连续的错误
}
React.useEffect(() => {
// @ts-ignore
window.toggleDevtools = () => setShowDevtools((old) => !old)
}, [])
}, 100); // 100ms 延迟,确保能捕获到快速连续的错误
};
return (
<QueryClientProvider client={queryClient}>
@ -119,13 +120,13 @@ export function Providers({ children }: ProvidersProps) {
'!bg-surface-base-normal !border-none !px-4 !py-3 !rounded-m !txt-body-m !text-txt-primary-normal',
}}
/>
<ReactQueryDevtools initialIsOpen={true} />
{/* <ReactQueryDevtools initialIsOpen={true} /> */}
{showDevtools && (
{/* {showDevtools && (
<React.Suspense fallback={null}>
<ReactQueryDevtoolsProduction />
</React.Suspense>
)}
)} */}
</QueryClientProvider>
)
);
}

31
src/lib/server/auth.ts Normal file
View File

@ -0,0 +1,31 @@
import { cookies } from 'next/headers';
const authInfoKey = 'auth';
/**
* authInfo
* cookies 访 localStorage
*/
export async function getServerAuthInfo() {
const cookieStore = await cookies();
const authInfoStr = cookieStore.get(authInfoKey)?.value;
if (!authInfoStr) {
return null;
}
try {
return JSON.parse(authInfoStr);
} catch {
return null;
}
}
/**
* token
* cookies authInfo token
*/
export async function getServerToken() {
const authInfo = await getServerAuthInfo();
return authInfo?.token || null;
}

95
src/lib/server/request.ts Normal file
View File

@ -0,0 +1,95 @@
import { getServerToken } from './auth';
type ResponseType<T = any> = {
code: number;
message: string;
data: T;
};
export async function fetchServerRequest<T = any>(
url: string,
options?: {
method?: 'GET' | 'POST';
params?: Record<string, any>;
data?: Record<string, any>;
requireAuth?: boolean;
// Next.js 缓存选项
revalidate?: number | false; // 秒数,或 false 表示永不过期
tags?: string[]; // 缓存标签,用于手动刷新
}
): Promise<ResponseType<T>> {
const {
method = 'GET',
params,
data,
requireAuth = false,
revalidate,
tags,
} = options || {};
// 获取 token如果需要
let token: string | null = null;
if (requireAuth) {
token = await getServerToken();
if (!token) {
throw new Error('Authentication required');
}
}
// 构建请求配置
const fetchOptions: RequestInit = {
method,
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
},
// Next.js 缓存配置
next: {
...(revalidate !== undefined && { revalidate }),
...(tags && { tags }),
},
};
// 构建完整 URL服务端必须用完整 URL
const baseURL = 'http://54.223.196.180:8091';
const fullURL = new URL(url, baseURL);
// 处理查询参数
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== '') {
fullURL.searchParams.append(key, String(value));
}
});
}
// 处理 body 数据
if (data) {
fetchOptions.body = JSON.stringify(
Object.fromEntries(
Object.entries(data).filter(([, value]) => value !== '')
)
);
}
// 发起请求
const response = await fetch(fullURL.toString(), fetchOptions);
if (!response.ok) {
return { code: 400, message: '请求失败', data: {} as T };
}
return await response.json();
}
export async function serverRequest<T = any>(
url: string,
options?: {
method?: 'GET' | 'POST';
params?: Record<string, any>;
data?: Record<string, any>;
revalidate?: number | false;
tags?: string[];
}
): Promise<ResponseType<T>> {
return fetchServerRequest<T>(url, { ...options, requireAuth: false });
}

View File

@ -1,13 +1,17 @@
import { editHttp } from '@/lib/http/instances'
import { editorRequest } from '@/lib/client';
export async function fetchCharacters(params: any) {
return editHttp.post('/api/character/list', params)
export async function fetchCharacters({ index, limit, query }: any) {
const { data } = await editorRequest('/api/character/list', {
method: 'POST',
data: { index, limit, ...query },
});
return data;
}
export async function fetchCharacter(params: any) {
return editHttp.post('/api/character/detail', params)
return editorRequest('/api/character/detail', { method: 'POST', data: params });
}
export async function fetchTags(params: any): Promise<any[]> {
return editHttp.post('/api/tag/list', params)
export async function fetchCharacterTags(params: any = {}) {
return editorRequest('/api/tag/list', { method: 'POST', data: params });
}

23
src/services/editor/type.d.ts vendored Normal file
View File

@ -0,0 +1,23 @@
export type CharacterType = {
name: '';
description: '';
coverImage: '';
sourceId: number;
sourceType: number;
headPortrait: '';
basicInfo: '';
exampleDialogue: '';
note: '';
firstSentence: '';
characterStand: '';
tagId: string[];
greeting: string[];
depth: number;
chatTarget: '';
id: string;
tags?: {
id: string;
name: string;
}[];
[x: string]: any;
};

View File

@ -1,59 +0,0 @@
import { frogHttp } from '@/lib/http/instances'
import {
AiCarouselListOutput,
AiChatRankOutput,
AiGiftRankOutput,
AiHeartbeatRankOutput,
ExploreInfoOutput,
GetMeetListRequest,
GetMeetListResponse,
HomeRecommendV2Output,
SignInRoundOutput,
} from './types'
export const homeService = {
// 发现
getExplore: (): Promise<ExploreInfoOutput> => {
return frogHttp.post('/web/explore/info')
},
// 首页分类列表
getMeetList: (data: GetMeetListRequest): Promise<GetMeetListResponse[]> => {
return frogHttp.post('/web/home/classification-list', data)
},
// 热聊榜
getChatRank: (): Promise<AiChatRankOutput[]> => {
return frogHttp.post('/web/rank/chat')
},
// 心动榜
getHeartbeatRank: (): Promise<AiHeartbeatRankOutput[]> => {
return frogHttp.post('/web/rank/heartbeat')
},
// 送礼榜
getGiftRank: (): Promise<AiGiftRankOutput[]> => {
return frogHttp.post('/web/rank/gift')
},
// 七天签到列表
getSevenDaysSignList: (): Promise<SignInRoundOutput> => {
return frogHttp.post('/web/si/list')
},
// 签到
signIn: (): Promise<boolean> => {
return frogHttp.post('/web/si/asi')
},
// 首页AI轮播列表
getHomeAiCarouselList: (): Promise<AiCarouselListOutput[]> => {
return frogHttp.post('/web/home/ai-carousel-list')
},
// 首页聚合推荐
getHomeAggregateRecommend: (): Promise<HomeRecommendV2Output> => {
return frogHttp.post('/web/home/agg-recommend')
},
}

View File

@ -1,2 +0,0 @@
export * from './types'
export * from './home.service'

View File

@ -1,819 +0,0 @@
import { Gender } from 'nim-web-sdk-ng/dist/v2/NIM_BROWSER_SDK/UserServiceInterface'
/**
* ClassificationListInput
*/
export interface GetMeetListRequest {
/**
* code
*/
characterCodeList: string[]
/**
* aiId列表
*/
exList?: number[]
/**
*
*/
pn?: number
/**
*
*/
ps: number
/**
* code列表
*/
roleCodeList?: string[]
sex?: Gender
age?: Age
/** 多选:性别列表 */
sexList?: Gender[]
/** 多选:年龄列表 */
ageList?: Age[]
/**
* code列表
*/
tagCodeList?: string[]
}
/**
* GetMeetListResponse
*/
export interface GetMeetListResponse {
/**
* AI的id
*/
aiId?: number
/**
*
*/
birthday?: string
/**
*
*/
characterName?: string
/**
*
*/
headImg?: string
/**
*
*/
homeImageUrl?: string
/**
*
*/
introduction?: string
/**
*
*/
likedNum?: number
/**
*
*/
nickname?: string
/**
*
*/
roleName?: string
/**
* 0,;1,;2,
*/
sex?: number
/**
*
*/
tagName?: string
/**
* ai所属用户id
*/
userId?: number
}
/**
* AiChatRankOutput
*/
export interface AiChatRankOutput {
/**
* AI的id
*/
aiId?: number
/**
*
*/
birthday?: string
/**
*
*/
characterName?: string
/**
*
*/
chatNum?: number
/**
*
*/
headImg?: string
/**
*
*/
homeImageUrl?: string
/**
*
*/
introduction?: string
/**
*
*/
nickname?: string
/**
*
*/
rankNo?: number
/**
*
*/
roleName?: string
/**
* 0,;1,;2,
*/
sex?: number
/**
*
*/
tagName?: string
}
/**
* AiHeartbeatRankOutput
*/
export interface AiHeartbeatRankOutput {
/**
* AI的id
*/
aiId?: number
/**
*
*/
birthday?: string
/**
*
*/
characterName?: string
/**
*
*/
headImg?: string
/**
*
*/
heartbeatValTotal?: number
/**
*
*/
homeImageUrl?: string
/**
*
*/
introduction?: string
/**
*
*/
nickname?: string
/**
*
*/
rankNo?: number
/**
*
*/
roleName?: string
/**
* 0,;1,;2,
*/
sex?: number
/**
*
*/
tagName?: string
}
/**
* AiGiftRankOutput
*/
export interface AiGiftRankOutput {
/**
* AI的id
*/
aiId?: number
/**
*
*/
birthday?: string
/**
*
*/
characterName?: string
/**
*
*/
giftCoinNum?: number
/**
*
*/
headImg?: string
/**
*
*/
homeImageUrl?: string
/**
*
*/
introduction?: string
/**
*
*/
nickname?: string
/**
*
*/
rankNo?: number
/**
*
*/
roleName?: string
/**
* 0,;1,;2,
*/
sex?: number
/**
*
*/
tagName?: string
}
/**
* SignInRoundOutput
*/
export interface SignInRoundOutput {
/**
*
*/
continuousDays?: number
/**
*
*/
list?: SignInListOutput[]
}
/**
* SignInListOutput
*/
export interface SignInListOutput {
/**
* coin的数量
*/
coinNum?: number
/**
* PST yyyy-MM-dd
*/
dayStr?: string
/**
*
*/
signIn?: boolean
}
/**
* ExploreInfoOutput
*/
export interface ExploreInfoOutput {
/**
* AI总心动值榜单top3
*/
aiChatRankTop3List?: AiChatRankOutput[]
/**
* AI总心动值榜单top3
*/
aiGiftRankTop3List?: AiGiftRankOutput[]
/**
* AI总心动值榜单top3
*/
aiHeartbeatRankTop3List?: AiHeartbeatRankOutput[]
/**
* 广
*/
outputList?: AdvertiseOutput[]
}
/**
* com.sonic.frog.domain.output.AiChatRankOutput
*
* AiChatRankOutput
*/
export interface AiChatRankOutput {
/**
* AI的id
*/
aiId?: number
/**
*
*/
birthday?: string
/**
*
*/
characterName?: string
/**
*
*/
chatNum?: number
/**
*
*/
headImg?: string
/**
*
*/
homeImageUrl?: string
/**
*
*/
introduction?: string
/**
*
*/
nickname?: string
/**
*
*/
rankNo?: number
/**
*
*/
roleName?: string
/**
* 0,;1,;2,
*/
sex?: number
/**
*
*/
tagName?: string
}
/**
* com.sonic.frog.domain.output.AiGiftRankOutput
*
* AiGiftRankOutput
*/
export interface AiGiftRankOutput {
/**
* AI的id
*/
aiId?: number
/**
*
*/
birthday?: string
/**
*
*/
characterName?: string
/**
*
*/
giftCoinNum?: number
/**
*
*/
headImg?: string
/**
*
*/
homeImageUrl?: string
/**
*
*/
introduction?: string
/**
*
*/
nickname?: string
/**
*
*/
rankNo?: number
/**
*
*/
roleName?: string
/**
* 0,;1,;2,
*/
sex?: number
/**
*
*/
tagName?: string
}
/**
* com.sonic.frog.domain.output.AiHeartbeatRankOutput
*
* AiHeartbeatRankOutput
*/
export interface AiHeartbeatRankOutput {
/**
* AI的id
*/
aiId?: number
/**
*
*/
birthday?: string
/**
*
*/
characterName?: string
/**
*
*/
headImg?: string
/**
*
*/
heartbeatValTotal?: number
/**
*
*/
homeImageUrl?: string
/**
*
*/
introduction?: string
/**
*
*/
nickname?: string
/**
*
*/
rankNo?: number
/**
*
*/
roleName?: string
/**
* 0,;1,;2,
*/
sex?: number
/**
*
*/
tagName?: string
}
/**
* AdvertiseOutput
*/
export interface AdvertiseOutput {
/**
* 使WEB/ANDROID/IOS
* endpoint
*/
endpoint?: string
/**
*
* ext
*/
ext?: string
/**
* 广
* icon
*/
icon?: string
/**
* (1.,0.)
* is_global
*/
isGlobal?: number
/**
*
* jump_link
*/
jumpLink?: string
/**
* 广
* name
*/
name?: string
/**
*
* show_end_time
*/
showEndTime?: string
/**
*
* show_start_time
*/
showStartTime?: string
/**
*
* sort
*/
sort?: number
}
/**
* AGE_118-24AGE_225-34AGE_335-44AGE_445-54AGE_5>54
*/
export enum Age {
Age1 = 'AGE_1',
Age2 = 'AGE_2',
Age3 = 'AGE_3',
Age4 = 'AGE_4',
Age5 = 'AGE_5',
}
/**
* ai轮播列表输出
*
* AiCarouselListOutput
*/
export interface AiCarouselListOutput {
/**
* AI的id
*/
aiId?: number
/**
*
*/
birthday?: string
/**
*
*/
characterName?: string
/**
*
*/
headImg?: string
/**
*
*/
homeImageUrl?: string
/**
*
*/
introduction?: string
/**
*
*/
liked?: boolean
/**
*
*/
likedCount?: number
/**
*
*/
nickname?: string
/**
*
*/
roleName?: string
/**
* 0,;1,;2,
*/
sex?: number
/**
*
*/
tagName?: string
/**
* ai所属用户id
*/
userId?: number
}
/**
* HomeRecommendV2Output
*/
export interface HomeRecommendV2Output {
mostChat?: AiChatRankOutput[]
mustCrush?: AiHeartbeatRankOutput[]
mustGifted?: AiGiftRankOutput[]
starAChat?: StartChatOutput[]
}
/**
* com.sonic.frog.domain.output.AiChatRankOutput
*
* AiChatRankOutput
*/
export interface AiChatRankOutput {
/**
* AI的id
*/
aiId?: number
/**
*
*/
birthday?: string
/**
*
*/
characterName?: string
/**
*
*/
chatNum?: number
/**
*
*/
headImg?: string
/**
*
*/
homeImageUrl?: string
/**
*
*/
introduction?: string
/**
*
*/
likedNum?: number
/**
*
*/
nickname?: string
/**
*
*/
rankNo?: number
/**
*
*/
roleName?: string
/**
* 0,;1,;2,
*/
sex?: number
/**
*
*/
tagName?: string
}
/**
* com.sonic.frog.domain.output.AiHeartbeatRankOutput
*
* AiHeartbeatRankOutput
*/
export interface AiHeartbeatRankOutput {
/**
* AI的id
*/
aiId?: number
/**
*
*/
birthday?: string
/**
*
*/
characterName?: string
/**
*
*/
headImg?: string
/**
*
*/
heartbeatValTotal?: number
/**
*
*/
homeImageUrl?: string
/**
*
*/
introduction?: string
/**
*
*/
likedNum?: number
/**
*
*/
nickname?: string
/**
*
*/
rankNo?: number
/**
*
*/
roleName?: string
/**
* 0,;1,;2,
*/
sex?: number
/**
*
*/
tagName?: string
}
/**
* com.sonic.frog.domain.output.AiGiftRankOutput
*
* AiGiftRankOutput
*/
export interface AiGiftRankOutput {
/**
* AI的id
*/
aiId?: number
/**
*
*/
birthday?: string
/**
*
*/
characterName?: string
/**
*
*/
giftCoinNum?: number
/**
*
*/
headImg?: string
/**
*
*/
homeImageUrl?: string
/**
*
*/
introduction?: string
/**
*
*/
likedNum?: number
/**
*
*/
nickname?: string
/**
*
*/
rankNo?: number
/**
*
*/
roleName?: string
/**
* 0,;1,;2,
*/
sex?: number
/**
*
*/
tagName?: string
}
/**
* com.sonic.frog.domain.output.StartChatOutput
*
* StartChatOutput
*/
export interface StartChatOutput {
/**
* AI的id
*/
aiId?: number
/**
*
*/
dialoguePrologueSound?: string
/**
*
*/
headImg?: string
/**
*
*/
likedNum?: number
/**
*
*/
nickname?: string
/**
*
*/
supportingContentList?: string[]
}

Some files were not shown because too many files have changed in this diff Show More