feat: 增加了响应式
12
.env
|
|
@ -1,20 +1,16 @@
|
||||||
NEXT_PUBLIC_AUTH_API_URL=https://localhost:3000/api/mock
|
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_BEAR_API_URL=https://test-bear.crushlevel.ai
|
||||||
NEXT_PUBLIC_LION_API_URL=https://test-lion.crushlevel.ai
|
NEXT_PUBLIC_LION_API_URL=https://test-lion.crushlevel.ai
|
||||||
NEXT_PUBLIC_SHARK_API_URL=https://test-shark.crushlevel.ai
|
NEXT_PUBLIC_SHARK_API_URL=https://test-shark.crushlevel.ai
|
||||||
NEXT_PUBLIC_COW_API_URL=https://test-cow.crushlevel.ai
|
NEXT_PUBLIC_COW_API_URL=https://test-cow.crushlevel.ai
|
||||||
NEXT_PUBLIC_PIGEON_API_URL=https://test-pigeon.crushlevel.ai
|
NEXT_PUBLIC_PIGEON_API_URL=https://test-pigeon.crushlevel.ai
|
||||||
|
|
||||||
# 自建服务
|
# A18 服务
|
||||||
NEXT_PUBLIC_EDIT_API_URL=http://54.223.196.180
|
NEXT_PUBLIC_EDITOR_API_URL=http://35.82.37.117
|
||||||
NEXT_PUBLIC_CHAT_API_URL=http://54.223.196.180
|
|
||||||
|
|
||||||
# 三方登录
|
# 三方登录
|
||||||
NEXT_PUBLIC_DISCORD_CLIENT_ID=1396735872459866233
|
NEXT_PUBLIC_DISCORD_CLIENT_ID=1448143535609217076
|
||||||
|
|
||||||
# yunxin IM
|
|
||||||
NEXT_PUBLIC_NIM_APP_KEY=2d6abc076f434fc52320c7118de5fead
|
|
||||||
|
|
||||||
# S3
|
# S3
|
||||||
NEXT_PUBLIC_S3_URI=https://hhb.crushlevel.ai
|
NEXT_PUBLIC_S3_URI=https://hhb.crushlevel.ai
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"semi": false,
|
"semi": true,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"trailingComma": "es5",
|
"trailingComma": "es5",
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@
|
||||||
"@tanstack/react-query-devtools": "^5.83.0",
|
"@tanstack/react-query-devtools": "^5.83.0",
|
||||||
"@types/crypto-js": "^4.2.2",
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@types/react-stickynode": "^4.0.3",
|
"@types/react-stickynode": "^4.0.3",
|
||||||
|
"ahooks": "^3.9.6",
|
||||||
"axios": "^1.10.0",
|
"axios": "^1.10.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
@ -61,6 +62,7 @@
|
||||||
"react-stickynode": "^5.0.2",
|
"react-stickynode": "^5.0.2",
|
||||||
"react-virtuoso": "^4.17.0",
|
"react-virtuoso": "^4.17.0",
|
||||||
"sonner": "^2.0.6",
|
"sonner": "^2.0.6",
|
||||||
|
"stream-chat": "^9.27.0",
|
||||||
"swiper": "^12.0.3",
|
"swiper": "^12.0.3",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
|
|
|
||||||
209
pnpm-lock.yaml
|
|
@ -83,6 +83,9 @@ importers:
|
||||||
'@types/react-stickynode':
|
'@types/react-stickynode':
|
||||||
specifier: ^4.0.3
|
specifier: ^4.0.3
|
||||||
version: 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:
|
axios:
|
||||||
specifier: ^1.10.0
|
specifier: ^1.10.0
|
||||||
version: 1.10.0
|
version: 1.10.0
|
||||||
|
|
@ -152,6 +155,9 @@ importers:
|
||||||
sonner:
|
sonner:
|
||||||
specifier: ^2.0.6
|
specifier: ^2.0.6
|
||||||
version: 2.0.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
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:
|
swiper:
|
||||||
specifier: ^12.0.3
|
specifier: ^12.0.3
|
||||||
version: 12.0.3
|
version: 12.0.3
|
||||||
|
|
@ -1927,6 +1933,12 @@ packages:
|
||||||
'@types/json5@0.0.29':
|
'@types/json5@0.0.29':
|
||||||
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
|
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':
|
'@types/node@20.19.8':
|
||||||
resolution: {integrity: sha512-HzbgCY53T6bfu4tT7Aq3TvViJyHjLjPNaAS3HOuMc9pw97KHsUtXNX4L+wu59g1WnjsZSko35MbEqnO58rihhw==}
|
resolution: {integrity: sha512-HzbgCY53T6bfu4tT7Aq3TvViJyHjLjPNaAS3HOuMc9pw97KHsUtXNX4L+wu59g1WnjsZSko35MbEqnO58rihhw==}
|
||||||
|
|
||||||
|
|
@ -1956,6 +1968,9 @@ packages:
|
||||||
'@types/uuid@9.0.8':
|
'@types/uuid@9.0.8':
|
||||||
resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==}
|
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':
|
'@typescript-eslint/eslint-plugin@8.49.0':
|
||||||
resolution: {integrity: sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A==}
|
resolution: {integrity: sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
@ -2163,6 +2178,12 @@ packages:
|
||||||
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
|
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
|
||||||
engines: {node: '>=0.8'}
|
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:
|
ajv@6.12.6:
|
||||||
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
|
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
|
||||||
|
|
||||||
|
|
@ -2249,6 +2270,9 @@ packages:
|
||||||
axios@1.10.0:
|
axios@1.10.0:
|
||||||
resolution: {integrity: sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==}
|
resolution: {integrity: sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==}
|
||||||
|
|
||||||
|
axios@1.13.2:
|
||||||
|
resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==}
|
||||||
|
|
||||||
axobject-query@4.1.0:
|
axobject-query@4.1.0:
|
||||||
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
|
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
@ -2300,6 +2324,9 @@ packages:
|
||||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
buffer-equal-constant-time@1.0.1:
|
||||||
|
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||||
|
|
||||||
buffer@6.0.3:
|
buffer@6.0.3:
|
||||||
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
||||||
|
|
||||||
|
|
@ -2492,6 +2519,9 @@ packages:
|
||||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
ecdsa-sig-formatter@1.0.11:
|
||||||
|
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||||
|
|
||||||
electron-to-chromium@1.5.267:
|
electron-to-chromium@1.5.267:
|
||||||
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
|
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
|
||||||
|
|
||||||
|
|
@ -2969,6 +2999,10 @@ packages:
|
||||||
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
||||||
engines: {node: '>= 0.4'}
|
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:
|
is-array-buffer@3.0.5:
|
||||||
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
|
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
@ -3104,6 +3138,11 @@ packages:
|
||||||
resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==}
|
resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
isomorphic-ws@5.0.0:
|
||||||
|
resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==}
|
||||||
|
peerDependencies:
|
||||||
|
ws: '*'
|
||||||
|
|
||||||
iterator.prototype@1.1.5:
|
iterator.prototype@1.1.5:
|
||||||
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
|
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
@ -3164,10 +3203,20 @@ packages:
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
hasBin: true
|
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:
|
jsx-ast-utils@3.3.5:
|
||||||
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
||||||
engines: {node: '>=4.0'}
|
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:
|
keyv@4.5.4:
|
||||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||||
|
|
||||||
|
|
@ -3257,13 +3306,37 @@ packages:
|
||||||
resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==}
|
resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
|
|
||||||
|
linkifyjs@4.3.2:
|
||||||
|
resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==}
|
||||||
|
|
||||||
locate-path@6.0.0:
|
locate-path@6.0.0:
|
||||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||||
engines: {node: '>=10'}
|
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:
|
lodash.merge@4.6.2:
|
||||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||||
|
|
||||||
|
lodash.once@4.1.1:
|
||||||
|
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
|
||||||
|
|
||||||
lodash@4.17.21:
|
lodash@4.17.21:
|
||||||
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
||||||
|
|
||||||
|
|
@ -3622,6 +3695,9 @@ packages:
|
||||||
react: '>=16.4.0'
|
react: '>=16.4.0'
|
||||||
react-dom: '>=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:
|
react-hook-form@7.60.0:
|
||||||
resolution: {integrity: sha512-SBrYOvMbDB7cV8ZfNpaiLcgjH/a1c7aK0lK+aNigpf4xWLO8q+o4tcvVurv3c4EOyzn/3dCsYt4GKD42VvJ/+A==}
|
resolution: {integrity: sha512-SBrYOvMbDB7cV8ZfNpaiLcgjH/a1c7aK0lK+aNigpf4xWLO8q+o4tcvVurv3c4EOyzn/3dCsYt4GKD42VvJ/+A==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
@ -3717,6 +3793,9 @@ packages:
|
||||||
requires-port@1.0.0:
|
requires-port@1.0.0:
|
||||||
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
|
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
|
||||||
|
|
||||||
|
resize-observer-polyfill@1.5.1:
|
||||||
|
resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==}
|
||||||
|
|
||||||
resolve-from@4.0.0:
|
resolve-from@4.0.0:
|
||||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
@ -3768,6 +3847,10 @@ packages:
|
||||||
scheduler@0.27.0:
|
scheduler@0.27.0:
|
||||||
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
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:
|
semver@6.3.1:
|
||||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
@ -3861,6 +3944,10 @@ packages:
|
||||||
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
||||||
engines: {node: '>= 0.4'}
|
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:
|
stream-composer@1.0.2:
|
||||||
resolution: {integrity: sha512-bnBselmwfX5K10AH6L4c8+S5lgZMWI7ZYrz2rvYjCPB2DIMC4Ig8OpxGpNJSxRZ58oti7y1IcNvjBAz9vW5m4w==}
|
resolution: {integrity: sha512-bnBselmwfX5K10AH6L4c8+S5lgZMWI7ZYrz2rvYjCPB2DIMC4Ig8OpxGpNJSxRZ58oti7y1IcNvjBAz9vW5m4w==}
|
||||||
|
|
||||||
|
|
@ -4207,6 +4294,18 @@ packages:
|
||||||
wrappy@1.0.2:
|
wrappy@1.0.2:
|
||||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
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:
|
xlsx@0.18.5:
|
||||||
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
|
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
|
||||||
engines: {node: '>=0.8'}
|
engines: {node: '>=0.8'}
|
||||||
|
|
@ -6319,6 +6418,13 @@ snapshots:
|
||||||
|
|
||||||
'@types/json5@0.0.29': {}
|
'@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':
|
'@types/node@20.19.8':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
|
|
@ -6345,6 +6451,10 @@ snapshots:
|
||||||
|
|
||||||
'@types/uuid@9.0.8': {}
|
'@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)':
|
'@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:
|
dependencies:
|
||||||
'@eslint-community/regexpp': 4.12.1
|
'@eslint-community/regexpp': 4.12.1
|
||||||
|
|
@ -6537,6 +6647,21 @@ snapshots:
|
||||||
|
|
||||||
adler-32@1.3.1: {}
|
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:
|
ajv@6.12.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-deep-equal: 3.1.3
|
fast-deep-equal: 3.1.3
|
||||||
|
|
@ -6656,6 +6781,14 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- debug
|
- 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: {}
|
axobject-query@4.1.0: {}
|
||||||
|
|
||||||
b4a@1.7.3: {}
|
b4a@1.7.3: {}
|
||||||
|
|
@ -6697,6 +6830,8 @@ snapshots:
|
||||||
node-releases: 2.0.27
|
node-releases: 2.0.27
|
||||||
update-browserslist-db: 1.2.2(browserslist@4.28.1)
|
update-browserslist-db: 1.2.2(browserslist@4.28.1)
|
||||||
|
|
||||||
|
buffer-equal-constant-time@1.0.1: {}
|
||||||
|
|
||||||
buffer@6.0.3:
|
buffer@6.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
base64-js: 1.5.1
|
base64-js: 1.5.1
|
||||||
|
|
@ -6865,6 +7000,10 @@ snapshots:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
gopd: 1.2.0
|
gopd: 1.2.0
|
||||||
|
|
||||||
|
ecdsa-sig-formatter@1.0.11:
|
||||||
|
dependencies:
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
electron-to-chromium@1.5.267: {}
|
electron-to-chromium@1.5.267: {}
|
||||||
|
|
||||||
embla-carousel-react@8.6.0(react@19.2.1):
|
embla-carousel-react@8.6.0(react@19.2.1):
|
||||||
|
|
@ -7528,6 +7667,8 @@ snapshots:
|
||||||
hasown: 2.0.2
|
hasown: 2.0.2
|
||||||
side-channel: 1.1.0
|
side-channel: 1.1.0
|
||||||
|
|
||||||
|
intersection-observer@0.12.2: {}
|
||||||
|
|
||||||
is-array-buffer@3.0.5:
|
is-array-buffer@3.0.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind: 1.0.8
|
call-bind: 1.0.8
|
||||||
|
|
@ -7659,6 +7800,10 @@ snapshots:
|
||||||
|
|
||||||
isobject@3.0.1: {}
|
isobject@3.0.1: {}
|
||||||
|
|
||||||
|
isomorphic-ws@5.0.0(ws@8.18.3):
|
||||||
|
dependencies:
|
||||||
|
ws: 8.18.3
|
||||||
|
|
||||||
iterator.prototype@1.1.5:
|
iterator.prototype@1.1.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
define-data-property: 1.1.4
|
define-data-property: 1.1.4
|
||||||
|
|
@ -7699,6 +7844,19 @@ snapshots:
|
||||||
|
|
||||||
json5@2.2.3: {}
|
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:
|
jsx-ast-utils@3.3.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
array-includes: 3.1.9
|
array-includes: 3.1.9
|
||||||
|
|
@ -7706,6 +7864,17 @@ snapshots:
|
||||||
object.assign: 4.1.7
|
object.assign: 4.1.7
|
||||||
object.values: 1.2.1
|
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:
|
keyv@4.5.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
json-buffer: 3.0.1
|
json-buffer: 3.0.1
|
||||||
|
|
@ -7774,12 +7943,28 @@ snapshots:
|
||||||
lightningcss-win32-arm64-msvc: 1.30.1
|
lightningcss-win32-arm64-msvc: 1.30.1
|
||||||
lightningcss-win32-x64-msvc: 1.30.1
|
lightningcss-win32-x64-msvc: 1.30.1
|
||||||
|
|
||||||
|
linkifyjs@4.3.2: {}
|
||||||
|
|
||||||
locate-path@6.0.0:
|
locate-path@6.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
p-locate: 5.0.0
|
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.merge@4.6.2: {}
|
||||||
|
|
||||||
|
lodash.once@4.1.1: {}
|
||||||
|
|
||||||
lodash@4.17.21: {}
|
lodash@4.17.21: {}
|
||||||
|
|
||||||
loose-envify@1.4.0:
|
loose-envify@1.4.0:
|
||||||
|
|
@ -8075,6 +8260,8 @@ snapshots:
|
||||||
react-dom: 19.2.1(react@19.2.1)
|
react-dom: 19.2.1(react@19.2.1)
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
react-fast-compare@3.2.2: {}
|
||||||
|
|
||||||
react-hook-form@7.60.0(react@19.2.1):
|
react-hook-form@7.60.0(react@19.2.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.1
|
react: 19.2.1
|
||||||
|
|
@ -8175,6 +8362,8 @@ snapshots:
|
||||||
|
|
||||||
requires-port@1.0.0: {}
|
requires-port@1.0.0: {}
|
||||||
|
|
||||||
|
resize-observer-polyfill@1.5.1: {}
|
||||||
|
|
||||||
resolve-from@4.0.0: {}
|
resolve-from@4.0.0: {}
|
||||||
|
|
||||||
resolve-options@2.0.0:
|
resolve-options@2.0.0:
|
||||||
|
|
@ -8228,6 +8417,8 @@ snapshots:
|
||||||
|
|
||||||
scheduler@0.27.0: {}
|
scheduler@0.27.0: {}
|
||||||
|
|
||||||
|
screenfull@5.2.0: {}
|
||||||
|
|
||||||
semver@6.3.1: {}
|
semver@6.3.1: {}
|
||||||
|
|
||||||
semver@7.7.3: {}
|
semver@7.7.3: {}
|
||||||
|
|
@ -8352,6 +8543,22 @@ snapshots:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
internal-slot: 1.1.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:
|
stream-composer@1.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
streamx: 2.23.0
|
streamx: 2.23.0
|
||||||
|
|
@ -8832,6 +9039,8 @@ snapshots:
|
||||||
|
|
||||||
wrappy@1.0.2: {}
|
wrappy@1.0.2: {}
|
||||||
|
|
||||||
|
ws@8.18.3: {}
|
||||||
|
|
||||||
xlsx@0.18.5:
|
xlsx@0.18.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
adler-32: 1.3.1
|
adler-32: 1.3.1
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -1,8 +1,18 @@
|
||||||
|
'use client';
|
||||||
|
import { usePolicyLayout } from '../layout';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
const AboutPage = () => {
|
const AboutPage = () => {
|
||||||
|
const { setTitle } = usePolicyLayout();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTitle('Crushlevel About');
|
||||||
|
}, [setTitle]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex size-full flex-col items-center justify-start">
|
<div className="relative flex h-full 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="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="max-w-[752px]">
|
<div className="h-full max-w-[752px]">
|
||||||
<div className="relative w-full pb-[27.26%]">
|
<div className="relative w-full pb-[27.26%]">
|
||||||
<img
|
<img
|
||||||
src="/images/about/banner.png"
|
src="/images/about/banner.png"
|
||||||
|
|
@ -31,7 +41,7 @@ const AboutPage = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default AboutPage
|
export default AboutPage;
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,78 @@
|
||||||
import { ReactNode } from 'react'
|
'use client';
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const AuthLayout = ({ children }: { children: ReactNode }) => {
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex items-center justify-center bg-[url('/common-bg.png')] bg-cover bg-fixed bg-top bg-no-repeat">
|
<PolicyLayoutContext.Provider value={{ setTitle }}>
|
||||||
<div className="min-h-screen w-full">{children}</div>
|
<div className="flex h-screen overflow-hidden bg-[url('/common-bg.png')] bg-cover bg-fixed bg-top bg-no-repeat">
|
||||||
</div>
|
<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 AuthLayout
|
export default PolicyLayout;
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,22 @@
|
||||||
|
'use client';
|
||||||
|
import { usePolicyLayout } from '../../layout';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
export default function PrivacyPolicyPage() {
|
export default function PrivacyPolicyPage() {
|
||||||
|
const { setTitle } = usePolicyLayout();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTitle('Privacy Policy');
|
||||||
|
}, [setTitle]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex size-full min-h-screen flex-col items-center justify-start">
|
<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="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>
|
<p className="whitespace-pre-wrap">Crushlevel Privacy Policy</p>
|
||||||
</div>
|
</div> */}
|
||||||
|
|
||||||
{/* 前言 */}
|
{/* 前言 */}
|
||||||
<div className="txt-body-l w-full text-white">
|
<div className="txt-body-l w-full text-white">
|
||||||
|
|
@ -273,5 +283,5 @@ export default function PrivacyPolicyPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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!
|
|
||||||
|
|
||||||
\
|
|
||||||
\
|
|
||||||
|
|
@ -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 生成)
|
|
||||||
|
|
@ -1,12 +1,22 @@
|
||||||
|
'use client';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { usePolicyLayout } from '../../layout';
|
||||||
|
|
||||||
export default function TermsOfServicePage() {
|
export default function TermsOfServicePage() {
|
||||||
|
const { setTitle } = usePolicyLayout();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTitle('Crushlevel User Agreement');
|
||||||
|
}, [setTitle]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex size-full min-h-screen flex-col items-center justify-start">
|
<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="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>
|
<p className="whitespace-pre-wrap">Crushlevel User Agreement</p>
|
||||||
</div>
|
</div> */}
|
||||||
|
|
||||||
{/* 前言 */}
|
{/* 前言 */}
|
||||||
<div className="txt-body-l w-full text-white">
|
<div className="txt-body-l w-full text-white">
|
||||||
|
|
@ -419,5 +429,5 @@ export default function TermsOfServicePage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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!
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
'use client'
|
'use client';
|
||||||
|
|
||||||
import { IconButton } from '@/components/ui/button'
|
import { IconButton } from '@/components/ui/button';
|
||||||
import Input from './Input'
|
import Input from './Input';
|
||||||
import MessageList from './MessageList'
|
import MessageList from './MessageList';
|
||||||
import { useChatStore } from './store'
|
import { useChatStore } from './store';
|
||||||
import Sider from './Sider'
|
import Sider from './Sider';
|
||||||
|
|
||||||
export default function ChatPage() {
|
export default function ChatPage() {
|
||||||
const isSidebarOpen = useChatStore((store) => store.isSidebarOpen)
|
const isSidebarOpen = useChatStore((store) => store.isSidebarOpen);
|
||||||
const setIsSidebarOpen = useChatStore((store) => store.setIsSidebarOpen)
|
const setIsSidebarOpen = useChatStore((store) => store.setIsSidebarOpen);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
|
|
@ -29,5 +29,5 @@ export default function ChatPage() {
|
||||||
|
|
||||||
{isSidebarOpen && <Sider />}
|
{isSidebarOpen && <Sider />}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
export default function ChatPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Chat</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 = () => {
|
const Character = () => {
|
||||||
return (
|
const selectedTags = useHomeStore((state) => state.selectedTags);
|
||||||
<div>
|
|
||||||
<div className="h-1000">Character</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,28 @@
|
||||||
'use client'
|
'use client';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils';
|
||||||
import Image from 'next/image'
|
import Image from 'next/image';
|
||||||
import { Chip } from '@/components/ui/chip'
|
import { Chip } from '@/components/ui/chip';
|
||||||
import { useHomeStore } from '../store'
|
import { useHomeStore } from '../store';
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { fetchTags } from '@/services/editor'
|
import { fetchCharacterTags } from '@/services/editor';
|
||||||
|
|
||||||
const Filter = () => {
|
const Filter = () => {
|
||||||
const tab = useHomeStore((state) => state.tab)
|
const tab = useHomeStore((state) => state.tab);
|
||||||
const setTab = useHomeStore((state) => state.setTab)
|
const setTab = useHomeStore((state) => state.setTab);
|
||||||
const selectedTags = useHomeStore((state) => state.selectedTags)
|
const selectedTags = useHomeStore((state) => state.selectedTags);
|
||||||
const setSelectedTags = useHomeStore((state) => state.setSelectedTags)
|
const setSelectedTags = useHomeStore((state) => state.setSelectedTags);
|
||||||
|
|
||||||
const { data: tags = [] } = useQuery({
|
const { data: tags = [] } = useQuery({
|
||||||
queryKey: ['tags'],
|
queryKey: ['tags', tab],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await fetchTags({})
|
if (tab === 'character') {
|
||||||
console.log('data', data)
|
const { data } = await fetchCharacterTags({ limit: 10 });
|
||||||
return data.rows
|
return data.rows;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
|
|
@ -34,13 +37,13 @@ const Filter = () => {
|
||||||
icon: 'icon-character',
|
icon: 'icon-character',
|
||||||
activeIcon: 'icon-character-active',
|
activeIcon: 'icon-character-active',
|
||||||
},
|
},
|
||||||
] as const
|
] as const;
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex mb-6 gap-12">
|
||||||
{tabs.map((item) => {
|
{tabs.map((item) => {
|
||||||
const active = tab === item.value
|
const active = tab === item.value;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.value}
|
key={item.value}
|
||||||
|
|
@ -58,24 +61,24 @@ const Filter = () => {
|
||||||
/>
|
/>
|
||||||
<span className="txt-headline-s">{item.label}</span>
|
<span className="txt-headline-s">{item.label}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{tags?.map((tag) => (
|
{tags?.map((tag: any) => (
|
||||||
<Chip
|
<Chip
|
||||||
key={tag.value}
|
key={tag.id}
|
||||||
size="small"
|
size="small"
|
||||||
className="px-4"
|
className="px-4"
|
||||||
state={selectedTags.includes(tag.value) ? 'active' : 'inactive'}
|
state={selectedTags.includes(tag.id) ? 'active' : 'inactive'}
|
||||||
onClick={() => setSelectedTags([tag.value])}
|
onClick={() => setSelectedTags([tag.id])}
|
||||||
>
|
>
|
||||||
# {tag.label}
|
# {tag.name}
|
||||||
</Chip>
|
</Chip>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default Filter
|
export default Filter;
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,23 @@
|
||||||
'use client'
|
'use client';
|
||||||
|
|
||||||
import Image from 'next/image'
|
import Image from 'next/image';
|
||||||
import { IconButton } from '@/components/ui/button'
|
import { IconButton } from '@/components/ui/button';
|
||||||
import Link from 'next/link'
|
import Link from 'next/link';
|
||||||
import React from 'react'
|
import React from 'react';
|
||||||
|
import { useMedia } from '@/hooks/tools';
|
||||||
|
|
||||||
const Header = React.memo(() => {
|
const Header = React.memo(() => {
|
||||||
|
const response = useMedia();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-center flex-col lg:flex-row"
|
className="flex items-center justify-center"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: 'url(/images/home/bg-star.png)',
|
backgroundImage: 'url(/images/home/bg-star.png)',
|
||||||
backgroundSize: 'contain',
|
backgroundSize: 'contain',
|
||||||
backgroundPosition: 'center',
|
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
|
<Image
|
||||||
src="/images/home/left-star.png"
|
src="/images/home/left-star.png"
|
||||||
className="h-12 w-12 object-cover"
|
className="h-12 w-12 object-cover"
|
||||||
|
|
@ -57,9 +59,11 @@ const Header = React.memo(() => {
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<Image src="/images/home/banner-header.png" alt="banner-header" width={400} height={400} />
|
{response?.sm && (
|
||||||
|
<Image src="/images/home/banner-header.png" alt="banner-header" width={400} height={400} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
|
|
||||||
export default Header
|
export default Header;
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,16 @@
|
||||||
'use client'
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react';
|
||||||
import Link from 'next/link'
|
import Link from 'next/link';
|
||||||
import Image from 'next/image'
|
import Image from 'next/image';
|
||||||
import { useMainLayout } from '@/context/mainLayout'
|
import { cn } from '@/lib/utils';
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import useCreatorNavigation from '@/hooks/useCreatorNavigation'
|
|
||||||
|
|
||||||
const HomePageFooter = () => {
|
const HomePageFooter = () => {
|
||||||
const [isExpanded, setIsExpanded] = useState(false)
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const { isSidebarExpanded } = useMainLayout()
|
|
||||||
const { routerToCreate } = useCreatorNavigation()
|
|
||||||
|
|
||||||
// 根据侧边栏状态计算左侧边距
|
|
||||||
const leftMargin = isSidebarExpanded ? 'ml-80' : 'ml-20'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer
|
<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)}
|
onMouseEnter={() => setIsExpanded(true)}
|
||||||
onMouseLeave={() => setIsExpanded(false)}
|
onMouseLeave={() => setIsExpanded(false)}
|
||||||
>
|
>
|
||||||
|
|
@ -49,12 +42,6 @@ const HomePageFooter = () => {
|
||||||
<div>
|
<div>
|
||||||
<h3 className="txt-title-s mb-4">Features</h3>
|
<h3 className="txt-title-s mb-4">Features</h3>
|
||||||
<div className="space-y-3">
|
<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
|
<Link
|
||||||
href="/wallet"
|
href="/wallet"
|
||||||
className="txt-label-m text-txt-secondary-normal hover:text-txt-primary-normal block transition-colors"
|
className="txt-label-m text-txt-secondary-normal hover:text-txt-primary-normal block transition-colors"
|
||||||
|
|
@ -67,12 +54,6 @@ const HomePageFooter = () => {
|
||||||
>
|
>
|
||||||
CrushLevel VIP
|
CrushLevel VIP
|
||||||
</Link>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -86,14 +67,6 @@ const HomePageFooter = () => {
|
||||||
>
|
>
|
||||||
Daily Free CrushCoins
|
Daily Free CrushCoins
|
||||||
</Link>
|
</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
|
<Link
|
||||||
href="/about"
|
href="/about"
|
||||||
className="txt-label-m text-txt-secondary-normal hover:text-txt-primary-normal block transition-colors"
|
className="txt-label-m text-txt-secondary-normal hover:text-txt-primary-normal block transition-colors"
|
||||||
|
|
@ -149,7 +122,7 @@ const HomePageFooter = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default HomePageFooter
|
export default HomePageFooter;
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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, there’s 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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import React from 'react'
|
'use client';
|
||||||
|
import ConditionalLayout from '@/layout/BasicLayout';
|
||||||
|
|
||||||
export default async function MainLayout({ children }: { children: React.ReactNode }) {
|
export default ConditionalLayout;
|
||||||
return children
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import Home from './home'
|
|
||||||
|
|
||||||
const MainPage = () => {
|
|
||||||
return <Home />
|
|
||||||
}
|
|
||||||
|
|
||||||
export default MainPage
|
|
||||||
|
|
@ -7,10 +7,10 @@ import {
|
||||||
AlertDialogFooter,
|
AlertDialogFooter,
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog';
|
||||||
import { useLogout } from '@/hooks/auth'
|
import { useLogout } from '@/hooks/auth';
|
||||||
import { useNimChat, useNimConversation } from '@/context/NimChat/useNimChat'
|
import { useNimChat, useNimConversation } from '@/context/NimChat/useNimChat';
|
||||||
import { useSetAtom } from 'jotai'
|
import { useSetAtom } from 'jotai';
|
||||||
import {
|
import {
|
||||||
conversationListAtom,
|
conversationListAtom,
|
||||||
msgListAtom,
|
msgListAtom,
|
||||||
|
|
@ -19,20 +19,20 @@ import {
|
||||||
imReconnectStatusAtom,
|
imReconnectStatusAtom,
|
||||||
IMReconnectStatus,
|
IMReconnectStatus,
|
||||||
selectedConversationIdAtom,
|
selectedConversationIdAtom,
|
||||||
} from '@/atoms/im'
|
} from '@/atoms/im';
|
||||||
import { QueueMap } from '@/lib/queue'
|
import { QueueMap } from '@/lib/queue';
|
||||||
import Link from 'next/link'
|
import Link from 'next/link';
|
||||||
import { useState } from 'react'
|
import { useState } from 'react';
|
||||||
import { useMainLayout } from '@/context/mainLayout'
|
import { useLayoutStore } from '@/stores';
|
||||||
|
|
||||||
const ProfileDropdownItem = ({
|
const ProfileDropdownItem = ({
|
||||||
icon,
|
icon,
|
||||||
children,
|
children,
|
||||||
onClick,
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
icon: string
|
icon: string;
|
||||||
children: React.ReactNode
|
children: React.ReactNode;
|
||||||
onClick?: () => void
|
onClick?: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -45,87 +45,82 @@ const ProfileDropdownItem = ({
|
||||||
</div>
|
</div>
|
||||||
<i className="iconfont icon-arrow-right-border text-white/60 text-lg" />
|
<i className="iconfont icon-arrow-right-border text-white/60 text-lg" />
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const ProfileDropdown = () => {
|
const ProfileDropdown = () => {
|
||||||
const { mutateAsync: logout } = useLogout()
|
const { mutateAsync: logout } = useLogout();
|
||||||
const { nim } = useNimChat()
|
const { nim } = useNimChat();
|
||||||
const { clearAllConversations } = useNimConversation()
|
const { clearAllConversations } = useNimConversation();
|
||||||
const [isLogoutDialogOpen, setIsLogoutDialogOpen] = useState(false)
|
const [isLogoutDialogOpen, setIsLogoutDialogOpen] = useState(false);
|
||||||
const [isLogoutDialogLoading, setIsLogoutDialogLoading] = useState(false)
|
const [isLogoutDialogLoading, setIsLogoutDialogLoading] = useState(false);
|
||||||
const { isSidebarExpanded, dispatch } = useMainLayout()
|
const { isSidebarExpanded, setSidebarExpanded } = useLayoutStore();
|
||||||
|
|
||||||
// IM相关状态重置
|
// IM相关状态重置
|
||||||
const setConversationList = useSetAtom(conversationListAtom)
|
const setConversationList = useSetAtom(conversationListAtom);
|
||||||
const setMsgList = useSetAtom(msgListAtom)
|
const setMsgList = useSetAtom(msgListAtom);
|
||||||
const setUserList = useSetAtom(userListAtom)
|
const setUserList = useSetAtom(userListAtom);
|
||||||
const setImSynced = useSetAtom(imSyncedAtom)
|
const setImSynced = useSetAtom(imSyncedAtom);
|
||||||
const setImReconnectStatus = useSetAtom(imReconnectStatusAtom)
|
const setImReconnectStatus = useSetAtom(imReconnectStatusAtom);
|
||||||
const setSelectedConversationId = useSetAtom(selectedConversationIdAtom)
|
const setSelectedConversationId = useSetAtom(selectedConversationIdAtom);
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLogoutDialogLoading(true)
|
setIsLogoutDialogLoading(true);
|
||||||
|
|
||||||
// 1. 断开IM连接
|
// 1. 断开IM连接
|
||||||
try {
|
try {
|
||||||
console.log('开始断开IM连接...')
|
console.log('开始断开IM连接...');
|
||||||
await nim.V2NIMLoginService.logout()
|
await nim.V2NIMLoginService.logout();
|
||||||
console.log('IM连接已断开')
|
console.log('IM连接已断开');
|
||||||
} catch (imError) {
|
} catch (imError) {
|
||||||
console.error('断开IM连接失败:', imError)
|
console.error('断开IM连接失败:', imError);
|
||||||
// 即使IM断开失败,也继续执行后续步骤
|
// 即使IM断开失败,也继续执行后续步骤
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 清除所有聊天数据
|
// 2. 清除所有聊天数据
|
||||||
try {
|
try {
|
||||||
console.log('开始清除聊天历史数据...')
|
console.log('开始清除聊天历史数据...');
|
||||||
await clearAllConversations()
|
await clearAllConversations();
|
||||||
console.log('聊天历史数据已清除')
|
console.log('聊天历史数据已清除');
|
||||||
} catch (clearError) {
|
} catch (clearError) {
|
||||||
console.error('清除聊天数据失败:', clearError)
|
console.error('清除聊天数据失败:', clearError);
|
||||||
// 即使清除失败,也继续执行后续步骤
|
// 即使清除失败,也继续执行后续步骤
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 重置所有IM相关的本地状态
|
// 3. 重置所有IM相关的本地状态
|
||||||
setConversationList(new Map())
|
setConversationList(new Map());
|
||||||
setMsgList(new QueueMap(20, 'rightToLeft'))
|
setMsgList(new QueueMap(20, 'rightToLeft'));
|
||||||
setUserList(new Map())
|
setUserList(new Map());
|
||||||
setImSynced(false)
|
setImSynced(false);
|
||||||
setImReconnectStatus(IMReconnectStatus.DISCONNECTED)
|
setImReconnectStatus(IMReconnectStatus.DISCONNECTED);
|
||||||
setSelectedConversationId(null)
|
setSelectedConversationId(null);
|
||||||
|
|
||||||
if (isSidebarExpanded) {
|
if (isSidebarExpanded) {
|
||||||
dispatch({
|
setSidebarExpanded(false);
|
||||||
type: 'updateState',
|
|
||||||
payload: {
|
|
||||||
isSidebarExpanded: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 执行用户登出
|
// 4. 执行用户登出
|
||||||
await logout()
|
await logout();
|
||||||
|
|
||||||
setIsLogoutDialogOpen(false)
|
setIsLogoutDialogOpen(false);
|
||||||
setIsLogoutDialogLoading(false)
|
setIsLogoutDialogLoading(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('登出过程中发生错误:', error)
|
console.error('登出过程中发生错误:', error);
|
||||||
setIsLogoutDialogLoading(false)
|
setIsLogoutDialogLoading(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// 菜单项配置
|
// 菜单项配置
|
||||||
const items: Array<
|
const items: Array<
|
||||||
| { type: 'separator' }
|
| { type: 'separator' }
|
||||||
| {
|
| {
|
||||||
type: 'item'
|
type: 'item';
|
||||||
label: string
|
label: string;
|
||||||
icon: string
|
icon: string;
|
||||||
href?: string
|
href?: string;
|
||||||
target?: string
|
target?: string;
|
||||||
onClick?: () => void
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
> = [
|
> = [
|
||||||
{
|
{
|
||||||
|
|
@ -146,21 +141,18 @@ const ProfileDropdown = () => {
|
||||||
label: 'About Us',
|
label: 'About Us',
|
||||||
icon: 'icon-info',
|
icon: 'icon-info',
|
||||||
href: '/about',
|
href: '/about',
|
||||||
target: '_blank',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'item',
|
type: 'item',
|
||||||
label: 'Terms of Services',
|
label: 'Terms of Services',
|
||||||
icon: 'icon-audits',
|
icon: 'icon-audits',
|
||||||
href: '/policy/tos',
|
href: '/policy/tos',
|
||||||
target: '_blank',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'item',
|
type: 'item',
|
||||||
label: 'Privacy Policy',
|
label: 'Privacy Policy',
|
||||||
icon: 'icon-shield',
|
icon: 'icon-shield',
|
||||||
href: '/policy/privacy',
|
href: '/policy/privacy',
|
||||||
target: '_blank',
|
|
||||||
},
|
},
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{
|
{
|
||||||
|
|
@ -169,7 +161,7 @@ const ProfileDropdown = () => {
|
||||||
icon: 'icon-icon_exit',
|
icon: 'icon-icon_exit',
|
||||||
onClick: () => setIsLogoutDialogOpen(true),
|
onClick: () => setIsLogoutDialogOpen(true),
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -180,24 +172,24 @@ const ProfileDropdown = () => {
|
||||||
<div key={`separator-${index}`} className="my-2 px-4">
|
<div key={`separator-${index}`} className="my-2 px-4">
|
||||||
<div className="h-px bg-white/10" />
|
<div className="h-px bg-white/10" />
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const menuItem = (
|
const menuItem = (
|
||||||
<ProfileDropdownItem icon={item.icon} onClick={item.onClick}>
|
<ProfileDropdownItem icon={item.icon} onClick={item.onClick}>
|
||||||
{item.label}
|
{item.label}
|
||||||
</ProfileDropdownItem>
|
</ProfileDropdownItem>
|
||||||
)
|
);
|
||||||
|
|
||||||
if (item.href) {
|
if (item.href) {
|
||||||
return (
|
return (
|
||||||
<Link key={item.label} href={item.href} target={item.target}>
|
<Link key={item.label} href={item.href} target={item.target}>
|
||||||
{menuItem}
|
{menuItem}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div key={item.label}>{menuItem}</div>
|
return <div key={item.label}>{menuItem}</div>;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -220,7 +212,7 @@ const ProfileDropdown = () => {
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default ProfileDropdown
|
export default ProfileDropdown;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
export default function SearchPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Search</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,53 +1,37 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
|
console.log('request', request);
|
||||||
|
const url = 'http://localhost:3000';
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url);
|
||||||
const code = searchParams.get('code')
|
const code = searchParams.get('code');
|
||||||
const error = searchParams.get('error')
|
const error = searchParams.get('error');
|
||||||
const state = searchParams.get('state')
|
const state = searchParams.get('state');
|
||||||
|
|
||||||
// 处理用户拒绝授权的情况
|
// 处理用户拒绝授权的情况
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Discord OAuth error:', error)
|
console.error('Discord OAuth error:', error);
|
||||||
return NextResponse.redirect(
|
return NextResponse.redirect(new URL(`${url}/login?error=discord_denied`, request.url));
|
||||||
new URL(
|
|
||||||
`${process.env.NEXT_PUBLIC_APP_URL || 'https://test.crushlevel.ai'}/login?error=discord_denied`,
|
|
||||||
request.url
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查授权码
|
// 检查授权码
|
||||||
if (!code) {
|
if (!code) {
|
||||||
console.error('No authorization code received from Discord')
|
console.error('No authorization code received from Discord');
|
||||||
return NextResponse.redirect(
|
return NextResponse.redirect(new URL(`${url}/login?error=discord_no_code`, request.url));
|
||||||
new URL(
|
|
||||||
`${process.env.NEXT_PUBLIC_APP_URL || 'https://test.crushlevel.ai'}/login?error=discord_no_code`,
|
|
||||||
request.url
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将code作为URL参数传递给前端登录页面处理
|
// 将code作为URL参数传递给前端登录页面处理
|
||||||
const loginUrl = new URL(
|
const loginUrl = new URL(`${url}/login`, request.url);
|
||||||
`${process.env.NEXT_PUBLIC_APP_URL || 'https://test.crushlevel.ai'}/login`,
|
loginUrl.searchParams.set('discord_code', code);
|
||||||
request.url
|
|
||||||
)
|
|
||||||
loginUrl.searchParams.set('discord_code', code)
|
|
||||||
if (state) {
|
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')
|
console.log('Discord OAuth callback successful, redirecting to login page with code');
|
||||||
return NextResponse.redirect(loginUrl)
|
return NextResponse.redirect(loginUrl);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Discord OAuth callback error:', error)
|
console.error('Discord OAuth callback error:', error);
|
||||||
return NextResponse.redirect(
|
return NextResponse.redirect(new URL(`${url}/login?error=discord_callback_error`, request.url));
|
||||||
new URL(
|
|
||||||
`${process.env.NEXT_PUBLIC_APP_URL || 'https://test.crushlevel.ai'}/login?error=discord_callback_error`,
|
|
||||||
request.url
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +1,26 @@
|
||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next';
|
||||||
import { Poppins, Oleo_Script_Swash_Caps } from 'next/font/google'
|
import { Poppins, Oleo_Script_Swash_Caps } from 'next/font/google';
|
||||||
import localFont from 'next/font/local'
|
import localFont from 'next/font/local';
|
||||||
import '../css/iconfont.css'
|
import '../css/iconfont.css';
|
||||||
import '../css/iconfont-v2.css'
|
import '../css/iconfont-v2.css';
|
||||||
import './globals.css'
|
import './globals.css';
|
||||||
import { Providers } from '@/lib/providers'
|
import { Providers } from '@/lib/providers';
|
||||||
import { DeviceIdProvider } from '@/components/device-id-provider'
|
import { DeviceIdProvider } from '@/components/device-id-provider';
|
||||||
import ProgressBar from '@/context/progress'
|
import ProgressBar from '@/context/progress';
|
||||||
import { MainLayoutProvider } from '@/context/mainLayout'
|
|
||||||
import ConditionalLayout from '@/components/layout/ConditionalLayout'
|
|
||||||
|
|
||||||
const poppins = Poppins({
|
const poppins = Poppins({
|
||||||
variable: '--font-poppins',
|
variable: '--font-poppins',
|
||||||
weight: ['400', '500', '600', '700'],
|
weight: ['400', '500', '600', '700'],
|
||||||
display: 'swap',
|
display: 'swap',
|
||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
})
|
});
|
||||||
|
|
||||||
const oleoScriptSwashCaps = Oleo_Script_Swash_Caps({
|
const oleoScriptSwashCaps = Oleo_Script_Swash_Caps({
|
||||||
variable: '--font-oleo-script-swash-caps',
|
variable: '--font-oleo-script-swash-caps',
|
||||||
weight: ['400'],
|
weight: ['400'],
|
||||||
display: 'swap',
|
display: 'swap',
|
||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
})
|
});
|
||||||
|
|
||||||
const NumDisplay = localFont({
|
const NumDisplay = localFont({
|
||||||
src: [
|
src: [
|
||||||
|
|
@ -34,18 +32,18 @@ const NumDisplay = localFont({
|
||||||
],
|
],
|
||||||
variable: '--font-display-num',
|
variable: '--font-display-num',
|
||||||
display: 'swap',
|
display: 'swap',
|
||||||
})
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'),
|
metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'),
|
||||||
title: 'CrushLevel',
|
title: 'CrushLevel',
|
||||||
description: 'CrushLevel - Next Generation Social Platform',
|
description: 'CrushLevel - Next Generation Social Platform',
|
||||||
}
|
};
|
||||||
|
|
||||||
export default async function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
@ -54,14 +52,10 @@ export default async function RootLayout({
|
||||||
>
|
>
|
||||||
<DeviceIdProvider>
|
<DeviceIdProvider>
|
||||||
<Providers>
|
<Providers>
|
||||||
<ProgressBar>
|
<ProgressBar>{children}</ProgressBar>
|
||||||
<MainLayoutProvider>
|
|
||||||
<ConditionalLayout>{children}</ConditionalLayout>
|
|
||||||
</MainLayoutProvider>
|
|
||||||
</ProgressBar>
|
|
||||||
</Providers>
|
</Providers>
|
||||||
</DeviceIdProvider>
|
</DeviceIdProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
'use client'
|
'use client';
|
||||||
// import { SocialButton } from './SocialButton'
|
// import { SocialButton } from './SocialButton'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link';
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner';
|
||||||
import DiscordButton from './DiscordButton'
|
import DiscordButton from './DiscordButton';
|
||||||
import GoogleButton from './GoogleButton'
|
import GoogleButton from './GoogleButton';
|
||||||
|
|
||||||
export function LoginForm() {
|
export function LoginForm() {
|
||||||
const handleAppleLogin = () => {
|
const handleAppleLogin = () => {
|
||||||
toast.info('Apple Sign In', {
|
toast.info('Apple Sign In', {
|
||||||
description: 'Apple登录功能正在开发中...',
|
description: 'Apple登录功能正在开发中...',
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full space-y-3 sm:space-y-4">
|
<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">
|
<div className="mt-4 text-center sm:mt-6">
|
||||||
<p className="txt-body-s sm:txt-body-m text-txt-secondary-normal">
|
<p className="txt-body-s sm:txt-body-m text-txt-secondary-normal">
|
||||||
By continuing, you agree to Crush Level's{' '}
|
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
|
Terms of Service
|
||||||
</Link>{' '}
|
</Link>{' '}
|
||||||
and{' '}
|
and{' '}
|
||||||
<Link href="/policy/privacy" target="_blank" className="text-primary-variant-normal">
|
<Link href="/policy/privacy" className="text-primary-variant-normal">
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next';
|
||||||
import MainPage from './(main)/home'
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'CrushLevel AI - Grow Your Love Story',
|
title: 'CrushLevel AI - Grow Your Love Story',
|
||||||
|
|
@ -56,8 +56,8 @@ export const metadata: Metadata = {
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: 'https://www.crushlevel.com',
|
canonical: 'https://www.crushlevel.com',
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
return <MainPage />
|
redirect('/home');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,64 +1,68 @@
|
||||||
'use client'
|
'use client';
|
||||||
|
|
||||||
import React, { useRef, useEffect, useState } from 'react'
|
import React, { useRef, useEffect, useState } from 'react';
|
||||||
import { GetMeetListResponse } from '@/services/home/types'
|
import { formatNumberToKMB } from '@/lib/utils';
|
||||||
import { formatNumberToKMB } from '@/lib/utils'
|
import { Tag } from '@/components/ui/tag';
|
||||||
import { Tag } from '@/components/ui/tag'
|
import { Avatar, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { Avatar, AvatarImage } from '@/components/ui/avatar'
|
import Link from 'next/link';
|
||||||
import Link from 'next/link'
|
import { CharacterType } from '@/services/editor/type';
|
||||||
|
|
||||||
interface AIStandardCardProps {
|
interface AIStandardCardProps {
|
||||||
character: GetMeetListResponse
|
character: CharacterType;
|
||||||
disableHover?: boolean
|
disableHover?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AIStandardCard: React.FC<AIStandardCardProps> = React.memo(
|
const AIStandardCard: React.FC<AIStandardCardProps> = React.memo(
|
||||||
({ character, disableHover = false }) => {
|
({ character, disableHover = false }) => {
|
||||||
const {
|
const {
|
||||||
aiId,
|
id,
|
||||||
nickname,
|
name,
|
||||||
characterName,
|
description,
|
||||||
tagName,
|
coverImage,
|
||||||
headImg,
|
sourceId,
|
||||||
homeImageUrl,
|
sourceType,
|
||||||
introduction,
|
headPortrait,
|
||||||
likedNum,
|
basicInfo,
|
||||||
} = character
|
exampleDialogue,
|
||||||
|
note,
|
||||||
|
firstSentence,
|
||||||
|
characterStand,
|
||||||
|
tagId,
|
||||||
|
greeting,
|
||||||
|
depth,
|
||||||
|
tags,
|
||||||
|
chatTarget,
|
||||||
|
} = character;
|
||||||
|
|
||||||
const introContainerRef = useRef<HTMLDivElement>(null)
|
const introContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const introTextRef = useRef<HTMLParagraphElement>(null)
|
const introTextRef = useRef<HTMLParagraphElement>(null);
|
||||||
const [maxLines, setMaxLines] = useState<number>(6)
|
const [maxLines, setMaxLines] = useState<number>(6);
|
||||||
|
|
||||||
// 动态计算可用空间的行数
|
// 动态计算可用空间的行数
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const calculateMaxLines = () => {
|
const calculateMaxLines = () => {
|
||||||
if (introContainerRef.current) {
|
if (introContainerRef.current) {
|
||||||
const containerHeight = introContainerRef.current.offsetHeight
|
const containerHeight = introContainerRef.current.offsetHeight;
|
||||||
const lineHeight = 20 // 对应 leading-[20px]
|
const lineHeight = 20; // 对应 leading-[20px]
|
||||||
const calculatedLines = Math.floor(containerHeight / lineHeight)
|
const calculatedLines = Math.floor(containerHeight / lineHeight);
|
||||||
// 确保至少显示 1 行,最多不超过合理的行数
|
// 确保至少显示 1 行,最多不超过合理的行数
|
||||||
const finalLines = Math.max(1, Math.min(calculatedLines, 12))
|
const finalLines = Math.max(1, Math.min(calculatedLines, 12));
|
||||||
setMaxLines(finalLines)
|
setMaxLines(finalLines);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
calculateMaxLines()
|
calculateMaxLines();
|
||||||
|
|
||||||
// 监听窗口大小变化
|
// 监听窗口大小变化
|
||||||
window.addEventListener('resize', calculateMaxLines)
|
window.addEventListener('resize', calculateMaxLines);
|
||||||
return () => window.removeEventListener('resize', calculateMaxLines)
|
return () => window.removeEventListener('resize', calculateMaxLines);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
// 解析标签(假设是逗号分隔的字符串)
|
|
||||||
const tags = tagName ? tagName.split(',').filter((tag) => tag.trim()) : []
|
|
||||||
|
|
||||||
// 获取显示的背景图片
|
// 获取显示的背景图片
|
||||||
const displayImage = homeImageUrl || headImg
|
const displayImage = coverImage;
|
||||||
|
|
||||||
const displayName = `${nickname}`
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/chat/${aiId}`} className="h-full w-full" prefetch={false}>
|
<Link href={`/character/${id}`} className="h-full w-full" prefetch={false}>
|
||||||
<div
|
<div
|
||||||
className={`relative flex shrink-0 grow basis-0 cursor-pointer flex-col content-stretch items-start justify-start gap-3 ${disableHover ? '' : 'group'}`}
|
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"
|
className="txt-headline-s text-txt-primary-normal"
|
||||||
style={{ wordBreak: 'break-word' }}
|
style={{ wordBreak: 'break-word' }}
|
||||||
>
|
>
|
||||||
{displayName}
|
{name}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{/* 性格标签 */}
|
{!!tags?.length && (
|
||||||
{characterName && <Tag size="small">{characterName}</Tag>}
|
|
||||||
|
|
||||||
{tags.length > 0 && (
|
|
||||||
<div className="relative flex shrink-0 flex-wrap content-start items-start justify-start gap-1">
|
<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 key={index} size="small" className="backdrop-blur-[8px]">
|
||||||
{tag}
|
{tag.name}
|
||||||
</Tag>
|
</Tag>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
||||||
|
|
||||||
{/* 点赞数 */}
|
{/* 点赞数 */}
|
||||||
<div className="flex items-center gap-1 rounded-xs px-1 py-0.5">
|
<div className="flex items-center gap-1 rounded-xs px-1 py-0.5">
|
||||||
<i className="iconfont icon-Like-fill" />
|
<i className="iconfont icon-Like-fill" />
|
||||||
<span className="txt-label-s text-txt-primary-specialmap-normal">
|
<span className="txt-label-s text-txt-primary-specialmap-normal">
|
||||||
{formatNumberToKMB(likedNum ?? 0)}
|
{formatNumberToKMB(1000)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -133,9 +123,9 @@ const AIStandardCard: React.FC<AIStandardCardProps> = React.memo(
|
||||||
{/* 头像和名称 */}
|
{/* 头像和名称 */}
|
||||||
<div className="flex w-full items-center gap-2">
|
<div className="flex w-full items-center gap-2">
|
||||||
<Avatar className="sh size-12">
|
<Avatar className="sh size-12">
|
||||||
<AvatarImage src={headImg} alt={nickname} />
|
<AvatarImage src={headPortrait} alt={name} />
|
||||||
</Avatar>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* 简介文本 */}
|
{/* 简介文本 */}
|
||||||
|
|
@ -152,7 +142,7 @@ const AIStandardCard: React.FC<AIStandardCardProps> = React.memo(
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p ref={introTextRef} className="leading-[20px]">
|
<p ref={introTextRef} className="leading-[20px]">
|
||||||
{introduction || 'No introduction'}
|
{description || 'No description'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex items-center gap-1 rounded-xs px-1 py-0.5">
|
||||||
<i className="iconfont icon-Like-fill" />
|
<i className="iconfont icon-Like-fill" />
|
||||||
<span className="txt-label-s text-txt-primary-specialmap-normal">
|
<span className="txt-label-s text-txt-primary-specialmap-normal">
|
||||||
{formatNumberToKMB(likedNum ?? 0)}
|
{formatNumberToKMB(100)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -176,8 +166,8 @@ const AIStandardCard: React.FC<AIStandardCardProps> = React.memo(
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
|
|
||||||
export default AIStandardCard
|
export default AIStandardCard;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,84 +1,84 @@
|
||||||
import React, { ReactNode } from 'react'
|
import React, { ReactNode, useMemo } from 'react';
|
||||||
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'
|
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll';
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface InfiniteScrollListProps<T> {
|
interface InfiniteScrollListProps<T> {
|
||||||
/**
|
/**
|
||||||
* 数据项数组
|
* 数据项数组
|
||||||
*/
|
*/
|
||||||
items: T[]
|
items: T[];
|
||||||
/**
|
/**
|
||||||
* 渲染每个数据项的函数
|
* 渲染每个数据项的函数
|
||||||
*/
|
*/
|
||||||
renderItem: (item: T, index: number) => ReactNode
|
renderItem: (item: T, index: number) => ReactNode;
|
||||||
/**
|
/**
|
||||||
* 获取每个数据项的唯一key
|
* 获取每个数据项的唯一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
|
||||||
*/
|
*/
|
||||||
className?: string
|
className?: string;
|
||||||
/**
|
/**
|
||||||
* 网格列数(支持响应式)
|
* 网格列数(支持响应式)
|
||||||
*/
|
*/
|
||||||
columns?:
|
columns?:
|
||||||
| {
|
| {
|
||||||
default: number
|
xs?: number;
|
||||||
sm?: number
|
sm?: number;
|
||||||
md?: number
|
md?: number;
|
||||||
lg?: number
|
lg?: number;
|
||||||
xl?: 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)
|
* 触发加载的阈值(px)
|
||||||
*/
|
*/
|
||||||
threshold?: number
|
threshold?: number;
|
||||||
/**
|
/**
|
||||||
* 是否启用无限滚动
|
* 是否启用无限滚动
|
||||||
*/
|
*/
|
||||||
enabled?: boolean
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -111,10 +111,10 @@ export function InfiniteScrollList<T>({
|
||||||
threshold,
|
threshold,
|
||||||
enabled,
|
enabled,
|
||||||
isError: hasError,
|
isError: hasError,
|
||||||
})
|
});
|
||||||
|
|
||||||
// 生成网格列数的CSS类名映射
|
// 生成网格列数的CSS类名映射
|
||||||
const getGridColsClass = () => {
|
const gridColsClass = useMemo(() => {
|
||||||
if (typeof columns === 'number') {
|
if (typeof columns === 'number') {
|
||||||
const gridClassMap: Record<number, string> = {
|
const gridClassMap: Record<number, string> = {
|
||||||
1: 'grid-cols-1',
|
1: 'grid-cols-1',
|
||||||
|
|
@ -123,31 +123,58 @@ export function InfiniteScrollList<T>({
|
||||||
4: 'grid-cols-4',
|
4: 'grid-cols-4',
|
||||||
5: 'grid-cols-5',
|
5: 'grid-cols-5',
|
||||||
6: 'grid-cols-6',
|
6: 'grid-cols-6',
|
||||||
}
|
};
|
||||||
return gridClassMap[columns] || 'grid-cols-4'
|
return gridClassMap[columns] || 'grid-cols-4';
|
||||||
}
|
}
|
||||||
|
|
||||||
const classes = []
|
// 使用完整的类名字符串,让 Tailwind 能够正确识别
|
||||||
const colsClassMap: Record<number, string> = {
|
const classes: 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',
|
|
||||||
}
|
|
||||||
|
|
||||||
classes.push(colsClassMap[columns.default] || 'grid-cols-4')
|
// xs 断点
|
||||||
if (columns.sm) classes.push(`sm:${colsClassMap[columns.sm]}`)
|
if (columns.xs === 1) classes.push('xs:grid-cols-1');
|
||||||
if (columns.md) classes.push(`md:${colsClassMap[columns.md]}`)
|
if (columns.xs === 2) classes.push('xs:grid-cols-2');
|
||||||
if (columns.lg) classes.push(`lg:${colsClassMap[columns.lg]}`)
|
if (columns.xs === 3) classes.push('xs:grid-cols-3');
|
||||||
if (columns.xl) classes.push(`xl:${colsClassMap[columns.xl]}`)
|
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> = {
|
const gapClassMap: Record<number, string> = {
|
||||||
1: 'gap-1',
|
1: 'gap-1',
|
||||||
2: 'gap-2',
|
2: 'gap-2',
|
||||||
|
|
@ -156,29 +183,29 @@ export function InfiniteScrollList<T>({
|
||||||
5: 'gap-5',
|
5: 'gap-5',
|
||||||
6: 'gap-6',
|
6: 'gap-6',
|
||||||
8: 'gap-8',
|
8: 'gap-8',
|
||||||
}
|
};
|
||||||
return gapClassMap[gap] || 'gap-4'
|
return gapClassMap[gap] || 'gap-4';
|
||||||
}
|
}, [gap]);
|
||||||
|
|
||||||
// 错误状态
|
// 错误状态
|
||||||
if (hasError && ErrorComponent && onRetry) {
|
if (hasError && ErrorComponent && onRetry) {
|
||||||
return <ErrorComponent onRetry={onRetry} />
|
return <ErrorComponent onRetry={onRetry} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 首次加载状态
|
// 首次加载状态
|
||||||
if (isLoading && items.length === 0) {
|
if (isLoading && items.length === 0) {
|
||||||
if (LoadingSkeleton) {
|
if (LoadingSkeleton) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('grid', getGridColsClass(), getGapClass(), className)}>
|
<div className={cn('grid', gridColsClass, gapClass, className)}>
|
||||||
{Array.from({ length: 8 }).map((_, index) => (
|
{Array.from({ length: 8 }).map((_, index) => (
|
||||||
<LoadingSkeleton key={index} />
|
<LoadingSkeleton key={index} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('grid', getGridColsClass(), getGapClass(), className)}>
|
<div className={cn('grid', gridColsClass, gapClass, className)}>
|
||||||
{Array.from({ length: 8 }).map((_, index) => (
|
{Array.from({ length: 8 }).map((_, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
|
|
@ -186,18 +213,18 @@ export function InfiniteScrollList<T>({
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 空状态
|
// 空状态
|
||||||
if (items.length === 0 && EmptyComponent) {
|
if (items.length === 0 && EmptyComponent) {
|
||||||
return <EmptyComponent />
|
return <EmptyComponent />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{/* 主要内容 */}
|
{/* 主要内容 */}
|
||||||
<div className={cn('grid', getGridColsClass(), getGapClass(), className)}>
|
<div className={cn('grid', gridColsClass, gapClass, className)}>
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<React.Fragment key={getItemKey(item, index)}>{renderItem(item, index)}</React.Fragment>
|
<React.Fragment key={getItemKey(item, index)}>{renderItem(item, index)}</React.Fragment>
|
||||||
))}
|
))}
|
||||||
|
|
@ -221,5 +248,5 @@ export function InfiniteScrollList<T>({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -572,6 +572,12 @@
|
||||||
var(--glo-color-violet-20) 50%,
|
var(--glo-color-violet-20) 50%,
|
||||||
var(--glo-color-mint-20) 100%
|
var(--glo-color-mint-20) 100%
|
||||||
);
|
);
|
||||||
|
|
||||||
|
--breakpoint-xs: 375px;
|
||||||
|
--breakpoint-sm: 768px;
|
||||||
|
--breakpoint-md: 1024px;
|
||||||
|
--breakpoint-lg: 1280px;
|
||||||
|
--breakpoint-xl: 1440px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Typography 工具类 */
|
/* Typography 工具类 */
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
'use client';
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +1,31 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useMemoizedFn } from 'ahooks';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
interface UseInfiniteScrollOptions {
|
interface UseInfiniteScrollOptions {
|
||||||
/**
|
/**
|
||||||
* 是否有更多数据可以加载
|
* 是否有更多数据可以加载
|
||||||
*/
|
*/
|
||||||
hasNextPage: boolean
|
hasNextPage: boolean;
|
||||||
/**
|
/**
|
||||||
* 是否正在加载
|
* 是否正在加载
|
||||||
*/
|
*/
|
||||||
isLoading: boolean
|
isLoading: boolean;
|
||||||
/**
|
/**
|
||||||
* 加载下一页的函数
|
* 加载下一页的函数
|
||||||
*/
|
*/
|
||||||
fetchNextPage: () => void
|
fetchNextPage: () => void;
|
||||||
/**
|
/**
|
||||||
* 触发加载的阈值(px),当距离容器底部多少像素时触发加载
|
* 触发加载的阈值(px),当距离容器底部多少像素时触发加载
|
||||||
*/
|
*/
|
||||||
threshold?: number
|
threshold?: number;
|
||||||
/**
|
/**
|
||||||
* 是否启用(默认为true)
|
* 是否启用(默认为true)
|
||||||
*/
|
*/
|
||||||
enabled?: boolean
|
enabled?: boolean;
|
||||||
/**
|
/**
|
||||||
* 是否有错误
|
* 是否有错误
|
||||||
*/
|
*/
|
||||||
isError?: boolean
|
isError?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -39,67 +40,67 @@ export function useInfiniteScroll({
|
||||||
enabled = true,
|
enabled = true,
|
||||||
isError = false,
|
isError = false,
|
||||||
}: UseInfiniteScrollOptions) {
|
}: UseInfiniteScrollOptions) {
|
||||||
const [isFetching, setIsFetching] = useState(false)
|
const [isFetching, setIsFetching] = useState(false);
|
||||||
const observerRef = useRef<IntersectionObserver | null>(null)
|
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||||
const loadMoreRef = useRef<HTMLDivElement>(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 {
|
try {
|
||||||
fetchNextPage()
|
fetchNextPage();
|
||||||
} finally {
|
} finally {
|
||||||
// 延迟重置状态,避免快速重复触发
|
// 延迟重置状态,避免快速重复触发
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setIsFetching(false)
|
setIsFetching(false);
|
||||||
}, 100)
|
}, 100);
|
||||||
}
|
}
|
||||||
}, [hasNextPage, isLoading, isFetching, isError, fetchNextPage])
|
});
|
||||||
|
|
||||||
// 设置Intersection Observer
|
// 设置Intersection Observer
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled || !loadMoreRef.current) return
|
if (!enabled || !loadMoreRef.current) return;
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
root: null,
|
root: null,
|
||||||
rootMargin: `${threshold}px`,
|
rootMargin: `${threshold}px`,
|
||||||
threshold: 0.1,
|
threshold: 0.1,
|
||||||
}
|
};
|
||||||
|
|
||||||
observerRef.current = new IntersectionObserver((entries) => {
|
observerRef.current = new IntersectionObserver((entries) => {
|
||||||
const [entry] = entries
|
const [entry] = entries;
|
||||||
if (entry.isIntersecting) {
|
if (entry.isIntersecting) {
|
||||||
loadMore()
|
loadMore();
|
||||||
}
|
}
|
||||||
}, options)
|
}, options);
|
||||||
|
|
||||||
const currentRef = loadMoreRef.current
|
const currentRef = loadMoreRef.current;
|
||||||
if (currentRef) {
|
if (currentRef) {
|
||||||
observerRef.current.observe(currentRef)
|
observerRef.current.observe(currentRef);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (observerRef.current && currentRef) {
|
if (observerRef.current && currentRef) {
|
||||||
observerRef.current.unobserve(currentRef)
|
observerRef.current.unobserve(currentRef);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
}, [enabled, threshold, loadMore])
|
}, [enabled, threshold, loadMore]);
|
||||||
|
|
||||||
// 清理observer
|
// 清理observer
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (observerRef.current) {
|
if (observerRef.current) {
|
||||||
observerRef.current.disconnect()
|
observerRef.current.disconnect();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loadMoreRef,
|
loadMoreRef,
|
||||||
isFetching,
|
isFetching,
|
||||||
loadMore,
|
loadMore,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -1,60 +1,57 @@
|
||||||
'use client'
|
'use client';
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react';
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils';
|
||||||
import Image from 'next/image'
|
import Image from 'next/image';
|
||||||
import { Button } from '../ui/button'
|
import { Button } from '../components/ui/button';
|
||||||
import { useCurrentUser } from '@/hooks/auth'
|
import { useCurrentUser } from '@/hooks/auth';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from '../components/ui/avatar';
|
||||||
import Link from 'next/link'
|
import Link from 'next/link';
|
||||||
import { usePathname, useSearchParams, useRouter } from 'next/navigation'
|
import { usePathname, useSearchParams, useRouter } from 'next/navigation';
|
||||||
import { useMainLayout } from '@/context/mainLayout'
|
|
||||||
|
|
||||||
function Topbar() {
|
function Topbar() {
|
||||||
const [isBlur, setIsBlur] = useState(false)
|
const [isBlur, setIsBlur] = useState(false);
|
||||||
const { data: user } = useCurrentUser()
|
const { data: user } = useCurrentUser();
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const pathname = usePathname()
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams();
|
||||||
const { isSidebarExpanded } = useMainLayout()
|
|
||||||
|
|
||||||
const searchParamsString = searchParams.toString()
|
const searchParamsString = searchParams.toString();
|
||||||
const redirectURL = `${pathname}${searchParamsString ? `?${searchParamsString}` : ''}`
|
const redirectURL = `${pathname}${searchParamsString ? `?${searchParamsString}` : ''}`;
|
||||||
const loginHref = `/login?redirect=${encodeURIComponent(redirectURL)}`
|
const loginHref = `/login?redirect=${encodeURIComponent(redirectURL)}`;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleScroll(event: Event) {
|
function handleScroll(event: Event) {
|
||||||
const dom = event.target as HTMLElement
|
const dom = event.target as HTMLElement;
|
||||||
setIsBlur(dom.scrollTop > 0)
|
setIsBlur(dom.scrollTop > 0);
|
||||||
}
|
}
|
||||||
const dom = document.getElementById('main-content')
|
const dom = document.getElementById('main-content');
|
||||||
if (dom) {
|
if (dom) {
|
||||||
dom.addEventListener('scroll', handleScroll, { passive: true })
|
dom.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
if (dom) {
|
if (dom) {
|
||||||
dom.removeEventListener('scroll', handleScroll)
|
dom.removeEventListener('scroll', handleScroll);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
router.prefetch(loginHref)
|
router.prefetch(loginHref);
|
||||||
} else {
|
} else {
|
||||||
router.prefetch('/profile')
|
router.prefetch('/profile');
|
||||||
if (user.cpUserInfo) {
|
if (user.cpUserInfo) {
|
||||||
router.push('/login/fields?redirect=' + encodeURIComponent(redirectURL))
|
router.push('/login/fields?redirect=' + encodeURIComponent(redirectURL));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [user])
|
}, [user]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={cn(
|
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,
|
'backdrop-blur-[10px]': isBlur,
|
||||||
'left-80': isSidebarExpanded,
|
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -85,7 +82,7 @@ function Topbar() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Topbar
|
export default Topbar;
|
||||||
|
|
@ -1,73 +1,46 @@
|
||||||
import ChatSidebarItem from './ChatSidebarItem'
|
import ChatSidebarItem from './ChatSidebarItem';
|
||||||
import { IconButton } from '@/components/ui/button'
|
import { IconButton } from '@/components/ui/button';
|
||||||
import { useAtomValue } from 'jotai'
|
import ChatSidebarAction from './ChatSidebarAction';
|
||||||
import { conversationListAtom, selectedConversationIdAtom } from '@/atoms/im'
|
import ChatSearchResults from './ChatSearchResults';
|
||||||
import ChatSidebarAction from './ChatSidebarAction'
|
import { Input } from '@/components/ui/input';
|
||||||
import ChatSearchResults from './ChatSearchResults'
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Input } from '@/components/ui/input'
|
import { useStreamChatStore } from '@/stores/stream-chat';
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useLayoutStore } from '@/stores';
|
||||||
|
|
||||||
const ChatSidebar = ({ isExpanded }: { isExpanded: boolean }) => {
|
const ChatSidebar = () => {
|
||||||
const selectedChat = useAtomValue(selectedConversationIdAtom)
|
const isSidebarExpanded = useLayoutStore((s) => s.isSidebarExpanded);
|
||||||
const conversationList = useAtomValue(conversationListAtom)
|
const currentChannel = useStreamChatStore((state) => state.currentChannel);
|
||||||
const [searchKeyword, setSearchKeyword] = useState('')
|
const channels = useStreamChatStore((state) => state.channels);
|
||||||
const [showSearchInput, setShowSearchInput] = useState(false)
|
const [search, setSearch] = useState('');
|
||||||
|
const [inSearching, setIsSearching] = useState(false);
|
||||||
|
|
||||||
const datas = Array.from(conversationList.values()).sort((a, b) => {
|
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
|
// const aTime = a.lastMessage?.messageRefer?.createTime || a.updateTime || 0;
|
||||||
// 按时间倒序排列(最新的在前面)
|
// const bTime = b.lastMessage?.messageRefer?.createTime || b.updateTime || 0;
|
||||||
return bTime - aTime
|
// // 按时间倒序排列(最新的在前面)
|
||||||
})
|
// return bTime - aTime;
|
||||||
|
});
|
||||||
|
|
||||||
// 当侧边栏收缩时,取消搜索功能
|
// 当侧边栏收缩时,取消搜索功能
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isExpanded) {
|
if (!isSidebarExpanded) {
|
||||||
setShowSearchInput(false)
|
setIsSearching(false);
|
||||||
setSearchKeyword('')
|
setSearch('');
|
||||||
}
|
}
|
||||||
}, [isExpanded])
|
}, [isSidebarExpanded]);
|
||||||
|
|
||||||
const handleSearchClick = () => {
|
|
||||||
setShowSearchInput(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setSearchKeyword(e.target.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClearSearch = () => {
|
|
||||||
setSearchKeyword('')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCloseSearch = useCallback(() => {
|
const handleCloseSearch = useCallback(() => {
|
||||||
setSearchKeyword('')
|
setSearch('');
|
||||||
setShowSearchInput(false)
|
setIsSearching(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])
|
|
||||||
|
|
||||||
// 如果有搜索关键词,显示搜索结果
|
// 如果有搜索关键词,显示搜索结果
|
||||||
const isShowingSearchResults = searchKeyword.trim().length > 0
|
const isShowingSearchResults = search.trim().length > 0;
|
||||||
|
|
||||||
if (!datas.length && !isShowingSearchResults) {
|
if (!datas.length && !isShowingSearchResults) {
|
||||||
return <div className="flex-1"></div>
|
return <div className="flex-1"></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -79,13 +52,13 @@ const ChatSidebar = ({ isExpanded }: { isExpanded: boolean }) => {
|
||||||
<div className="flex min-h-0 flex-1 flex-col px-4">
|
<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">
|
<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>
|
<span className="txt-label-s text-txt-secondary-normal">Chats</span>
|
||||||
<ChatSidebarAction
|
<ChatSidebarAction
|
||||||
onSearchClick={handleSearchClick}
|
onSearchClick={() => setIsSearching(true)}
|
||||||
onCancelSearch={handleCloseSearch}
|
onCancelSearch={handleCloseSearch}
|
||||||
isSearchActive={showSearchInput}
|
isSearchActive={inSearching}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -94,13 +67,13 @@ const ChatSidebar = ({ isExpanded }: { isExpanded: boolean }) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 搜索框 - 根据设计稿实现 */}
|
{/* 搜索框 - 根据设计稿实现 */}
|
||||||
{showSearchInput && isExpanded && (
|
{inSearching && isSidebarExpanded && (
|
||||||
<div className="relative mb-2 flex items-center gap-1 px-2 py-1">
|
<div className="relative mb-2 flex items-center gap-1 px-2 py-1">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchKeyword}
|
value={search}
|
||||||
onChange={handleSearchChange}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
size="small"
|
size="small"
|
||||||
autoFocus
|
autoFocus
|
||||||
|
|
@ -112,7 +85,7 @@ const ChatSidebar = ({ isExpanded }: { isExpanded: boolean }) => {
|
||||||
/>
|
/>
|
||||||
{isShowingSearchResults && (
|
{isShowingSearchResults && (
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={handleClearSearch}
|
onClick={() => setSearch('')}
|
||||||
size="mini"
|
size="mini"
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
className="absolute top-1/2 right-3 shrink-0 -translate-y-1/2 transform"
|
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 ? (
|
isShowingSearchResults ? (
|
||||||
<ChatSearchResults
|
<ChatSearchResults
|
||||||
searchKeyword={searchKeyword}
|
searchKeyword={search}
|
||||||
isExpanded={isExpanded}
|
isExpanded={isSidebarExpanded}
|
||||||
onCloseSearch={handleCloseSearch}
|
onCloseSearch={handleCloseSearch}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -153,7 +126,7 @@ const ChatSidebar = ({ isExpanded }: { isExpanded: boolean }) => {
|
||||||
<ChatSidebarItem
|
<ChatSidebarItem
|
||||||
key={chat.conversationId}
|
key={chat.conversationId}
|
||||||
conversation={chat}
|
conversation={chat}
|
||||||
isExpanded={isExpanded}
|
isExpanded={isSidebarExpanded}
|
||||||
isSelected={selectedChat === chat.conversationId}
|
isSelected={selectedChat === chat.conversationId}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
@ -171,7 +144,7 @@ const ChatSidebar = ({ isExpanded }: { isExpanded: boolean }) => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default ChatSidebar
|
export default ChatSidebar;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -1,21 +1,18 @@
|
||||||
'use client'
|
'use client';
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react';
|
||||||
import AIRelationTag from '@/components/features/AIRelationTag'
|
import AIRelationTag from '@/components/features/AIRelationTag';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { useNimChat } from '@/context/NimChat/useNimChat'
|
import { cn, durationText, getConversationTime } from '@/lib/utils';
|
||||||
import { cn, durationText, getConversationTime } from '@/lib/utils'
|
import { CustomMessageType } from '@/types/im';
|
||||||
import { CustomMessageType } from '@/types/im'
|
import Image from 'next/image';
|
||||||
import Image from 'next/image'
|
import { useRouter } from 'next/navigation';
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import { V2NIMConversation } from 'nim-web-sdk-ng/dist/v2/NIM_BROWSER_SDK/V2NIMConversationService'
|
|
||||||
import { usePrefetchRoutes } from '@/hooks/useGlobalPrefetchRoutes'
|
|
||||||
|
|
||||||
// 高亮搜索关键词的组件
|
// 高亮搜索关键词的组件
|
||||||
const HighlightText = ({ text, keyword }: { text: string; keyword?: string }) => {
|
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 (
|
return (
|
||||||
<span>
|
<span>
|
||||||
{parts.map((part, index) =>
|
{parts.map((part, index) =>
|
||||||
|
|
@ -28,8 +25,8 @@ const HighlightText = ({ text, keyword }: { text: string; keyword?: string }) =>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
// 聊天项组件
|
// 聊天项组件
|
||||||
export default function ChatSidebarItem({
|
export default function ChatSidebarItem({
|
||||||
|
|
@ -38,52 +35,33 @@ export default function ChatSidebarItem({
|
||||||
isSelected = false,
|
isSelected = false,
|
||||||
searchKeyword,
|
searchKeyword,
|
||||||
}: {
|
}: {
|
||||||
conversation: V2NIMConversation
|
isExpanded: boolean;
|
||||||
isExpanded: boolean
|
isSelected?: boolean;
|
||||||
isSelected?: boolean
|
searchKeyword?: string;
|
||||||
searchKeyword?: string
|
|
||||||
}) {
|
}) {
|
||||||
const { avatar, name, lastMessage, unreadCount, updateTime, serverExtension } = conversation
|
const { avatar, name, lastMessage, unreadCount, updateTime, serverExtension } = conversation;
|
||||||
const { text, attachment } = lastMessage || {}
|
const { text, attachment } = lastMessage || {};
|
||||||
const router = useRouter()
|
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 { heartbeatVal, heartbeatLevel, isShow } = JSON.parse(serverExtension || '{}') || {}
|
const { heartbeatVal, heartbeatLevel, isShow } = JSON.parse(serverExtension || '{}') || {};
|
||||||
|
|
||||||
const handleChat = () => {
|
const handleChat = () => {
|
||||||
if (chatHref) {
|
router.push('/');
|
||||||
router.push(chatHref)
|
};
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderText = () => {
|
const renderText = () => {
|
||||||
const { raw } = attachment || {}
|
const { raw } = attachment || {};
|
||||||
const customData = JSON.parse(raw || '{}')
|
const customData = JSON.parse(raw || '{}');
|
||||||
const { type, duration } = customData || {}
|
const { type, duration } = customData || {};
|
||||||
if (type === CustomMessageType.CALL_CANCEL) {
|
if (type === CustomMessageType.CALL_CANCEL) {
|
||||||
return 'Call Canceled'
|
return 'Call Canceled';
|
||||||
} else if (type === CustomMessageType.CALL) {
|
} else if (type === CustomMessageType.CALL) {
|
||||||
return `Call duration ${durationText(duration)}`
|
return `Call duration ${durationText(duration)}`;
|
||||||
} else if (type == CustomMessageType.IMAGE) {
|
} else if (type == CustomMessageType.IMAGE) {
|
||||||
return '[Image]'
|
return '[Image]';
|
||||||
}
|
}
|
||||||
return text
|
return text;
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -143,5 +121,5 @@ export default function ChatSidebarItem({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
import createClient from './request'
|
||||||
|
|
||||||
|
export const editorRequest = createClient({ serviceName: 'editor' })
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -51,9 +51,6 @@ const endpoints = {
|
||||||
shark: process.env.NEXT_PUBLIC_SHARK_API_URL,
|
shark: process.env.NEXT_PUBLIC_SHARK_API_URL,
|
||||||
cow: process.env.NEXT_PUBLIC_COW_API_URL,
|
cow: process.env.NEXT_PUBLIC_COW_API_URL,
|
||||||
pigeon: process.env.NEXT_PUBLIC_PIGEON_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 {
|
export function createHttpClient(config: CreateHttpClientConfig): ExtendedAxiosInstance {
|
||||||
|
|
@ -138,7 +135,6 @@ export function createHttpClient(config: CreateHttpClientConfig): ExtendedAxiosI
|
||||||
instance.interceptors.response.use(
|
instance.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
const apiResponse = response.data as ApiResponse
|
const apiResponse = response.data as ApiResponse
|
||||||
console.log('apiResponse', apiResponse)
|
|
||||||
|
|
||||||
// 检查业务状态
|
// 检查业务状态
|
||||||
if (apiResponse.status === API_STATUS.OK) {
|
if (apiResponse.status === API_STATUS.OK) {
|
||||||
|
|
|
||||||
|
|
@ -54,14 +54,3 @@ export const lionHttp = createHttpClient({
|
||||||
encryptHeader: 'encrypt',
|
encryptHeader: 'encrypt',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// 自扩展服务 ------------ //
|
|
||||||
// 编辑器主服务
|
|
||||||
export const editHttp = createHttpClient({
|
|
||||||
serviceName: 'edit',
|
|
||||||
})
|
|
||||||
|
|
||||||
// 聊天
|
|
||||||
export const chatHttp = createHttpClient({
|
|
||||||
serviceName: 'chat',
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
export interface DiscordUser {
|
||||||
id: string
|
id: string;
|
||||||
username: string
|
username: string;
|
||||||
email: string
|
email: string;
|
||||||
avatar?: string
|
avatar?: string;
|
||||||
discriminator: string
|
discriminator: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiscordTokenResponse {
|
export interface DiscordTokenResponse {
|
||||||
access_token: string
|
access_token: string;
|
||||||
token_type: string
|
token_type: string;
|
||||||
expires_in: number
|
expires_in: number;
|
||||||
refresh_token: string
|
refresh_token: string;
|
||||||
scope: string
|
scope: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const discordOAuth = {
|
export const discordOAuth = {
|
||||||
// 获取Discord授权URL
|
// 获取Discord授权URL
|
||||||
getAuthUrl: (state?: string): string => {
|
getAuthUrl: (state?: string): string => {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
client_id: DISCORD_CLIENT_ID,
|
client_id: process.env.NEXT_PUBLIC_DISCORD_CLIENT_ID!,
|
||||||
redirect_uri: DISCORD_REDIRECT_URI,
|
redirect_uri: `${window.location.origin}/api/auth/discord/callback`,
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
scope: DISCORD_SCOPES.join(' '),
|
scope: ['identify', 'email'].join(' '),
|
||||||
...(state && { state }),
|
...(state && { state }),
|
||||||
})
|
});
|
||||||
|
|
||||||
return `https://discord.com/api/oauth2/authorize?${params.toString()}`
|
return `https://discord.com/api/oauth2/authorize?${params.toString()}`;
|
||||||
},
|
},
|
||||||
|
};
|
||||||
// 注意:token交换和用户信息获取将在后端处理
|
|
||||||
// 前端只负责获取授权码并传递给后端API
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,37 @@
|
||||||
'use client'
|
'use client';
|
||||||
import { ApiError } from '@/types/api'
|
import { ApiError } from '@/types/api';
|
||||||
import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
import React, { useState, useRef, type ReactNode } from 'react'
|
import React, { useState, useRef, type ReactNode } from 'react';
|
||||||
import { toast, Toaster } from 'sonner'
|
import { toast, Toaster } from 'sonner';
|
||||||
import { tokenManager } from './auth/token'
|
import { tokenManager } from './auth/token';
|
||||||
import { COIN_INSUFFICIENT_ERROR_CODE } from '@/hooks/useWallet'
|
import { COIN_INSUFFICIENT_ERROR_CODE } from '@/hooks/useWallet';
|
||||||
import { walletKeys } from './query-keys'
|
import { walletKeys } from './query-keys';
|
||||||
interface ProvidersProps {
|
interface ProvidersProps {
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ReactQueryDevtoolsProduction = React.lazy(() =>
|
// const ReactQueryDevtoolsProduction = React.lazy(() =>
|
||||||
import('@tanstack/react-query-devtools/build/modern/production.js').then((d) => ({
|
// import('@tanstack/react-query-devtools/build/modern/production.js').then((d) => ({
|
||||||
default: d.ReactQueryDevtools,
|
// 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) {
|
export function Providers({ children }: ProvidersProps) {
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
// 用于错误去重的引用
|
// 用于错误去重的引用
|
||||||
const errorTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
const errorTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const lastErrorRef = useRef<string>('')
|
const lastErrorRef = useRef<string>('');
|
||||||
const [showDevtools, setShowDevtools] = React.useState(false)
|
|
||||||
|
|
||||||
const [queryClient] = useState(
|
const [queryClient] = useState(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -45,60 +51,55 @@ export function Providers({ children }: ProvidersProps) {
|
||||||
},
|
},
|
||||||
queryCache: new QueryCache({
|
queryCache: new QueryCache({
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
handleError(error as ApiError)
|
handleError(error as ApiError);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
mutationCache: new MutationCache({
|
mutationCache: new MutationCache({
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
handleError(error as ApiError)
|
handleError(error as ApiError);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
)
|
);
|
||||||
const pathname = usePathname()
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const searchParamsString = searchParams.toString()
|
const searchParamsString = searchParams.toString();
|
||||||
const redirectURL = `${pathname}${searchParamsString ? `?${searchParamsString}` : ''}`
|
const redirectURL = `${pathname}${searchParamsString ? `?${searchParamsString}` : ''}`;
|
||||||
|
|
||||||
const handleError = (error: ApiError) => {
|
const handleError = (error: ApiError) => {
|
||||||
if (error.errorCode === COIN_INSUFFICIENT_ERROR_CODE) {
|
if (error.errorCode === COIN_INSUFFICIENT_ERROR_CODE) {
|
||||||
queryClient.invalidateQueries({ queryKey: walletKeys.getWalletBalance() })
|
queryClient.invalidateQueries({ queryKey: walletKeys.getWalletBalance() });
|
||||||
}
|
}
|
||||||
if (EXPIRED_ERROR_CODES.includes(error.errorCode)) {
|
if (EXPIRED_ERROR_CODES.includes(error.errorCode)) {
|
||||||
// 清除 cookie 中的 st
|
// 清除 cookie 中的 st
|
||||||
tokenManager.removeToken()
|
tokenManager.removeToken();
|
||||||
router.push('/login?redirect=' + encodeURIComponent(redirectURL))
|
router.push('/login?redirect=' + encodeURIComponent(redirectURL));
|
||||||
return // 对于登录过期错误,不显示错误toast,直接跳转
|
return; // 对于登录过期错误,不显示错误toast,直接跳转
|
||||||
}
|
}
|
||||||
if (error.ignoreError) {
|
if (error.ignoreError) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 错误去重逻辑:只显示最后一次的错误
|
// 错误去重逻辑:只显示最后一次的错误
|
||||||
const errorKey = `${error.errorCode}:${error.errorMsg}`
|
const errorKey = `${error.errorCode}:${error.errorMsg}`;
|
||||||
|
|
||||||
// 清除之前的定时器
|
// 清除之前的定时器
|
||||||
if (errorTimeoutRef.current) {
|
if (errorTimeoutRef.current) {
|
||||||
clearTimeout(errorTimeoutRef.current)
|
clearTimeout(errorTimeoutRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新最后一次错误信息
|
// 更新最后一次错误信息
|
||||||
lastErrorRef.current = errorKey
|
lastErrorRef.current = errorKey;
|
||||||
|
|
||||||
// 设置新的定时器,延迟显示错误
|
// 设置新的定时器,延迟显示错误
|
||||||
errorTimeoutRef.current = setTimeout(() => {
|
errorTimeoutRef.current = setTimeout(() => {
|
||||||
// 只有当前错误仍然是最后一次错误时才显示
|
// 只有当前错误仍然是最后一次错误时才显示
|
||||||
if (lastErrorRef.current === errorKey) {
|
if (lastErrorRef.current === errorKey) {
|
||||||
toast.error(error.errorMsg)
|
toast.error(error.errorMsg);
|
||||||
}
|
}
|
||||||
}, 100) // 100ms 延迟,确保能捕获到快速连续的错误
|
}, 100); // 100ms 延迟,确保能捕获到快速连续的错误
|
||||||
}
|
};
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
// @ts-ignore
|
|
||||||
window.toggleDevtools = () => setShowDevtools((old) => !old)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<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',
|
'!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}>
|
<React.Suspense fallback={null}>
|
||||||
<ReactQueryDevtoolsProduction />
|
<ReactQueryDevtoolsProduction />
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
)}
|
)} */}
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,17 @@
|
||||||
import { editHttp } from '@/lib/http/instances'
|
import { editorRequest } from '@/lib/client';
|
||||||
|
|
||||||
export async function fetchCharacters(params: any) {
|
export async function fetchCharacters({ index, limit, query }: any) {
|
||||||
return editHttp.post('/api/character/list', params)
|
const { data } = await editorRequest('/api/character/list', {
|
||||||
|
method: 'POST',
|
||||||
|
data: { index, limit, ...query },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchCharacter(params: any) {
|
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[]> {
|
export async function fetchCharacterTags(params: any = {}) {
|
||||||
return editHttp.post('/api/tag/list', params)
|
return editorRequest('/api/tag/list', { method: 'POST', data: params });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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')
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
export * from './types'
|
|
||||||
export * from './home.service'
|
|
||||||
|
|
@ -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_1(18-24)、AGE_2(25-34)、AGE_3(35-44)、AGE_4(45-54)、AGE_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[]
|
|
||||||
}
|
|
||||||