Compare commits
2 Commits
ddff5100b6
...
a30ad9dd89
| Author | SHA1 | Date |
|---|---|---|
|
|
a30ad9dd89 | |
|
|
e89f6ce94d |
|
|
@ -1,4 +1,4 @@
|
||||||
import type { NextConfig } from 'next'
|
import type { NextConfig } from 'next';
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
/* config options here */
|
||||||
|
|
@ -26,6 +26,10 @@ const nextConfig: NextConfig = {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
typescript: {
|
||||||
|
// 构建时忽略所有 TypeScript 错误
|
||||||
|
ignoreBuildErrors: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default nextConfig
|
export default nextConfig;
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,24 @@
|
||||||
<div class="content unicode" style="display: block;">
|
<div class="content unicode" style="display: block;">
|
||||||
<ul class="icon_lists dib-box">
|
<ul class="icon_lists dib-box">
|
||||||
|
|
||||||
|
<li class="dib">
|
||||||
|
<span class="icon iconfont"></span>
|
||||||
|
<div class="name">Logo</div>
|
||||||
|
<div class="code-name">&#xe624;</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="dib">
|
||||||
|
<span class="icon iconfont"></span>
|
||||||
|
<div class="name">12</div>
|
||||||
|
<div class="code-name">&#xe622;</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="dib">
|
||||||
|
<span class="icon iconfont"></span>
|
||||||
|
<div class="name">群聊</div>
|
||||||
|
<div class="code-name">&#xe621;</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
<li class="dib">
|
<li class="dib">
|
||||||
<span class="icon iconfont"></span>
|
<span class="icon iconfont"></span>
|
||||||
<div class="name">挂断电话</div>
|
<div class="name">挂断电话</div>
|
||||||
|
|
@ -98,7 +116,7 @@
|
||||||
|
|
||||||
<li class="dib">
|
<li class="dib">
|
||||||
<span class="icon iconfont"></span>
|
<span class="icon iconfont"></span>
|
||||||
<div class="name">Frame 195</div>
|
<div class="name">16-右</div>
|
||||||
<div class="code-name">&#xe616;</div>
|
<div class="code-name">&#xe616;</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|
@ -122,7 +140,7 @@
|
||||||
|
|
||||||
<li class="dib">
|
<li class="dib">
|
||||||
<span class="icon iconfont"></span>
|
<span class="icon iconfont"></span>
|
||||||
<div class="name">Frame 194</div>
|
<div class="name">16-左</div>
|
||||||
<div class="code-name">&#xe61a;</div>
|
<div class="code-name">&#xe61a;</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|
@ -162,18 +180,6 @@
|
||||||
<div class="code-name">&#xe60d;</div>
|
<div class="code-name">&#xe60d;</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li class="dib">
|
|
||||||
<span class="icon iconfont"></span>
|
|
||||||
<div class="name">折叠</div>
|
|
||||||
<div class="code-name">&#xe60e;</div>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li class="dib">
|
|
||||||
<span class="icon iconfont"></span>
|
|
||||||
<div class="name">展开</div>
|
|
||||||
<div class="code-name">&#xe60f;</div>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
</ul>
|
</ul>
|
||||||
<div class="article markdown">
|
<div class="article markdown">
|
||||||
<h2 id="unicode-">Unicode 引用</h2>
|
<h2 id="unicode-">Unicode 引用</h2>
|
||||||
|
|
@ -192,12 +198,12 @@
|
||||||
<pre><code class="language-css"
|
<pre><code class="language-css"
|
||||||
>@font-face {
|
>@font-face {
|
||||||
font-family: 'iconfont';
|
font-family: 'iconfont';
|
||||||
src: url('iconfont.eot?t=1765173841330'); /* IE9 */
|
src: url('iconfont.eot?t=1766052523135'); /* IE9 */
|
||||||
src: url('iconfont.eot?t=1765173841330#iefix') format('embedded-opentype'), /* IE6-IE8 */
|
src: url('iconfont.eot?t=1766052523135#iefix') format('embedded-opentype'), /* IE6-IE8 */
|
||||||
url('iconfont.woff2?t=1765173841330') format('woff2'),
|
url('iconfont.woff2?t=1766052523135') format('woff2'),
|
||||||
url('iconfont.woff?t=1765173841330') format('woff'),
|
url('iconfont.woff?t=1766052523135') format('woff'),
|
||||||
url('iconfont.ttf?t=1765173841330') format('truetype'),
|
url('iconfont.ttf?t=1766052523135') format('truetype'),
|
||||||
url('iconfont.svg?t=1765173841330#iconfont') format('svg');
|
url('iconfont.svg?t=1766052523135#iconfont') format('svg');
|
||||||
}
|
}
|
||||||
</code></pre>
|
</code></pre>
|
||||||
<h3 id="-iconfont-">第二步:定义使用 iconfont 的样式</h3>
|
<h3 id="-iconfont-">第二步:定义使用 iconfont 的样式</h3>
|
||||||
|
|
@ -223,6 +229,33 @@
|
||||||
<div class="content font-class">
|
<div class="content font-class">
|
||||||
<ul class="icon_lists dib-box">
|
<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">
|
<li class="dib">
|
||||||
<span class="icon iconfont icon-guaduandianhua"></span>
|
<span class="icon iconfont icon-guaduandianhua"></span>
|
||||||
<div class="name">
|
<div class="name">
|
||||||
|
|
@ -289,7 +322,7 @@
|
||||||
<li class="dib">
|
<li class="dib">
|
||||||
<span class="icon iconfont icon-a-Frame195"></span>
|
<span class="icon iconfont icon-a-Frame195"></span>
|
||||||
<div class="name">
|
<div class="name">
|
||||||
Frame 195
|
16-右
|
||||||
</div>
|
</div>
|
||||||
<div class="code-name">.icon-a-Frame195
|
<div class="code-name">.icon-a-Frame195
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -325,7 +358,7 @@
|
||||||
<li class="dib">
|
<li class="dib">
|
||||||
<span class="icon iconfont icon-a-Frame194"></span>
|
<span class="icon iconfont icon-a-Frame194"></span>
|
||||||
<div class="name">
|
<div class="name">
|
||||||
Frame 194
|
16-左
|
||||||
</div>
|
</div>
|
||||||
<div class="code-name">.icon-a-Frame194
|
<div class="code-name">.icon-a-Frame194
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -385,24 +418,6 @@
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li class="dib">
|
|
||||||
<span class="icon iconfont icon-zhedie"></span>
|
|
||||||
<div class="name">
|
|
||||||
折叠
|
|
||||||
</div>
|
|
||||||
<div class="code-name">.icon-zhedie
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li class="dib">
|
|
||||||
<span class="icon iconfont icon-zhankai"></span>
|
|
||||||
<div class="name">
|
|
||||||
展开
|
|
||||||
</div>
|
|
||||||
<div class="code-name">.icon-zhankai
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
</ul>
|
</ul>
|
||||||
<div class="article markdown">
|
<div class="article markdown">
|
||||||
<h2 id="font-class-">font-class 引用</h2>
|
<h2 id="font-class-">font-class 引用</h2>
|
||||||
|
|
@ -430,6 +445,30 @@
|
||||||
<div class="content symbol">
|
<div class="content symbol">
|
||||||
<ul class="icon_lists dib-box">
|
<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">
|
<li class="dib">
|
||||||
<svg class="icon svg-icon" aria-hidden="true">
|
<svg class="icon svg-icon" aria-hidden="true">
|
||||||
<use xlink:href="#icon-guaduandianhua"></use>
|
<use xlink:href="#icon-guaduandianhua"></use>
|
||||||
|
|
@ -490,7 +529,7 @@
|
||||||
<svg class="icon svg-icon" aria-hidden="true">
|
<svg class="icon svg-icon" aria-hidden="true">
|
||||||
<use xlink:href="#icon-a-Frame195"></use>
|
<use xlink:href="#icon-a-Frame195"></use>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="name">Frame 195</div>
|
<div class="name">16-右</div>
|
||||||
<div class="code-name">#icon-a-Frame195</div>
|
<div class="code-name">#icon-a-Frame195</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|
@ -522,7 +561,7 @@
|
||||||
<svg class="icon svg-icon" aria-hidden="true">
|
<svg class="icon svg-icon" aria-hidden="true">
|
||||||
<use xlink:href="#icon-a-Frame194"></use>
|
<use xlink:href="#icon-a-Frame194"></use>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="name">Frame 194</div>
|
<div class="name">16-左</div>
|
||||||
<div class="code-name">#icon-a-Frame194</div>
|
<div class="code-name">#icon-a-Frame194</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|
@ -574,22 +613,6 @@
|
||||||
<div class="code-name">#icon-sousuo</div>
|
<div class="code-name">#icon-sousuo</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li class="dib">
|
|
||||||
<svg class="icon svg-icon" aria-hidden="true">
|
|
||||||
<use xlink:href="#icon-zhedie"></use>
|
|
||||||
</svg>
|
|
||||||
<div class="name">折叠</div>
|
|
||||||
<div class="code-name">#icon-zhedie</div>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li class="dib">
|
|
||||||
<svg class="icon svg-icon" aria-hidden="true">
|
|
||||||
<use xlink:href="#icon-zhankai"></use>
|
|
||||||
</svg>
|
|
||||||
<div class="name">展开</div>
|
|
||||||
<div class="code-name">#icon-zhankai</div>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
</ul>
|
</ul>
|
||||||
<div class="article markdown">
|
<div class="article markdown">
|
||||||
<h2 id="symbol-">Symbol 引用</h2>
|
<h2 id="symbol-">Symbol 引用</h2>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "iconfont"; /* Project id 5076160 */
|
font-family: "iconfont"; /* Project id 5076160 */
|
||||||
src: url('iconfont.eot?t=1765173841330'); /* IE9 */
|
src: url('iconfont.eot?t=1766052523135'); /* IE9 */
|
||||||
src: url('iconfont.eot?t=1765173841330#iefix') format('embedded-opentype'), /* IE6-IE8 */
|
src: url('iconfont.eot?t=1766052523135#iefix') format('embedded-opentype'), /* IE6-IE8 */
|
||||||
url('iconfont.woff2?t=1765173841330') format('woff2'),
|
url('iconfont.woff2?t=1766052523135') format('woff2'),
|
||||||
url('iconfont.woff?t=1765173841330') format('woff'),
|
url('iconfont.woff?t=1766052523135') format('woff'),
|
||||||
url('iconfont.ttf?t=1765173841330') format('truetype'),
|
url('iconfont.ttf?t=1766052523135') format('truetype'),
|
||||||
url('iconfont.svg?t=1765173841330#iconfont') format('svg');
|
url('iconfont.svg?t=1766052523135#iconfont') format('svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
.iconfont {
|
.iconfont {
|
||||||
|
|
@ -16,6 +16,18 @@
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-Logo:before {
|
||||||
|
content: "\e624";
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-wodejiemianqianwang:before {
|
||||||
|
content: "\e622";
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-qunliao:before {
|
||||||
|
content: "\e621";
|
||||||
|
}
|
||||||
|
|
||||||
.icon-guaduandianhua:before {
|
.icon-guaduandianhua:before {
|
||||||
content: "\e620";
|
content: "\e620";
|
||||||
}
|
}
|
||||||
|
|
@ -88,11 +100,3 @@
|
||||||
content: "\e60d";
|
content: "\e60d";
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-zhedie:before {
|
|
||||||
content: "\e60e";
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-zhankai:before {
|
|
||||||
content: "\e60f";
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
File diff suppressed because one or more lines are too long
|
|
@ -5,6 +5,27 @@
|
||||||
"css_prefix_text": "icon-",
|
"css_prefix_text": "icon-",
|
||||||
"description": "",
|
"description": "",
|
||||||
"glyphs": [
|
"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",
|
"icon_id": "46281959",
|
||||||
"name": "挂断电话",
|
"name": "挂断电话",
|
||||||
|
|
@ -56,7 +77,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"icon_id": "46252114",
|
"icon_id": "46252114",
|
||||||
"name": "Frame 195",
|
"name": "16-右",
|
||||||
"font_class": "a-Frame195",
|
"font_class": "a-Frame195",
|
||||||
"unicode": "e616",
|
"unicode": "e616",
|
||||||
"unicode_decimal": 58902
|
"unicode_decimal": 58902
|
||||||
|
|
@ -84,7 +105,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"icon_id": "46252113",
|
"icon_id": "46252113",
|
||||||
"name": "Frame 194",
|
"name": "16-左",
|
||||||
"font_class": "a-Frame194",
|
"font_class": "a-Frame194",
|
||||||
"unicode": "e61a",
|
"unicode": "e61a",
|
||||||
"unicode_decimal": 58906
|
"unicode_decimal": 58906
|
||||||
|
|
@ -130,20 +151,6 @@
|
||||||
"font_class": "sousuo",
|
"font_class": "sousuo",
|
||||||
"unicode": "e60d",
|
"unicode": "e60d",
|
||||||
"unicode_decimal": 58893
|
"unicode_decimal": 58893
|
||||||
},
|
|
||||||
{
|
|
||||||
"icon_id": "46211226",
|
|
||||||
"name": "折叠",
|
|
||||||
"font_class": "zhedie",
|
|
||||||
"unicode": "e60e",
|
|
||||||
"unicode_decimal": 58894
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"icon_id": "46211225",
|
|
||||||
"name": "展开",
|
|
||||||
"font_class": "zhankai",
|
|
||||||
"unicode": "e60f",
|
|
||||||
"unicode_decimal": 58895
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 23 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,3 @@
|
||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M24 22C24.5523 22 25 22.4477 25 23C25 23.5523 24.5523 24 24 24H8C7.44772 24 7 23.5523 7 23C7 22.4477 7.44772 22 8 22H24ZM25 16L21 19V17H8C7.44772 17 7 16.5523 7 16C7 15.4477 7.44772 15 8 15H21V13L25 16ZM24 8C24.5523 8 25 8.44772 25 9C25 9.55228 24.5523 10 24 10H8C7.44772 10 7 9.55228 7 9C7 8.44772 7.44772 8 8 8H24Z" fill="#958E9E"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 447 B |
|
|
@ -2,7 +2,7 @@
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useAsyncFn } from '@/hooks/tools';
|
import { useAsyncFn } from '@/hooks/tools';
|
||||||
import { useStreamChatStore } from '@/stores/stream-chat';
|
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
||||||
|
|
||||||
export default function ChatButton({ id }: { id: string }) {
|
export default function ChatButton({ id }: { id: string }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,30 @@
|
||||||
import { AvatarImage, AvatarFallback, Avatar } from '@radix-ui/react-avatar';
|
|
||||||
import ChatButton from './ChatButton';
|
import ChatButton from './ChatButton';
|
||||||
|
import { fetchCharacter } from './service';
|
||||||
|
import { Chip } from '@/components/ui/chip';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
|
||||||
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const user = {
|
const character = await fetchCharacter(id);
|
||||||
name: 'Honey Snow',
|
|
||||||
headImage: 'https://picsum.photos/200/300',
|
|
||||||
nickname: 'Crush',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex px-4 pt-10">
|
<div className="flex px-4 pt-10">
|
||||||
<div className="mx-auto w-full max-w-[752px]">
|
<div className="mx-auto w-full max-w-[752px]">
|
||||||
<header className="flex items-end justify-between">
|
<header className="flex items-end gap-10 justify-between">
|
||||||
<div className="flex gap-6 items-end">
|
<div className="flex gap-6 items-end">
|
||||||
<Avatar className="size-32 rounded-full overflow-hidden cursor-pointer">
|
<Avatar className="size-32">
|
||||||
<AvatarImage className="h-32 w-32 object-cover" src={user?.headImage} />
|
<AvatarImage src={character?.headPortrait} />
|
||||||
<AvatarFallback className="!txt-headline-m">
|
<AvatarFallback>{character?.name?.slice(0, 2)}</AvatarFallback>
|
||||||
{user?.nickname?.slice(0, 1)}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div>
|
<div>
|
||||||
<div className="txt-headline-s">{user?.name}</div>
|
<div className="txt-headline-s">{character?.name}</div>
|
||||||
<div className="text-sm mt-4 text-text-300">{user?.nickname}</div>
|
<div className="flex flex-wrap mt-4 gap-2">
|
||||||
|
{character?.tags?.map((tag: any) => (
|
||||||
|
<Chip className="rounded-xs" key={tag.tagId}>
|
||||||
|
{tag.name}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -31,11 +33,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
|
||||||
</header>
|
</header>
|
||||||
<div className="mt-12 rounded-2xl bg-white/10 p-6">
|
<div className="mt-12 rounded-2xl bg-white/10 p-6">
|
||||||
<div className="txt-headline-s">Introduction</div>
|
<div className="txt-headline-s">Introduction</div>
|
||||||
<div>
|
<div className="mt-4">{character?.description}</div>
|
||||||
She is a new and beautiful teacher and has just graduated. You are the most rebellious
|
|
||||||
student of the whole school workers. She is a new and beautiful teacher and has just
|
|
||||||
graduated. You are the most rebellious student of the whole school workers. In...
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { serverRequest } from '@/lib/server/request';
|
||||||
|
import { cache } from 'react';
|
||||||
|
|
||||||
|
export const fetchCharacter = cache(async (id: string) => {
|
||||||
|
const { data } = await serverRequest('/api/character/detail', {
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
|
@ -1,7 +1,19 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
import { useMedia } from '@/hooks/tools';
|
||||||
import ChatSidebar from '@/layout/components/ChatSidebar';
|
import ChatSidebar from '@/layout/components/ChatSidebar';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
export default function ChatPage() {
|
export default function ChatPage() {
|
||||||
|
const response = useMedia();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (response?.sm) {
|
||||||
|
router.push('/home');
|
||||||
|
}
|
||||||
|
}, [response?.sm]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full">
|
<div className="h-full">
|
||||||
<ChatSidebar expand />
|
<ChatSidebar expand />
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,27 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
import AITextRender from '@/components/ui/text-md';
|
import AITextRender from '@/components/ui/text-md';
|
||||||
|
|
||||||
export default function AIMessage({ data }: { data: any }) {
|
const Loading = () => {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="bg-txt-secondary-normal h-2 w-2 animate-bounce rounded-full [animation-delay:-0.3s]"></div>
|
||||||
|
<div className="bg-txt-secondary-normal h-2 w-2 animate-bounce rounded-full [animation-delay:-0.15s]"></div>
|
||||||
|
<div className="bg-txt-secondary-normal h-2 w-2 animate-bounce rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function AIMessage({ data }: { data: any }) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-8 max-w-[90%]">
|
<div className="mb-8 max-w-[90%]">
|
||||||
<div className="bg-surface-element-normal inline-block rounded-lg p-4 backdrop-blur-2xl">
|
<div className="bg-surface-element-normal inline-block rounded-lg p-4 backdrop-blur-2xl">
|
||||||
<AITextRender text={data.content} />
|
{data.content ? <AITextRender text={data.content} /> : <Loading />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export default React.memo(AIMessage);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { cn } from '@/lib/utils';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { useCharacter } from '@/hooks/services/character';
|
import { useCharacter } from '@/hooks/services/character';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
|
import React from 'react';
|
||||||
// import CrushLevelAvatar from './CrushLevelAvatar'
|
// import CrushLevelAvatar from './CrushLevelAvatar'
|
||||||
|
|
||||||
export const CharacterAvatorAndName = ({ name, avator }: { name: string; avator: string }) => {
|
export const CharacterAvatorAndName = ({ name, avator }: { name: string; avator: string }) => {
|
||||||
|
|
@ -20,25 +21,22 @@ export const CharacterAvatorAndName = ({ name, avator }: { name: string; avator:
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ChatMessageUserHeader = () => {
|
function ChatMessageUserHeader() {
|
||||||
const [isFullIntroduction, setIsFullIntroduction] = useState(false);
|
const [isFullIntroduction, setIsFullIntroduction] = useState(false);
|
||||||
const [shouldShowExpandButton, setShouldShowExpandButton] = useState(false);
|
const [shouldShowExpandButton, setShouldShowExpandButton] = useState(false);
|
||||||
const textRef = useRef<HTMLDivElement>(null);
|
const textRef = useRef<HTMLDivElement>(null);
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const { data: character = {} } = useCharacter(id.split('-')[2]);
|
const { data: character = {} } = useCharacter(id.split('-')[2]);
|
||||||
const { introduction } = {
|
|
||||||
introduction: 'introduction introduction introduction introduction introduction',
|
|
||||||
};
|
|
||||||
|
|
||||||
// 检测文本是否超过三行
|
// 检测文本是否超过三行
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (textRef.current && introduction) {
|
if (textRef.current && character.description) {
|
||||||
// 直接比较滚动高度和可见高度
|
// 直接比较滚动高度和可见高度
|
||||||
// 如果内容的实际高度大于容器的可见高度,说明内容被截断了
|
// 如果内容的实际高度大于容器的可见高度,说明内容被截断了
|
||||||
const isOverflowing = textRef.current.scrollHeight > textRef.current.clientHeight;
|
const isOverflowing = textRef.current.scrollHeight > textRef.current.clientHeight;
|
||||||
setShouldShowExpandButton(isOverflowing);
|
setShouldShowExpandButton(isOverflowing);
|
||||||
}
|
}
|
||||||
}, [introduction]);
|
}, [character.description]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-6">
|
<div className="flex flex-col items-center gap-6">
|
||||||
|
|
@ -55,7 +53,7 @@ const ChatMessageUserHeader = () => {
|
||||||
wordBreak: 'break-word',
|
wordBreak: 'break-word',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{introduction.repeat(10)}
|
{character.description}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
@ -85,6 +83,6 @@ const ChatMessageUserHeader = () => {
|
||||||
<div className="txt-body-m text-txt-secondary-normal">Content generated by AI</div>
|
<div className="txt-body-m text-txt-secondary-normal">Content generated by AI</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default ChatMessageUserHeader;
|
export default React.memo(ChatMessageUserHeader);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { useChatStore } from '../store';
|
|
||||||
import { Button, IconButton } from '@/components/ui/button';
|
import { Button, IconButton } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
|
@ -104,9 +103,7 @@ const BackgroundItemCard = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Background() {
|
export default function Background() {
|
||||||
const setSideBar = useChatStore((store) => store.setSideBar);
|
|
||||||
const [selectId, setSelectId] = useState<number | undefined>(1);
|
const [selectId, setSelectId] = useState<number | undefined>(1);
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
// 静态数据:模拟背景图片列表
|
// 静态数据:模拟背景图片列表
|
||||||
const backgroundList: BackgroundItem[] = [
|
const backgroundList: BackgroundItem[] = [
|
||||||
|
|
@ -148,23 +145,7 @@ export default function Background() {
|
||||||
handleIndexChange,
|
handleIndexChange,
|
||||||
} = useImageViewer();
|
} = useImageViewer();
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
const handleConfirm = async () => {};
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
// TODO: 调用实际的 API
|
|
||||||
// await updateChatBackground({ aiId, backgroundId: selectId })
|
|
||||||
console.log('Selected background:', selectId);
|
|
||||||
|
|
||||||
// 模拟延迟
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
||||||
|
|
||||||
setSideBar('profile');
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleImagePreview = (index: number) => {
|
const handleImagePreview = (index: number) => {
|
||||||
openViewer(backgroundList?.map((item) => item.imgUrl || '') || [], index);
|
openViewer(backgroundList?.map((item) => item.imgUrl || '') || [], index);
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
'use client';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { IconButton } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { useModels } from '@/hooks/services/chat';
|
||||||
|
import { useStreamChatStore } from '../stream-chat';
|
||||||
|
|
||||||
|
export default function ChatModel() {
|
||||||
|
const { data: models = [] } = useModels();
|
||||||
|
const chatSetting = useStreamChatStore((store) => store.chatSetting);
|
||||||
|
const setChatSetting = useStreamChatStore((store) => store.setChatSetting);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex-1 flex flex-col gap-2">
|
||||||
|
{models.map((model: any) => (
|
||||||
|
<div
|
||||||
|
key={model}
|
||||||
|
onClick={() => setChatSetting({ chatModel: model })}
|
||||||
|
className="bg-surface-element-normal cursor-pointer overflow-hidden rounded-lg p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="txt-title-s">{model}</div>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<IconButton iconfont="icon-question" variant="tertiary" size="mini" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-[300px]">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="break-words">
|
||||||
|
Text Message Price: Refers to the cost of chatting with the character via
|
||||||
|
text messages, including sending text, images, or gifts. Charged per
|
||||||
|
message.
|
||||||
|
</p>
|
||||||
|
<p className="break-words">
|
||||||
|
Voice Message Price: Refers to the cost of sending a voice message to the
|
||||||
|
character or playing the character’s voice. Charged per use.
|
||||||
|
</p>
|
||||||
|
<p className="break-words">
|
||||||
|
Voice Call Price: Refers to the cost of having a voice call with the
|
||||||
|
character. Charged per minute.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<Checkbox checked={chatSetting.chatModel === model} shape="round" />
|
||||||
|
</div>
|
||||||
|
<div className="txt-body-m text-txt-secondary-normal mt-1">
|
||||||
|
Role-play a conversation with AI
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-surface-district-normal mt-3 rounded-sm p-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Image src="/icons/diamond.svg" alt="diamond" width={16} height={16} />
|
||||||
|
<span className="txt-label-m text-txt-primary-normal">1/Text Message</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center justify-between gap-1">
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||||
|
<Image src="/icons/diamond.svg" alt="diamond" width={16} height={16} />
|
||||||
|
<span className="txt-label-m text-txt-primary-normal">10/Send or play voice</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center justify-between gap-1">
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||||
|
<Image src="/icons/diamond.svg" alt="diamond" width={16} height={16} />
|
||||||
|
<span className="txt-label-m text-txt-primary-normal">20/min Voice call</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="txt-body-m text-txt-secondary-normal mt-6">Stay tuned for more models</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { useChatStore } from '../store';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
||||||
|
|
||||||
type FontOption = {
|
type FontOption = {
|
||||||
value: number;
|
value: number;
|
||||||
|
|
@ -12,9 +10,9 @@ type FontOption = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Font() {
|
export default function Font() {
|
||||||
const setSideBar = useChatStore((store) => store.setSideBar);
|
const chatSetting = useStreamChatStore((store) => store.chatSetting);
|
||||||
|
const setChatSetting = useStreamChatStore((store) => store.setChatSetting);
|
||||||
|
|
||||||
// 字体大小选项
|
|
||||||
const fontOptions: FontOption[] = [
|
const fontOptions: FontOption[] = [
|
||||||
{ value: 12, label: 'A 12' },
|
{ value: 12, label: 'A 12' },
|
||||||
{ value: 14, label: 'A 14' },
|
{ value: 14, label: 'A 14' },
|
||||||
|
|
@ -23,27 +21,6 @@ export default function Font() {
|
||||||
{ value: 20, label: 'A 20' },
|
{ value: 20, label: 'A 20' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const [selectedFont, setSelectedFont] = useState(16);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
// TODO: 调用实际的 API 保存字体设置
|
|
||||||
// await updateFontSize({ fontSize: selectedFont })
|
|
||||||
console.log('Selected font size:', selectedFont);
|
|
||||||
|
|
||||||
// 模拟延迟
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
||||||
|
|
||||||
setSideBar('profile');
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|
@ -53,9 +30,9 @@ export default function Font() {
|
||||||
key={option.value}
|
key={option.value}
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-surface-element-normal flex h-12 cursor-pointer items-center justify-between rounded-lg px-5 transition-colors',
|
'bg-surface-element-normal flex h-12 cursor-pointer items-center justify-between rounded-lg px-5 transition-colors',
|
||||||
selectedFont === option.value && 'bg-surface-element-hover'
|
chatSetting.font === option.value && 'bg-surface-element-hover'
|
||||||
)}
|
)}
|
||||||
onClick={() => setSelectedFont(option.value)}
|
onClick={() => setChatSetting({ font: option.value })}
|
||||||
>
|
>
|
||||||
<div className="txt-title-s flex items-center gap-2">
|
<div className="txt-title-s flex items-center gap-2">
|
||||||
{option.label}
|
{option.label}
|
||||||
|
|
@ -63,7 +40,7 @@ export default function Font() {
|
||||||
<span className="txt-body-m text-txt-secondary-normal">(standard)</span>
|
<span className="txt-body-m text-txt-secondary-normal">(standard)</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Checkbox shape="round" checked={selectedFont === option.value} />
|
<Checkbox shape="round" checked={chatSetting.font === option.value} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { useChatStore } from '../store';
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
||||||
|
|
||||||
type TokenOption = {
|
type TokenOption = {
|
||||||
value: number;
|
value: number;
|
||||||
|
|
@ -11,9 +11,9 @@ type TokenOption = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function MaxToken() {
|
export default function MaxToken() {
|
||||||
const setSideBar = useChatStore((store) => store.setSideBar);
|
const chatSetting = useStreamChatStore((store) => store.chatSetting);
|
||||||
|
const setChatSetting = useStreamChatStore((store) => store.setChatSetting);
|
||||||
|
|
||||||
// 最大回复数选项
|
|
||||||
const tokenOptions: TokenOption[] = [
|
const tokenOptions: TokenOption[] = [
|
||||||
{ value: 800, label: '800' },
|
{ value: 800, label: '800' },
|
||||||
{ value: 1000, label: '1000' },
|
{ value: 1000, label: '1000' },
|
||||||
|
|
@ -21,27 +21,6 @@ export default function MaxToken() {
|
||||||
{ value: 1500, label: '1500' },
|
{ value: 1500, label: '1500' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const [selectedToken, setSelectedToken] = useState(800);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
// TODO: 调用实际的 API 保存最大回复数设置
|
|
||||||
// await updateMaxToken({ maxToken: selectedToken })
|
|
||||||
console.log('Selected max token:', selectedToken);
|
|
||||||
|
|
||||||
// 模拟延迟
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
||||||
|
|
||||||
setSideBar('profile');
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|
@ -51,12 +30,12 @@ export default function MaxToken() {
|
||||||
key={option.value}
|
key={option.value}
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-surface-element-normal flex h-12 cursor-pointer items-center justify-between rounded-lg px-5 transition-colors',
|
'bg-surface-element-normal flex h-12 cursor-pointer items-center justify-between rounded-lg px-5 transition-colors',
|
||||||
selectedToken === option.value && 'bg-surface-element-hover'
|
chatSetting.maximumReplies === option.value && 'bg-surface-element-hover'
|
||||||
)}
|
)}
|
||||||
onClick={() => setSelectedToken(option.value)}
|
onClick={() => setChatSetting({ maximumReplies: option.value })}
|
||||||
>
|
>
|
||||||
<div className="txt-title-s">{option.label}</div>
|
<div className="txt-title-s">{option.label}</div>
|
||||||
<Checkbox shape="round" checked={selectedToken === option.value} />
|
<Checkbox shape="round" checked={chatSetting.maximumReplies === option.value} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { useChatStore } from '../store';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import {
|
import {
|
||||||
|
|
@ -83,10 +82,6 @@ const characterFormSchema = z
|
||||||
);
|
);
|
||||||
|
|
||||||
export default function Personal() {
|
export default function Personal() {
|
||||||
const setSideBar = useChatStore((store) => store.setSideBar);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
|
||||||
|
|
||||||
// 静态数据,模拟从接口获取的数据
|
// 静态数据,模拟从接口获取的数据
|
||||||
const chatSettingData = {
|
const chatSettingData = {
|
||||||
nickname: 'John',
|
nickname: 'John',
|
||||||
|
|
@ -112,62 +107,6 @@ export default function Personal() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 处理返回的逻辑
|
|
||||||
const handleGoBack = useCallback(() => {
|
|
||||||
if (form.formState.isDirty) {
|
|
||||||
setShowConfirmDialog(true);
|
|
||||||
} else {
|
|
||||||
setSideBar('profile');
|
|
||||||
}
|
|
||||||
}, [form.formState.isDirty, setSideBar]);
|
|
||||||
|
|
||||||
// 确认放弃修改
|
|
||||||
const handleConfirmDiscard = useCallback(() => {
|
|
||||||
form.reset();
|
|
||||||
setShowConfirmDialog(false);
|
|
||||||
setSideBar('profile');
|
|
||||||
}, [form, setSideBar]);
|
|
||||||
|
|
||||||
async function onSubmit(data: z.infer<typeof characterFormSchema>) {
|
|
||||||
if (!form.formState.isDirty) {
|
|
||||||
setSideBar('profile');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
// TODO: 这里应该调用实际的 API
|
|
||||||
// 模拟检查昵称是否存在
|
|
||||||
const isExist = false; // await checkNickname({ nickname: data.nickname.trim() })
|
|
||||||
|
|
||||||
if (isExist) {
|
|
||||||
form.setError('nickname', {
|
|
||||||
message: 'This nickname is already taken',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: 这里应该调用实际的保存 API
|
|
||||||
// await setMyChatSetting({
|
|
||||||
// aiId,
|
|
||||||
// nickname: data.nickname,
|
|
||||||
// birthday: new Date(`${data.year}-${data.month}-${data.day}`).getTime(),
|
|
||||||
// whoAmI: data.profile || '',
|
|
||||||
// })
|
|
||||||
|
|
||||||
console.log('Saved data:', {
|
|
||||||
nickname: data.nickname,
|
|
||||||
birthday: new Date(`${data.year}-${data.month}-${data.day}`).getTime(),
|
|
||||||
whoAmI: data.profile || '',
|
|
||||||
});
|
|
||||||
|
|
||||||
setSideBar('profile');
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedYear = form.watch('year');
|
const selectedYear = form.watch('year');
|
||||||
const selectedMonth = form.watch('month');
|
const selectedMonth = form.watch('month');
|
||||||
const days = selectedYear && selectedMonth ? getDaysInMonth(selectedYear, selectedMonth) : [];
|
const days = selectedYear && selectedMonth ? getDaysInMonth(selectedYear, selectedMonth) : [];
|
||||||
|
|
@ -345,7 +284,7 @@ export default function Personal() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 确认放弃修改的对话框 */}
|
{/* 确认放弃修改的对话框 */}
|
||||||
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
{/* <AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Unsaved Edits</AlertDialogTitle>
|
<AlertDialogTitle>Unsaved Edits</AlertDialogTitle>
|
||||||
|
|
@ -363,7 +302,7 @@ export default function Personal() {
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog> */}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -5,13 +5,16 @@ import { CharacterAvatorAndName } from '../CharacterHeader';
|
||||||
import { Tag } from '@/components/ui/tag';
|
import { Tag } from '@/components/ui/tag';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useChatStore } from '../store';
|
|
||||||
import { Button, IconButton } from '@/components/ui/button';
|
import { Button, IconButton } from '@/components/ui/button';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { useCharacter } from '@/hooks/services/character';
|
import { useCharacter } from '@/hooks/services/character';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { ActiveTabType } from './index';
|
import { ActiveTabType } from './index';
|
||||||
|
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
||||||
|
import { useAsyncFn } from '@/hooks/tools';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useModels } from '@/hooks/services/chat';
|
||||||
|
|
||||||
const genderMap = {
|
const genderMap = {
|
||||||
0: '/icons/male.svg',
|
0: '/icons/male.svg',
|
||||||
|
|
@ -77,48 +80,67 @@ type ProfileProps = {
|
||||||
|
|
||||||
export default function Profile({ onActiveTab }: ProfileProps) {
|
export default function Profile({ onActiveTab }: ProfileProps) {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const { data: character = {} } = useCharacter(id.split('-')[2]);
|
const characterId = id.split('-')[2];
|
||||||
|
const {} = useModels();
|
||||||
|
const router = useRouter();
|
||||||
|
const { data: character = {} } = useCharacter(characterId);
|
||||||
|
const chatSetting = useStreamChatStore((s) => s.chatSetting);
|
||||||
|
const setChatSetting = useStreamChatStore((s) => s.setChatSetting);
|
||||||
|
const createChannel = useStreamChatStore((s) => s.createChannel);
|
||||||
|
const deleteChannel = useStreamChatStore((s) => s.deleteChannel);
|
||||||
|
|
||||||
const preferenceItems: SettingItem[][] = [
|
const { loading: creating, run: createChannelAndPush } = useAsyncFn(async () => {
|
||||||
[
|
const channelId = await createChannel(characterId);
|
||||||
{
|
if (!channelId) return;
|
||||||
onClick: () => onActiveTab('language'),
|
router.push(`/chat/${channelId}`);
|
||||||
label: 'Language',
|
});
|
||||||
value: 'zh-CN',
|
|
||||||
},
|
const { run: deleteChannelAsync, loading: deleting } = useAsyncFn(async () => {
|
||||||
],
|
const { result, newChannels } = await deleteChannel([id]);
|
||||||
];
|
if (result === 'ok') {
|
||||||
|
if (newChannels?.length) {
|
||||||
|
router.push(`/chat/${newChannels[0].id}`);
|
||||||
|
} else {
|
||||||
|
router.push(`/`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const chatSettingItems: SettingItem[][] = [
|
const chatSettingItems: SettingItem[][] = [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
onClick: () => onActiveTab('model'),
|
onClick: () => onActiveTab('model'),
|
||||||
label: 'Chat Model',
|
label: 'Chat Model',
|
||||||
value: 'Role-Playing',
|
value: chatSetting?.chatModel,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onClick: () => null,
|
onClick: () => null,
|
||||||
label: 'Long text',
|
label: 'Long text',
|
||||||
value: <Switch checked={true} />,
|
value: (
|
||||||
|
<Switch
|
||||||
|
onCheckedChange={() => setChatSetting({ longText: chatSetting.longText ? 0 : 1 })}
|
||||||
|
checked={chatSetting.longText === 0}
|
||||||
|
/>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
onClick: () => onActiveTab('max_token'),
|
onClick: () => onActiveTab('max_token'),
|
||||||
label: 'Maximum Replies',
|
label: 'Maximum Replies',
|
||||||
value: '1200',
|
value: String(chatSetting?.maximumReplies),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
onClick: () => onActiveTab('font'),
|
onClick: () => onActiveTab('font'),
|
||||||
label: 'Font',
|
label: 'Font',
|
||||||
value: '17px',
|
value: String(chatSetting?.font),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onClick: () => onActiveTab('background'),
|
onClick: () => onActiveTab('background'),
|
||||||
label: 'Chat Background',
|
label: 'Chat Background',
|
||||||
value: '17px',
|
value: String(chatSetting?.background),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
@ -149,7 +171,7 @@ export default function Profile({ onActiveTab }: ProfileProps) {
|
||||||
)}
|
)}
|
||||||
onClick={item.onClick}
|
onClick={item.onClick}
|
||||||
>
|
>
|
||||||
<div className="txt-label-l flex-1">{item.label}</div>
|
<div className="txt-label-l">{item.label}</div>
|
||||||
<div className="flex min-w-0 flex-1 cursor-pointer items-center justify-end gap-2">
|
<div className="flex min-w-0 flex-1 cursor-pointer items-center justify-end gap-2">
|
||||||
{item.value && (
|
{item.value && (
|
||||||
<div className="txt-body-l flex items-center text-txt-primary-normal truncate">
|
<div className="txt-body-l flex items-center text-txt-primary-normal truncate">
|
||||||
|
|
@ -171,47 +193,39 @@ export default function Profile({ onActiveTab }: ProfileProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{/* <div className="flex justify-between">
|
<div className="flex flex-1 overflow-y-auto pr-1 show-scrollbar flex-col gap-4">
|
||||||
<IconButton variant="ghost" size="small" onClick={() => setSideBar('profile')}>
|
|
||||||
<i className="iconfont-v2 iconv2-jiantou" />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton variant="ghost" size="small" onClick={() => setSideBar('profile')}>
|
|
||||||
<i className="iconfont-v2 iconv2-jiantou" />
|
|
||||||
</IconButton>
|
|
||||||
</div> */}
|
|
||||||
<div className="flex flex-1 overflow-y-auto show-scrollbar flex-col gap-4">
|
|
||||||
<CharacterAvatorAndName avator={character.headPortrait} name={character.name} />
|
<CharacterAvatorAndName avator={character.headPortrait} name={character.name} />
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
<div className="flex w-full flex-col items-start justify-start gap-4">
|
<div className="flex w-full flex-col items-start justify-start gap-4">
|
||||||
<div className="flex w-full flex-wrap items-start justify-center gap-2">
|
<div className="flex w-full flex-wrap items-start justify-center gap-2">
|
||||||
<Tag>
|
{character.tags?.map((tag: any) => (
|
||||||
<Image
|
<Tag key={tag.tagId}>{tag.name}</Tag>
|
||||||
src={genderMap[0 as keyof typeof genderMap]}
|
))}
|
||||||
alt="Gender"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
<div>{getAge(Number(24))}</div>
|
|
||||||
</Tag>
|
|
||||||
<Tag>{'Sensibility'}</Tag>
|
|
||||||
<Tag>{'Romantic'}</Tag>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ChatProfilePersona onActiveTab={onActiveTab} />
|
<ChatProfilePersona onActiveTab={onActiveTab} />
|
||||||
|
|
||||||
{bundleRender('Preference', preferenceItems)}
|
|
||||||
|
|
||||||
{bundleRender('Chat Setting', chatSettingItems)}
|
{bundleRender('Chat Setting', chatSettingItems)}
|
||||||
|
|
||||||
{bundleRender('Voice Setting', voiceSettingItems)}
|
{bundleRender('Voice Setting', voiceSettingItems)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 mt-2">
|
<div className="flex flex-col gap-2 mt-2">
|
||||||
<Button variant="tertiary" className="w-full">
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
className="w-full"
|
||||||
|
loading={deleting}
|
||||||
|
onClick={deleteChannelAsync}
|
||||||
|
>
|
||||||
Detele
|
Detele
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="primary" className="w-full">
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
loading={creating}
|
||||||
|
onClick={createChannelAndPush}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
+ New Chat
|
+ New Chat
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { useChatStore } from '../store';
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||||
|
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
||||||
|
|
||||||
type VoiceGender = 'all' | 'male' | 'female';
|
type VoiceGender = 'all' | 'male' | 'female';
|
||||||
|
|
||||||
|
|
@ -17,8 +17,8 @@ type VoiceActorItem = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function VoiceActor() {
|
export default function VoiceActor() {
|
||||||
const setSideBar = useChatStore((store) => store.setSideBar);
|
const chatSetting = useStreamChatStore((store) => store.chatSetting);
|
||||||
|
const setChatSetting = useStreamChatStore((store) => store.setChatSetting);
|
||||||
// 语音演员列表(静态数据)
|
// 语音演员列表(静态数据)
|
||||||
const voiceActors: VoiceActorItem[] = [
|
const voiceActors: VoiceActorItem[] = [
|
||||||
{
|
{
|
||||||
|
|
@ -74,7 +74,6 @@ export default function VoiceActor() {
|
||||||
|
|
||||||
const [selectedGender, setSelectedGender] = useState<VoiceGender>('all');
|
const [selectedGender, setSelectedGender] = useState<VoiceGender>('all');
|
||||||
const [selectedActorId, setSelectedActorId] = useState(1);
|
const [selectedActorId, setSelectedActorId] = useState(1);
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
// 根据性别过滤演员列表
|
// 根据性别过滤演员列表
|
||||||
const filteredActors = voiceActors.filter((actor) => {
|
const filteredActors = voiceActors.filter((actor) => {
|
||||||
|
|
@ -82,24 +81,6 @@ export default function VoiceActor() {
|
||||||
return actor.gender === selectedGender;
|
return actor.gender === selectedGender;
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
// TODO: 调用实际的 API 保存语音演员设置
|
|
||||||
// await updateVoiceActor({ voiceActorId: selectedActorId })
|
|
||||||
console.log('Selected voice actor:', selectedActorId);
|
|
||||||
|
|
||||||
// 模拟延迟
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
||||||
|
|
||||||
setSideBar('profile');
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{/* Gender Tabs */}
|
{/* Gender Tabs */}
|
||||||
|
|
@ -152,16 +133,6 @@ export default function VoiceActor() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer Buttons */}
|
|
||||||
{/* <div className="mt-6 flex justify-end gap-3">
|
|
||||||
<Button variant="tertiary" size="large" onClick={() => setSideBar('profile')}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button size="large" variant="primary" loading={loading} onClick={handleConfirm}>
|
|
||||||
Select
|
|
||||||
</Button>
|
|
||||||
</div> */}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { useChatStore } from '../store';
|
|
||||||
import Profile from './Profile';
|
import Profile from './Profile';
|
||||||
import Personal from './Personal';
|
import Personal from './Personal';
|
||||||
import VoiceActor from './VoiceActor';
|
import VoiceActor from './VoiceActor';
|
||||||
|
|
@ -7,7 +6,6 @@ import Font from './Font';
|
||||||
import MaxToken from './MaxToken';
|
import MaxToken from './MaxToken';
|
||||||
import Background from './Background';
|
import Background from './Background';
|
||||||
import ChatModel from './ChatModel';
|
import ChatModel from './ChatModel';
|
||||||
import Language from './Language';
|
|
||||||
import { IconButton } from '@/components/ui/button';
|
import { IconButton } from '@/components/ui/button';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
|
@ -20,6 +18,7 @@ import {
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import { useStreamChatStore } from '../stream-chat';
|
||||||
|
|
||||||
type SettingProps = {
|
type SettingProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -33,8 +32,7 @@ export type ActiveTabType =
|
||||||
| 'font'
|
| 'font'
|
||||||
| 'max_token'
|
| 'max_token'
|
||||||
| 'background'
|
| 'background'
|
||||||
| 'model'
|
| 'model';
|
||||||
| 'language';
|
|
||||||
|
|
||||||
const titleMap = {
|
const titleMap = {
|
||||||
personal: 'Personal',
|
personal: 'Personal',
|
||||||
|
|
@ -44,15 +42,22 @@ const titleMap = {
|
||||||
max_token: 'Max Token',
|
max_token: 'Max Token',
|
||||||
background: 'Background',
|
background: 'Background',
|
||||||
model: 'Chat Model',
|
model: 'Chat Model',
|
||||||
language: 'Language',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SettingDialog({ open, onOpenChange }: SettingProps) {
|
export default function SettingDialog({ open, onOpenChange }: SettingProps) {
|
||||||
const [activeTab, setActiveTab] = useState<ActiveTabType>('profile');
|
const [activeTab, setActiveTab] = useState<ActiveTabType>('profile');
|
||||||
|
const updateUserChatSetting = useStreamChatStore((store) => store.updateUserChatSetting);
|
||||||
|
|
||||||
|
const handleChange = (open: boolean) => {
|
||||||
|
if (!open) {
|
||||||
|
updateUserChatSetting();
|
||||||
|
}
|
||||||
|
onOpenChange(open);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
<AlertDialog open={open} onOpenChange={handleChange}>
|
||||||
<AlertDialogContent showCloseButton={activeTab === 'profile'}>
|
<AlertDialogContent className="max-w-[500px]" showCloseButton={activeTab === 'profile'}>
|
||||||
<AlertDialogTitle className="flex justify-between">
|
<AlertDialogTitle className="flex justify-between">
|
||||||
{activeTab === 'profile' ? (
|
{activeTab === 'profile' ? (
|
||||||
<IconButton variant="tertiary" size="small" iconfont="icon-Like" />
|
<IconButton variant="tertiary" size="small" iconfont="icon-Like" />
|
||||||
|
|
@ -73,7 +78,6 @@ export default function SettingDialog({ open, onOpenChange }: SettingProps) {
|
||||||
{activeTab === 'max_token' && <MaxToken />}
|
{activeTab === 'max_token' && <MaxToken />}
|
||||||
{activeTab === 'background' && <Background />}
|
{activeTab === 'background' && <Background />}
|
||||||
{activeTab === 'model' && <ChatModel />}
|
{activeTab === 'model' && <ChatModel />}
|
||||||
{activeTab === 'language' && <Language />}
|
|
||||||
</div>
|
</div>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { ChatSettingType } from '@/app/(main)/chat/[id]/stream-chat';
|
||||||
|
|
||||||
|
interface ChatDrawerStore {
|
||||||
|
setting: ChatSettingType;
|
||||||
|
setSetting: (setting: Partial<ChatSettingType>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useChatDrawerStore = create<ChatDrawerStore>((set, get) => ({
|
||||||
|
setting: {
|
||||||
|
chatModel: '',
|
||||||
|
longText: 0,
|
||||||
|
maximumReplies: 0,
|
||||||
|
background: '',
|
||||||
|
font: 16,
|
||||||
|
voiceActor: '',
|
||||||
|
},
|
||||||
|
setSetting: (value: Partial<ChatSettingType>) => {
|
||||||
|
const { setting } = get();
|
||||||
|
set({ setting: { ...setting, ...value } });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
@ -1,12 +1,18 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { IconButton } from '@/components/ui/button';
|
import { IconButton } from '@/components/ui/button';
|
||||||
|
import { useAsyncFn } from '@/hooks/tools';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useStreamChatStore } from '@/stores/stream-chat';
|
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
const AuthHeightTextarea = (props: React.ComponentProps<'textarea'> & { maxHeight?: number }) => {
|
const AuthHeightTextarea = (
|
||||||
const { maxHeight = 200, className, value, onChange, ...restProps } = props;
|
props: React.ComponentProps<'textarea'> & {
|
||||||
|
maxHeight?: number;
|
||||||
|
onSend?: (text: string) => void;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const { maxHeight = 200, className, value, onChange, onSend, ...restProps } = props;
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
// 调整高度的函数
|
// 调整高度的函数
|
||||||
|
|
@ -28,6 +34,14 @@ const AuthHeightTextarea = (props: React.ComponentProps<'textarea'> & { maxHeigh
|
||||||
textarea.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden';
|
textarea.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
// Enter 发送,Shift+Enter 换行
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault(); // 阻止默认的换行行为
|
||||||
|
onSend?.(value as string);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 监听内容变化,自动调整高度
|
// 监听内容变化,自动调整高度
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
adjustHeight();
|
adjustHeight();
|
||||||
|
|
@ -52,6 +66,7 @@ const AuthHeightTextarea = (props: React.ComponentProps<'textarea'> & { maxHeigh
|
||||||
height: '24px', // 初始高度
|
height: '24px', // 初始高度
|
||||||
overflow: 'hidden', // 初始隐藏滚动条
|
overflow: 'hidden', // 初始隐藏滚动条
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -61,6 +76,14 @@ export default function Input() {
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
const [inputValue, setInputValue] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
const sendMessage = useStreamChatStore((state) => state.sendMessage);
|
const sendMessage = useStreamChatStore((state) => state.sendMessage);
|
||||||
|
const { run: sendMessageAsync, loading } = useAsyncFn(sendMessage);
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
if (inputValue.trim()) {
|
||||||
|
sendMessageAsync(inputValue);
|
||||||
|
setInputValue('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col mb-6 items-end gap-4">
|
<div className="flex flex-col mb-6 items-end gap-4">
|
||||||
|
|
@ -81,6 +104,7 @@ export default function Input() {
|
||||||
maxHeight={70}
|
maxHeight={70}
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(e) => setInputValue(e.target.value)}
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
onSend={handleSend}
|
||||||
className="py-1"
|
className="py-1"
|
||||||
/>
|
/>
|
||||||
{/* 提示词提示按钮 */}
|
{/* 提示词提示按钮 */}
|
||||||
|
|
@ -94,10 +118,10 @@ export default function Input() {
|
||||||
</div>
|
</div>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="large"
|
size="large"
|
||||||
loading={false}
|
loading={loading}
|
||||||
iconfont="icon-icon-send"
|
iconfont="icon-icon-send"
|
||||||
onClick={() => sendMessage(inputValue)}
|
onClick={handleSend}
|
||||||
disabled={false}
|
disabled={!inputValue.trim()}
|
||||||
className="flex-shrink-0"
|
className="flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import CharacterHeader from './CharacterHeader';
|
||||||
import AIMessage from './AIMessage';
|
import AIMessage from './AIMessage';
|
||||||
import UserMessage from './UserMessage';
|
import UserMessage from './UserMessage';
|
||||||
import VirtualList from '@/components/ui/virtual-list';
|
import VirtualList from '@/components/ui/virtual-list';
|
||||||
import { useStreamChatStore } from '@/stores/stream-chat';
|
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
||||||
|
|
||||||
export default function MessageList() {
|
export default function MessageList() {
|
||||||
const messages = useStreamChatStore((s) => s.messages);
|
const messages = useStreamChatStore((s) => s.messages);
|
||||||
|
|
@ -34,7 +34,7 @@ export default function MessageList() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 replative">
|
<div className="flex-1 min-h-0">
|
||||||
<VirtualList className="h-full" data={itemList} itemContent={itemContent} />
|
<VirtualList className="h-full" data={itemList} itemContent={itemContent} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
'use client';
|
|
||||||
import { useChatStore } from '../store';
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
||||||
import { Button, IconButton } from '@/components/ui/button';
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
|
||||||
import Image from 'next/image';
|
|
||||||
|
|
||||||
export default function ChatModel() {
|
|
||||||
const setSideBar = useChatStore((store) => store.setSideBar);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="bg-surface-element-normal overflow-hidden rounded-lg p-4">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="txt-title-s">Role-Playing Model</div>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<IconButton iconfont="icon-question" variant="tertiary" size="mini" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className="max-w-[300px]">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="break-words">
|
|
||||||
Text Message Price: Refers to the cost of chatting with the character via text
|
|
||||||
messages, including sending text, images, or gifts. Charged per message.
|
|
||||||
</p>
|
|
||||||
<p className="break-words">
|
|
||||||
Voice Message Price: Refers to the cost of sending a voice message to the
|
|
||||||
character or playing the character’s voice. Charged per use.
|
|
||||||
</p>
|
|
||||||
<p className="break-words">
|
|
||||||
Voice Call Price: Refers to the cost of having a voice call with the
|
|
||||||
character. Charged per minute.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<Checkbox checked={true} shape="round" />
|
|
||||||
</div>
|
|
||||||
<div className="txt-body-m text-txt-secondary-normal mt-1">
|
|
||||||
Role-play a conversation with AI
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-surface-district-normal mt-3 rounded-sm p-3">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Image src="/icons/diamond.svg" alt="diamond" width={16} height={16} />
|
|
||||||
<span className="txt-label-m text-txt-primary-normal">1/Text Message</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3 flex items-center justify-between gap-1">
|
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
|
||||||
<Image src="/icons/diamond.svg" alt="diamond" width={16} height={16} />
|
|
||||||
<span className="txt-label-m text-txt-primary-normal">10/Send or play voice</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3 flex items-center justify-between gap-1">
|
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
|
||||||
<Image src="/icons/diamond.svg" alt="diamond" width={16} height={16} />
|
|
||||||
<span className="txt-label-m text-txt-primary-normal">20/min Voice call</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="txt-body-m text-txt-secondary-normal mt-6">Stay tuned for more models</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
'use client';
|
|
||||||
import { useChatStore } from '../store';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
export default function Language() {
|
|
||||||
const setSideBar = useChatStore((store) => store.setSideBar);
|
|
||||||
|
|
||||||
const tokenOptions = [
|
|
||||||
{ value: 'zh-CN', label: 'Chinese' },
|
|
||||||
{ value: 'en-US', label: 'English' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const [selectedToken, setSelectedToken] = useState('zh-CN');
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
// TODO: 调用实际的 API 保存最大回复数设置
|
|
||||||
// await updateMaxToken({ maxToken: selectedToken })
|
|
||||||
console.log('Selected max token:', selectedToken);
|
|
||||||
|
|
||||||
// 模拟延迟
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
||||||
|
|
||||||
setSideBar('profile');
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{tokenOptions.map((option) => (
|
|
||||||
<div
|
|
||||||
key={option.value}
|
|
||||||
className={cn(
|
|
||||||
'bg-surface-element-normal flex h-12 cursor-pointer items-center justify-between rounded-lg px-5 transition-colors',
|
|
||||||
selectedToken === option.value && 'bg-surface-element-hover'
|
|
||||||
)}
|
|
||||||
onClick={() => setSelectedToken(option.value)}
|
|
||||||
>
|
|
||||||
<div className="txt-title-s">{option.label}</div>
|
|
||||||
<Checkbox shape="round" checked={selectedToken === option.value} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
'use client';
|
'use client';
|
||||||
export default function UserMessage({ data }: { data: any }) {
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
function UserMessage({ data }: { data: any }) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-8 flex justify-end">
|
<div className="mb-8 flex justify-end">
|
||||||
<div className="bg-primary-normal/20 inline-block max-w-[90%] rounded-lg p-4 backdrop-blur-2xl">
|
<div className="bg-primary-normal/20 inline-block max-w-[90%] rounded-lg p-4 backdrop-blur-2xl">
|
||||||
|
|
@ -8,3 +11,4 @@ export default function UserMessage({ data }: { data: any }) {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export default React.memo(UserMessage);
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
import { IconButton } from '@/components/ui/button';
|
import { IconButton } from '@/components/ui/button';
|
||||||
import Input from './Input';
|
import Input from './Input';
|
||||||
import MessageList from './MessageList';
|
import MessageList from './MessageList';
|
||||||
import SettingDialog from './Sider';
|
import SettingDialog from './Drawer';
|
||||||
import { useStreamChatStore } from '@/stores/stream-chat';
|
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
|
@ -29,11 +29,11 @@ export default function ChatPage() {
|
||||||
</div>
|
</div>
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => setSettingOpen(!settingOpen)}
|
onClick={() => setSettingOpen(!settingOpen)}
|
||||||
className="absolute top-1 right-1"
|
className="absolute top-2 right-2"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="small"
|
size="small"
|
||||||
>
|
>
|
||||||
<i className="iconfont-v2 iconv2-zhedie" />
|
<i className="iconfont-v2 iconv2-zhankai-1" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
<SettingDialog open={settingOpen} onOpenChange={setSettingOpen} />
|
<SettingDialog open={settingOpen} onOpenChange={setSettingOpen} />
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import { create } from 'zustand';
|
|
||||||
|
|
||||||
type SideBar =
|
|
||||||
| 'profile'
|
|
||||||
| 'personal'
|
|
||||||
| 'history'
|
|
||||||
| 'voice_actor'
|
|
||||||
| 'font'
|
|
||||||
| 'max_token'
|
|
||||||
| 'background'
|
|
||||||
| 'model'
|
|
||||||
| 'language';
|
|
||||||
interface ChatStore {
|
|
||||||
isSidebarOpen: boolean;
|
|
||||||
setIsSidebarOpen: (isSidebarOpen: boolean) => void;
|
|
||||||
sideBar: SideBar;
|
|
||||||
setSideBar: (sideBar: SideBar) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useChatStore = create<ChatStore>((set) => ({
|
|
||||||
isSidebarOpen: false,
|
|
||||||
setIsSidebarOpen: (isSidebarOpen: boolean) => set({ isSidebarOpen }),
|
|
||||||
sideBar: 'profile',
|
|
||||||
setSideBar: (sideBar: SideBar) => set({ sideBar }),
|
|
||||||
}));
|
|
||||||
|
|
@ -1,8 +1,15 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { Channel, StreamChat } from 'stream-chat';
|
import { Channel, StreamChat } from 'stream-chat';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { getUserToken, createChannel } from '@/services/editor';
|
import {
|
||||||
|
getUserToken,
|
||||||
|
createChannel,
|
||||||
|
deleteChannel,
|
||||||
|
fetchUserChatSetting,
|
||||||
|
updateUserChatSetting,
|
||||||
|
} from '@/services/chat';
|
||||||
import { parseSSEStream, parseData } from '@/utils/streamParser';
|
import { parseSSEStream, parseData } from '@/utils/streamParser';
|
||||||
|
import { protect } from '@/lib/protect';
|
||||||
|
|
||||||
type Message = {
|
type Message = {
|
||||||
key: string;
|
key: string;
|
||||||
|
|
@ -10,23 +17,42 @@ type Message = {
|
||||||
content: string;
|
content: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ChatSettingType = {
|
||||||
|
chatModel: string;
|
||||||
|
longText: 0 | 1;
|
||||||
|
maximumReplies: number;
|
||||||
|
background: string;
|
||||||
|
font: number;
|
||||||
|
voiceActor: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UserType = {
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
};
|
||||||
|
|
||||||
interface StreamChatStore {
|
interface StreamChatStore {
|
||||||
client: StreamChat | null;
|
client: StreamChat | null;
|
||||||
user: {
|
user: UserType;
|
||||||
userId: string;
|
chatSetting: ChatSettingType;
|
||||||
userName: string;
|
|
||||||
};
|
|
||||||
// 连接 StreamChat 客户端
|
// 连接 StreamChat 客户端
|
||||||
connect: (user: any) => Promise<void>;
|
connect: (user: any) => Promise<void>;
|
||||||
// 频道
|
// 频道
|
||||||
channels: Channel[];
|
channels: Channel[];
|
||||||
currentChannel: Channel | null;
|
currentChannel: Channel | null;
|
||||||
|
|
||||||
|
// 用户聊天设置管理
|
||||||
|
setChatSetting: (chatSetting: any) => void;
|
||||||
|
fetchUserChatSetting: () => Promise<void>;
|
||||||
|
updateUserChatSetting: () => Promise<void>;
|
||||||
|
|
||||||
// 创建某个角色的聊天频道, 返回channelId
|
// 创建某个角色的聊天频道, 返回channelId
|
||||||
createChannel: (characterId: string) => Promise<string | false>;
|
createChannel: (characterId: string) => Promise<string | false>;
|
||||||
switchToChannel: (id: string) => Promise<void>;
|
switchToChannel: (id: string) => Promise<void>;
|
||||||
queryChannels: (filter: any) => Promise<void>;
|
queryChannels: (filter: any) => Promise<Channel[]>;
|
||||||
deleteChannel: (id: string) => Promise<void>;
|
deleteChannel: (
|
||||||
clearChannels: () => Promise<void>;
|
id: string[]
|
||||||
|
) => Promise<{ result: string; newChannels?: Channel[]; error?: unknown }>;
|
||||||
getCurrentCharacter: () => any | null;
|
getCurrentCharacter: () => any | null;
|
||||||
|
|
||||||
// 消息列表
|
// 消息列表
|
||||||
|
|
@ -38,6 +64,9 @@ interface StreamChatStore {
|
||||||
|
|
||||||
// 清除通知
|
// 清除通知
|
||||||
clearNotifications: () => Promise<void>;
|
clearNotifications: () => Promise<void>;
|
||||||
|
|
||||||
|
// 推出登录,清除状态
|
||||||
|
clearClient: () => void;
|
||||||
}
|
}
|
||||||
export const useStreamChatStore = create<StreamChatStore>((set, get) => ({
|
export const useStreamChatStore = create<StreamChatStore>((set, get) => ({
|
||||||
client: null,
|
client: null,
|
||||||
|
|
@ -45,6 +74,14 @@ export const useStreamChatStore = create<StreamChatStore>((set, get) => ({
|
||||||
userId: '',
|
userId: '',
|
||||||
userName: '',
|
userName: '',
|
||||||
},
|
},
|
||||||
|
chatSetting: {
|
||||||
|
chatModel: '',
|
||||||
|
longText: 0,
|
||||||
|
maximumReplies: 0,
|
||||||
|
background: '',
|
||||||
|
font: 16,
|
||||||
|
voiceActor: '',
|
||||||
|
},
|
||||||
channels: [],
|
channels: [],
|
||||||
messages: [],
|
messages: [],
|
||||||
setMessages: (messages: any[]) => set({ messages }),
|
setMessages: (messages: any[]) => set({ messages }),
|
||||||
|
|
@ -52,10 +89,9 @@ export const useStreamChatStore = create<StreamChatStore>((set, get) => ({
|
||||||
// 获取当前聊天频道中的角色id
|
// 获取当前聊天频道中的角色id
|
||||||
getCurrentCharacter() {
|
getCurrentCharacter() {
|
||||||
const { currentChannel, user } = get();
|
const { currentChannel, user } = get();
|
||||||
return (
|
return Object.values(currentChannel?.state?.members || {})?.find((i) => {
|
||||||
Object.values(currentChannel?.state?.members || {})?.find((i) => i.user?.id !== user?.userId)
|
return i.user_id !== user?.userId;
|
||||||
?.user?.id || null
|
});
|
||||||
);
|
|
||||||
},
|
},
|
||||||
// 创建某个角色的聊天频道
|
// 创建某个角色的聊天频道
|
||||||
async createChannel(characterId: string) {
|
async createChannel(characterId: string) {
|
||||||
|
|
@ -77,28 +113,58 @@ export const useStreamChatStore = create<StreamChatStore>((set, get) => ({
|
||||||
return data.channelId;
|
return data.channelId;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setChatSetting: (setting: any) => {
|
||||||
|
const { chatSetting } = get();
|
||||||
|
set({ chatSetting: { ...chatSetting, ...setting } });
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchUserChatSetting() {
|
||||||
|
const { user } = get();
|
||||||
|
const { data } = await fetchUserChatSetting({
|
||||||
|
userId: Number(user.userId),
|
||||||
|
});
|
||||||
|
if (data) {
|
||||||
|
set({ chatSetting: data });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateUserChatSetting() {
|
||||||
|
const { user, chatSetting, fetchUserChatSetting } = get();
|
||||||
|
await updateUserChatSetting({
|
||||||
|
...chatSetting,
|
||||||
|
userId: user.userId,
|
||||||
|
});
|
||||||
|
fetchUserChatSetting();
|
||||||
|
},
|
||||||
|
|
||||||
async connect(user) {
|
async connect(user) {
|
||||||
const { client } = get();
|
const { client, queryChannels, fetchUserChatSetting } = get();
|
||||||
set({ user });
|
set({ user });
|
||||||
if (client) return;
|
if (client) return;
|
||||||
const { data } = await getUserToken(user);
|
const { data } = await getUserToken(user);
|
||||||
const streamClient = new StreamChat(process.env.NEXT_PUBLIC_STREAM_CHAT_API_KEY || '');
|
const streamClient = new StreamChat(process.env.NEXT_PUBLIC_STREAM_CHAT_API_KEY || '');
|
||||||
const res = await streamClient.connectUser(
|
await protect(() =>
|
||||||
{
|
streamClient.connectUser(
|
||||||
id: user.userId,
|
{
|
||||||
name: user.userName,
|
id: user.userId,
|
||||||
},
|
name: user.userName,
|
||||||
data
|
},
|
||||||
|
data
|
||||||
|
)
|
||||||
);
|
);
|
||||||
set({ client: streamClient });
|
set({ client: streamClient });
|
||||||
|
await queryChannels({});
|
||||||
|
await fetchUserChatSetting();
|
||||||
},
|
},
|
||||||
|
|
||||||
async switchToChannel(id: string) {
|
async switchToChannel(id: string) {
|
||||||
const { client, user } = get();
|
const { client, user } = get();
|
||||||
const channel = client!.channel('messaging', id);
|
const channel = client!.channel('messaging', id);
|
||||||
const result = await channel.query({
|
const result = await protect(() =>
|
||||||
messages: { limit: 100 },
|
channel.query({
|
||||||
});
|
messages: { limit: 100 },
|
||||||
|
})
|
||||||
|
);
|
||||||
const messages = result.messages.map((i) => ({
|
const messages = result.messages.map((i) => ({
|
||||||
key: i.id,
|
key: i.id,
|
||||||
role: i.user?.id === user.userId ? 'user' : 'assistant',
|
role: i.user?.id === user.userId ? 'user' : 'assistant',
|
||||||
|
|
@ -111,68 +177,52 @@ export const useStreamChatStore = create<StreamChatStore>((set, get) => ({
|
||||||
const { user, client } = get();
|
const { user, client } = get();
|
||||||
if (!client) {
|
if (!client) {
|
||||||
console.error('StreamChat client is not connected');
|
console.error('StreamChat client is not connected');
|
||||||
return;
|
return [];
|
||||||
}
|
}
|
||||||
|
let channels: Channel[] = [];
|
||||||
try {
|
try {
|
||||||
const channels = await client.queryChannels(
|
channels = await protect(() =>
|
||||||
{
|
client.queryChannels(
|
||||||
members: {
|
{
|
||||||
$in: [user.userId],
|
members: {
|
||||||
|
$in: [user.userId],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
last_message_at: -1,
|
||||||
last_message_at: -1,
|
},
|
||||||
},
|
{
|
||||||
{
|
message_limit: 1, // 返回最新的1条消息
|
||||||
message_limit: 1, // 返回最新的1条消息
|
}
|
||||||
}
|
)
|
||||||
);
|
);
|
||||||
set({ channels });
|
set({ channels });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to query channels:', error);
|
console.error('Failed to query channels:', error);
|
||||||
}
|
}
|
||||||
|
return channels;
|
||||||
},
|
},
|
||||||
|
|
||||||
async deleteChannel(id: string) {
|
async deleteChannel(ids: string[]) {
|
||||||
const { channels, currentChannel, queryChannels } = get();
|
const { channels, currentChannel, client, queryChannels } = get();
|
||||||
const channel = channels.find((ch) => ch.id === id);
|
const deleteChannels = channels.filter((ch) => ids.includes(ch.id!));
|
||||||
if (!channel) {
|
|
||||||
console.warn(`Channel with id ${id} not found`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await channel.delete();
|
await Promise.all(
|
||||||
await queryChannels({});
|
deleteChannels.map((ch) => {
|
||||||
if (currentChannel?.id === id) {
|
return client?.channel('messaging', ch.id)?.delete();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await deleteChannel(ids);
|
||||||
|
const newChannels = await queryChannels({});
|
||||||
|
if (currentChannel?.id && ids.includes(currentChannel.id)) {
|
||||||
set({ currentChannel: null });
|
set({ currentChannel: null });
|
||||||
}
|
}
|
||||||
|
return { result: 'ok', newChannels };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to delete channel ${id}:`, error);
|
return { result: 'error', error: error };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async clearChannels() {
|
|
||||||
const { channels } = get();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 停止监听所有频道
|
|
||||||
for (const channel of channels) {
|
|
||||||
try {
|
|
||||||
await channel.stopWatching();
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Failed to stop watching channel ${channel.id}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清空频道列表和当前频道
|
|
||||||
set({
|
|
||||||
channels: [],
|
|
||||||
currentChannel: null,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to clear channels:', error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async clearNotifications() {},
|
async clearNotifications() {},
|
||||||
|
|
||||||
// 发送消息
|
// 发送消息
|
||||||
|
|
@ -189,7 +239,7 @@ export const useStreamChatStore = create<StreamChatStore>((set, get) => ({
|
||||||
|
|
||||||
// 发送消息到服务器
|
// 发送消息到服务器
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${process.env.NEXT_PUBLIC_CHAT_API_URL}/chat-api/chat/testPrompt`,
|
`${process.env.NEXT_PUBLIC_CHAT_API_URL}/chat-api/chat/ai/generateReply`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -199,9 +249,8 @@ export const useStreamChatStore = create<StreamChatStore>((set, get) => ({
|
||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
channelId: currentChannel?.id || '',
|
channelId: currentChannel?.id || '',
|
||||||
message: content,
|
message: content,
|
||||||
promptTemplateId: 'default',
|
characterId: getCurrentCharacter()?.user_id,
|
||||||
characterId: getCurrentCharacter()?.id,
|
language: 'zh',
|
||||||
modelName: 'gpt-3.5-turbo',
|
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
@ -210,12 +259,36 @@ export const useStreamChatStore = create<StreamChatStore>((set, get) => ({
|
||||||
await parseSSEStream(response, (event: string, data: string) => {
|
await parseSSEStream(response, (event: string, data: string) => {
|
||||||
if (event === 'chat-message') {
|
if (event === 'chat-message') {
|
||||||
const d = parseData(data);
|
const d = parseData(data);
|
||||||
const lastMsg = finalMessages[finalMessages.length - 1];
|
// 重新赋值最后一项,改变引用
|
||||||
|
const lastMsg = { ...finalMessages[finalMessages.length - 1] };
|
||||||
if (lastMsg.role === 'assistant') {
|
if (lastMsg.role === 'assistant') {
|
||||||
lastMsg.content = d.content || '';
|
lastMsg.content = lastMsg.content + d.text || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
finalMessages[finalMessages.length - 1] = lastMsg;
|
||||||
setMessages([...finalMessages]);
|
setMessages([...finalMessages]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 推出登录,清除状态
|
||||||
|
clearClient: async () => {
|
||||||
|
const { client } = get();
|
||||||
|
await client?.disconnectUser();
|
||||||
|
set({
|
||||||
|
client: null,
|
||||||
|
user: { userId: '', userName: '' },
|
||||||
|
chatSetting: {
|
||||||
|
chatModel: '',
|
||||||
|
longText: 0,
|
||||||
|
maximumReplies: 0,
|
||||||
|
background: '',
|
||||||
|
font: 16,
|
||||||
|
voiceActor: '',
|
||||||
|
},
|
||||||
|
channels: [],
|
||||||
|
messages: [],
|
||||||
|
currentChannel: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
// import { useGetSevenDaysSignList, useSignIn } from '@/hooks/useHome'
|
import { useGetSevenDaysSignList, useSignIn } from '@/hooks/useHome';
|
||||||
// import { SignInListOutput } from '@/services/home/types'
|
import { SignInListOutput } from '@/services/home/types';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { homeKeys } from '@/lib/query-keys';
|
import { homeKeys } from '@/lib/query-keys';
|
||||||
import { CheckInCard } from './CheckInCard';
|
import { CheckInCard } from './CheckInCard';
|
||||||
|
|
@ -10,8 +10,8 @@ import { toast } from 'sonner';
|
||||||
|
|
||||||
export function CheckInGrid() {
|
export function CheckInGrid() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
// const { data: signListData, isLoading } = useGetSevenDaysSignList()
|
const { data: signListData, isLoading } = useGetSevenDaysSignList();
|
||||||
// const signInMutation = useSignIn()
|
const signInMutation = useSignIn();
|
||||||
const hasSignRef = useRef(false);
|
const hasSignRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,12 @@ const Character = () => {
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<InfiniteScrollList<any>
|
<InfiniteScrollList<any>
|
||||||
items={dataSource}
|
items={dataSource}
|
||||||
columns={(width) => Math.floor(width / 213)}
|
columns={(width) => {
|
||||||
|
const cardWidth = width > 1200 ? 256 : width > 588 ? 200 : 170;
|
||||||
|
return Math.floor(width / cardWidth);
|
||||||
|
}}
|
||||||
renderItem={(character) => <AIStandardCard character={character} />}
|
renderItem={(character) => <AIStandardCard character={character} />}
|
||||||
getItemKey={(character) => character.id}
|
getItemKey={(character, index) => character.id + index}
|
||||||
hasNextPage={!noMoreData}
|
hasNextPage={!noMoreData}
|
||||||
isLoading={isFirstLoading || isLoadingMore}
|
isLoading={isFirstLoading || isLoadingMore}
|
||||||
fetchNextPage={onLoadMore}
|
fetchNextPage={onLoadMore}
|
||||||
|
|
|
||||||
|
|
@ -10,60 +10,53 @@ const Header = React.memo(() => {
|
||||||
const response = useMedia();
|
const response = useMedia();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href="/crushcoin">
|
// <Link href="/crushcoin">
|
||||||
<div
|
<div
|
||||||
className="h-50 rounded-4xl px-6 mb-12 flex items-center justify-between"
|
className="h-25 sm:h-50 rounded-2xl sm:rounded-4xl px-6 mb-12 flex items-center justify-between"
|
||||||
style={{
|
style={{
|
||||||
background:
|
background:
|
||||||
'linear-gradient(90deg, rgba(255, 255, 255, 0.1) 0%, rgba(202, 153, 255, 0.2) 100%)',
|
'linear-gradient(90deg, rgba(255, 255, 255, 0.1) 0%, rgba(202, 153, 255, 0.2) 100%)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex gap-3 items-center">
|
<div className="flex gap-3 items-center">
|
||||||
<Image
|
<img
|
||||||
src="/images/home/icon-crush-free.png"
|
src="/images/home/icon-crush-free.png"
|
||||||
className="h-30 w-30 object-cover"
|
className="h-15 w-15 sm:h-30 sm:w-30 object-cover"
|
||||||
alt="header-bg"
|
alt="header-bg"
|
||||||
width={120}
|
/>
|
||||||
height={120}
|
<div>
|
||||||
/>
|
<div className="flex gap-5 txt-display-m sm:txt-display-l">
|
||||||
<div>
|
Check-in{' '}
|
||||||
<div className="flex gap-5 txt-display-l">
|
<Image
|
||||||
Check-in{' '}
|
src="/images/home/left-star.png"
|
||||||
<Image
|
className="h-6 w-6 sm:h-12 sm:w-12 object-cover"
|
||||||
src="/images/home/left-star.png"
|
alt="header-bg"
|
||||||
className="h-12 w-12 object-cover"
|
width={48}
|
||||||
alt="header-bg"
|
height={48}
|
||||||
width={48}
|
/>
|
||||||
height={48}
|
</div>
|
||||||
/>
|
<div className="flex gap-5 items-center">
|
||||||
</div>
|
<span
|
||||||
<div className="flex gap-5 items-center">
|
className="txt-body-m sm:txt-headline-s bg-clip-text text-transparent"
|
||||||
<span
|
style={{
|
||||||
className="txt-headline-s bg-clip-text text-transparent"
|
background:
|
||||||
style={{
|
'linear-gradient(109.2deg, rgba(211, 123, 235, 1) 37.08%, rgba(147, 123, 235, 1) 128.91%)',
|
||||||
background:
|
WebkitBackgroundClip: 'text',
|
||||||
'linear-gradient(109.2deg, rgba(211, 123, 235, 1) 37.08%, rgba(147, 123, 235, 1) 128.91%)',
|
WebkitTextFillColor: 'transparent',
|
||||||
WebkitBackgroundClip: 'text',
|
backgroundClip: 'text',
|
||||||
WebkitTextFillColor: 'transparent',
|
}}
|
||||||
backgroundClip: 'text',
|
>
|
||||||
}}
|
Daily Free crush coinsh
|
||||||
>
|
</span>
|
||||||
Daily Free crush coinsh
|
<IconButton iconfont="icon-arrow-right-border" size="small" variant="primary" />
|
||||||
</span>
|
|
||||||
<IconButton iconfont="icon-arrow-right-border" size="small" variant="primary" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{response?.lg && (
|
|
||||||
<Image
|
|
||||||
src="/images/home/banner-header.png"
|
|
||||||
alt="banner-header"
|
|
||||||
width={250}
|
|
||||||
height={250}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
{response?.lg && (
|
||||||
|
<Image src="/images/home/banner-header.png" alt="banner-header" width={250} height={250} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
// </Link>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
const Story = () => {
|
const Story = () => {
|
||||||
return <div>Story</div>;
|
return <div>开发中ing</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Story;
|
export default Story;
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
import { create } from 'zustand'
|
import { create } from 'zustand';
|
||||||
|
|
||||||
interface HomeStore {
|
interface HomeStore {
|
||||||
tab: 'story' | 'character'
|
tab: 'story' | 'character';
|
||||||
selectedTags: string[]
|
selectedTags: string[];
|
||||||
setTab: (tab: 'story' | 'character') => void
|
setTab: (tab: 'story' | 'character') => void;
|
||||||
setSelectedTags: (selectedTags: string[]) => void
|
setSelectedTags: (selectedTags: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useHomeStore = create<HomeStore>((set) => ({
|
export const useHomeStore = create<HomeStore>((set) => ({
|
||||||
tab: 'story',
|
tab: 'character',
|
||||||
setTab: (tab: 'story' | 'character') => set({ tab }),
|
setTab: (tab: 'story' | 'character') => set({ tab }),
|
||||||
selectedTags: [],
|
selectedTags: [],
|
||||||
setSelectedTags: (selectedTags: string[]) => set({ selectedTags }),
|
setSelectedTags: (selectedTags: string[]) => set({ selectedTags }),
|
||||||
}))
|
}));
|
||||||
|
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { formatNumberToKMB } from '@/lib/utils'
|
|
||||||
import Image from 'next/image'
|
|
||||||
import { AiChatRankOutput, AiHeartbeatRankOutput, AiGiftRankOutput } from '@/services/home/types'
|
|
||||||
import { RankType } from '@/types/global'
|
|
||||||
import Link from 'next/link'
|
|
||||||
|
|
||||||
interface LargeRankCardProps {
|
|
||||||
item: AiChatRankOutput | AiHeartbeatRankOutput | AiGiftRankOutput
|
|
||||||
rankType: RankType
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LargeRankCard({ item, rankType }: LargeRankCardProps) {
|
|
||||||
// 根据排行榜类型获取对应的数值显示
|
|
||||||
const getDisplayValue = () => {
|
|
||||||
switch (rankType) {
|
|
||||||
case RankType.CHAT:
|
|
||||||
return formatNumberToKMB((item as AiChatRankOutput).chatNum || 0)
|
|
||||||
case RankType.CRUSH:
|
|
||||||
return `${(item as AiHeartbeatRankOutput).heartbeatValTotal || 0}℃`
|
|
||||||
case RankType.GIFTS:
|
|
||||||
return formatNumberToKMB(Math.floor((item as AiGiftRankOutput).giftCoinNum || 0) / 100)
|
|
||||||
default:
|
|
||||||
return '0'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 根据排行榜类型获取对应的图标
|
|
||||||
const getIcon = () => {
|
|
||||||
switch (rankType) {
|
|
||||||
case RankType.CHAT:
|
|
||||||
return <i className={`iconfont icon-Chat !text-[12px]`} />
|
|
||||||
case RankType.CRUSH:
|
|
||||||
return <Image src="/icons/icon-crush.svg" alt="" width={12} height={12} />
|
|
||||||
case RankType.GIFTS:
|
|
||||||
return <Image src="/icons/diamond.svg" alt="" width={12} height={12} />
|
|
||||||
default:
|
|
||||||
return <i className={`iconfont icon-Chat !text-[12px]`} />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageUrl = item.homeImageUrl || ''
|
|
||||||
|
|
||||||
if (!item) {
|
|
||||||
return <div className="relative aspect-[240/360] w-[38.5%] overflow-hidden rounded-b-lg"></div>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={`/chat/${item.aiId}`}
|
|
||||||
prefetch={false}
|
|
||||||
key={item.aiId}
|
|
||||||
className="relative aspect-[240/360] w-[38.5%] overflow-hidden rounded-b-lg"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="absolute inset-0"
|
|
||||||
style={{
|
|
||||||
background: `url(${imageUrl}) #211a2b 50% / cover no-repeat`,
|
|
||||||
maskImage: 'linear-gradient(180deg, rgba(255, 255, 255, 0.00) 0%, #FFF 55%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="absolute right-0 bottom-0 left-0 aspect-[240/112]">
|
|
||||||
<Image src="/images/leaderboard/1-st.svg" alt="" fill className="object-cover" />
|
|
||||||
</div>
|
|
||||||
<div className="absolute inset-0">
|
|
||||||
<div className="absolute right-0 bottom-[44] left-0 flex items-center justify-center gap-1">
|
|
||||||
{getIcon()}
|
|
||||||
<div className="txt-numMonotype-s">{getDisplayValue()}</div>
|
|
||||||
</div>
|
|
||||||
<div className="txt-numDisplay-m absolute right-0 bottom-1 left-0 text-center">1st</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import Image from 'next/image'
|
|
||||||
import { useMemo } from 'react'
|
|
||||||
import { AiChatRankOutput, AiHeartbeatRankOutput, AiGiftRankOutput } from '@/services/home/types'
|
|
||||||
import { RankType } from '@/types/global'
|
|
||||||
import { formatNumberToKMB } from '@/lib/utils'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { usePrefetchRoutes } from '@/hooks/useGlobalPrefetchRoutes'
|
|
||||||
|
|
||||||
interface RankingListProps {
|
|
||||||
rankData: (AiChatRankOutput | AiHeartbeatRankOutput | AiGiftRankOutput)[]
|
|
||||||
rankType: RankType
|
|
||||||
startFromRank?: number // 从第几名开始显示,默认为4
|
|
||||||
}
|
|
||||||
|
|
||||||
const RankingList: React.FC<RankingListProps> = ({ rankData, rankType, startFromRank = 4 }) => {
|
|
||||||
const filteredData = useMemo(
|
|
||||||
() => rankData.filter((item) => item.rankNo && item.rankNo >= startFromRank),
|
|
||||||
[rankData, startFromRank]
|
|
||||||
)
|
|
||||||
const chatRoutes = useMemo(
|
|
||||||
() => filteredData.map((item) => (item?.aiId ? `/chat/${item.aiId}` : null)),
|
|
||||||
[filteredData]
|
|
||||||
)
|
|
||||||
usePrefetchRoutes(chatRoutes, { limit: 20 })
|
|
||||||
|
|
||||||
// 根据排行榜类型获取格式化后的显示值
|
|
||||||
const getDisplayValue = (item: AiChatRankOutput | AiHeartbeatRankOutput | AiGiftRankOutput) => {
|
|
||||||
switch (rankType) {
|
|
||||||
case RankType.CHAT:
|
|
||||||
return formatNumberToKMB((item as AiChatRankOutput).chatNum || 0)
|
|
||||||
case RankType.CRUSH:
|
|
||||||
return `${(item as AiHeartbeatRankOutput).heartbeatValTotal || 0}℃`
|
|
||||||
case RankType.GIFTS:
|
|
||||||
return formatNumberToKMB(Math.floor(((item as AiGiftRankOutput).giftCoinNum || 0) / 100))
|
|
||||||
default:
|
|
||||||
return '0'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 根据排行榜类型获取对应的图标组件
|
|
||||||
const getRankIcon = () => {
|
|
||||||
switch (rankType) {
|
|
||||||
case RankType.CHAT:
|
|
||||||
return <i className="iconfont icon-Chat !text-[24px]" />
|
|
||||||
case RankType.CRUSH:
|
|
||||||
return <Image src="/icons/icon-crush.svg" alt="" width={24} height={24} />
|
|
||||||
case RankType.GIFTS:
|
|
||||||
return <Image src="/icons/diamond.svg" alt="" width={24} height={24} />
|
|
||||||
default:
|
|
||||||
return <i className="iconfont icon-Chat !text-[24px]" />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getLikedCount = (item: AiChatRankOutput | AiHeartbeatRankOutput | AiGiftRankOutput) => {
|
|
||||||
if ('likedNum' in item && typeof item.likedNum === 'number') {
|
|
||||||
return item.likedNum
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filteredData.length === 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto mt-6 flex max-w-[752px] flex-col gap-2 px-4 pb-4">
|
|
||||||
{filteredData.map((item) => {
|
|
||||||
const displayValue = getDisplayValue(item)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link href={`/chat/${item.aiId}`} key={item.aiId} prefetch={false}>
|
|
||||||
<div
|
|
||||||
key={item.aiId}
|
|
||||||
className="box-border flex w-full items-center justify-center gap-4 p-2"
|
|
||||||
>
|
|
||||||
{/* 排名编号 */}
|
|
||||||
<div className="txt-numDisplay-s w-6 shrink-0 text-center">
|
|
||||||
{String(item.rankNo).padStart(2, '0')}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 头像 */}
|
|
||||||
<div className="relative h-16 w-16 shrink-0">
|
|
||||||
<div className="absolute top-1/2 left-1/2 h-16 w-16 -translate-x-1/2 -translate-y-1/2">
|
|
||||||
<Image
|
|
||||||
src={item.headImg || ''}
|
|
||||||
alt={item.nickname || 'Avatar'}
|
|
||||||
width={64}
|
|
||||||
height={64}
|
|
||||||
className="h-full w-full rounded-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 用户信息区域 */}
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
|
||||||
{/* 用户名 */}
|
|
||||||
<div className="flex w-full items-start gap-2">
|
|
||||||
<div className="txt-title-m truncate">{item.nickname}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 喜欢数 */}
|
|
||||||
<div className="flex w-full items-center gap-2">
|
|
||||||
<div className="text-txt-secondary-normal flex items-center gap-1">
|
|
||||||
<i className="iconfont icon-Like-fill !text-[12px]" />
|
|
||||||
<div className="txt-numMonotype-s">
|
|
||||||
{formatNumberToKMB(getLikedCount(item) || 0)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 排行榜数值 */}
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex w-16 min-w-6 flex-col items-center justify-center gap-1 rounded-[4px] px-1 py-0.5 backdrop-blur-sm">
|
|
||||||
{/* 图标 */}
|
|
||||||
<div className="flex h-6 w-6 items-center justify-center">{getRankIcon()}</div>
|
|
||||||
|
|
||||||
{/* 数值 */}
|
|
||||||
<div className="txt-numMonotype-s text-center">{displayValue}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default RankingList
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { formatNumberToKMB } from '@/lib/utils'
|
|
||||||
import Image from 'next/image'
|
|
||||||
import { AiChatRankOutput, AiHeartbeatRankOutput, AiGiftRankOutput } from '@/services/home/types'
|
|
||||||
import { RankType } from '@/types/global'
|
|
||||||
import Link from 'next/link'
|
|
||||||
|
|
||||||
interface SmallRankCardProps {
|
|
||||||
item: AiChatRankOutput | AiHeartbeatRankOutput | AiGiftRankOutput
|
|
||||||
rankType: RankType
|
|
||||||
rank?: number // 用于显示排名,如果不传则使用item.rankNo
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SmallRankCard({ item, rankType, rank }: SmallRankCardProps) {
|
|
||||||
// 根据排行榜类型获取对应的数值显示
|
|
||||||
const getDisplayValue = () => {
|
|
||||||
switch (rankType) {
|
|
||||||
case RankType.CHAT:
|
|
||||||
return formatNumberToKMB((item as AiChatRankOutput).chatNum || 0)
|
|
||||||
case RankType.CRUSH:
|
|
||||||
return `${(item as AiHeartbeatRankOutput).heartbeatValTotal || 0}℃`
|
|
||||||
case RankType.GIFTS:
|
|
||||||
return formatNumberToKMB(Math.floor(((item as AiGiftRankOutput).giftCoinNum || 0) / 100))
|
|
||||||
default:
|
|
||||||
return '0'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 根据排行榜类型获取对应的图标
|
|
||||||
const getIcon = () => {
|
|
||||||
switch (rankType) {
|
|
||||||
case RankType.CHAT:
|
|
||||||
return <i className="iconfont icon-Chat !text-[12px]" />
|
|
||||||
case RankType.CRUSH:
|
|
||||||
return <Image src="/icons/icon-crush.svg" alt="" width={12} height={12} />
|
|
||||||
case RankType.GIFTS:
|
|
||||||
return <Image src="/icons/diamond.svg" alt="" width={12} height={12} />
|
|
||||||
default:
|
|
||||||
return <i className="iconfont icon-Chat !text-[12px]" />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!item) {
|
|
||||||
return <div className="flex-1 py-12"></div>
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageUrl = item.homeImageUrl || ''
|
|
||||||
const rankNo = rank || item.rankNo || 1
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link href={`/chat/${item.aiId}`} prefetch={false} key={item.aiId} className="flex-1">
|
|
||||||
<div className="flex-1 py-12">
|
|
||||||
<div className="relative aspect-[240/360] w-full overflow-hidden rounded-b-lg">
|
|
||||||
<div
|
|
||||||
className="absolute inset-0"
|
|
||||||
style={{
|
|
||||||
background: `url(${imageUrl}) #211a2b 50% / cover no-repeat`,
|
|
||||||
maskImage: 'linear-gradient(180deg, rgba(255, 255, 255, 0.00) 0%, #FFF 55%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="absolute right-0 bottom-0 left-0 aspect-[240/112]">
|
|
||||||
<Image
|
|
||||||
src={`/images/leaderboard/${rankNo}-st.svg`}
|
|
||||||
alt=""
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="absolute inset-0">
|
|
||||||
<div className="absolute right-0 bottom-[44] left-0 flex items-center justify-center gap-1">
|
|
||||||
{getIcon()}
|
|
||||||
<div className="txt-numMonotype-s">{getDisplayValue()}</div>
|
|
||||||
</div>
|
|
||||||
<div className="txt-numDisplay-m absolute right-0 bottom-1 left-0 text-center">
|
|
||||||
{rankNo === 2 ? '2nd' : '3rd'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { useMemo } from 'react'
|
|
||||||
import LargeRankCard from './LargeRankCard'
|
|
||||||
import SmallRankCard from './SmallRankCard'
|
|
||||||
import { AiChatRankOutput, AiHeartbeatRankOutput, AiGiftRankOutput } from '@/services/home/types'
|
|
||||||
import { RankType } from '@/types/global'
|
|
||||||
import { usePrefetchRoutes } from '@/hooks/useGlobalPrefetchRoutes'
|
|
||||||
|
|
||||||
interface TopHeaderProps {
|
|
||||||
rankData: AiChatRankOutput[] | AiHeartbeatRankOutput[] | AiGiftRankOutput[]
|
|
||||||
rankType: RankType
|
|
||||||
isLoading?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TopHeader({ rankData, rankType, isLoading }: TopHeaderProps) {
|
|
||||||
const topThree = useMemo(() => (rankData || []).slice(0, 3), [rankData])
|
|
||||||
const chatRoutes = useMemo(
|
|
||||||
() => topThree.map((item) => (item?.aiId ? `/chat/${item.aiId}` : null)),
|
|
||||||
[topThree]
|
|
||||||
)
|
|
||||||
usePrefetchRoutes(chatRoutes, { limit: 3 })
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="mx-auto mt-6 max-w-[624px]">
|
|
||||||
<div className="flex w-full items-center gap-4">
|
|
||||||
<div className="flex-1 py-12">
|
|
||||||
<div className="bg-surface-nest-normal aspect-[240/360] w-full animate-pulse rounded-b-lg" />
|
|
||||||
</div>
|
|
||||||
<div className="bg-surface-nest-normal aspect-[240/360] w-[38.5%] animate-pulse rounded-b-lg" />
|
|
||||||
<div className="flex-1 py-12">
|
|
||||||
<div className="bg-surface-nest-normal aspect-[240/360] w-full animate-pulse rounded-b-lg" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!rankData || rankData.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="mx-auto mt-6 max-w-[624px]">
|
|
||||||
<div className="flex h-64 items-center justify-center">
|
|
||||||
<div className="text-txt-secondary-normal">No leaderboard data yet</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstPlace = topThree[0]
|
|
||||||
const secondPlace = topThree[1]
|
|
||||||
const thirdPlace = topThree[2]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto mt-6 max-w-[624px]">
|
|
||||||
<div className="flex w-full items-center gap-4">
|
|
||||||
{/* 第二名 */}
|
|
||||||
<SmallRankCard item={secondPlace} rankType={rankType} rank={2} />
|
|
||||||
|
|
||||||
{/* 第一名 */}
|
|
||||||
<LargeRankCard item={firstPlace} rankType={rankType} />
|
|
||||||
|
|
||||||
{/* 第三名 */}
|
|
||||||
<SmallRankCard item={thirdPlace} rankType={rankType} rank={3} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,166 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { useSearchParams } from 'next/navigation'
|
|
||||||
import TopHeader from './components/TopHeader'
|
|
||||||
import RankingList from './components/RankingList'
|
|
||||||
import { useGetChatRank, useGetHeartbeatRank, useGetGiftRank } from '@/hooks/useHome'
|
|
||||||
import { RankType } from '@/types/global'
|
|
||||||
import Image from 'next/image'
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
|
||||||
import { IconButton } from '@/components/ui/button'
|
|
||||||
|
|
||||||
const LeaderboardPage = () => {
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const typeFromUrl = searchParams.get('type')
|
|
||||||
|
|
||||||
// 验证并设置初始 tab,确保 typeFromUrl 是有效的 RankType
|
|
||||||
const getInitialTab = (): RankType => {
|
|
||||||
if (typeFromUrl && Object.values(RankType).includes(typeFromUrl as RankType)) {
|
|
||||||
return typeFromUrl as RankType
|
|
||||||
}
|
|
||||||
return RankType.CHAT
|
|
||||||
}
|
|
||||||
|
|
||||||
const [selectedTab, setSelectedTab] = useState<RankType>(getInitialTab())
|
|
||||||
|
|
||||||
// 当 URL 参数变化时,更新选中的 tab
|
|
||||||
useEffect(() => {
|
|
||||||
const newTab = getInitialTab()
|
|
||||||
setSelectedTab(newTab)
|
|
||||||
}, [typeFromUrl])
|
|
||||||
|
|
||||||
// 调用三个排行榜接口
|
|
||||||
const { data: chatRankData, isLoading: chatLoading, error: chatError } = useGetChatRank()
|
|
||||||
const {
|
|
||||||
data: heartbeatRankData,
|
|
||||||
isLoading: heartbeatLoading,
|
|
||||||
error: heartbeatError,
|
|
||||||
} = useGetHeartbeatRank()
|
|
||||||
const { data: giftRankData, isLoading: giftLoading, error: giftError } = useGetGiftRank()
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ value: RankType.CHAT, label: 'Chat' },
|
|
||||||
{ value: RankType.CRUSH, label: 'Crush' },
|
|
||||||
{ value: RankType.GIFTS, label: 'Gifts' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// 根据选中的tab获取对应的数据
|
|
||||||
const getCurrentRankData = () => {
|
|
||||||
switch (selectedTab) {
|
|
||||||
case RankType.CHAT:
|
|
||||||
return { data: chatRankData, isLoading: chatLoading, error: chatError }
|
|
||||||
case RankType.CRUSH:
|
|
||||||
return { data: heartbeatRankData, isLoading: heartbeatLoading, error: heartbeatError }
|
|
||||||
case RankType.GIFTS:
|
|
||||||
return { data: giftRankData, isLoading: giftLoading, error: giftError }
|
|
||||||
default:
|
|
||||||
return { data: chatRankData, isLoading: chatLoading, error: chatError }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentRankData = getCurrentRankData()
|
|
||||||
|
|
||||||
const backgroundColorMap = {
|
|
||||||
[RankType.CHAT]: 'linear-gradient(122.5deg, #F264A4 20.45%, #C241E6 100%)',
|
|
||||||
[RankType.CRUSH]: 'linear-gradient(122.5deg, #D664F2 20.45%, #416DE6 100%)',
|
|
||||||
[RankType.GIFTS]: 'linear-gradient(122.5deg, #FFC336 20.45%, #FF972F 100%)',
|
|
||||||
}
|
|
||||||
// return (background: linear-gradient(122.5deg, #F264A4 20.45%, #C241E6 100%);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-background-default w-full">
|
|
||||||
<div className="bg-background-default absolute top-0 right-0 bottom-0 left-0">
|
|
||||||
<div
|
|
||||||
className="absolute -top-[215px] h-[300px] w-[100%] opacity-50 blur-3xl"
|
|
||||||
style={{
|
|
||||||
background: backgroundColorMap[selectedTab],
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Image src="/images/leaderboard/bg.png" alt="Bg" fill className="object-cover" />
|
|
||||||
</div>
|
|
||||||
<div className="relative mx-auto w-full max-w-[752px]">
|
|
||||||
<h1 className="txt-headline-m pt-12 text-center">Leaderboard</h1>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<IconButton
|
|
||||||
iconfont="icon-question"
|
|
||||||
variant="tertiaryDark"
|
|
||||||
size="small"
|
|
||||||
className="absolute top-0 right-0"
|
|
||||||
/>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>The hot chat list is ranked by the number of AI chat sessions.</p>
|
|
||||||
<p>
|
|
||||||
The heart list is ranked by the sum of the heart values generated by all the
|
|
||||||
interlocutors of the AI character.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
The gift list is ranked by the sum of the gift value received by the AI character.
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<div className="mt-4 flex items-center justify-center">
|
|
||||||
<Tabs
|
|
||||||
className="h-auto rounded-none p-0"
|
|
||||||
value={selectedTab}
|
|
||||||
onValueChange={(value) => setSelectedTab(value as RankType)}
|
|
||||||
>
|
|
||||||
<TabsList>
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<TabsTrigger
|
|
||||||
key={tab.value}
|
|
||||||
value={tab.value}
|
|
||||||
className={cn(
|
|
||||||
'relative h-8 border-0 bg-transparent px-2 shadow-none',
|
|
||||||
'txt-title-m text-txt-secondary-normal',
|
|
||||||
'data-[state=active]:text-txt-primary-normal',
|
|
||||||
'data-[state=active]:bg-transparent',
|
|
||||||
'data-[state=active]:shadow-none',
|
|
||||||
'hover:text-txt-primary-normal',
|
|
||||||
'focus-visible:ring-0 focus-visible:ring-offset-0',
|
|
||||||
'flex cursor-pointer items-center justify-center'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="whitespace-nowrap">{tab.label}</span>
|
|
||||||
{/* 活跃状态指示器 */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'bg-primary-normal absolute bottom-0 left-1/2 h-1 w-5 -translate-x-1/2 rounded-xs transition-opacity',
|
|
||||||
selectedTab === tab.value ? 'opacity-100' : 'opacity-0'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 排行榜内容 */}
|
|
||||||
{
|
|
||||||
<>
|
|
||||||
<TopHeader
|
|
||||||
rankData={currentRankData.data || []}
|
|
||||||
rankType={selectedTab}
|
|
||||||
isLoading={currentRankData.isLoading}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 后续排名列表 */}
|
|
||||||
{!currentRankData.isLoading && (
|
|
||||||
<RankingList
|
|
||||||
rankData={currentRankData.data || []}
|
|
||||||
rankType={selectedTab}
|
|
||||||
startFromRank={4}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default LeaderboardPage
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
import Leaderboard from './leaderboard-page'
|
|
||||||
|
|
||||||
const LeaderboardPage = () => {
|
|
||||||
return (
|
|
||||||
<div className="w-full">
|
|
||||||
<Leaderboard />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default LeaderboardPage
|
|
||||||
|
|
@ -9,20 +9,11 @@ import {
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import { useLogout } from '@/hooks/auth';
|
import { useLogout } from '@/hooks/auth';
|
||||||
import { useSetAtom } from 'jotai';
|
|
||||||
import {
|
|
||||||
conversationListAtom,
|
|
||||||
msgListAtom,
|
|
||||||
userListAtom,
|
|
||||||
imSyncedAtom,
|
|
||||||
imReconnectStatusAtom,
|
|
||||||
IMReconnectStatus,
|
|
||||||
selectedConversationIdAtom,
|
|
||||||
} from '@/atoms/im';
|
|
||||||
import { QueueMap } from '@/lib/queue';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useLayoutStore } from '@/stores';
|
import { useLayoutStore } from '@/stores';
|
||||||
|
import { useStreamChatStore } from '../../chat/[id]/stream-chat';
|
||||||
|
import { useAsyncFn } from '@/hooks/tools';
|
||||||
|
|
||||||
const ProfileDropdownItem = ({
|
const ProfileDropdownItem = ({
|
||||||
icon,
|
icon,
|
||||||
|
|
@ -49,65 +40,20 @@ const ProfileDropdownItem = ({
|
||||||
|
|
||||||
const ProfileDropdown = () => {
|
const ProfileDropdown = () => {
|
||||||
const { mutateAsync: logout } = useLogout();
|
const { mutateAsync: logout } = useLogout();
|
||||||
// const { clearAllConversations } = useNimConversation();
|
|
||||||
const [isLogoutDialogOpen, setIsLogoutDialogOpen] = useState(false);
|
const [isLogoutDialogOpen, setIsLogoutDialogOpen] = useState(false);
|
||||||
const [isLogoutDialogLoading, setIsLogoutDialogLoading] = useState(false);
|
const setSidebarExpanded = useLayoutStore((s) => s.setSidebarExpanded);
|
||||||
const { isSidebarExpanded, setSidebarExpanded } = useLayoutStore();
|
const clearClient = useStreamChatStore((s) => s.clearClient);
|
||||||
|
|
||||||
// IM相关状态重置
|
const { run: handleLogout, loading } = useAsyncFn(async () => {
|
||||||
const setConversationList = useSetAtom(conversationListAtom);
|
|
||||||
const setMsgList = useSetAtom(msgListAtom);
|
|
||||||
const setUserList = useSetAtom(userListAtom);
|
|
||||||
const setImSynced = useSetAtom(imSyncedAtom);
|
|
||||||
const setImReconnectStatus = useSetAtom(imReconnectStatusAtom);
|
|
||||||
const setSelectedConversationId = useSetAtom(selectedConversationIdAtom);
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
|
||||||
try {
|
try {
|
||||||
setIsLogoutDialogLoading(true);
|
await clearClient();
|
||||||
|
setSidebarExpanded(false);
|
||||||
// 1. 断开IM连接
|
|
||||||
try {
|
|
||||||
console.log('开始断开IM连接...');
|
|
||||||
// await nim.V2NIMLoginService.logout();
|
|
||||||
console.log('IM连接已断开');
|
|
||||||
} catch (imError) {
|
|
||||||
console.error('断开IM连接失败:', imError);
|
|
||||||
// 即使IM断开失败,也继续执行后续步骤
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 清除所有聊天数据
|
|
||||||
try {
|
|
||||||
console.log('开始清除聊天历史数据...');
|
|
||||||
// await clearAllConversations();
|
|
||||||
console.log('聊天历史数据已清除');
|
|
||||||
} catch (clearError) {
|
|
||||||
console.error('清除聊天数据失败:', clearError);
|
|
||||||
// 即使清除失败,也继续执行后续步骤
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 重置所有IM相关的本地状态
|
|
||||||
setConversationList(new Map());
|
|
||||||
setMsgList(new QueueMap(20, 'rightToLeft'));
|
|
||||||
setUserList(new Map());
|
|
||||||
setImSynced(false);
|
|
||||||
setImReconnectStatus(IMReconnectStatus.DISCONNECTED);
|
|
||||||
setSelectedConversationId(null);
|
|
||||||
|
|
||||||
if (isSidebarExpanded) {
|
|
||||||
setSidebarExpanded(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 执行用户登出
|
|
||||||
await logout();
|
await logout();
|
||||||
|
|
||||||
setIsLogoutDialogOpen(false);
|
setIsLogoutDialogOpen(false);
|
||||||
setIsLogoutDialogLoading(false);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('登出过程中发生错误:', error);
|
console.error('登出过程中发生错误:', error);
|
||||||
setIsLogoutDialogLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
// 菜单项配置
|
// 菜单项配置
|
||||||
const items: Array<
|
const items: Array<
|
||||||
|
|
@ -199,11 +145,7 @@ const ProfileDropdown = () => {
|
||||||
<AlertDialogDescription>Are you sure you want to log out?</AlertDialogDescription>
|
<AlertDialogDescription>Are you sure you want to log out?</AlertDialogDescription>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction variant="destructive" loading={loading} onClick={handleLogout}>
|
||||||
variant="destructive"
|
|
||||||
loading={isLogoutDialogLoading}
|
|
||||||
onClick={handleLogout}
|
|
||||||
>
|
|
||||||
Log out
|
Log out
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
interface AboutSectionProps {
|
|
||||||
introduction: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AboutSection({ introduction }: AboutSectionProps) {
|
|
||||||
return (
|
|
||||||
<div className="bg-surface-base-normal flex w-full flex-col items-start justify-start gap-4 rounded-lg p-6">
|
|
||||||
<h3 className="txt-title-m text-txt-primary-normal">Introduction</h3>
|
|
||||||
<p className="txt-body-l text-txt-primary-normal">{introduction}</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,266 +0,0 @@
|
||||||
'use client'
|
|
||||||
import { IAlbumItem, LikedStatus, LockStatus } from '@/services/user'
|
|
||||||
import { useAIUser } from '../context/aiUser'
|
|
||||||
import { Button, IconButton } from '@/components/ui/button'
|
|
||||||
import {
|
|
||||||
useLikeAlbumImage,
|
|
||||||
useSetAlbumImageUnlockMethod,
|
|
||||||
useSetDefaultAlbumImage,
|
|
||||||
} from '@/hooks/aiUser'
|
|
||||||
import { cn, delay } from '@/lib/utils'
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from '@/components/ui/alert-dialog'
|
|
||||||
import { useState, useRef } from 'react'
|
|
||||||
import AlbumPriceSetting, { AlbumPriceFormData } from '@/components/features/album-price-setting'
|
|
||||||
import Image from 'next/image'
|
|
||||||
import AlbumDeleteAlert from '@/components/features/album-delete-alert'
|
|
||||||
import { formatFromCents } from '@/utils/number'
|
|
||||||
import { useUpdateWalletBalance } from '@/hooks/useWallet'
|
|
||||||
import { isChargeDrawerOpenAtom } from '@/atoms/im'
|
|
||||||
import { useSetAtom } from 'jotai'
|
|
||||||
import Decimal from 'decimal.js'
|
|
||||||
|
|
||||||
const AlbumImageViewerAction = ({
|
|
||||||
datas,
|
|
||||||
originalDatas,
|
|
||||||
currentIndex,
|
|
||||||
onDeleted,
|
|
||||||
onUnlock,
|
|
||||||
unlockingAlbumIdsRef,
|
|
||||||
}: {
|
|
||||||
currentIndex: number
|
|
||||||
datas: IAlbumItem[]
|
|
||||||
originalDatas: IAlbumItem[]
|
|
||||||
onUnlock?: (imageId: number) => Promise<void>
|
|
||||||
onDeleted?: (nextIndex: number | null) => void
|
|
||||||
unlockingAlbumIdsRef: React.RefObject<Set<number>>
|
|
||||||
}) => {
|
|
||||||
const [lockLoading, setLockLoading] = useState(false)
|
|
||||||
const [isDefaultDialogOpen, setIsDefaultDialogOpen] = useState(false)
|
|
||||||
const { isOwner, userId } = useAIUser()
|
|
||||||
const currentData = datas[currentIndex]
|
|
||||||
const { albumId: originalAlbumId } = currentData
|
|
||||||
const current = originalDatas.find((item) => item.albumId === originalAlbumId)
|
|
||||||
const walletUpdate = useUpdateWalletBalance()
|
|
||||||
const setIsChargeDrawerOpen = useSetAtom(isChargeDrawerOpenAtom)
|
|
||||||
|
|
||||||
if (!current) return null
|
|
||||||
const { lockStatus, likedStatus, isDefault, unlockPrice, albumId } = current
|
|
||||||
const isLiked = likedStatus === LikedStatus.Liked
|
|
||||||
|
|
||||||
const likeMutation = useLikeAlbumImage()
|
|
||||||
const setDefaultMutation = useSetDefaultAlbumImage()
|
|
||||||
const setAlbumImageUnlockMethodMutation = useSetAlbumImageUnlockMethod()
|
|
||||||
|
|
||||||
const handleLike = (albumId: number, isLiked: boolean) => {
|
|
||||||
if (!userId) return
|
|
||||||
likeMutation.mutate({
|
|
||||||
albumId,
|
|
||||||
likedStatus: isLiked ? LikedStatus.Canceled : LikedStatus.Liked,
|
|
||||||
aiId: userId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSetDefault = (albumId: number) => {
|
|
||||||
if (!userId) return
|
|
||||||
// 如果是付费图片,则需要弹窗确认
|
|
||||||
if (lockStatus) {
|
|
||||||
setIsDefaultDialogOpen(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
handleConfirmSetDefault(albumId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUnlock = async () => {
|
|
||||||
if (!userId || lockLoading || unlockingAlbumIdsRef.current?.has(albumId)) return
|
|
||||||
|
|
||||||
if (!walletUpdate.checkSufficient((unlockPrice || 0) / 100)) {
|
|
||||||
setIsChargeDrawerOpen(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
unlockingAlbumIdsRef.current?.add(albumId)
|
|
||||||
setLockLoading(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await onUnlock?.(albumId)
|
|
||||||
} finally {
|
|
||||||
setLockLoading(false)
|
|
||||||
unlockingAlbumIdsRef.current?.delete(albumId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleConfirmSetDefault = (albumId: number) => {
|
|
||||||
if (!userId) return
|
|
||||||
setDefaultMutation.mutate(
|
|
||||||
{
|
|
||||||
aiId: userId,
|
|
||||||
albumId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
setIsDefaultDialogOpen(false)
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
setIsDefaultDialogOpen(false)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSetAlbumImageUnlockMethod = async (data: AlbumPriceFormData) => {
|
|
||||||
if (!userId) return
|
|
||||||
await setAlbumImageUnlockMethodMutation.mutateAsync({
|
|
||||||
aiId: userId,
|
|
||||||
albumId: datas[currentIndex].albumId,
|
|
||||||
unlockPrice: data.price ? new Decimal(data.price).mul(100).toNumber() : 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderPayAction = () => {
|
|
||||||
if (lockStatus === LockStatus.Lock) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="txt-label-m text-txt-primary-normal">How to Unlock:</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Image src="/icons/diamond.svg" alt="diamond" width={16} height={16} />
|
|
||||||
<div className="txt-numMonotype-s text-txt-primary-normal">
|
|
||||||
{formatFromCents(unlockPrice)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isOwner && lockStatus === LockStatus.Unlock) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="txt-label-m text-txt-primary-normal">How to Unlock:</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Image src="/icons/diamond.svg" alt="diamond" width={16} height={16} />
|
|
||||||
<div className="txt-numMonotype-s text-txt-primary-normal">
|
|
||||||
{formatFromCents(unlockPrice)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return <div className="txt-label-m text-txt-primary-normal">How to Unlock: Free</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderDefaultAction = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="bg-outline-normal h-6 w-px" />
|
|
||||||
<div
|
|
||||||
className="bg-surface-element-light-normal flex h-8 cursor-pointer items-center justify-center gap-2 rounded-full px-3 backdrop-blur-lg"
|
|
||||||
onClick={() => handleSetDefault(albumId)}
|
|
||||||
>
|
|
||||||
<Checkbox shape="round" checked={isDefault} />
|
|
||||||
<div className="txt-label-s">Default</div>
|
|
||||||
</div>
|
|
||||||
<AlertDialog open={isDefaultDialogOpen} onOpenChange={setIsDefaultDialogOpen}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Default image</AlertDialogTitle>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
After setting this as the default image, its unlock method can only be "Free."
|
|
||||||
</AlertDialogDescription>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
loading={setDefaultMutation.isPending}
|
|
||||||
onClick={() => handleConfirmSetDefault(albumId)}
|
|
||||||
>
|
|
||||||
Confirm
|
|
||||||
</Button>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
{!isDefault && <div className="bg-outline-normal h-6 w-px" />}
|
|
||||||
{!isDefault && (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{renderPayAction()}
|
|
||||||
<AlbumPriceSetting
|
|
||||||
defaultUnlockPrice={unlockPrice || 0}
|
|
||||||
onConfirm={handleSetAlbumImageUnlockMethod}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="bg-outline-normal h-6 w-px" />
|
|
||||||
<AlbumDeleteAlert
|
|
||||||
aiId={userId!}
|
|
||||||
albumId={albumId}
|
|
||||||
isDefaultImage={!!isDefault}
|
|
||||||
onDeleted={() => {
|
|
||||||
const nextLength = datas.length - 1
|
|
||||||
if (nextLength <= 0) {
|
|
||||||
onDeleted?.(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const isLast = currentIndex >= nextLength
|
|
||||||
const nextIndex = isLast ? nextLength - 1 : currentIndex
|
|
||||||
onDeleted?.(nextIndex)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconButton iconfont="icon-trashcan" variant="tertiary" size="small" />
|
|
||||||
</AlbumDeleteAlert>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isOwner) {
|
|
||||||
if (!lockStatus || lockStatus === LockStatus.Unlock) {
|
|
||||||
// 没有上锁的图片,显示点赞按钮
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="bg-outline-normal h-6 w-px" />
|
|
||||||
<Button
|
|
||||||
variant="tertiary"
|
|
||||||
size="small"
|
|
||||||
className="gap-1"
|
|
||||||
onClick={() => handleLike(datas[currentIndex].albumId, isLiked)}
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
className={cn('iconfont !text-[16px] leading-none', {
|
|
||||||
'icon-Like': !isLiked,
|
|
||||||
'icon-Like-fill !text-important-normal': isLiked,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<div className="txt-label-s">Like</div>
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="bg-outline-normal h-6 w-px" />
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="small"
|
|
||||||
className="gap-1"
|
|
||||||
onClick={handleUnlock}
|
|
||||||
loading={lockLoading}
|
|
||||||
>
|
|
||||||
Unlock
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{renderDefaultAction()}</>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AlbumImageViewerAction
|
|
||||||
|
|
@ -1,153 +0,0 @@
|
||||||
import { Tag } from '@/components/ui/tag'
|
|
||||||
import { cn, formatNumberToKMB } from '@/lib/utils'
|
|
||||||
import { IAlbumItem, LikedStatus, LockStatus } from '@/services/user'
|
|
||||||
import Image from 'next/image'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { useAIUser } from '../context/aiUser'
|
|
||||||
import { IconButton } from '@/components/ui/button'
|
|
||||||
import AlbumItemAction from './AlbumItemAction'
|
|
||||||
import { formatFromCents } from '@/utils/number'
|
|
||||||
|
|
||||||
interface AlbumItemProps {
|
|
||||||
item: IAlbumItem
|
|
||||||
onLike: (albumId: number, isLiked: boolean) => void
|
|
||||||
onImageClick: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const AlbumItem = ({ item, onLike, onImageClick }: AlbumItemProps) => {
|
|
||||||
const [imageLoading, setImageLoading] = useState(true)
|
|
||||||
const { isOwner } = useAIUser()
|
|
||||||
|
|
||||||
const handleLike = () => {
|
|
||||||
onLike(item.albumId, item.likedStatus === LikedStatus.Liked)
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderTag = () => {
|
|
||||||
if (item.isDefault) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
if (isOwner) {
|
|
||||||
if (item.lockStatus) {
|
|
||||||
return (
|
|
||||||
<Tag variant="dark" className="absolute top-2 right-2 p-[6px]" size="small">
|
|
||||||
<i className="iconfont icon-private !text-[12px] leading-none" />
|
|
||||||
</Tag>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (item.lockStatus === LockStatus.Unlock) {
|
|
||||||
return (
|
|
||||||
<Tag
|
|
||||||
variant="default"
|
|
||||||
className="bg-primary-gradient-normal absolute top-2 right-2 p-[6px]"
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
<i className="iconfont icon-public !text-[12px] leading-none" />
|
|
||||||
</Tag>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderOverlay = () => {
|
|
||||||
// 如果是自己的相册,则不显示解锁按钮
|
|
||||||
if (isOwner) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.lockStatus === LockStatus.Lock) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 flex cursor-pointer flex-col items-center justify-center gap-3"
|
|
||||||
onClick={(e) => {
|
|
||||||
// e.stopPropagation();
|
|
||||||
// handleUnlock();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<i className="iconfont icon-private-border !text-[24px] leading-none" />
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<div className="relative h-4 w-4">
|
|
||||||
<Image src="/icons/diamond.svg" alt="diamond" fill className="object-contain" />
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-semibold text-white">
|
|
||||||
{formatFromCents(item.unlockPrice || 0)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="txt-label-m text-txt-primary-normal">Unlock</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderDefaultTag = () => {
|
|
||||||
if (item.isDefault) {
|
|
||||||
return (
|
|
||||||
<Tag variant="dark" className="absolute top-2 left-2" size="small">
|
|
||||||
Default
|
|
||||||
</Tag>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="group relative cursor-pointer overflow-hidden rounded-2xl pb-[134%]">
|
|
||||||
<div className="absolute inset-0" onClick={onImageClick}>
|
|
||||||
{/* 背景图片 */}
|
|
||||||
<div className="relative h-full w-full">
|
|
||||||
<Image
|
|
||||||
src={item.imgUrl || item.img1}
|
|
||||||
alt="Album image"
|
|
||||||
fill
|
|
||||||
className={cn(
|
|
||||||
'object-cover object-top transition-opacity duration-300',
|
|
||||||
imageLoading ? 'opacity-0' : 'opacity-100'
|
|
||||||
)}
|
|
||||||
onLoadingComplete={() => setImageLoading(false)}
|
|
||||||
sizes="(max-width: 768px) 50vw, 176px"
|
|
||||||
/>
|
|
||||||
{imageLoading && (
|
|
||||||
<div className="bg-surface-nest-normal absolute inset-0 animate-pulse" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{renderDefaultTag()}
|
|
||||||
|
|
||||||
{/* 标签 */}
|
|
||||||
{renderTag()}
|
|
||||||
|
|
||||||
{/* 付费内容遮罩 */}
|
|
||||||
{renderOverlay()}
|
|
||||||
|
|
||||||
{/* 底部操作区 */}
|
|
||||||
<div
|
|
||||||
className="absolute right-2 bottom-2 left-2 flex items-center justify-between"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* 点赞按钮 */}
|
|
||||||
{(item.lockStatus !== LockStatus.Lock || isOwner) && (
|
|
||||||
<div className="bg-surface-element-dark-normal flex items-center gap-[2px] rounded-full pr-1 backdrop-blur-lg">
|
|
||||||
<IconButton variant="ghost" size="xs" onClick={handleLike}>
|
|
||||||
{item.likedStatus === LikedStatus.Liked ? (
|
|
||||||
<i className="iconfont icon-Like-fill !text-important-normal !text-[16px] leading-none" />
|
|
||||||
) : (
|
|
||||||
<i className="iconfont icon-Like !text-[16px] leading-none" />
|
|
||||||
)}
|
|
||||||
</IconButton>
|
|
||||||
<span className="txt-numMonotype-xs text-white">
|
|
||||||
{formatNumberToKMB(item.likedCount ?? 0)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<AlbumItemAction data={item} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AlbumItem
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu'
|
|
||||||
import { IconButton } from '@/components/ui/button'
|
|
||||||
import { useAIUser } from '../context/aiUser'
|
|
||||||
import { IAlbumItem } from '@/services/user/types'
|
|
||||||
import AlbumDeleteAlert from '@/components/features/album-delete-alert'
|
|
||||||
|
|
||||||
const AlbumItemAction = ({ data }: { data: IAlbumItem }) => {
|
|
||||||
const { isOwner, userId } = useAIUser()
|
|
||||||
|
|
||||||
if (!isOwner) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<IconButton
|
|
||||||
variant="tertiaryDark"
|
|
||||||
size="xs"
|
|
||||||
className="relative opacity-0 transition-opacity duration-200 group-hover:opacity-100 data-[state=open]:opacity-100"
|
|
||||||
>
|
|
||||||
<i className="iconfont icon-More !text-[16px] leading-none" />
|
|
||||||
</IconButton>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent>
|
|
||||||
<AlbumDeleteAlert aiId={userId!} albumId={data.albumId} isDefaultImage={!!data.isDefault}>
|
|
||||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
|
||||||
<i className="iconfont icon-trashcan !text-[16px] leading-none" />
|
|
||||||
<span>Delete</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</AlbumDeleteAlert>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AlbumItemAction
|
|
||||||
|
|
@ -1,233 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { useParams, useRouter, usePathname } from 'next/navigation'
|
|
||||||
import {
|
|
||||||
useGetAIUserAlbumInfinite,
|
|
||||||
useLikeAlbumImage,
|
|
||||||
useUnlockAlbumImage,
|
|
||||||
useUnlockImage,
|
|
||||||
} from '@/hooks/aiUser'
|
|
||||||
import { IAlbumItem, LikedStatus, LockStatus } from '@/services/user/types'
|
|
||||||
import Empty from '@/components/ui/empty'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import AlbumItem from './AlbumItem'
|
|
||||||
import { InfiniteScrollList } from '@/components/ui/infinite-scroll-list'
|
|
||||||
import { ImageViewer, ImageViewerPaginationContent } from '@/components/ui/image-viewer'
|
|
||||||
import { useImageViewer } from '@/hooks/useImageViewer'
|
|
||||||
import { useMemo, useRef, useState } from 'react'
|
|
||||||
import { useAIUser } from '../context/aiUser'
|
|
||||||
import AlbumImageViewerAction from './AlbumImageViewerAction'
|
|
||||||
import Image from 'next/image'
|
|
||||||
import { formatFromCents } from '@/utils/number'
|
|
||||||
import { useToken } from '@/hooks/auth'
|
|
||||||
|
|
||||||
// 专门的相册骨架屏组件
|
|
||||||
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 AlbumList = () => {
|
|
||||||
const { userId } = useParams()
|
|
||||||
const router = useRouter()
|
|
||||||
const pathname = usePathname()
|
|
||||||
const { isLogin } = useToken()
|
|
||||||
const pageSize = 20
|
|
||||||
const unlockingAlbumIdsRef = useRef<Set<number>>(new Set())
|
|
||||||
|
|
||||||
const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage, isError, refetch } =
|
|
||||||
useGetAIUserAlbumInfinite(Number(userId), pageSize)
|
|
||||||
const likeMutation = useLikeAlbumImage()
|
|
||||||
const unlockMutation = useUnlockImage()
|
|
||||||
const { isOwner } = useAIUser()
|
|
||||||
const [tempList, setTempList] = useState<IAlbumItem[]>([])
|
|
||||||
|
|
||||||
// 图片查看器
|
|
||||||
const {
|
|
||||||
isOpen: isViewerOpen,
|
|
||||||
currentIndex: viewerIndex,
|
|
||||||
openViewer,
|
|
||||||
closeViewer,
|
|
||||||
handleIndexChange,
|
|
||||||
} = useImageViewer()
|
|
||||||
|
|
||||||
// 展平所有页面的数据
|
|
||||||
const albumItems = useMemo(() => {
|
|
||||||
if (!data?.pages) return []
|
|
||||||
return data.pages.flatMap((page) => page.datas || [])
|
|
||||||
}, [data?.pages])
|
|
||||||
|
|
||||||
const handleLike = (albumId: number, isLiked: boolean) => {
|
|
||||||
likeMutation.mutate({
|
|
||||||
albumId,
|
|
||||||
likedStatus: isLiked ? LikedStatus.Canceled : LikedStatus.Liked,
|
|
||||||
aiId: Number(userId),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUnlock = async (imageId: number) => {
|
|
||||||
await unlockMutation.mutateAsync(
|
|
||||||
{ aiId: Number(userId), albumId: imageId },
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success('Unlocked successfully!')
|
|
||||||
},
|
|
||||||
onError: (error) => {},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleImageClick = (item: IAlbumItem, index: number) => {
|
|
||||||
// 检查是否登录,如果未登录则跳转到登录页面
|
|
||||||
if (!isLogin) {
|
|
||||||
const loginUrl = `/login?redirect=${encodeURIComponent(pathname)}`
|
|
||||||
router.push(loginUrl)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取所有图片URL
|
|
||||||
const imageUrls = albumItems.map((albumItem) => albumItem.imgUrl || albumItem.img3)
|
|
||||||
setTempList(albumItems)
|
|
||||||
// 打开图片查看器
|
|
||||||
openViewer(imageUrls, index)
|
|
||||||
}
|
|
||||||
|
|
||||||
const viewerImages: IAlbumItem[] = useMemo(() => {
|
|
||||||
return tempList.map((item) => {
|
|
||||||
const { albumId } = item
|
|
||||||
const data = albumItems.find((item) => item.albumId === albumId) || {}
|
|
||||||
return data as IAlbumItem
|
|
||||||
})
|
|
||||||
}, [tempList, albumItems])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<InfiniteScrollList<IAlbumItem>
|
|
||||||
items={albumItems}
|
|
||||||
renderItem={(item, index) => (
|
|
||||||
<AlbumItem
|
|
||||||
item={item}
|
|
||||||
onLike={handleLike}
|
|
||||||
onImageClick={() => handleImageClick(item, index)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
getItemKey={(item) => item.albumId}
|
|
||||||
hasNextPage={!!hasNextPage}
|
|
||||||
isLoading={isLoading || isFetchingNextPage}
|
|
||||||
fetchNextPage={fetchNextPage}
|
|
||||||
columns={{
|
|
||||||
default: 2,
|
|
||||||
sm: 3,
|
|
||||||
md: 4,
|
|
||||||
lg: 4,
|
|
||||||
xl: 5,
|
|
||||||
}}
|
|
||||||
gap={4}
|
|
||||||
LoadingSkeleton={AlbumSkeleton}
|
|
||||||
EmptyComponent={() => (
|
|
||||||
<div className="bg-surface-base-normal rounded-lg p-6 py-[117px]">
|
|
||||||
<Empty title="No photos yet" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
hasError={isError}
|
|
||||||
onRetry={refetch}
|
|
||||||
threshold={300}
|
|
||||||
enabled={true}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 图片查看器 */}
|
|
||||||
<ImageViewer
|
|
||||||
images={viewerImages.map((albumItem) => albumItem.imgUrl || albumItem.img3)}
|
|
||||||
currentIndex={viewerIndex}
|
|
||||||
open={isViewerOpen}
|
|
||||||
onClose={closeViewer}
|
|
||||||
onIndexChange={handleIndexChange}
|
|
||||||
showChooseButton={false}
|
|
||||||
ActionComponent={() => {
|
|
||||||
return (
|
|
||||||
<AlbumImageViewerAction
|
|
||||||
datas={tempList}
|
|
||||||
originalDatas={albumItems}
|
|
||||||
currentIndex={viewerIndex}
|
|
||||||
onUnlock={handleUnlock}
|
|
||||||
unlockingAlbumIdsRef={unlockingAlbumIdsRef}
|
|
||||||
onDeleted={(nextIndex) => {
|
|
||||||
if (nextIndex === null) {
|
|
||||||
// 删除后没有图片了
|
|
||||||
closeViewer()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 调整到新的索引,避免越界
|
|
||||||
handleIndexChange(nextIndex)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
OverlayComponent={() => {
|
|
||||||
const findItem = albumItems.find((item, index) => index === viewerIndex)
|
|
||||||
const { unlockPrice, lockStatus } = findItem || {}
|
|
||||||
if (isOwner || !lockStatus || lockStatus === LockStatus.Unlock) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-6 bg-black/15 backdrop-blur-3xl">
|
|
||||||
<i className="iconfont icon-private !text-[48px] leading-none" />
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Image src="/icons/diamond.svg" alt="diamond" width={32} height={32} />
|
|
||||||
<div className="txt-title-m">{`${formatFromCents(unlockPrice || 0)} to unlock`}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
PaginationComponent={() => {
|
|
||||||
const currentIndex = viewerIndex + 1
|
|
||||||
const findItem = tempList.find((item, index) => index === viewerIndex)
|
|
||||||
const { albumId } = findItem || {}
|
|
||||||
const { lockStatus } = albumItems.find((item) => item.albumId === albumId) || {}
|
|
||||||
|
|
||||||
if (isOwner) {
|
|
||||||
if (lockStatus) {
|
|
||||||
return (
|
|
||||||
<ImageViewerPaginationContent className="gap-2">
|
|
||||||
<i className="iconfont icon-private !text-[16px] leading-none" />
|
|
||||||
<span>{`${currentIndex}/${albumItems.length}`}</span>
|
|
||||||
</ImageViewerPaginationContent>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<ImageViewerPaginationContent>
|
|
||||||
<span>{`${currentIndex}/${albumItems.length}`}</span>
|
|
||||||
</ImageViewerPaginationContent>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (lockStatus === LockStatus.Lock) {
|
|
||||||
return (
|
|
||||||
<ImageViewerPaginationContent className="bg-primary-gradient-normal gap-2">
|
|
||||||
<i className="iconfont icon-private !text-[16px] leading-none" />
|
|
||||||
<span>{`${currentIndex}/${albumItems.length}`}</span>
|
|
||||||
</ImageViewerPaginationContent>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (lockStatus === LockStatus.Unlock) {
|
|
||||||
return (
|
|
||||||
<ImageViewerPaginationContent className="bg-primary-gradient-normal gap-2">
|
|
||||||
<i className="iconfont icon-public !text-[16px] leading-none" />
|
|
||||||
<span>{`${currentIndex}/${albumItems.length}`}</span>
|
|
||||||
</ImageViewerPaginationContent>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<ImageViewerPaginationContent>
|
|
||||||
<span>{`${currentIndex}/${albumItems.length}`}</span>
|
|
||||||
</ImageViewerPaginationContent>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AlbumList
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
'use client'
|
|
||||||
import Empty from '@/components/ui/empty'
|
|
||||||
import { useGetAIUserGifts } from '@/hooks/aiUser'
|
|
||||||
import { formatNumberToKMB } from '@/lib/utils'
|
|
||||||
import Image from 'next/image'
|
|
||||||
|
|
||||||
export function GiftGrid({ userId }: { userId: string }) {
|
|
||||||
const { data } = useGetAIUserGifts({ aiId: Number(userId), page: { pn: 1, ps: 100 } })
|
|
||||||
const { datas } = data || {}
|
|
||||||
|
|
||||||
if (datas && !datas.length) {
|
|
||||||
return (
|
|
||||||
<div className="flex w-full flex-col items-start justify-start gap-4 rounded-2xl bg-[#352e3e] p-6">
|
|
||||||
<h3 className="font-['Poppins'] text-[20px] leading-[24px] font-semibold text-white">
|
|
||||||
Gifts
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="w-full py-20">
|
|
||||||
<Empty title="No gifts yet" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex w-full flex-col items-start justify-start gap-4 rounded-2xl bg-[#352e3e] p-6">
|
|
||||||
<h3 className="font-['Poppins'] text-[20px] leading-[24px] font-semibold text-white">
|
|
||||||
Gifts
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="grid w-full gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-6">
|
|
||||||
{datas?.map((gift) => (
|
|
||||||
<div key={gift.id} className="flex flex-col items-start justify-start gap-1">
|
|
||||||
<div className="bg-surface-nest-normal relative flex w-full items-center justify-center rounded-sm pb-[100%]">
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
|
||||||
<Image
|
|
||||||
src={gift.icon ?? ''}
|
|
||||||
alt={gift.name ?? ''}
|
|
||||||
width={100}
|
|
||||||
height={100}
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="txt-label-m text-txt-primary-normal w-full text-center">
|
|
||||||
{gift.name} X{formatNumberToKMB(gift.getNum ?? 0)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { UserTab } from '../types'
|
|
||||||
|
|
||||||
interface TabNavigationProps {
|
|
||||||
activeTab?: UserTab
|
|
||||||
onTabChange?: (tab: UserTab) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TabNavigation({ activeTab = UserTab.About, onTabChange }: TabNavigationProps) {
|
|
||||||
const [currentTab, setCurrentTab] = useState(activeTab)
|
|
||||||
|
|
||||||
const handleTabClick = (tab: UserTab) => {
|
|
||||||
setCurrentTab(tab)
|
|
||||||
onTabChange?.(tab)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-row items-center justify-start">
|
|
||||||
{/* About 选项卡 */}
|
|
||||||
<div className="flex flex-col items-start justify-start py-0 pr-4 pl-0">
|
|
||||||
<button
|
|
||||||
onClick={() => handleTabClick(UserTab.About)}
|
|
||||||
className={cn(
|
|
||||||
'flex h-8 w-full flex-col items-center justify-start gap-1',
|
|
||||||
'transition-colors duration-200'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"text-left font-['Poppins'] text-[20px] leading-[24px] font-semibold whitespace-nowrap",
|
|
||||||
currentTab === UserTab.About ? 'text-white' : 'text-[#958e9e]'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
About
|
|
||||||
</div>
|
|
||||||
{currentTab === UserTab.About && <div className="h-1 w-5 rounded bg-[#d21f77]" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Album 选项卡 */}
|
|
||||||
<div className="flex flex-col items-start justify-start py-0 pr-4 pl-0">
|
|
||||||
<button
|
|
||||||
onClick={() => handleTabClick(UserTab.Album)}
|
|
||||||
className={cn(
|
|
||||||
'flex h-8 w-full flex-col items-center justify-start gap-1',
|
|
||||||
'transition-colors duration-200'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"text-left font-['Poppins'] text-[20px] leading-[24px] font-semibold whitespace-nowrap",
|
|
||||||
currentTab === UserTab.Album ? 'text-white' : 'text-[#958e9e]'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Album
|
|
||||||
</div>
|
|
||||||
{currentTab === UserTab.Album && <div className="h-1 w-5 rounded bg-[#d21f77]" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from '@/components/ui/alert-dialog'
|
|
||||||
import { IconButton } from '@/components/ui/button'
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu'
|
|
||||||
import { useDeleteCharacter } from '@/hooks/aiUser'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { useAIUser } from '../context/aiUser'
|
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
|
||||||
import { useNimChat, useNimConversation, useNimMsgContext } from '@/context/NimChat/useNimChat'
|
|
||||||
|
|
||||||
const UserActionDropdown = () => {
|
|
||||||
const router = useRouter()
|
|
||||||
const [isDeleteCharacterDialogOpen, setIsDeleteCharacterDialogOpen] = useState(false)
|
|
||||||
const { userId, isOwner } = useAIUser()
|
|
||||||
const { removeConversationById } = useNimConversation()
|
|
||||||
const { clearHistoryMessage } = useNimMsgContext()
|
|
||||||
const { nim } = useNimChat()
|
|
||||||
|
|
||||||
const { mutate: deleteCharacter, isPending: isDeleteCharacterLoading } = useDeleteCharacter({
|
|
||||||
aiId: Number(userId),
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleDeleteCharacter = () => {
|
|
||||||
setIsDeleteCharacterDialogOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDeleteCharacterConfirm = async () => {
|
|
||||||
const conversationId = await nim.V2NIMConversationIdUtil.p2pConversationId(
|
|
||||||
`${userId}@r@t` as string
|
|
||||||
)
|
|
||||||
await removeConversationById(conversationId)
|
|
||||||
await clearHistoryMessage(conversationId)
|
|
||||||
await deleteCharacter({ aiId: Number(userId) })
|
|
||||||
router.replace('/profile')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isOwner) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="absolute top-0 right-0 left-0">
|
|
||||||
<div className="mx-auto max-w-[1120px] px-6">
|
|
||||||
<div className="relative w-full">
|
|
||||||
<div className="absolute top-0 right-0">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<IconButton iconfont="icon-More" size="large" variant="tertiaryDark" />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={handleDeleteCharacter}>
|
|
||||||
<i className="iconfont icon-trashcan !text-[16px] leading-none" />
|
|
||||||
<span>Delete Character</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AlertDialog open={isDeleteCharacterDialogOpen} onOpenChange={setIsDeleteCharacterDialogOpen}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Delete Character</AlertDialogTitle>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
Once deleted, the character cannot be restored. To ensure a good user experience, users
|
|
||||||
who have previously chatted with or made purchases for this character will still be able
|
|
||||||
to interact with them.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
variant="destructive"
|
|
||||||
loading={isDeleteCharacterLoading}
|
|
||||||
onClick={handleDeleteCharacterConfirm}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default UserActionDropdown
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
import { useGetAIUserBaseInfo } from '@/hooks/aiUser'
|
|
||||||
import { cn, loadImageAsync } from '@/lib/utils'
|
|
||||||
import { useParams } from 'next/navigation'
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
|
||||||
|
|
||||||
const getDominantColor = (data: Uint8ClampedArray) => {
|
|
||||||
let red = 0,
|
|
||||||
green = 0,
|
|
||||||
blue = 0
|
|
||||||
const length = data.length
|
|
||||||
for (let i = 0; i < length; i += 4) {
|
|
||||||
red += data[i]
|
|
||||||
green += data[i + 1]
|
|
||||||
blue += data[i + 2]
|
|
||||||
}
|
|
||||||
// 计算像素点数
|
|
||||||
const pixelCount = length / 4
|
|
||||||
red = Math.round(red / pixelCount)
|
|
||||||
green = Math.round(green / pixelCount)
|
|
||||||
blue = Math.round(blue / pixelCount)
|
|
||||||
return `${red}, ${green}, ${blue}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const getImageRightDominantColor = (ctx: any, image: any) => {
|
|
||||||
const imageData = ctx.getImageData(image.width - 1, 0, 1, image.height)
|
|
||||||
return getDominantColor(imageData.data)
|
|
||||||
}
|
|
||||||
const getImageLeftDominantColor = (ctx: any, image: any) => {
|
|
||||||
const imageData = ctx.getImageData(0, 0, 1, image.height)
|
|
||||||
return getDominantColor(imageData.data)
|
|
||||||
}
|
|
||||||
|
|
||||||
const UserBackground = () => {
|
|
||||||
const { userId } = useParams()
|
|
||||||
const { data } = useGetAIUserBaseInfo({ aiId: Number(userId) })
|
|
||||||
const [colors, setColors] = useState({
|
|
||||||
leftColor: '#37363b',
|
|
||||||
rightColor: '#313133',
|
|
||||||
})
|
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
||||||
|
|
||||||
// 获取图片两边的颜色
|
|
||||||
const getImageColors = async (url: string) => {
|
|
||||||
const image: any = await loadImageAsync(url)
|
|
||||||
const canvas: any = canvasRef.current
|
|
||||||
canvas.width = image.width
|
|
||||||
canvas.height = image.height
|
|
||||||
const ctx = canvas.getContext('2d')
|
|
||||||
ctx.drawImage(image, 0, 0)
|
|
||||||
const rightColor = getImageRightDominantColor(ctx, image)
|
|
||||||
const leftColor = getImageLeftDominantColor(ctx, image)
|
|
||||||
return {
|
|
||||||
leftColor,
|
|
||||||
rightColor,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const init = async () => {
|
|
||||||
if (data?.homeImageUrl) {
|
|
||||||
setIsLoading(true)
|
|
||||||
try {
|
|
||||||
const { leftColor, rightColor } = await getImageColors(data.homeImageUrl)
|
|
||||||
setColors({
|
|
||||||
leftColor,
|
|
||||||
rightColor,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取图片颜色失败:', error)
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
init()
|
|
||||||
}, [data?.homeImageUrl])
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* 背景头部区域 */}
|
|
||||||
<div className="bg-background-default absolute top-0 right-0 left-0 h-60 overflow-hidden">
|
|
||||||
{/* 加载状态 - 显示默认背景 */}
|
|
||||||
{isLoading && (
|
|
||||||
<>
|
|
||||||
<div className="absolute top-0 right-1/2 left-0 h-60 bg-[#36363b]" />
|
|
||||||
<div className="absolute top-0 right-0 left-1/2 h-60 bg-[#313133]" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 加载完成后的背景 */}
|
|
||||||
{!isLoading && (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className="absolute top-0 right-1/2 left-0 h-60 bg-[#36363b] transition-all duration-500 ease-in-out"
|
|
||||||
style={{ background: `rgb(${colors.leftColor})` }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="absolute top-0 right-0 left-1/2 h-60 bg-[#313133] transition-all duration-500 ease-in-out"
|
|
||||||
style={{ background: `rgb(${colors.rightColor})` }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 背景图片 */}
|
|
||||||
<div
|
|
||||||
className="absolute top-[-167px] left-1/2 h-[1337px] w-[752px] translate-x-[-50%] bg-cover bg-center bg-no-repeat transition-opacity duration-500 ease-in-out"
|
|
||||||
style={{ backgroundImage: `url(${data?.homeImageUrl || ''})` }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 渐变遮罩 */}
|
|
||||||
<div
|
|
||||||
className="absolute top-0 left-1/2 flex h-full w-[752px] translate-x-[-50%] items-center justify-between transition-opacity duration-500 ease-in-out"
|
|
||||||
style={{
|
|
||||||
backgroundImage: `linear-gradient(90deg, rgba(${colors.leftColor}, 1) 0%, rgba(${colors.leftColor}, 0) 20%, rgba(${colors.rightColor}, 0) 80%, rgba(${colors.rightColor}, 1) 100%)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* <div className={cn('h-60 w-32', `bg-gradient-to-r from-[${colors.leftColor}] to-transparent`)} />
|
|
||||||
<div className={cn('h-60 w-32', `bg-gradient-to-l from-[${colors.rightColor}] to-transparent`)} /> */}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 底部渐变遮罩 */}
|
|
||||||
<div className="to-background-default absolute inset-0 bg-linear-to-b from-transparent" />
|
|
||||||
<canvas ref={canvasRef} style={{ display: 'none' }}></canvas>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default UserBackground
|
|
||||||
|
|
@ -1,223 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { Button, IconButton } from '@/components/ui/button'
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
|
||||||
import { useGetAIUserBaseInfo, useGetAIUserStat } from '@/hooks/aiUser'
|
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
|
||||||
import { formatNumberToKMB, getAge } from '@/lib/utils'
|
|
||||||
import { Tag } from '@/components/ui/tag'
|
|
||||||
import Image from 'next/image'
|
|
||||||
import { useAIUser } from '../context/aiUser'
|
|
||||||
import { useEditFormStorage, useEditAI, useEditAIAvatar } from '@/hooks/create'
|
|
||||||
import { useNimChat, useNimConversation } from '@/context/NimChat/useNimChat'
|
|
||||||
import { useCurrentUser, useToken } from '@/hooks/auth'
|
|
||||||
import { AvatarCropModal } from '@/components/ui/avatar-crop-modal'
|
|
||||||
import { useState, useRef } from 'react'
|
|
||||||
import { BizTypeEnum } from '@/services/common'
|
|
||||||
import { AIPermission, CreateOrEditAiRequest } from '@/services/create'
|
|
||||||
import UserShare from './UserShare'
|
|
||||||
import UserLikeButton from './UserLikeButton'
|
|
||||||
import Decimal from 'decimal.js'
|
|
||||||
|
|
||||||
const genderMap = {
|
|
||||||
0: '/icons/male.svg',
|
|
||||||
1: '/icons/female.svg',
|
|
||||||
2: '/icons/gender-neutral.svg',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UserCard() {
|
|
||||||
const { userId } = useParams()
|
|
||||||
const { data } = useGetAIUserBaseInfo({ aiId: Number(userId) })
|
|
||||||
const { isOwner } = useAIUser()
|
|
||||||
const router = useRouter()
|
|
||||||
const { insertConversationActive } = useNimConversation()
|
|
||||||
|
|
||||||
const { birthday, characterName, headImg, nickname, sex, tagName, homeImageUrl } = data || {}
|
|
||||||
const { data: statData } = useGetAIUserStat({ aiId: Number(userId) })
|
|
||||||
const { chatNum, coinNum, conversationNum, likedNum } = statData || {}
|
|
||||||
const { clearFormData } = useEditFormStorage()
|
|
||||||
const { isNimLoggedIn } = useNimChat()
|
|
||||||
const { getLoginStatus } = useToken()
|
|
||||||
|
|
||||||
// AI用户编辑hook
|
|
||||||
const { mutateAsync: editAIAvatar, isPending: isEditingAIAvatar } = useEditAIAvatar()
|
|
||||||
|
|
||||||
// 头像裁剪相关状态
|
|
||||||
const [showCropModal, setShowCropModal] = useState(false)
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
||||||
|
|
||||||
const statList = [
|
|
||||||
{
|
|
||||||
label: 'Likes',
|
|
||||||
value: formatNumberToKMB(likedNum || 0),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Chats',
|
|
||||||
value: formatNumberToKMB(chatNum || 0),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Users',
|
|
||||||
value: formatNumberToKMB(conversationNum || 0),
|
|
||||||
},
|
|
||||||
isOwner && {
|
|
||||||
label: 'CrushCoin',
|
|
||||||
value: formatNumberToKMB(new Decimal(coinNum || 0).div(100).toNumber()),
|
|
||||||
},
|
|
||||||
].filter(Boolean) as { label: string; value: string | number }[]
|
|
||||||
|
|
||||||
const handleEdit = () => {
|
|
||||||
clearFormData()
|
|
||||||
router.push(`/edit/${userId}/type`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleChat = () => {
|
|
||||||
if (!getLoginStatus()) {
|
|
||||||
router.push(`/chat/${userId}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isNimLoggedIn) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// insertConversationActive({
|
|
||||||
// receiverId: `${userId}@r@t` as string,
|
|
||||||
// })
|
|
||||||
router.push(`/chat/${userId}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理头像点击
|
|
||||||
const handleAvatarClick = () => {
|
|
||||||
if (!isOwner) return
|
|
||||||
setShowCropModal(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理裁剪完成
|
|
||||||
const handleCropComplete = async (croppedImageUrl: string) => {
|
|
||||||
try {
|
|
||||||
// 更新AI用户头像 - 只更新头像,其他信息保持原样
|
|
||||||
await editAIAvatar({
|
|
||||||
aiId: Number(userId),
|
|
||||||
userHead: croppedImageUrl,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
// 可以添加错误提示
|
|
||||||
} finally {
|
|
||||||
setShowCropModal(false)
|
|
||||||
// 清空文件输入
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理裁剪取消
|
|
||||||
const handleCropCancel = () => {
|
|
||||||
setShowCropModal(false)
|
|
||||||
// 清空文件输入
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderChatButton = () => {
|
|
||||||
if (isOwner) {
|
|
||||||
return (
|
|
||||||
<Button variant="tertiary" size="large" className="flex-1" onClick={handleEdit}>
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button size="large" className="flex-1" onClick={handleChat}>
|
|
||||||
Chat
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
{/* 背景卡片 */}
|
|
||||||
<div className="absolute top-16 right-0 bottom-0 left-0 rounded-2xl bg-[#352e3e]" />
|
|
||||||
|
|
||||||
{/* 内容 */}
|
|
||||||
<div className="relative flex flex-col items-center justify-start gap-6 px-6 pt-0 pb-6">
|
|
||||||
{/* 头像 */}
|
|
||||||
<div className="relative size-32">
|
|
||||||
<Avatar
|
|
||||||
className={`size-full ${isOwner ? 'cursor-pointer transition-opacity hover:opacity-80' : ''}`}
|
|
||||||
onClick={handleAvatarClick}
|
|
||||||
>
|
|
||||||
<AvatarImage src={headImg} width={128} height={128} className="object-cover" />
|
|
||||||
<AvatarFallback className="!txt-headline-l text-txt-primary-normal">
|
|
||||||
{nickname?.slice(0, 1)}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 用户信息 */}
|
|
||||||
<div className="flex w-full flex-col items-start justify-start gap-4">
|
|
||||||
{/* 用户名 */}
|
|
||||||
<h1 className="txt-headline-s w-full truncate text-center">{nickname}</h1>
|
|
||||||
|
|
||||||
{/* 标签 */}
|
|
||||||
<div className="flex w-full flex-wrap items-start justify-center gap-2">
|
|
||||||
{/* 年龄和性别标签 */}
|
|
||||||
<Tag>
|
|
||||||
<Image src={genderMap[sex ?? 0]} alt="Gender" width={16} height={16} />
|
|
||||||
<div>{getAge(birthday ?? 0)}</div>
|
|
||||||
</Tag>
|
|
||||||
<Tag>{characterName}</Tag>
|
|
||||||
<Tag>{tagName}</Tag>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 统计数据 */}
|
|
||||||
<div className="bg-surface-nest-normal flex w-full flex-row items-start justify-start rounded-sm px-1 py-3">
|
|
||||||
{statList.map((item, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="flex flex-1 flex-col items-center justify-start gap-1 px-1 py-0"
|
|
||||||
>
|
|
||||||
<div className="txt-numDisplay-s text-txt-primary-normal">{item.value}</div>
|
|
||||||
<div className="txt-label-s text-txt-secondary-normal">{item.label}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
|
||||||
<div className="flex w-full flex-row items-start justify-center gap-4">
|
|
||||||
{/* 分享按钮 */}
|
|
||||||
<UserShare />
|
|
||||||
|
|
||||||
{/* 聊天 */}
|
|
||||||
{isOwner && (
|
|
||||||
<IconButton variant="tertiary" size="large" onClick={handleChat}>
|
|
||||||
<i className="iconfont icon-Chat" />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<UserLikeButton />
|
|
||||||
|
|
||||||
{renderChatButton()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 头像裁剪弹窗 */}
|
|
||||||
{homeImageUrl && (
|
|
||||||
<AvatarCropModal
|
|
||||||
isOpen={showCropModal}
|
|
||||||
onClose={() => setShowCropModal(false)}
|
|
||||||
image={homeImageUrl}
|
|
||||||
onConfirm={handleCropComplete}
|
|
||||||
onCancel={handleCropCancel}
|
|
||||||
bizType={BizTypeEnum.Album}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
'use client'
|
|
||||||
import { IconButton } from '@/components/ui/button'
|
|
||||||
import { useGetAIUserBaseInfo } from '@/hooks/aiUser'
|
|
||||||
import { useDoAiUserLiked } from '@/hooks/useCommon'
|
|
||||||
import { aiUserKeys, imKeys } from '@/lib/query-keys'
|
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
|
||||||
import { useToken } from '@/hooks/auth'
|
|
||||||
|
|
||||||
const UserLikeButton = () => {
|
|
||||||
const { userId } = useParams()
|
|
||||||
const router = useRouter()
|
|
||||||
const { isLogin } = useToken()
|
|
||||||
const { data } = useGetAIUserBaseInfo({ aiId: Number(userId) })
|
|
||||||
const { mutateAsync: doAiUserLiked } = useDoAiUserLiked()
|
|
||||||
const { liked } = data || {}
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
const handleLike = () => {
|
|
||||||
// 检查是否登录,如果未登录则跳转到登录页面
|
|
||||||
if (!isLogin) {
|
|
||||||
const currentPath = `/@${userId}`
|
|
||||||
router.push(`/login?redirect=${encodeURIComponent(currentPath)}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
doAiUserLiked({ aiId: Number(userId), likedStatus: liked ? 'CANCELED' : 'LIKED' })
|
|
||||||
queryClient.setQueryData(aiUserKeys.baseInfo({ aiId: Number(userId) }), (oldData: any) => {
|
|
||||||
return {
|
|
||||||
...oldData,
|
|
||||||
liked: !liked,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
queryClient.setQueryData(imKeys.imUserInfo(Number(userId)), (oldData: any) => {
|
|
||||||
return {
|
|
||||||
...oldData,
|
|
||||||
liked: !liked,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
queryClient.setQueryData(aiUserKeys.stat({ aiId: Number(userId) }), (oldData: any) => {
|
|
||||||
return {
|
|
||||||
...oldData,
|
|
||||||
likedNum: !liked ? oldData.likedNum + 1 : oldData.likedNum - 1,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IconButton variant="tertiary" size="large" onClick={handleLike}>
|
|
||||||
{!liked ? (
|
|
||||||
<i className="iconfont icon-Like" />
|
|
||||||
) : (
|
|
||||||
<i className="iconfont icon-Like-fill text-important-normal" />
|
|
||||||
)}
|
|
||||||
</IconButton>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default UserLikeButton
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
import { TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
||||||
import { UserTab } from '../types'
|
|
||||||
import { useAIUser } from '../context/aiUser'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
|
|
||||||
const UserProfileTabs = ({ currentTab }: { currentTab: UserTab }) => {
|
|
||||||
const { isOwner, userId } = useAIUser()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const handleCreatePhoto = () => {
|
|
||||||
router.push(`/generate/image-2-image?id=${userId}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
{/* Tab 列表 */}
|
|
||||||
<TabsList className="mb-0 h-auto w-fit justify-start gap-4 bg-transparent p-0">
|
|
||||||
<TabsTrigger
|
|
||||||
value={UserTab.About}
|
|
||||||
className="relative h-auto cursor-pointer flex-col gap-1"
|
|
||||||
>
|
|
||||||
<span className="txt-title-m text-txt-secondary-normal group-data-[state=active]:text-txt-primary-normal">
|
|
||||||
About
|
|
||||||
</span>
|
|
||||||
<div className="bg-primary-normal h-1 w-5 rounded opacity-0 group-data-[state=active]:opacity-100" />
|
|
||||||
</TabsTrigger>
|
|
||||||
|
|
||||||
<TabsTrigger
|
|
||||||
value={UserTab.Album}
|
|
||||||
className="relative h-auto cursor-pointer flex-col gap-1"
|
|
||||||
>
|
|
||||||
<span className="txt-title-m text-txt-secondary-normal group-data-[state=active]:text-txt-primary-normal">
|
|
||||||
Album
|
|
||||||
</span>
|
|
||||||
<div className="bg-primary-normal h-1 w-5 rounded opacity-0 group-data-[state=active]:opacity-100" />
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{isOwner && currentTab === UserTab.Album && (
|
|
||||||
<Button variant="secondary" size="small" onClick={handleCreatePhoto}>
|
|
||||||
Generate
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default UserProfileTabs
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { IconButton } from '@/components/ui/button'
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu'
|
|
||||||
import useShare from '@/hooks/useShare'
|
|
||||||
import { useParams } from 'next/navigation'
|
|
||||||
|
|
||||||
const UserShare = () => {
|
|
||||||
const { userId } = useParams()
|
|
||||||
|
|
||||||
const { shareFacebook, shareTwitter } = useShare()
|
|
||||||
|
|
||||||
const handleShareFacebook = () => {
|
|
||||||
shareFacebook({
|
|
||||||
text: 'Come to Crushlevel for chat, Crush, and AI - chat.',
|
|
||||||
shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${userId}`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const handleShareTwitter = () => {
|
|
||||||
shareTwitter({
|
|
||||||
text: 'Come to Crushlevel for chat, Crush, and AI - chat.',
|
|
||||||
shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${userId}`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<IconButton variant="tertiary" size="large">
|
|
||||||
<i className="iconfont icon-Share-border" />
|
|
||||||
</IconButton>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent>
|
|
||||||
<DropdownMenuItem onClick={handleShareFacebook}>
|
|
||||||
<i className="iconfont icon-social-facebook" />
|
|
||||||
<span>Share to Facebook</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={handleShareTwitter}>
|
|
||||||
<i className="iconfont icon-social-twitter" />
|
|
||||||
<span>Share to X</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default UserShare
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { AiUserBaseOutput } from '@/services/user'
|
|
||||||
import { createContext } from 'react'
|
|
||||||
import { useGetAIUserBaseInfo } from '@/hooks/aiUser'
|
|
||||||
import { useParams } from 'next/navigation'
|
|
||||||
import { useCurrentUser } from '@/hooks/auth'
|
|
||||||
import Empty from '@/components/ui/empty'
|
|
||||||
|
|
||||||
export * from './useAIUser'
|
|
||||||
|
|
||||||
const AIUserContext = createContext<{
|
|
||||||
user: AiUserBaseOutput | undefined
|
|
||||||
isOwner: boolean
|
|
||||||
userId: number | undefined
|
|
||||||
}>({
|
|
||||||
user: undefined,
|
|
||||||
isOwner: false,
|
|
||||||
userId: undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const AIUserProvider = ({ children }: { children: React.ReactNode }) => {
|
|
||||||
const { userId } = useParams()
|
|
||||||
|
|
||||||
const { data, error } = useGetAIUserBaseInfo({ aiId: Number(userId) })
|
|
||||||
const { data: currentUser } = useCurrentUser()
|
|
||||||
const isOwner = currentUser?.userId === data?.userId
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<Empty title="Oops, there’s nothing here…" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AIUserContext.Provider value={{ user: data, isOwner, userId: Number(userId) }}>
|
|
||||||
{children}
|
|
||||||
</AIUserContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AIUserContext
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { useContext } from 'react'
|
|
||||||
import AIUserContext from '.'
|
|
||||||
|
|
||||||
export const useAIUser = () => {
|
|
||||||
const context = useContext(AIUserContext)
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useAIUser must be used within a AIUserProvider')
|
|
||||||
}
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
import Empty from '@/components/ui/empty'
|
|
||||||
|
|
||||||
export default async function NotFound() {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<Empty title="Oops, there’s nothing here…" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
import UserPage from './user-page'
|
|
||||||
import { HydrationBoundary } from '@tanstack/react-query'
|
|
||||||
import { dehydrate } from '@tanstack/react-query'
|
|
||||||
import { QueryClient } from '@tanstack/react-query'
|
|
||||||
import { aiUserKeys } from '@/lib/query-keys'
|
|
||||||
import { userService } from '@/services/user'
|
|
||||||
import { ApiError } from '@/types/api'
|
|
||||||
import { notFound, redirect } from 'next/navigation'
|
|
||||||
import { headers } from 'next/headers'
|
|
||||||
import { isMobileDevice } from '@/utils/device'
|
|
||||||
|
|
||||||
export const generateMetadata = async ({ params }: { params: Promise<{ userId: string }> }) => {
|
|
||||||
const { userId } = await params
|
|
||||||
try {
|
|
||||||
const resp = await userService.getSEOUserBaseInfo({ aiId: Number(userId) })
|
|
||||||
const { nickname, homeImageUrl } = resp || {}
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: `${nickname} - Crushlevel AI`,
|
|
||||||
description: `${nickname}`,
|
|
||||||
openGraph: {
|
|
||||||
title: `Crushlevel AI`,
|
|
||||||
description: `Grow your love story with CrushLevel AI—From ‘Hi’ to ‘I Do', sparked by every chat`,
|
|
||||||
images: {
|
|
||||||
url: homeImageUrl,
|
|
||||||
width: 720,
|
|
||||||
height: 1280,
|
|
||||||
alt: `${nickname} - Crushlevel AI`,
|
|
||||||
},
|
|
||||||
siteName: 'Crushlevel AI',
|
|
||||||
type: 'website',
|
|
||||||
url: `https://www.crushlevel.com/@${userId}`,
|
|
||||||
},
|
|
||||||
twitter: {
|
|
||||||
title: `Crushlevel AI`,
|
|
||||||
description: `Grow your love story with CrushLevel AI—From ‘Hi’ to ‘I Do', sparked by every chat`,
|
|
||||||
images: {
|
|
||||||
url: homeImageUrl,
|
|
||||||
width: 720,
|
|
||||||
height: 1280,
|
|
||||||
alt: `${nickname} - Crushlevel AI`,
|
|
||||||
},
|
|
||||||
siteName: 'Crushlevel AI',
|
|
||||||
type: 'website',
|
|
||||||
url: `https://www.crushlevel.com/@${userId}`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
title: `Crushlevel AI`,
|
|
||||||
description: `Grow your love story with CrushLevel AI—From ‘Hi’ to ‘I Do', sparked by every chat`,
|
|
||||||
openGraph: {
|
|
||||||
title: `Crushlevel AI`,
|
|
||||||
description: `Grow your love story with CrushLevel AI—From ‘Hi’ to ‘I Do', sparked by every chat`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const Page = async ({ params }: { params: Promise<{ userId: string }> }) => {
|
|
||||||
const { userId } = await params
|
|
||||||
|
|
||||||
// 检测移动端设备并重定向到分享页面
|
|
||||||
const headersList = await headers()
|
|
||||||
const userAgent = headersList.get('user-agent') || ''
|
|
||||||
|
|
||||||
if (isMobileDevice(userAgent)) {
|
|
||||||
redirect(`/share/${userId}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryClient = new QueryClient()
|
|
||||||
|
|
||||||
// try {
|
|
||||||
// // 使用 fetchQuery 替代 prefetchQuery,因为 fetchQuery 会抛出错误
|
|
||||||
// await queryClient.fetchQuery({
|
|
||||||
// queryKey: aiUserKeys.tempBaseInfo({ aiId: Number(userId) }),
|
|
||||||
// queryFn: () => userService.getAIUserBaseInfo({ aiId: Number(userId) }),
|
|
||||||
// });
|
|
||||||
// } catch (error) {
|
|
||||||
// if (error instanceof ApiError && error.errorCode === "10010012") {
|
|
||||||
// notFound();
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
return (
|
|
||||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
|
||||||
<UserPage />
|
|
||||||
</HydrationBoundary>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Page
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
export enum UserTab {
|
|
||||||
About = 'about',
|
|
||||||
Album = 'album',
|
|
||||||
}
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { useParams, useSearchParams, useRouter } from 'next/navigation'
|
|
||||||
import { UserCard } from './components/UserCard'
|
|
||||||
import { AboutSection } from './components/AboutSection'
|
|
||||||
import { GiftGrid } from './components/GiftGrid'
|
|
||||||
import UserBackground from './components/UserBackground'
|
|
||||||
import AlbumList from './components/AlbumList'
|
|
||||||
import { UserTab } from './types'
|
|
||||||
import { useGetAIUserBaseInfo } from '@/hooks/aiUser'
|
|
||||||
import { Tabs, TabsContent } from '@/components/ui/tabs'
|
|
||||||
import { AIUserProvider } from './context/aiUser'
|
|
||||||
import UserActionDropdown from './components/UserActionDropdown'
|
|
||||||
import UserProfileTabs from './components/UserProfileTabs'
|
|
||||||
import { useCallback } from 'react'
|
|
||||||
|
|
||||||
const UserPage = () => {
|
|
||||||
const { userId } = useParams()
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const router = useRouter()
|
|
||||||
const { data } = useGetAIUserBaseInfo({ aiId: Number(userId) })
|
|
||||||
const { introduction } = data || {}
|
|
||||||
|
|
||||||
// 从 URL 参数中获取当前 tab,如果没有或无效则默认为 About
|
|
||||||
const tabParam = searchParams.get('tab')
|
|
||||||
const currentTab = (
|
|
||||||
Object.values(UserTab).includes(tabParam as UserTab) ? tabParam : UserTab.About
|
|
||||||
) as UserTab
|
|
||||||
|
|
||||||
// 处理 tab 切换
|
|
||||||
const handleTabChange = useCallback(
|
|
||||||
(newTab: string) => {
|
|
||||||
const params = new URLSearchParams(searchParams.toString())
|
|
||||||
params.set('tab', newTab)
|
|
||||||
router.push(`/@${userId}?${params.toString()}`, { scroll: false })
|
|
||||||
},
|
|
||||||
[searchParams, router, userId]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AIUserProvider>
|
|
||||||
{data && (
|
|
||||||
<div className="w-full bg-[#211a2b] px-16">
|
|
||||||
<UserBackground />
|
|
||||||
|
|
||||||
{/* 主要内容 */}
|
|
||||||
<div className="relative flex justify-center pt-28 pb-20">
|
|
||||||
<UserActionDropdown />
|
|
||||||
|
|
||||||
{/* 左侧用户卡片 */}
|
|
||||||
<div className="w-[368px]">
|
|
||||||
<UserCard />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 右侧内容区域 */}
|
|
||||||
<div className="max-w-[752px] flex-1 px-6 pt-4">
|
|
||||||
<Tabs value={currentTab} onValueChange={handleTabChange} className="w-full gap-4">
|
|
||||||
<UserProfileTabs currentTab={currentTab} />
|
|
||||||
|
|
||||||
{/* Tab 内容 */}
|
|
||||||
<TabsContent value={UserTab.About} className="mt-0">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<AboutSection introduction={introduction ?? ''} />
|
|
||||||
<GiftGrid userId={userId as string} />
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value={UserTab.Album} className="mt-0">
|
|
||||||
<AlbumList />
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</AIUserProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default UserPage
|
|
||||||
|
|
@ -1,17 +1,16 @@
|
||||||
'use client'
|
'use client';
|
||||||
|
|
||||||
import { Button, IconButton } from '@/components/ui/button'
|
import { Button, IconButton } from '@/components/ui/button';
|
||||||
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer'
|
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer';
|
||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react';
|
||||||
import SubscribeProducts from './SubscribeProducs'
|
import SubscribeProducts from './SubscribeProducs';
|
||||||
import SubscribeProductsSkeleton from './SubscribeProductsSkeleton'
|
import SubscribeProductsSkeleton from './SubscribeProductsSkeleton';
|
||||||
import CarouselBanner from './CarouselBanner'
|
import CarouselBanner from './CarouselBanner';
|
||||||
import { useGetSubProductCheckoutLink, useGetSubProductList } from '@/hooks/useWallet'
|
import { useGetSubProductCheckoutLink, useGetSubProductList } from '@/hooks/useWallet';
|
||||||
import { SubProductListOutput, Period, VipType } from '@/services/wallet/types'
|
import { SubProductListOutput, Period, VipType } from '@/services/wallet/types';
|
||||||
import { isVipDrawerOpenAtom } from '@/atoms/im'
|
import { useAtom } from 'jotai';
|
||||||
import { useAtom } from 'jotai'
|
import QueryString from 'qs';
|
||||||
import QueryString from 'qs'
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
|
||||||
|
|
||||||
function SubscribeBackground() {
|
function SubscribeBackground() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -24,43 +23,43 @@ function SubscribeBackground() {
|
||||||
backgroundRepeat: 'no-repeat',
|
backgroundRepeat: 'no-repeat',
|
||||||
}}
|
}}
|
||||||
></div>
|
></div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 数据转换函数
|
// 数据转换函数
|
||||||
const convertProductListToPricingPlans = (productList: SubProductListOutput[]) => {
|
const convertProductListToPricingPlans = (productList: SubProductListOutput[]) => {
|
||||||
if (!productList) return []
|
if (!productList) return [];
|
||||||
|
|
||||||
return productList.map((product, index) => {
|
return productList.map((product, index) => {
|
||||||
// 根据订阅时长获取标题
|
// 根据订阅时长获取标题
|
||||||
const getTitleByPeriod = (period?: Period) => {
|
const getTitleByPeriod = (period?: Period) => {
|
||||||
switch (period) {
|
switch (period) {
|
||||||
case Period.SubMonth:
|
case Period.SubMonth:
|
||||||
return '1 Month'
|
return '1 Month';
|
||||||
case Period.SubSeason:
|
case Period.SubSeason:
|
||||||
return '3 Months'
|
return '3 Months';
|
||||||
case Period.SubYear:
|
case Period.SubYear:
|
||||||
return '12 Months'
|
return '12 Months';
|
||||||
default:
|
default:
|
||||||
return 'Unknown'
|
return 'Unknown';
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// 计算月单价(如果不是月订阅)
|
// 计算月单价(如果不是月订阅)
|
||||||
const getSubtitle = (period?: Period, payAmount?: number) => {
|
const getSubtitle = (period?: Period, payAmount?: number) => {
|
||||||
if (!payAmount) return ''
|
if (!payAmount) return '';
|
||||||
|
|
||||||
const monthlyPrice = payAmount / 100 // 转换为元
|
const monthlyPrice = payAmount / 100; // 转换为元
|
||||||
|
|
||||||
switch (period) {
|
switch (period) {
|
||||||
case Period.SubSeason:
|
case Period.SubSeason:
|
||||||
return `$${(monthlyPrice / 3).toFixed(2)}/Month`
|
return `$${(monthlyPrice / 3).toFixed(2)}/Month`;
|
||||||
case Period.SubYear:
|
case Period.SubYear:
|
||||||
return `$${(monthlyPrice / 12).toFixed(2)}/Month`
|
return `$${(monthlyPrice / 12).toFixed(2)}/Month`;
|
||||||
default:
|
default:
|
||||||
return ''
|
return '';
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: index + 1, // 使用索引+1作为ID
|
id: index + 1, // 使用索引+1作为ID
|
||||||
|
|
@ -70,69 +69,73 @@ const convertProductListToPricingPlans = (productList: SubProductListOutput[]) =
|
||||||
discount: product.discount || '',
|
discount: product.discount || '',
|
||||||
isSelected: false, // 初始都不选中
|
isSelected: false, // 初始都不选中
|
||||||
productId: product.productId, // 保留原始产品ID用于后续操作
|
productId: product.productId, // 保留原始产品ID用于后续操作
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
const SubscribeVipDrawer = () => {
|
const SubscribeVipDrawer = () => {
|
||||||
const [selectedPlan, setSelectedPlan] = useState<number>(0) // 默认不选择任何计划
|
const [selectedPlan, setSelectedPlan] = useState<number>(0); // 默认不选择任何计划
|
||||||
const [isVipDrawerOpen, setIsVipDrawerOpen] = useAtom(isVipDrawerOpenAtom)
|
// const [isVipDrawerOpen, setIsVipDrawerOpen] = useAtom(isVipDrawerOpenAtom)
|
||||||
|
const [isVipDrawerOpen, setIsVipDrawerOpen] = useState({
|
||||||
|
open: false,
|
||||||
|
vipType: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
const { data: productList, isLoading } = useGetSubProductList()
|
const { data: productList, isLoading } = useGetSubProductList();
|
||||||
const { mutateAsync, isPending } = useGetSubProductCheckoutLink()
|
const { mutateAsync, isPending } = useGetSubProductCheckoutLink();
|
||||||
const pathname = usePathname()
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
// 转换产品数据为组件需要的格式
|
// 转换产品数据为组件需要的格式
|
||||||
const pricingPlans = useMemo(() => {
|
const pricingPlans = useMemo(() => {
|
||||||
const plans = convertProductListToPricingPlans(productList || [])
|
const plans = convertProductListToPricingPlans(productList || []);
|
||||||
// 设置选中状态
|
// 设置选中状态
|
||||||
return plans.map((plan) => ({
|
return plans.map((plan) => ({
|
||||||
...plan,
|
...plan,
|
||||||
isSelected: plan.id === selectedPlan,
|
isSelected: plan.id === selectedPlan,
|
||||||
}))
|
}));
|
||||||
}, [productList, selectedPlan])
|
}, [productList, selectedPlan]);
|
||||||
|
|
||||||
// 设置默认选中第二个计划(3个月)
|
// 设置默认选中第二个计划(3个月)
|
||||||
useMemo(() => {
|
useMemo(() => {
|
||||||
if (pricingPlans.length > 0 && selectedPlan === 0) {
|
if (pricingPlans.length > 0 && selectedPlan === 0) {
|
||||||
const seasonPlan = pricingPlans.find((plan) => plan.title === '3 Month')
|
const seasonPlan = pricingPlans.find((plan) => plan.title === '3 Month');
|
||||||
if (seasonPlan) {
|
if (seasonPlan) {
|
||||||
setSelectedPlan(seasonPlan.id)
|
setSelectedPlan(seasonPlan.id);
|
||||||
} else {
|
} else {
|
||||||
setSelectedPlan(pricingPlans[0].id) // 如果没有3个月计划,选择第一个
|
setSelectedPlan(pricingPlans[0].id); // 如果没有3个月计划,选择第一个
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [pricingPlans, selectedPlan])
|
}, [pricingPlans, selectedPlan]);
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setIsVipDrawerOpen({ open: false, vipType: undefined })
|
setIsVipDrawerOpen({ open: false, vipType: undefined });
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleSubscribe = async () => {
|
const handleSubscribe = async () => {
|
||||||
// 获取选中的计划详情
|
// 获取选中的计划详情
|
||||||
const selectedPlanData = pricingPlans.find((plan) => plan.id === selectedPlan)
|
const selectedPlanData = pricingPlans.find((plan) => plan.id === selectedPlan);
|
||||||
if (selectedPlanData && selectedPlanData.productId) {
|
if (selectedPlanData && selectedPlanData.productId) {
|
||||||
// 这里可以调用订阅API,使用selectedPlanData.productId
|
// 这里可以调用订阅API,使用selectedPlanData.productId
|
||||||
|
|
||||||
const query = {
|
const query = {
|
||||||
...QueryString.parse(searchParams.toString()),
|
...QueryString.parse(searchParams.toString()),
|
||||||
back: 1,
|
back: 1,
|
||||||
}
|
};
|
||||||
|
|
||||||
const baseURL = `${process.env.NEXT_PUBLIC_APP_URL}${pathname}?${QueryString.stringify(query)}`
|
const baseURL = `${process.env.NEXT_PUBLIC_APP_URL}${pathname}?${QueryString.stringify(query)}`;
|
||||||
|
|
||||||
const response = await mutateAsync({
|
const response = await mutateAsync({
|
||||||
subProductId: selectedPlanData.productId,
|
subProductId: selectedPlanData.productId,
|
||||||
returnUrl: baseURL,
|
returnUrl: baseURL,
|
||||||
cancelUrl: baseURL,
|
cancelUrl: baseURL,
|
||||||
})
|
});
|
||||||
const { payUrl } = response || {}
|
const { payUrl } = response || {};
|
||||||
if (payUrl) {
|
if (payUrl) {
|
||||||
window.location.href = payUrl
|
window.location.href = payUrl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
|
|
@ -178,7 +181,7 @@ const SubscribeVipDrawer = () => {
|
||||||
</div>
|
</div>
|
||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default SubscribeVipDrawer
|
export default SubscribeVipDrawer;
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,41 @@
|
||||||
'use client'
|
'use client';
|
||||||
import { Button, IconButton } from '@/components/ui/button'
|
import { Button, IconButton } from '@/components/ui/button';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useGetMemberDetail } from '@/hooks/useWallet'
|
import { useGetMemberDetail } from '@/hooks/useWallet';
|
||||||
import SubscribeText from './components/SubscribeText'
|
import SubscribeText from './components/SubscribeText';
|
||||||
import { useSetAtom } from 'jotai'
|
import { VipType } from '@/services/wallet';
|
||||||
import { isVipDrawerOpenAtom } from '@/atoms/im'
|
import { useEffect, useState } from 'react';
|
||||||
import { VipType } from '@/services/wallet'
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
|
|
||||||
const VipPage = () => {
|
const VipPage = () => {
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams();
|
||||||
const back = searchParams.get('back')
|
const back = searchParams.get('back');
|
||||||
const setIsVipDrawerOpen = useSetAtom(isVipDrawerOpenAtom)
|
const setIsVipDrawerOpen = (P: any) => null;
|
||||||
const [enableQuery, setEnableQuery] = useState(!back)
|
const [enableQuery, setEnableQuery] = useState(!back);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (back) {
|
if (back) {
|
||||||
// 如果有 back 参数,等待 2 秒后再启用查询
|
// 如果有 back 参数,等待 2 秒后再启用查询
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setEnableQuery(true)
|
setEnableQuery(true);
|
||||||
}, 2000)
|
}, 2000);
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}, [back])
|
}, [back]);
|
||||||
|
|
||||||
const { data: memberDetail, isLoading } = useGetMemberDetail({ enabled: enableQuery })
|
const { data: memberDetail, isLoading } = useGetMemberDetail({ enabled: enableQuery });
|
||||||
const { memberPrivList, userMemberInfo } = memberDetail || {}
|
const { memberPrivList, userMemberInfo } = memberDetail || {};
|
||||||
|
|
||||||
const hasSubscribe =
|
const hasSubscribe =
|
||||||
userMemberInfo && userMemberInfo.expTime && new Date(userMemberInfo.expTime) > new Date()
|
userMemberInfo && userMemberInfo.expTime && new Date(userMemberInfo.expTime) > new Date();
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
if (back) {
|
if (back) {
|
||||||
window.history.go(-3)
|
window.history.go(-3);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
router.back()
|
router.back();
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-3 pt-12 pb-20 sm:px-6 lg:px-12">
|
<div className="px-3 pt-12 pb-20 sm:px-6 lg:px-12">
|
||||||
|
|
@ -126,7 +124,7 @@ const VipPage = () => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default VipPage
|
export default VipPage;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
console.log('request', request);
|
// console.log('request', request);
|
||||||
const url = 'http://localhost:3000';
|
const url = request.nextUrl.origin;
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const code = searchParams.get('code');
|
const code = searchParams.get('code');
|
||||||
|
|
|
||||||
|
|
@ -1,57 +1,64 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
|
||||||
import { useRef, useState } from 'react';
|
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
type ItemType<T = any> = {
|
||||||
|
type: string;
|
||||||
|
data?: T;
|
||||||
|
};
|
||||||
type VirtualListProps<T = any> = {
|
type VirtualListProps<T = any> = {
|
||||||
data?: { type: string; data?: T }[];
|
data?: ItemType<T>[];
|
||||||
itemContent?: (index: number, item: { type: string; data: T }) => React.ReactNode;
|
itemContent?: (index: number, item: { type: string; data: T }) => React.ReactNode;
|
||||||
virtuosoProps?: React.ComponentProps<typeof Virtuoso>;
|
|
||||||
} & React.HTMLAttributes<HTMLDivElement>;
|
} & React.HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
export default function VirtualList<T = any>(props: VirtualListProps<T>) {
|
export default function VirtualList<T = any>(props: VirtualListProps<T>) {
|
||||||
const {
|
const { data = [], itemContent = (index) => <div>{index}</div>, ...restProps } = props;
|
||||||
data = [],
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
itemContent = (index) => <div>{index}</div>,
|
const previousData = useRef<ItemType[]>([]);
|
||||||
virtuosoProps,
|
const [isUserAtBottom, setIsUserAtBottom] = useState(true);
|
||||||
...restProps
|
|
||||||
} = props;
|
|
||||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
|
||||||
// const [showScrollButton, setShowScrollButton] = useState(false);
|
|
||||||
|
|
||||||
// // 滚动到最新消息
|
// 检查用户是否滚动到底部附近(阈值为 50px)
|
||||||
// const scrollToBottom = () => {
|
const checkIfAtBottom = () => {
|
||||||
// virtuosoRef.current?.scrollToIndex({
|
const element = ref.current;
|
||||||
// index: data.length - 1,
|
if (!element) return false;
|
||||||
// behavior: 'smooth',
|
|
||||||
// });
|
const { scrollTop, scrollHeight, clientHeight } = element;
|
||||||
// };
|
const threshold = 50; // 距离底部 50px 以内认为是在底部
|
||||||
|
return scrollHeight - scrollTop - clientHeight < threshold;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听用户滚动事件
|
||||||
|
const handleScroll = () => {
|
||||||
|
const atBottom = checkIfAtBottom();
|
||||||
|
setIsUserAtBottom(atBottom);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 当数据更新时,只有用户在底部才自动滚动
|
||||||
|
useEffect(() => {
|
||||||
|
const last = (list: ItemType[]) => (list?.length ? list[list.length - 1] : null);
|
||||||
|
const lastChanged = last(previousData.current)?.data !== last(data)?.data;
|
||||||
|
|
||||||
|
if (lastChanged && isUserAtBottom) {
|
||||||
|
ref.current?.scrollTo({
|
||||||
|
top: ref.current?.scrollHeight,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
previousData.current = data;
|
||||||
|
}, [data, isUserAtBottom]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div {...restProps}>
|
<div
|
||||||
<Virtuoso
|
{...restProps}
|
||||||
ref={virtuosoRef}
|
ref={ref}
|
||||||
alignToBottom={false}
|
onScroll={handleScroll}
|
||||||
{...virtuosoProps}
|
className={cn('overflow-auto', restProps.className)}
|
||||||
style={{ height: '100%', ...virtuosoProps?.style }}
|
>
|
||||||
data={data}
|
{data.map((item, index) => (
|
||||||
followOutput="smooth"
|
<div key={index}>{itemContent(index, item as any)}</div>
|
||||||
initialTopMostItemIndex={data.length - 1}
|
))}
|
||||||
// atBottomStateChange={(atBottom) => {
|
|
||||||
// // 当不在底部时显示按钮,在底部时隐藏
|
|
||||||
// setShowScrollButton(!atBottom);
|
|
||||||
// }}
|
|
||||||
itemContent={(index, item) => itemContent(index, item as any)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 回到底部按钮 */}
|
|
||||||
{/* {showScrollButton && (
|
|
||||||
<div
|
|
||||||
onClick={scrollToBottom}
|
|
||||||
className="absolute -right-1 bottom-0 z-10 flex h-10 w-10 items-center justify-center rounded-full hover:scale-110 hover:cursor-pointer"
|
|
||||||
>
|
|
||||||
scroll
|
|
||||||
</div>
|
|
||||||
)} */}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'iconfont-v2'; /* Project id 5076160 - spicyxx.ai */
|
font-family: 'iconfont-v2'; /* Project id 5076160 - spicyxx.ai */
|
||||||
src:
|
src:
|
||||||
url('/font-v2/iconfont.woff2?t=1765173841330') format('woff2'),
|
url('/font-v2/iconfont.woff2?t=1766052523135') format('woff2'),
|
||||||
url('/font-v2/iconfont.woff?t=1765173841330') format('woff'),
|
url('/font-v2/iconfont.woff?t=1766052523135') format('woff'),
|
||||||
url('/font-v2/iconfont.ttf?t=1765173841330') format('truetype');
|
url('/font-v2/iconfont.ttf?t=1766052523135') format('truetype');
|
||||||
}
|
}
|
||||||
|
|
||||||
.iconfont-v2 {
|
.iconfont-v2 {
|
||||||
|
|
@ -14,6 +14,21 @@
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Logo */
|
||||||
|
.iconv2-Logo:before {
|
||||||
|
content: '\e624';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 我的界面前往 */
|
||||||
|
.iconv2-wodejiemianqianwang:before {
|
||||||
|
content: '\e622';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 群聊 */
|
||||||
|
.iconv2-qunliao:before {
|
||||||
|
content: '\e621';
|
||||||
|
}
|
||||||
|
|
||||||
/* 挂断电话 */
|
/* 挂断电话 */
|
||||||
.iconv2-guaduandianhua:before {
|
.iconv2-guaduandianhua:before {
|
||||||
content: '\e620';
|
content: '\e620';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { fetchModels } from '@/services/chat';
|
||||||
|
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
export function useModels() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['models'],
|
||||||
|
queryFn: () => fetchModels(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useMemo } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { useCurrentUser } from '@/hooks/auth'
|
||||||
|
import { useToken } from '@/hooks/auth'
|
||||||
|
|
||||||
|
const PUBLIC_PREFETCH_TARGETS = ['/']
|
||||||
|
|
||||||
|
const AUTH_PREFETCH_TARGETS = [
|
||||||
|
'/contact',
|
||||||
|
'/profile',
|
||||||
|
'/profile/edit',
|
||||||
|
'/profile/account',
|
||||||
|
'/vip',
|
||||||
|
'/wallet',
|
||||||
|
'/wallet/charge',
|
||||||
|
'/wallet/charge/result',
|
||||||
|
'/wallet/transactions',
|
||||||
|
'/leaderboard',
|
||||||
|
'/crushcoin',
|
||||||
|
'/generate/image',
|
||||||
|
'/generate/image-2-image',
|
||||||
|
'/generate/image-edit',
|
||||||
|
'/generate/image-2-background',
|
||||||
|
'/explore',
|
||||||
|
'/creator',
|
||||||
|
'/create/type',
|
||||||
|
'/create/dialogue',
|
||||||
|
'/create/character',
|
||||||
|
'/create/image',
|
||||||
|
]
|
||||||
|
|
||||||
|
// 受保护路由前缀列表(需要登录才能访问的路由)
|
||||||
|
const PROTECTED_ROUTE_PREFIXES = [
|
||||||
|
'/profile',
|
||||||
|
'/create',
|
||||||
|
'/settings',
|
||||||
|
'/login/fields',
|
||||||
|
'/chat',
|
||||||
|
'/contact',
|
||||||
|
'/vip',
|
||||||
|
'/wallet',
|
||||||
|
'/crushcoin',
|
||||||
|
'/leaderboard',
|
||||||
|
'/generate',
|
||||||
|
'/explore',
|
||||||
|
'/creator',
|
||||||
|
]
|
||||||
|
|
||||||
|
// 检查路由是否是受保护路由
|
||||||
|
const isProtectedRoute = (href: string): boolean => {
|
||||||
|
return PROTECTED_ROUTE_PREFIXES.some((prefix) => href.startsWith(prefix))
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PREFETCH_TARGETS = [...PUBLIC_PREFETCH_TARGETS, ...AUTH_PREFETCH_TARGETS]
|
||||||
|
|
||||||
|
type NullableRoute = string | null | undefined
|
||||||
|
|
||||||
|
const prefetchedRouteCache = new Set<string>()
|
||||||
|
|
||||||
|
export function usePrefetchRoutes(
|
||||||
|
routes?: NullableRoute[],
|
||||||
|
options?: {
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const router = useRouter()
|
||||||
|
const { isLogin } = useToken()
|
||||||
|
const normalizedRoutes = useMemo(() => {
|
||||||
|
if (!routes || routes.length === 0) return []
|
||||||
|
return routes.filter(Boolean) as string[]
|
||||||
|
}, [routes])
|
||||||
|
const limit = options?.limit ?? Infinity
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!normalizedRoutes.length) return
|
||||||
|
let count = 0
|
||||||
|
for (const href of normalizedRoutes) {
|
||||||
|
if (!href || prefetchedRouteCache.has(href)) continue
|
||||||
|
// 如果未登录且是受保护路由,跳过 prefetch,避免缓存重定向响应
|
||||||
|
if (!isLogin && isProtectedRoute(href)) continue
|
||||||
|
prefetchedRouteCache.add(href)
|
||||||
|
router.prefetch(href)
|
||||||
|
count += 1
|
||||||
|
if (count >= limit) break
|
||||||
|
}
|
||||||
|
}, [limit, normalizedRoutes, router, isLogin])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGlobalPrefetchRoutes(extraRoutes?: NullableRoute[]) {
|
||||||
|
const { data: user } = useCurrentUser()
|
||||||
|
const isAuthenticated = !!user
|
||||||
|
|
||||||
|
const protectedTargets = useMemo(() => {
|
||||||
|
const routes = new Set(AUTH_PREFETCH_TARGETS)
|
||||||
|
extraRoutes?.forEach((href) => {
|
||||||
|
if (href && href !== '/') {
|
||||||
|
routes.add(href)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return Array.from(routes)
|
||||||
|
}, [extraRoutes])
|
||||||
|
|
||||||
|
usePrefetchRoutes(PUBLIC_PREFETCH_TARGETS)
|
||||||
|
usePrefetchRoutes(isAuthenticated ? protectedTargets : [])
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GLOBAL_PREFETCH_ROUTES = DEFAULT_PREFETCH_TARGETS
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query';
|
||||||
|
import { homeKeys } from '@/lib/query-keys';
|
||||||
|
import { GetMeetListRequest, homeService } from '@/services/home';
|
||||||
|
|
||||||
|
type PageParam = number | { page: number; exList: number[] };
|
||||||
|
|
||||||
|
export function useGetMeetList(params: Omit<GetMeetListRequest, 'pn'>, enabled: boolean = true) {
|
||||||
|
return useInfiniteQuery({
|
||||||
|
queryKey: homeKeys.getMeetList(params),
|
||||||
|
queryFn: ({ pageParam }: { pageParam: PageParam }) => {
|
||||||
|
// pageParam 可能是数字(第一页)或对象(后续页面包含 exList)
|
||||||
|
const page = typeof pageParam === 'number' ? pageParam : pageParam.page;
|
||||||
|
const exList =
|
||||||
|
typeof pageParam === 'object' && pageParam !== null ? pageParam.exList : undefined;
|
||||||
|
|
||||||
|
return homeService.getMeetList({
|
||||||
|
...params,
|
||||||
|
pn: page,
|
||||||
|
...(exList && { exList }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
initialPageParam: 1 as PageParam,
|
||||||
|
getNextPageParam: (lastPage, allPages) => {
|
||||||
|
// 如果最后一页的数据数量少于每页大小,说明没有更多数据了
|
||||||
|
if (lastPage.length < params.ps) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集所有已获取的 aiId 作为下一页的排除列表
|
||||||
|
const exList: number[] = [];
|
||||||
|
|
||||||
|
// 首先添加初始传入的 exList(如果有的话)
|
||||||
|
if (params.exList && Array.isArray(params.exList)) {
|
||||||
|
exList.push(...params.exList);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 然后添加所有已获取页面的 aiId
|
||||||
|
for (const page of allPages) {
|
||||||
|
for (const item of page) {
|
||||||
|
if (item.aiId) {
|
||||||
|
exList.push(item.aiId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { page: allPages.length + 1, exList } as PageParam;
|
||||||
|
},
|
||||||
|
enabled, // 控制是否启用查询
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGetChatRank() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: homeKeys.getChatRank(),
|
||||||
|
queryFn: homeService.getChatRank,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGetHeartbeatRank() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: homeKeys.getHeartbeatRank(),
|
||||||
|
queryFn: homeService.getHeartbeatRank,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGetGiftRank() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: homeKeys.getGiftRank(),
|
||||||
|
queryFn: homeService.getGiftRank,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGetSevenDaysSignList() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: homeKeys.getSevenDaysSignList(),
|
||||||
|
queryFn: homeService.getSevenDaysSignList,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSignIn() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: homeService.signIn,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGetExplore() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: homeKeys.getExplore(),
|
||||||
|
queryFn: homeService.getExplore,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGetHomeAiCarouselList() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: homeKeys.getHomeAiCarouselList(),
|
||||||
|
queryFn: homeService.getHomeAiCarouselList,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGetHomeAggregateRecommend() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: homeKeys.getHomeAggregateRecommend(),
|
||||||
|
queryFn: homeService.getHomeAggregateRecommend,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -4,13 +4,13 @@ import { usePathname } from 'next/navigation';
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import Sidebar from './Sidebar';
|
import Sidebar from './Sidebar';
|
||||||
import Topbar from './Topbar';
|
import Topbar from './Topbar';
|
||||||
import ChargeDrawer from '../components/features/charge-drawer';
|
// import ChargeDrawer from '../components/features/charge-drawer';
|
||||||
import SubscribeVipDrawer from '@/app/(main)/vip/components/SubscribeVipDrawer';
|
// import SubscribeVipDrawer from '@/app/(main)/vip/components/SubscribeVipDrawer';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import CreateReachedLimitDialog from '../components/features/create-reached-limit-dialog';
|
// import CreateReachedLimitDialog from '../components/features/create-reached-limit-dialog';
|
||||||
import { useMedia } from '@/hooks/tools';
|
import { useMedia } from '@/hooks/tools';
|
||||||
import BottomBar from './BottomBar';
|
import BottomBar from './BottomBar';
|
||||||
import { useStreamChatStore } from '@/stores/stream-chat';
|
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
||||||
import { useCurrentUser } from '@/hooks/auth';
|
import { useCurrentUser } from '@/hooks/auth';
|
||||||
|
|
||||||
interface ConditionalLayoutProps {
|
interface ConditionalLayoutProps {
|
||||||
|
|
@ -20,20 +20,14 @@ interface ConditionalLayoutProps {
|
||||||
const useInitChat = () => {
|
const useInitChat = () => {
|
||||||
const { data } = useCurrentUser();
|
const { data } = useCurrentUser();
|
||||||
const connect = useStreamChatStore((state) => state.connect);
|
const connect = useStreamChatStore((state) => state.connect);
|
||||||
const queryChannels = useStreamChatStore((state) => state.queryChannels);
|
|
||||||
|
|
||||||
const initChat = async () => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
await connect({
|
connect({
|
||||||
userId: data.userId + '',
|
userId: data.userId + '',
|
||||||
userName: data.nickname,
|
userName: data.nickname,
|
||||||
});
|
});
|
||||||
await queryChannels({});
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
initChat();
|
|
||||||
}, [data]);
|
}, [data]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -66,9 +60,9 @@ export default function ConditionalLayout({ children }: ConditionalLayoutProps)
|
||||||
</main>
|
</main>
|
||||||
{response && !response.sm && <BottomBar />}
|
{response && !response.sm && <BottomBar />}
|
||||||
</div>
|
</div>
|
||||||
<ChargeDrawer />
|
{/* <ChargeDrawer /> */}
|
||||||
<SubscribeVipDrawer />
|
{/* <SubscribeVipDrawer /> */}
|
||||||
<CreateReachedLimitDialog />
|
{/* <CreateReachedLimitDialog /> */}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@ import { Badge } from '../components/ui/badge';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { useLayoutStore } from '@/stores';
|
import { useLayoutStore } from '@/stores';
|
||||||
import { useCurrentUser } from '@/hooks/auth';
|
|
||||||
import Notice from './components/Notice';
|
|
||||||
|
|
||||||
// 菜单项接口
|
// 菜单项接口
|
||||||
interface IMenuItem {
|
interface IMenuItem {
|
||||||
|
|
@ -27,7 +25,6 @@ function Sidebar() {
|
||||||
const isSidebarExpanded = useLayoutStore((s) => s.isSidebarExpanded);
|
const isSidebarExpanded = useLayoutStore((s) => s.isSidebarExpanded);
|
||||||
const setSidebarExpanded = useLayoutStore((s) => s.setSidebarExpanded);
|
const setSidebarExpanded = useLayoutStore((s) => s.setSidebarExpanded);
|
||||||
const setHydrated = useLayoutStore((s) => s.setHydrated);
|
const setHydrated = useLayoutStore((s) => s.setHydrated);
|
||||||
const { data: user } = useCurrentUser();
|
|
||||||
|
|
||||||
// 在客户端挂载后,从 localStorage 恢复侧边栏状态
|
// 在客户端挂载后,从 localStorage 恢复侧边栏状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -103,7 +100,6 @@ function Sidebar() {
|
||||||
<div className="bg-outline-normal h-px" />
|
<div className="bg-outline-normal h-px" />
|
||||||
</div>
|
</div>
|
||||||
<ChatSidebar />
|
<ChatSidebar />
|
||||||
<Notice actualIsExpanded={isSidebarExpanded} />
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '../components/ui/avatar';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname, useSearchParams, useRouter } from 'next/navigation';
|
import { usePathname, useSearchParams, useRouter } from 'next/navigation';
|
||||||
import { useMedia } from '@/hooks/tools';
|
import { useMedia } from '@/hooks/tools';
|
||||||
|
import Notice from './components/Notice';
|
||||||
import { items } from './BottomBar';
|
import { items } from './BottomBar';
|
||||||
|
|
||||||
const mobileHidenMenus = ['/profile/edit', '/profile/account'];
|
const mobileHidenMenus = ['/profile/edit', '/profile/account'];
|
||||||
|
|
@ -21,7 +22,7 @@ function Topbar() {
|
||||||
const response = useMedia();
|
const response = useMedia();
|
||||||
const searchParamsString = searchParams.toString();
|
const searchParamsString = searchParams.toString();
|
||||||
const redirectURL = `${pathname}${searchParamsString ? `?${searchParamsString}` : ''}`;
|
const redirectURL = `${pathname}${searchParamsString ? `?${searchParamsString}` : ''}`;
|
||||||
const loginHref = `/login?redirect=${encodeURIComponent(redirectURL)}`;
|
// const loginHref = `/login?redirect=${encodeURIComponent(redirectURL)}`;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleScroll(event: Event) {
|
function handleScroll(event: Event) {
|
||||||
|
|
@ -41,7 +42,7 @@ function Topbar() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
router.prefetch(loginHref);
|
router.prefetch('/login');
|
||||||
} else {
|
} else {
|
||||||
router.prefetch('/profile');
|
router.prefetch('/profile');
|
||||||
if (user.cpUserInfo) {
|
if (user.cpUserInfo) {
|
||||||
|
|
@ -54,11 +55,9 @@ function Topbar() {
|
||||||
if (!response) return null;
|
if (!response) return null;
|
||||||
if (response.sm || items.some((item) => item.path === pathname)) {
|
if (response.sm || items.some((item) => item.path === pathname)) {
|
||||||
return (
|
return (
|
||||||
<div className="h-8 w-[103.6px]">
|
<Link href="/">
|
||||||
<Link href="/">
|
<i className="iconfont-v2 iconv2-Logo" style={{ fontSize: '50px' }} />
|
||||||
<Image src="/logo.svg" alt="logo" width={103.6} height={32} />
|
</Link>
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
|
@ -74,23 +73,26 @@ function Topbar() {
|
||||||
const rightDomRender = () => {
|
const rightDomRender = () => {
|
||||||
if (!user)
|
if (!user)
|
||||||
return (
|
return (
|
||||||
<Link href={loginHref} prefetch>
|
<Link href="/login" prefetch>
|
||||||
<Button size="small">Login in / Sign up</Button>
|
<Button size="small">Login in / Sign up</Button>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<Link href="/profile" prefetch>
|
<div className="flex items-center">
|
||||||
<Avatar className="size-8 cursor-pointer">
|
<Notice />
|
||||||
<AvatarImage
|
<Link href="/profile" prefetch>
|
||||||
className="object-cover"
|
<Avatar className="size-8 cursor-pointer">
|
||||||
src={user.headImage}
|
<AvatarImage
|
||||||
alt={user.nickname}
|
className="object-cover"
|
||||||
width={32}
|
src={user.headImage}
|
||||||
height={32}
|
alt={user.nickname}
|
||||||
/>
|
width={32}
|
||||||
<AvatarFallback>{user.nickname?.slice(0, 1)}</AvatarFallback>
|
height={32}
|
||||||
</Avatar>
|
/>
|
||||||
</Link>
|
<AvatarFallback>{user.nickname?.slice(0, 1)}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { useState, useMemo } from 'react';
|
||||||
import ChatSidebarItem from './ChatSidebarItem';
|
import ChatSidebarItem from './ChatSidebarItem';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
import Empty from '@/components/ui/empty';
|
import Empty from '@/components/ui/empty';
|
||||||
import { useStreamChatStore } from '@/stores/stream-chat';
|
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
||||||
|
|
||||||
interface ChatSearchResultsProps {
|
interface ChatSearchResultsProps {
|
||||||
searchKeyword: string;
|
searchKeyword: string;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import ChatSidebarAction from './ChatSidebarAction';
|
||||||
import ChatSearchResults from './ChatSearchResults';
|
import ChatSearchResults from './ChatSearchResults';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useStreamChatStore } from '@/stores/stream-chat';
|
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
||||||
import { useLayoutStore } from '@/stores';
|
import { useLayoutStore } from '@/stores';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import {
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useStreamChatStore } from '@/stores/stream-chat';
|
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
||||||
import { useAsyncFn } from '@/hooks/tools';
|
import { useAsyncFn } from '@/hooks/tools';
|
||||||
|
|
||||||
interface ChatSidebarActionProps {
|
interface ChatSidebarActionProps {
|
||||||
|
|
@ -33,14 +33,17 @@ const ChatSidebarAction = ({
|
||||||
isSearchActive = false,
|
isSearchActive = false,
|
||||||
}: ChatSidebarActionProps) => {
|
}: ChatSidebarActionProps) => {
|
||||||
const clearNotifications = useStreamChatStore((state) => state.clearNotifications);
|
const clearNotifications = useStreamChatStore((state) => state.clearNotifications);
|
||||||
const clearChannels = useStreamChatStore((state) => state.clearChannels);
|
const clearChannels = useStreamChatStore((state) => state.deleteChannel);
|
||||||
|
const channels = useStreamChatStore((state) => state.channels);
|
||||||
const [isDeleteMessageDialogOpen, setIsDeleteMessageDialogOpen] = useState(false);
|
const [isDeleteMessageDialogOpen, setIsDeleteMessageDialogOpen] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { run: handleClearChannels, loading } = useAsyncFn(async () => {
|
const { run: handleClearChannels, loading } = useAsyncFn(async () => {
|
||||||
await clearChannels();
|
const { result } = await clearChannels(channels.map((ch) => ch.id!));
|
||||||
setIsDeleteMessageDialogOpen(false);
|
if (result === 'ok') {
|
||||||
router.replace('/');
|
router.replace('/');
|
||||||
|
setIsDeleteMessageDialogOpen(false);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,42 @@
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { useCurrentUser, useUserNoticeStat } from '@/hooks/auth'
|
import { useCurrentUser, useUserNoticeStat } from '@/hooks/auth';
|
||||||
import Image from 'next/image'
|
import Image from 'next/image';
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react';
|
||||||
import { usePathname } from 'next/navigation'
|
import { usePathname } from 'next/navigation';
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { userKeys } from '@/lib/query-keys'
|
import { userKeys } from '@/lib/query-keys';
|
||||||
import NoticeDrawer from './NoticeDrawer'
|
import NoticeDrawer from './NoticeDrawer';
|
||||||
|
|
||||||
const Notice = ({ actualIsExpanded }: { actualIsExpanded: boolean }) => {
|
const Notice = () => {
|
||||||
const { data: user } = useCurrentUser()
|
const { data: user } = useCurrentUser();
|
||||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
|
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||||
const pathname = usePathname()
|
const pathname = usePathname();
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data } = useUserNoticeStat()
|
const { data } = useUserNoticeStat();
|
||||||
|
|
||||||
// 监听路径变化,刷新通知统计
|
// 监听路径变化,刷新通知统计
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
return;
|
||||||
if (user) {
|
if (user) {
|
||||||
// 当路径变化时,无效化并重新获取通知统计数据
|
// 当路径变化时,无效化并重新获取通知统计数据
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: userKeys.noticeStat(),
|
queryKey: userKeys.noticeStat(),
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}, [pathname])
|
}, [pathname]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
return;
|
||||||
if (isDrawerOpen && user) {
|
if (isDrawerOpen && user) {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: userKeys.noticeStat(),
|
queryKey: userKeys.noticeStat(),
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}, [isDrawerOpen])
|
}, [isDrawerOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
{/* 分割线 */}
|
|
||||||
{user && (
|
|
||||||
<div className="mx-6 my-4">
|
|
||||||
<div className="bg-outline-normal h-px" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 底部通知 */}
|
|
||||||
{user && (
|
{user && (
|
||||||
<div className="px-4">
|
<div className="px-4">
|
||||||
<div
|
<div
|
||||||
|
|
@ -53,17 +47,15 @@ const Notice = ({ actualIsExpanded }: { actualIsExpanded: boolean }) => {
|
||||||
<Image src="/icons/notice.svg" alt="Notice" width={32} height={32} />
|
<Image src="/icons/notice.svg" alt="Notice" width={32} height={32} />
|
||||||
<Badge count={data?.unRead} className="absolute -top-2 -right-2" />
|
<Badge count={data?.unRead} className="absolute -top-2 -right-2" />
|
||||||
</div>
|
</div>
|
||||||
{actualIsExpanded && (
|
{/* {actualIsExpanded && (
|
||||||
<span className="txt-label-m text-txt-primary-normal flex-1">Notice</span>
|
<span className="txt-label-m text-txt-primary-normal flex-1">Notice</span>
|
||||||
)}
|
)} */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 通知抽屉 */}
|
|
||||||
<NoticeDrawer isOpen={isDrawerOpen} onOpenChange={setIsDrawerOpen} />
|
<NoticeDrawer isOpen={isDrawerOpen} onOpenChange={setIsDrawerOpen} />
|
||||||
</div>
|
</>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default Notice
|
export default Notice;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
type ProtectOptions = {
|
||||||
|
maxRetries?: number;
|
||||||
|
delay?: number;
|
||||||
|
onRetry?: (error: any, attempt: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保护函数,用于包裹异步请求并在失败时自动重试
|
||||||
|
* @param fn 需要执行的异步函数
|
||||||
|
* @param options 配置项
|
||||||
|
* @param options.maxRetries 最大重试次数,默认为 3
|
||||||
|
* @param options.delay 重试间隔时间(毫秒),默认为 1000
|
||||||
|
* @param options.onRetry 重试时的回调函数
|
||||||
|
* @returns 返回函数执行结果
|
||||||
|
*/
|
||||||
|
export async function protect<T>(fn: () => Promise<T>, options: ProtectOptions = {}): Promise<T> {
|
||||||
|
const { maxRetries = 3, delay = 1000, onRetry } = options;
|
||||||
|
|
||||||
|
let lastError: any;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
|
||||||
|
// 如果已经是最后一次尝试,直接抛出错误
|
||||||
|
if (attempt === maxRetries) {
|
||||||
|
console.error(`请求失败,已重试 ${maxRetries} 次:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行重试回调
|
||||||
|
if (onRetry) {
|
||||||
|
onRetry(error, attempt);
|
||||||
|
} else {
|
||||||
|
console.warn(`请求失败,第 ${attempt} 次重试中...`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待一段时间后重试
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay * attempt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建一个带保护的函数包装器
|
||||||
|
* @param fn 需要包装的异步函数
|
||||||
|
* @param options 配置项
|
||||||
|
* @returns 返回包装后的函数
|
||||||
|
*/
|
||||||
|
export function createProtected<T extends (...args: any[]) => Promise<any>>(
|
||||||
|
fn: T,
|
||||||
|
options: ProtectOptions = {}
|
||||||
|
): T {
|
||||||
|
return (async (...args: any[]) => {
|
||||||
|
return protect(() => fn(...args), options);
|
||||||
|
}) as T;
|
||||||
|
}
|
||||||
|
|
@ -18,14 +18,7 @@ export async function fetchServerRequest<T = any>(
|
||||||
tags?: string[]; // 缓存标签,用于手动刷新
|
tags?: string[]; // 缓存标签,用于手动刷新
|
||||||
}
|
}
|
||||||
): Promise<ResponseType<T>> {
|
): Promise<ResponseType<T>> {
|
||||||
const {
|
const { method = 'POST', params, data, requireAuth = false, revalidate, tags } = options || {};
|
||||||
method = 'GET',
|
|
||||||
params,
|
|
||||||
data,
|
|
||||||
requireAuth = false,
|
|
||||||
revalidate,
|
|
||||||
tags,
|
|
||||||
} = options || {};
|
|
||||||
|
|
||||||
// 获取 token(如果需要)
|
// 获取 token(如果需要)
|
||||||
let token: string | null = null;
|
let token: string | null = null;
|
||||||
|
|
@ -51,7 +44,7 @@ export async function fetchServerRequest<T = any>(
|
||||||
};
|
};
|
||||||
|
|
||||||
// 构建完整 URL(服务端必须用完整 URL)
|
// 构建完整 URL(服务端必须用完整 URL)
|
||||||
const baseURL = 'http://54.223.196.180:8091';
|
const baseURL = 'http://54.223.196.180';
|
||||||
const fullURL = new URL(url, baseURL);
|
const fullURL = new URL(url, baseURL);
|
||||||
|
|
||||||
// 处理查询参数
|
// 处理查询参数
|
||||||
|
|
@ -66,9 +59,7 @@ export async function fetchServerRequest<T = any>(
|
||||||
// 处理 body 数据
|
// 处理 body 数据
|
||||||
if (data) {
|
if (data) {
|
||||||
fetchOptions.body = JSON.stringify(
|
fetchOptions.body = JSON.stringify(
|
||||||
Object.fromEntries(
|
Object.fromEntries(Object.entries(data).filter(([, value]) => value !== ''))
|
||||||
Object.entries(data).filter(([, value]) => value !== '')
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,9 @@ import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
// 需要认证的路由
|
// 需要认证的路由
|
||||||
const protectedRoutes = [
|
const protectedRoutes = [
|
||||||
// '/profile',
|
'/profile',
|
||||||
// '/profile/account',
|
'/profile/account',
|
||||||
// '/profile/edit',
|
'/profile/edit',
|
||||||
'/create',
|
|
||||||
'/settings',
|
'/settings',
|
||||||
'/login/fields',
|
'/login/fields',
|
||||||
'/chat',
|
'/chat',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { chatRequest } from '@/lib/client';
|
||||||
|
|
||||||
|
export async function getUserToken(data: { userId: string; userName: string }) {
|
||||||
|
return await chatRequest('/chat-api/v1/im/user/createOrGet', {
|
||||||
|
method: 'post',
|
||||||
|
data: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createChannel(data: any) {
|
||||||
|
return await chatRequest('/chat-api/v1/im/user/conversation/create', {
|
||||||
|
method: 'post',
|
||||||
|
data: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteChannel(chanelIds: string[]) {
|
||||||
|
return await chatRequest('/chat-api/v1/im/user/conversation/delete', {
|
||||||
|
method: 'post',
|
||||||
|
data: { chanelIds },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUserChatSetting(params: any) {
|
||||||
|
return await chatRequest('/chat-api/user/setting/chat/select', {
|
||||||
|
method: 'post',
|
||||||
|
data: params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserChatSetting(params: any) {
|
||||||
|
return await chatRequest('/chat-api/user/setting/chat/createOrUpdate', {
|
||||||
|
method: 'post',
|
||||||
|
data: params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchModels() {
|
||||||
|
const { data } = await chatRequest('/chat-api/user/setting/model/list', {
|
||||||
|
method: 'post',
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { chatRequest, editorRequest } from '@/lib/client';
|
import { editorRequest } from '@/lib/client';
|
||||||
|
|
||||||
export async function fetchCharacters({ index, limit, query }: any) {
|
export async function fetchCharacters({ index, limit, query }: any) {
|
||||||
const { data } = await editorRequest('/api/character/list', {
|
const { data } = await editorRequest('/api/character/list', {
|
||||||
|
|
@ -16,24 +16,3 @@ export async function fetchCharacter(params: any) {
|
||||||
export async function fetchCharacterTags(params: any = {}) {
|
export async function fetchCharacterTags(params: any = {}) {
|
||||||
return editorRequest('/api/tag/list', { method: 'POST', data: params });
|
return editorRequest('/api/tag/list', { method: 'POST', data: params });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserToken(data: { userId: string; userName: string }) {
|
|
||||||
return await chatRequest('/chat-api/v1/im/user/createOrGet', {
|
|
||||||
method: 'post',
|
|
||||||
data: data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createChannel(data: any) {
|
|
||||||
return await chatRequest('/chat-api/v1/im/user/conversation/create', {
|
|
||||||
method: 'post',
|
|
||||||
data: data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteChannel(chanelId: string) {
|
|
||||||
return await chatRequest('/chat-api/v1/im/user/conversation/delete', {
|
|
||||||
method: 'post',
|
|
||||||
data: { chanelId },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { frogHttp } from '@/lib/http/instances'
|
||||||
|
import {
|
||||||
|
AiCarouselListOutput,
|
||||||
|
AiChatRankOutput,
|
||||||
|
AiGiftRankOutput,
|
||||||
|
AiHeartbeatRankOutput,
|
||||||
|
ExploreInfoOutput,
|
||||||
|
GetMeetListRequest,
|
||||||
|
GetMeetListResponse,
|
||||||
|
HomeRecommendV2Output,
|
||||||
|
SignInRoundOutput,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
export const homeService = {
|
||||||
|
// 发现
|
||||||
|
getExplore: (): Promise<ExploreInfoOutput> => {
|
||||||
|
return frogHttp.post('/web/explore/info')
|
||||||
|
},
|
||||||
|
|
||||||
|
// 首页分类列表
|
||||||
|
getMeetList: (data: GetMeetListRequest): Promise<GetMeetListResponse[]> => {
|
||||||
|
return frogHttp.post('/web/home/classification-list', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
// 热聊榜
|
||||||
|
getChatRank: (): Promise<AiChatRankOutput[]> => {
|
||||||
|
return frogHttp.post('/web/rank/chat')
|
||||||
|
},
|
||||||
|
|
||||||
|
// 心动榜
|
||||||
|
getHeartbeatRank: (): Promise<AiHeartbeatRankOutput[]> => {
|
||||||
|
return frogHttp.post('/web/rank/heartbeat')
|
||||||
|
},
|
||||||
|
|
||||||
|
// 送礼榜
|
||||||
|
getGiftRank: (): Promise<AiGiftRankOutput[]> => {
|
||||||
|
return frogHttp.post('/web/rank/gift')
|
||||||
|
},
|
||||||
|
|
||||||
|
// 七天签到列表
|
||||||
|
getSevenDaysSignList: (): Promise<SignInRoundOutput> => {
|
||||||
|
return frogHttp.post('/web/si/list')
|
||||||
|
},
|
||||||
|
|
||||||
|
// 签到
|
||||||
|
signIn: (): Promise<boolean> => {
|
||||||
|
return frogHttp.post('/web/si/asi')
|
||||||
|
},
|
||||||
|
|
||||||
|
// 首页AI轮播列表
|
||||||
|
getHomeAiCarouselList: (): Promise<AiCarouselListOutput[]> => {
|
||||||
|
return frogHttp.post('/web/home/ai-carousel-list')
|
||||||
|
},
|
||||||
|
|
||||||
|
// 首页聚合推荐
|
||||||
|
getHomeAggregateRecommend: (): Promise<HomeRecommendV2Output> => {
|
||||||
|
return frogHttp.post('/web/home/agg-recommend')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './types'
|
||||||
|
export * from './home.service'
|
||||||
|
|
@ -0,0 +1,819 @@
|
||||||
|
import { Gender } from 'nim-web-sdk-ng/dist/v2/NIM_BROWSER_SDK/UserServiceInterface'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ClassificationListInput
|
||||||
|
*/
|
||||||
|
export interface GetMeetListRequest {
|
||||||
|
/**
|
||||||
|
* 情感性格code
|
||||||
|
*/
|
||||||
|
characterCodeList: string[]
|
||||||
|
/**
|
||||||
|
* 需要排除的aiId列表
|
||||||
|
*/
|
||||||
|
exList?: number[]
|
||||||
|
/**
|
||||||
|
* 页码
|
||||||
|
*/
|
||||||
|
pn?: number
|
||||||
|
/**
|
||||||
|
* 每页大小
|
||||||
|
*/
|
||||||
|
ps: number
|
||||||
|
/**
|
||||||
|
* 角色code列表
|
||||||
|
*/
|
||||||
|
roleCodeList?: string[]
|
||||||
|
sex?: Gender
|
||||||
|
age?: Age
|
||||||
|
/** 多选:性别列表 */
|
||||||
|
sexList?: Gender[]
|
||||||
|
/** 多选:年龄列表 */
|
||||||
|
ageList?: Age[]
|
||||||
|
/**
|
||||||
|
* 标签code列表
|
||||||
|
*/
|
||||||
|
tagCodeList?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GetMeetListResponse
|
||||||
|
*/
|
||||||
|
export interface GetMeetListResponse {
|
||||||
|
/**
|
||||||
|
* AI的id
|
||||||
|
*/
|
||||||
|
aiId?: number
|
||||||
|
/**
|
||||||
|
* 出生日期
|
||||||
|
*/
|
||||||
|
birthday?: string
|
||||||
|
/**
|
||||||
|
* 性格名称
|
||||||
|
*/
|
||||||
|
characterName?: string
|
||||||
|
/**
|
||||||
|
* 头像
|
||||||
|
*/
|
||||||
|
headImg?: string
|
||||||
|
/**
|
||||||
|
* 主页头图
|
||||||
|
*/
|
||||||
|
homeImageUrl?: string
|
||||||
|
/**
|
||||||
|
* 简介
|
||||||
|
*/
|
||||||
|
introduction?: string
|
||||||
|
/**
|
||||||
|
* 被喜欢数
|
||||||
|
*/
|
||||||
|
likedNum?: number
|
||||||
|
/**
|
||||||
|
* 昵称
|
||||||
|
*/
|
||||||
|
nickname?: string
|
||||||
|
/**
|
||||||
|
* 角色名称
|
||||||
|
*/
|
||||||
|
roleName?: string
|
||||||
|
/**
|
||||||
|
* 0,男;1,女;2,自定义
|
||||||
|
*/
|
||||||
|
sex?: number
|
||||||
|
/**
|
||||||
|
* 标签名称
|
||||||
|
*/
|
||||||
|
tagName?: string
|
||||||
|
/**
|
||||||
|
* ai所属用户id
|
||||||
|
*/
|
||||||
|
userId?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AiChatRankOutput
|
||||||
|
*/
|
||||||
|
export interface AiChatRankOutput {
|
||||||
|
/**
|
||||||
|
* AI的id
|
||||||
|
*/
|
||||||
|
aiId?: number
|
||||||
|
/**
|
||||||
|
* 出生日期
|
||||||
|
*/
|
||||||
|
birthday?: string
|
||||||
|
/**
|
||||||
|
* 性格名称
|
||||||
|
*/
|
||||||
|
characterName?: string
|
||||||
|
/**
|
||||||
|
* 聊天次数
|
||||||
|
*/
|
||||||
|
chatNum?: number
|
||||||
|
/**
|
||||||
|
* 头像
|
||||||
|
*/
|
||||||
|
headImg?: string
|
||||||
|
/**
|
||||||
|
* 主页头图
|
||||||
|
*/
|
||||||
|
homeImageUrl?: string
|
||||||
|
/**
|
||||||
|
* 简介
|
||||||
|
*/
|
||||||
|
introduction?: string
|
||||||
|
/**
|
||||||
|
* 昵称
|
||||||
|
*/
|
||||||
|
nickname?: string
|
||||||
|
/**
|
||||||
|
* 排名编号
|
||||||
|
*/
|
||||||
|
rankNo?: number
|
||||||
|
/**
|
||||||
|
* 角色名称
|
||||||
|
*/
|
||||||
|
roleName?: string
|
||||||
|
/**
|
||||||
|
* 0,男;1,女;2,自定义
|
||||||
|
*/
|
||||||
|
sex?: number
|
||||||
|
/**
|
||||||
|
* 标签名称
|
||||||
|
*/
|
||||||
|
tagName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AiHeartbeatRankOutput
|
||||||
|
*/
|
||||||
|
export interface AiHeartbeatRankOutput {
|
||||||
|
/**
|
||||||
|
* AI的id
|
||||||
|
*/
|
||||||
|
aiId?: number
|
||||||
|
/**
|
||||||
|
* 出生日期
|
||||||
|
*/
|
||||||
|
birthday?: string
|
||||||
|
/**
|
||||||
|
* 性格名称
|
||||||
|
*/
|
||||||
|
characterName?: string
|
||||||
|
/**
|
||||||
|
* 头像
|
||||||
|
*/
|
||||||
|
headImg?: string
|
||||||
|
/**
|
||||||
|
* 心动总分值
|
||||||
|
*/
|
||||||
|
heartbeatValTotal?: number
|
||||||
|
/**
|
||||||
|
* 主页头图
|
||||||
|
*/
|
||||||
|
homeImageUrl?: string
|
||||||
|
/**
|
||||||
|
* 简介
|
||||||
|
*/
|
||||||
|
introduction?: string
|
||||||
|
/**
|
||||||
|
* 昵称
|
||||||
|
*/
|
||||||
|
nickname?: string
|
||||||
|
/**
|
||||||
|
* 排名编号
|
||||||
|
*/
|
||||||
|
rankNo?: number
|
||||||
|
/**
|
||||||
|
* 角色名称
|
||||||
|
*/
|
||||||
|
roleName?: string
|
||||||
|
/**
|
||||||
|
* 0,男;1,女;2,自定义
|
||||||
|
*/
|
||||||
|
sex?: number
|
||||||
|
/**
|
||||||
|
* 标签名称
|
||||||
|
*/
|
||||||
|
tagName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AiGiftRankOutput
|
||||||
|
*/
|
||||||
|
export interface AiGiftRankOutput {
|
||||||
|
/**
|
||||||
|
* AI的id
|
||||||
|
*/
|
||||||
|
aiId?: number
|
||||||
|
/**
|
||||||
|
* 出生日期
|
||||||
|
*/
|
||||||
|
birthday?: string
|
||||||
|
/**
|
||||||
|
* 性格名称
|
||||||
|
*/
|
||||||
|
characterName?: string
|
||||||
|
/**
|
||||||
|
* 礼物
|
||||||
|
*/
|
||||||
|
giftCoinNum?: number
|
||||||
|
/**
|
||||||
|
* 头像
|
||||||
|
*/
|
||||||
|
headImg?: string
|
||||||
|
/**
|
||||||
|
* 主页头图
|
||||||
|
*/
|
||||||
|
homeImageUrl?: string
|
||||||
|
/**
|
||||||
|
* 简介
|
||||||
|
*/
|
||||||
|
introduction?: string
|
||||||
|
/**
|
||||||
|
* 昵称
|
||||||
|
*/
|
||||||
|
nickname?: string
|
||||||
|
/**
|
||||||
|
* 排名编号
|
||||||
|
*/
|
||||||
|
rankNo?: number
|
||||||
|
/**
|
||||||
|
* 角色名称
|
||||||
|
*/
|
||||||
|
roleName?: string
|
||||||
|
/**
|
||||||
|
* 0,男;1,女;2,自定义
|
||||||
|
*/
|
||||||
|
sex?: number
|
||||||
|
/**
|
||||||
|
* 标签名称
|
||||||
|
*/
|
||||||
|
tagName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SignInRoundOutput
|
||||||
|
*/
|
||||||
|
export interface SignInRoundOutput {
|
||||||
|
/**
|
||||||
|
* 最大连续签到天数
|
||||||
|
*/
|
||||||
|
continuousDays?: number
|
||||||
|
/**
|
||||||
|
* 签到周期基础数据
|
||||||
|
*/
|
||||||
|
list?: SignInListOutput[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SignInListOutput
|
||||||
|
*/
|
||||||
|
export interface SignInListOutput {
|
||||||
|
/**
|
||||||
|
* 得到coin的数量
|
||||||
|
*/
|
||||||
|
coinNum?: number
|
||||||
|
/**
|
||||||
|
* PST 天(yyyy-MM-dd)
|
||||||
|
*/
|
||||||
|
dayStr?: string
|
||||||
|
/**
|
||||||
|
* 是否已签到
|
||||||
|
*/
|
||||||
|
signIn?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ExploreInfoOutput
|
||||||
|
*/
|
||||||
|
export interface ExploreInfoOutput {
|
||||||
|
/**
|
||||||
|
* AI总心动值榜单top3
|
||||||
|
*/
|
||||||
|
aiChatRankTop3List?: AiChatRankOutput[]
|
||||||
|
/**
|
||||||
|
* AI总心动值榜单top3
|
||||||
|
*/
|
||||||
|
aiGiftRankTop3List?: AiGiftRankOutput[]
|
||||||
|
/**
|
||||||
|
* AI总心动值榜单top3
|
||||||
|
*/
|
||||||
|
aiHeartbeatRankTop3List?: AiHeartbeatRankOutput[]
|
||||||
|
/**
|
||||||
|
* 广告列表
|
||||||
|
*/
|
||||||
|
outputList?: AdvertiseOutput[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* com.sonic.frog.domain.output.AiChatRankOutput
|
||||||
|
*
|
||||||
|
* AiChatRankOutput
|
||||||
|
*/
|
||||||
|
export interface AiChatRankOutput {
|
||||||
|
/**
|
||||||
|
* AI的id
|
||||||
|
*/
|
||||||
|
aiId?: number
|
||||||
|
/**
|
||||||
|
* 出生日期
|
||||||
|
*/
|
||||||
|
birthday?: string
|
||||||
|
/**
|
||||||
|
* 性格名称
|
||||||
|
*/
|
||||||
|
characterName?: string
|
||||||
|
/**
|
||||||
|
* 聊天次数
|
||||||
|
*/
|
||||||
|
chatNum?: number
|
||||||
|
/**
|
||||||
|
* 头像
|
||||||
|
*/
|
||||||
|
headImg?: string
|
||||||
|
/**
|
||||||
|
* 主页头图
|
||||||
|
*/
|
||||||
|
homeImageUrl?: string
|
||||||
|
/**
|
||||||
|
* 简介
|
||||||
|
*/
|
||||||
|
introduction?: string
|
||||||
|
/**
|
||||||
|
* 昵称
|
||||||
|
*/
|
||||||
|
nickname?: string
|
||||||
|
/**
|
||||||
|
* 排名编号
|
||||||
|
*/
|
||||||
|
rankNo?: number
|
||||||
|
/**
|
||||||
|
* 角色名称
|
||||||
|
*/
|
||||||
|
roleName?: string
|
||||||
|
/**
|
||||||
|
* 0,男;1,女;2,自定义
|
||||||
|
*/
|
||||||
|
sex?: number
|
||||||
|
/**
|
||||||
|
* 标签名称
|
||||||
|
*/
|
||||||
|
tagName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* com.sonic.frog.domain.output.AiGiftRankOutput
|
||||||
|
*
|
||||||
|
* AiGiftRankOutput
|
||||||
|
*/
|
||||||
|
export interface AiGiftRankOutput {
|
||||||
|
/**
|
||||||
|
* AI的id
|
||||||
|
*/
|
||||||
|
aiId?: number
|
||||||
|
/**
|
||||||
|
* 出生日期
|
||||||
|
*/
|
||||||
|
birthday?: string
|
||||||
|
/**
|
||||||
|
* 性格名称
|
||||||
|
*/
|
||||||
|
characterName?: string
|
||||||
|
/**
|
||||||
|
* 礼物
|
||||||
|
*/
|
||||||
|
giftCoinNum?: number
|
||||||
|
/**
|
||||||
|
* 头像
|
||||||
|
*/
|
||||||
|
headImg?: string
|
||||||
|
/**
|
||||||
|
* 主页头图
|
||||||
|
*/
|
||||||
|
homeImageUrl?: string
|
||||||
|
/**
|
||||||
|
* 简介
|
||||||
|
*/
|
||||||
|
introduction?: string
|
||||||
|
/**
|
||||||
|
* 昵称
|
||||||
|
*/
|
||||||
|
nickname?: string
|
||||||
|
/**
|
||||||
|
* 排名编号
|
||||||
|
*/
|
||||||
|
rankNo?: number
|
||||||
|
/**
|
||||||
|
* 角色名称
|
||||||
|
*/
|
||||||
|
roleName?: string
|
||||||
|
/**
|
||||||
|
* 0,男;1,女;2,自定义
|
||||||
|
*/
|
||||||
|
sex?: number
|
||||||
|
/**
|
||||||
|
* 标签名称
|
||||||
|
*/
|
||||||
|
tagName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* com.sonic.frog.domain.output.AiHeartbeatRankOutput
|
||||||
|
*
|
||||||
|
* AiHeartbeatRankOutput
|
||||||
|
*/
|
||||||
|
export interface AiHeartbeatRankOutput {
|
||||||
|
/**
|
||||||
|
* AI的id
|
||||||
|
*/
|
||||||
|
aiId?: number
|
||||||
|
/**
|
||||||
|
* 出生日期
|
||||||
|
*/
|
||||||
|
birthday?: string
|
||||||
|
/**
|
||||||
|
* 性格名称
|
||||||
|
*/
|
||||||
|
characterName?: string
|
||||||
|
/**
|
||||||
|
* 头像
|
||||||
|
*/
|
||||||
|
headImg?: string
|
||||||
|
/**
|
||||||
|
* 心动总分值
|
||||||
|
*/
|
||||||
|
heartbeatValTotal?: number
|
||||||
|
/**
|
||||||
|
* 主页头图
|
||||||
|
*/
|
||||||
|
homeImageUrl?: string
|
||||||
|
/**
|
||||||
|
* 简介
|
||||||
|
*/
|
||||||
|
introduction?: string
|
||||||
|
/**
|
||||||
|
* 昵称
|
||||||
|
*/
|
||||||
|
nickname?: string
|
||||||
|
/**
|
||||||
|
* 排名编号
|
||||||
|
*/
|
||||||
|
rankNo?: number
|
||||||
|
/**
|
||||||
|
* 角色名称
|
||||||
|
*/
|
||||||
|
roleName?: string
|
||||||
|
/**
|
||||||
|
* 0,男;1,女;2,自定义
|
||||||
|
*/
|
||||||
|
sex?: number
|
||||||
|
/**
|
||||||
|
* 标签名称
|
||||||
|
*/
|
||||||
|
tagName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AdvertiseOutput
|
||||||
|
*/
|
||||||
|
export interface AdvertiseOutput {
|
||||||
|
/**
|
||||||
|
* 使用端点(WEB/ANDROID/IOS)
|
||||||
|
* endpoint
|
||||||
|
*/
|
||||||
|
endpoint?: string
|
||||||
|
/**
|
||||||
|
* 扩展字段
|
||||||
|
* ext
|
||||||
|
*/
|
||||||
|
ext?: string
|
||||||
|
/**
|
||||||
|
* 广告配图
|
||||||
|
* icon
|
||||||
|
*/
|
||||||
|
icon?: string
|
||||||
|
/**
|
||||||
|
* 是否弹窗(1.是,0.否)
|
||||||
|
* is_global
|
||||||
|
*/
|
||||||
|
isGlobal?: number
|
||||||
|
/**
|
||||||
|
* 跳转连接
|
||||||
|
* jump_link
|
||||||
|
*/
|
||||||
|
jumpLink?: string
|
||||||
|
/**
|
||||||
|
* 广告名称
|
||||||
|
* name
|
||||||
|
*/
|
||||||
|
name?: string
|
||||||
|
/**
|
||||||
|
* 展示结束时间
|
||||||
|
* show_end_time
|
||||||
|
*/
|
||||||
|
showEndTime?: string
|
||||||
|
/**
|
||||||
|
* 展示开始时间
|
||||||
|
* show_start_time
|
||||||
|
*/
|
||||||
|
showStartTime?: string
|
||||||
|
/**
|
||||||
|
* 排序
|
||||||
|
* sort
|
||||||
|
*/
|
||||||
|
sort?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 年龄:单选 AGE_1(18-24)、AGE_2(25-34)、AGE_3(35-44)、AGE_4(45-54)、AGE_5(>54)
|
||||||
|
*/
|
||||||
|
export enum Age {
|
||||||
|
Age1 = 'AGE_1',
|
||||||
|
Age2 = 'AGE_2',
|
||||||
|
Age3 = 'AGE_3',
|
||||||
|
Age4 = 'AGE_4',
|
||||||
|
Age5 = 'AGE_5',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ai轮播列表输出
|
||||||
|
*
|
||||||
|
* AiCarouselListOutput
|
||||||
|
*/
|
||||||
|
export interface AiCarouselListOutput {
|
||||||
|
/**
|
||||||
|
* AI的id
|
||||||
|
*/
|
||||||
|
aiId?: number
|
||||||
|
/**
|
||||||
|
* 出生日期
|
||||||
|
*/
|
||||||
|
birthday?: string
|
||||||
|
/**
|
||||||
|
* 性格名称
|
||||||
|
*/
|
||||||
|
characterName?: string
|
||||||
|
/**
|
||||||
|
* 头像
|
||||||
|
*/
|
||||||
|
headImg?: string
|
||||||
|
/**
|
||||||
|
* 主页头图
|
||||||
|
*/
|
||||||
|
homeImageUrl?: string
|
||||||
|
/**
|
||||||
|
* 简介
|
||||||
|
*/
|
||||||
|
introduction?: string
|
||||||
|
/**
|
||||||
|
* 是否点赞过
|
||||||
|
*/
|
||||||
|
liked?: boolean
|
||||||
|
/**
|
||||||
|
* 点赞数
|
||||||
|
*/
|
||||||
|
likedCount?: number
|
||||||
|
/**
|
||||||
|
* 昵称
|
||||||
|
*/
|
||||||
|
nickname?: string
|
||||||
|
/**
|
||||||
|
* 角色名称
|
||||||
|
*/
|
||||||
|
roleName?: string
|
||||||
|
/**
|
||||||
|
* 0,男;1,女;2,自定义
|
||||||
|
*/
|
||||||
|
sex?: number
|
||||||
|
/**
|
||||||
|
* 标签名称
|
||||||
|
*/
|
||||||
|
tagName?: string
|
||||||
|
/**
|
||||||
|
* ai所属用户id
|
||||||
|
*/
|
||||||
|
userId?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HomeRecommendV2Output
|
||||||
|
*/
|
||||||
|
export interface HomeRecommendV2Output {
|
||||||
|
mostChat?: AiChatRankOutput[]
|
||||||
|
mustCrush?: AiHeartbeatRankOutput[]
|
||||||
|
mustGifted?: AiGiftRankOutput[]
|
||||||
|
starAChat?: StartChatOutput[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* com.sonic.frog.domain.output.AiChatRankOutput
|
||||||
|
*
|
||||||
|
* AiChatRankOutput
|
||||||
|
*/
|
||||||
|
export interface AiChatRankOutput {
|
||||||
|
/**
|
||||||
|
* AI的id
|
||||||
|
*/
|
||||||
|
aiId?: number
|
||||||
|
/**
|
||||||
|
* 出生日期
|
||||||
|
*/
|
||||||
|
birthday?: string
|
||||||
|
/**
|
||||||
|
* 性格名称
|
||||||
|
*/
|
||||||
|
characterName?: string
|
||||||
|
/**
|
||||||
|
* 聊天次数
|
||||||
|
*/
|
||||||
|
chatNum?: number
|
||||||
|
/**
|
||||||
|
* 头像
|
||||||
|
*/
|
||||||
|
headImg?: string
|
||||||
|
/**
|
||||||
|
* 主页头图
|
||||||
|
*/
|
||||||
|
homeImageUrl?: string
|
||||||
|
/**
|
||||||
|
* 简介
|
||||||
|
*/
|
||||||
|
introduction?: string
|
||||||
|
/**
|
||||||
|
* 被喜欢数
|
||||||
|
*/
|
||||||
|
likedNum?: number
|
||||||
|
/**
|
||||||
|
* 昵称
|
||||||
|
*/
|
||||||
|
nickname?: string
|
||||||
|
/**
|
||||||
|
* 排名编号
|
||||||
|
*/
|
||||||
|
rankNo?: number
|
||||||
|
/**
|
||||||
|
* 角色名称
|
||||||
|
*/
|
||||||
|
roleName?: string
|
||||||
|
/**
|
||||||
|
* 0,男;1,女;2,自定义
|
||||||
|
*/
|
||||||
|
sex?: number
|
||||||
|
/**
|
||||||
|
* 标签名称
|
||||||
|
*/
|
||||||
|
tagName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* com.sonic.frog.domain.output.AiHeartbeatRankOutput
|
||||||
|
*
|
||||||
|
* AiHeartbeatRankOutput
|
||||||
|
*/
|
||||||
|
export interface AiHeartbeatRankOutput {
|
||||||
|
/**
|
||||||
|
* AI的id
|
||||||
|
*/
|
||||||
|
aiId?: number
|
||||||
|
/**
|
||||||
|
* 出生日期
|
||||||
|
*/
|
||||||
|
birthday?: string
|
||||||
|
/**
|
||||||
|
* 性格名称
|
||||||
|
*/
|
||||||
|
characterName?: string
|
||||||
|
/**
|
||||||
|
* 头像
|
||||||
|
*/
|
||||||
|
headImg?: string
|
||||||
|
/**
|
||||||
|
* 心动总分值
|
||||||
|
*/
|
||||||
|
heartbeatValTotal?: number
|
||||||
|
/**
|
||||||
|
* 主页头图
|
||||||
|
*/
|
||||||
|
homeImageUrl?: string
|
||||||
|
/**
|
||||||
|
* 简介
|
||||||
|
*/
|
||||||
|
introduction?: string
|
||||||
|
/**
|
||||||
|
* 被喜欢数
|
||||||
|
*/
|
||||||
|
likedNum?: number
|
||||||
|
/**
|
||||||
|
* 昵称
|
||||||
|
*/
|
||||||
|
nickname?: string
|
||||||
|
/**
|
||||||
|
* 排名编号
|
||||||
|
*/
|
||||||
|
rankNo?: number
|
||||||
|
/**
|
||||||
|
* 角色名称
|
||||||
|
*/
|
||||||
|
roleName?: string
|
||||||
|
/**
|
||||||
|
* 0,男;1,女;2,自定义
|
||||||
|
*/
|
||||||
|
sex?: number
|
||||||
|
/**
|
||||||
|
* 标签名称
|
||||||
|
*/
|
||||||
|
tagName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* com.sonic.frog.domain.output.AiGiftRankOutput
|
||||||
|
*
|
||||||
|
* AiGiftRankOutput
|
||||||
|
*/
|
||||||
|
export interface AiGiftRankOutput {
|
||||||
|
/**
|
||||||
|
* AI的id
|
||||||
|
*/
|
||||||
|
aiId?: number
|
||||||
|
/**
|
||||||
|
* 出生日期
|
||||||
|
*/
|
||||||
|
birthday?: string
|
||||||
|
/**
|
||||||
|
* 性格名称
|
||||||
|
*/
|
||||||
|
characterName?: string
|
||||||
|
/**
|
||||||
|
* 礼物
|
||||||
|
*/
|
||||||
|
giftCoinNum?: number
|
||||||
|
/**
|
||||||
|
* 头像
|
||||||
|
*/
|
||||||
|
headImg?: string
|
||||||
|
/**
|
||||||
|
* 主页头图
|
||||||
|
*/
|
||||||
|
homeImageUrl?: string
|
||||||
|
/**
|
||||||
|
* 简介
|
||||||
|
*/
|
||||||
|
introduction?: string
|
||||||
|
/**
|
||||||
|
* 被喜欢数
|
||||||
|
*/
|
||||||
|
likedNum?: number
|
||||||
|
/**
|
||||||
|
* 昵称
|
||||||
|
*/
|
||||||
|
nickname?: string
|
||||||
|
/**
|
||||||
|
* 排名编号
|
||||||
|
*/
|
||||||
|
rankNo?: number
|
||||||
|
/**
|
||||||
|
* 角色名称
|
||||||
|
*/
|
||||||
|
roleName?: string
|
||||||
|
/**
|
||||||
|
* 0,男;1,女;2,自定义
|
||||||
|
*/
|
||||||
|
sex?: number
|
||||||
|
/**
|
||||||
|
* 标签名称
|
||||||
|
*/
|
||||||
|
tagName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* com.sonic.frog.domain.output.StartChatOutput
|
||||||
|
*
|
||||||
|
* StartChatOutput
|
||||||
|
*/
|
||||||
|
export interface StartChatOutput {
|
||||||
|
/**
|
||||||
|
* AI的id
|
||||||
|
*/
|
||||||
|
aiId?: number
|
||||||
|
/**
|
||||||
|
* 开场白语音地址
|
||||||
|
*/
|
||||||
|
dialoguePrologueSound?: string
|
||||||
|
/**
|
||||||
|
* 头像
|
||||||
|
*/
|
||||||
|
headImg?: string
|
||||||
|
/**
|
||||||
|
* 被喜欢数
|
||||||
|
*/
|
||||||
|
likedNum?: number
|
||||||
|
/**
|
||||||
|
* 昵称
|
||||||
|
*/
|
||||||
|
nickname?: string
|
||||||
|
/**
|
||||||
|
* 主动聊天内容
|
||||||
|
*/
|
||||||
|
supportingContentList?: string[]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue