From 506e70204068506e8cb7c0dee5c2a4e67429510c Mon Sep 17 00:00:00 2001 From: liuyonghe0111 <1763195287@qq.com> Date: Thu, 11 Dec 2025 19:31:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E4=BA=86=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 12 +- .prettierrc | 2 +- package.json | 2 + pnpm-lock.yaml | 209 +++++ public/images/layout/chat.svg | 7 + public/images/layout/chat_active.svg | 22 + public/images/layout/explore.svg | 6 + public/images/layout/explore_active.svg | 17 + public/images/layout/me.svg | 6 + public/images/layout/me_active.svg | 17 + public/images/layout/search.svg | 7 + public/images/layout/search_active.svg | 13 + src/app/(auth)/about/page.tsx | 22 +- src/app/(auth)/layout.tsx | 83 +- src/app/(auth)/policy/privacy/page.tsx | 18 +- src/app/(auth)/policy/privacy/privacy.md | 92 -- .../Crushlevel Recharge Service Agreement.md | 131 --- src/app/(auth)/policy/tos/page.tsx | 18 +- src/app/(auth)/policy/tos/tos.md | 125 --- src/app/(main)/character/[id]/chat/page.tsx | 18 +- src/app/(main)/chat/page.tsx | 9 + .../home/components/Character/index.tsx | 48 +- src/app/(main)/home/components/Filter.tsx | 61 +- src/app/(main)/home/components/Header.tsx | 26 +- .../(main)/home/components/HomePageFooter.tsx | 47 +- .../home/components/MoreType/FilterDrawer.tsx | 241 ------ .../home/components/MoreType/MeetHeader.tsx | 230 ----- .../home/components/MoreType/MeetList.tsx | 123 --- .../(main)/home/components/MoreType/index.tsx | 180 ---- src/app/(main)/home/index.tsx | 27 - src/app/(main)/home/page.tsx | 29 + src/app/(main)/home/useSmartInfiniteQuery.ts | 142 +++ src/app/(main)/layout.tsx | 7 +- src/app/(main)/mainPage.tsx | 7 - .../profile/components/ProfileDropdown.tsx | 132 ++- src/app/(main)/search/page.tsx | 9 + src/app/api/auth/discord/callback/route.ts | 52 +- src/app/debug-mock/page.tsx | 147 ---- src/app/demo/page.tsx | 298 ------- src/app/layout.tsx | 38 +- .../login/components/DiscordButton.tsx | 0 .../login/components/GoogleButton.tsx | 0 .../login/components/ImageCarousel.tsx | 0 .../login/components/LeftPanel.tsx | 0 .../login/components/ScrollingBackground.tsx | 0 .../login/components/SocialButton.tsx | 0 .../login/components/login-form.tsx | 20 +- .../{(auth) => }/login/fields/fields-page.tsx | 0 src/app/{(auth) => }/login/fields/page.tsx | 0 src/app/{(auth) => }/login/login-page.tsx | 0 src/app/{(auth) => }/login/page.tsx | 0 src/app/page.tsx | 8 +- src/app/server-device-test/page.tsx | 192 ---- src/app/test-avatar-crop/page.tsx | 241 ------ src/app/test-avatar-setting/page.tsx | 62 -- src/app/test-discord/page.tsx | 284 ------ src/app/test-image-crop/page.tsx | 220 ----- src/app/test-lamejs/page.tsx | 67 -- src/app/test-middleware/page.tsx | 109 --- src/app/test-mp3-conversion/page.tsx | 173 ---- src/app/test-s3-upload/page.tsx | 45 - src/components/features/ai-standard-card.tsx | 118 ++- src/components/layout/ConditionalLayout.tsx | 80 -- src/components/layout/Sidebar.tsx | 185 ---- src/components/layout/TopBarWithoutLogin.tsx | 48 - .../ChatConversationsDeleteDialog.tsx | 57 -- .../layout/components/ChatSidebarAction.tsx | 77 -- src/components/ui/infinite-scroll-list.tsx | 141 +-- src/context/mainLayout/index.tsx | 64 -- src/context/mainLayout/useMainLayout.ts | 10 - src/css/tailwindcss.css | 6 + src/hooks/tools/index.ts | 71 ++ src/hooks/tools/useStreamChat.ts | 1 + src/hooks/useGlobalPrefetchRoutes.ts | 109 --- src/hooks/useHome.ts | 105 --- src/hooks/useInfiniteScroll.ts | 63 +- src/layout/BasicLayout.tsx | 49 ++ src/layout/BottomBar.tsx | 65 ++ src/layout/Sidebar.tsx | 109 +++ src/{components => }/layout/Topbar.tsx | 65 +- .../layout/components/ChatSearchResults.tsx | 0 .../layout/components/ChatSidebar.tsx | 117 +-- src/layout/components/ChatSidebarAction.tsx | 98 +++ .../layout/components/ChatSidebarItem.tsx | 84 +- .../layout/components/Notice.tsx | 0 .../layout/components/NoticeDrawer.tsx | 0 src/lib/client/auth.ts | 13 + src/lib/client/index.ts | 3 + src/lib/client/request.ts | 98 +++ src/lib/http/create-http-client.ts | 4 - src/lib/http/instances.ts | 11 - src/lib/oauth/discord.ts | 42 +- src/lib/providers.tsx | 97 ++- src/lib/server/auth.ts | 31 + src/lib/server/request.ts | 95 ++ src/services/editor/index.ts | 16 +- src/services/editor/type.d.ts | 23 + src/services/home/home.service.ts | 59 -- src/services/home/index.ts | 2 - src/services/home/types.ts | 819 ------------------ src/stores/index.ts | 61 ++ src/stores/stream-chat.ts | 95 ++ 102 files changed, 2008 insertions(+), 5284 deletions(-) create mode 100644 public/images/layout/chat.svg create mode 100644 public/images/layout/chat_active.svg create mode 100644 public/images/layout/explore.svg create mode 100644 public/images/layout/explore_active.svg create mode 100644 public/images/layout/me.svg create mode 100644 public/images/layout/me_active.svg create mode 100644 public/images/layout/search.svg create mode 100644 public/images/layout/search_active.svg delete mode 100644 src/app/(auth)/policy/privacy/privacy.md delete mode 100644 src/app/(auth)/policy/recharge/Crushlevel Recharge Service Agreement.md delete mode 100644 src/app/(auth)/policy/tos/tos.md create mode 100644 src/app/(main)/chat/page.tsx delete mode 100644 src/app/(main)/home/components/MoreType/FilterDrawer.tsx delete mode 100644 src/app/(main)/home/components/MoreType/MeetHeader.tsx delete mode 100644 src/app/(main)/home/components/MoreType/MeetList.tsx delete mode 100644 src/app/(main)/home/components/MoreType/index.tsx delete mode 100644 src/app/(main)/home/index.tsx create mode 100644 src/app/(main)/home/page.tsx create mode 100644 src/app/(main)/home/useSmartInfiniteQuery.ts delete mode 100644 src/app/(main)/mainPage.tsx create mode 100644 src/app/(main)/search/page.tsx delete mode 100644 src/app/debug-mock/page.tsx delete mode 100644 src/app/demo/page.tsx rename src/app/{(auth) => }/login/components/DiscordButton.tsx (100%) rename src/app/{(auth) => }/login/components/GoogleButton.tsx (100%) rename src/app/{(auth) => }/login/components/ImageCarousel.tsx (100%) rename src/app/{(auth) => }/login/components/LeftPanel.tsx (100%) rename src/app/{(auth) => }/login/components/ScrollingBackground.tsx (100%) rename src/app/{(auth) => }/login/components/SocialButton.tsx (100%) rename src/app/{(auth) => }/login/components/login-form.tsx (76%) rename src/app/{(auth) => }/login/fields/fields-page.tsx (100%) rename src/app/{(auth) => }/login/fields/page.tsx (100%) rename src/app/{(auth) => }/login/login-page.tsx (100%) rename src/app/{(auth) => }/login/page.tsx (100%) delete mode 100644 src/app/server-device-test/page.tsx delete mode 100644 src/app/test-avatar-crop/page.tsx delete mode 100644 src/app/test-avatar-setting/page.tsx delete mode 100644 src/app/test-discord/page.tsx delete mode 100644 src/app/test-image-crop/page.tsx delete mode 100644 src/app/test-lamejs/page.tsx delete mode 100644 src/app/test-middleware/page.tsx delete mode 100644 src/app/test-mp3-conversion/page.tsx delete mode 100644 src/app/test-s3-upload/page.tsx delete mode 100644 src/components/layout/ConditionalLayout.tsx delete mode 100644 src/components/layout/Sidebar.tsx delete mode 100644 src/components/layout/TopBarWithoutLogin.tsx delete mode 100644 src/components/layout/components/ChatConversationsDeleteDialog.tsx delete mode 100644 src/components/layout/components/ChatSidebarAction.tsx delete mode 100644 src/context/mainLayout/index.tsx delete mode 100644 src/context/mainLayout/useMainLayout.ts create mode 100644 src/hooks/tools/index.ts create mode 100644 src/hooks/tools/useStreamChat.ts delete mode 100644 src/hooks/useGlobalPrefetchRoutes.ts delete mode 100644 src/hooks/useHome.ts create mode 100644 src/layout/BasicLayout.tsx create mode 100644 src/layout/BottomBar.tsx create mode 100644 src/layout/Sidebar.tsx rename src/{components => }/layout/Topbar.tsx (58%) rename src/{components => }/layout/components/ChatSearchResults.tsx (100%) rename src/{components => }/layout/components/ChatSidebar.tsx (55%) create mode 100644 src/layout/components/ChatSidebarAction.tsx rename src/{components => }/layout/components/ChatSidebarItem.tsx (66%) rename src/{components => }/layout/components/Notice.tsx (100%) rename src/{components => }/layout/components/NoticeDrawer.tsx (100%) create mode 100644 src/lib/client/auth.ts create mode 100644 src/lib/client/index.ts create mode 100644 src/lib/client/request.ts create mode 100644 src/lib/server/auth.ts create mode 100644 src/lib/server/request.ts create mode 100644 src/services/editor/type.d.ts delete mode 100644 src/services/home/home.service.ts delete mode 100644 src/services/home/index.ts delete mode 100644 src/services/home/types.ts create mode 100644 src/stores/index.ts create mode 100644 src/stores/stream-chat.ts diff --git a/.env b/.env index a1d3eb6..dde9e76 100644 --- a/.env +++ b/.env @@ -1,20 +1,16 @@ NEXT_PUBLIC_AUTH_API_URL=https://localhost:3000/api/mock -NEXT_PUBLIC_FROG_API_URL=https://test-frog.crushlevel.ai +NEXT_PUBLIC_FROG_API_URL=http://35.82.37.117:8082/frog NEXT_PUBLIC_BEAR_API_URL=https://test-bear.crushlevel.ai NEXT_PUBLIC_LION_API_URL=https://test-lion.crushlevel.ai NEXT_PUBLIC_SHARK_API_URL=https://test-shark.crushlevel.ai NEXT_PUBLIC_COW_API_URL=https://test-cow.crushlevel.ai NEXT_PUBLIC_PIGEON_API_URL=https://test-pigeon.crushlevel.ai -# 自建服务 -NEXT_PUBLIC_EDIT_API_URL=http://54.223.196.180 -NEXT_PUBLIC_CHAT_API_URL=http://54.223.196.180 +# A18 服务 +NEXT_PUBLIC_EDITOR_API_URL=http://35.82.37.117 # 三方登录 -NEXT_PUBLIC_DISCORD_CLIENT_ID=1396735872459866233 - -# yunxin IM -NEXT_PUBLIC_NIM_APP_KEY=2d6abc076f434fc52320c7118de5fead +NEXT_PUBLIC_DISCORD_CLIENT_ID=1448143535609217076 # S3 NEXT_PUBLIC_S3_URI=https://hhb.crushlevel.ai diff --git a/.prettierrc b/.prettierrc index 238d4d9..1f4c4bb 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,5 +1,5 @@ { - "semi": false, + "semi": true, "singleQuote": true, "tabWidth": 2, "trailingComma": "es5", diff --git a/package.json b/package.json index fbfdd76..0312dbf 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@tanstack/react-query-devtools": "^5.83.0", "@types/crypto-js": "^4.2.2", "@types/react-stickynode": "^4.0.3", + "ahooks": "^3.9.6", "axios": "^1.10.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -61,6 +62,7 @@ "react-stickynode": "^5.0.2", "react-virtuoso": "^4.17.0", "sonner": "^2.0.6", + "stream-chat": "^9.27.0", "swiper": "^12.0.3", "tailwind-merge": "^3.3.1", "vaul": "^1.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb45398..0d5c6ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,9 @@ importers: '@types/react-stickynode': specifier: ^4.0.3 version: 4.0.3 + ahooks: + specifier: ^3.9.6 + version: 3.9.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1) axios: specifier: ^1.10.0 version: 1.10.0 @@ -152,6 +155,9 @@ importers: sonner: specifier: ^2.0.6 version: 2.0.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + stream-chat: + specifier: ^9.27.0 + version: 9.27.0 swiper: specifier: ^12.0.3 version: 12.0.3 @@ -1927,6 +1933,12 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@20.19.8': resolution: {integrity: sha512-HzbgCY53T6bfu4tT7Aq3TvViJyHjLjPNaAS3HOuMc9pw97KHsUtXNX4L+wu59g1WnjsZSko35MbEqnO58rihhw==} @@ -1956,6 +1968,9 @@ packages: '@types/uuid@9.0.8': resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@8.49.0': resolution: {integrity: sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2163,6 +2178,12 @@ packages: resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==} engines: {node: '>=0.8'} + ahooks@3.9.6: + resolution: {integrity: sha512-Mr7f05swd5SmKlR9SZo5U6M0LsL4ErweLzpdgXjA1JPmnZ78Vr6wzx0jUtvoxrcqGKYnX0Yjc02iEASVxHFPjQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} @@ -2249,6 +2270,9 @@ packages: axios@1.10.0: resolution: {integrity: sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==} + axios@1.13.2: + resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -2300,6 +2324,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} @@ -2492,6 +2519,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} @@ -2969,6 +2999,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + intersection-observer@0.12.2: + resolution: {integrity: sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==} + deprecated: The Intersection Observer polyfill is no longer needed and can safely be removed. Intersection Observer has been Baseline since 2019. + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -3104,6 +3138,11 @@ packages: resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} engines: {node: '>=0.10.0'} + isomorphic-ws@5.0.0: + resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} + peerDependencies: + ws: '*' + iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} @@ -3164,10 +3203,20 @@ packages: engines: {node: '>=6'} hasBin: true + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -3257,13 +3306,37 @@ packages: resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==} engines: {node: '>= 12.0.0'} + linkifyjs@4.3.2: + resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -3622,6 +3695,9 @@ packages: react: '>=16.4.0' react-dom: '>=16.4.0' + react-fast-compare@3.2.2: + resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} + react-hook-form@7.60.0: resolution: {integrity: sha512-SBrYOvMbDB7cV8ZfNpaiLcgjH/a1c7aK0lK+aNigpf4xWLO8q+o4tcvVurv3c4EOyzn/3dCsYt4GKD42VvJ/+A==} engines: {node: '>=18.0.0'} @@ -3717,6 +3793,9 @@ packages: requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resize-observer-polyfill@1.5.1: + resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -3768,6 +3847,10 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + screenfull@5.2.0: + resolution: {integrity: sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==} + engines: {node: '>=0.10.0'} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -3861,6 +3944,10 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + stream-chat@9.27.0: + resolution: {integrity: sha512-MG1Jo0eu1Z7W/wkytsGZlqvwh1YEACxAr/momrfd+g0rJA/wQ6vyC42edm91p47AVICL2c4IBK3CSVDGKOOoJQ==} + engines: {node: '>=18'} + stream-composer@1.0.2: resolution: {integrity: sha512-bnBselmwfX5K10AH6L4c8+S5lgZMWI7ZYrz2rvYjCPB2DIMC4Ig8OpxGpNJSxRZ58oti7y1IcNvjBAz9vW5m4w==} @@ -4207,6 +4294,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xlsx@0.18.5: resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} engines: {node: '>=0.8'} @@ -6319,6 +6418,13 @@ snapshots: '@types/json5@0.0.29': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 20.19.8 + + '@types/ms@2.1.0': {} + '@types/node@20.19.8': dependencies: undici-types: 6.21.0 @@ -6345,6 +6451,10 @@ snapshots: '@types/uuid@9.0.8': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 20.19.8 + '@typescript-eslint/eslint-plugin@8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3)': dependencies: '@eslint-community/regexpp': 4.12.1 @@ -6537,6 +6647,21 @@ snapshots: adler-32@1.3.1: {} + ahooks@3.9.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + dependencies: + '@babel/runtime': 7.28.4 + '@types/js-cookie': 3.0.6 + dayjs: 1.11.13 + intersection-observer: 0.12.2 + js-cookie: 3.0.5 + lodash: 4.17.21 + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + react-fast-compare: 3.2.2 + resize-observer-polyfill: 1.5.1 + screenfull: 5.2.0 + tslib: 2.8.1 + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -6656,6 +6781,14 @@ snapshots: transitivePeerDependencies: - debug + axios@1.13.2: + dependencies: + follow-redirects: 1.15.9 + form-data: 4.0.4 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + axobject-query@4.1.0: {} b4a@1.7.3: {} @@ -6697,6 +6830,8 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.2(browserslist@4.28.1) + buffer-equal-constant-time@1.0.1: {} + buffer@6.0.3: dependencies: base64-js: 1.5.1 @@ -6865,6 +7000,10 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + electron-to-chromium@1.5.267: {} embla-carousel-react@8.6.0(react@19.2.1): @@ -7528,6 +7667,8 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + intersection-observer@0.12.2: {} + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 @@ -7659,6 +7800,10 @@ snapshots: isobject@3.0.1: {} + isomorphic-ws@5.0.0(ws@8.18.3): + dependencies: + ws: 8.18.3 + iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -7699,6 +7844,19 @@ snapshots: json5@2.2.3: {} + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.3 + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -7706,6 +7864,17 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -7774,12 +7943,28 @@ snapshots: lightningcss-win32-arm64-msvc: 1.30.1 lightningcss-win32-x64-msvc: 1.30.1 + linkifyjs@4.3.2: {} + locate-path@6.0.0: dependencies: p-locate: 5.0.0 + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} + lodash@4.17.21: {} loose-envify@1.4.0: @@ -8075,6 +8260,8 @@ snapshots: react-dom: 19.2.1(react@19.2.1) tslib: 2.8.1 + react-fast-compare@3.2.2: {} + react-hook-form@7.60.0(react@19.2.1): dependencies: react: 19.2.1 @@ -8175,6 +8362,8 @@ snapshots: requires-port@1.0.0: {} + resize-observer-polyfill@1.5.1: {} + resolve-from@4.0.0: {} resolve-options@2.0.0: @@ -8228,6 +8417,8 @@ snapshots: scheduler@0.27.0: {} + screenfull@5.2.0: {} + semver@6.3.1: {} semver@7.7.3: {} @@ -8352,6 +8543,22 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + stream-chat@9.27.0: + dependencies: + '@types/jsonwebtoken': 9.0.10 + '@types/ws': 8.18.1 + axios: 1.13.2 + base64-js: 1.5.1 + form-data: 4.0.4 + isomorphic-ws: 5.0.0(ws@8.18.3) + jsonwebtoken: 9.0.3 + linkifyjs: 4.3.2 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - debug + - utf-8-validate + stream-composer@1.0.2: dependencies: streamx: 2.23.0 @@ -8832,6 +9039,8 @@ snapshots: wrappy@1.0.2: {} + ws@8.18.3: {} + xlsx@0.18.5: dependencies: adler-32: 1.3.1 diff --git a/public/images/layout/chat.svg b/public/images/layout/chat.svg new file mode 100644 index 0000000..a710a8e --- /dev/null +++ b/public/images/layout/chat.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/layout/chat_active.svg b/public/images/layout/chat_active.svg new file mode 100644 index 0000000..960160c --- /dev/null +++ b/public/images/layout/chat_active.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/layout/explore.svg b/public/images/layout/explore.svg new file mode 100644 index 0000000..7f9aef6 --- /dev/null +++ b/public/images/layout/explore.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/layout/explore_active.svg b/public/images/layout/explore_active.svg new file mode 100644 index 0000000..9a09e25 --- /dev/null +++ b/public/images/layout/explore_active.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/public/images/layout/me.svg b/public/images/layout/me.svg new file mode 100644 index 0000000..a8faad6 --- /dev/null +++ b/public/images/layout/me.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/layout/me_active.svg b/public/images/layout/me_active.svg new file mode 100644 index 0000000..a3e9e77 --- /dev/null +++ b/public/images/layout/me_active.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/public/images/layout/search.svg b/public/images/layout/search.svg new file mode 100644 index 0000000..7e382e4 --- /dev/null +++ b/public/images/layout/search.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/layout/search_active.svg b/public/images/layout/search_active.svg new file mode 100644 index 0000000..792c0df --- /dev/null +++ b/public/images/layout/search_active.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/app/(auth)/about/page.tsx b/src/app/(auth)/about/page.tsx index bb7c06e..0e8f5bd 100644 --- a/src/app/(auth)/about/page.tsx +++ b/src/app/(auth)/about/page.tsx @@ -1,8 +1,18 @@ +'use client'; +import { usePolicyLayout } from '../layout'; +import { useEffect } from 'react'; + const AboutPage = () => { + const { setTitle } = usePolicyLayout(); + + useEffect(() => { + setTitle('Crushlevel About'); + }, [setTitle]); + return ( -
-
-
+
+
+
{
- ) -} + ); +}; -export default AboutPage +export default AboutPage; diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx index bf0b756..1f99a65 100644 --- a/src/app/(auth)/layout.tsx +++ b/src/app/(auth)/layout.tsx @@ -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(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 ( -
-
{children}
-
- ) -} + +
+
+
+ {isBlur &&
} +
+
+ router.back()} + iconfont="icon-arrow-left" + /> +
{title}
+
+
+
+
+
+
+ {children} +
+
+
+
+
+ ); +}; -export default AuthLayout +export default PolicyLayout; diff --git a/src/app/(auth)/policy/privacy/page.tsx b/src/app/(auth)/policy/privacy/page.tsx index ed46a6a..8b102a5 100644 --- a/src/app/(auth)/policy/privacy/page.tsx +++ b/src/app/(auth)/policy/privacy/page.tsx @@ -1,12 +1,22 @@ +'use client'; +import { usePolicyLayout } from '../../layout'; +import { useEffect } from 'react'; + export default function PrivacyPolicyPage() { + const { setTitle } = usePolicyLayout(); + + useEffect(() => { + setTitle('Privacy Policy'); + }, [setTitle]); + return (
-
+
{/* 主标题 */} -
+ {/*

Crushlevel Privacy Policy

-
+
*/} {/* 前言 */}
@@ -273,5 +283,5 @@ export default function PrivacyPolicyPage() {
- ) + ); } diff --git a/src/app/(auth)/policy/privacy/privacy.md b/src/app/(auth)/policy/privacy/privacy.md deleted file mode 100644 index 4f58410..0000000 --- a/src/app/(auth)/policy/privacy/privacy.md +++ /dev/null @@ -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! - -\ -\ diff --git a/src/app/(auth)/policy/recharge/Crushlevel Recharge Service Agreement.md b/src/app/(auth)/policy/recharge/Crushlevel Recharge Service Agreement.md deleted file mode 100644 index 337af59..0000000 --- a/src/app/(auth)/policy/recharge/Crushlevel Recharge Service Agreement.md +++ /dev/null @@ -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 生成) diff --git a/src/app/(auth)/policy/tos/page.tsx b/src/app/(auth)/policy/tos/page.tsx index acde335..916d07d 100644 --- a/src/app/(auth)/policy/tos/page.tsx +++ b/src/app/(auth)/policy/tos/page.tsx @@ -1,12 +1,22 @@ +'use client'; +import { useEffect } from 'react'; +import { usePolicyLayout } from '../../layout'; + export default function TermsOfServicePage() { + const { setTitle } = usePolicyLayout(); + + useEffect(() => { + setTitle('Crushlevel User Agreement'); + }, [setTitle]); + return (
-
+
{/* 主标题 */} -
+ {/*

Crushlevel User Agreement

-
+
*/} {/* 前言 */}
@@ -419,5 +429,5 @@ export default function TermsOfServicePage() {
- ) + ); } diff --git a/src/app/(auth)/policy/tos/tos.md b/src/app/(auth)/policy/tos/tos.md deleted file mode 100644 index 4cdc145..0000000 --- a/src/app/(auth)/policy/tos/tos.md +++ /dev/null @@ -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! diff --git a/src/app/(main)/character/[id]/chat/page.tsx b/src/app/(main)/character/[id]/chat/page.tsx index 478503c..b345dc3 100644 --- a/src/app/(main)/character/[id]/chat/page.tsx +++ b/src/app/(main)/character/[id]/chat/page.tsx @@ -1,14 +1,14 @@ -'use client' +'use client'; -import { IconButton } from '@/components/ui/button' -import Input from './Input' -import MessageList from './MessageList' -import { useChatStore } from './store' -import Sider from './Sider' +import { IconButton } from '@/components/ui/button'; +import Input from './Input'; +import MessageList from './MessageList'; +import { useChatStore } from './store'; +import Sider from './Sider'; export default function ChatPage() { - const isSidebarOpen = useChatStore((store) => store.isSidebarOpen) - const setIsSidebarOpen = useChatStore((store) => store.setIsSidebarOpen) + const isSidebarOpen = useChatStore((store) => store.isSidebarOpen); + const setIsSidebarOpen = useChatStore((store) => store.setIsSidebarOpen); return (
@@ -29,5 +29,5 @@ export default function ChatPage() { {isSidebarOpen && }
- ) + ); } diff --git a/src/app/(main)/chat/page.tsx b/src/app/(main)/chat/page.tsx new file mode 100644 index 0000000..4f61f02 --- /dev/null +++ b/src/app/(main)/chat/page.tsx @@ -0,0 +1,9 @@ +'use client'; + +export default function ChatPage() { + return ( +
+

Chat

+
+ ); +} diff --git a/src/app/(main)/home/components/Character/index.tsx b/src/app/(main)/home/components/Character/index.tsx index 80bab39..7e20c25 100644 --- a/src/app/(main)/home/components/Character/index.tsx +++ b/src/app/(main)/home/components/Character/index.tsx @@ -1,11 +1,43 @@ -'use client' +'use client'; +import { InfiniteScrollList } from '@/components/ui/infinite-scroll-list'; +import AIStandardCard from '@/components/features/ai-standard-card'; +import useSmartInfiniteQuery from '../../useSmartInfiniteQuery'; +import { fetchCharacters } from '@/services/editor'; +import { useHomeStore } from '../../store'; +import { useEffect } from 'react'; const Character = () => { - return ( -
-
Character
-
- ) -} + const selectedTags = useHomeStore((state) => state.selectedTags); -export default Character + const { dataSource, isFirstLoading, isLoadingMore, noMoreData, onLoadMore, onSearch } = + useSmartInfiniteQuery(fetchCharacters, { + queryKey: 'characters', + defaultQuery: { tagIds: selectedTags }, + }); + + useEffect(() => { + onSearch({ tagIds: selectedTags }); + }, [selectedTags]); + + return ( +
+ + items={dataSource} + columns={{ + xs: 2, + sm: 3, + md: 4, + lg: 5, + xl: 6, + }} + renderItem={(character) => } + getItemKey={(character) => character.id} + hasNextPage={!noMoreData} + isLoading={isFirstLoading || isLoadingMore} + fetchNextPage={onLoadMore} + /> +
+ ); +}; + +export default Character; diff --git a/src/app/(main)/home/components/Filter.tsx b/src/app/(main)/home/components/Filter.tsx index 41cca72..7a3f720 100644 --- a/src/app/(main)/home/components/Filter.tsx +++ b/src/app/(main)/home/components/Filter.tsx @@ -1,25 +1,28 @@ -'use client' +'use client'; -import { cn } from '@/lib/utils' -import Image from 'next/image' -import { Chip } from '@/components/ui/chip' -import { useHomeStore } from '../store' -import { useQuery } from '@tanstack/react-query' -import { fetchTags } from '@/services/editor' +import { cn } from '@/lib/utils'; +import Image from 'next/image'; +import { Chip } from '@/components/ui/chip'; +import { useHomeStore } from '../store'; +import { useQuery } from '@tanstack/react-query'; +import { fetchCharacterTags } from '@/services/editor'; const Filter = () => { - const tab = useHomeStore((state) => state.tab) - const setTab = useHomeStore((state) => state.setTab) - const selectedTags = useHomeStore((state) => state.selectedTags) - const setSelectedTags = useHomeStore((state) => state.setSelectedTags) + const tab = useHomeStore((state) => state.tab); + const setTab = useHomeStore((state) => state.setTab); + const selectedTags = useHomeStore((state) => state.selectedTags); + const setSelectedTags = useHomeStore((state) => state.setSelectedTags); + const { data: tags = [] } = useQuery({ - queryKey: ['tags'], + queryKey: ['tags', tab], queryFn: async () => { - const { data } = await fetchTags({}) - console.log('data', data) - return data.rows + if (tab === 'character') { + const { data } = await fetchCharacterTags({ limit: 10 }); + return data.rows; + } + return []; }, - }) + }); const tabs = [ { @@ -34,13 +37,13 @@ const Filter = () => { icon: 'icon-character', activeIcon: 'icon-character-active', }, - ] as const + ] as const; return ( -
+
{tabs.map((item) => { - const active = tab === item.value + const active = tab === item.value; return (
{ /> {item.label}
- ) + ); })}
-
- {tags?.map((tag) => ( +
+ {tags?.map((tag: any) => ( setSelectedTags([tag.value])} + state={selectedTags.includes(tag.id) ? 'active' : 'inactive'} + onClick={() => setSelectedTags([tag.id])} > - # {tag.label} + # {tag.name} ))}
- ) -} + ); +}; -export default Filter +export default Filter; diff --git a/src/app/(main)/home/components/Header.tsx b/src/app/(main)/home/components/Header.tsx index 89b1e32..4e286f6 100644 --- a/src/app/(main)/home/components/Header.tsx +++ b/src/app/(main)/home/components/Header.tsx @@ -1,21 +1,23 @@ -'use client' +'use client'; -import Image from 'next/image' -import { IconButton } from '@/components/ui/button' -import Link from 'next/link' -import React from 'react' +import Image from 'next/image'; +import { IconButton } from '@/components/ui/button'; +import Link from 'next/link'; +import React from 'react'; +import { useMedia } from '@/hooks/tools'; const Header = React.memo(() => { + const response = useMedia(); return (
-
+
{
- banner-header + {response?.sm && ( + banner-header + )}
- ) -}) + ); +}); -export default Header +export default Header; diff --git a/src/app/(main)/home/components/HomePageFooter.tsx b/src/app/(main)/home/components/HomePageFooter.tsx index aeddbb0..df6c619 100644 --- a/src/app/(main)/home/components/HomePageFooter.tsx +++ b/src/app/(main)/home/components/HomePageFooter.tsx @@ -1,23 +1,16 @@ -'use client' +'use client'; -import { useState } from 'react' -import Link from 'next/link' -import Image from 'next/image' -import { useMainLayout } from '@/context/mainLayout' -import { cn } from '@/lib/utils' -import useCreatorNavigation from '@/hooks/useCreatorNavigation' +import { useState } from 'react'; +import Link from 'next/link'; +import Image from 'next/image'; +import { cn } from '@/lib/utils'; const HomePageFooter = () => { - const [isExpanded, setIsExpanded] = useState(false) - const { isSidebarExpanded } = useMainLayout() - const { routerToCreate } = useCreatorNavigation() - - // 根据侧边栏状态计算左侧边距 - const leftMargin = isSidebarExpanded ? 'ml-80' : 'ml-20' + const [isExpanded, setIsExpanded] = useState(false); return (
setIsExpanded(true)} onMouseLeave={() => setIsExpanded(false)} > @@ -49,12 +42,6 @@ const HomePageFooter = () => {

Features

-
- Create a Character -
{ > CrushLevel VIP - - Character Leaderboard -
@@ -86,14 +67,6 @@ const HomePageFooter = () => { > Daily Free CrushCoins - {/* - Get App - */} {
- ) -} + ); +}; -export default HomePageFooter +export default HomePageFooter; diff --git a/src/app/(main)/home/components/MoreType/FilterDrawer.tsx b/src/app/(main)/home/components/MoreType/FilterDrawer.tsx deleted file mode 100644 index d452238..0000000 --- a/src/app/(main)/home/components/MoreType/FilterDrawer.tsx +++ /dev/null @@ -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 = ({ - open, - onOpenChange, - filters, - onFiltersChange, - onReset, - onConfirm, -}) => { - const [localFilters, setLocalFilters] = useState(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 ( - - - {/* Header */} - -
- - - - Filter -
-
- - {/* Content */} -
- {/* Gender Section */} -
-

Gender

-
- {GENDER_OPTIONS.map((option) => ( - handleGenderToggle(option.code.toString())} - className="px-4 py-1.5" - > - {option.name} - - ))} -
-
- - {/* Age Section */} -
-

Age

-
- {AGE_OPTIONS.map((option) => ( - handleAgeToggle(option.code)} - className="px-4 py-1.5" - > - {option.name} - - ))} -
-
- - {/* Type Section */} -
-

Type

-
- {TYPE_OPTIONS.map((option, index) => { - const isSelected = localFilters.type.includes(option.code) - return ( -
handleTypeToggle(option.code)} - style={{ - background: isSelected ? option.background : 'rgba(251, 222, 255, 0.08', - }} - > -
-
- {option.name} -
-
- {option.name} -
-
-
- ) - })} -
-
-
- - {/* Footer */} -
-
- - -
-
-
-
- ) -} - -export default FilterDrawer diff --git a/src/app/(main)/home/components/MoreType/MeetHeader.tsx b/src/app/(main)/home/components/MoreType/MeetHeader.tsx deleted file mode 100644 index ee8dd6c..0000000 --- a/src/app/(main)/home/components/MoreType/MeetHeader.tsx +++ /dev/null @@ -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({ - 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 = () => ( -
- {/* 角色类型标签页和筛选按钮 */} -
- {/* 使用 Tabs 组件 */} - - - {/* ALL 选项 */} - - All - {/* 活跃状态指示器 */} -
- - {roles.map((role) => ( - - {role.name} - {/* 活跃状态指示器 */} -
- - ))} - - - - {/* Filter 按钮 */} - -
- -
- {filterCount > 0 ? `Filter(${filterCount})` : 'Filter'} -
-
-
-
- - {/* 快速筛选标签 */} -
- {quickTags.map((tag) => ( - handleTagToggle(tag.code || '')} - > - #{tag.name} - - ))} -
-
- ) - - return ( - <> - {/* 原始容器 - 用于检测 sticky 触发点 */} -
- {/* 占位元素 - 当 fixed 时保持布局不跳动 */} - {isSticky &&
} - - {!isSticky && ( -
-
- {/* Meet 标题 */} -

🔮 More Characters

- - {/* 筛选区域 */} - {renderFilterContent()} -
-
- )} -
- - {/* Fixed 定位的筛选栏 - sticky 时显示 */} - {isSticky && ( -
- {/* 半透明背景 */} -
- - {/* 内容区域 - 与 HomePage 保持一致的 padding 和 max-width */} -
-
{renderFilterContent()}
-
-
- )} - - {/* 筛选抽屉 */} - {})} - onReset={handleFiltersReset} - onConfirm={handleFiltersConfirm} - /> - - ) -} - -export default MeetHeader diff --git a/src/app/(main)/home/components/MoreType/MeetList.tsx b/src/app/(main)/home/components/MoreType/MeetList.tsx deleted file mode 100644 index 63991ad..0000000 --- a/src/app/(main)/home/components/MoreType/MeetList.tsx +++ /dev/null @@ -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 = () => ( -
-
-
- {/* 底部信息区域骨架 */} -
-
- {/* 名称骨架 */} -
- - {/* 标签骨架 */} -
-
-
-
- - {/* 点赞数骨架 */} -
-
-
-
-
-
-) - -// 空状态组件 -const EmptyState = () => ( -
- -
-) - -// 错误状态组件 -const ErrorState: React.FC<{ onRetry: () => void }> = ({ onRetry }) => ( -
-
- - - - -
-

Load failed

-

- The network connection is abnormal. Please try again later -

-
-) - -const MeetList: React.FC = ({ - items, - hasNextPage, - isLoading, - fetchNextPage, - onCharacterClick, - className, - hasError = false, - onRetry, -}) => { - return ( -
- - items={items} - renderItem={(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} - /> -
- ) -} - -export default MeetList diff --git a/src/app/(main)/home/components/MoreType/index.tsx b/src/app/(main)/home/components/MoreType/index.tsx deleted file mode 100644 index c28f36c..0000000 --- a/src/app/(main)/home/components/MoreType/index.tsx +++ /dev/null @@ -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('') - const [selectedTags, setSelectedTags] = useState([]) - const [filters, setFilters] = useState({ - 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 = { - 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, 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 ( -
- -
- -
-
- ) -} - -export default MoreType diff --git a/src/app/(main)/home/index.tsx b/src/app/(main)/home/index.tsx deleted file mode 100644 index e0c10bb..0000000 --- a/src/app/(main)/home/index.tsx +++ /dev/null @@ -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 ( - <> -
-
-
- - {tab === 'story' ? : } -
-
- - - ) -} - -export default HomePage diff --git a/src/app/(main)/home/page.tsx b/src/app/(main)/home/page.tsx new file mode 100644 index 0000000..03fc8a6 --- /dev/null +++ b/src/app/(main)/home/page.tsx @@ -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 ( +
+
+
+
+ + {tab === 'story' ? : } +
+
+ {response?.sm && } +
+ ); +}; + +export default HomePage; diff --git a/src/app/(main)/home/useSmartInfiniteQuery.ts b/src/app/(main)/home/useSmartInfiniteQuery.ts new file mode 100644 index 0000000..a8d3a34 --- /dev/null +++ b/src/app/(main)/home/useSmartInfiniteQuery.ts @@ -0,0 +1,142 @@ +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { useDebounceFn, useMemoizedFn } from 'ahooks'; +import { useState, useRef } from 'react'; + +type ParamsType = { + index: number; + limit: number; + query: Q; +}; + +type PropsType = { + queryKey: string; + defaultQuery?: Q; + defaultIndex?: number; + limit?: number; + isRowSame?: (d: T, n: T) => boolean; +}; + +type RequestType = ( + params: ParamsType +) => Promise<{ rows: T[]; total: number } | undefined>; + +type UseInfiniteScrollValue = { + query: Q; + onLoadMore: () => void; + onSearch: (query: Q) => void; + dataSource: T[]; + total: number; + // 是否正在加载第一页,包括 初始化加载 和 参数改变时加载 + isFirstLoading: boolean; + isLoadingMore: boolean; + noMoreData: boolean; +}; + +type DataType = { + rows: T[]; + total: number; + index: number; +}; + +const useSmartInfiniteQuery = ( + request: RequestType, + props: PropsType +): UseInfiniteScrollValue => { + const { + queryKey, + defaultQuery, + defaultIndex = 1, + limit = 20, + isRowSame = (d, n) => { + return (d as any)?.id === (n as any)?.id; + }, + } = props; + const [query, setQuery] = useState(defaultQuery as Q); + const index = useRef(defaultIndex); + + // 判断第一页数据和缓存中的第一页数据是否相等 + const isSameData = useMemoizedFn((prevData: DataType, result: Omit, '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) ?? { + 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; diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index 790721a..f7092bf 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -1,5 +1,4 @@ -import React from 'react' +'use client'; +import ConditionalLayout from '@/layout/BasicLayout'; -export default async function MainLayout({ children }: { children: React.ReactNode }) { - return children -} +export default ConditionalLayout; diff --git a/src/app/(main)/mainPage.tsx b/src/app/(main)/mainPage.tsx deleted file mode 100644 index 99839f5..0000000 --- a/src/app/(main)/mainPage.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import Home from './home' - -const MainPage = () => { - return -} - -export default MainPage diff --git a/src/app/(main)/profile/components/ProfileDropdown.tsx b/src/app/(main)/profile/components/ProfileDropdown.tsx index 290b7a5..140cbc1 100644 --- a/src/app/(main)/profile/components/ProfileDropdown.tsx +++ b/src/app/(main)/profile/components/ProfileDropdown.tsx @@ -7,10 +7,10 @@ import { AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, -} from '@/components/ui/alert-dialog' -import { useLogout } from '@/hooks/auth' -import { useNimChat, useNimConversation } from '@/context/NimChat/useNimChat' -import { useSetAtom } from 'jotai' +} from '@/components/ui/alert-dialog'; +import { useLogout } from '@/hooks/auth'; +import { useNimChat, useNimConversation } from '@/context/NimChat/useNimChat'; +import { useSetAtom } from 'jotai'; import { conversationListAtom, msgListAtom, @@ -19,20 +19,20 @@ import { imReconnectStatusAtom, IMReconnectStatus, selectedConversationIdAtom, -} from '@/atoms/im' -import { QueueMap } from '@/lib/queue' -import Link from 'next/link' -import { useState } from 'react' -import { useMainLayout } from '@/context/mainLayout' +} from '@/atoms/im'; +import { QueueMap } from '@/lib/queue'; +import Link from 'next/link'; +import { useState } from 'react'; +import { useLayoutStore } from '@/stores'; const ProfileDropdownItem = ({ icon, children, onClick, }: { - icon: string - children: React.ReactNode - onClick?: () => void + icon: string; + children: React.ReactNode; + onClick?: () => void; }) => { return (
- ) -} + ); +}; const ProfileDropdown = () => { - const { mutateAsync: logout } = useLogout() - const { nim } = useNimChat() - const { clearAllConversations } = useNimConversation() - const [isLogoutDialogOpen, setIsLogoutDialogOpen] = useState(false) - const [isLogoutDialogLoading, setIsLogoutDialogLoading] = useState(false) - const { isSidebarExpanded, dispatch } = useMainLayout() + const { mutateAsync: logout } = useLogout(); + const { nim } = useNimChat(); + const { clearAllConversations } = useNimConversation(); + const [isLogoutDialogOpen, setIsLogoutDialogOpen] = useState(false); + const [isLogoutDialogLoading, setIsLogoutDialogLoading] = useState(false); + const { isSidebarExpanded, setSidebarExpanded } = useLayoutStore(); // IM相关状态重置 - const setConversationList = useSetAtom(conversationListAtom) - const setMsgList = useSetAtom(msgListAtom) - const setUserList = useSetAtom(userListAtom) - const setImSynced = useSetAtom(imSyncedAtom) - const setImReconnectStatus = useSetAtom(imReconnectStatusAtom) - const setSelectedConversationId = useSetAtom(selectedConversationIdAtom) + const setConversationList = useSetAtom(conversationListAtom); + const setMsgList = useSetAtom(msgListAtom); + const setUserList = useSetAtom(userListAtom); + const setImSynced = useSetAtom(imSyncedAtom); + const setImReconnectStatus = useSetAtom(imReconnectStatusAtom); + const setSelectedConversationId = useSetAtom(selectedConversationIdAtom); const handleLogout = async () => { try { - setIsLogoutDialogLoading(true) + setIsLogoutDialogLoading(true); // 1. 断开IM连接 try { - console.log('开始断开IM连接...') - await nim.V2NIMLoginService.logout() - console.log('IM连接已断开') + console.log('开始断开IM连接...'); + await nim.V2NIMLoginService.logout(); + console.log('IM连接已断开'); } catch (imError) { - console.error('断开IM连接失败:', imError) + console.error('断开IM连接失败:', imError); // 即使IM断开失败,也继续执行后续步骤 } // 2. 清除所有聊天数据 try { - console.log('开始清除聊天历史数据...') - await clearAllConversations() - console.log('聊天历史数据已清除') + console.log('开始清除聊天历史数据...'); + await clearAllConversations(); + console.log('聊天历史数据已清除'); } catch (clearError) { - console.error('清除聊天数据失败:', clearError) + console.error('清除聊天数据失败:', clearError); // 即使清除失败,也继续执行后续步骤 } // 3. 重置所有IM相关的本地状态 - setConversationList(new Map()) - setMsgList(new QueueMap(20, 'rightToLeft')) - setUserList(new Map()) - setImSynced(false) - setImReconnectStatus(IMReconnectStatus.DISCONNECTED) - setSelectedConversationId(null) + setConversationList(new Map()); + setMsgList(new QueueMap(20, 'rightToLeft')); + setUserList(new Map()); + setImSynced(false); + setImReconnectStatus(IMReconnectStatus.DISCONNECTED); + setSelectedConversationId(null); if (isSidebarExpanded) { - dispatch({ - type: 'updateState', - payload: { - isSidebarExpanded: false, - }, - }) + setSidebarExpanded(false); } // 4. 执行用户登出 - await logout() + await logout(); - setIsLogoutDialogOpen(false) - setIsLogoutDialogLoading(false) + setIsLogoutDialogOpen(false); + setIsLogoutDialogLoading(false); } catch (error) { - console.error('登出过程中发生错误:', error) - setIsLogoutDialogLoading(false) + console.error('登出过程中发生错误:', error); + setIsLogoutDialogLoading(false); } - } + }; // 菜单项配置 const items: Array< | { type: 'separator' } | { - type: 'item' - label: string - icon: string - href?: string - target?: string - onClick?: () => void + type: 'item'; + label: string; + icon: string; + href?: string; + target?: string; + onClick?: () => void; } > = [ { @@ -146,21 +141,18 @@ const ProfileDropdown = () => { label: 'About Us', icon: 'icon-info', href: '/about', - target: '_blank', }, { type: 'item', label: 'Terms of Services', icon: 'icon-audits', href: '/policy/tos', - target: '_blank', }, { type: 'item', label: 'Privacy Policy', icon: 'icon-shield', href: '/policy/privacy', - target: '_blank', }, { type: 'separator' }, { @@ -169,7 +161,7 @@ const ProfileDropdown = () => { icon: 'icon-icon_exit', onClick: () => setIsLogoutDialogOpen(true), }, - ] + ]; return ( <> @@ -180,24 +172,24 @@ const ProfileDropdown = () => {
- ) + ); } const menuItem = ( {item.label} - ) + ); if (item.href) { return ( {menuItem} - ) + ); } - return
{menuItem}
+ return
{menuItem}
; })}
@@ -220,7 +212,7 @@ const ProfileDropdown = () => { - ) -} + ); +}; -export default ProfileDropdown +export default ProfileDropdown; diff --git a/src/app/(main)/search/page.tsx b/src/app/(main)/search/page.tsx new file mode 100644 index 0000000..e9bfe70 --- /dev/null +++ b/src/app/(main)/search/page.tsx @@ -0,0 +1,9 @@ +'use client'; + +export default function SearchPage() { + return ( +
+

Search

+
+ ); +} diff --git a/src/app/api/auth/discord/callback/route.ts b/src/app/api/auth/discord/callback/route.ts index 97c0707..fae3af6 100644 --- a/src/app/api/auth/discord/callback/route.ts +++ b/src/app/api/auth/discord/callback/route.ts @@ -1,53 +1,37 @@ -import { NextRequest, NextResponse } from 'next/server' +import { NextRequest, NextResponse } from 'next/server'; export async function GET(request: NextRequest) { + console.log('request', request); + const url = 'http://localhost:3000'; try { - const { searchParams } = new URL(request.url) - const code = searchParams.get('code') - const error = searchParams.get('error') - const state = searchParams.get('state') + const { searchParams } = new URL(request.url); + const code = searchParams.get('code'); + const error = searchParams.get('error'); + const state = searchParams.get('state'); // 处理用户拒绝授权的情况 if (error) { - console.error('Discord OAuth error:', error) - return NextResponse.redirect( - new URL( - `${process.env.NEXT_PUBLIC_APP_URL || 'https://test.crushlevel.ai'}/login?error=discord_denied`, - request.url - ) - ) + console.error('Discord OAuth error:', error); + return NextResponse.redirect(new URL(`${url}/login?error=discord_denied`, request.url)); } // 检查授权码 if (!code) { - console.error('No authorization code received from Discord') - return NextResponse.redirect( - new URL( - `${process.env.NEXT_PUBLIC_APP_URL || 'https://test.crushlevel.ai'}/login?error=discord_no_code`, - request.url - ) - ) + console.error('No authorization code received from Discord'); + return NextResponse.redirect(new URL(`${url}/login?error=discord_no_code`, request.url)); } // 将code作为URL参数传递给前端登录页面处理 - const loginUrl = new URL( - `${process.env.NEXT_PUBLIC_APP_URL || 'https://test.crushlevel.ai'}/login`, - request.url - ) - loginUrl.searchParams.set('discord_code', code) + const loginUrl = new URL(`${url}/login`, request.url); + loginUrl.searchParams.set('discord_code', code); if (state) { - loginUrl.searchParams.set('discord_state', state) + loginUrl.searchParams.set('discord_state', state); } - console.log('Discord OAuth callback successful, redirecting to login page with code') - return NextResponse.redirect(loginUrl) + console.log('Discord OAuth callback successful, redirecting to login page with code'); + return NextResponse.redirect(loginUrl); } catch (error) { - console.error('Discord OAuth callback error:', error) - return NextResponse.redirect( - new URL( - `${process.env.NEXT_PUBLIC_APP_URL || 'https://test.crushlevel.ai'}/login?error=discord_callback_error`, - request.url - ) - ) + console.error('Discord OAuth callback error:', error); + return NextResponse.redirect(new URL(`${url}/login?error=discord_callback_error`, request.url)); } } diff --git a/src/app/debug-mock/page.tsx b/src/app/debug-mock/page.tsx deleted file mode 100644 index 0ec46d2..0000000 --- a/src/app/debug-mock/page.tsx +++ /dev/null @@ -1,147 +0,0 @@ -'use client' - -import { useState } from 'react' -import { Button } from '@/components/ui/button' - -export default function DebugMockPage() { - const [testResults, setTestResults] = useState([]) - - 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 ( -
-

🔧 Mock API 调试页面

- -
- {/* 测试按钮 */} -
-

test options

- - - - - - - - -
- - {/* 环境信息 */} -
-

Environmental information

-
-
- NODE_ENV: {process.env.NODE_ENV} -
-
- ENABLE_MOCK: {process.env.NEXT_PUBLIC_ENABLE_MOCK} -
-
- AUTH_API_URL: {process.env.NEXT_PUBLIC_AUTH_API_URL || '未设置'} -
-
- Service Worker支持: {'serviceWorker' in navigator ? '是' : '否'} -
-
-
-
- - {/* 测试结果 */} -
-

Test Results

-
- {testResults.length === 0 ? ( -
Waiting for the test results...
- ) : ( - testResults.map((result, index) => ( -
- {result} -
- )) - )} -
-
-
- ) -} diff --git a/src/app/demo/page.tsx b/src/app/demo/page.tsx deleted file mode 100644 index 18fa188..0000000 --- a/src/app/demo/page.tsx +++ /dev/null @@ -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 = () => ( -
- {/* 渐变背景圆 */} -
-
-
- - {/* 主要的 C 形状 */} -
-
-
- - {/* 装饰性轨道 */} -
-
-
-
-) - -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 ( -
- {/* 侧边栏演示 */} - - - {/* 主内容区域 */} -
-
-

- Component demo -

- - {/* 侧边栏演示说明 */} -
-

Collapsible Sidebar

-
-

- The left sidebar supports the following functions: -

-
    -
  • Click the top fold/expand button to toggle the sidebar width
  • -
  • Navigation menu supports selection status and icon switching
  • -
  • Chat list shows user avatar, message preview, time, and unread count
  • -
  • Support user label and temperature display
  • -
  • The bottom notification function comes with a quantity badge.
  • -
  • Smooth animation transitions
  • -
-
-
- - {/* Alert Dialog 演示 */} -
-

- Alert Dialog dialog box Alert Dialog dialog box Alert Dialog dialog box -

-
- {/* 基础警告对话框 */} - - - - - - - Warning - - Using AI generation will overwrite the content you have already entered. Do - you want to continue? - - - - Cancel - Continue - - - - - {/* 带图标的对话框 */} - - - - - - - - - - - Using AI generation will overwrite the content you have already entered. Do - you want to continue? - - - - Continue - Cancel - - - - - {/* 带 Loading 的对话框 */} - - - - - - - confirm operation - - This operation will permanently delete your data. Are you sure you want to - continue? - - - - - {loading1 ? '取消中...' : '取消'} - - handleAsyncAction(setLoading2)} - > - {loading2 ? '删除中...' : '确认删除'} - - - - - - {/* 无关闭按钮的对话框 */} - - - - - - - Important Note - - This is an important reminder that you must choose an option to proceed. - - - - Oh, I see. - Continue operation - - - -
-
- - {/* 普通按钮 Loading 演示 */} -
-

- Normal Button Loading Effect Normal Button Loading Effect Normal Button Loading Effect -

-
- - - - - - - -
-
- - {/* 图标按钮 Loading 演示 */} -
-

- Icon button Loading effect Icon button Loading effect Icon button Loading effect -

-
- handleAsyncAction(setLoading1)} - > - {!loading1 && 🔄} - - - handleAsyncAction(setLoading2)} - > - {!loading2 && ❤️} - - - handleAsyncAction(setLoading3)} - > - {!loading3 && 🗑️} - -
-
- - {/* 块级按钮 Loading 演示 */} -
-

- Block Button Loading Effect Block Button Loading Effect Block Button Loading Effect -

- -
- - {/* 控制按钮 */} -
-

- Manually Control Loading Status Manually Control Loading Status Manually Control - Loading Status -

-
- - - - -
-
-
-
-
- ) -} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e875f88..1555df6 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,28 +1,26 @@ -import type { Metadata } from 'next' -import { Poppins, Oleo_Script_Swash_Caps } from 'next/font/google' -import localFont from 'next/font/local' -import '../css/iconfont.css' -import '../css/iconfont-v2.css' -import './globals.css' -import { Providers } from '@/lib/providers' -import { DeviceIdProvider } from '@/components/device-id-provider' -import ProgressBar from '@/context/progress' -import { MainLayoutProvider } from '@/context/mainLayout' -import ConditionalLayout from '@/components/layout/ConditionalLayout' +import type { Metadata } from 'next'; +import { Poppins, Oleo_Script_Swash_Caps } from 'next/font/google'; +import localFont from 'next/font/local'; +import '../css/iconfont.css'; +import '../css/iconfont-v2.css'; +import './globals.css'; +import { Providers } from '@/lib/providers'; +import { DeviceIdProvider } from '@/components/device-id-provider'; +import ProgressBar from '@/context/progress'; const poppins = Poppins({ variable: '--font-poppins', weight: ['400', '500', '600', '700'], display: 'swap', subsets: ['latin'], -}) +}); const oleoScriptSwashCaps = Oleo_Script_Swash_Caps({ variable: '--font-oleo-script-swash-caps', weight: ['400'], display: 'swap', subsets: ['latin'], -}) +}); const NumDisplay = localFont({ src: [ @@ -34,18 +32,18 @@ const NumDisplay = localFont({ ], variable: '--font-display-num', display: 'swap', -}) +}); export const metadata: Metadata = { metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'), title: 'CrushLevel', description: 'CrushLevel - Next Generation Social Platform', -} +}; export default async function RootLayout({ children, }: Readonly<{ - children: React.ReactNode + children: React.ReactNode; }>) { return ( @@ -54,14 +52,10 @@ export default async function RootLayout({ > - - - {children} - - + {children} - ) + ); } diff --git a/src/app/(auth)/login/components/DiscordButton.tsx b/src/app/login/components/DiscordButton.tsx similarity index 100% rename from src/app/(auth)/login/components/DiscordButton.tsx rename to src/app/login/components/DiscordButton.tsx diff --git a/src/app/(auth)/login/components/GoogleButton.tsx b/src/app/login/components/GoogleButton.tsx similarity index 100% rename from src/app/(auth)/login/components/GoogleButton.tsx rename to src/app/login/components/GoogleButton.tsx diff --git a/src/app/(auth)/login/components/ImageCarousel.tsx b/src/app/login/components/ImageCarousel.tsx similarity index 100% rename from src/app/(auth)/login/components/ImageCarousel.tsx rename to src/app/login/components/ImageCarousel.tsx diff --git a/src/app/(auth)/login/components/LeftPanel.tsx b/src/app/login/components/LeftPanel.tsx similarity index 100% rename from src/app/(auth)/login/components/LeftPanel.tsx rename to src/app/login/components/LeftPanel.tsx diff --git a/src/app/(auth)/login/components/ScrollingBackground.tsx b/src/app/login/components/ScrollingBackground.tsx similarity index 100% rename from src/app/(auth)/login/components/ScrollingBackground.tsx rename to src/app/login/components/ScrollingBackground.tsx diff --git a/src/app/(auth)/login/components/SocialButton.tsx b/src/app/login/components/SocialButton.tsx similarity index 100% rename from src/app/(auth)/login/components/SocialButton.tsx rename to src/app/login/components/SocialButton.tsx diff --git a/src/app/(auth)/login/components/login-form.tsx b/src/app/login/components/login-form.tsx similarity index 76% rename from src/app/(auth)/login/components/login-form.tsx rename to src/app/login/components/login-form.tsx index b5a91a8..0499088 100644 --- a/src/app/(auth)/login/components/login-form.tsx +++ b/src/app/login/components/login-form.tsx @@ -1,16 +1,16 @@ -'use client' +'use client'; // import { SocialButton } from './SocialButton' -import Link from 'next/link' -import { toast } from 'sonner' -import DiscordButton from './DiscordButton' -import GoogleButton from './GoogleButton' +import Link from 'next/link'; +import { toast } from 'sonner'; +import DiscordButton from './DiscordButton'; +import GoogleButton from './GoogleButton'; export function LoginForm() { const handleAppleLogin = () => { toast.info('Apple Sign In', { description: 'Apple登录功能正在开发中...', - }) - } + }); + }; return (
@@ -34,15 +34,15 @@ export function LoginForm() {

By continuing, you agree to Crush Level's{' '} - + Terms of Service {' '} and{' '} - + Privacy Policy

- ) + ); } diff --git a/src/app/(auth)/login/fields/fields-page.tsx b/src/app/login/fields/fields-page.tsx similarity index 100% rename from src/app/(auth)/login/fields/fields-page.tsx rename to src/app/login/fields/fields-page.tsx diff --git a/src/app/(auth)/login/fields/page.tsx b/src/app/login/fields/page.tsx similarity index 100% rename from src/app/(auth)/login/fields/page.tsx rename to src/app/login/fields/page.tsx diff --git a/src/app/(auth)/login/login-page.tsx b/src/app/login/login-page.tsx similarity index 100% rename from src/app/(auth)/login/login-page.tsx rename to src/app/login/login-page.tsx diff --git a/src/app/(auth)/login/page.tsx b/src/app/login/page.tsx similarity index 100% rename from src/app/(auth)/login/page.tsx rename to src/app/login/page.tsx diff --git a/src/app/page.tsx b/src/app/page.tsx index 4036d35..68e0af2 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,5 @@ -import type { Metadata } from 'next' -import MainPage from './(main)/home' +import type { Metadata } from 'next'; +import { redirect } from 'next/navigation'; export const metadata: Metadata = { title: 'CrushLevel AI - Grow Your Love Story', @@ -56,8 +56,8 @@ export const metadata: Metadata = { alternates: { canonical: 'https://www.crushlevel.com', }, -} +}; export default function HomePage() { - return + redirect('/home'); } diff --git a/src/app/server-device-test/page.tsx b/src/app/server-device-test/page.tsx deleted file mode 100644 index 6f1634c..0000000 --- a/src/app/server-device-test/page.tsx +++ /dev/null @@ -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 ( -
-
-

🖥️ 服务端设备ID测试

-

- Device ID generation and management when testing server-side rendering -

-
- -
- {/* 设备ID信息 */} - - - 📱 设备ID信息 - - -
-

- Current Device ID (Cookie) Current Device ID (Cookie) -

-
- {currentDeviceId || '等待中间件生成'} -
-
- -
-

- Device ID passed by middleware (Header) Device ID passed by middleware (Header) -

-
- {xDeviceId || '未传递'} -
-
- -
-

Status description:

-
    -
  • - • 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 -
  • -
  • - • 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 -
  • -
  • - • Device ID is passed to server level component via header • Device ID is passed - to server level component via header -
  • -
-
-
-
- - {/* 请求信息 */} - - - 🌐 请求信息 - - -
-

User-Agent

-
- {userAgent || '未知'} -
-
- -
-

render time

-
- {new Date().toISOString()} -
-
- -
-

rendering environment

-
- Server-side rendering (SSR) Server-side rendering (SSR) -
-
-
-
-
- - {/* 功能说明 */} - - - 📋 服务端设备ID处理流程 - - -
-
-
- 1. Middleware processing (middleware.ts) 1. Middleware processing (middleware.ts) 1. - Middleware processing (middleware.ts) -
-

- • 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 -
- • 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 -
- • Set the device ID to respond to cookies • Set the device ID to respond to cookies -
- 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 -

-
- -
-
2. DeviceIdProvider 检查
-

- • Check device ID status in root layout • Check device ID status in root layout -
- Get the device ID from both cookies and headers Get the device ID from both cookies - and headers -
• Record device ID status for debugging • Record device ID status for - debugging -

-
- -
-
- 3. API calls at the server level 3. API calls at the server level -
-

- All server level API requests attempt to carry the device ID All server level API - requests attempt to carry the device ID -
- • Send via AUTH_DID request header • Send via AUTH_DID request header • Send via - AUTH_DID request header • Send via AUTH_DID request header -
• Pre-request function that supports server-side rendering • Pre-request - function that supports server-side rendering -

-
- -
-
4. Restrictions 4. Restrictions
-

- • 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 -
- • Server level components can only read cookies and cannot be modified • Server - level components can only read cookies and cannot be modified -
- 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 -
- 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 + -

-
-
-
-
-
- ) -} diff --git a/src/app/test-avatar-crop/page.tsx b/src/app/test-avatar-crop/page.tsx deleted file mode 100644 index b232048..0000000 --- a/src/app/test-avatar-crop/page.tsx +++ /dev/null @@ -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(null) - const [imageUrl, setImageUrl] = useState('') - const [showCropModal, setShowCropModal] = useState(false) - const [croppedAvatarUrl, setCroppedAvatarUrl] = useState('') - const fileInputRef = useRef(null) - - // 处理文件选择 - const handleFileSelect = (event: React.ChangeEvent) => { - 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 ( -
-
- {/* 标题 */} -
-

- Avatar Crop Component Test -

-

- Avatar clipping pop-up window based on design draft restoration -

-
- - {/* 文件上传 */} - -

选择图片

-
- - - {selectedImage && ( - - )} - {imageUrl && ( - Selected: {selectedImage?.name} - )} -
-
- - {/* 裁剪操作 */} - {imageUrl && ( - -

crop avatar

-
- -
- Click the button to open the cropping pop-up window and adjust the position and size - of the picture. -
-
-
- )} - - {/* 裁剪结果 */} -
- {/* 原图预览 */} - {imageUrl && ( - -

original image

-
- original image -
-
- )} - - {/* 裁剪后的头像 */} - {croppedAvatarUrl && ( - -

Cropped avatar

-
-
- Cropped avatar -
-
- -
-
-
- )} -
- - {/* 功能说明 */} - -

Function Description

-
-
- • Support drag and drop to adjust the position of the picture • Support drag and drop - to adjust the position of the picture -
-
- • 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 -
-
• Automatically generate round avatars • Automatically generate round avatars
-
- • 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 -
-
- • Cancel Cancel operation, Confirm crop • Cancel Cancel operation, Confirm crop • - Cancel Cancel operation, Confirm crop • Cancel Cancel operation, Confirm crop -
-
-
- - {/* 设计还原度对比 */} - -

Design Restore

-
-
-

- ✅ Implemented design elements ✅ Implemented design elements -

-
-
• Dark translucent background mask • Dark translucent background mask
-
- • Highlight the circular cropping area • Highlight the circular cropping area -
-
• Bottom zoom control slider • Bottom zoom control slider
-
• +/- zoom button • +/- zoom button • +/- zoom button
-
• Cancel 和 Confirm 按钮
-
- • Close button in the upper left corner • Close button in the upper left corner -
-
- • Confirm button for gradual color change • Confirm button for gradual color - change • Confirm button for gradual color change • Confirm button for gradual - color change -
-
-
-
-

🎨 设计细节

-
-
- • The button adopts frosted glass effect • The button adopts frosted glass effect -
-
• Slider uses white round buttons • Slider uses white round buttons
-
- • A full-screen pop-up window that completely covers the screen • A full-screen - pop-up window that completely covers the screen -
-
• Responsive layout for mobile end • Responsive layout for mobile end
-
- • Smooth interactive animation effects • Smooth interactive animation effects -
-
-
-
-
-
- - {/* 头像裁剪弹窗 */} - {selectedImage && ( - setShowCropModal(false)} - image={selectedImage} - onConfirm={handleCropConfirm} - onCancel={() => setShowCropModal(false)} - /> - )} -
- ) -} diff --git a/src/app/test-avatar-setting/page.tsx b/src/app/test-avatar-setting/page.tsx deleted file mode 100644 index 7ae1155..0000000 --- a/src/app/test-avatar-setting/page.tsx +++ /dev/null @@ -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('') - - 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 ( -
- {/* 当前头像显示 */} -
-

avatar setup test

-
- {currentAvatar ? ( - Current Avatar - ) : ( - - )} -
-

- {currentAvatar ? '已设置头像' : '未设置头像'} -

-
- - {/* 打开头像设置按钮 */} - - - {/* 头像设置模态框 */} - -
- ) -} diff --git a/src/app/test-discord/page.tsx b/src/app/test-discord/page.tsx deleted file mode 100644 index 3903654..0000000 --- a/src/app/test-discord/page.tsx +++ /dev/null @@ -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([]) - 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 = { - 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 ( -
-
-

Discord login test page

-

- Testing Discord OAuth Login Function and Mock Interface Testing Discord OAuth Login - Function and Mock Interface -

-
- - {/* 用户状态 */} - - - user status - - - {isLoading ? ( -

Loading...

- ) : currentUser ? ( -
-

- User ID: {currentUser.userId} -

-

- Nickname: {currentUser.nickname} -

-

- Avatar: {currentUser.headImage || '无'} -

-

- Gender:{' '} - {currentUser.sex === 1 ? '男' : currentUser.sex === 2 ? '女' : '未知'} -

-

- Birthday: {currentUser.birthday} -

-

- Need to improve information: {currentUser.cpUserInfo ? '是' : '否'} -

-
- ) : ( -

Not logged in

- )} -
-
- - {/* 操作按钮 */} - - - test operation - - -
- - - - {currentUser && ( - - )} -
-
-
- - {/* 操作日志 */} - - - operation log - - - -
- {logs.length === 0 ? ( -

No log yet

- ) : ( -
-                {logs.map((log, index) => (
-                  
- {log} -
- ))} -
- )} -
-
-
- - {/* 配置说明 */} - - - configuration instructions - - -
-
-

New process description:

-
-
    -
  1. Users click on Discord to log in
  2. -
  3. Go to the Discord license page
  4. -
  5. Discord回调到 /api/auth/discord/callback
  6. -
  7. - Callback route gets code and redirects to /login? discord_code = xxx Callback - route gets code and redirects to /login? discord_code = xxx -
  8. -
  9. - The front-end login page detects the code, and calls the back-end API to - complete the login. -
  10. -
-
-
-
-

Environment variables:

-
-                {`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`}
-              
-
-
-

Discord回调URL:

-

- Http://localhost:3000/api/auth/discord/callback -

-
-
-

API interface:

-
-                {`POST /web/third/login
-{
-  "appClient": "WEB",
-  "deviceCode": "设备ID", 
-  "thirdToken": "discord_code",
-  "thirdType": "DISCORD"
-}`}
-              
-
-
-
-
-
- ) -} diff --git a/src/app/test-image-crop/page.tsx b/src/app/test-image-crop/page.tsx deleted file mode 100644 index a57ff68..0000000 --- a/src/app/test-image-crop/page.tsx +++ /dev/null @@ -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(null) - const [imageUrl, setImageUrl] = useState('') - const [showCropModal, setShowCropModal] = useState(false) - const [showSimpleCropModal, setShowSimpleCropModal] = useState(false) - const [croppedImageUrls, setCroppedImageUrls] = useState([]) - const [currentPreset, setCurrentPreset] = useState<'avatar' | 'cover' | 'thumbnail' | 'free'>( - 'avatar' - ) - const fileInputRef = useRef(null) - - // 处理文件选择 - const handleFileSelect = (event: React.ChangeEvent) => { - 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 ( -
-
- {/* 标题 */} -
-

Image cropping test

-

- Test various image cropping features and preset configurations -

-
- - {/* 文件上传 */} - -

选择图片

