feat(core): 增加面具配置和语言切换

This commit is contained in:
liuyonghe0111 2025-12-22 19:31:36 +08:00
parent 46c30795c3
commit d571477a0e
75 changed files with 1645 additions and 2826 deletions

1
.env
View File

@ -12,6 +12,7 @@ NEXT_PUBLIC_CHAT_API_URL=http://54.223.196.180
# 三方登录
NEXT_PUBLIC_DISCORD_CLIENT_ID=1448143535609217076
NEXT_PUBLIC_GOOGLE_CLIENT_ID=754103853450-vn4af37tmguerut5a7abo54jr87mv09k.apps.googleusercontent.com
# 前端回调地址
NEXT_PUBLIC_APP_URL=http://54.223.196.180:3000

View File

@ -51,6 +51,7 @@
"lamejs": "^1.2.1",
"lucide-react": "^0.525.0",
"next": "16.0.8",
"next-intl": "^4.6.1",
"next-themes": "^0.4.6",
"numeral": "^2.0.6",
"qs": "^6.14.0",
@ -63,7 +64,6 @@
"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",
"zod": "^4.0.5",

View File

@ -122,6 +122,9 @@ importers:
next:
specifier: 16.0.8
version: 16.0.8(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
next-intl:
specifier: ^4.6.1
version: 4.6.1(next@16.0.8(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(typescript@5.8.3)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
@ -158,9 +161,6 @@ importers:
stream-chat:
specifier: ^9.27.0
version: 9.27.0
swiper:
specifier: ^12.0.3
version: 12.0.3
tailwind-merge:
specifier: ^3.3.1
version: 3.3.1
@ -242,7 +242,7 @@ importers:
version: 27.0.2
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@20.19.8)(typescript@5.8.3)
version: 10.9.2(@swc/core@1.15.7)(@types/node@20.19.8)(typescript@5.8.3)
tsx:
specifier: ^4.20.6
version: 4.20.6
@ -793,6 +793,24 @@ packages:
'@floating-ui/utils@0.2.10':
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
'@formatjs/ecma402-abstract@2.3.6':
resolution: {integrity: sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==}
'@formatjs/fast-memoize@2.2.7':
resolution: {integrity: sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==}
'@formatjs/icu-messageformat-parser@2.11.4':
resolution: {integrity: sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==}
'@formatjs/icu-skeleton-parser@1.8.16':
resolution: {integrity: sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==}
'@formatjs/intl-localematcher@0.5.10':
resolution: {integrity: sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==}
'@formatjs/intl-localematcher@0.6.2':
resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==}
'@gulpjs/to-absolute-glob@4.0.0':
resolution: {integrity: sha512-kjotm7XJrJ6v+7knhPaRgaT6q8F8K2jiafwYdNHLzmV0uGLuZY43FK6smNSHUPrhq5kX2slCUy+RGG/xGqmIKA==}
engines: {node: '>=10.13.0'}
@ -1107,6 +1125,88 @@ packages:
'@open-draft/until@2.1.0':
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
'@parcel/watcher-android-arm64@2.5.1':
resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [android]
'@parcel/watcher-darwin-arm64@2.5.1':
resolution: {integrity: sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [darwin]
'@parcel/watcher-darwin-x64@2.5.1':
resolution: {integrity: sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [darwin]
'@parcel/watcher-freebsd-x64@2.5.1':
resolution: {integrity: sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [freebsd]
'@parcel/watcher-linux-arm-glibc@2.5.1':
resolution: {integrity: sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
'@parcel/watcher-linux-arm-musl@2.5.1':
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
'@parcel/watcher-linux-arm64-glibc@2.5.1':
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
'@parcel/watcher-linux-arm64-musl@2.5.1':
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
'@parcel/watcher-linux-x64-glibc@2.5.1':
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
'@parcel/watcher-linux-x64-musl@2.5.1':
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
'@parcel/watcher-win32-arm64@2.5.1':
resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [win32]
'@parcel/watcher-win32-ia32@2.5.1':
resolution: {integrity: sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==}
engines: {node: '>= 10.0.0'}
cpu: [ia32]
os: [win32]
'@parcel/watcher-win32-x64@2.5.1':
resolution: {integrity: sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [win32]
'@parcel/watcher@2.5.1':
resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==}
engines: {node: '>= 10.0.0'}
'@pkgr/core@0.2.9':
resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
@ -1570,6 +1670,9 @@ packages:
'@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
'@schummar/icu-type-parser@1.21.5':
resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==}
'@sindresorhus/merge-streams@4.0.0':
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'}
@ -1789,9 +1892,84 @@ packages:
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@swc/core-darwin-arm64@1.15.7':
resolution: {integrity: sha512-+hNVUfezUid7LeSHqnhoC6Gh3BROABxjlDNInuZ/fie1RUxaEX4qzDwdTgozJELgHhvYxyPIg1ro8ibnKtgO4g==}
engines: {node: '>=10'}
cpu: [arm64]
os: [darwin]
'@swc/core-darwin-x64@1.15.7':
resolution: {integrity: sha512-ZAFuvtSYZTuXPcrhanaD5eyp27H8LlDzx2NAeVyH0FchYcuXf0h5/k3GL9ZU6Jw9eQ63R1E8KBgpXEJlgRwZUQ==}
engines: {node: '>=10'}
cpu: [x64]
os: [darwin]
'@swc/core-linux-arm-gnueabihf@1.15.7':
resolution: {integrity: sha512-K3HTYocpqnOw8KcD8SBFxiDHjIma7G/X+bLdfWqf+qzETNBrzOub/IEkq9UaeupaJiZJkPptr/2EhEXXWryS/A==}
engines: {node: '>=10'}
cpu: [arm]
os: [linux]
'@swc/core-linux-arm64-gnu@1.15.7':
resolution: {integrity: sha512-HCnVIlsLnCtQ3uXcXgWrvQ6SAraskLA9QJo9ykTnqTH6TvUYqEta+TdTdGjzngD6TOE7XjlAiUs/RBtU8Z0t+Q==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
'@swc/core-linux-arm64-musl@1.15.7':
resolution: {integrity: sha512-/OOp9UZBg4v2q9+x/U21Jtld0Wb8ghzBScwhscI7YvoSh4E8RALaJ1msV8V8AKkBkZH7FUAFB7Vbv0oVzZsezA==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
'@swc/core-linux-x64-gnu@1.15.7':
resolution: {integrity: sha512-VBbs4gtD4XQxrHuQ2/2+TDZpPQQgrOHYRnS6SyJW+dw0Nj/OomRqH+n5Z4e/TgKRRbieufipeIGvADYC/90PYQ==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
'@swc/core-linux-x64-musl@1.15.7':
resolution: {integrity: sha512-kVuy2unodso6p0rMauS2zby8/bhzoGRYxBDyD6i2tls/fEYAE74oP0VPFzxIyHaIjK1SN6u5TgvV9MpyJ5xVug==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
'@swc/core-win32-arm64-msvc@1.15.7':
resolution: {integrity: sha512-uddYoo5Xmo1XKLhAnh4NBIyy5d0xk33x1sX3nIJboFySLNz878ksCFCZ3IBqrt1Za0gaoIWoOSSSk0eNhAc/sw==}
engines: {node: '>=10'}
cpu: [arm64]
os: [win32]
'@swc/core-win32-ia32-msvc@1.15.7':
resolution: {integrity: sha512-rqq8JjNMLx3QNlh0aPTtN/4+BGLEHC94rj9mkH1stoNRf3ra6IksNHMHy+V1HUqElEgcZyx+0yeXx3eLOTcoFw==}
engines: {node: '>=10'}
cpu: [ia32]
os: [win32]
'@swc/core-win32-x64-msvc@1.15.7':
resolution: {integrity: sha512-4BK06EGdPnuplgcNhmSbOIiLdRgHYX3v1nl4HXo5uo4GZMfllXaCyBUes+0ePRfwbn9OFgVhCWPcYYjMT6hycQ==}
engines: {node: '>=10'}
cpu: [x64]
os: [win32]
'@swc/core@1.15.7':
resolution: {integrity: sha512-kTGB8XI7P+pTKW83tnUEDVP4zduF951u3UAOn5eTi0vyW6MvL56A3+ggMdfuVFtDI0/DsbSzf5z34HVBbuScWw==}
engines: {node: '>=10'}
peerDependencies:
'@swc/helpers': '>=0.5.17'
peerDependenciesMeta:
'@swc/helpers':
optional: true
'@swc/counter@0.1.3':
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
'@swc/types@0.1.25':
resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==}
'@tailwindcss/node@4.1.11':
resolution: {integrity: sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q==}
@ -2500,6 +2678,11 @@ packages:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
detect-libc@1.0.3:
resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==}
engines: {node: '>=0.10'}
hasBin: true
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@ -3003,6 +3186,9 @@ packages:
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.
intl-messageformat@10.7.18:
resolution: {integrity: sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==}
is-array-buffer@3.0.5:
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
engines: {node: '>= 0.4'}
@ -3435,6 +3621,23 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
negotiator@1.0.0:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
next-intl-swc-plugin-extractor@4.6.1:
resolution: {integrity: sha512-+HHNeVERfSvuPDF7LYVn3pxst5Rf7EYdUTw7C7WIrYhcLaKiZ1b9oSRkTQddAN3mifDMCfHqO4kAQ/pcKiBl3A==}
next-intl@4.6.1:
resolution: {integrity: sha512-KlWgWtKLBPUsTPgxqwyjws1wCMD2QKxLlVjeeGj53DC1JWfKmBShKOrhIP0NznZrRQ0GleeoDUeHSETmyyIFeA==}
peerDependencies:
next: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0
typescript: ^5.0.0
peerDependenciesMeta:
typescript:
optional: true
next-themes@0.4.6:
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
peerDependencies:
@ -3462,6 +3665,9 @@ packages:
sass:
optional: true
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
@ -3575,6 +3781,9 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
po-parser@2.0.0:
resolution: {integrity: sha512-SZvoKi3PoI/hHa2V9je9CW7Xgxl4dvO74cvaa6tWShIHT51FkPxje6pt0gTJznJrU67ix91nDaQp2hUxkOYhKA==}
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@ -4029,10 +4238,6 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
swiper@12.0.3:
resolution: {integrity: sha512-BHd6U1VPEIksrXlyXjMmRWO0onmdNPaTAFduzqR3pgjvi7KfmUCAm/0cj49u2D7B0zNjMw02TSeXfinC1hDCXg==}
engines: {node: '>= 4.7.0'}
synckit@0.11.11:
resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==}
engines: {node: ^14.18.0 || >=16.0.0}
@ -4196,6 +4401,11 @@ packages:
'@types/react':
optional: true
use-intl@4.6.1:
resolution: {integrity: sha512-mUIj6QvJZ7Rk33mLDxRziz1YiBBAnIji8YW4TXXMdYHtaPEbVucrXD3iKQGAqJhbVn0VnjrEtIKYO1B18mfSJw==}
peerDependencies:
react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0
use-sidecar@1.1.3:
resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==}
engines: {node: '>=10'}
@ -5253,6 +5463,36 @@ snapshots:
'@floating-ui/utils@0.2.10': {}
'@formatjs/ecma402-abstract@2.3.6':
dependencies:
'@formatjs/fast-memoize': 2.2.7
'@formatjs/intl-localematcher': 0.6.2
decimal.js: 10.6.0
tslib: 2.8.1
'@formatjs/fast-memoize@2.2.7':
dependencies:
tslib: 2.8.1
'@formatjs/icu-messageformat-parser@2.11.4':
dependencies:
'@formatjs/ecma402-abstract': 2.3.6
'@formatjs/icu-skeleton-parser': 1.8.16
tslib: 2.8.1
'@formatjs/icu-skeleton-parser@1.8.16':
dependencies:
'@formatjs/ecma402-abstract': 2.3.6
tslib: 2.8.1
'@formatjs/intl-localematcher@0.5.10':
dependencies:
tslib: 2.8.1
'@formatjs/intl-localematcher@0.6.2':
dependencies:
tslib: 2.8.1
'@gulpjs/to-absolute-glob@4.0.0':
dependencies:
is-negated-glob: 1.0.0
@ -5501,6 +5741,66 @@ snapshots:
'@open-draft/until@2.1.0': {}
'@parcel/watcher-android-arm64@2.5.1':
optional: true
'@parcel/watcher-darwin-arm64@2.5.1':
optional: true
'@parcel/watcher-darwin-x64@2.5.1':
optional: true
'@parcel/watcher-freebsd-x64@2.5.1':
optional: true
'@parcel/watcher-linux-arm-glibc@2.5.1':
optional: true
'@parcel/watcher-linux-arm-musl@2.5.1':
optional: true
'@parcel/watcher-linux-arm64-glibc@2.5.1':
optional: true
'@parcel/watcher-linux-arm64-musl@2.5.1':
optional: true
'@parcel/watcher-linux-x64-glibc@2.5.1':
optional: true
'@parcel/watcher-linux-x64-musl@2.5.1':
optional: true
'@parcel/watcher-win32-arm64@2.5.1':
optional: true
'@parcel/watcher-win32-ia32@2.5.1':
optional: true
'@parcel/watcher-win32-x64@2.5.1':
optional: true
'@parcel/watcher@2.5.1':
dependencies:
detect-libc: 1.0.3
is-glob: 4.0.3
micromatch: 4.0.8
node-addon-api: 7.1.1
optionalDependencies:
'@parcel/watcher-android-arm64': 2.5.1
'@parcel/watcher-darwin-arm64': 2.5.1
'@parcel/watcher-darwin-x64': 2.5.1
'@parcel/watcher-freebsd-x64': 2.5.1
'@parcel/watcher-linux-arm-glibc': 2.5.1
'@parcel/watcher-linux-arm-musl': 2.5.1
'@parcel/watcher-linux-arm64-glibc': 2.5.1
'@parcel/watcher-linux-arm64-musl': 2.5.1
'@parcel/watcher-linux-x64-glibc': 2.5.1
'@parcel/watcher-linux-x64-musl': 2.5.1
'@parcel/watcher-win32-arm64': 2.5.1
'@parcel/watcher-win32-ia32': 2.5.1
'@parcel/watcher-win32-x64': 2.5.1
'@pkgr/core@0.2.9': {}
'@radix-ui/number@1.1.1': {}
@ -5959,6 +6259,8 @@ snapshots:
'@rtsao/scc@1.1.0': {}
'@schummar/icu-type-parser@1.21.5': {}
'@sindresorhus/merge-streams@4.0.0': {}
'@smithy/abort-controller@4.0.4':
@ -6296,10 +6598,62 @@ snapshots:
'@standard-schema/utils@0.3.0': {}
'@swc/core-darwin-arm64@1.15.7':
optional: true
'@swc/core-darwin-x64@1.15.7':
optional: true
'@swc/core-linux-arm-gnueabihf@1.15.7':
optional: true
'@swc/core-linux-arm64-gnu@1.15.7':
optional: true
'@swc/core-linux-arm64-musl@1.15.7':
optional: true
'@swc/core-linux-x64-gnu@1.15.7':
optional: true
'@swc/core-linux-x64-musl@1.15.7':
optional: true
'@swc/core-win32-arm64-msvc@1.15.7':
optional: true
'@swc/core-win32-ia32-msvc@1.15.7':
optional: true
'@swc/core-win32-x64-msvc@1.15.7':
optional: true
'@swc/core@1.15.7':
dependencies:
'@swc/counter': 0.1.3
'@swc/types': 0.1.25
optionalDependencies:
'@swc/core-darwin-arm64': 1.15.7
'@swc/core-darwin-x64': 1.15.7
'@swc/core-linux-arm-gnueabihf': 1.15.7
'@swc/core-linux-arm64-gnu': 1.15.7
'@swc/core-linux-arm64-musl': 1.15.7
'@swc/core-linux-x64-gnu': 1.15.7
'@swc/core-linux-x64-musl': 1.15.7
'@swc/core-win32-arm64-msvc': 1.15.7
'@swc/core-win32-ia32-msvc': 1.15.7
'@swc/core-win32-x64-msvc': 1.15.7
'@swc/counter@0.1.3': {}
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1
'@swc/types@0.1.25':
dependencies:
'@swc/counter': 0.1.3
'@tailwindcss/node@4.1.11':
dependencies:
'@ampproject/remapping': 2.3.0
@ -6984,6 +7338,8 @@ snapshots:
delayed-stream@1.0.0: {}
detect-libc@1.0.3: {}
detect-libc@2.1.2: {}
detect-node-es@1.1.0: {}
@ -7669,6 +8025,13 @@ snapshots:
intersection-observer@0.12.2: {}
intl-messageformat@10.7.18:
dependencies:
'@formatjs/ecma402-abstract': 2.3.6
'@formatjs/fast-memoize': 2.2.7
'@formatjs/icu-messageformat-parser': 2.11.4
tslib: 2.8.1
is-array-buffer@3.0.5:
dependencies:
call-bind: 1.0.8
@ -8057,6 +8420,26 @@ snapshots:
natural-compare@1.4.0: {}
negotiator@1.0.0: {}
next-intl-swc-plugin-extractor@4.6.1: {}
next-intl@4.6.1(next@16.0.8(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(typescript@5.8.3):
dependencies:
'@formatjs/intl-localematcher': 0.5.10
'@parcel/watcher': 2.5.1
'@swc/core': 1.15.7
negotiator: 1.0.0
next: 16.0.8(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
next-intl-swc-plugin-extractor: 4.6.1
po-parser: 2.0.0
react: 19.2.1
use-intl: 4.6.1(react@19.2.1)
optionalDependencies:
typescript: 5.8.3
transitivePeerDependencies:
- '@swc/helpers'
next-themes@0.4.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
dependencies:
react: 19.2.1
@ -8085,6 +8468,8 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
node-addon-api@7.1.1: {}
node-releases@2.0.27: {}
normalize-path@3.0.0: {}
@ -8194,6 +8579,8 @@ snapshots:
picomatch@4.0.3: {}
po-parser@2.0.0: {}
possible-typed-array-names@1.1.0: {}
postcss@8.4.31:
@ -8669,8 +9056,6 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
swiper@12.0.3: {}
synckit@0.11.11:
dependencies:
'@pkgr/core': 0.2.9
@ -8744,7 +9129,7 @@ snapshots:
'@ts-morph/common': 0.28.1
code-block-writer: 13.0.3
ts-node@10.9.2(@types/node@20.19.8)(typescript@5.8.3):
ts-node@10.9.2(@swc/core@1.15.7)(@types/node@20.19.8)(typescript@5.8.3):
dependencies:
'@cspotcode/source-map-support': 0.8.1
'@tsconfig/node10': 1.0.11
@ -8761,6 +9146,8 @@ snapshots:
typescript: 5.8.3
v8-compile-cache-lib: 3.0.1
yn: 3.1.1
optionalDependencies:
'@swc/core': 1.15.7
tsconfig-paths@3.15.0:
dependencies:
@ -8893,6 +9280,13 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.7
use-intl@4.6.1(react@19.2.1):
dependencies:
'@formatjs/fast-memoize': 2.2.7
'@schummar/icu-type-parser': 1.21.5
intl-messageformat: 10.7.18
react: 19.2.1
use-sidecar@1.1.3(@types/react@19.2.7)(react@19.2.1):
dependencies:
detect-node-es: 1.1.0

View File

@ -1,539 +0,0 @@
/* Logo 字体 */
@font-face {
font-family: "iconfont logo";
src: url('https://at.alicdn.com/t/font_985780_km7mi63cihi.eot?t=1545807318834');
src: url('https://at.alicdn.com/t/font_985780_km7mi63cihi.eot?t=1545807318834#iefix') format('embedded-opentype'),
url('https://at.alicdn.com/t/font_985780_km7mi63cihi.woff?t=1545807318834') format('woff'),
url('https://at.alicdn.com/t/font_985780_km7mi63cihi.ttf?t=1545807318834') format('truetype'),
url('https://at.alicdn.com/t/font_985780_km7mi63cihi.svg?t=1545807318834#iconfont') format('svg');
}
.logo {
font-family: "iconfont logo";
font-size: 160px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* tabs */
.nav-tabs {
position: relative;
}
.nav-tabs .nav-more {
position: absolute;
right: 0;
bottom: 0;
height: 42px;
line-height: 42px;
color: #666;
}
#tabs {
border-bottom: 1px solid #eee;
}
#tabs li {
cursor: pointer;
width: 100px;
height: 40px;
line-height: 40px;
text-align: center;
font-size: 16px;
border-bottom: 2px solid transparent;
position: relative;
z-index: 1;
margin-bottom: -1px;
color: #666;
}
#tabs .active {
border-bottom-color: #f00;
color: #222;
}
.tab-container .content {
display: none;
}
/* 页面布局 */
.main {
padding: 30px 100px;
width: 960px;
margin: 0 auto;
}
.main .logo {
color: #333;
text-align: left;
margin-bottom: 30px;
line-height: 1;
height: 110px;
margin-top: -50px;
overflow: hidden;
*zoom: 1;
}
.main .logo a {
font-size: 160px;
color: #333;
}
.helps {
margin-top: 40px;
}
.helps pre {
padding: 20px;
margin: 10px 0;
border: solid 1px #e7e1cd;
background-color: #fffdef;
overflow: auto;
}
.icon_lists {
width: 100% !important;
overflow: hidden;
*zoom: 1;
}
.icon_lists li {
width: 100px;
margin-bottom: 10px;
margin-right: 20px;
text-align: center;
list-style: none !important;
cursor: default;
}
.icon_lists li .code-name {
line-height: 1.2;
}
.icon_lists .icon {
display: block;
height: 100px;
line-height: 100px;
font-size: 42px;
margin: 10px auto;
color: #333;
-webkit-transition: font-size 0.25s linear, width 0.25s linear;
-moz-transition: font-size 0.25s linear, width 0.25s linear;
transition: font-size 0.25s linear, width 0.25s linear;
}
.icon_lists .icon:hover {
font-size: 100px;
}
.icon_lists .svg-icon {
/* 通过设置 font-size 来改变图标大小 */
width: 1em;
/* 图标和文字相邻时,垂直对齐 */
vertical-align: -0.15em;
/* 通过设置 color 来改变 SVG 的颜色/fill */
fill: currentColor;
/* path stroke 溢出 viewBox 部分在 IE 下会显示
normalize.css 中也包含这行 */
overflow: hidden;
}
.icon_lists li .name,
.icon_lists li .code-name {
color: #666;
}
/* markdown 样式 */
.markdown {
color: #666;
font-size: 14px;
line-height: 1.8;
}
.highlight {
line-height: 1.5;
}
.markdown img {
vertical-align: middle;
max-width: 100%;
}
.markdown h1 {
color: #404040;
font-weight: 500;
line-height: 40px;
margin-bottom: 24px;
}
.markdown h2,
.markdown h3,
.markdown h4,
.markdown h5,
.markdown h6 {
color: #404040;
margin: 1.6em 0 0.6em 0;
font-weight: 500;
clear: both;
}
.markdown h1 {
font-size: 28px;
}
.markdown h2 {
font-size: 22px;
}
.markdown h3 {
font-size: 16px;
}
.markdown h4 {
font-size: 14px;
}
.markdown h5 {
font-size: 12px;
}
.markdown h6 {
font-size: 12px;
}
.markdown hr {
height: 1px;
border: 0;
background: #e9e9e9;
margin: 16px 0;
clear: both;
}
.markdown p {
margin: 1em 0;
}
.markdown>p,
.markdown>blockquote,
.markdown>.highlight,
.markdown>ol,
.markdown>ul {
width: 80%;
}
.markdown ul>li {
list-style: circle;
}
.markdown>ul li,
.markdown blockquote ul>li {
margin-left: 20px;
padding-left: 4px;
}
.markdown>ul li p,
.markdown>ol li p {
margin: 0.6em 0;
}
.markdown ol>li {
list-style: decimal;
}
.markdown>ol li,
.markdown blockquote ol>li {
margin-left: 20px;
padding-left: 4px;
}
.markdown code {
margin: 0 3px;
padding: 0 5px;
background: #eee;
border-radius: 3px;
}
.markdown strong,
.markdown b {
font-weight: 600;
}
.markdown>table {
border-collapse: collapse;
border-spacing: 0px;
empty-cells: show;
border: 1px solid #e9e9e9;
width: 95%;
margin-bottom: 24px;
}
.markdown>table th {
white-space: nowrap;
color: #333;
font-weight: 600;
}
.markdown>table th,
.markdown>table td {
border: 1px solid #e9e9e9;
padding: 8px 16px;
text-align: left;
}
.markdown>table th {
background: #F7F7F7;
}
.markdown blockquote {
font-size: 90%;
color: #999;
border-left: 4px solid #e9e9e9;
padding-left: 0.8em;
margin: 1em 0;
}
.markdown blockquote p {
margin: 0;
}
.markdown .anchor {
opacity: 0;
transition: opacity 0.3s ease;
margin-left: 8px;
}
.markdown .waiting {
color: #ccc;
}
.markdown h1:hover .anchor,
.markdown h2:hover .anchor,
.markdown h3:hover .anchor,
.markdown h4:hover .anchor,
.markdown h5:hover .anchor,
.markdown h6:hover .anchor {
opacity: 1;
display: inline-block;
}
.markdown>br,
.markdown>p>br {
clear: both;
}
.hljs {
display: block;
background: white;
padding: 0.5em;
color: #333333;
overflow-x: auto;
}
.hljs-comment,
.hljs-meta {
color: #969896;
}
.hljs-string,
.hljs-variable,
.hljs-template-variable,
.hljs-strong,
.hljs-emphasis,
.hljs-quote {
color: #df5000;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-type {
color: #a71d5d;
}
.hljs-literal,
.hljs-symbol,
.hljs-bullet,
.hljs-attribute {
color: #0086b3;
}
.hljs-section,
.hljs-name {
color: #63a35c;
}
.hljs-tag {
color: #333333;
}
.hljs-title,
.hljs-attr,
.hljs-selector-id,
.hljs-selector-class,
.hljs-selector-attr,
.hljs-selector-pseudo {
color: #795da3;
}
.hljs-addition {
color: #55a532;
background-color: #eaffea;
}
.hljs-deletion {
color: #bd2c00;
background-color: #ffecec;
}
.hljs-link {
text-decoration: underline;
}
/* 代码高亮 */
/* PrismJS 1.15.0
https://prismjs.com/download.html#themes=prism&languages=markup+css+clike+javascript */
/**
* prism.js default theme for JavaScript, CSS and HTML
* Based on dabblet (http://dabblet.com)
* @author Lea Verou
*/
code[class*="language-"],
pre[class*="language-"] {
color: black;
background: none;
text-shadow: 0 1px white;
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
pre[class*="language-"]::-moz-selection,
pre[class*="language-"] ::-moz-selection,
code[class*="language-"]::-moz-selection,
code[class*="language-"] ::-moz-selection {
text-shadow: none;
background: #b3d4fc;
}
pre[class*="language-"]::selection,
pre[class*="language-"] ::selection,
code[class*="language-"]::selection,
code[class*="language-"] ::selection {
text-shadow: none;
background: #b3d4fc;
}
@media print {
code[class*="language-"],
pre[class*="language-"] {
text-shadow: none;
}
}
/* Code blocks */
pre[class*="language-"] {
padding: 1em;
margin: .5em 0;
overflow: auto;
}
:not(pre)>code[class*="language-"],
pre[class*="language-"] {
background: #f5f2f0;
}
/* Inline code */
:not(pre)>code[class*="language-"] {
padding: .1em;
border-radius: .3em;
white-space: normal;
}
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: slategray;
}
.token.punctuation {
color: #999;
}
.namespace {
opacity: .7;
}
.token.property,
.token.tag,
.token.boolean,
.token.number,
.token.constant,
.token.symbol,
.token.deleted {
color: #905;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin,
.token.inserted {
color: #690;
}
.token.operator,
.token.entity,
.token.url,
.language-css .token.string,
.style .token.string {
color: #9a6e3a;
background: hsla(0, 0%, 100%, .5);
}
.token.atrule,
.token.attr-value,
.token.keyword {
color: #07a;
}
.token.function,
.token.class-name {
color: #DD4A68;
}
.token.regex,
.token.important,
.token.variable {
color: #e90;
}
.token.important,
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}

View File

@ -1,674 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>iconfont Demo</title>
<link rel="shortcut icon" href="//img.alicdn.com/imgextra/i4/O1CN01Z5paLz1O0zuCC7osS_!!6000000001644-55-tps-83-82.svg" type="image/x-icon"/>
<link rel="icon" type="image/svg+xml" href="//img.alicdn.com/imgextra/i4/O1CN01Z5paLz1O0zuCC7osS_!!6000000001644-55-tps-83-82.svg"/>
<link rel="stylesheet" href="https://g.alicdn.com/thx/cube/1.3.2/cube.min.css">
<link rel="stylesheet" href="demo.css">
<link rel="stylesheet" href="iconfont.css">
<script src="iconfont.js"></script>
<!-- jQuery -->
<script src="https://a1.alicdn.com/oss/uploads/2018/12/26/7bfddb60-08e8-11e9-9b04-53e73bb6408b.js"></script>
<!-- 代码高亮 -->
<script src="https://a1.alicdn.com/oss/uploads/2018/12/26/a3f714d0-08e6-11e9-8a15-ebf944d7534c.js"></script>
<style>
.main .logo {
margin-top: 0;
height: auto;
}
.main .logo a {
display: flex;
align-items: center;
}
.main .logo .sub-title {
margin-left: 0.5em;
font-size: 22px;
color: #fff;
background: linear-gradient(-45deg, #3967FF, #B500FE);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
</style>
</head>
<body>
<div class="main">
<h1 class="logo"><a href="https://www.iconfont.cn/" title="iconfont 首页" target="_blank">
<img width="200" src="https://img.alicdn.com/imgextra/i3/O1CN01Mn65HV1FfSEzR6DKv_!!6000000000514-55-tps-228-59.svg">
</a></h1>
<div class="nav-tabs">
<ul id="tabs" class="dib-box">
<li class="dib active"><span>Unicode</span></li>
<li class="dib"><span>Font class</span></li>
<li class="dib"><span>Symbol</span></li>
</ul>
<a href="https://www.iconfont.cn/manage/index?manage_type=myprojects&projectId=5076160" target="_blank" class="nav-more">查看项目</a>
</div>
<div class="tab-container">
<div class="content unicode" style="display: block;">
<ul class="icon_lists dib-box">
<li class="dib">
<span class="icon iconfont">&#xe624;</span>
<div class="name">Logo</div>
<div class="code-name">&amp;#xe624;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe622;</span>
<div class="name">12</div>
<div class="code-name">&amp;#xe622;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe621;</span>
<div class="name">群聊</div>
<div class="code-name">&amp;#xe621;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe620;</span>
<div class="name">挂断电话</div>
<div class="code-name">&amp;#xe620;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe61f;</span>
<div class="name">电话</div>
<div class="code-name">&amp;#xe61f;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe61d;</span>
<div class="name">艾特</div>
<div class="code-name">&amp;#xe61d;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe61e;</span>
<div class="name">性别</div>
<div class="code-name">&amp;#xe61e;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe61c;</span>
<div class="name">Frame 247</div>
<div class="code-name">&amp;#xe61c;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe61b;</span>
<div class="name">编辑</div>
<div class="code-name">&amp;#xe61b;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe615;</span>
<div class="name">展开</div>
<div class="code-name">&amp;#xe615;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe616;</span>
<div class="name">16-右</div>
<div class="code-name">&amp;#xe616;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe617;</span>
<div class="name">展开-1</div>
<div class="code-name">&amp;#xe617;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe618;</span>
<div class="name">生成</div>
<div class="code-name">&amp;#xe618;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe619;</span>
<div class="name">复制</div>
<div class="code-name">&amp;#xe619;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe61a;</span>
<div class="name">16-左</div>
<div class="code-name">&amp;#xe61a;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe614;</span>
<div class="name">gender-female-line</div>
<div class="code-name">&amp;#xe614;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe613;</span>
<div class="name">gender-male-line</div>
<div class="code-name">&amp;#xe613;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe612;</span>
<div class="name">刷新</div>
<div class="code-name">&amp;#xe612;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe610;</span>
<div class="name">箭头</div>
<div class="code-name">&amp;#xe610;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe611;</span>
<div class="name">关闭</div>
<div class="code-name">&amp;#xe611;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe60d;</span>
<div class="name">搜索</div>
<div class="code-name">&amp;#xe60d;</div>
</li>
</ul>
<div class="article markdown">
<h2 id="unicode-">Unicode 引用</h2>
<hr>
<p>Unicode 是字体在网页端最原始的应用方式,特点是:</p>
<ul>
<li>支持按字体的方式去动态调整图标大小,颜色等等。</li>
<li>默认情况下不支持多色,直接添加多色图标会自动去色。</li>
</ul>
<blockquote>
<p>注意:新版 iconfont 支持两种方式引用多色图标SVG symbol 引用方式和彩色字体图标模式。(使用彩色字体图标需要在「编辑项目」中开启「彩色」选项后并重新生成。)</p>
</blockquote>
<p>Unicode 使用步骤如下:</p>
<h3 id="-font-face">第一步:拷贝项目下面生成的 <code>@font-face</code></h3>
<pre><code class="language-css"
>@font-face {
font-family: 'iconfont';
src: url('iconfont.eot?t=1766052523135'); /* IE9 */
src: url('iconfont.eot?t=1766052523135#iefix') format('embedded-opentype'), /* IE6-IE8 */
url('iconfont.woff2?t=1766052523135') format('woff2'),
url('iconfont.woff?t=1766052523135') format('woff'),
url('iconfont.ttf?t=1766052523135') format('truetype'),
url('iconfont.svg?t=1766052523135#iconfont') format('svg');
}
</code></pre>
<h3 id="-iconfont-">第二步:定义使用 iconfont 的样式</h3>
<pre><code class="language-css"
>.iconfont {
font-family: "iconfont" !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
</code></pre>
<h3 id="-">第三步:挑选相应图标并获取字体编码,应用于页面</h3>
<pre>
<code class="language-html"
>&lt;span class="iconfont"&gt;&amp;#x33;&lt;/span&gt;
</code></pre>
<blockquote>
<p>"iconfont" 是你项目下的 font-family。可以通过编辑项目查看默认是 "iconfont"。</p>
</blockquote>
</div>
</div>
<div class="content font-class">
<ul class="icon_lists dib-box">
<li class="dib">
<span class="icon iconfont icon-Logo"></span>
<div class="name">
Logo
</div>
<div class="code-name">.icon-Logo
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-wodejiemianqianwang"></span>
<div class="name">
12
</div>
<div class="code-name">.icon-wodejiemianqianwang
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-qunliao"></span>
<div class="name">
群聊
</div>
<div class="code-name">.icon-qunliao
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-guaduandianhua"></span>
<div class="name">
挂断电话
</div>
<div class="code-name">.icon-guaduandianhua
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-dianhua"></span>
<div class="name">
电话
</div>
<div class="code-name">.icon-dianhua
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-aite"></span>
<div class="name">
艾特
</div>
<div class="code-name">.icon-aite
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-xingbie"></span>
<div class="name">
性别
</div>
<div class="code-name">.icon-xingbie
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-a-Frame247"></span>
<div class="name">
Frame 247
</div>
<div class="code-name">.icon-a-Frame247
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-bianji"></span>
<div class="name">
编辑
</div>
<div class="code-name">.icon-bianji
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-zhankai1"></span>
<div class="name">
展开
</div>
<div class="code-name">.icon-zhankai1
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-a-Frame195"></span>
<div class="name">
16-右
</div>
<div class="code-name">.icon-a-Frame195
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-zhankai-1"></span>
<div class="name">
展开-1
</div>
<div class="code-name">.icon-zhankai-1
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-shengcheng"></span>
<div class="name">
生成
</div>
<div class="code-name">.icon-shengcheng
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-fuzhi"></span>
<div class="name">
复制
</div>
<div class="code-name">.icon-fuzhi
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-a-Frame194"></span>
<div class="name">
16-左
</div>
<div class="code-name">.icon-a-Frame194
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-gender-female-line"></span>
<div class="name">
gender-female-line
</div>
<div class="code-name">.icon-gender-female-line
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-gender-male-line"></span>
<div class="name">
gender-male-line
</div>
<div class="code-name">.icon-gender-male-line
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-shuaxin"></span>
<div class="name">
刷新
</div>
<div class="code-name">.icon-shuaxin
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-jiantou"></span>
<div class="name">
箭头
</div>
<div class="code-name">.icon-jiantou
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-guanbi"></span>
<div class="name">
关闭
</div>
<div class="code-name">.icon-guanbi
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-sousuo"></span>
<div class="name">
搜索
</div>
<div class="code-name">.icon-sousuo
</div>
</li>
</ul>
<div class="article markdown">
<h2 id="font-class-">font-class 引用</h2>
<hr>
<p>font-class 是 Unicode 使用方式的一种变种,主要是解决 Unicode 书写不直观,语意不明确的问题。</p>
<p>与 Unicode 使用方式相比,具有如下特点:</p>
<ul>
<li>相比于 Unicode 语意明确,书写更直观。可以很容易分辨这个 icon 是什么。</li>
<li>因为使用 class 来定义图标,所以当要替换图标时,只需要修改 class 里面的 Unicode 引用。</li>
</ul>
<p>使用步骤如下:</p>
<h3 id="-fontclass-">第一步:引入项目下面生成的 fontclass 代码:</h3>
<pre><code class="language-html">&lt;link rel="stylesheet" href="./iconfont.css"&gt;
</code></pre>
<h3 id="-">第二步:挑选相应图标并获取类名,应用于页面:</h3>
<pre><code class="language-html">&lt;span class="iconfont icon-xxx"&gt;&lt;/span&gt;
</code></pre>
<blockquote>
<p>"
iconfont" 是你项目下的 font-family。可以通过编辑项目查看默认是 "iconfont"。</p>
</blockquote>
</div>
</div>
<div class="content symbol">
<ul class="icon_lists dib-box">
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-Logo"></use>
</svg>
<div class="name">Logo</div>
<div class="code-name">#icon-Logo</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-wodejiemianqianwang"></use>
</svg>
<div class="name">12</div>
<div class="code-name">#icon-wodejiemianqianwang</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-qunliao"></use>
</svg>
<div class="name">群聊</div>
<div class="code-name">#icon-qunliao</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-guaduandianhua"></use>
</svg>
<div class="name">挂断电话</div>
<div class="code-name">#icon-guaduandianhua</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-dianhua"></use>
</svg>
<div class="name">电话</div>
<div class="code-name">#icon-dianhua</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-aite"></use>
</svg>
<div class="name">艾特</div>
<div class="code-name">#icon-aite</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-xingbie"></use>
</svg>
<div class="name">性别</div>
<div class="code-name">#icon-xingbie</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-a-Frame247"></use>
</svg>
<div class="name">Frame 247</div>
<div class="code-name">#icon-a-Frame247</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-bianji"></use>
</svg>
<div class="name">编辑</div>
<div class="code-name">#icon-bianji</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-zhankai1"></use>
</svg>
<div class="name">展开</div>
<div class="code-name">#icon-zhankai1</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-a-Frame195"></use>
</svg>
<div class="name">16-右</div>
<div class="code-name">#icon-a-Frame195</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-zhankai-1"></use>
</svg>
<div class="name">展开-1</div>
<div class="code-name">#icon-zhankai-1</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-shengcheng"></use>
</svg>
<div class="name">生成</div>
<div class="code-name">#icon-shengcheng</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-fuzhi"></use>
</svg>
<div class="name">复制</div>
<div class="code-name">#icon-fuzhi</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-a-Frame194"></use>
</svg>
<div class="name">16-左</div>
<div class="code-name">#icon-a-Frame194</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-gender-female-line"></use>
</svg>
<div class="name">gender-female-line</div>
<div class="code-name">#icon-gender-female-line</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-gender-male-line"></use>
</svg>
<div class="name">gender-male-line</div>
<div class="code-name">#icon-gender-male-line</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-shuaxin"></use>
</svg>
<div class="name">刷新</div>
<div class="code-name">#icon-shuaxin</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-jiantou"></use>
</svg>
<div class="name">箭头</div>
<div class="code-name">#icon-jiantou</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-guanbi"></use>
</svg>
<div class="name">关闭</div>
<div class="code-name">#icon-guanbi</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-sousuo"></use>
</svg>
<div class="name">搜索</div>
<div class="code-name">#icon-sousuo</div>
</li>
</ul>
<div class="article markdown">
<h2 id="symbol-">Symbol 引用</h2>
<hr>
<p>这是一种全新的使用方式,应该说这才是未来的主流,也是平台目前推荐的用法。相关介绍可以参考这篇<a href="">文章</a>
这种用法其实是做了一个 SVG 的集合,与另外两种相比具有如下特点:</p>
<ul>
<li>支持多色图标了,不再受单色限制。</li>
<li>通过一些技巧,支持像字体那样,通过 <code>font-size</code>, <code>color</code> 来调整样式。</li>
<li>兼容性较差,支持 IE9+,及现代浏览器。</li>
<li>浏览器渲染 SVG 的性能一般,还不如 png。</li>
</ul>
<p>使用步骤如下:</p>
<h3 id="-symbol-">第一步:引入项目下面生成的 symbol 代码:</h3>
<pre><code class="language-html">&lt;script src="./iconfont.js"&gt;&lt;/script&gt;
</code></pre>
<h3 id="-css-">第二步:加入通用 CSS 代码(引入一次就行):</h3>
<pre><code class="language-html">&lt;style&gt;
.icon {
width: 1em;
height: 1em;
vertical-align: -0.15em;
fill: currentColor;
overflow: hidden;
}
&lt;/style&gt;
</code></pre>
<h3 id="-">第三步:挑选相应图标并获取类名,应用于页面:</h3>
<pre><code class="language-html">&lt;svg class="icon" aria-hidden="true"&gt;
&lt;use xlink:href="#icon-xxx"&gt;&lt;/use&gt;
&lt;/svg&gt;
</code></pre>
</div>
</div>
</div>
</div>
<script>
$(document).ready(function () {
$('.tab-container .content:first').show()
$('#tabs li').click(function (e) {
var tabContent = $('.tab-container .content')
var index = $(this).index()
if ($(this).hasClass('active')) {
return
} else {
$('#tabs li').removeClass('active')
$(this).addClass('active')
tabContent.hide().eq(index).fadeIn()
}
})
})
</script>
</body>
</html>

View File

@ -1,102 +0,0 @@
@font-face {
font-family: "iconfont"; /* Project id 5076160 */
src: url('iconfont.eot?t=1766052523135'); /* IE9 */
src: url('iconfont.eot?t=1766052523135#iefix') format('embedded-opentype'), /* IE6-IE8 */
url('iconfont.woff2?t=1766052523135') format('woff2'),
url('iconfont.woff?t=1766052523135') format('woff'),
url('iconfont.ttf?t=1766052523135') format('truetype'),
url('iconfont.svg?t=1766052523135#iconfont') format('svg');
}
.iconfont {
font-family: "iconfont" !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-Logo:before {
content: "\e624";
}
.icon-wodejiemianqianwang:before {
content: "\e622";
}
.icon-qunliao:before {
content: "\e621";
}
.icon-guaduandianhua:before {
content: "\e620";
}
.icon-dianhua:before {
content: "\e61f";
}
.icon-aite:before {
content: "\e61d";
}
.icon-xingbie:before {
content: "\e61e";
}
.icon-a-Frame247:before {
content: "\e61c";
}
.icon-bianji:before {
content: "\e61b";
}
.icon-zhankai1:before {
content: "\e615";
}
.icon-a-Frame195:before {
content: "\e616";
}
.icon-zhankai-1:before {
content: "\e617";
}
.icon-shengcheng:before {
content: "\e618";
}
.icon-fuzhi:before {
content: "\e619";
}
.icon-a-Frame194:before {
content: "\e61a";
}
.icon-gender-female-line:before {
content: "\e614";
}
.icon-gender-male-line:before {
content: "\e613";
}
.icon-shuaxin:before {
content: "\e612";
}
.icon-jiantou:before {
content: "\e610";
}
.icon-guanbi:before {
content: "\e611";
}
.icon-sousuo:before {
content: "\e60d";
}

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@ -1,156 +0,0 @@
{
"id": "5076160",
"name": "spicyxx.ai",
"font_family": "iconfont",
"css_prefix_text": "icon-",
"description": "",
"glyphs": [
{
"icon_id": "46381406",
"name": "Logo",
"font_class": "Logo",
"unicode": "e624",
"unicode_decimal": 58916
},
{
"icon_id": "46339903",
"name": "12",
"font_class": "wodejiemianqianwang",
"unicode": "e622",
"unicode_decimal": 58914
},
{
"icon_id": "46337600",
"name": "群聊",
"font_class": "qunliao",
"unicode": "e621",
"unicode_decimal": 58913
},
{
"icon_id": "46281959",
"name": "挂断电话",
"font_class": "guaduandianhua",
"unicode": "e620",
"unicode_decimal": 58912
},
{
"icon_id": "46281954",
"name": "电话",
"font_class": "dianhua",
"unicode": "e61f",
"unicode_decimal": 58911
},
{
"icon_id": "46261887",
"name": "艾特",
"font_class": "aite",
"unicode": "e61d",
"unicode_decimal": 58909
},
{
"icon_id": "46261886",
"name": "性别",
"font_class": "xingbie",
"unicode": "e61e",
"unicode_decimal": 58910
},
{
"icon_id": "46252831",
"name": "Frame 247",
"font_class": "a-Frame247",
"unicode": "e61c",
"unicode_decimal": 58908
},
{
"icon_id": "46252115",
"name": "编辑",
"font_class": "bianji",
"unicode": "e61b",
"unicode_decimal": 58907
},
{
"icon_id": "46252119",
"name": "展开",
"font_class": "zhankai1",
"unicode": "e615",
"unicode_decimal": 58901
},
{
"icon_id": "46252114",
"name": "16-右",
"font_class": "a-Frame195",
"unicode": "e616",
"unicode_decimal": 58902
},
{
"icon_id": "46252118",
"name": "展开-1",
"font_class": "zhankai-1",
"unicode": "e617",
"unicode_decimal": 58903
},
{
"icon_id": "46252117",
"name": "生成",
"font_class": "shengcheng",
"unicode": "e618",
"unicode_decimal": 58904
},
{
"icon_id": "46252116",
"name": "复制",
"font_class": "fuzhi",
"unicode": "e619",
"unicode_decimal": 58905
},
{
"icon_id": "46252113",
"name": "16-左",
"font_class": "a-Frame194",
"unicode": "e61a",
"unicode_decimal": 58906
},
{
"icon_id": "46234515",
"name": "gender-female-line",
"font_class": "gender-female-line",
"unicode": "e614",
"unicode_decimal": 58900
},
{
"icon_id": "46234139",
"name": "gender-male-line",
"font_class": "gender-male-line",
"unicode": "e613",
"unicode_decimal": 58899
},
{
"icon_id": "46211262",
"name": "刷新",
"font_class": "shuaxin",
"unicode": "e612",
"unicode_decimal": 58898
},
{
"icon_id": "46211223",
"name": "箭头",
"font_class": "jiantou",
"unicode": "e610",
"unicode_decimal": 58896
},
{
"icon_id": "46211222",
"name": "关闭",
"font_class": "guanbi",
"unicode": "e611",
"unicode_decimal": 58897
},
{
"icon_id": "46211227",
"name": "搜索",
"font_class": "sousuo",
"unicode": "e60d",
"unicode_decimal": 58893
}
]
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,11 +1,11 @@
'use client';
import { useMedia } from '@/hooks/tools';
import ChatSidebar from '@/layout/components/ChatSidebar';
import ChatSidebar from '@/layout/BasicLayout/components/ChatSidebar';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useLayoutStore } from '@/stores';
export default function ChatPage() {
const response = useMedia();
const response = useLayoutStore((s) => s.response);
const router = useRouter();
useEffect(() => {

View File

@ -9,7 +9,7 @@ function Background({ imageUrl }: { imageUrl: string }) {
return (
<div className="bg-background-default absolute top-0 right-0 bottom-0 left-0 overflow-hidden">
<div className="absolute top-0 bottom-0 left-1/2 w-[752px] -translate-x-1/2">
<div className="absolute top-0 bottom-0 left-1/2 max-w-[752px] w-full -translate-x-1/2">
{imageUrl && (
<img
src={imageUrl}
@ -21,13 +21,13 @@ function Background({ imageUrl }: { imageUrl: string }) {
/>
)}
<div
className="absolute top-0 bottom-0 left-0 w-120"
className="absolute top-0 bottom-0 left-0 w-[40%]"
style={{
background: 'linear-gradient(to right, rgba(6, 3, 24, 1), transparent)',
}}
/>
<div
className="absolute top-0 bottom-0 right-0 w-120"
className="absolute top-0 bottom-0 right-0 w-[40%]"
style={{
background: 'linear-gradient(to left, rgba(6, 3, 24, 1), transparent)',
}}

View File

@ -31,7 +31,7 @@ export const CharacterAvatorAndName = ({
);
};
function ChatMessageUserHeader() {
const ChatMessageUserHeader = React.memo(() => {
const [isFullIntroduction, setIsFullIntroduction] = useState(false);
const [shouldShowExpandButton, setShouldShowExpandButton] = useState(false);
const textRef = useRef<HTMLDivElement>(null);
@ -50,7 +50,7 @@ function ChatMessageUserHeader() {
}, [character.description]);
return (
<div className="flex flex-col items-center gap-6">
<div className="flex flex-col mt-4 items-center gap-6">
<CharacterAvatorAndName
id={characterId}
name={character.name || '-'}
@ -97,6 +97,6 @@ function ChatMessageUserHeader() {
<div className="txt-body-m text-txt-secondary-normal">Content generated by AI</div>
</div>
);
}
});
export default React.memo(ChatMessageUserHeader);
export default ChatMessageUserHeader;

View File

@ -0,0 +1,12 @@
'use client';
import { MaskForm } from '@/app/(main)/profile/mask/MaskForm';
import { ActiveTabType } from '.';
export default function MaskCreate({ onActiveTab }: { onActiveTab: (tab: ActiveTabType) => void }) {
return (
<div>
<MaskForm onSubmitSuccess={() => onActiveTab('mask')} />
</div>
);
}

View File

@ -0,0 +1,13 @@
'use client';
import MaskList from '@/app/(main)/profile/mask/MaskList';
import { ActiveTabType } from '.';
export default function Personal({ onActiveTab }: { onActiveTab: (tab: ActiveTabType) => void }) {
// const router = useRouter();
return (
<div>
<MaskList selectAble onAdd={() => onActiveTab('mask_create')} />
</div>
);
}

View File

@ -1,308 +0,0 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { z } from 'zod';
import dayjs from 'dayjs';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { Gender } from '@/types/user';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { calculateAge, getDaysInMonth } from '@/lib/utils';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
const currentYear = dayjs().year();
const years = Array.from({ length: currentYear - 1950 + 1 }, (_, i) => `${1950 + i}`);
const months = Array.from({ length: 12 }, (_, i) => `${i + 1}`.padStart(2, '0'));
const monthTexts = Array.from({ length: 12 }, (_, i) => dayjs().month(i).format('MMM'));
const characterFormSchema = z
.object({
nickname: z
.string()
.trim()
.min(1, 'Please Enter nickname')
.min(2, 'Nickname must be between 2 and 20 characters')
.max(20, 'Nickname must be less than 20 characters'),
sex: z.enum(Gender, { message: 'Please select gender' }),
year: z.string().min(1, 'Please select year'),
month: z.string().min(1, 'Please select month'),
day: z.string().min(1, 'Please select day'),
profile: z.string().trim().optional(),
})
.refine(
(data) => {
const age = calculateAge(data.year, data.month, data.day);
return age >= 18;
},
{
message: 'Character age must be at least 18 years old',
path: ['year'],
}
)
.refine(
(data) => {
if (data.profile) {
if (data.profile.trim().length > 300) {
return false;
}
return data.profile.trim().length >= 10;
}
return true;
},
{
message: 'At least 10 characters',
path: ['profile'],
}
);
export default function Personal() {
// 静态数据,模拟从接口获取的数据
const chatSettingData = {
nickname: 'John',
sex: Gender.MALE,
birthday: dayjs('1995-06-15').valueOf(),
whoAmI: 'A creative and passionate developer',
};
const birthday = chatSettingData?.birthday ? dayjs(chatSettingData.birthday) : undefined;
const form = useForm<z.infer<typeof characterFormSchema>>({
resolver: zodResolver(characterFormSchema),
defaultValues: {
nickname: chatSettingData?.nickname || '',
sex: chatSettingData?.sex,
year: birthday?.year().toString() || undefined,
month:
birthday?.month() !== undefined
? (birthday.month() + 1).toString().padStart(2, '0')
: undefined,
day: birthday?.date().toString().padStart(2, '0') || undefined,
profile: chatSettingData?.whoAmI || '',
},
});
const selectedYear = form.watch('year');
const selectedMonth = form.watch('month');
const days = selectedYear && selectedMonth ? getDaysInMonth(selectedYear, selectedMonth) : [];
const genderTexts = [
{
value: Gender.MALE,
label: 'Male',
},
{
value: Gender.FEMALE,
label: 'Female',
},
{
value: Gender.OTHER,
label: 'Other',
},
];
const gender = form.watch('sex');
const genderText = genderTexts.find((text) => text.value === gender)?.label;
return (
<>
<div className="flex flex-col gap-6">
<Form {...form}>
<form className="space-y-8">
<FormField
control={form.control}
name="nickname"
render={({ field }) => (
<FormItem>
<FormLabel>Nickname</FormLabel>
<FormControl>
<Input
placeholder="Enter nickname"
maxLength={20}
error={!!form.formState.errors.nickname}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="">
<div className="txt-label-m text-txt-secondary-normal">Gender</div>
<div className="mt-3">
<div className="bg-surface-element-normal rounded-m txt-body-l text-txt-secondary-disabled flex h-12 items-center px-4 py-3">
{genderText}
</div>
<div className="txt-body-s text-txt-secondary-disabled mt-1">
Please note: gender cannot be changed after setting
</div>
</div>
</div>
<div>
<Label className="txt-label-m mb-3 block">Birthday</Label>
<div className="flex gap-2">
<FormField
control={form.control}
name="year"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Year" />
</SelectTrigger>
<SelectContent>
{years.map((year) => (
<SelectItem key={year} value={year}>
{year}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="month"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Month" />
</SelectTrigger>
<SelectContent>
{months.map((m, index) => (
<SelectItem key={m} value={m}>
{monthTexts[index]}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="day"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Day" />
</SelectTrigger>
<SelectContent>
{days.map((d) => (
<SelectItem key={d} value={d}>
{d}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
</div>
<FormMessage>
{form.formState.errors.year?.message ||
form.formState.errors.month?.message ||
form.formState.errors.day?.message}
</FormMessage>
</div>
<FormField
control={form.control}
name="profile"
render={({ field }) => (
<FormItem>
<FormLabel>
My Persona
<span className="txt-label-m text-txt-secondary-normal">(Optional)</span>
</FormLabel>
<FormControl>
<Textarea
{...field}
maxLength={300}
error={!!form.formState.errors.profile}
placeholder="Set your own persona in CrushLevel"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
{/* <div className="flex gap-3">
<Button variant="tertiary" size="large" className="flex-1" onClick={handleGoBack}>
Cancel
</Button>
<Button
type="submit"
size="large"
className="flex-1"
onClick={form.handleSubmit(onSubmit)}
loading={loading}
>
Save
</Button>
</div> */}
</div>
{/* 确认放弃修改的对话框 */}
{/* <AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Unsaved Edits</AlertDialogTitle>
<AlertDialogDescription>
The edited content will not be saved after exiting. Please confirm whether to continue
exiting?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setShowConfirmDialog(false)}>
Cancel
</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={handleConfirmDiscard}>
Exit
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog> */}
</>
);
}

View File

@ -29,10 +29,10 @@ const ChatProfilePersona = React.memo(({ onActiveTab }: ProfileProps) => {
return (
<div className="flex w-full flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<div className="txt-title-s">My Chat Persona</div>
<div className="txt-title-s">Masked Identity Mode</div>
<div
className="txt-label-m text-primary-variant-normal cursor-pointer"
onClick={() => onActiveTab('personal')}
onClick={() => onActiveTab('mask')}
>
Edit
</div>

View File

@ -1,6 +1,6 @@
'use client';
import Profile from './Profile';
import Personal from './Personal';
import MaskList from './MaskList';
import VoiceActor from './VoiceActor';
import Font from './Font';
import MaxToken from './MaxToken';
@ -19,6 +19,8 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { useStreamChatStore } from '../stream-chat';
import IconFont from '@/components/ui/iconFont';
import MaskCreate from './MaskCreate';
type SettingProps = {
open: boolean;
@ -26,7 +28,8 @@ type SettingProps = {
};
export type ActiveTabType =
| 'profile'
| 'personal'
| 'mask'
| 'mask_create'
| 'history'
| 'voice_actor'
| 'font'
@ -35,7 +38,8 @@ export type ActiveTabType =
| 'model';
const titleMap = {
personal: 'Personal',
mask: 'Masked Identity Mode',
mask_create: 'Create Mask',
history: 'History',
voice_actor: 'Voice Actor',
font: 'Font',
@ -44,6 +48,17 @@ const titleMap = {
model: 'Chat Model',
};
const backMap = {
mask: 'profile',
mask_create: 'mask',
history: 'profile',
voice_actor: 'profile',
font: 'profile',
max_token: 'profile',
background: 'profile',
model: 'profile',
} as const;
export default function SettingDialog({ open, onOpenChange }: SettingProps) {
const [activeTab, setActiveTab] = useState<ActiveTabType>('profile');
const updateUserChatSetting = useStreamChatStore((store) => store.updateUserChatSetting);
@ -65,14 +80,19 @@ export default function SettingDialog({ open, onOpenChange }: SettingProps) {
titleMap[activeTab]
)}
{activeTab !== 'profile' && (
<IconButton variant="tertiary" size="small" onClick={() => setActiveTab('profile')}>
<i className="iconfont-v2 iconv2-jiantou" />
<IconButton
variant="tertiary"
size="small"
onClick={() => setActiveTab(backMap[activeTab])}
>
<IconFont size={24} className="text-white cursor-pointer" type="icon-jiantou" />
</IconButton>
)}
</AlertDialogTitle>
<div className="w-full h-[calc(100vh-160px)] pr-1 mt-4">
{activeTab === 'profile' && <Profile onActiveTab={setActiveTab} />}
{activeTab === 'personal' && <Personal />}
{activeTab === 'mask' && <MaskList onActiveTab={setActiveTab} />}
{activeTab === 'mask_create' && <MaskCreate onActiveTab={setActiveTab} />}
{activeTab === 'voice_actor' && <VoiceActor />}
{activeTab === 'font' && <Font />}
{activeTab === 'max_token' && <MaxToken />}

View File

@ -5,6 +5,7 @@ import { useAsyncFn } from '@/hooks/tools';
import { cn } from '@/lib/utils';
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
import { useState, useRef, useEffect } from 'react';
import IconFont from '@/components/ui/iconFont';
const AuthHeightTextarea = (
props: React.ComponentProps<'textarea'> & {
@ -90,7 +91,9 @@ export default function Input() {
<div></div>
<div className="flex w-full items-end gap-4">
{/* 打电话按钮 */}
<IconButton onClick={() => {}} iconfont="icon-gift-border" />
<IconButton onClick={() => {}}>
<IconFont type="icon-dianhua" size={30} />
</IconButton>
<div className="flex-1 flex items-end gap-2 min-h-12 py-2 px-2 bg-white/15 rounded-3xl">
{/* 语音录制按钮 */}
<IconButton

View File

@ -9,6 +9,7 @@ import { useParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useCharacter } from '@/hooks/services/character';
import Background from './Background';
import IconFont from '@/components/ui/iconFont';
export default function ChatPage() {
const { id } = useParams<{ id: string }>();
@ -34,11 +35,11 @@ export default function ChatPage() {
</div>
<IconButton
onClick={() => setSettingOpen(!settingOpen)}
className="absolute top-2 right-2"
className="absolute z-10 top-2 right-2"
variant="ghost"
size="small"
>
<i className="iconfont-v2 iconv2-zhankai-1" />
<IconFont size={24} className="text-white cursor-pointer" type="icon-zhankai-1" />
</IconButton>
</div>
<SettingDialog open={settingOpen} onOpenChange={setSettingOpen} />

View File

@ -23,6 +23,8 @@ const Character = () => {
<div className="mt-8">
<InfiniteScrollList<any>
items={dataSource}
enableLazyRender
lazyRenderMargin="500px"
columns={(width) => {
const cardWidth = width > 1200 ? 256 : width > 588 ? 200 : width > 375 ? 170 : 150;
return Math.floor(width / cardWidth);

View File

@ -7,6 +7,7 @@ import { useHomeStore } from '../store';
import { useQuery } from '@tanstack/react-query';
import { fetchCharacterTags } from '@/services/editor';
import { useRef } from 'react';
import { useTranslations } from 'next-intl';
const Filter = () => {
const tab = useHomeStore((state) => state.tab);
@ -14,6 +15,7 @@ const Filter = () => {
const ref = useRef<HTMLDivElement>(null);
const selectedTags = useHomeStore((state) => state.selectedTags);
const setSelectedTags = useHomeStore((state) => state.setSelectedTags);
const t = useTranslations('home');
// useEffect(() => {
// const mainContent = document.getElementById('main-content');
@ -48,13 +50,13 @@ const Filter = () => {
const tabs = [
{
label: 'Story',
label: t('story'),
value: 'story',
icon: 'icon-story',
activeIcon: 'icon-story-active',
},
{
label: 'Character',
label: t('character'),
value: 'character',
icon: 'icon-character',
activeIcon: 'icon-character-active',

View File

@ -4,10 +4,10 @@ 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';
import { useLayoutStore } from '@/stores';
const Header = React.memo(() => {
const response = useMedia();
const response = useLayoutStore((s) => s.response);
return (
// <Link href="/crushcoin">

View File

@ -6,11 +6,11 @@ import Story from './components/Story';
import Character from './components/Character';
import Filter from './components/Filter';
import { useHomeStore } from './store';
import { useMedia } from '@/hooks/tools';
import { useLayoutStore } from '@/stores';
const HomePage = () => {
const tab = useHomeStore((state) => state.tab);
const response = useMedia();
const response = useLayoutStore((s) => s.response);
return (
<>

View File

@ -14,6 +14,7 @@ import {
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { IconButton, Button } from '@/components/ui/button';
import { useCurrentUser, useDeleteUser } from '@/hooks/auth';
import ProfileLayout from '@/layout/ProfileLayout';
import { ThirdType } from '@/services/auth';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
@ -25,10 +26,6 @@ const AccountPage = () => {
const [isDisabling, setIsDisabling] = useState(false);
const { mutateAsync: deleteUser } = useDeleteUser();
const handleBack = () => {
router.back();
};
const handleDisableAccount = async () => {
setIsDisabling(true);
try {
@ -56,15 +53,7 @@ const AccountPage = () => {
};
return (
<div className="mx-auto px-4 max-w-[752px] pt-6 pb-6">
{/* 标题栏 */}
<div className="mb-6 flex items-center gap-2">
<IconButton variant="ghost" size="large" onClick={handleBack} className="p-2">
<i className="iconfont icon-arrow-left !text-[16px]" />
</IconButton>
<h1 className="txt-title-l text-txt-primary-normal">Account</h1>
</div>
<ProfileLayout title="Account">
{/* 账户信息容器 */}
<div className="bg-surface-base-normal rounded-2xl p-6">
<div className="space-y-6">
@ -114,7 +103,7 @@ const AccountPage = () => {
</div>
</div>
</div>
</div>
</ProfileLayout>
);
};

View File

@ -1,106 +0,0 @@
import { Tag } from '@/components/ui/tag'
import { cn, formatNumberToKMB } from '@/lib/utils'
import { AIPermission, AiUserBaseListOutput } from '@/services/create'
import Image from 'next/image'
import Link from 'next/link'
const CharacterCard = ({
character,
isHovered,
onHover,
}: {
character: AiUserBaseListOutput
isHovered: boolean
onHover: (hovered: boolean) => void
}) => {
// 根据权限判断是否私密
const isPrivate = character.permission === AIPermission.Private
return (
<Link href={`/@${character.aiId}`}>
<div
className="group flex w-full min-w-[200px] grow basis-0 flex-col gap-3"
onMouseEnter={() => onHover(true)}
onMouseLeave={() => onHover(false)}
>
{/* 角色图片 */}
<div
className="relative overflow-hidden rounded-lg bg-cover bg-center bg-no-repeat pb-[133%]"
style={
{
// backgroundImage: character.homeImageUrl ? `url('${character.homeImageUrl}')` : 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
}
}
>
<Image
src={character.homeImageUrl ?? ''}
alt={character.nickname ?? ''}
fill
className="object-cover object-top"
sizes="100%"
/>
{/* 私密标识 */}
<div className="bg-surface-element-dark-normal absolute top-2 right-2 z-[1] flex h-6 w-6 items-center justify-center rounded-xs backdrop-blur-xs">
<i
className={cn('iconfont !text-[12px]', {
'icon-eye-off': isPrivate,
'icon-eye-on': !isPrivate,
})}
/>
</div>
{/* 底部遮罩层 */}
<div
className={cn(
'absolute right-0 bottom-0 left-0 flex flex-col justify-end bg-gradient-to-b from-transparent to-black px-3 pb-3',
{
'pt-6': !isHovered,
'h-full pt-12': isHovered,
}
)}
>
{/* 描述文字 - hover时显示带过渡动效 */}
<div
className={cn(
'txt-body-m mb-2 line-clamp-6 transform overflow-hidden text-white transition-all duration-300 ease-in-out',
{
'max-h-0 opacity-0': !isHovered,
'h-full max-h-max opacity-100': isHovered,
}
)}
>
{character.introduction || ''}
</div>
{/* 点赞数 - 暂时用固定值实际应该从API获取 */}
<div className="flex h-6 items-center gap-1 px-1 py-0.5">
{/* <img src={likeIcon} alt="点赞" className="w-3 h-3" /> */}
<i className="iconfont icon-Like-fill" />
<span className="text-xs font-medium text-white">
{formatNumberToKMB(character.likedNum ?? 0)}
</span>
</div>
</div>
</div>
{/* 角色信息 */}
<div className="flex flex-col gap-2">
{/* 角色名称 */}
<div className="txt-title-m truncate">{character.nickname}</div>
{/* 标签 */}
<div className="flex flex-wrap gap-1">
{/* 性格标签 */}
{character.characterName && <Tag size="small">{character.characterName}</Tag>}
{/* 标签 */}
{character.tagName && <Tag size="small">{character.tagName}</Tag>}
</div>
</div>
</div>
</Link>
)
}
export default CharacterCard

View File

@ -1,52 +0,0 @@
import { useCurrentUser } from '@/hooks/auth'
import { useGetAICharacterList } from '@/hooks/create'
import useCreatorNavigation from '@/hooks/useCreatorNavigation'
import Image from 'next/image'
import { useSetAtom } from 'jotai'
import { isVipDrawerOpenAtom } from '@/atoms/im'
import { VipType } from '@/services/wallet'
const CharacterCardAdd = () => {
const { routerToCreate } = useCreatorNavigation()
const { data: user } = useCurrentUser()
const { isMember, canCreateAiCount } = user || {}
const { data: characters = [] } = useGetAICharacterList()
const isFull = characters.length >= (canCreateAiCount || 0)
const setIsVipDrawerOpen = useSetAtom(isVipDrawerOpenAtom)
const handleVip = () => {
setIsVipDrawerOpen({ open: true, vipType: VipType.ADD_CREATE_AI })
}
if (isMember && isFull) {
return null
}
if (!isMember && isFull) {
return (
<div className="cursor-pointer" onClick={handleVip}>
<div className="bg-surface-element-normal rounded-m pointer-events-none relative w-full pb-[133%]">
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
<Image src="/icons/vip.svg" alt="VIP" width={24} height={24} />
<span className="bg-gradient-to-r from-[#ff9696] via-[#aa90f9] to-[#8df3e2] bg-clip-text text-base font-medium text-transparent">
Add More Characters
</span>
</div>
</div>
</div>
)
}
return (
<div className="cursor-pointer" onClick={routerToCreate}>
<div className="bg-surface-element-normal rounded-m relative w-full pb-[133%]">
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
<i className="iconfont icon-add text-txt-tertiary-normal !text-[24px]" />
<span className="text-txt-tertiary-normal txt-body-l">Create a Character</span>
</div>
</div>
</div>
)
}
export default CharacterCardAdd

View File

@ -1,14 +0,0 @@
const CharacterCardVipAdd = () => {
return (
<div className="cursor-pointer">
<div className="bg-surface-element-normal rounded-m border-gradient relative w-full border border-dashed pb-[133%]">
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
<i className="iconfont icon-add text-txt-tertiary-normal !text-[24px]" />
<span className="text-txt-tertiary-normal txt-body-l">Add More Character</span>
</div>
</div>
</div>
)
}
export default CharacterCardVipAdd

View File

@ -1,91 +0,0 @@
import { useGetAICharacterList } from '@/hooks/create'
import { useState } from 'react'
import CharacterCard from './CharacterCard'
import CharacterCardAdd from './CharacterCardAdd'
import { useCurrentUser } from '@/hooks/auth'
import { isVipDrawerOpenAtom } from '@/atoms/im'
import { useSetAtom } from 'jotai'
import { VipType } from '@/services/wallet'
const AlbumSkeleton = () => (
<div className="bg-surface-nest-normal relative animate-pulse overflow-hidden rounded-2xl pb-[134%]">
<div className="absolute inset-0">
<div className="h-full w-full rounded-2xl" />
</div>
</div>
)
const CharacterList = () => {
const { data: characters = [], isPending } = useGetAICharacterList()
const { data: user } = useCurrentUser()
const { isMember, canCreateAiCount } = user || {}
const setIsVipDrawerOpen = useSetAtom(isVipDrawerOpenAtom)
const [hoveredCard, setHoveredCard] = useState<number | null>(null)
const handleUnlockMore = () => {
setIsVipDrawerOpen({ open: true, vipType: VipType.ADD_CREATE_AI })
}
const renderList = () => {
if (isPending) {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-3">
{Array.from({ length: 3 }).map((_, index) => (
<AlbumSkeleton key={index} />
))}
</div>
)
}
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-3">
{characters.map((character) => (
<CharacterCard
key={character.aiId}
character={character}
isHovered={hoveredCard === character.aiId}
onHover={(hovered) => setHoveredCard(hovered ? (character.aiId ?? null) : null)}
/>
))}
<CharacterCardAdd />
</div>
)
}
return (
<div className="mt-6 w-full">
{/* 标题和按钮 */}
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="txt-title-m text-white">Characters</h2>
<span className="txt-title-m text-white">
{characters.length}/{canCreateAiCount}
</span>
</div>
{!isMember && (
<div className="relative">
<div
className="cursor-pointer rounded-full p-px"
style={{
background: 'linear-gradient(to right, #ff9696, #aa90f9, #8df3e2)',
}}
onClick={handleUnlockMore}
>
<div className="bg-background-default flex h-8 w-full items-center gap-2 rounded-full px-4">
<img src="/icons/vip.svg" alt="VIP" className="h-4 w-4" />
<span className="txt-label-s bg-gradient-to-r from-[#ff9696] via-[#aa90f9] to-[#8df3e2] bg-clip-text text-transparent">
Unlock More
</span>
</div>
</div>
</div>
)}
</div>
{renderList()}
</div>
)
}
export default CharacterList

View File

@ -14,13 +14,14 @@ import { useState } from 'react';
import { useLayoutStore } from '@/stores';
import { useStreamChatStore } from '../../chat/[id]/stream-chat';
import { useAsyncFn } from '@/hooks/tools';
import IconFont from '@/components/ui/iconFont';
const ProfileDropdownItem = ({
icon,
children,
onClick,
}: {
icon: string;
icon: string | React.ReactNode;
children: React.ReactNode;
onClick?: () => void;
}) => {
@ -30,7 +31,7 @@ const ProfileDropdownItem = ({
className="flex w-full items-center justify-between gap-3 cursor-pointer px-5 py-4 hover:bg-white/5 transition-colors"
>
<div className="flex items-center gap-3">
<i className={`iconfont ${icon} text-xl text-white`} />
{typeof icon === 'string' ? <i className={`iconfont ${icon} text-xl text-white`} /> : icon}
<div className="txt-body-l text-white">{children}</div>
</div>
<i className="iconfont icon-arrow-right-border text-white/60 text-lg" />
@ -61,7 +62,7 @@ const ProfileDropdown = () => {
| {
type: 'item';
label: string;
icon: string;
icon: string | React.ReactNode;
href?: string;
target?: string;
onClick?: () => void;
@ -73,6 +74,12 @@ const ProfileDropdown = () => {
icon: 'icon-icon_order_remark',
href: '/profile/edit',
},
{
type: 'item',
label: 'Masked Identity Mode',
icon: <IconFont type="icon-shezhi" />,
href: '/profile/mask',
},
{
type: 'item',
label: 'Account',

View File

@ -27,6 +27,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { calculateAge } from '@/lib/utils';
import dayjs from 'dayjs';
import ProfileLayout from '@/layout/ProfileLayout';
const schema = z
.object({
@ -196,15 +197,7 @@ const EditPage = () => {
}, [selectedYear, selectedMonth, selectedDay, form]);
return (
<div className="mx-auto px-4 max-w-[752px] pt-6 pb-6">
{/* 标题栏 */}
<div className="mb-6 flex items-center gap-2">
<IconButton variant="ghost" size="large" onClick={handleBack} className="p-2">
<i className="iconfont icon-arrow-left !text-[16px]" />
</IconButton>
<h1 className="txt-title-l text-txt-primary-normal">Edit Profile</h1>
</div>
<ProfileLayout title="Edit Profile">
{/* 表单容器 */}
<div className="bg-surface-base-normal rounded-2xl p-6">
<Form {...form}>
@ -336,7 +329,7 @@ const EditPage = () => {
</form>
</Form>
</div>
</div>
</ProfileLayout>
);
};

View File

@ -0,0 +1,155 @@
'use client';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import * as z from 'zod';
import { Gender } from '@/types/user';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useCurrentUser } from '@/hooks/auth';
import { useAsyncFn } from '@/hooks/tools';
import { Input } from '@/components/ui/input';
import GenderInput from '@/components/features/genderInput';
import { Button } from '@/components/ui/button';
import { InputNumber } from '@/components/ui/inputnumber';
import { Textarea } from '@/components/ui/textarea';
const schema = z.object({
nickname: z
.string()
.trim()
.min(1, 'Nickname is required')
.min(2, 'Nickname must be between 2 and 20 characters'),
gender: z.enum(Gender, { message: 'Please select a gender' }),
age: z.number().min(1, 'Age is required'),
whoAmI: z.string(),
});
type MaskFormProps = {
onSubmitSuccess?: (data: any) => void;
};
export const MaskForm = ({ onSubmitSuccess }: MaskFormProps) => {
const { data: user } = useCurrentUser();
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: {
nickname: user?.nickname || '',
gender: user?.sex,
age: 18,
whoAmI: '',
},
mode: 'onChange',
});
const { run: onSubmitFn, loading } = useAsyncFn(async (data: any) => {
onSubmitSuccess?.(data);
});
const {
formState: { isValid, isDirty },
} = form;
return (
<div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmitFn)} className="space-y-6">
{/* 昵称字段 */}
<FormField
control={form.control}
name="nickname"
render={({ field }) => (
<FormItem>
<FormLabel className="txt-label-m text-txt-primary-normal">Nickname</FormLabel>
<FormControl>
<Input
placeholder="Enter nickname"
maxLength={100}
showCount
error={!!form.formState.errors.nickname}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* 性别字段 */}
<FormField
control={form.control}
name="gender"
render={({ field }) => (
<FormItem>
<FormLabel className="txt-label-m text-txt-primary-normal">Gender</FormLabel>
<FormControl>
<GenderInput value={field.value as Gender} onChange={field.onChange} />
</FormControl>
<p className="txt-body-s text-txt-secondary-normal">
Please note: gender cannot be changed after setting
</p>
<FormMessage />
</FormItem>
)}
/>
{/* AGE字段 */}
<FormField
control={form.control}
name="age"
render={({ field }) => (
<FormItem>
<FormLabel className="txt-label-m text-txt-primary-normal">Age</FormLabel>
<FormControl>
<InputNumber
min={18}
placeholder="Enter age"
error={!!form.formState.errors.age}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* WHO AM I */}
<FormField
control={form.control}
name="whoAmI"
render={({ field }) => (
<FormItem>
<FormLabel className="txt-label-m text-txt-primary-normal">
Who am Ioptional
</FormLabel>
<FormControl>
<Textarea
placeholder="Describe the character characteristics and scene settings of your role"
error={!!form.formState.errors.whoAmI}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* 保存按钮 */}
<div className="flex justify-end">
<Button
type="submit"
className="w-full sm:w-auto"
size="large"
disabled={!isValid || !isDirty}
loading={loading}
>
Save
</Button>
</div>
</form>
</Form>
</div>
);
};

View File

@ -0,0 +1,70 @@
'use client';
import { Gender } from '@/types/user';
import { useCurrentUser } from '@/hooks/auth';
import { useRouter } from 'next/navigation';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
type MaskListProps = {
selectAble?: boolean;
value?: null;
onChange?: (mask: any) => void;
onAdd?: () => void;
};
export default function MaskList(props: MaskListProps) {
const { selectAble, value, onChange, onAdd } = props;
const { data: user } = useCurrentUser();
const router = useRouter();
const masks = [
{
nickname: 'NickName',
gender: 0,
age: 18,
whoAmI: ' whoAmI whoAmI whoAmI whoAmI whoAmI whoAmI whoAmI whoAmI',
},
];
const handleSelect = (mask: any) => {
if (selectAble) {
onChange?.(mask);
} else {
router.push(`/profile/mask/${mask.nickname}`);
}
};
const iconRender = (mask: any) => {
if (selectAble) {
return <Checkbox shape="round" checked />;
} else {
return <i className="iconfont icon-arrow-right-border" />;
}
};
return (
<div className="flex flex-col gap-2">
{masks.map((mask) => (
<div key={mask.nickname} className="rounded-lg overflow-hidden">
<div
onClick={() => handleSelect(mask)}
className="flex bg-white/10 px-4 py-3 items-center justify-between"
>
<span>
{mask.nickname}{mask.gender === Gender.MALE ? 'Male' : 'Female'}{mask.age}
</span>
{iconRender(mask)}
</div>
<div className="bg-white/5 px-4 py-3">{mask.whoAmI}</div>
</div>
))}
{onAdd && (
<div className="pt-2">
<Button variant="primary" className="w-full" onClick={onAdd}>
+ Add new Mask
</Button>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,19 @@
'use client';
import { useCurrentUser } from '@/hooks/auth';
import { MaskForm } from '../MaskForm';
import { IconButton } from '@/components/ui/button';
import { useRouter } from 'next/navigation';
import ProfileLayout from '@/layout/ProfileLayout';
export default function MaskPage() {
const { data: user } = useCurrentUser();
const router = useRouter();
return (
<ProfileLayout title="Edit">
<div className="bg-surface-base-normal rounded-2xl p-6">
<MaskForm onSubmitSuccess={() => router.push(`/profile/mask`)} />
</div>
</ProfileLayout>
);
}

View File

@ -0,0 +1,41 @@
'use client';
import { Button, IconButton } from '@/components/ui/button';
import { useRouter } from 'next/navigation';
import ProfileLayout from '@/layout/ProfileLayout';
import Link from 'next/link';
import IconFont from '@/components/ui/iconFont';
import { useLayoutStore } from '@/stores';
import MaskList from './MaskList';
export default function MaskPage() {
const response = useLayoutStore((s) => s.response);
const router = useRouter();
return (
<ProfileLayout
bottomButton={
<Link href={'/profile/mask/new'} className="w-full">
<Button className="w-full" variant="primary">
+ Add new Mask
</Button>
</Link>
}
rightDom={
response?.isPC && (
<IconFont
onClick={() => router.push('/profile/mask/new')}
size={32}
className="text-white cursor-pointer"
type="icon-tianjia"
/>
)
}
title="Masked Identity Mode"
>
<div className="flex flex-col gap-6 bg-surface-base-normal p-6 rounded-2xl">
<MaskList />
</div>
</ProfileLayout>
);
}

View File

@ -82,8 +82,6 @@ export default function ProfilePage() {
<ProfileFeatureList />
</div>
{/* <CharacterList /> */}
<ProfileDropdown />
</div>
</div>

View File

@ -4,9 +4,9 @@ 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 { Providers } from '@/layout/Providers';
import ProgressBar from '@/layout/Providers/ProgressBar';
import Script from 'next/script';
const poppins = Poppins({
variable: '--font-poppins',
@ -50,11 +50,12 @@ export default async function RootLayout({
<body
className={`${poppins.variable} ${oleoScriptSwashCaps.variable} ${NumDisplay.variable} antialiased`}
>
<DeviceIdProvider>
<Providers>
<ProgressBar>{children}</ProgressBar>
</Providers>
</DeviceIdProvider>
<Script
src="//at.alicdn.com/t/c/font_5076160_m6catzpb7dc.js"
strategy="afterInteractive"
async
/>
<Providers>{children}</Providers>
</body>
</html>
);

View File

@ -1,6 +0,0 @@
import { atom } from 'jotai'
/**
* AI达到上限
*/
export const isCreateAiLimitReachedDialogOpenAtom = atom<boolean>(false)

View File

@ -14,25 +14,7 @@ interface AIStandardCardProps {
const AIStandardCard: React.FC<AIStandardCardProps> = React.memo(
({ character, disableHover = false }) => {
const {
id,
name,
description,
coverImage,
sourceId,
sourceType,
headPortrait,
basicInfo,
exampleDialogue,
note,
firstSentence,
characterStand,
tagId,
greeting,
depth,
tags,
chatTarget,
} = character;
const { id, name, description, coverImage, headPortrait, tags } = character;
const introContainerRef = useRef<HTMLDivElement>(null);
const introTextRef = useRef<HTMLParagraphElement>(null);

View File

@ -1,38 +0,0 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { useAtom } from 'jotai'
import { isCreateAiLimitReachedDialogOpenAtom } from '@/atoms/global'
const CreateReachedLimitDialog = () => {
const [isCreateAiLimitReachedDialogOpen, setIsCreateAiLimitReachedDialogOpen] = useAtom(
isCreateAiLimitReachedDialogOpenAtom
)
return (
<AlertDialog
open={isCreateAiLimitReachedDialogOpen}
onOpenChange={setIsCreateAiLimitReachedDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Warning</AlertDialogTitle>
<AlertDialogDescription>
you have reached the maximum number of AI creations.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction>Got it</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
export default CreateReachedLimitDialog

View File

@ -1,23 +1,24 @@
import * as React from 'react'
import { Chip } from '@/components/ui/chip'
import { Gender } from '@/types/user'
import { cn } from '@/lib/utils'
'use client';
import { Chip } from '@/components/ui/chip';
import { Gender } from '@/types/user';
import { cn } from '@/lib/utils';
const GenderInput = ({
value,
onChange,
disabled = false,
}: {
value: Gender | undefined
onChange: (value: Gender) => void
disabled?: boolean
value: Gender | undefined;
onChange: (value: Gender) => void;
disabled?: boolean;
}) => {
const items = [
{
value: Gender.MALE,
label: 'Male',
className: 'bg-[rgba(78,72,255,1)] border-[rgba(78,72,255,1)] text-white',
className: 'border-[rgba(78,72,255,1)] text-white',
icon: <i className="iconfont icon-male !text-[24px]" aria-hidden="true"></i>,
activeClassName: 'bg-[rgba(78,72,255,1)]',
},
{
value: Gender.FEMALE,
@ -29,29 +30,37 @@ const GenderInput = ({
label: 'Other',
icon: 'Other',
},
]
];
return (
<div className="grid grid-cols-3 gap-2" role="radiogroup" aria-label="Gender">
{items.map((item) => (
<Chip
size="large"
block
key={item.value}
disabled={disabled && value !== item.value}
state={value === item.value ? 'active' : 'inactive'}
className={cn('cursor-pointer', item.className, disabled && 'pointer-events-none')}
onClick={() => !disabled && onChange(item.value)}
role="radio"
aria-checked={value === item.value}
aria-label={item.label}
>
{item.icon}
<span className="sr-only">{item.label}</span>
</Chip>
))}
{items.map((item) => {
const isActive = value === item.value;
return (
<Chip
size="large"
block
key={item.value}
disabled={disabled && value !== item.value}
state={isActive ? 'active' : 'inactive'}
className={cn(
'cursor-pointer',
item.className,
isActive && item.activeClassName,
disabled && 'pointer-events-none'
)}
onClick={() => !disabled && onChange(item.value)}
role="radio"
aria-checked={isActive}
aria-label={item.label}
>
{item.icon}
<span className="sr-only">{item.label}</span>
</Chip>
);
})}
</div>
)
}
);
};
export default GenderInput
export default GenderInput;

View File

@ -0,0 +1,36 @@
'use client';
import React from 'react';
type IconFontProps = {
/** 图标名称,对应 iconfont 中的图标 ID */
type: string;
/** 图标大小,如果不传则继承父元素的 font-size */
size?: number;
/** 图标颜色,默认 currentColor继承父元素颜色 */
color?: string;
/** 额外的 className */
className?: string;
} & React.HTMLAttributes<SVGSVGElement>;
const IconFont = ({
type,
size,
color = 'currentColor',
className = '',
...props
}: IconFontProps) => {
return (
<svg
className={`iconfont-v2 ${className}`}
style={size ? { fontSize: size, color } : { color }}
aria-hidden="true"
width={size}
height={size}
{...props}
>
<use xlinkHref={`#${type}`}></use>
</svg>
);
};
export default IconFont;

View File

@ -1,8 +1,57 @@
import React, { ReactNode, useEffect, useMemo, useRef } from 'react';
import React, { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll';
import { cn } from '@/lib/utils';
import { useSize } from 'ahooks';
/**
*
* 使 IntersectionObserver
*/
interface LazyItemProps {
children: ReactNode;
rootMargin?: string;
placeholder?: ReactNode;
}
function LazyItem({ children, rootMargin = '300px', placeholder }: LazyItemProps) {
const ref = useRef<HTMLDivElement>(null);
const [hasBeenVisible, setHasBeenVisible] = useState(false);
useEffect(() => {
const element = ref.current;
if (!element) return;
// 如果已经渲染过,不需要再观察
if (hasBeenVisible) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setHasBeenVisible(true);
// ✅ 渲染后立即断开观察,释放资源
observer.disconnect();
}
},
{
rootMargin,
threshold: 0,
}
);
observer.observe(element);
return () => {
observer.disconnect();
};
}, [rootMargin, hasBeenVisible]);
return (
<div ref={ref}>
{hasBeenVisible ? children : placeholder || <div className="aspect-[3/4]" />}
</div>
);
}
interface InfiniteScrollListProps<T> {
/**
*
@ -81,6 +130,14 @@ interface InfiniteScrollListProps<T> {
*
*/
enabled?: boolean;
/**
*
*/
enableLazyRender?: boolean;
/**
* px开始渲染
*/
lazyRenderMargin?: string;
}
/**
@ -105,6 +162,8 @@ export function InfiniteScrollList<T>({
onRetry,
threshold = 200,
enabled = true,
enableLazyRender = false,
lazyRenderMargin = '300px',
}: InfiniteScrollListProps<T>) {
const ref = useRef<HTMLDivElement>(null);
const size = useSize(ref);
@ -199,9 +258,26 @@ export function InfiniteScrollList<T>({
<>
{/* 主要内容 */}
<div className={cn('grid', gridColsClass, gapClass, className)}>
{items.map((item, index) => (
<React.Fragment key={getItemKey(item, index)}>{renderItem(item, index)}</React.Fragment>
))}
{items.map((item, index) => {
const key = getItemKey(item, index);
const content = renderItem(item, index);
if (enableLazyRender) {
return (
<LazyItem
key={key}
rootMargin={lazyRenderMargin}
placeholder={
<div className="bg-surface-nest-normal aspect-[3/4] animate-pulse rounded-2xl" />
}
>
{content}
</LazyItem>
);
}
return <React.Fragment key={key}>{content}</React.Fragment>;
})}
</div>
{/* 加载更多触发器 - 只在没有错误时显示 */}
{hasNextPage && !hasError && (

View File

@ -1,14 +1,14 @@
import * as React from 'react'
import * as React from 'react';
import { cn } from '@/lib/utils'
import { cn } from '@/lib/utils';
type InputSize = 'small' | 'large'
type InputSize = 'small' | 'large';
interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> {
prefixIcon?: React.ReactNode
showCount?: boolean
size?: InputSize
error?: boolean
prefixIcon?: React.ReactNode;
showCount?: boolean;
size?: InputSize;
error?: boolean;
}
function Input({
@ -22,16 +22,16 @@ function Input({
size = 'large',
...props
}: InputProps) {
let inputValue = ''
let inputValue = '';
if (typeof value === 'string' || typeof value === 'number') {
inputValue = String(value)
inputValue = String(value);
}
const count = inputValue.length
const count = inputValue.length;
return (
<div
className={cn(
'relative flex w-full items-center',
'relative flex w-full gap-2 items-center',
error && '[&>input]:border-primary-variant-press'
)}
>
@ -66,7 +66,7 @@ function Input({
</span>
)}
</div>
)
);
}
export { Input }
export { Input };

View File

@ -0,0 +1,85 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { useControllableValue } from 'ahooks';
type InputSize = 'small' | 'large';
interface InputNumberProps extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
'size' | 'value' | 'onChange' | 'type'
> {
prefixIcon?: React.ReactNode;
size?: InputSize;
error?: boolean;
value?: number;
onChange?: (value: number) => void;
min?: number;
max?: number;
}
function InputNumber({
className,
prefixIcon,
error = false,
size = 'large',
min,
max,
...props
}: InputNumberProps) {
const [value, onChange] = useControllableValue(props);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const inputValue = e.target.value;
// 验证是否为有效数字
const numValue = parseFloat(inputValue);
if (isNaN(numValue)) {
onChange(inputValue);
return;
}
onChange(numValue);
};
return (
<div
className={cn(
'relative flex w-full gap-2 items-center',
error && '[&>input]:border-primary-variant-press'
)}
>
{prefixIcon && (
<span className="pointer-events-none absolute left-3 flex items-center">{prefixIcon}</span>
)}
<input
type="text"
inputMode="decimal"
data-slot="input"
className={cn(
'placeholder:text-txt-tertiary-normal bg-surface-element-normal rounded-sm',
'selection:bg-surface-element-normal selection:text-txt-primary-normal',
'border border-solid border-transparent',
'hover:border-outline-hover focus-visible:border-primary-variant-press',
'transition-[color,box-shadow] outline-none',
'disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
size === 'small' && 'txt-body-m flex h-8 w-full min-w-0 px-4 py-1',
size === 'large' && 'txt-body-l flex h-12 w-full min-w-0 px-4 py-1',
error && '!border-important-variant-normal',
prefixIcon && 'pl-10',
className
)}
aria-invalid={error}
{...props}
value={value}
onChange={handleChange}
/>
{min !== undefined && (
<span className="text-txt-secondary-normal absolute right-3 text-xs select-none">
{'≥'}
{min}
</span>
)}
</div>
);
}
export { InputNumber };

View File

@ -1,10 +1,11 @@
import * as React from 'react'
import * as React from 'react';
import { cn } from '@/lib/utils'
import { cn } from '@/lib/utils';
import { useControllableValue } from 'ahooks';
interface TextareaProps extends React.ComponentProps<'textarea'> {
showCount?: boolean
error?: boolean
showCount?: boolean;
error?: boolean;
}
function Textarea({
@ -12,14 +13,9 @@ function Textarea({
showCount = false,
error = false,
maxLength,
value,
...props
}: TextareaProps) {
let inputValue = ''
if (typeof value === 'string' || typeof value === 'number') {
inputValue = String(value)
}
const count = inputValue.length
const [value, onChange] = useControllableValue(props);
return (
<div className={cn('relative w-full', error && '[&>textarea]:border-primary-variant-press')}>
@ -38,17 +34,18 @@ function Textarea({
className
)}
maxLength={maxLength}
value={value}
aria-invalid={error}
{...props}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
{showCount && typeof maxLength === 'number' && (
<span className="text-txt-secondary-normal absolute right-4 bottom-3 text-xs select-none">
{count}/{maxLength}
{value.length}/{maxLength}
</span>
)}
</div>
)
);
}
export { Textarea }
export { Textarea };

View File

@ -1,130 +1,12 @@
@font-face {
font-family: 'iconfont-v2'; /* Project id 5076160 - spicyxx.ai */
src:
url('/font-v2/iconfont.woff2?t=1766052523135') format('woff2'),
url('/font-v2/iconfont.woff?t=1766052523135') format('woff'),
url('/font-v2/iconfont.ttf?t=1766052523135') format('truetype');
}
/* iconfont 默认样式 */
.iconfont-v2 {
font-family: 'iconfont-v2' !important;
font-size: 16px;
/* 不设置 font-size让它继承父元素的 font-size */
font-style: normal;
fill: currentColor;
width: 1em;
height: 1em;
vertical-align: -0.15em;
overflow: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Logo */
.iconv2-Logo:before {
content: '\e624';
}
/* 我的界面前往 */
.iconv2-wodejiemianqianwang:before {
content: '\e622';
}
/* 群聊 */
.iconv2-qunliao:before {
content: '\e621';
}
/* 挂断电话 */
.iconv2-guaduandianhua:before {
content: '\e620';
}
/* 电话 */
.iconv2-dianhua:before {
content: '\e61f';
}
/* 艾特 */
.iconv2-aite:before {
content: '\e61d';
}
/* 性别 */
.iconv2-xingbie:before {
content: '\e61e';
}
/* Frame 247 */
.iconv2-a-Frame247:before {
content: '\e61c';
}
/* 编辑 */
.iconv2-bianji:before {
content: '\e61b';
}
/* 展开 */
.iconv2-zhankai1:before {
content: '\e615';
}
/* Frame 195 */
.iconv2-a-Frame195:before {
content: '\e616';
}
/* 展开-1 */
.iconv2-zhankai-1:before {
content: '\e617';
}
/* 生成 */
.iconv2-shengcheng:before {
content: '\e618';
}
/* 复制 */
.iconv2-fuzhi:before {
content: '\e619';
}
/* Frame 194 */
.iconv2-a-Frame194:before {
content: '\e61a';
}
/* 女性性别图标 */
.iconv2-gender-female-line:before {
content: '\e614';
}
/* 男性性别图标 */
.iconv2-gender-male-line:before {
content: '\e613';
}
/* 刷新 */
.iconv2-shuaxin:before {
content: '\e612';
}
/* 箭头 */
.iconv2-jiantou:before {
content: '\e610';
}
/* 关闭 */
.iconv2-guanbi:before {
content: '\e611';
}
/* 搜索 */
.iconv2-sousuo:before {
content: '\e60d';
}
/* 折叠 */
.iconv2-zhedie:before {
content: '\e60e';
}
/* 展开 */
.iconv2-zhankai:before {
content: '\e60f';
}

View File

@ -2,15 +2,7 @@
import { useMemoizedFn } from 'ahooks';
import { useEffect, useState } from 'react';
const ResponsiveConfig = {
xs: 375,
// 从这里开始变为移动端试图
sm: 768,
md: 1024,
lg: 1280,
xl: 1440,
};
export const useMedia = (config: Record<string, number> = ResponsiveConfig) => {
export const useMedia = (config: Record<string, number>) => {
// 追踪客户端是否已挂载
const [mounted, setMounted] = useState(false);
const [response, setResponse] = useState<Record<keyof typeof config, boolean>>();
@ -18,11 +10,9 @@ export const useMedia = (config: Record<string, number> = ResponsiveConfig) => {
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];
@ -35,7 +25,6 @@ export const useMedia = (config: Record<string, number> = ResponsiveConfig) => {
return () => window.removeEventListener('resize', onResize);
}, []);
// 在服务端渲染和客户端首次渲染时返回 undefined避免 hydration 不匹配
if (!mounted) return undefined;
return response;

View File

@ -1,57 +0,0 @@
import { useEffect } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import { useCurrentUser, useToken } from './auth'
import { useSetAtom } from 'jotai'
import { isCreateAiLimitReachedDialogOpenAtom } from '@/atoms/global'
import { VipType } from '@/services/wallet'
import { isVipDrawerOpenAtom } from '@/atoms/im'
const CREATE_CHARACTER_ROUTE = '/create/type'
const useCreatorNavigation = () => {
const router = useRouter()
const pathname = usePathname()
const { data: currentUser } = useCurrentUser()
const { isLogin } = useToken()
const { isMember, canCreateAiCount, createdAiCount } = currentUser || {}
const setIsCreateAiLimitReachedDialogOpen = useSetAtom(isCreateAiLimitReachedDialogOpenAtom)
const setIsVipDrawerOpen = useSetAtom(isVipDrawerOpenAtom)
const loginRedirectHref = `/login?redirect=${encodeURIComponent(CREATE_CHARACTER_ROUTE)}`
useEffect(() => {
// 根据登录状态选择性 prefetch避免缓存重定向响应
if (isLogin) {
router.prefetch(CREATE_CHARACTER_ROUTE)
} else {
router.prefetch(loginRedirectHref)
}
}, [router, loginRedirectHref, isLogin])
const routerToCreate = () => {
// 检查是否登录,如果未登录则跳转到登录页面
if (!isLogin) {
router.push(loginRedirectHref)
return
}
if ((createdAiCount ?? 0) >= (canCreateAiCount ?? 0)) {
if (isMember) {
setIsCreateAiLimitReachedDialogOpen(true)
return
} else {
setIsVipDrawerOpen({ open: true, vipType: VipType.ADD_CREATE_AI })
return
}
}
localStorage.setItem('before_creator_navigation', pathname)
router.push(CREATE_CHARACTER_ROUTE)
}
return {
routerToCreate,
}
}
export default useCreatorNavigation

View File

@ -4,7 +4,7 @@ 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 { Badge } from '../components/ui/badge';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useLayoutStore } from '@/stores';
@ -24,12 +24,6 @@ function Sidebar() {
const pathname = usePathname();
const isSidebarExpanded = useLayoutStore((s) => s.isSidebarExpanded);
const setSidebarExpanded = useLayoutStore((s) => s.setSidebarExpanded);
const setHydrated = useLayoutStore((s) => s.setHydrated);
// 在客户端挂载后,从 localStorage 恢复侧边栏状态
useEffect(() => {
setHydrated();
}, [setHydrated]);
const menuItems: IMenuItem[] = [
{
@ -95,11 +89,7 @@ function Sidebar() {
);
})}
</div>
{/* 分割线 */}
<div className="mx-6 my-4">
<div className="bg-outline-normal h-px" />
</div>
<ChatSidebar />
<ChatSidebar showSeparator />
</div>
</aside>
);

View File

@ -1,47 +1,36 @@
'use client';
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { cn } from '@/lib/utils';
import Image from 'next/image';
import { Button, IconButton } from '../components/ui/button';
import { Button, IconButton } from '@/components/ui/button';
import { useCurrentUser } from '@/hooks/auth';
import { Avatar, AvatarFallback, AvatarImage } from '../components/ui/avatar';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import Link from 'next/link';
import { usePathname, useSearchParams, useRouter } from 'next/navigation';
import { useMedia } from '@/hooks/tools';
import Notice from './components/Notice';
import LocaleSwitch from './components/LocaleSwitch';
import { items } from './BottomBar';
const mobileHidenMenus = ['/profile/edit', '/profile/account', '/character/:id'];
// 将路由模式转换为正则表达式进行匹配
function matchRoutePattern(pathname: string, patterns: string[]): boolean {
return patterns.some((pattern) => {
// 将路由模式中的 :param 转换为正则表达式
const regexPattern = pattern
.replace(/:[^/]+/g, '[^/]+') // 将 :id, :userId 等替换为 [^/]+
.replace(/\//g, '\\/'); // 转义斜杠
const regex = new RegExp(`^${regexPattern}$`);
return regex.test(pathname);
});
}
const blurPages = ['/chat/:id'];
import { getTopbarConfig } from './config';
import IconFont from '@/components/ui/iconFont';
import { useLayoutStore } from '@/stores';
function Topbar() {
const [isBlur, setIsBlur] = useState(false);
const { data: user } = useCurrentUser();
const [isScrollBlur, setIsScrollBlur] = useState(false);
const { data: user, isLoading } = useCurrentUser();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const response = useMedia();
const response = useLayoutStore((s) => s.response);
const searchParamsString = searchParams.toString();
const redirectURL = `${pathname}${searchParamsString ? `?${searchParamsString}` : ''}`;
const loginHref = `/login?redirect=${encodeURIComponent(redirectURL)}`;
// 获取当前路由的 Topbar 配置
const routeConfig = getTopbarConfig(pathname);
useEffect(() => {
function handleScroll(event: Event) {
const dom = event.target as HTMLElement;
setIsBlur(dom.scrollTop > 0);
setIsScrollBlur(dom.scrollTop > 0);
}
const dom = document.getElementById('main-content');
if (dom) {
@ -59,9 +48,6 @@ function Topbar() {
router.prefetch('/login');
} else {
router.prefetch('/profile');
// if (user.cpUserInfo) {
// router.push('/login/fields?redirect=' + encodeURIComponent(redirectURL));
// }
}
}, [user]);
@ -70,9 +56,10 @@ function Topbar() {
if (response.sm || items.some((item) => item.path === pathname)) {
return (
<Link href="/">
<i
className="iconfont-v2 iconv2-Logo"
style={{ fontSize: response.sm ? '50px' : '32px' }}
<IconFont
size={response.sm ? 150 : 100}
className="text-white cursor-pointer"
type="icon-Logo"
/>
</Link>
);
@ -88,14 +75,17 @@ function Topbar() {
};
const rightDomRender = () => {
if (!response || isLoading) return null;
if (!user)
return (
<Link href={loginHref} prefetch>
<Button size="small">{`Login in${response?.sm ? ' / Sign up' : ''}`}</Button>
<Button size="small">Login in</Button>
</Link>
);
return (
<div className="flex items-center">
const userDom = user ? (
<>
<Notice />
<Link href="/profile" prefetch>
<Avatar className="size-8 cursor-pointer">
@ -109,21 +99,37 @@ function Topbar() {
<AvatarFallback>{user.nickname?.slice(0, 1)}</AvatarFallback>
</Avatar>
</Link>
</>
) : (
<Link href={loginHref} prefetch>
<Button size="small">Login in</Button>
</Link>
);
return (
<div className="flex items-center">
<LocaleSwitch />
{userDom}
</div>
);
};
if (response && !response.sm && matchRoutePattern(pathname, mobileHidenMenus)) return null;
// 移动端根据配置决定是否隐藏 Topbar
const isMobile = response && !response.sm;
if (isMobile && routeConfig.hideOnMobile) {
return null;
}
const finalIgBlur = isBlur || matchRoutePattern(pathname, blurPages);
// 计算最终是否需要模糊效果(滚动模糊 或 路由配置模糊)
const shouldBlur = isScrollBlur || routeConfig.enableBlur;
return (
<header
className={cn('flex h-16 w-full items-center justify-between px-4 sm:px-8 transition-all', {
'backdrop-blur-[10px]': finalIgBlur,
'backdrop-blur-[10px]': shouldBlur,
})}
>
{finalIgBlur && <div className="bg-background-default absolute inset-0 opacity-85" />}
{shouldBlur && <div className="bg-background-default absolute inset-0 opacity-85" />}
<div className="relative inset-0 flex w-full items-center justify-between">
{leftDomRender()}
{rightDomRender()}

View File

@ -0,0 +1,132 @@
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 '@/app/(main)/chat/[id]/stream-chat';
import { useLayoutStore } from '@/stores';
import { useParams } from 'next/navigation';
const ChatSidebar = ({
expand,
showSeparator = false,
}: {
expand?: boolean;
showSeparator?: boolean;
}) => {
const isSidebarExpanded = useLayoutStore((s) => s.isSidebarExpanded);
const { id } = useParams<{ id: string }>();
const channels = useStreamChatStore((state) => state.channels);
const [search, setSearch] = useState('');
const [inSearching, setIsSearching] = useState(false);
// 当侧边栏收缩时,取消搜索功能
useEffect(() => {
if (!isSidebarExpanded) {
setIsSearching(false);
setSearch('');
}
}, [isSidebarExpanded]);
const handleCloseSearch = useCallback(() => {
setSearch('');
setIsSearching(false);
}, []);
// 如果有搜索关键词,显示搜索结果
const isShowingSearchResults = search.trim().length > 0;
if (!channels.length && !isShowingSearchResults) {
return <div className="flex-1"></div>;
}
const finalExpand = expand || isSidebarExpanded;
return (
<>
{/* 分割线 */}
{showSeparator && channels.length > 0 && (
<div className="mx-6 my-4">
<div className="bg-outline-normal h-px" />
</div>
)}
<div className="flex min-h-0 flex-1 flex-col px-4">
{/* 聊天标题 */}
<div className="mb-2 flex h-10 items-center justify-between px-2 py-1">
<span className="txt-label-s text-txt-secondary-normal">Chats</span>
{finalExpand && (
<ChatSidebarAction
onSearchClick={() => setIsSearching(true)}
onCancelSearch={handleCloseSearch}
isSearchActive={inSearching}
/>
)}
</div>
{/* 搜索框 - 根据设计稿实现 */}
{inSearching && finalExpand && (
<div className="relative mb-2 flex items-center gap-1 px-2 py-1">
<div className="relative flex-1">
<Input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search"
size="small"
autoFocus
maxLength={50}
prefixIcon={
<i className="iconfont icon-Search text-txt-secondary-normal text-sm" />
}
className="rounded-full"
/>
{isShowingSearchResults && (
<IconButton
onClick={() => setSearch('')}
size="mini"
variant="tertiary"
className="absolute top-1/2 right-3 shrink-0 -translate-y-1/2 transform"
>
<i className="iconfont icon-close" />
</IconButton>
)}
</div>
<IconButton
onClick={handleCloseSearch}
variant="ghost"
size="small"
className="shrink-0"
>
<i className="iconfont icon-close" />
</IconButton>
</div>
)}
{/* 根据搜索状态显示不同内容 */}
{inSearching ? (
isShowingSearchResults ? (
<ChatSearchResults searchKeyword={search} isExpanded={finalExpand} />
) : (
<div className="flex-1" />
)
) : (
<div className="relative min-h-0 flex-1">
<div className="h-full max-w-full space-y-2 overflow-x-hidden overflow-y-auto py-2">
{channels.map((chat) => (
<ChatSidebarItem
key={chat.id}
chanel={chat}
isExpanded={finalExpand}
isSelected={id === chat.id}
/>
))}
</div>
</div>
)}
</div>
</>
);
};
export default ChatSidebar;

View File

@ -0,0 +1,30 @@
'use client';
import { useLocale } from '@/layout/Providers/IntlProvider';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
export default function LocaleSwitch() {
const { locale, setLocale } = useLocale();
return (
<Select value={locale} onValueChange={(value) => setLocale(value as 'zh' | 'en')}>
<SelectTrigger>
<SelectValue placeholder="选择语言" />
</SelectTrigger>
<SelectContent>
<SelectItem value="zh">
<span></span>
</SelectItem>
<SelectItem value="en">
<span>English</span>
</SelectItem>
</SelectContent>
</Select>
);
}

View File

@ -0,0 +1,55 @@
/**
* Topbar
* Topbar
*/
export interface TopbarRouteConfig {
/** 移动端是否隐藏 Topbar */
hideOnMobile?: boolean;
/** 是否启用背景模糊效果 */
enableBlur?: boolean;
}
/**
*
* key: 路由路径模式支持 :id
* value: 对应的行为配置
*/
export const topbarRouteConfigs: Record<string, TopbarRouteConfig> = {
'/profile/edit': { hideOnMobile: true },
'/profile/mask': { hideOnMobile: true },
'/profile/mask/:id': { hideOnMobile: true },
'/profile/account': { hideOnMobile: true },
'/character/:id': { hideOnMobile: true },
'/chat/:id': { enableBlur: true },
};
/**
*
* @param pathname
* @param pattern 支持 :id
* @returns
*/
export function matchRoutePattern(pathname: string, pattern: string): boolean {
// 将路由模式中的 :param 转换为正则表达式
const regexPattern = pattern
.replace(/:[^/]+/g, '[^/]+') // 将 :id, :userId 等替换为 [^/]+
.replace(/\//g, '\\/'); // 转义斜杠
const regex = new RegExp(`^${regexPattern}$`);
return regex.test(pathname);
}
/**
* Topbar
* @param pathname
* @returns
*/
export function getTopbarConfig(pathname: string): TopbarRouteConfig {
for (const [pattern, config] of Object.entries(topbarRouteConfigs)) {
if (matchRoutePattern(pathname, pattern)) {
return config;
}
}
// 返回默认配置
return {};
}

View File

@ -8,10 +8,10 @@ import Topbar from './Topbar';
// 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';
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
import { useCurrentUser } from '@/hooks/auth';
import { useLayoutStore } from '@/stores';
interface ConditionalLayoutProps {
children: React.ReactNode;
@ -35,7 +35,7 @@ export default function ConditionalLayout({ children }: ConditionalLayoutProps)
const pathname = usePathname();
const mainContentRef = useRef<HTMLDivElement>(null);
const prevPathnameRef = useRef<string>(pathname);
const response = useMedia();
const response = useLayoutStore((s) => s.response);
// 初始化聊天
useInitChat();

View File

@ -0,0 +1,36 @@
'use client';
import { IconButton } from '@/components/ui/button';
import { useRouter } from 'next/navigation';
import { useLayoutStore } from '@/stores';
type ProfileLayoutProps = {
children?: React.ReactNode;
title?: string;
rightDom?: React.ReactNode;
bottomButton?: React.ReactNode;
};
export default function ProfileLayout(props: ProfileLayoutProps) {
const { children, title, rightDom, bottomButton } = props;
const router = useRouter();
const response = useLayoutStore((s) => s.response);
return (
<div className="mx-auto px-4 h-full flex flex-col max-w-[752px] pt-6">
{/* 标题栏 */}
<div className="mb-6 flex shrink-0 justify-between items-center">
<div className="flex items-center gap-2">
<IconButton variant="ghost" size="large" onClick={() => router.back()} className="p-2">
<i className="iconfont icon-arrow-left !text-[16px]" />
</IconButton>
<h1 className="txt-title-l text-txt-primary-normal">{title}</h1>
</div>
{rightDom}
</div>
<div className="flex-1">{children}</div>
{bottomButton && response && !response.sm && (
<div className="shrink-0 h-18 flex items-center">{bottomButton}</div>
)}
</div>
);
}

View File

@ -0,0 +1,87 @@
'use client';
import { NextIntlClientProvider } from 'next-intl';
import { useRouter } from 'next/navigation';
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import Cookies from 'js-cookie';
import { useMemoizedFn } from 'ahooks';
import { useLayoutStore } from '@/stores';
type Locale = 'zh' | 'en';
interface LocaleContextType {
locale: Locale;
setLocale: (locale: Locale) => void;
}
const LocaleContext = createContext<LocaleContextType | undefined>(undefined);
export function useLocale() {
const context = useContext(LocaleContext);
if (!context) {
throw new Error('useLocale must be used within IntlProvider');
}
return context;
}
interface IntlProviderProps {
children: ReactNode;
}
function setLocaleToCookie(locale: Locale) {
Cookies.set('locale', locale, { expires: 365, path: '/' });
}
function getLocaleFromCookie(): Locale {
const cookieLocale = Cookies.get('locale') as Locale | undefined;
if (cookieLocale && (cookieLocale === 'zh' || cookieLocale === 'en')) {
return cookieLocale;
}
setLocaleToCookie('en');
return 'en';
}
export function IntlProvider({ children }: IntlProviderProps) {
const [locale, setLocaleState] = useState<Locale>('en');
const [messages, setMessages] = useState<Record<string, any>>();
const router = useRouter();
const setHydrated = useLayoutStore((s) => s.setHydrated);
useEffect(() => {
setHydrated();
}, [setHydrated]);
const loadLocale = useMemoizedFn(async (locale: Locale) => {
// 动态加载, 提升首屏加载速度
const messages = await import(`@/locales/${locale}.ts`);
setMessages(messages.default);
});
useEffect(() => {
const cookieLocale = getLocaleFromCookie();
if (cookieLocale) {
loadLocale(cookieLocale);
setLocaleState(cookieLocale);
}
}, []);
const setLocale = useMemoizedFn((newLocale: Locale) => {
setLocaleState(newLocale);
setLocaleToCookie(newLocale);
loadLocale(newLocale);
router.refresh();
});
if (!messages) return null;
return (
<LocaleContext.Provider value={{ locale, setLocale }}>
<NextIntlClientProvider
locale={locale as any}
messages={messages}
timeZone={Intl.DateTimeFormat().resolvedOptions().timeZone}
>
{children}
</NextIntlClientProvider>
</LocaleContext.Provider>
);
}

View File

@ -1,17 +1,12 @@
'use client'
import { AppProgressProvider as ProgressProvider } from '@bprogress/next'
import { useEffect } from 'react'
'use client';
import { AppProgressProvider as ProgressProvider } from '@bprogress/next';
const ProgressBar = ({ children }: { children: React.ReactNode }) => {
useEffect(() => {
console.log('progress')
}, [])
return (
<ProgressProvider height="4px" color="#E63C8B" options={{ showSpinner: false }} shallowRouting>
{children}
</ProgressProvider>
)
}
);
};
export default ProgressBar
export default ProgressBar;

View File

@ -1,23 +1,16 @@
'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 { 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 {
import { walletKeys } from '@/lib/query-keys';
interface QueryProviderProps {
children: ReactNode;
}
// const ReactQueryDevtoolsProduction = React.lazy(() =>
// import('@tanstack/react-query-devtools/build/modern/production.js').then((d) => ({
// default: d.ReactQueryDevtools,
// }))
// );
const EXPIRED_ERROR_CODES = [
'10050001',
'10050002',
@ -27,7 +20,7 @@ const EXPIRED_ERROR_CODES = [
'10050006',
];
export function Providers({ children }: ProvidersProps) {
export function QueryProvider({ children }: QueryProviderProps) {
const router = useRouter();
// 用于错误去重的引用
const errorTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@ -120,13 +113,6 @@ export function Providers({ children }: ProvidersProps) {
'!bg-surface-base-normal !border-none !px-4 !py-3 !rounded-m !txt-body-m !text-txt-primary-normal',
}}
/>
{/* <ReactQueryDevtools initialIsOpen={true} /> */}
{/* {showDevtools && (
<React.Suspense fallback={null}>
<ReactQueryDevtoolsProduction />
</React.Suspense>
)} */}
</QueryClientProvider>
);
}

View File

@ -0,0 +1,20 @@
import { IntlProvider } from './IntlProvider';
import { QueryProvider } from './QueryProvider';
import { DeviceIdProvider } from './DeviceIdProvider';
import ProgressBar from './ProgressBar';
interface ProvidersProps {
children: React.ReactNode;
}
export function Providers({ children }: ProvidersProps) {
return (
<DeviceIdProvider>
<IntlProvider>
<ProgressBar>
<QueryProvider>{children}</QueryProvider>
</ProgressBar>
</IntlProvider>
</DeviceIdProvider>
);
}

View File

@ -1,112 +0,0 @@
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 '@/app/(main)/chat/[id]/stream-chat';
import { useLayoutStore } from '@/stores';
import { useParams } from 'next/navigation';
const ChatSidebar = ({ expand }: { expand?: boolean }) => {
const isSidebarExpanded = useLayoutStore((s) => s.isSidebarExpanded);
const { id } = useParams<{ id: string }>();
const channels = useStreamChatStore((state) => state.channels);
const [search, setSearch] = useState('');
const [inSearching, setIsSearching] = useState(false);
// console.log('channels', channels);
// 当侧边栏收缩时,取消搜索功能
useEffect(() => {
if (!isSidebarExpanded) {
setIsSearching(false);
setSearch('');
}
}, [isSidebarExpanded]);
const handleCloseSearch = useCallback(() => {
setSearch('');
setIsSearching(false);
}, []);
// 如果有搜索关键词,显示搜索结果
const isShowingSearchResults = search.trim().length > 0;
if (!channels.length && !isShowingSearchResults) {
return <div className="flex-1"></div>;
}
const finalExpand = expand || isSidebarExpanded;
return (
<div className="flex min-h-0 flex-1 flex-col px-4">
{/* 聊天标题 */}
<div className="mb-2 flex h-10 items-center justify-between px-2 py-1">
<span className="txt-label-s text-txt-secondary-normal">Chats</span>
{finalExpand && (
<ChatSidebarAction
onSearchClick={() => setIsSearching(true)}
onCancelSearch={handleCloseSearch}
isSearchActive={inSearching}
/>
)}
</div>
{/* 搜索框 - 根据设计稿实现 */}
{inSearching && finalExpand && (
<div className="relative mb-2 flex items-center gap-1 px-2 py-1">
<div className="relative flex-1">
<Input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search"
size="small"
autoFocus
maxLength={50}
prefixIcon={<i className="iconfont icon-Search text-txt-secondary-normal text-sm" />}
className="rounded-full"
/>
{isShowingSearchResults && (
<IconButton
onClick={() => setSearch('')}
size="mini"
variant="tertiary"
className="absolute top-1/2 right-3 shrink-0 -translate-y-1/2 transform"
>
<i className="iconfont icon-close" />
</IconButton>
)}
</div>
<IconButton onClick={handleCloseSearch} variant="ghost" size="small" className="shrink-0">
<i className="iconfont icon-close" />
</IconButton>
</div>
)}
{/* 根据搜索状态显示不同内容 */}
{inSearching ? (
isShowingSearchResults ? (
<ChatSearchResults searchKeyword={search} isExpanded={finalExpand} />
) : (
<div className="flex-1" />
)
) : (
<div className="relative min-h-0 flex-1">
<div className="h-full max-w-full space-y-2 overflow-x-hidden overflow-y-auto py-2">
{channels.map((chat) => (
<ChatSidebarItem
key={chat.id}
chanel={chat}
isExpanded={finalExpand}
isSelected={id === chat.id}
/>
))}
</div>
</div>
)}
</div>
);
};
export default ChatSidebar;

View File

@ -1,37 +1,36 @@
// Google Identity Services (GIS) 配置
// const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!
const GOOGLE_CLIENT_ID = '606396962663-9pagar3g9vuhovi37vq9jqob6q1gngns.apps.googleusercontent.com'
const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!;
console.log('GOOGLE_CLIENT_ID', GOOGLE_CLIENT_ID);
// Google OAuth scopes
const GOOGLE_SCOPES = [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
].join(' ')
].join(' ');
export interface GoogleUser {
id: string
email: string
verified_email: boolean
name: string
given_name: string
family_name: string
picture?: string
locale?: string
id: string;
email: string;
verified_email: boolean;
name: string;
given_name: string;
family_name: string;
picture?: string;
locale?: string;
}
// Google Identity Services 响应类型
export interface GoogleCredentialResponse {
credential: string // JWT ID token
select_by?: string
clientId?: string
credential: string; // JWT ID token
select_by?: string;
clientId?: string;
}
// Google OAuth Code Response (使用 Code Model)
export interface GoogleCodeResponse {
code: string // Authorization code
scope: string
authuser?: string
prompt?: string
code: string; // Authorization code
scope: string;
authuser?: string;
prompt?: string;
}
// 声明 Google Identity Services 全局对象
@ -40,84 +39,84 @@ declare global {
google?: {
accounts: {
id: {
initialize: (config: GoogleIdConfiguration) => void
prompt: (momentListener?: (notification: PromptMomentNotification) => void) => void
renderButton: (parent: HTMLElement, options: GsiButtonConfiguration) => void
disableAutoSelect: () => void
cancel: () => void
}
initialize: (config: GoogleIdConfiguration) => void;
prompt: (momentListener?: (notification: PromptMomentNotification) => void) => void;
renderButton: (parent: HTMLElement, options: GsiButtonConfiguration) => void;
disableAutoSelect: () => void;
cancel: () => void;
};
oauth2: {
initCodeClient: (config: CodeClientConfig) => CodeClient
initTokenClient: (config: TokenClientConfig) => TokenClient
}
}
}
initCodeClient: (config: CodeClientConfig) => CodeClient;
initTokenClient: (config: TokenClientConfig) => TokenClient;
};
};
};
}
}
interface GoogleIdConfiguration {
client_id: string
callback?: (response: GoogleCredentialResponse) => void
auto_select?: boolean
cancel_on_tap_outside?: boolean
context?: 'signin' | 'signup' | 'use'
ux_mode?: 'popup' | 'redirect'
login_uri?: string
native_callback?: (response: GoogleCredentialResponse) => void
itp_support?: boolean
client_id: string;
callback?: (response: GoogleCredentialResponse) => void;
auto_select?: boolean;
cancel_on_tap_outside?: boolean;
context?: 'signin' | 'signup' | 'use';
ux_mode?: 'popup' | 'redirect';
login_uri?: string;
native_callback?: (response: GoogleCredentialResponse) => void;
itp_support?: boolean;
}
interface GsiButtonConfiguration {
type?: 'standard' | 'icon'
theme?: 'outline' | 'filled_blue' | 'filled_black'
size?: 'large' | 'medium' | 'small'
text?: 'signin_with' | 'signup_with' | 'continue_with' | 'signin'
shape?: 'rectangular' | 'pill' | 'circle' | 'square'
logo_alignment?: 'left' | 'center'
width?: string
locale?: string
type?: 'standard' | 'icon';
theme?: 'outline' | 'filled_blue' | 'filled_black';
size?: 'large' | 'medium' | 'small';
text?: 'signin_with' | 'signup_with' | 'continue_with' | 'signin';
shape?: 'rectangular' | 'pill' | 'circle' | 'square';
logo_alignment?: 'left' | 'center';
width?: string;
locale?: string;
}
interface PromptMomentNotification {
isDisplayMoment: () => boolean
isDisplayed: () => boolean
isNotDisplayed: () => boolean
getNotDisplayedReason: () => string
isSkippedMoment: () => boolean
getSkippedReason: () => string
isDismissedMoment: () => boolean
getDismissedReason: () => string
getMomentType: () => string
isDisplayMoment: () => boolean;
isDisplayed: () => boolean;
isNotDisplayed: () => boolean;
getNotDisplayedReason: () => string;
isSkippedMoment: () => boolean;
getSkippedReason: () => string;
isDismissedMoment: () => boolean;
getDismissedReason: () => string;
getMomentType: () => string;
}
interface CodeClientConfig {
client_id: string
scope: string
callback: (response: GoogleCodeResponse) => void
error_callback?: (error: { type: string; message: string }) => void
ux_mode?: 'popup' | 'redirect'
redirect_uri?: string
state?: string
client_id: string;
scope: string;
callback: (response: GoogleCodeResponse) => void;
error_callback?: (error: { type: string; message: string }) => void;
ux_mode?: 'popup' | 'redirect';
redirect_uri?: string;
state?: string;
}
interface CodeClient {
requestCode: () => void
requestCode: () => void;
}
interface TokenClientConfig {
client_id: string
scope: string
client_id: string;
scope: string;
callback: (response: {
access_token: string
expires_in: number
scope: string
token_type: string
}) => void
error_callback?: (error: { type: string; message: string }) => void
access_token: string;
expires_in: number;
scope: string;
token_type: string;
}) => void;
error_callback?: (error: { type: string; message: string }) => void;
}
interface TokenClient {
requestAccessToken: () => void
requestAccessToken: () => void;
}
export const googleOAuth = {
@ -129,20 +128,20 @@ export const googleOAuth = {
return new Promise((resolve, reject) => {
// 检查是否已加载
if (window.google?.accounts) {
resolve()
return
resolve();
return;
}
// 创建 script 标签
const script = document.createElement('script')
script.src = 'https://accounts.google.com/gsi/client'
script.async = true
script.defer = true
script.onload = () => resolve()
script.onerror = () => reject(new Error('Failed to load Google Identity Services SDK'))
const script = document.createElement('script');
script.src = 'https://accounts.google.com/gsi/client';
script.async = true;
script.defer = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error('Failed to load Google Identity Services SDK'));
document.head.appendChild(script)
})
document.head.appendChild(script);
});
},
// 初始化 Code Client推荐方式获取授权码
@ -151,7 +150,7 @@ export const googleOAuth = {
errorCallback?: (error: any) => void
) => {
if (!window.google?.accounts?.oauth2) {
throw new Error('Google Identity Services SDK not loaded')
throw new Error('Google Identity Services SDK not loaded');
}
return window.google.accounts.oauth2.initCodeClient({
@ -160,21 +159,21 @@ export const googleOAuth = {
ux_mode: 'popup', // 使用 popup 模式,不需要 redirect_uri
callback,
error_callback: errorCallback,
})
});
},
// 初始化 Token Client直接获取 access token
initTokenClient: (
callback: (response: {
access_token: string
expires_in: number
scope: string
token_type: string
access_token: string;
expires_in: number;
scope: string;
token_type: string;
}) => void,
errorCallback?: (error: any) => void
) => {
if (!window.google?.accounts?.oauth2) {
throw new Error('Google Identity Services SDK not loaded')
throw new Error('Google Identity Services SDK not loaded');
}
return window.google.accounts.oauth2.initTokenClient({
@ -182,13 +181,13 @@ export const googleOAuth = {
scope: GOOGLE_SCOPES,
callback,
error_callback: errorCallback,
})
});
},
// 初始化 Google Identity (获取 ID Token - JWT)
initGoogleId: (callback: (response: GoogleCredentialResponse) => void) => {
if (!window.google?.accounts?.id) {
throw new Error('Google Identity Services SDK not loaded')
throw new Error('Google Identity Services SDK not loaded');
}
window.google.accounts.id.initialize({
@ -197,12 +196,12 @@ export const googleOAuth = {
auto_select: false,
cancel_on_tap_outside: true,
ux_mode: 'popup',
})
});
},
// 触发 One Tap 流程
promptOneTap: (callback: (response: GoogleCredentialResponse) => void) => {
googleOAuth.initGoogleId(callback)
googleOAuth.initGoogleId(callback);
if (window.google?.accounts?.id) {
window.google.accounts.id.prompt((notification) => {
@ -210,9 +209,9 @@ export const googleOAuth = {
console.log(
'One Tap not displayed:',
notification.getNotDisplayedReason() || notification.getSkippedReason()
)
);
}
})
});
}
},
@ -224,7 +223,7 @@ export const googleOAuth = {
options?: Partial<GsiButtonConfiguration>
) => {
if (!window.google?.accounts?.id) {
throw new Error('Google Identity Services SDK not loaded')
throw new Error('Google Identity Services SDK not loaded');
}
// 先初始化
@ -233,7 +232,7 @@ export const googleOAuth = {
callback,
auto_select: false,
cancel_on_tap_outside: true,
})
});
// 渲染 Google 标准按钮
window.google.accounts.id.renderButton(parent, {
@ -244,6 +243,6 @@ export const googleOAuth = {
shape: 'rectangular',
logo_alignment: 'left',
...options,
})
});
},
}
};

10
src/locales/en.ts Normal file
View File

@ -0,0 +1,10 @@
export default {
hello: 'Hello',
home: {
character: 'Character',
story: 'Story',
},
chat: {
chats: 'Chats',
},
};

10
src/locales/zh.ts Normal file
View File

@ -0,0 +1,10 @@
export default {
hello: '你好',
home: {
character: '角色',
story: '故事',
},
chat: {
chats: '聊天',
},
};

View File

@ -1,61 +1,64 @@
'use client';
import { create } from 'zustand';
const responsiveConfig = {
xs: 375,
sm: 768,
md: 1024,
lg: 1280,
xl: 1440,
};
type ResponsiveResponse =
| (Record<keyof typeof responsiveConfig, boolean> & { isMobile: boolean; isPC: boolean })
| null;
type LayoutStore = {
isSidebarExpanded: boolean;
setSidebarExpanded: (isSidebarExpanded: boolean) => void;
hydrated: boolean;
setHydrated: () => void;
response: ResponsiveResponse;
initResponse: () => void;
};
// localStorage key
const SIDEBAR_EXPANDED_KEY = 'sidebarExpanded';
export const useLayoutStore = create<LayoutStore>((set) => ({
export const useLayoutStore = create<LayoutStore>((set, get) => ({
// 默认值在服务器端和客户端都是 false确保 Hydration 一致
isSidebarExpanded: false,
hydrated: false,
response: null,
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;
initResponse: () => {
const onResize = () => {
const newResponse = Object.fromEntries(
Object.entries(responsiveConfig).map(([key, value]) => {
if (window.innerWidth > value) {
return [key, true];
}
// 兼容新格式(直接保存布尔值)
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 });
return [key, false];
})
) as Record<keyof typeof responsiveConfig, boolean>;
set({
response: { ...newResponse, isMobile: !newResponse.sm, isPC: newResponse.sm },
});
};
window.addEventListener('resize', onResize);
onResize();
},
setSidebarExpanded: (isSidebarExpanded: boolean) => {
// 更新状态
set({ isSidebarExpanded });
localStorage.setItem(SIDEBAR_EXPANDED_KEY, JSON.stringify(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);
}
}
setHydrated: () => {
const { initResponse, setSidebarExpanded } = get();
const saved = localStorage.getItem(SIDEBAR_EXPANDED_KEY);
initResponse();
setSidebarExpanded(JSON.parse(saved || 'false'));
set({ hydrated: true });
},
}));