-
- - - {selectedImage && ( - - )} - {imageUrl && ( - Selected: {selectedImage?.name} - )} -
-
- - {/* 预设选择 */} - {imageUrl && ( - -

Select crop preset

-
- {Object.keys(CropPresets).map((preset) => ( - - ))} -
-
- )} - - {/* 裁剪操作 */} - {imageUrl && ( - -

cropping operation

-
- - -
-
- )} - - {/* 内联裁剪器 */} - {imageUrl && ( - -

Internal connection clipper

- handleCropSave(croppedImageUrl)} - preset={currentPreset} - className="h-96" - /> -
- )} - - {/* 裁剪结果 */} - {croppedImageUrls.length > 0 && ( - -

crop result

-
- {croppedImageUrls.map((url, index) => ( -
-
- {`裁剪结果 -
- -
- ))} -
-
- )} - - {/* 原图预览 */} - {imageUrl && ( - -

Original image preview

-
- original image -
-
- )} -
- - {/* 高级裁剪弹窗 */} - {selectedImage && ( - setShowCropModal(false)} - image={selectedImage} - onSave={handleCropSave} - title="Advanced image crop" - preset={currentPreset} - cropConfig={{ - showGrid: true, - showControls: true, - }} - /> - )} - - {/* 简单裁剪弹窗 */} - {selectedImage && ( - setShowSimpleCropModal(false)} - image={selectedImage} - onSave={handleCropSave} - title="Simple image cropping" - /> - )} -
- ) -} diff --git a/src/app/test-lamejs/page.tsx b/src/app/test-lamejs/page.tsx deleted file mode 100644 index 732dc78..0000000 --- a/src/app/test-lamejs/page.tsx +++ /dev/null @@ -1,67 +0,0 @@ -'use client' - -import React, { useState } from 'react' - -export default function TestLamejs() { - const [status, setStatus] = useState('未测试') - const [error, setError] = useState('') - - 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 ( -
-
-

lamejs 导入测试

- -
- - -
-

测试状态

-

{status}

- {error &&

错误: {error}

} -
- -
-

说明

-

- 这个页面用于测试 lamejs 库是否能正确导入和初始化。 - 请打开浏览器控制台查看详细的日志信息。 -

-
-
-
-
- ) -} diff --git a/src/app/test-middleware/page.tsx b/src/app/test-middleware/page.tsx deleted file mode 100644 index edadf61..0000000 --- a/src/app/test-middleware/page.tsx +++ /dev/null @@ -1,109 +0,0 @@ -'use client' - -import { useEffect, useState } from 'react' - -export default function TestMiddlewarePage() { - const [mswStatus, setMswStatus] = useState('检查中...') - const [pageInfo, setPageInfo] = useState({}) - - 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 ( -
-

🔧 Middleware 测试页面

- -
-

MSW status MSW status

-

{mswStatus}

-
- -
-

page information

-
{JSON.stringify(pageInfo, null, 2)}
-
- -
-

Navigation test

-
- - -
-
- -
-

explain

-

- 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. -

-
-
- ) -} diff --git a/src/app/test-mp3-conversion/page.tsx b/src/app/test-mp3-conversion/page.tsx deleted file mode 100644 index 3044ab8..0000000 --- a/src/app/test-mp3-conversion/page.tsx +++ /dev/null @@ -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(null) - const [originalBlob, setOriginalBlob] = useState(null) - - const mediaRecorderRef = useRef(null) - const audioChunksRef = useRef([]) - - 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 ( -
-
-

MP3转换测试

- -
- {/* 录音控制 */} -
- {!isRecording ? ( - - ) : ( - - )} -
- - {/* 状态显示 */} -
- {isRecording &&

🔴 正在录音...

} - {isProcessing &&

⏳ 正在转换为MP3...

} -
- - {/* 文件信息 */} - {originalBlob && ( -
-

原始文件信息

-

大小: {(originalBlob.size / 1024).toFixed(1)} KB

-

类型: {originalBlob.type}

- -
- )} - - {mp3Blob && ( -
-

MP3文件信息

-

大小: {(mp3Blob.size / 1024).toFixed(1)} KB

-

类型: {mp3Blob.type}

-

- 压缩率:{' '} - {originalBlob - ? `${((1 - mp3Blob.size / originalBlob.size) * 100).toFixed(1)}%` - : 'N/A'} -

- -
- )} - - {/* 音频播放测试 */} - {mp3Blob && ( -
-

音频播放测试

- -
- )} -
-
-
- ) -} diff --git a/src/app/test-s3-upload/page.tsx b/src/app/test-s3-upload/page.tsx deleted file mode 100644 index 1d068ec..0000000 --- a/src/app/test-s3-upload/page.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { S3UploadDemo } from '@/components/features/S3UploadDemo' - -export default function TestS3UploadPage() { - return ( -
-
-
-

- AWS S3 Upload Test AWS S3 Upload Test AWS S3 Upload Test -

-

- 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 -

-
- - - -
-

- How to use Hook How to use Hook -

-
-            {`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)`}
-          
-
-
-
- ) -} diff --git a/src/components/features/ai-standard-card.tsx b/src/components/features/ai-standard-card.tsx index 8fa5bbf..d9c009a 100644 --- a/src/components/features/ai-standard-card.tsx +++ b/src/components/features/ai-standard-card.tsx @@ -1,64 +1,68 @@ -'use client' +'use client'; -import React, { useRef, useEffect, useState } from 'react' -import { GetMeetListResponse } from '@/services/home/types' -import { formatNumberToKMB } from '@/lib/utils' -import { Tag } from '@/components/ui/tag' -import { Avatar, AvatarImage } from '@/components/ui/avatar' -import Link from 'next/link' +import React, { useRef, useEffect, useState } from 'react'; +import { formatNumberToKMB } from '@/lib/utils'; +import { Tag } from '@/components/ui/tag'; +import { Avatar, AvatarImage } from '@/components/ui/avatar'; +import Link from 'next/link'; +import { CharacterType } from '@/services/editor/type'; interface AIStandardCardProps { - character: GetMeetListResponse - disableHover?: boolean + character: CharacterType; + disableHover?: boolean; } const AIStandardCard: React.FC = React.memo( ({ character, disableHover = false }) => { const { - aiId, - nickname, - characterName, - tagName, - headImg, - homeImageUrl, - introduction, - likedNum, - } = character + id, + name, + description, + coverImage, + sourceId, + sourceType, + headPortrait, + basicInfo, + exampleDialogue, + note, + firstSentence, + characterStand, + tagId, + greeting, + depth, + tags, + chatTarget, + } = character; - const introContainerRef = useRef(null) - const introTextRef = useRef(null) - const [maxLines, setMaxLines] = useState(6) + const introContainerRef = useRef(null); + const introTextRef = useRef(null); + const [maxLines, setMaxLines] = useState(6); // 动态计算可用空间的行数 useEffect(() => { const calculateMaxLines = () => { if (introContainerRef.current) { - const containerHeight = introContainerRef.current.offsetHeight - const lineHeight = 20 // 对应 leading-[20px] - const calculatedLines = Math.floor(containerHeight / lineHeight) + const containerHeight = introContainerRef.current.offsetHeight; + const lineHeight = 20; // 对应 leading-[20px] + const calculatedLines = Math.floor(containerHeight / lineHeight); // 确保至少显示 1 行,最多不超过合理的行数 - const finalLines = Math.max(1, Math.min(calculatedLines, 12)) - setMaxLines(finalLines) + const finalLines = Math.max(1, Math.min(calculatedLines, 12)); + setMaxLines(finalLines); } - } + }; - calculateMaxLines() + calculateMaxLines(); // 监听窗口大小变化 - window.addEventListener('resize', calculateMaxLines) - return () => window.removeEventListener('resize', calculateMaxLines) - }, []) - - // 解析标签(假设是逗号分隔的字符串) - const tags = tagName ? tagName.split(',').filter((tag) => tag.trim()) : [] + window.addEventListener('resize', calculateMaxLines); + return () => window.removeEventListener('resize', calculateMaxLines); + }, []); // 获取显示的背景图片 - const displayImage = homeImageUrl || headImg - - const displayName = `${nickname}` + const displayImage = coverImage; return ( - +
@@ -87,41 +91,27 @@ const AIStandardCard: React.FC = React.memo( className="txt-headline-s text-txt-primary-normal" style={{ wordBreak: 'break-word' }} > - {displayName} + {name}
- {/* 性格标签 */} - {characterName && {characterName}} - - {tags.length > 0 && ( + {!!tags?.length && (
- {tags.slice(0, 2).map((tag, index) => ( + {tags?.slice(0, 2).map((tag, index) => ( - {tag} + {tag.name} ))}
)}
- - {/* 标签 - {tags.length > 0 && ( -
- {tags.slice(0, 2).map((tag, index) => ( - - {tag} - - ))} -
- )} */}
{/* 点赞数 */}
- {formatNumberToKMB(likedNum ?? 0)} + {formatNumberToKMB(1000)}
@@ -133,9 +123,9 @@ const AIStandardCard: React.FC = React.memo( {/* 头像和名称 */}
- + -
{nickname}
+
{name}
{/* 简介文本 */} @@ -152,7 +142,7 @@ const AIStandardCard: React.FC = React.memo( }} >

- {introduction || 'No introduction'} + {description || 'No description'}

@@ -161,7 +151,7 @@ const AIStandardCard: React.FC = React.memo(
- {formatNumberToKMB(likedNum ?? 0)} + {formatNumberToKMB(100)}
@@ -176,8 +166,8 @@ const AIStandardCard: React.FC = React.memo(
- ) + ); } -) +); -export default AIStandardCard +export default AIStandardCard; diff --git a/src/components/layout/ConditionalLayout.tsx b/src/components/layout/ConditionalLayout.tsx deleted file mode 100644 index 170fefb..0000000 --- a/src/components/layout/ConditionalLayout.tsx +++ /dev/null @@ -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(null) - const prevPathnameRef = useRef(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 ( -
-
- -
{children}
-
-
- ) - } - - // 其他路由使用主布局(包括首页、main路由组等) - return ( -
- -
- -
{children}
-
- - - -
- ) -} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx deleted file mode 100644 index 904a468..0000000 --- a/src/components/layout/Sidebar.tsx +++ /dev/null @@ -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 ( - - ) -} - -export default Sidebar diff --git a/src/components/layout/TopBarWithoutLogin.tsx b/src/components/layout/TopBarWithoutLogin.tsx deleted file mode 100644 index 0d2e960..0000000 --- a/src/components/layout/TopBarWithoutLogin.tsx +++ /dev/null @@ -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 ( -
- {isBlur &&
} -
-
- - Logo - -
-
-
-
- ) -} - -export default TopbarWithoutLogin diff --git a/src/components/layout/components/ChatConversationsDeleteDialog.tsx b/src/components/layout/components/ChatConversationsDeleteDialog.tsx deleted file mode 100644 index 47e776d..0000000 --- a/src/components/layout/components/ChatConversationsDeleteDialog.tsx +++ /dev/null @@ -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 ( - - - - Clear Chat List - - - This will clear your chat list. Your individual messages will remain intact. - - - Cancel - - Clear - - - - - ) -} - -export default ChatConversationsDeleteDialog diff --git a/src/components/layout/components/ChatSidebarAction.tsx b/src/components/layout/components/ChatSidebarAction.tsx deleted file mode 100644 index 51642a7..0000000 --- a/src/components/layout/components/ChatSidebarAction.tsx +++ /dev/null @@ -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 ( - <> - - - - - - - - - - Mark All - - - - {isSearchActive ? 'Cancel' : 'Search'} - -
- -
- - - Clear Chat List - -
-
- - - ) -} - -export default ChatSidebarAction diff --git a/src/components/ui/infinite-scroll-list.tsx b/src/components/ui/infinite-scroll-list.tsx index 356f8ca..a519000 100644 --- a/src/components/ui/infinite-scroll-list.tsx +++ b/src/components/ui/infinite-scroll-list.tsx @@ -1,84 +1,84 @@ -import React, { ReactNode } from 'react' -import { useInfiniteScroll } from '@/hooks/useInfiniteScroll' -import { cn } from '@/lib/utils' +import React, { ReactNode, useMemo } from 'react'; +import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'; +import { cn } from '@/lib/utils'; interface InfiniteScrollListProps { /** * 数据项数组 */ - items: T[] + items: T[]; /** * 渲染每个数据项的函数 */ - renderItem: (item: T, index: number) => ReactNode + renderItem: (item: T, index: number) => ReactNode; /** * 获取每个数据项的唯一key */ - getItemKey: (item: T, index: number) => string | number + getItemKey: (item: T, index: number) => string | number; /** * 是否有更多数据可以加载 */ - hasNextPage: boolean + hasNextPage: boolean; /** * 是否正在加载 */ - isLoading: boolean + isLoading: boolean; /** * 加载下一页的函数 */ - fetchNextPage: () => void + fetchNextPage: () => void; /** * 列表容器的className */ - className?: string + className?: string; /** * 网格列数(支持响应式) */ columns?: | { - default: number - sm?: number - md?: number - lg?: number - xl?: number + xs?: number; + sm?: number; + md?: number; + lg?: number; + xl?: number; } - | number + | number; /** * 网格间距 */ - gap?: number + gap?: number; /** * 加载状态的骨架屏组件 */ - LoadingSkeleton?: React.ComponentType + LoadingSkeleton?: React.ComponentType; /** * 加载更多时显示的组件 */ - LoadingMore?: React.ComponentType + LoadingMore?: React.ComponentType; /** * 空状态组件 */ - EmptyComponent?: React.ComponentType + EmptyComponent?: React.ComponentType; /** * 错误状态组件 */ - ErrorComponent?: React.ComponentType<{ onRetry: () => void }> + ErrorComponent?: React.ComponentType<{ onRetry: () => void }>; /** * 是否有错误 */ - hasError?: boolean + hasError?: boolean; /** * 重试函数 */ - onRetry?: () => void + onRetry?: () => void; /** * 触发加载的阈值(px) */ - threshold?: number + threshold?: number; /** * 是否启用无限滚动 */ - enabled?: boolean + enabled?: boolean; } /** @@ -111,10 +111,10 @@ export function InfiniteScrollList({ threshold, enabled, isError: hasError, - }) + }); // 生成网格列数的CSS类名映射 - const getGridColsClass = () => { + const gridColsClass = useMemo(() => { if (typeof columns === 'number') { const gridClassMap: Record = { 1: 'grid-cols-1', @@ -123,31 +123,58 @@ export function InfiniteScrollList({ 4: 'grid-cols-4', 5: 'grid-cols-5', 6: 'grid-cols-6', - } - return gridClassMap[columns] || 'grid-cols-4' + }; + return gridClassMap[columns] || 'grid-cols-4'; } - const classes = [] - const colsClassMap: Record = { - 1: 'grid-cols-1', - 2: 'grid-cols-2', - 3: 'grid-cols-3', - 4: 'grid-cols-4', - 5: 'grid-cols-5', - 6: 'grid-cols-6', - } + // 使用完整的类名字符串,让 Tailwind 能够正确识别 + const classes: string[] = []; - classes.push(colsClassMap[columns.default] || 'grid-cols-4') - if (columns.sm) classes.push(`sm:${colsClassMap[columns.sm]}`) - if (columns.md) classes.push(`md:${colsClassMap[columns.md]}`) - if (columns.lg) classes.push(`lg:${colsClassMap[columns.lg]}`) - if (columns.xl) classes.push(`xl:${colsClassMap[columns.xl]}`) + // xs 断点 + if (columns.xs === 1) classes.push('xs:grid-cols-1'); + if (columns.xs === 2) classes.push('xs:grid-cols-2'); + if (columns.xs === 3) classes.push('xs:grid-cols-3'); + if (columns.xs === 4) classes.push('xs:grid-cols-4'); + if (columns.xs === 5) classes.push('xs:grid-cols-5'); + if (columns.xs === 6) classes.push('xs:grid-cols-6'); - return classes.join(' ') - } + // sm 断点 + if (columns.sm === 1) classes.push('sm:grid-cols-1'); + if (columns.sm === 2) classes.push('sm:grid-cols-2'); + if (columns.sm === 3) classes.push('sm:grid-cols-3'); + if (columns.sm === 4) classes.push('sm:grid-cols-4'); + if (columns.sm === 5) classes.push('sm:grid-cols-5'); + if (columns.sm === 6) classes.push('sm:grid-cols-6'); + + // md 断点 + if (columns.md === 1) classes.push('md:grid-cols-1'); + if (columns.md === 2) classes.push('md:grid-cols-2'); + if (columns.md === 3) classes.push('md:grid-cols-3'); + if (columns.md === 4) classes.push('md:grid-cols-4'); + if (columns.md === 5) classes.push('md:grid-cols-5'); + if (columns.md === 6) classes.push('md:grid-cols-6'); + + // lg 断点 + if (columns.lg === 1) classes.push('lg:grid-cols-1'); + if (columns.lg === 2) classes.push('lg:grid-cols-2'); + if (columns.lg === 3) classes.push('lg:grid-cols-3'); + if (columns.lg === 4) classes.push('lg:grid-cols-4'); + if (columns.lg === 5) classes.push('lg:grid-cols-5'); + if (columns.lg === 6) classes.push('lg:grid-cols-6'); + + // xl 断点 + if (columns.xl === 1) classes.push('xl:grid-cols-1'); + if (columns.xl === 2) classes.push('xl:grid-cols-2'); + if (columns.xl === 3) classes.push('xl:grid-cols-3'); + if (columns.xl === 4) classes.push('xl:grid-cols-4'); + if (columns.xl === 5) classes.push('xl:grid-cols-5'); + if (columns.xl === 6) classes.push('xl:grid-cols-6'); + + return classes.join(' '); + }, [columns]); // 生成间距类名 - const getGapClass = () => { + const gapClass = useMemo(() => { const gapClassMap: Record = { 1: 'gap-1', 2: 'gap-2', @@ -156,29 +183,29 @@ export function InfiniteScrollList({ 5: 'gap-5', 6: 'gap-6', 8: 'gap-8', - } - return gapClassMap[gap] || 'gap-4' - } + }; + return gapClassMap[gap] || 'gap-4'; + }, [gap]); // 错误状态 if (hasError && ErrorComponent && onRetry) { - return + return ; } // 首次加载状态 if (isLoading && items.length === 0) { if (LoadingSkeleton) { return ( -
+
{Array.from({ length: 8 }).map((_, index) => ( ))}
- ) + ); } return ( -
+
{Array.from({ length: 8 }).map((_, index) => (
({ /> ))}
- ) + ); } // 空状态 if (items.length === 0 && EmptyComponent) { - return + return ; } return (
{/* 主要内容 */} -
+
{items.map((item, index) => ( {renderItem(item, index)} ))} @@ -221,5 +248,5 @@ export function InfiniteScrollList({
)}
- ) + ); } diff --git a/src/context/mainLayout/index.tsx b/src/context/mainLayout/index.tsx deleted file mode 100644 index a33ffa0..0000000 --- a/src/context/mainLayout/index.tsx +++ /dev/null @@ -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 }) => void -}>({ - isSidebarExpanded: false, - dispatch: () => {}, -}) - -const initialState = { - isSidebarExpanded: false, -} - -const reducer = ( - state: typeof initialState, - action: { type: 'updateState'; payload: Partial } -) => { - 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 ( - - {children} - - ) -} - -export default MainLayoutContext diff --git a/src/context/mainLayout/useMainLayout.ts b/src/context/mainLayout/useMainLayout.ts deleted file mode 100644 index cb48cad..0000000 --- a/src/context/mainLayout/useMainLayout.ts +++ /dev/null @@ -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 -} diff --git a/src/css/tailwindcss.css b/src/css/tailwindcss.css index 325c895..1c6f14e 100644 --- a/src/css/tailwindcss.css +++ b/src/css/tailwindcss.css @@ -572,6 +572,12 @@ var(--glo-color-violet-20) 50%, var(--glo-color-mint-20) 100% ); + + --breakpoint-xs: 375px; + --breakpoint-sm: 768px; + --breakpoint-md: 1024px; + --breakpoint-lg: 1280px; + --breakpoint-xl: 1440px; } /* Typography 工具类 */ diff --git a/src/hooks/tools/index.ts b/src/hooks/tools/index.ts new file mode 100644 index 0000000..8407e3f --- /dev/null +++ b/src/hooks/tools/index.ts @@ -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 = ResponsiveConfig) => { + // 追踪客户端是否已挂载 + const [mounted, setMounted] = useState(false); + const [response, setResponse] = useState>(); + + 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; + 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 = Promise>( + fn: T +): { + loading: boolean; + run: T; +} => { + const [loading, setLoading] = useState(false); + + const run = useMemoizedFn(async (...args: Parameters) => { + setLoading(true); + try { + const result = await fn(...args); + return result; + } finally { + setLoading(false); + } + }) as T; + + return { + loading, + run, + }; +}; diff --git a/src/hooks/tools/useStreamChat.ts b/src/hooks/tools/useStreamChat.ts new file mode 100644 index 0000000..ef4af0a --- /dev/null +++ b/src/hooks/tools/useStreamChat.ts @@ -0,0 +1 @@ +'use client'; diff --git a/src/hooks/useGlobalPrefetchRoutes.ts b/src/hooks/useGlobalPrefetchRoutes.ts deleted file mode 100644 index e2defdd..0000000 --- a/src/hooks/useGlobalPrefetchRoutes.ts +++ /dev/null @@ -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() - -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 diff --git a/src/hooks/useHome.ts b/src/hooks/useHome.ts deleted file mode 100644 index 24389c7..0000000 --- a/src/hooks/useHome.ts +++ /dev/null @@ -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, 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, - }) -} diff --git a/src/hooks/useInfiniteScroll.ts b/src/hooks/useInfiniteScroll.ts index 545c649..6ead2ae 100644 --- a/src/hooks/useInfiniteScroll.ts +++ b/src/hooks/useInfiniteScroll.ts @@ -1,30 +1,31 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useMemoizedFn } from 'ahooks'; +import { useEffect, useRef, useState } from 'react'; interface UseInfiniteScrollOptions { /** * 是否有更多数据可以加载 */ - hasNextPage: boolean + hasNextPage: boolean; /** * 是否正在加载 */ - isLoading: boolean + isLoading: boolean; /** * 加载下一页的函数 */ - fetchNextPage: () => void + fetchNextPage: () => void; /** * 触发加载的阈值(px),当距离容器底部多少像素时触发加载 */ - threshold?: number + threshold?: number; /** * 是否启用(默认为true) */ - enabled?: boolean + enabled?: boolean; /** * 是否有错误 */ - isError?: boolean + isError?: boolean; } /** @@ -39,67 +40,67 @@ export function useInfiniteScroll({ enabled = true, isError = false, }: UseInfiniteScrollOptions) { - const [isFetching, setIsFetching] = useState(false) - const observerRef = useRef(null) - const loadMoreRef = useRef(null) + const [isFetching, setIsFetching] = useState(false); + const observerRef = useRef(null); + const loadMoreRef = useRef(null); // 加载更多数据 - const loadMore = useCallback(async () => { + const loadMore = useMemoizedFn(async () => { // 如果有错误,不继续加载 - if (!hasNextPage || isLoading || isFetching || isError) return + if (!hasNextPage || isLoading || isFetching || isError) return; - setIsFetching(true) + setIsFetching(true); try { - fetchNextPage() + fetchNextPage(); } finally { // 延迟重置状态,避免快速重复触发 setTimeout(() => { - setIsFetching(false) - }, 100) + setIsFetching(false); + }, 100); } - }, [hasNextPage, isLoading, isFetching, isError, fetchNextPage]) + }); // 设置Intersection Observer useEffect(() => { - if (!enabled || !loadMoreRef.current) return + if (!enabled || !loadMoreRef.current) return; const options = { root: null, rootMargin: `${threshold}px`, threshold: 0.1, - } + }; observerRef.current = new IntersectionObserver((entries) => { - const [entry] = entries + const [entry] = entries; if (entry.isIntersecting) { - loadMore() + loadMore(); } - }, options) + }, options); - const currentRef = loadMoreRef.current + const currentRef = loadMoreRef.current; if (currentRef) { - observerRef.current.observe(currentRef) + observerRef.current.observe(currentRef); } return () => { if (observerRef.current && currentRef) { - observerRef.current.unobserve(currentRef) + observerRef.current.unobserve(currentRef); } - } - }, [enabled, threshold, loadMore]) + }; + }, [enabled, threshold, loadMore]); // 清理observer useEffect(() => { return () => { if (observerRef.current) { - observerRef.current.disconnect() + observerRef.current.disconnect(); } - } - }, []) + }; + }, []); return { loadMoreRef, isFetching, loadMore, - } + }; } diff --git a/src/layout/BasicLayout.tsx b/src/layout/BasicLayout.tsx new file mode 100644 index 0000000..d04485d --- /dev/null +++ b/src/layout/BasicLayout.tsx @@ -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(null); + const prevPathnameRef = useRef(pathname); + const response = useMedia(); + + // 路由切换时重置滚动位置 + useEffect(() => { + if (prevPathnameRef.current !== pathname) { + if (mainContentRef.current) { + mainContentRef.current.scrollTop = 0; + } + prevPathnameRef.current = pathname; + } + }, [pathname]); + + return ( +
+ {response?.sm && } +
+ +
+ {children} +
+ {response && !response.sm && } +
+ + + +
+ ); +} diff --git a/src/layout/BottomBar.tsx b/src/layout/BottomBar.tsx new file mode 100644 index 0000000..810ebf7 --- /dev/null +++ b/src/layout/BottomBar.tsx @@ -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 ( +
+ {items.map((item) => { + const isSelected = pathname === item.path; + return ( + + {item.label} + {response?.hide && {item.label}} + + ); + })} +
+ ); +} diff --git a/src/layout/Sidebar.tsx b/src/layout/Sidebar.tsx new file mode 100644 index 0000000..458c527 --- /dev/null +++ b/src/layout/Sidebar.tsx @@ -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 ( + + ); +} + +export default Sidebar; diff --git a/src/components/layout/Topbar.tsx b/src/layout/Topbar.tsx similarity index 58% rename from src/components/layout/Topbar.tsx rename to src/layout/Topbar.tsx index 1c9a416..618242c 100644 --- a/src/components/layout/Topbar.tsx +++ b/src/layout/Topbar.tsx @@ -1,60 +1,57 @@ -'use client' -import React, { useEffect, useState } from 'react' -import { cn } from '@/lib/utils' -import Image from 'next/image' -import { Button } from '../ui/button' -import { useCurrentUser } from '@/hooks/auth' -import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar' -import Link from 'next/link' -import { usePathname, useSearchParams, useRouter } from 'next/navigation' -import { useMainLayout } from '@/context/mainLayout' +'use client'; +import React, { useEffect, useState } from 'react'; +import { cn } from '@/lib/utils'; +import Image from 'next/image'; +import { Button } from '../components/ui/button'; +import { useCurrentUser } from '@/hooks/auth'; +import { Avatar, AvatarFallback, AvatarImage } from '../components/ui/avatar'; +import Link from 'next/link'; +import { usePathname, useSearchParams, useRouter } from 'next/navigation'; function Topbar() { - const [isBlur, setIsBlur] = useState(false) - const { data: user } = useCurrentUser() - const router = useRouter() - const pathname = usePathname() - const searchParams = useSearchParams() - const { isSidebarExpanded } = useMainLayout() + const [isBlur, setIsBlur] = useState(false); + const { data: user } = useCurrentUser(); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); - const searchParamsString = searchParams.toString() - const redirectURL = `${pathname}${searchParamsString ? `?${searchParamsString}` : ''}` - const loginHref = `/login?redirect=${encodeURIComponent(redirectURL)}` + const searchParamsString = searchParams.toString(); + const redirectURL = `${pathname}${searchParamsString ? `?${searchParamsString}` : ''}`; + const loginHref = `/login?redirect=${encodeURIComponent(redirectURL)}`; useEffect(() => { function handleScroll(event: Event) { - const dom = event.target as HTMLElement - setIsBlur(dom.scrollTop > 0) + const dom = event.target as HTMLElement; + setIsBlur(dom.scrollTop > 0); } - const dom = document.getElementById('main-content') + const dom = document.getElementById('main-content'); if (dom) { - dom.addEventListener('scroll', handleScroll, { passive: true }) + dom.addEventListener('scroll', handleScroll, { passive: true }); } return () => { if (dom) { - dom.removeEventListener('scroll', handleScroll) + dom.removeEventListener('scroll', handleScroll); } - } - }, []) + }; + }, []); useEffect(() => { if (!user) { - router.prefetch(loginHref) + router.prefetch(loginHref); } else { - router.prefetch('/profile') + router.prefetch('/profile'); if (user.cpUserInfo) { - router.push('/login/fields?redirect=' + encodeURIComponent(redirectURL)) + router.push('/login/fields?redirect=' + encodeURIComponent(redirectURL)); } } - }, [user]) + }, [user]); return (
@@ -85,7 +82,7 @@ function Topbar() { )}
- ) + ); } -export default Topbar +export default Topbar; diff --git a/src/components/layout/components/ChatSearchResults.tsx b/src/layout/components/ChatSearchResults.tsx similarity index 100% rename from src/components/layout/components/ChatSearchResults.tsx rename to src/layout/components/ChatSearchResults.tsx diff --git a/src/components/layout/components/ChatSidebar.tsx b/src/layout/components/ChatSidebar.tsx similarity index 55% rename from src/components/layout/components/ChatSidebar.tsx rename to src/layout/components/ChatSidebar.tsx index af7fd5e..21aa6a5 100644 --- a/src/components/layout/components/ChatSidebar.tsx +++ b/src/layout/components/ChatSidebar.tsx @@ -1,73 +1,46 @@ -import ChatSidebarItem from './ChatSidebarItem' -import { IconButton } from '@/components/ui/button' -import { useAtomValue } from 'jotai' -import { conversationListAtom, selectedConversationIdAtom } from '@/atoms/im' -import ChatSidebarAction from './ChatSidebarAction' -import ChatSearchResults from './ChatSearchResults' -import { Input } from '@/components/ui/input' -import { useState, useEffect, useCallback } from 'react' +import ChatSidebarItem from './ChatSidebarItem'; +import { IconButton } from '@/components/ui/button'; +import ChatSidebarAction from './ChatSidebarAction'; +import ChatSearchResults from './ChatSearchResults'; +import { Input } from '@/components/ui/input'; +import { useState, useEffect, useCallback } from 'react'; +import { useStreamChatStore } from '@/stores/stream-chat'; +import { useLayoutStore } from '@/stores'; -const ChatSidebar = ({ isExpanded }: { isExpanded: boolean }) => { - const selectedChat = useAtomValue(selectedConversationIdAtom) - const conversationList = useAtomValue(conversationListAtom) - const [searchKeyword, setSearchKeyword] = useState('') - const [showSearchInput, setShowSearchInput] = useState(false) +const ChatSidebar = () => { + const isSidebarExpanded = useLayoutStore((s) => s.isSidebarExpanded); + const currentChannel = useStreamChatStore((state) => state.currentChannel); + const channels = useStreamChatStore((state) => state.channels); + const [search, setSearch] = useState(''); + const [inSearching, setIsSearching] = useState(false); - const datas = Array.from(conversationList.values()).sort((a, b) => { - // 获取会话的最后活跃时间(优先使用最后一条消息的时间,否则使用会话更新时间) - const aTime = a.lastMessage?.messageRefer?.createTime || a.updateTime || 0 - const bTime = b.lastMessage?.messageRefer?.createTime || b.updateTime || 0 - // 按时间倒序排列(最新的在前面) - return bTime - aTime - }) + const datas = Array.from(channels.values()).sort((a, b) => { + return false; + // // 获取会话的最后活跃时间(优先使用最后一条消息的时间,否则使用会话更新时间) + // const aTime = a.lastMessage?.messageRefer?.createTime || a.updateTime || 0; + // const bTime = b.lastMessage?.messageRefer?.createTime || b.updateTime || 0; + // // 按时间倒序排列(最新的在前面) + // return bTime - aTime; + }); // 当侧边栏收缩时,取消搜索功能 useEffect(() => { - if (!isExpanded) { - setShowSearchInput(false) - setSearchKeyword('') + if (!isSidebarExpanded) { + setIsSearching(false); + setSearch(''); } - }, [isExpanded]) - - const handleSearchClick = () => { - setShowSearchInput(true) - } - - const handleSearchChange = (e: React.ChangeEvent) => { - setSearchKeyword(e.target.value) - } - - const handleClearSearch = () => { - setSearchKeyword('') - } + }, [isSidebarExpanded]); const handleCloseSearch = useCallback(() => { - setSearchKeyword('') - setShowSearchInput(false) - }, []) - - // ESC键关闭搜索 - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape' && showSearchInput) { - handleCloseSearch() - } - } - - if (showSearchInput) { - document.addEventListener('keydown', handleKeyDown) - } - - return () => { - document.removeEventListener('keydown', handleKeyDown) - } - }, [showSearchInput, handleCloseSearch]) + setSearch(''); + setIsSearching(false); + }, []); // 如果有搜索关键词,显示搜索结果 - const isShowingSearchResults = searchKeyword.trim().length > 0 + const isShowingSearchResults = search.trim().length > 0; if (!datas.length && !isShowingSearchResults) { - return
+ return
; } return ( @@ -79,13 +52,13 @@ const ChatSidebar = ({ isExpanded }: { isExpanded: boolean }) => {
{/* 聊天标题 */}
- {isExpanded ? ( + {isSidebarExpanded ? ( <> Chats setIsSearching(true)} onCancelSearch={handleCloseSearch} - isSearchActive={showSearchInput} + isSearchActive={inSearching} /> ) : ( @@ -94,13 +67,13 @@ const ChatSidebar = ({ isExpanded }: { isExpanded: boolean }) => {
{/* 搜索框 - 根据设计稿实现 */} - {showSearchInput && isExpanded && ( + {inSearching && isSidebarExpanded && (
setSearch(e.target.value)} placeholder="Search" size="small" autoFocus @@ -112,7 +85,7 @@ const ChatSidebar = ({ isExpanded }: { isExpanded: boolean }) => { /> {isShowingSearchResults && ( setSearch('')} size="mini" variant="tertiary" className="absolute top-1/2 right-3 shrink-0 -translate-y-1/2 transform" @@ -133,11 +106,11 @@ const ChatSidebar = ({ isExpanded }: { isExpanded: boolean }) => { )} {/* 根据搜索状态显示不同内容 */} - {showSearchInput ? ( + {inSearching ? ( isShowingSearchResults ? ( ) : ( @@ -153,7 +126,7 @@ const ChatSidebar = ({ isExpanded }: { isExpanded: boolean }) => { ))} @@ -171,7 +144,7 @@ const ChatSidebar = ({ isExpanded }: { isExpanded: boolean }) => { )}
- ) -} + ); +}; -export default ChatSidebar +export default ChatSidebar; diff --git a/src/layout/components/ChatSidebarAction.tsx b/src/layout/components/ChatSidebarAction.tsx new file mode 100644 index 0000000..7e6d082 --- /dev/null +++ b/src/layout/components/ChatSidebarAction.tsx @@ -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 ( + <> + + + + + + + + + + Mark All + + + + {isSearchActive ? 'Cancel' : 'Search'} + +
+ +
+ setIsDeleteMessageDialogOpen(true)}> + + Clear Chat List + +
+
+ + {/* 删除全部 确认modal */} + + + + Clear Chat List + + + This will clear your chat list. Your individual messages will remain intact. + + + Cancel + + Clear + + + + + + ); +}; + +export default ChatSidebarAction; diff --git a/src/components/layout/components/ChatSidebarItem.tsx b/src/layout/components/ChatSidebarItem.tsx similarity index 66% rename from src/components/layout/components/ChatSidebarItem.tsx rename to src/layout/components/ChatSidebarItem.tsx index 7834acb..5309bc7 100644 --- a/src/components/layout/components/ChatSidebarItem.tsx +++ b/src/layout/components/ChatSidebarItem.tsx @@ -1,21 +1,18 @@ -'use client' -import { useMemo } from 'react' -import AIRelationTag from '@/components/features/AIRelationTag' -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' -import { Badge } from '@/components/ui/badge' -import { useNimChat } from '@/context/NimChat/useNimChat' -import { cn, durationText, getConversationTime } from '@/lib/utils' -import { CustomMessageType } from '@/types/im' -import Image from 'next/image' -import { useRouter } from 'next/navigation' -import { V2NIMConversation } from 'nim-web-sdk-ng/dist/v2/NIM_BROWSER_SDK/V2NIMConversationService' -import { usePrefetchRoutes } from '@/hooks/useGlobalPrefetchRoutes' +'use client'; +import { useMemo } from 'react'; +import AIRelationTag from '@/components/features/AIRelationTag'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Badge } from '@/components/ui/badge'; +import { cn, durationText, getConversationTime } from '@/lib/utils'; +import { CustomMessageType } from '@/types/im'; +import Image from 'next/image'; +import { useRouter } from 'next/navigation'; // 高亮搜索关键词的组件 const HighlightText = ({ text, keyword }: { text: string; keyword?: string }) => { - if (!keyword || !text) return {text} + if (!keyword || !text) return {text}; - const parts = text.split(new RegExp(`(${keyword})`, 'gi')) + const parts = text.split(new RegExp(`(${keyword})`, 'gi')); return ( {parts.map((part, index) => @@ -28,8 +25,8 @@ const HighlightText = ({ text, keyword }: { text: string; keyword?: string }) => ) )} - ) -} + ); +}; // 聊天项组件 export default function ChatSidebarItem({ @@ -38,52 +35,33 @@ export default function ChatSidebarItem({ isSelected = false, searchKeyword, }: { - conversation: V2NIMConversation - isExpanded: boolean - isSelected?: boolean - searchKeyword?: string + isExpanded: boolean; + isSelected?: boolean; + searchKeyword?: string; }) { - const { avatar, name, lastMessage, unreadCount, updateTime, serverExtension } = conversation - const { text, attachment } = lastMessage || {} - const router = useRouter() - const { nim } = useNimChat() - const chatHref = useMemo(() => { - if (!nim?.V2NIMConversationIdUtil || !conversation?.conversationId) { - return null - } - try { - const targetId = nim.V2NIMConversationIdUtil.parseConversationTargetId( - conversation.conversationId - ) - const aiid = targetId.split('@')[0] - return `/chat/${aiid}` - } catch { - return null - } - }, [conversation?.conversationId, nim]) - usePrefetchRoutes(chatHref ? [chatHref] : undefined) + const { avatar, name, lastMessage, unreadCount, updateTime, serverExtension } = conversation; + const { text, attachment } = lastMessage || {}; + const router = useRouter(); - const { heartbeatVal, heartbeatLevel, isShow } = JSON.parse(serverExtension || '{}') || {} + const { heartbeatVal, heartbeatLevel, isShow } = JSON.parse(serverExtension || '{}') || {}; const handleChat = () => { - if (chatHref) { - router.push(chatHref) - } - } + router.push('/'); + }; const renderText = () => { - const { raw } = attachment || {} - const customData = JSON.parse(raw || '{}') - const { type, duration } = customData || {} + const { raw } = attachment || {}; + const customData = JSON.parse(raw || '{}'); + const { type, duration } = customData || {}; if (type === CustomMessageType.CALL_CANCEL) { - return 'Call Canceled' + return 'Call Canceled'; } else if (type === CustomMessageType.CALL) { - return `Call duration ${durationText(duration)}` + return `Call duration ${durationText(duration)}`; } else if (type == CustomMessageType.IMAGE) { - return '[Image]' + return '[Image]'; } - return text - } + return text; + }; return (
)}
- ) + ); } diff --git a/src/components/layout/components/Notice.tsx b/src/layout/components/Notice.tsx similarity index 100% rename from src/components/layout/components/Notice.tsx rename to src/layout/components/Notice.tsx diff --git a/src/components/layout/components/NoticeDrawer.tsx b/src/layout/components/NoticeDrawer.tsx similarity index 100% rename from src/components/layout/components/NoticeDrawer.tsx rename to src/layout/components/NoticeDrawer.tsx diff --git a/src/lib/client/auth.ts b/src/lib/client/auth.ts new file mode 100644 index 0000000..a7ee814 --- /dev/null +++ b/src/lib/client/auth.ts @@ -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; +}; diff --git a/src/lib/client/index.ts b/src/lib/client/index.ts new file mode 100644 index 0000000..3e0c247 --- /dev/null +++ b/src/lib/client/index.ts @@ -0,0 +1,3 @@ +import createClient from './request' + +export const editorRequest = createClient({ serviceName: 'editor' }) diff --git a/src/lib/client/request.ts b/src/lib/client/request.ts new file mode 100644 index 0000000..f9fff4b --- /dev/null +++ b/src/lib/client/request.ts @@ -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 => { + 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 = { + code: number + message: string + data: T + } + + return async function request( + url: string, + config?: AxiosRequestConfig + ): Promise> { + let data: any + if (config && config?.params) { + const { params } = config + data = Object.fromEntries(Object.entries(params).filter(([, value]) => value !== '')) + } + const response = await instance>(url, { + ...config, + params: data, + }) + return response.data + } +} diff --git a/src/lib/http/create-http-client.ts b/src/lib/http/create-http-client.ts index c5b9ac4..f7c1ede 100644 --- a/src/lib/http/create-http-client.ts +++ b/src/lib/http/create-http-client.ts @@ -51,9 +51,6 @@ const endpoints = { shark: process.env.NEXT_PUBLIC_SHARK_API_URL, cow: process.env.NEXT_PUBLIC_COW_API_URL, pigeon: process.env.NEXT_PUBLIC_PIGEON_API_URL, - // 自建 - edit: process.env.NEXT_PUBLIC_EDIT_API_URL, - chat: process.env.NEXT_PUBLIC_CHAT_API_URL, } export function createHttpClient(config: CreateHttpClientConfig): ExtendedAxiosInstance { @@ -138,7 +135,6 @@ export function createHttpClient(config: CreateHttpClientConfig): ExtendedAxiosI instance.interceptors.response.use( (response) => { const apiResponse = response.data as ApiResponse - console.log('apiResponse', apiResponse) // 检查业务状态 if (apiResponse.status === API_STATUS.OK) { diff --git a/src/lib/http/instances.ts b/src/lib/http/instances.ts index 4f15e3d..c7cc74e 100644 --- a/src/lib/http/instances.ts +++ b/src/lib/http/instances.ts @@ -54,14 +54,3 @@ export const lionHttp = createHttpClient({ encryptHeader: 'encrypt', }, }) - -// 自扩展服务 ------------ // -// 编辑器主服务 -export const editHttp = createHttpClient({ - serviceName: 'edit', -}) - -// 聊天 -export const chatHttp = createHttpClient({ - serviceName: 'chat', -}) diff --git a/src/lib/oauth/discord.ts b/src/lib/oauth/discord.ts index 4695c30..ddc92a6 100644 --- a/src/lib/oauth/discord.ts +++ b/src/lib/oauth/discord.ts @@ -1,40 +1,30 @@ -// Discord OAuth配置 -const DISCORD_CLIENT_ID = process.env.NEXT_PUBLIC_DISCORD_CLIENT_ID! -const DISCORD_REDIRECT_URI = `${process.env.NEXT_PUBLIC_APP_URL || 'https://test.crushlevel.ai'}/api/auth/discord/callback` - -// Discord OAuth scopes -const DISCORD_SCOPES = ['identify', 'email'] - export interface DiscordUser { - id: string - username: string - email: string - avatar?: string - discriminator: string + id: string; + username: string; + email: string; + avatar?: string; + discriminator: string; } export interface DiscordTokenResponse { - access_token: string - token_type: string - expires_in: number - refresh_token: string - scope: string + access_token: string; + token_type: string; + expires_in: number; + refresh_token: string; + scope: string; } export const discordOAuth = { // 获取Discord授权URL getAuthUrl: (state?: string): string => { const params = new URLSearchParams({ - client_id: DISCORD_CLIENT_ID, - redirect_uri: DISCORD_REDIRECT_URI, + client_id: process.env.NEXT_PUBLIC_DISCORD_CLIENT_ID!, + redirect_uri: `${window.location.origin}/api/auth/discord/callback`, response_type: 'code', - scope: DISCORD_SCOPES.join(' '), + scope: ['identify', 'email'].join(' '), ...(state && { state }), - }) + }); - return `https://discord.com/api/oauth2/authorize?${params.toString()}` + return `https://discord.com/api/oauth2/authorize?${params.toString()}`; }, - - // 注意:token交换和用户信息获取将在后端处理 - // 前端只负责获取授权码并传递给后端API -} +}; diff --git a/src/lib/providers.tsx b/src/lib/providers.tsx index e1f5dea..7cff69a 100644 --- a/src/lib/providers.tsx +++ b/src/lib/providers.tsx @@ -1,31 +1,37 @@ -'use client' -import { ApiError } from '@/types/api' -import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { ReactQueryDevtools } from '@tanstack/react-query-devtools' -import { usePathname, useRouter, useSearchParams } from 'next/navigation' -import React, { useState, useRef, type ReactNode } from 'react' -import { toast, Toaster } from 'sonner' -import { tokenManager } from './auth/token' -import { COIN_INSUFFICIENT_ERROR_CODE } from '@/hooks/useWallet' -import { walletKeys } from './query-keys' +'use client'; +import { ApiError } from '@/types/api'; +import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import React, { useState, useRef, type ReactNode } from 'react'; +import { toast, Toaster } from 'sonner'; +import { tokenManager } from './auth/token'; +import { COIN_INSUFFICIENT_ERROR_CODE } from '@/hooks/useWallet'; +import { walletKeys } from './query-keys'; interface ProvidersProps { - children: ReactNode + children: ReactNode; } -const ReactQueryDevtoolsProduction = React.lazy(() => - import('@tanstack/react-query-devtools/build/modern/production.js').then((d) => ({ - default: d.ReactQueryDevtools, - })) -) +// const ReactQueryDevtoolsProduction = React.lazy(() => +// import('@tanstack/react-query-devtools/build/modern/production.js').then((d) => ({ +// default: d.ReactQueryDevtools, +// })) +// ); -const EXPIRED_ERROR_CODES = ['10050001', '10050002', '10050003', '10050004', '10050005', '10050006'] +const EXPIRED_ERROR_CODES = [ + '10050001', + '10050002', + '10050003', + '10050004', + '10050005', + '10050006', +]; export function Providers({ children }: ProvidersProps) { - const router = useRouter() + const router = useRouter(); // 用于错误去重的引用 - const errorTimeoutRef = useRef(null) - const lastErrorRef = useRef('') - const [showDevtools, setShowDevtools] = React.useState(false) + const errorTimeoutRef = useRef(null); + const lastErrorRef = useRef(''); const [queryClient] = useState( () => @@ -45,60 +51,55 @@ export function Providers({ children }: ProvidersProps) { }, queryCache: new QueryCache({ onError: (error) => { - handleError(error as ApiError) + handleError(error as ApiError); }, }), mutationCache: new MutationCache({ onError: (error) => { - handleError(error as ApiError) + handleError(error as ApiError); }, }), }) - ) - const pathname = usePathname() - const searchParams = useSearchParams() + ); + const pathname = usePathname(); + const searchParams = useSearchParams(); - const searchParamsString = searchParams.toString() - const redirectURL = `${pathname}${searchParamsString ? `?${searchParamsString}` : ''}` + const searchParamsString = searchParams.toString(); + const redirectURL = `${pathname}${searchParamsString ? `?${searchParamsString}` : ''}`; const handleError = (error: ApiError) => { if (error.errorCode === COIN_INSUFFICIENT_ERROR_CODE) { - queryClient.invalidateQueries({ queryKey: walletKeys.getWalletBalance() }) + queryClient.invalidateQueries({ queryKey: walletKeys.getWalletBalance() }); } if (EXPIRED_ERROR_CODES.includes(error.errorCode)) { // 清除 cookie 中的 st - tokenManager.removeToken() - router.push('/login?redirect=' + encodeURIComponent(redirectURL)) - return // 对于登录过期错误,不显示错误toast,直接跳转 + tokenManager.removeToken(); + router.push('/login?redirect=' + encodeURIComponent(redirectURL)); + return; // 对于登录过期错误,不显示错误toast,直接跳转 } if (error.ignoreError) { - return + return; } // 错误去重逻辑:只显示最后一次的错误 - const errorKey = `${error.errorCode}:${error.errorMsg}` + const errorKey = `${error.errorCode}:${error.errorMsg}`; // 清除之前的定时器 if (errorTimeoutRef.current) { - clearTimeout(errorTimeoutRef.current) + clearTimeout(errorTimeoutRef.current); } // 更新最后一次错误信息 - lastErrorRef.current = errorKey + lastErrorRef.current = errorKey; // 设置新的定时器,延迟显示错误 errorTimeoutRef.current = setTimeout(() => { // 只有当前错误仍然是最后一次错误时才显示 if (lastErrorRef.current === errorKey) { - toast.error(error.errorMsg) + toast.error(error.errorMsg); } - }, 100) // 100ms 延迟,确保能捕获到快速连续的错误 - } - - React.useEffect(() => { - // @ts-ignore - window.toggleDevtools = () => setShowDevtools((old) => !old) - }, []) + }, 100); // 100ms 延迟,确保能捕获到快速连续的错误 + }; return ( @@ -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', }} /> - + {/* */} - {showDevtools && ( + {/* {showDevtools && ( - )} + )} */} - ) + ); } diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts new file mode 100644 index 0000000..0a3e61c --- /dev/null +++ b/src/lib/server/auth.ts @@ -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; +} diff --git a/src/lib/server/request.ts b/src/lib/server/request.ts new file mode 100644 index 0000000..33a84cf --- /dev/null +++ b/src/lib/server/request.ts @@ -0,0 +1,95 @@ +import { getServerToken } from './auth'; + +type ResponseType = { + code: number; + message: string; + data: T; +}; + +export async function fetchServerRequest( + url: string, + options?: { + method?: 'GET' | 'POST'; + params?: Record; + data?: Record; + requireAuth?: boolean; + // Next.js 缓存选项 + revalidate?: number | false; // 秒数,或 false 表示永不过期 + tags?: string[]; // 缓存标签,用于手动刷新 + } +): Promise> { + 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( + url: string, + options?: { + method?: 'GET' | 'POST'; + params?: Record; + data?: Record; + revalidate?: number | false; + tags?: string[]; + } +): Promise> { + return fetchServerRequest(url, { ...options, requireAuth: false }); +} diff --git a/src/services/editor/index.ts b/src/services/editor/index.ts index 57ace8e..3d574d6 100644 --- a/src/services/editor/index.ts +++ b/src/services/editor/index.ts @@ -1,13 +1,17 @@ -import { editHttp } from '@/lib/http/instances' +import { editorRequest } from '@/lib/client'; -export async function fetchCharacters(params: any) { - return editHttp.post('/api/character/list', params) +export async function fetchCharacters({ index, limit, query }: any) { + const { data } = await editorRequest('/api/character/list', { + method: 'POST', + data: { index, limit, ...query }, + }); + return data; } export async function fetchCharacter(params: any) { - return editHttp.post('/api/character/detail', params) + return editorRequest('/api/character/detail', { method: 'POST', data: params }); } -export async function fetchTags(params: any): Promise { - return editHttp.post('/api/tag/list', params) +export async function fetchCharacterTags(params: any = {}) { + return editorRequest('/api/tag/list', { method: 'POST', data: params }); } diff --git a/src/services/editor/type.d.ts b/src/services/editor/type.d.ts new file mode 100644 index 0000000..c958cdd --- /dev/null +++ b/src/services/editor/type.d.ts @@ -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; +}; diff --git a/src/services/home/home.service.ts b/src/services/home/home.service.ts deleted file mode 100644 index 0e83727..0000000 --- a/src/services/home/home.service.ts +++ /dev/null @@ -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 => { - return frogHttp.post('/web/explore/info') - }, - - // 首页分类列表 - getMeetList: (data: GetMeetListRequest): Promise => { - return frogHttp.post('/web/home/classification-list', data) - }, - - // 热聊榜 - getChatRank: (): Promise => { - return frogHttp.post('/web/rank/chat') - }, - - // 心动榜 - getHeartbeatRank: (): Promise => { - return frogHttp.post('/web/rank/heartbeat') - }, - - // 送礼榜 - getGiftRank: (): Promise => { - return frogHttp.post('/web/rank/gift') - }, - - // 七天签到列表 - getSevenDaysSignList: (): Promise => { - return frogHttp.post('/web/si/list') - }, - - // 签到 - signIn: (): Promise => { - return frogHttp.post('/web/si/asi') - }, - - // 首页AI轮播列表 - getHomeAiCarouselList: (): Promise => { - return frogHttp.post('/web/home/ai-carousel-list') - }, - - // 首页聚合推荐 - getHomeAggregateRecommend: (): Promise => { - return frogHttp.post('/web/home/agg-recommend') - }, -} diff --git a/src/services/home/index.ts b/src/services/home/index.ts deleted file mode 100644 index 5d0027b..0000000 --- a/src/services/home/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './types' -export * from './home.service' diff --git a/src/services/home/types.ts b/src/services/home/types.ts deleted file mode 100644 index 71be8ee..0000000 --- a/src/services/home/types.ts +++ /dev/null @@ -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[] -} diff --git a/src/stores/index.ts b/src/stores/index.ts new file mode 100644 index 0000000..df81197 --- /dev/null +++ b/src/stores/index.ts @@ -0,0 +1,61 @@ +'use client'; +import { create } from 'zustand'; + +type LayoutStore = { + isSidebarExpanded: boolean; + setSidebarExpanded: (isSidebarExpanded: boolean) => void; + hydrated: boolean; + setHydrated: () => void; +}; + +// localStorage key +const SIDEBAR_EXPANDED_KEY = 'sidebarExpanded'; + +export const useLayoutStore = create((set) => ({ + // 默认值在服务器端和客户端都是 false,确保 Hydration 一致 + isSidebarExpanded: false, + hydrated: false, + + setHydrated: () => { + // Hydration 完成后,从 localStorage 读取真实的状态 + if (typeof window !== 'undefined') { + try { + const saved = localStorage.getItem(SIDEBAR_EXPANDED_KEY); + if (saved !== null) { + const parsed = JSON.parse(saved); + let actualState = false; + + // 从 persist 中间件的格式中提取 state.isSidebarExpanded + if (typeof parsed === 'object' && parsed !== null && 'state' in parsed) { + actualState = parsed.state.isSidebarExpanded ?? false; + } + // 兼容新格式(直接保存布尔值) + else if (typeof parsed === 'boolean') { + actualState = parsed; + } + + set({ hydrated: true, isSidebarExpanded: actualState }); + return; + } + } catch (error) { + console.error('Failed to load sidebar state from localStorage:', error); + } + } + + set({ hydrated: true }); + }, + + setSidebarExpanded: (isSidebarExpanded: boolean) => { + // 更新状态 + set({ isSidebarExpanded }); + + // 手动保存到 localStorage + if (typeof window !== 'undefined') { + try { + localStorage.setItem(SIDEBAR_EXPANDED_KEY, JSON.stringify(isSidebarExpanded)); + } catch (error) { + console.error('Failed to save sidebar state to localStorage:', error); + } + } + }, +})); diff --git a/src/stores/stream-chat.ts b/src/stores/stream-chat.ts new file mode 100644 index 0000000..242dd10 --- /dev/null +++ b/src/stores/stream-chat.ts @@ -0,0 +1,95 @@ +'use client'; +import { Channel, StreamChat } from 'stream-chat'; +import { create } from 'zustand'; + +interface StreamChatStore { + connect: (user: any) => Promise; + channels: Channel[]; + currentChannel: Channel | null; + switchToChannel: (id: string) => Promise; + queryChannels: (filter: any) => Promise; + deleteChannel: (id: string) => Promise; + clearChannels: () => Promise; + clearNotifications: () => Promise; +} + +let client: StreamChat | null = null; +export const useStreamChatStore = create((set, get) => ({ + channels: [], + currentChannel: null, + async connect(user) { + const user = {}; + const { data } = await getUserToken(user); + client = new StreamChat(process.env.NEXT_PUBLIC_STREAM_CHAT_API_KEY || ''); + await client.connectUser( + { + id: user.userId, + name: user.userName, + }, + data + ); + }, + async switchToChannel(id: string) { + const { channels } = get(); + const channel = channels.find((ch) => ch.id === id); + if (channel) { + set({ currentChannel: channel }); + // 可选:监听该频道的消息 + await channel.watch(); + } else { + console.warn(`Channel with id ${id} not found in channels list`); + } + }, + async queryChannels(filter: any) { + if (!client) { + console.error('StreamChat client is not connected'); + return; + } + try { + const channels = await client.queryChannels(filter, { + last_message_at: -1, + }); + set({ channels }); + } catch (error) { + console.error('Failed to query channels:', error); + } + }, + async deleteChannel(id: string) { + const { channels, currentChannel, queryChannels } = get(); + const channel = channels.find((ch) => ch.id === id); + if (!channel) { + console.warn(`Channel with id ${id} not found`); + return; + } + try { + await channel.delete(); + await queryChannels({}); + set({ currentChannel: null }); + } catch (error) { + console.error(`Failed to delete channel ${id}:`, error); + } + }, + async clearChannels() { + const { channels } = get(); + + try { + // 停止监听所有频道 + for (const channel of channels) { + try { + await channel.stopWatching(); + } catch (error) { + console.warn(`Failed to stop watching channel ${channel.id}:`, error); + } + } + + // 清空频道列表和当前频道 + set({ + channels: [], + currentChannel: null, + }); + } catch (error) { + console.error('Failed to clear channels:', error); + } + }, + async clearNotifications() {}, +}));