feat: 适配移动端
This commit is contained in:
parent
8b524fbf79
commit
ddff5100b6
1
.env
1
.env
|
|
@ -8,6 +8,7 @@ NEXT_PUBLIC_PIGEON_API_URL=http://35.82.37.117:8082/pigeon
|
|||
|
||||
# A18 服务
|
||||
NEXT_PUBLIC_EDITOR_API_URL=http://54.223.196.180
|
||||
NEXT_PUBLIC_CHAT_API_URL=http://54.223.196.180
|
||||
|
||||
# 三方登录
|
||||
NEXT_PUBLIC_DISCORD_CLIENT_ID=1448143535609217076
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 447 B |
|
|
@ -1,13 +1,6 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_164_982)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M25.7729 22.9651C27.1751 21.0012 28 18.5969 28 16C28 9.37258 22.6274 4 16 4C9.37258 4 4 9.37258 4 16C4 22.6274 9.37258 28 16 28C18.5937 28 20.9952 27.1771 22.9577 25.7782C22.5042 25.1743 22.6806 24.1646 23.3976 23.4307C24.1299 22.6812 25.1629 22.4886 25.7729 22.9651Z" fill="#5C5565"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M14 10.5C14 11.8807 12.8807 13 11.5 13C10.1193 13 9 11.8807 9 10.5C9 9.11929 10.1193 8 11.5 8C12.8807 8 14 9.11929 14 10.5Z" fill="#847D8B"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M11 16.5C11 17.3284 10.3284 18 9.5 18C8.67157 18 8 17.3284 8 16.5C8 15.6716 8.67157 15 9.5 15C10.3284 15 11 15.6716 11 16.5Z" fill="#847D8B"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.21523 18.2748C4.10475 17.6991 4.03542 17.1087 4.01041 16.5069C1.19344 18.2155 -0.352616 20.0249 0.068487 21.496C0.878956 24.3273 8.66928 24.8334 17.4687 22.6263C26.268 20.4192 32.7443 16.3347 31.9339 13.5034C31.5609 12.2003 29.7094 11.3897 26.9614 11.1096C27.181 11.6008 27.3684 12.1096 27.5212 12.6331C28.7042 12.902 29.4634 13.3596 29.6473 14.0022C30.2367 16.0613 24.6958 19.2403 17.2713 21.1025C9.84686 22.9647 3.35029 22.8051 2.76086 20.746C2.55441 20.0247 3.10002 19.1661 4.21523 18.2748Z" fill="#352E3E"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_164_982">
|
||||
<rect width="32.0025" height="32" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="24" cy="24" r="12" fill="#505473"/>
|
||||
<path d="M38 24C38 27.3137 31.732 30 24 30C16.268 30 10 27.3137 10 24" stroke="#505473" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M19 19L21 21L19 23" stroke="#7D82AA" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M29 19L27 21L29 23" stroke="#7D82AA" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 505 B |
|
|
@ -1,24 +1,17 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_164_1032)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M25.7729 22.9651C27.1751 21.0012 28 18.5969 28 16C28 9.37258 22.6274 4 16 4C9.37258 4 4 9.37258 4 16C4 22.6274 9.37258 28 16 28C18.5937 28 20.9952 27.1771 22.9577 25.7782C22.5042 25.1743 22.6806 24.1646 23.3976 23.4307C24.1299 22.6812 25.1629 22.4886 25.7729 22.9651Z" fill="url(#paint0_linear_164_1032)"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.5 13C12.8807 13 14 11.8807 14 10.5C14 9.11929 12.8807 8 11.5 8C10.1193 8 9 9.11929 9 10.5C9 11.8807 10.1193 13 11.5 13ZM9.5 18C10.3284 18 11 17.3284 11 16.5C11 15.6716 10.3284 15 9.5 15C8.67157 15 8 15.6716 8 16.5C8 17.3284 8.67157 18 9.5 18Z" fill="url(#paint1_radial_164_1032)"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.21523 18.2748C4.10475 17.6991 4.03542 17.1087 4.01041 16.5069C1.19344 18.2155 -0.352616 20.0249 0.068487 21.496C0.878956 24.3273 8.66928 24.8334 17.4687 22.6263C26.268 20.4192 32.7443 16.3347 31.9339 13.5034C31.5609 12.2003 29.7094 11.3897 26.9614 11.1096C27.181 11.6008 27.3684 12.1096 27.5212 12.6331C28.7042 12.902 29.4634 13.3596 29.6473 14.0022C30.2367 16.0613 24.6958 19.2403 17.2713 21.1025C9.84686 22.9647 3.35029 22.8051 2.76086 20.746C2.55441 20.0247 3.10002 19.1661 4.21523 18.2748Z" fill="url(#paint2_radial_164_1032)"/>
|
||||
</g>
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 24C0 10.7452 10.7452 0 24 0C37.2548 0 48 10.7452 48 24C48 37.2548 37.2548 48 24 48C10.7452 48 0 37.2548 0 24Z" fill="#B9ECFF" fill-opacity="0.08"/>
|
||||
<path d="M38 24C38 27.3137 31.732 30 24 30C16.268 30 10 27.3137 10 24" stroke="url(#paint0_linear_77_8651)" stroke-width="2" stroke-linecap="round"/>
|
||||
<circle cx="24" cy="24" r="12" fill="url(#paint1_linear_77_8651)"/>
|
||||
<path d="M19 19L21 21L19 23" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M29 19L27 21L29 23" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_164_1032" x1="4" y1="16" x2="28" y2="16" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#F264A4"/>
|
||||
<stop offset="1" stop-color="#C241E6"/>
|
||||
<linearGradient id="paint0_linear_77_8651" x1="24" y1="24" x2="24" y2="30" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFD051"/>
|
||||
<stop offset="1" stop-color="#4F3CFF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_77_8651" x1="35.9989" y1="12" x2="23.9989" y2="36" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.00534234" stop-color="#F157FF"/>
|
||||
<stop offset="1" stop-color="#3337FF"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="paint1_radial_164_1032" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(9.17906 8) rotate(63.049) scale(13.2384 13.0672)">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#F78AFF" stop-opacity="0.0117647"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="paint2_radial_164_1032" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(6.28879 11.1096) rotate(25.4231) scale(35.4337 35.9556)">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#F78AFF" stop-opacity="0.0117647"/>
|
||||
</radialGradient>
|
||||
<clipPath id="clip0_164_1032">
|
||||
<rect width="32.0025" height="32" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.1 KiB |
|
|
@ -0,0 +1,22 @@
|
|||
'use client';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAsyncFn } from '@/hooks/tools';
|
||||
import { useStreamChatStore } from '@/stores/stream-chat';
|
||||
|
||||
export default function ChatButton({ id }: { id: string }) {
|
||||
const router = useRouter();
|
||||
const createChannel = useStreamChatStore((s) => s.createChannel);
|
||||
|
||||
const { loading, run: createChannelAndPush } = useAsyncFn(async () => {
|
||||
const channelId = await createChannel(id);
|
||||
if (!channelId) return;
|
||||
router.push(`/chat/${channelId}`);
|
||||
});
|
||||
|
||||
return (
|
||||
<Button loading={loading} onClick={() => createChannelAndPush()} variant="primary">
|
||||
Chat
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import CharacterHeader from './CharacterHeader'
|
||||
import AIMessage from './AIMessage'
|
||||
import UserMessage from './UserMessage'
|
||||
import VirtualList from '@/components/ui/virtual-list'
|
||||
|
||||
export default function MessageList() {
|
||||
const itemList = [
|
||||
{
|
||||
type: 'header',
|
||||
},
|
||||
{
|
||||
type: 'aiMessage',
|
||||
data: {
|
||||
text: '"Hello, how are you?", *Long long ago*, ^there was a beautiful princess...^ (She was the most beautiful princess in the world)',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'userMessage',
|
||||
data: {
|
||||
text: '"Hello, how are you?", Long long ago, there was a beautiful princess... (She was the most beautiful princess in the world)',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const itemContent = (index: number, item: { type: string; data: any }) => {
|
||||
switch (item.type) {
|
||||
case 'aiMessage':
|
||||
return <AIMessage data={item.data} />
|
||||
case 'userMessage':
|
||||
return <UserMessage data={item.data} />
|
||||
case 'header':
|
||||
return <CharacterHeader />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 replative">
|
||||
<VirtualList className="h-full" data={itemList} itemContent={itemContent} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
'use client'
|
||||
import { useChatStore } from '../store'
|
||||
import Profile from './Profile'
|
||||
import Personal from './Personal'
|
||||
import VoiceActor from './VoiceActor'
|
||||
import Font from './Font'
|
||||
import MaxToken from './MaxToken'
|
||||
import Background from './Background'
|
||||
import ChatModel from './ChatModel'
|
||||
import { IconButton } from '@/components/ui/button'
|
||||
import React from 'react'
|
||||
|
||||
export const SiderHeader = React.memo(({ title }: { title: string }) => {
|
||||
const setSideBar = useChatStore((store) => store.setSideBar)
|
||||
return (
|
||||
<div className="flex gap-2 mb-7 items-center">
|
||||
<IconButton variant="ghost" size="small" onClick={() => setSideBar('profile')}>
|
||||
<i className="iconfont-v2 iconv2-jiantou" />
|
||||
</IconButton>
|
||||
<div className="txt-title-m">{title}</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default function Sider() {
|
||||
const sideBar = useChatStore((store) => store.sideBar)
|
||||
return (
|
||||
<div className="w-100 h-full overflow-y-auto border-outline-normal border-l bg-[rgba(17,16,38,1)] p-6">
|
||||
{sideBar === 'profile' && <Profile />}
|
||||
{sideBar === 'personal' && <Personal />}
|
||||
{sideBar === 'voice_actor' && <VoiceActor />}
|
||||
{sideBar === 'font' && <Font />}
|
||||
{sideBar === 'max_token' && <MaxToken />}
|
||||
{sideBar === 'background' && <Background />}
|
||||
{sideBar === 'model' && <ChatModel />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { AvatarImage, AvatarFallback, Avatar } from '@radix-ui/react-avatar';
|
||||
import Link from 'next/link';
|
||||
import ChatButton from './ChatButton';
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
|
|
@ -27,9 +26,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
|
|||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Link href={`/character/${id}/chat`}>
|
||||
<Button variant="primary">Chat</Button>
|
||||
</Link>
|
||||
<ChatButton id={id}></ChatButton>
|
||||
</div>
|
||||
</header>
|
||||
<div className="mt-12 rounded-2xl bg-white/10 p-6">
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
'use client';
|
||||
import ChatSidebar from '@/layout/components/ChatSidebar';
|
||||
|
||||
export default function ChatPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Chat</h1>
|
||||
<div className="h-full">
|
||||
<ChatSidebar expand />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
'use client'
|
||||
'use client';
|
||||
|
||||
import AITextRender from '@/components/ui/text-md'
|
||||
import AITextRender from '@/components/ui/text-md';
|
||||
|
||||
export default function AIMessage({ data }: { data: any }) {
|
||||
return (
|
||||
<div className="mb-8 max-w-[90%]">
|
||||
<div className="bg-surface-element-normal inline-block rounded-lg p-4 backdrop-blur-2xl">
|
||||
<AITextRender text={data.text} />
|
||||
<AITextRender text={data.content} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -1,51 +1,48 @@
|
|||
'use client'
|
||||
'use client';
|
||||
|
||||
import { Tag } from '@/components/ui/tag'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Tag } from '@/components/ui/tag';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { useCharacter } from '@/hooks/services/character';
|
||||
import { useParams } from 'next/navigation';
|
||||
// import CrushLevelAvatar from './CrushLevelAvatar'
|
||||
|
||||
export const CharacterAvatorAndName = () => {
|
||||
const { introduction, characterName, tagName } = {
|
||||
introduction: 'introduction introduction introduction introduction introduction',
|
||||
characterName: 'characterName',
|
||||
tagName: 'tagName',
|
||||
}
|
||||
export const CharacterAvatorAndName = ({ name, avator }: { name: string; avator: string }) => {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<Avatar className="h-20 w-20">
|
||||
<AvatarImage src="https://picsum.photos/200/300" />
|
||||
<AvatarFallback>{characterName?.slice(0, 1)}</AvatarFallback>
|
||||
<AvatarImage src={avator} />
|
||||
<AvatarFallback>{name?.slice(0, 1)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="txt-headline-s text-center text-white">Honey Snow</div>
|
||||
<div className="txt-headline-s text-center text-white">{name}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const ChatMessageUserHeader = () => {
|
||||
const [isFullIntroduction, setIsFullIntroduction] = useState(false)
|
||||
const [shouldShowExpandButton, setShouldShowExpandButton] = useState(false)
|
||||
const textRef = useRef<HTMLDivElement>(null)
|
||||
const { introduction, characterName, tagName } = {
|
||||
const [isFullIntroduction, setIsFullIntroduction] = useState(false);
|
||||
const [shouldShowExpandButton, setShouldShowExpandButton] = useState(false);
|
||||
const textRef = useRef<HTMLDivElement>(null);
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { data: character = {} } = useCharacter(id.split('-')[2]);
|
||||
const { introduction } = {
|
||||
introduction: 'introduction introduction introduction introduction introduction',
|
||||
characterName: 'characterName',
|
||||
tagName: 'tagName',
|
||||
}
|
||||
};
|
||||
|
||||
// 检测文本是否超过三行
|
||||
useEffect(() => {
|
||||
if (textRef.current && introduction) {
|
||||
// 直接比较滚动高度和可见高度
|
||||
// 如果内容的实际高度大于容器的可见高度,说明内容被截断了
|
||||
const isOverflowing = textRef.current.scrollHeight > textRef.current.clientHeight
|
||||
setShouldShowExpandButton(isOverflowing)
|
||||
const isOverflowing = textRef.current.scrollHeight > textRef.current.clientHeight;
|
||||
setShouldShowExpandButton(isOverflowing);
|
||||
}
|
||||
}, [introduction])
|
||||
}, [introduction]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<CharacterAvatorAndName />
|
||||
<CharacterAvatorAndName name={character.name || '-'} avator={character.headPortrait || ''} />
|
||||
|
||||
<div className="bg-surface-element-normal border-outline-normal flex w-full flex-col gap-2 rounded-lg border border-solid p-4 backdrop-blur-2xl">
|
||||
<div
|
||||
|
|
@ -63,12 +60,11 @@ const ChatMessageUserHeader = () => {
|
|||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag variant="purple" size="small">
|
||||
{characterName}
|
||||
</Tag>
|
||||
<Tag variant="magenta" size="small">
|
||||
{tagName}
|
||||
</Tag>
|
||||
{character?.tags?.slice(0, 2)?.map((tag: any, index: number) => (
|
||||
<Tag key={tag.tagId} variant={index ? 'purple' : 'magenta'} size="small">
|
||||
{tag.name}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
{shouldShowExpandButton && !isFullIntroduction && (
|
||||
<div
|
||||
|
|
@ -88,7 +84,7 @@ const ChatMessageUserHeader = () => {
|
|||
|
||||
<div className="txt-body-m text-txt-secondary-normal">Content generated by AI</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatMessageUserHeader
|
||||
export default ChatMessageUserHeader;
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { IconButton } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useStreamChatStore } from '@/stores/stream-chat';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
|
||||
const AuthHeightTextarea = (props: React.ComponentProps<'textarea'> & { maxHeight?: number }) => {
|
||||
|
|
@ -59,6 +60,7 @@ const AuthHeightTextarea = (props: React.ComponentProps<'textarea'> & { maxHeigh
|
|||
export default function Input() {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const sendMessage = useStreamChatStore((state) => state.sendMessage);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col mb-6 items-end gap-4">
|
||||
|
|
@ -85,7 +87,7 @@ export default function Input() {
|
|||
<IconButton
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => {}}
|
||||
onClick={() => null}
|
||||
className={cn('bg-surface-element-hover flex-shrink-0')}
|
||||
iconfont="icon-prompt"
|
||||
/>
|
||||
|
|
@ -94,7 +96,7 @@ export default function Input() {
|
|||
size="large"
|
||||
loading={false}
|
||||
iconfont="icon-icon-send"
|
||||
onClick={() => {}}
|
||||
onClick={() => sendMessage(inputValue)}
|
||||
disabled={false}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
'use client';
|
||||
|
||||
import CharacterHeader from './CharacterHeader';
|
||||
import AIMessage from './AIMessage';
|
||||
import UserMessage from './UserMessage';
|
||||
import VirtualList from '@/components/ui/virtual-list';
|
||||
import { useStreamChatStore } from '@/stores/stream-chat';
|
||||
|
||||
export default function MessageList() {
|
||||
const messages = useStreamChatStore((s) => s.messages);
|
||||
const itemList = [
|
||||
{
|
||||
type: 'header',
|
||||
},
|
||||
...messages.map((i) => {
|
||||
return {
|
||||
type: i.role === 'user' ? 'user-message' : 'assistant-message',
|
||||
data: i,
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
const itemContent = (index: number, item: { type: string; data: any }) => {
|
||||
switch (item.type) {
|
||||
case 'user-message':
|
||||
return <UserMessage data={item.data} />;
|
||||
case 'assistant-message':
|
||||
return <AIMessage data={item.data} />;
|
||||
case 'header':
|
||||
return <CharacterHeader />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 replative">
|
||||
<VirtualList className="h-full" data={itemList} itemContent={itemContent} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,21 +1,20 @@
|
|||
'use client'
|
||||
import { SiderHeader } from '.'
|
||||
import { useChatStore } from '../store'
|
||||
import { Button, IconButton } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import Image from 'next/image'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import React, { useState } from 'react'
|
||||
import { Tag } from '@/components/ui/tag'
|
||||
import { ImageViewer } from '@/components/ui/image-viewer'
|
||||
import { useImageViewer } from '@/hooks/useImageViewer'
|
||||
'use client';
|
||||
import { useChatStore } from '../store';
|
||||
import { Button, IconButton } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import Image from 'next/image';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import React, { useState } from 'react';
|
||||
import { Tag } from '@/components/ui/tag';
|
||||
import { ImageViewer } from '@/components/ui/image-viewer';
|
||||
import { useImageViewer } from '@/hooks/useImageViewer';
|
||||
|
||||
type BackgroundItem = {
|
||||
backgroundId: number
|
||||
imgUrl: string
|
||||
isDefault: boolean
|
||||
inUse?: boolean
|
||||
}
|
||||
backgroundId: number;
|
||||
imgUrl: string;
|
||||
isDefault: boolean;
|
||||
inUse?: boolean;
|
||||
};
|
||||
|
||||
const BackgroundImageViewerAction = ({
|
||||
datas,
|
||||
|
|
@ -23,18 +22,18 @@ const BackgroundImageViewerAction = ({
|
|||
isSelected,
|
||||
onChange,
|
||||
}: {
|
||||
datas: BackgroundItem[]
|
||||
backgroundId: number
|
||||
isSelected: boolean
|
||||
onChange: (backgroundId: number) => void
|
||||
datas: BackgroundItem[];
|
||||
backgroundId: number;
|
||||
isSelected: boolean;
|
||||
onChange: (backgroundId: number) => void;
|
||||
}) => {
|
||||
const handleSelect = () => {
|
||||
// 如果只有一张背景且当前已选中,不允许取消选中
|
||||
if (datas.length === 1 && isSelected) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
onChange(backgroundId)
|
||||
}
|
||||
onChange(backgroundId);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -47,8 +46,8 @@ const BackgroundImageViewerAction = ({
|
|||
<div className="txt-label-s">Select</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const BackgroundItemCard = ({
|
||||
item,
|
||||
|
|
@ -58,20 +57,20 @@ const BackgroundItemCard = ({
|
|||
onImagePreview,
|
||||
totalCount,
|
||||
}: {
|
||||
item: BackgroundItem
|
||||
selected: boolean
|
||||
inUse: boolean
|
||||
onClick: () => void
|
||||
onImagePreview: () => void
|
||||
totalCount: number
|
||||
item: BackgroundItem;
|
||||
selected: boolean;
|
||||
inUse: boolean;
|
||||
onClick: () => void;
|
||||
onImagePreview: () => void;
|
||||
totalCount: number;
|
||||
}) => {
|
||||
const handleClick = () => {
|
||||
// 如果只有一张背景且当前已选中,不允许取消选中
|
||||
if (totalCount === 1 && selected) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
onClick()
|
||||
}
|
||||
onClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group relative cursor-pointer" onClick={handleClick}>
|
||||
|
|
@ -94,20 +93,20 @@ const BackgroundItemCard = ({
|
|||
size="xs"
|
||||
variant="contrast"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onImagePreview()
|
||||
e.stopPropagation();
|
||||
onImagePreview();
|
||||
}}
|
||||
>
|
||||
<i className="iconfont icon-icon-fullImage" />
|
||||
</IconButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default function Background() {
|
||||
const setSideBar = useChatStore((store) => store.setSideBar)
|
||||
const [selectId, setSelectId] = useState<number | undefined>(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const setSideBar = useChatStore((store) => store.setSideBar);
|
||||
const [selectId, setSelectId] = useState<number | undefined>(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 静态数据:模拟背景图片列表
|
||||
const backgroundList: BackgroundItem[] = [
|
||||
|
|
@ -132,13 +131,13 @@ export default function Background() {
|
|||
imgUrl: 'https://picsum.photos/400/600?random=4',
|
||||
isDefault: false,
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
// 当前使用的背景
|
||||
const currentBackgroundImg = backgroundList.find((item) => item.inUse)?.imgUrl
|
||||
const currentBackgroundImg = backgroundList.find((item) => item.inUse)?.imgUrl;
|
||||
|
||||
// 静态数据:是否解锁生成图片功能
|
||||
const isUnlock = true
|
||||
const isUnlock = true;
|
||||
|
||||
// 图片查看器
|
||||
const {
|
||||
|
|
@ -147,35 +146,33 @@ export default function Background() {
|
|||
openViewer,
|
||||
closeViewer,
|
||||
handleIndexChange,
|
||||
} = useImageViewer()
|
||||
} = useImageViewer();
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setLoading(true)
|
||||
setLoading(true);
|
||||
try {
|
||||
// TODO: 调用实际的 API
|
||||
// await updateChatBackground({ aiId, backgroundId: selectId })
|
||||
console.log('Selected background:', selectId)
|
||||
console.log('Selected background:', selectId);
|
||||
|
||||
// 模拟延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
setSideBar('profile')
|
||||
setSideBar('profile');
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleImagePreview = (index: number) => {
|
||||
openViewer(backgroundList?.map((item) => item.imgUrl || '') || [], index)
|
||||
}
|
||||
openViewer(backgroundList?.map((item) => item.imgUrl || '') || [], index);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full flex-col">
|
||||
<SiderHeader title="Chat Background" />
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{backgroundList?.map((item, index) => (
|
||||
|
|
@ -191,15 +188,6 @@ export default function Background() {
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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}>
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ImageViewer
|
||||
|
|
@ -217,9 +205,9 @@ export default function Background() {
|
|||
isSelected={selectId === backgroundList?.[viewerIndex]?.backgroundId}
|
||||
onChange={setSelectId}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -1,17 +1,15 @@
|
|||
'use client'
|
||||
import { SiderHeader } from '.'
|
||||
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'
|
||||
'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)
|
||||
const setSideBar = useChatStore((store) => store.setSideBar);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<SiderHeader title="Chat Model" />
|
||||
<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">
|
||||
|
|
@ -68,12 +66,6 @@ export default function ChatModel() {
|
|||
</div>
|
||||
<div className="txt-body-m text-txt-secondary-normal mt-6">Stay tuned for more models</div>
|
||||
</div>
|
||||
<div className="flex gap-4 justify-end">
|
||||
<Button variant="tertiary" size="large" onClick={() => setSideBar('profile')}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => setSideBar('profile')}>Select</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -1,19 +1,18 @@
|
|||
'use client'
|
||||
import { SiderHeader } from '.'
|
||||
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'
|
||||
'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';
|
||||
|
||||
type FontOption = {
|
||||
value: number
|
||||
label: string
|
||||
isStandard?: boolean
|
||||
}
|
||||
value: number;
|
||||
label: string;
|
||||
isStandard?: boolean;
|
||||
};
|
||||
|
||||
export default function Font() {
|
||||
const setSideBar = useChatStore((store) => store.setSideBar)
|
||||
const setSideBar = useChatStore((store) => store.setSideBar);
|
||||
|
||||
// 字体大小选项
|
||||
const fontOptions: FontOption[] = [
|
||||
|
|
@ -22,33 +21,31 @@ export default function Font() {
|
|||
{ value: 16, label: 'A 16', isStandard: true },
|
||||
{ value: 18, label: 'A 18' },
|
||||
{ value: 20, label: 'A 20' },
|
||||
]
|
||||
];
|
||||
|
||||
const [selectedFont, setSelectedFont] = useState(16)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selectedFont, setSelectedFont] = useState(16);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setLoading(true)
|
||||
setLoading(true);
|
||||
try {
|
||||
// TODO: 调用实际的 API 保存字体设置
|
||||
// await updateFontSize({ fontSize: selectedFont })
|
||||
console.log('Selected font size:', selectedFont)
|
||||
console.log('Selected font size:', selectedFont);
|
||||
|
||||
// 模拟延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
setSideBar('profile')
|
||||
setSideBar('profile');
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<SiderHeader title="Font" />
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-col gap-3">
|
||||
{fontOptions.map((option) => (
|
||||
|
|
@ -71,15 +68,6 @@ export default function Font() {
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
'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,18 +1,17 @@
|
|||
'use client'
|
||||
import { SiderHeader } from '.'
|
||||
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'
|
||||
'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';
|
||||
|
||||
type TokenOption = {
|
||||
value: number
|
||||
label: string
|
||||
}
|
||||
value: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export default function MaxToken() {
|
||||
const setSideBar = useChatStore((store) => store.setSideBar)
|
||||
const setSideBar = useChatStore((store) => store.setSideBar);
|
||||
|
||||
// 最大回复数选项
|
||||
const tokenOptions: TokenOption[] = [
|
||||
|
|
@ -20,33 +19,31 @@ export default function MaxToken() {
|
|||
{ value: 1000, label: '1000' },
|
||||
{ value: 1200, label: '1200' },
|
||||
{ value: 1500, label: '1500' },
|
||||
]
|
||||
];
|
||||
|
||||
const [selectedToken, setSelectedToken] = useState(800)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selectedToken, setSelectedToken] = useState(800);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setLoading(true)
|
||||
setLoading(true);
|
||||
try {
|
||||
// TODO: 调用实际的 API 保存最大回复数设置
|
||||
// await updateMaxToken({ maxToken: selectedToken })
|
||||
console.log('Selected max token:', selectedToken)
|
||||
console.log('Selected max token:', selectedToken);
|
||||
|
||||
// 模拟延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
setSideBar('profile')
|
||||
setSideBar('profile');
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<SiderHeader title="Maximum Replies" />
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-col gap-3">
|
||||
{tokenOptions.map((option) => (
|
||||
|
|
@ -64,15 +61,6 @@ export default function MaxToken() {
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -1,9 +1,8 @@
|
|||
'use client'
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { SiderHeader } from '.'
|
||||
import { useChatStore } from '../store'
|
||||
import { z } from 'zod'
|
||||
import dayjs from 'dayjs'
|
||||
'use client';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useChatStore } from '../store';
|
||||
import { z } from 'zod';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
|
|
@ -11,22 +10,22 @@ import {
|
|||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Gender } from '@/types/user'
|
||||
import { Input } from '@/components/ui/input'
|
||||
} from '@/components/ui/form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Gender } from '@/types/user';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { calculateAge, getDaysInMonth } from '@/lib/utils'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Button } from '@/components/ui/button'
|
||||
} from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { calculateAge, getDaysInMonth } from '@/lib/utils';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
|
@ -36,12 +35,12 @@ import {
|
|||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
const currentYear = dayjs().year()
|
||||
const years = Array.from({ length: currentYear - 1950 + 1 }, (_, i) => `${1950 + i}`)
|
||||
const months = Array.from({ length: 12 }, (_, i) => `${i + 1}`.padStart(2, '0'))
|
||||
const monthTexts = Array.from({ length: 12 }, (_, i) => dayjs().month(i).format('MMM'))
|
||||
const currentYear = dayjs().year();
|
||||
const years = Array.from({ length: currentYear - 1950 + 1 }, (_, i) => `${1950 + i}`);
|
||||
const months = Array.from({ length: 12 }, (_, i) => `${i + 1}`.padStart(2, '0'));
|
||||
const monthTexts = Array.from({ length: 12 }, (_, i) => dayjs().month(i).format('MMM'));
|
||||
|
||||
const characterFormSchema = z
|
||||
.object({
|
||||
|
|
@ -59,8 +58,8 @@ const characterFormSchema = z
|
|||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
const age = calculateAge(data.year, data.month, data.day)
|
||||
return age >= 18
|
||||
const age = calculateAge(data.year, data.month, data.day);
|
||||
return age >= 18;
|
||||
},
|
||||
{
|
||||
message: 'Character age must be at least 18 years old',
|
||||
|
|
@ -71,22 +70,22 @@ const characterFormSchema = z
|
|||
(data) => {
|
||||
if (data.profile) {
|
||||
if (data.profile.trim().length > 300) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
return data.profile.trim().length >= 10
|
||||
return data.profile.trim().length >= 10;
|
||||
}
|
||||
return true
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'At least 10 characters',
|
||||
path: ['profile'],
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export default function Personal() {
|
||||
const setSideBar = useChatStore((store) => store.setSideBar)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
|
||||
const setSideBar = useChatStore((store) => store.setSideBar);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
|
||||
// 静态数据,模拟从接口获取的数据
|
||||
const chatSettingData = {
|
||||
|
|
@ -94,9 +93,9 @@ export default function Personal() {
|
|||
sex: Gender.MALE,
|
||||
birthday: dayjs('1995-06-15').valueOf(),
|
||||
whoAmI: 'A creative and passionate developer',
|
||||
}
|
||||
};
|
||||
|
||||
const birthday = chatSettingData?.birthday ? dayjs(chatSettingData.birthday) : undefined
|
||||
const birthday = chatSettingData?.birthday ? dayjs(chatSettingData.birthday) : undefined;
|
||||
|
||||
const form = useForm<z.infer<typeof characterFormSchema>>({
|
||||
resolver: zodResolver(characterFormSchema),
|
||||
|
|
@ -111,40 +110,40 @@ export default function Personal() {
|
|||
day: birthday?.date().toString().padStart(2, '0') || undefined,
|
||||
profile: chatSettingData?.whoAmI || '',
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// 处理返回的逻辑
|
||||
const handleGoBack = useCallback(() => {
|
||||
if (form.formState.isDirty) {
|
||||
setShowConfirmDialog(true)
|
||||
setShowConfirmDialog(true);
|
||||
} else {
|
||||
setSideBar('profile')
|
||||
setSideBar('profile');
|
||||
}
|
||||
}, [form.formState.isDirty, setSideBar])
|
||||
}, [form.formState.isDirty, setSideBar]);
|
||||
|
||||
// 确认放弃修改
|
||||
const handleConfirmDiscard = useCallback(() => {
|
||||
form.reset()
|
||||
setShowConfirmDialog(false)
|
||||
setSideBar('profile')
|
||||
}, [form, setSideBar])
|
||||
form.reset();
|
||||
setShowConfirmDialog(false);
|
||||
setSideBar('profile');
|
||||
}, [form, setSideBar]);
|
||||
|
||||
async function onSubmit(data: z.infer<typeof characterFormSchema>) {
|
||||
if (!form.formState.isDirty) {
|
||||
setSideBar('profile')
|
||||
return
|
||||
setSideBar('profile');
|
||||
return;
|
||||
}
|
||||
setLoading(true)
|
||||
setLoading(true);
|
||||
try {
|
||||
// TODO: 这里应该调用实际的 API
|
||||
// 模拟检查昵称是否存在
|
||||
const isExist = false // await checkNickname({ nickname: data.nickname.trim() })
|
||||
const isExist = false; // await checkNickname({ nickname: data.nickname.trim() })
|
||||
|
||||
if (isExist) {
|
||||
form.setError('nickname', {
|
||||
message: 'This nickname is already taken',
|
||||
})
|
||||
return
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: 这里应该调用实际的保存 API
|
||||
|
|
@ -159,19 +158,19 @@ export default function Personal() {
|
|||
nickname: data.nickname,
|
||||
birthday: new Date(`${data.year}-${data.month}-${data.day}`).getTime(),
|
||||
whoAmI: data.profile || '',
|
||||
})
|
||||
});
|
||||
|
||||
setSideBar('profile')
|
||||
setSideBar('profile');
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const selectedYear = form.watch('year')
|
||||
const selectedMonth = form.watch('month')
|
||||
const days = selectedYear && selectedMonth ? getDaysInMonth(selectedYear, selectedMonth) : []
|
||||
const selectedYear = form.watch('year');
|
||||
const selectedMonth = form.watch('month');
|
||||
const days = selectedYear && selectedMonth ? getDaysInMonth(selectedYear, selectedMonth) : [];
|
||||
|
||||
const genderTexts = [
|
||||
{
|
||||
|
|
@ -186,16 +185,14 @@ export default function Personal() {
|
|||
value: Gender.OTHER,
|
||||
label: 'Other',
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
const gender = form.watch('sex')
|
||||
const genderText = genderTexts.find((text) => text.value === gender)?.label
|
||||
const gender = form.watch('sex');
|
||||
const genderText = genderTexts.find((text) => text.value === gender)?.label;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-6">
|
||||
<SiderHeader title="My Chat Persona" />
|
||||
|
||||
<Form {...form}>
|
||||
<form className="space-y-8">
|
||||
<FormField
|
||||
|
|
@ -331,7 +328,7 @@ export default function Personal() {
|
|||
</form>
|
||||
</Form>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{/* <div className="flex gap-3">
|
||||
<Button variant="tertiary" size="large" className="flex-1" onClick={handleGoBack}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
|
@ -344,7 +341,7 @@ export default function Personal() {
|
|||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
{/* 确认放弃修改的对话框 */}
|
||||
|
|
@ -368,5 +365,5 @@ export default function Personal() {
|
|||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -1,24 +1,26 @@
|
|||
'use client'
|
||||
'use client';
|
||||
|
||||
import { getAge } from '@/lib/utils'
|
||||
import { CharacterAvatorAndName } from '../CharacterHeader'
|
||||
import { Tag } from '@/components/ui/tag'
|
||||
import Image from 'next/image'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useChatStore } from '../store'
|
||||
import { IconButton } from '@/components/ui/button'
|
||||
import React from 'react'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { getAge } from '@/lib/utils';
|
||||
import { CharacterAvatorAndName } from '../CharacterHeader';
|
||||
import { Tag } from '@/components/ui/tag';
|
||||
import Image from 'next/image';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useChatStore } from '../store';
|
||||
import { Button, IconButton } from '@/components/ui/button';
|
||||
import React from 'react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useCharacter } from '@/hooks/services/character';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { ActiveTabType } from './index';
|
||||
|
||||
const genderMap = {
|
||||
0: '/icons/male.svg',
|
||||
1: '/icons/female.svg',
|
||||
2: '/icons/gender-neutral.svg',
|
||||
}
|
||||
};
|
||||
|
||||
const ChatProfilePersona = React.memo(() => {
|
||||
const setSideBar = useChatStore((store) => store.setSideBar)
|
||||
const whoAmI = 'whoAmI'
|
||||
const ChatProfilePersona = React.memo(({ onActiveTab }: ProfileProps) => {
|
||||
const whoAmI = 'whoAmI';
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
|
|
@ -26,13 +28,13 @@ const ChatProfilePersona = React.memo(() => {
|
|||
<div className="txt-title-s">My Chat Persona</div>
|
||||
<div
|
||||
className="txt-label-m text-primary-variant-normal cursor-pointer"
|
||||
onClick={() => setSideBar('personal')}
|
||||
onClick={() => onActiveTab('personal')}
|
||||
>
|
||||
Edit
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-base-normal rounded-m py-1">
|
||||
<div className="bg-surface-element-normal rounded-m py-1">
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-3">
|
||||
<div className="txt-label-l text-txt-secondary-normal">Nickname</div>
|
||||
<div className="txt-body-l text-txt-primary-normal flex-1 truncate text-right">{''}</div>
|
||||
|
|
@ -60,22 +62,37 @@ const ChatProfilePersona = React.memo(() => {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
type SettingItem = {
|
||||
onClick: () => void
|
||||
label: string
|
||||
value?: React.ReactNode
|
||||
}
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
value?: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function Profile() {
|
||||
const setSideBar = useChatStore((store) => store.setSideBar)
|
||||
type ProfileProps = {
|
||||
onActiveTab: (tab: ActiveTabType) => void;
|
||||
};
|
||||
|
||||
export default function Profile({ onActiveTab }: ProfileProps) {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { data: character = {} } = useCharacter(id.split('-')[2]);
|
||||
|
||||
const preferenceItems: SettingItem[][] = [
|
||||
[
|
||||
{
|
||||
onClick: () => onActiveTab('language'),
|
||||
label: 'Language',
|
||||
value: 'zh-CN',
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
const chatSettingItems: SettingItem[][] = [
|
||||
[
|
||||
{
|
||||
onClick: () => setSideBar('model'),
|
||||
onClick: () => onActiveTab('model'),
|
||||
label: 'Chat Model',
|
||||
value: 'Role-Playing',
|
||||
},
|
||||
|
|
@ -87,34 +104,34 @@ export default function Profile() {
|
|||
],
|
||||
[
|
||||
{
|
||||
onClick: () => setSideBar('max_token'),
|
||||
onClick: () => onActiveTab('max_token'),
|
||||
label: 'Maximum Replies',
|
||||
value: '1200',
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
onClick: () => setSideBar('font'),
|
||||
onClick: () => onActiveTab('font'),
|
||||
label: 'Font',
|
||||
value: '17px',
|
||||
},
|
||||
{
|
||||
onClick: () => setSideBar('background'),
|
||||
onClick: () => onActiveTab('background'),
|
||||
label: 'Chat Background',
|
||||
value: '17px',
|
||||
},
|
||||
],
|
||||
]
|
||||
];
|
||||
|
||||
const voiceSettingItems: SettingItem[][] = [
|
||||
[
|
||||
{
|
||||
onClick: () => setSideBar('voice_actor'),
|
||||
onClick: () => onActiveTab('voice_actor'),
|
||||
label: 'Voice Artist',
|
||||
value: 'Default',
|
||||
},
|
||||
],
|
||||
]
|
||||
];
|
||||
|
||||
const bundleRender = (title: string, items: SettingItem[][]) => {
|
||||
return (
|
||||
|
|
@ -122,7 +139,7 @@ export default function Profile() {
|
|||
<div className="txt-title-s w-full text-left">{title}</div>
|
||||
<div className="flex w-full flex-col items-start justify-start gap-4">
|
||||
{items.map((list, index) => (
|
||||
<div key={`group_${index}`} className="bg-surface-base-normal rounded-m w-full py-1">
|
||||
<div key={`group_${index}`} className="bg-surface-element-normal rounded-m w-full py-1">
|
||||
{list.map((item, itemIndex) => (
|
||||
<div
|
||||
key={`item_${itemIndex}`}
|
||||
|
|
@ -149,35 +166,55 @@ export default function Profile() {
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<CharacterAvatorAndName />
|
||||
<div className="flex h-full flex-col">
|
||||
{/* <div className="flex justify-between">
|
||||
<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} />
|
||||
|
||||
{/* Tags */}
|
||||
<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">
|
||||
<Tag>
|
||||
<Image
|
||||
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>
|
||||
{/* Tags */}
|
||||
<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">
|
||||
<Tag>
|
||||
<Image
|
||||
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>
|
||||
|
||||
<ChatProfilePersona onActiveTab={onActiveTab} />
|
||||
|
||||
{bundleRender('Preference', preferenceItems)}
|
||||
|
||||
{bundleRender('Chat Setting', chatSettingItems)}
|
||||
|
||||
{bundleRender('Voice Setting', voiceSettingItems)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 mt-2">
|
||||
<Button variant="tertiary" className="w-full">
|
||||
Detele
|
||||
</Button>
|
||||
<Button variant="primary" className="w-full">
|
||||
+ New Chat
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ChatProfilePersona />
|
||||
|
||||
{bundleRender('Chat Setting', chatSettingItems)}
|
||||
|
||||
{bundleRender('Voice Setting', voiceSettingItems)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -1,24 +1,23 @@
|
|||
'use client'
|
||||
import { SiderHeader } from '.'
|
||||
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'
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||
'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';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
|
||||
type VoiceGender = 'all' | 'male' | 'female'
|
||||
type VoiceGender = 'all' | 'male' | 'female';
|
||||
|
||||
type VoiceActorItem = {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
avatarUrl: string
|
||||
gender: 'male' | 'female'
|
||||
}
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
avatarUrl: string;
|
||||
gender: 'male' | 'female';
|
||||
};
|
||||
|
||||
export default function VoiceActor() {
|
||||
const setSideBar = useChatStore((store) => store.setSideBar)
|
||||
const setSideBar = useChatStore((store) => store.setSideBar);
|
||||
|
||||
// 语音演员列表(静态数据)
|
||||
const voiceActors: VoiceActorItem[] = [
|
||||
|
|
@ -71,40 +70,38 @@ export default function VoiceActor() {
|
|||
avatarUrl: 'https://i.pravatar.cc/150?img=7',
|
||||
gender: 'male',
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
const [selectedGender, setSelectedGender] = useState<VoiceGender>('all')
|
||||
const [selectedActorId, setSelectedActorId] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selectedGender, setSelectedGender] = useState<VoiceGender>('all');
|
||||
const [selectedActorId, setSelectedActorId] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 根据性别过滤演员列表
|
||||
const filteredActors = voiceActors.filter((actor) => {
|
||||
if (selectedGender === 'all') return true
|
||||
return actor.gender === selectedGender
|
||||
})
|
||||
if (selectedGender === 'all') return true;
|
||||
return actor.gender === selectedGender;
|
||||
});
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setLoading(true)
|
||||
setLoading(true);
|
||||
try {
|
||||
// TODO: 调用实际的 API 保存语音演员设置
|
||||
// await updateVoiceActor({ voiceActorId: selectedActorId })
|
||||
console.log('Selected voice actor:', selectedActorId)
|
||||
console.log('Selected voice actor:', selectedActorId);
|
||||
|
||||
// 模拟延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
setSideBar('profile')
|
||||
setSideBar('profile');
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<SiderHeader title="Voice Artist" />
|
||||
|
||||
{/* Gender Tabs */}
|
||||
<div className="mb-6 flex gap-6">
|
||||
{[
|
||||
|
|
@ -157,14 +154,14 @@ export default function VoiceActor() {
|
|||
</div>
|
||||
|
||||
{/* Footer Buttons */}
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
{/* <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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
'use client';
|
||||
import { useChatStore } from '../store';
|
||||
import Profile from './Profile';
|
||||
import Personal from './Personal';
|
||||
import VoiceActor from './VoiceActor';
|
||||
import Font from './Font';
|
||||
import MaxToken from './MaxToken';
|
||||
import Background from './Background';
|
||||
import ChatModel from './ChatModel';
|
||||
import Language from './Language';
|
||||
import { IconButton } from '@/components/ui/button';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
type SettingProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
export type ActiveTabType =
|
||||
| 'profile'
|
||||
| 'personal'
|
||||
| 'history'
|
||||
| 'voice_actor'
|
||||
| 'font'
|
||||
| 'max_token'
|
||||
| 'background'
|
||||
| 'model'
|
||||
| 'language';
|
||||
|
||||
const titleMap = {
|
||||
personal: 'Personal',
|
||||
history: 'History',
|
||||
voice_actor: 'Voice Actor',
|
||||
font: 'Font',
|
||||
max_token: 'Max Token',
|
||||
background: 'Background',
|
||||
model: 'Chat Model',
|
||||
language: 'Language',
|
||||
};
|
||||
|
||||
export default function SettingDialog({ open, onOpenChange }: SettingProps) {
|
||||
const [activeTab, setActiveTab] = useState<ActiveTabType>('profile');
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent showCloseButton={activeTab === 'profile'}>
|
||||
<AlertDialogTitle className="flex justify-between">
|
||||
{activeTab === 'profile' ? (
|
||||
<IconButton variant="tertiary" size="small" iconfont="icon-Like" />
|
||||
) : (
|
||||
titleMap[activeTab]
|
||||
)}
|
||||
{activeTab !== 'profile' && (
|
||||
<IconButton variant="tertiary" size="small" onClick={() => setActiveTab('profile')}>
|
||||
<i className="iconfont-v2 iconv2-jiantou" />
|
||||
</IconButton>
|
||||
)}
|
||||
</AlertDialogTitle>
|
||||
<div className="w-full h-[calc(100vh-160px)] pr-1 mt-4">
|
||||
{activeTab === 'profile' && <Profile onActiveTab={setActiveTab} />}
|
||||
{activeTab === 'personal' && <Personal />}
|
||||
{activeTab === 'voice_actor' && <VoiceActor />}
|
||||
{activeTab === 'font' && <Font />}
|
||||
{activeTab === 'max_token' && <MaxToken />}
|
||||
{activeTab === 'background' && <Background />}
|
||||
{activeTab === 'model' && <ChatModel />}
|
||||
{activeTab === 'language' && <Language />}
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
'use client'
|
||||
'use client';
|
||||
export default function UserMessage({ data }: { data: any }) {
|
||||
return (
|
||||
<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">
|
||||
{data.text}
|
||||
{data.content}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -3,12 +3,22 @@
|
|||
import { IconButton } from '@/components/ui/button';
|
||||
import Input from './Input';
|
||||
import MessageList from './MessageList';
|
||||
import { useChatStore } from './store';
|
||||
import Sider from './Sider';
|
||||
import SettingDialog from './Sider';
|
||||
import { useStreamChatStore } from '@/stores/stream-chat';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function ChatPage() {
|
||||
const isSidebarOpen = useChatStore((store) => store.isSidebarOpen);
|
||||
const setIsSidebarOpen = useChatStore((store) => store.setIsSidebarOpen);
|
||||
const { id } = useParams();
|
||||
const [settingOpen, setSettingOpen] = useState(false);
|
||||
const switchToChannel = useStreamChatStore((s) => s.switchToChannel);
|
||||
const client = useStreamChatStore((s) => s.client);
|
||||
|
||||
useEffect(() => {
|
||||
if (id && client) {
|
||||
switchToChannel(id as string);
|
||||
}
|
||||
}, [id, client]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
|
|
@ -18,7 +28,7 @@ export default function ChatPage() {
|
|||
<Input />
|
||||
</div>
|
||||
<IconButton
|
||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||
onClick={() => setSettingOpen(!settingOpen)}
|
||||
className="absolute top-1 right-1"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
|
|
@ -26,8 +36,7 @@ export default function ChatPage() {
|
|||
<i className="iconfont-v2 iconv2-zhedie" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{isSidebarOpen && <Sider />}
|
||||
<SettingDialog open={settingOpen} onOpenChange={setSettingOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { create } from 'zustand'
|
||||
import { create } from 'zustand';
|
||||
|
||||
type SideBar =
|
||||
| 'profile'
|
||||
|
|
@ -9,11 +9,12 @@ type SideBar =
|
|||
| 'max_token'
|
||||
| 'background'
|
||||
| 'model'
|
||||
| 'language';
|
||||
interface ChatStore {
|
||||
isSidebarOpen: boolean
|
||||
setIsSidebarOpen: (isSidebarOpen: boolean) => void
|
||||
sideBar: SideBar
|
||||
setSideBar: (sideBar: SideBar) => void
|
||||
isSidebarOpen: boolean;
|
||||
setIsSidebarOpen: (isSidebarOpen: boolean) => void;
|
||||
sideBar: SideBar;
|
||||
setSideBar: (sideBar: SideBar) => void;
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatStore>((set) => ({
|
||||
|
|
@ -21,4 +22,4 @@ export const useChatStore = create<ChatStore>((set) => ({
|
|||
setIsSidebarOpen: (isSidebarOpen: boolean) => set({ isSidebarOpen }),
|
||||
sideBar: 'profile',
|
||||
setSideBar: (sideBar: SideBar) => set({ sideBar }),
|
||||
}))
|
||||
}));
|
||||
|
|
@ -1,52 +1,52 @@
|
|||
'use client'
|
||||
'use client';
|
||||
|
||||
import { useGetSevenDaysSignList, useSignIn } from '@/hooks/useHome'
|
||||
import { SignInListOutput } from '@/services/home/types'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { homeKeys } from '@/lib/query-keys'
|
||||
import { CheckInCard } from './CheckInCard'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
// import { useGetSevenDaysSignList, useSignIn } from '@/hooks/useHome'
|
||||
// import { SignInListOutput } from '@/services/home/types'
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { homeKeys } from '@/lib/query-keys';
|
||||
import { CheckInCard } from './CheckInCard';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function CheckInGrid() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: signListData, isLoading } = useGetSevenDaysSignList()
|
||||
const signInMutation = useSignIn()
|
||||
const hasSignRef = useRef(false)
|
||||
const queryClient = useQueryClient();
|
||||
// const { data: signListData, isLoading } = useGetSevenDaysSignList()
|
||||
// const signInMutation = useSignIn()
|
||||
const hasSignRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const initializeCheckIn = async () => {
|
||||
if (hasSignRef.current) return
|
||||
hasSignRef.current = true
|
||||
if (hasSignRef.current) return;
|
||||
hasSignRef.current = true;
|
||||
try {
|
||||
// 先进行签到
|
||||
const resp = await signInMutation.mutateAsync()
|
||||
const resp = await signInMutation.mutateAsync();
|
||||
if (resp) {
|
||||
toast.success('Check-in Successful!')
|
||||
toast.success('Check-in Successful!');
|
||||
}
|
||||
// 签到成功后再获取列表数据
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: homeKeys.getSevenDaysSignList(),
|
||||
})
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['wallet'],
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('初始化签到失败:', error)
|
||||
console.error('初始化签到失败:', error);
|
||||
// 即使签到失败,也要获取列表数据显示界面
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: homeKeys.getSevenDaysSignList(),
|
||||
})
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['wallet'],
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (signListData) {
|
||||
initializeCheckIn()
|
||||
initializeCheckIn();
|
||||
}
|
||||
}, [signListData])
|
||||
}, [signListData]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
|
@ -58,38 +58,38 @@ export function CheckInGrid() {
|
|||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const signList = signListData?.list || []
|
||||
const today = new Date()
|
||||
const todayStr = today.toISOString().split('T')[0] // yyyy-MM-dd 格式
|
||||
const signList = signListData?.list || [];
|
||||
const today = new Date();
|
||||
const todayStr = today.toISOString().split('T')[0]; // yyyy-MM-dd 格式
|
||||
|
||||
// 确保有7天的数据,如果不足则补充默认数据
|
||||
const fullSignList: (SignInListOutput & { day: number })[] = Array.from(
|
||||
{ length: 7 },
|
||||
(_, index) => {
|
||||
const day = index + 1
|
||||
const existingData = signList.find((item, itemIndex) => itemIndex === index)
|
||||
const day = index + 1;
|
||||
const existingData = signList.find((item, itemIndex) => itemIndex === index);
|
||||
|
||||
return {
|
||||
day,
|
||||
coinNum: existingData?.coinNum || [5, 10, 15, 20, 30, 50, 80][index],
|
||||
dayStr: existingData?.dayStr || '',
|
||||
signIn: existingData?.signIn || false,
|
||||
}
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// 找到今天应该签到的是第几天
|
||||
const todayIndex = fullSignList.findIndex((item) => {
|
||||
if (!item.dayStr) return false
|
||||
return item.dayStr === todayStr
|
||||
})
|
||||
if (!item.dayStr) return false;
|
||||
return item.dayStr === todayStr;
|
||||
});
|
||||
|
||||
// 如果没有找到今天的数据,假设是按顺序签到,找到第一个未签到的
|
||||
const currentDayIndex =
|
||||
todayIndex >= 0 ? todayIndex : fullSignList.findIndex((item) => !item.signIn)
|
||||
todayIndex >= 0 ? todayIndex : fullSignList.findIndex((item) => !item.signIn);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 grid-rows-2 gap-4">
|
||||
|
|
@ -105,7 +105,7 @@ export function CheckInGrid() {
|
|||
loading={signInMutation.isPending}
|
||||
className="col-span-1 row-span-2"
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
if (index < 3) {
|
||||
return (
|
||||
|
|
@ -118,7 +118,7 @@ export function CheckInGrid() {
|
|||
loading={signInMutation.isPending}
|
||||
className="col-span-1 row-span-1"
|
||||
/>
|
||||
)
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<CheckInCard
|
||||
|
|
@ -130,11 +130,11 @@ export function CheckInGrid() {
|
|||
loading={signInMutation.isPending}
|
||||
className="col-span-1 row-span-1"
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default CheckInGrid
|
||||
export default CheckInGrid;
|
||||
|
|
|
|||
|
|
@ -6,13 +6,35 @@ import { Chip } from '@/components/ui/chip';
|
|||
import { useHomeStore } from '../store';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetchCharacterTags } from '@/services/editor';
|
||||
import { useRef } from 'react';
|
||||
|
||||
const Filter = () => {
|
||||
const tab = useHomeStore((state) => state.tab);
|
||||
const setTab = useHomeStore((state) => state.setTab);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const selectedTags = useHomeStore((state) => state.selectedTags);
|
||||
const setSelectedTags = useHomeStore((state) => state.setSelectedTags);
|
||||
|
||||
// useEffect(() => {
|
||||
// const mainContent = document.getElementById('main-content');
|
||||
// if (!mainContent) {
|
||||
// return;
|
||||
// }
|
||||
// const handleScroll = () => {
|
||||
// const scrollTop = mainContent.scrollTop;
|
||||
// console.log('scrollTop', scrollTop, ref.current);
|
||||
// const className = 'absolute bg-bg-primary-normal';
|
||||
// if (scrollTop > 248) {
|
||||
// ref.current?.classList.add('absolute bg-bg-primary-normal');
|
||||
// } else {
|
||||
// }
|
||||
// };
|
||||
// mainContent?.addEventListener('scroll', handleScroll);
|
||||
// return () => {
|
||||
// mainContent?.removeEventListener('scroll', handleScroll);
|
||||
// };
|
||||
// }, []);
|
||||
|
||||
const { data: tags = [] } = useQuery({
|
||||
queryKey: ['tags', tab],
|
||||
queryFn: async () => {
|
||||
|
|
@ -39,8 +61,16 @@ const Filter = () => {
|
|||
},
|
||||
] as const;
|
||||
|
||||
const handleSelect = (tagId: string) => {
|
||||
if (selectedTags.includes(tagId)) {
|
||||
setSelectedTags(selectedTags.filter((id) => id !== tagId));
|
||||
} else {
|
||||
setSelectedTags([...selectedTags, tagId]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-10">
|
||||
<div ref={ref}>
|
||||
<div className="flex mb-6 gap-12">
|
||||
{tabs.map((item) => {
|
||||
const active = tab === item.value;
|
||||
|
|
@ -71,7 +101,7 @@ const Filter = () => {
|
|||
size="small"
|
||||
className="px-4"
|
||||
state={selectedTags.includes(tag.id) ? 'active' : 'inactive'}
|
||||
onClick={() => setSelectedTags([tag.id])}
|
||||
onClick={() => handleSelect(tag.id)}
|
||||
>
|
||||
# {tag.name}
|
||||
</Chip>
|
||||
|
|
|
|||
|
|
@ -8,61 +8,62 @@ import { useMedia } from '@/hooks/tools';
|
|||
|
||||
const Header = React.memo(() => {
|
||||
const response = useMedia();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center"
|
||||
style={{
|
||||
backgroundImage: 'url(/images/home/bg-star.png)',
|
||||
backgroundSize: 'contain',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 pt-12 pb-8 text-center">
|
||||
<Image
|
||||
src="/images/home/left-star.png"
|
||||
className="h-12 w-12 object-cover"
|
||||
alt="header-bg"
|
||||
width={48}
|
||||
height={48}
|
||||
/>
|
||||
<h1 className="txt-headline-m mt-3">Spicyxx.ai</h1>
|
||||
<h1
|
||||
className="txt-headline-m mt-3"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(109.2deg, rgba(211, 123, 235, 1) 37.08%, rgba(147, 123, 235, 1) 128.91%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
}}
|
||||
>
|
||||
A Different World
|
||||
</h1>
|
||||
<Link href="/crushcoin">
|
||||
<div
|
||||
className="border-outline-normal max-w-100 mt-8 relative flex h-12 items-center justify-between rounded-full border border-solid pr-2 pl-[59px]"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(90deg, rgba(168, 70, 201, 1) 0%, rgba(255, 183, 66, 1) 12.97%, rgba(80, 56, 255, 0) 100%)',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
>
|
||||
<span className="txt-label-l whitespace-nowrap">Daily Free CrushCoins</span>
|
||||
<IconButton iconfont="icon-arrow-right-border" size="small" variant="primary" />
|
||||
<Image
|
||||
src="/images/home/icon-crush-free.png"
|
||||
className="absolute -top-5 left-1"
|
||||
alt="icon-crush-free"
|
||||
width={60}
|
||||
height={60}
|
||||
/>
|
||||
<Link href="/crushcoin">
|
||||
<div
|
||||
className="h-50 rounded-4xl px-6 mb-12 flex items-center justify-between"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(90deg, rgba(255, 255, 255, 0.1) 0%, rgba(202, 153, 255, 0.2) 100%)',
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-3 items-center">
|
||||
<Image
|
||||
src="/images/home/icon-crush-free.png"
|
||||
className="h-30 w-30 object-cover"
|
||||
alt="header-bg"
|
||||
width={120}
|
||||
height={120}
|
||||
/>
|
||||
<div>
|
||||
<div className="flex gap-5 txt-display-l">
|
||||
Check-in{' '}
|
||||
<Image
|
||||
src="/images/home/left-star.png"
|
||||
className="h-12 w-12 object-cover"
|
||||
alt="header-bg"
|
||||
width={48}
|
||||
height={48}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-5 items-center">
|
||||
<span
|
||||
className="txt-headline-s bg-clip-text text-transparent"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(109.2deg, rgba(211, 123, 235, 1) 37.08%, rgba(147, 123, 235, 1) 128.91%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
}}
|
||||
>
|
||||
Daily Free crush coinsh
|
||||
</span>
|
||||
<IconButton iconfont="icon-arrow-right-border" size="small" variant="primary" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
{response?.lg && (
|
||||
<Image
|
||||
src="/images/home/banner-header.png"
|
||||
alt="banner-header"
|
||||
width={250}
|
||||
height={250}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{response?.sm && (
|
||||
<Image src="/images/home/banner-header.png" alt="banner-header" width={400} height={400} />
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client'
|
||||
'use client';
|
||||
|
||||
const Story = () => {
|
||||
return <div>Story</div>
|
||||
}
|
||||
return <div>Story</div>;
|
||||
};
|
||||
|
||||
export default Story
|
||||
export default Story;
|
||||
|
|
|
|||
|
|
@ -13,16 +13,16 @@ const HomePage = () => {
|
|||
const response = useMedia();
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<div className="text-txt-primary-normal relative px-4 sm:px-16 pb-32">
|
||||
<div className="mx-auto max-w-[1136px]">
|
||||
<>
|
||||
<div className="h-full text-txt-primary-normal relative px-4 sm:px-16">
|
||||
<div className="mx-auto max-w-[1136px] pb-32">
|
||||
<Header />
|
||||
<Filter />
|
||||
{tab === 'story' ? <Story /> : <Character />}
|
||||
</div>
|
||||
</div>
|
||||
{response?.sm && <HomePageFooter />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,25 @@ body {
|
|||
background: #888;
|
||||
}
|
||||
|
||||
.show-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
|
||||
}
|
||||
|
||||
.show-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.show-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.show-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@utility text-gradient {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
import { atom } from 'jotai'
|
||||
|
||||
/**
|
||||
* 自动播放语音
|
||||
*/
|
||||
export const playVoiceAtom = atom({
|
||||
voiceType: '',
|
||||
dialogueSpeechRate: 0,
|
||||
dialoguePitch: 0,
|
||||
isAutoPlayVoice: false,
|
||||
})
|
||||
|
||||
/**
|
||||
* 抽屉状态类型
|
||||
*/
|
||||
export interface DrawerState {
|
||||
open: boolean
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建抽屉打开状态的辅助函数
|
||||
*/
|
||||
export const createDrawerOpenState = (open: boolean): DrawerState => ({
|
||||
open,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
/**
|
||||
* 是否打开发送礼物抽屉
|
||||
*/
|
||||
export const isSendGiftsDrawerOpenAtom = atom<DrawerState>({ open: false, timestamp: 0 })
|
||||
|
||||
/**
|
||||
* 是否打开 crush level 抽屉
|
||||
*/
|
||||
export const isCrushLevelDrawerOpenAtom = atom<DrawerState>({ open: false, timestamp: 0 })
|
||||
|
||||
/**
|
||||
* 是否打开 crush level 获取抽屉
|
||||
*/
|
||||
export const isCrushLevelRetrieveDrawerOpenAtom = atom<DrawerState>({ open: false, timestamp: 0 })
|
||||
|
||||
/**
|
||||
* 是否打开 chat profile 抽屉
|
||||
*/
|
||||
export const isChatProfileDrawerOpenAtom = atom<DrawerState>({ open: false, timestamp: 0 })
|
||||
|
||||
/**
|
||||
* 是否打开 chat profile 编辑抽屉
|
||||
*/
|
||||
export const isChatProfileEditDrawerOpenAtom = atom<DrawerState>({ open: false, timestamp: 0 })
|
||||
|
||||
/**
|
||||
* 是否打开 chat model 抽屉
|
||||
*/
|
||||
export const isChatModelDrawerOpenAtom = atom<DrawerState>({ open: false, timestamp: 0 })
|
||||
|
||||
/**
|
||||
* 是否打开 chat buttle 抽屉
|
||||
*/
|
||||
export const isChatButtleDrawerOpenAtom = atom<DrawerState>({ open: false, timestamp: 0 })
|
||||
|
||||
/**
|
||||
* 是否打开 chat background 抽屉
|
||||
*/
|
||||
export const isChatBackgroundDrawerOpenAtom = atom<DrawerState>({ open: false, timestamp: 0 })
|
||||
|
|
@ -1,28 +1,28 @@
|
|||
'use client'
|
||||
'use client';
|
||||
|
||||
import * as React from 'react'
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
|
||||
import * as React from 'react';
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
// 关闭图标组件
|
||||
const CloseIcon = ({ className }: { className?: string }) => (
|
||||
<i className={cn('iconfont icon-close', className)} />
|
||||
)
|
||||
);
|
||||
|
||||
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
|
|
@ -38,7 +38,7 @@ function AlertDialogOverlay({
|
|||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
|
|
@ -47,8 +47,8 @@ function AlertDialogContent({
|
|||
showOverlay = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
showOverlay?: boolean
|
||||
showCloseButton?: boolean;
|
||||
showOverlay?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
|
|
@ -71,7 +71,7 @@ function AlertDialogContent({
|
|||
)}
|
||||
</AlertDialogPrimitive.Content>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
|
|
@ -81,7 +81,7 @@ function AlertDialogHeader({ className, ...props }: React.ComponentProps<'div'>)
|
|||
className={cn('mb-4 flex flex-col gap-4 text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
|
|
@ -89,7 +89,7 @@ function AlertDialogFooter({
|
|||
variant = 'horizontal',
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
variant?: 'horizontal' | 'vertical'
|
||||
variant?: 'horizontal' | 'vertical';
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
|
|
@ -101,7 +101,7 @@ function AlertDialogFooter({
|
|||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
|
|
@ -109,14 +109,14 @@ function AlertDialogTitle({
|
|||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<div className="flex items-start justify-between pr-10">
|
||||
<div className="flex items-start justify-between">
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn('txt-title-l text-txt-primary-normal w-full break-words', className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
|
|
@ -129,7 +129,7 @@ function AlertDialogDescription({
|
|||
className={cn('txt-body-l text-txt-primary-normal w-full break-words', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogIcon({ className, children, ...props }: React.ComponentProps<'div'>) {
|
||||
|
|
@ -141,7 +141,7 @@ function AlertDialogIcon({ className, children, ...props }: React.ComponentProps
|
|||
>
|
||||
<div className="flex h-[148px] w-[148px] items-center justify-center">{children}</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
|
|
@ -150,14 +150,14 @@ function AlertDialogAction({
|
|||
loading,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> & {
|
||||
variant?: 'primary' | 'secondary' | 'tertiary' | 'destructive'
|
||||
loading?: boolean
|
||||
variant?: 'primary' | 'secondary' | 'tertiary' | 'destructive';
|
||||
loading?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action asChild>
|
||||
<Button variant={variant} loading={loading} className={cn(className)} {...props} />
|
||||
</AlertDialogPrimitive.Action>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
|
|
@ -166,14 +166,14 @@ function AlertDialogCancel({
|
|||
loading,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> & {
|
||||
variant?: 'primary' | 'secondary' | 'tertiary' | 'destructive'
|
||||
loading?: boolean
|
||||
variant?: 'primary' | 'secondary' | 'tertiary' | 'destructive';
|
||||
loading?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel asChild>
|
||||
<Button variant={variant} loading={loading} className={cn(className)} {...props} />
|
||||
</AlertDialogPrimitive.Cancel>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
|
|
@ -189,4 +189,4 @@ export {
|
|||
AlertDialogIcon,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ export function InfiniteScrollList<T>({
|
|||
</div>
|
||||
{/* 加载更多触发器 - 只在没有错误时显示 */}
|
||||
{hasNextPage && !hasError && (
|
||||
<div ref={loadMoreRef} className="mt-8 flex justify-center">
|
||||
<div ref={loadMoreRef} className="h-8 flex justify-center">
|
||||
{LoadingMore ? (
|
||||
<LoadingMore />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
'use client'
|
||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'
|
||||
import { useRef, useState } from 'react'
|
||||
'use client';
|
||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
type VirtualListProps<T = any> = {
|
||||
data?: { type: string; data?: T }[]
|
||||
itemContent?: (index: number, item: { type: string; data: T }) => React.ReactNode
|
||||
virtuosoProps?: React.ComponentProps<typeof Virtuoso>
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
data?: { type: string; data?: T }[];
|
||||
itemContent?: (index: number, item: { type: string; data: T }) => React.ReactNode;
|
||||
virtuosoProps?: React.ComponentProps<typeof Virtuoso>;
|
||||
} & React.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export default function VirtualList<T = any>(props: VirtualListProps<T>) {
|
||||
const {
|
||||
|
|
@ -14,17 +14,17 @@ export default function VirtualList<T = any>(props: VirtualListProps<T>) {
|
|||
itemContent = (index) => <div>{index}</div>,
|
||||
virtuosoProps,
|
||||
...restProps
|
||||
} = props
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null)
|
||||
const [showScrollButton, setShowScrollButton] = useState(false)
|
||||
} = props;
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
// const [showScrollButton, setShowScrollButton] = useState(false);
|
||||
|
||||
// 滚动到最新消息
|
||||
const scrollToBottom = () => {
|
||||
virtuosoRef.current?.scrollToIndex({
|
||||
index: data.length - 1,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
// // 滚动到最新消息
|
||||
// const scrollToBottom = () => {
|
||||
// virtuosoRef.current?.scrollToIndex({
|
||||
// index: data.length - 1,
|
||||
// behavior: 'smooth',
|
||||
// });
|
||||
// };
|
||||
|
||||
return (
|
||||
<div {...restProps}>
|
||||
|
|
@ -36,22 +36,22 @@ export default function VirtualList<T = any>(props: VirtualListProps<T>) {
|
|||
data={data}
|
||||
followOutput="smooth"
|
||||
initialTopMostItemIndex={data.length - 1}
|
||||
atBottomStateChange={(atBottom) => {
|
||||
// 当不在底部时显示按钮,在底部时隐藏
|
||||
setShowScrollButton(!atBottom)
|
||||
}}
|
||||
// atBottomStateChange={(atBottom) => {
|
||||
// // 当不在底部时显示按钮,在底部时隐藏
|
||||
// setShowScrollButton(!atBottom);
|
||||
// }}
|
||||
itemContent={(index, item) => itemContent(index, item as any)}
|
||||
/>
|
||||
|
||||
{/* 回到底部按钮 */}
|
||||
{showScrollButton && (
|
||||
{/* {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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,16 +92,16 @@
|
|||
--glo-color-purple-70: #6e0098;
|
||||
--glo-color-purple-80: #520073;
|
||||
--glo-color-purple-90: #36004d;
|
||||
--glo-color-magenta-0: #fbdeff;
|
||||
--glo-color-magenta-10: #fdb6d3;
|
||||
--glo-color-magenta-20: #f98dbc;
|
||||
--glo-color-magenta-30: #f264a4;
|
||||
--glo-color-magenta-40: rgb(107, 134, 255);
|
||||
--glo-color-magenta-50: #d21f77;
|
||||
--glo-color-magenta-60: #b80761;
|
||||
--glo-color-magenta-70: #980050;
|
||||
--glo-color-magenta-80: #73003e;
|
||||
--glo-color-magenta-90: #4d002a;
|
||||
--glo-color-magenta-0: #e5eaff;
|
||||
--glo-color-magenta-10: #cbd5ff;
|
||||
--glo-color-magenta-20: #b1c1ff;
|
||||
--glo-color-magenta-30: #96acff;
|
||||
--glo-color-magenta-40: #6b86ff;
|
||||
--glo-color-magenta-50: #4861f5;
|
||||
--glo-color-magenta-60: #3348dd;
|
||||
--glo-color-magenta-70: #2536bf;
|
||||
--glo-color-magenta-80: #1a2898;
|
||||
--glo-color-magenta-90: #121d72;
|
||||
--glo-color-red-0: #ffdede;
|
||||
--glo-color-red-10: #ffbcbc;
|
||||
--glo-color-red-20: #ff9696;
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ export function useLogin() {
|
|||
return useMutation({
|
||||
mutationFn: (data: LoginRequest): Promise<LoginResponse> => authService.login(data),
|
||||
onSuccess: (response: LoginResponse) => {
|
||||
console.log('useLogin onSuccess save token', response.token);
|
||||
// 保存token到cookie
|
||||
tokenManager.setToken(response.token);
|
||||
// 刷新当前用户信息
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
import { fetchCharacter } from '@/services/editor';
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
export function useCharacter(id?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['character', id],
|
||||
queryFn: () => fetchCharacter({ id }),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
|
@ -72,6 +72,7 @@ export function useInfiniteScroll({
|
|||
|
||||
observerRef.current = new IntersectionObserver((entries) => {
|
||||
const [entry] = entries;
|
||||
|
||||
if (entry.isIntersecting) {
|
||||
loadMore();
|
||||
}
|
||||
|
|
@ -87,7 +88,7 @@ export function useInfiniteScroll({
|
|||
observerRef.current.unobserve(currentRef);
|
||||
}
|
||||
};
|
||||
}, [enabled, threshold, loadMore]);
|
||||
}, [enabled, threshold, loadMore, !!loadMoreRef.current]);
|
||||
|
||||
// 清理observer
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -11,20 +11,23 @@ import CreateReachedLimitDialog from '../components/features/create-reached-limi
|
|||
import { useMedia } from '@/hooks/tools';
|
||||
import BottomBar from './BottomBar';
|
||||
import { useStreamChatStore } from '@/stores/stream-chat';
|
||||
import { useLogin } from '@/hooks/auth';
|
||||
import { useCurrentUser } from '@/hooks/auth';
|
||||
|
||||
interface ConditionalLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const useInitChat = () => {
|
||||
const { data } = useLogin();
|
||||
const { data } = useCurrentUser();
|
||||
const connect = useStreamChatStore((state) => state.connect);
|
||||
const queryChannels = useStreamChatStore((state) => state.queryChannels);
|
||||
|
||||
const initChat = async () => {
|
||||
if (data) {
|
||||
await connect(data);
|
||||
await connect({
|
||||
userId: data.userId + '',
|
||||
userName: data.nickname,
|
||||
});
|
||||
await queryChannels({});
|
||||
}
|
||||
};
|
||||
|
|
@ -58,7 +61,7 @@ export default function ConditionalLayout({ children }: ConditionalLayoutProps)
|
|||
{response?.sm && <Sidebar />}
|
||||
<div ref={mainContentRef} className={cn('relative flex flex-1 flex-col')}>
|
||||
<Topbar />
|
||||
<main id="main-content" className="overflow-auto flex-1 pt-16">
|
||||
<main id="main-content" className="overflow-auto flex-1">
|
||||
{children}
|
||||
</main>
|
||||
{response && !response.sm && <BottomBar />}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useEffect } from 'react';
|
|||
import { MenuItem } from '@/types/global';
|
||||
import Image from 'next/image';
|
||||
import { cn } from '@/lib/utils';
|
||||
// import ChatSidebar from './components/ChatSidebar'
|
||||
import ChatSidebar from './components/ChatSidebar';
|
||||
import { Badge } from '../components/ui/badge';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
|
@ -98,8 +98,11 @@ function Sidebar() {
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* <ChatSidebar /> */}
|
||||
{/* 分割线 */}
|
||||
<div className="mx-6 my-4">
|
||||
<div className="bg-outline-normal h-px" />
|
||||
</div>
|
||||
<ChatSidebar />
|
||||
<Notice actualIsExpanded={isSidebarExpanded} />
|
||||
</div>
|
||||
</aside>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import { usePathname, useSearchParams, useRouter } from 'next/navigation';
|
|||
import { useMedia } from '@/hooks/tools';
|
||||
import { items } from './BottomBar';
|
||||
|
||||
const mobileHidenMenus = ['/profile/edit', '/profile/account'];
|
||||
|
||||
function Topbar() {
|
||||
const [isBlur, setIsBlur] = useState(false);
|
||||
const { data: user } = useCurrentUser();
|
||||
|
|
@ -92,14 +94,13 @@ function Topbar() {
|
|||
);
|
||||
};
|
||||
|
||||
if (response && !response.sm && mobileHidenMenus.some((item) => item === pathname)) return null;
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
'absolute z-40 flex h-16 w-full items-center justify-between px-4 sm:px-8 transition-all',
|
||||
{
|
||||
'backdrop-blur-[10px]': isBlur,
|
||||
}
|
||||
)}
|
||||
className={cn('flex h-16 w-full items-center justify-between px-4 sm:px-8 transition-all', {
|
||||
'backdrop-blur-[10px]': isBlur,
|
||||
})}
|
||||
>
|
||||
{isBlur && <div className="bg-background-default absolute inset-0 opacity-85" />}
|
||||
<div className="relative inset-0 flex w-full items-center justify-between">
|
||||
|
|
|
|||
|
|
@ -1,228 +1,54 @@
|
|||
'use client'
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { conversationListAtom } from '@/atoms/im'
|
||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { imService } from '@/services/im'
|
||||
import { imKeys } from '@/lib/query-keys'
|
||||
import ChatSidebarItem from './ChatSidebarItem'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { InfiniteScrollList } from '@/components/ui/infinite-scroll-list'
|
||||
import { getAge } from '@/lib/utils'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { HeartbeatRelationListOutput } from '@/services/im/types'
|
||||
import Empty from '@/components/ui/empty'
|
||||
import AIRelationTag from '@/components/features/AIRelationTag'
|
||||
import Image from 'next/image'
|
||||
import { IconButton } from '@/components/ui/button'
|
||||
import { usePrefetchRoutes } from '@/hooks/useGlobalPrefetchRoutes'
|
||||
'use client';
|
||||
import { useState, useMemo } from 'react';
|
||||
import ChatSidebarItem from './ChatSidebarItem';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import Empty from '@/components/ui/empty';
|
||||
import { useStreamChatStore } from '@/stores/stream-chat';
|
||||
|
||||
interface ChatSearchResultsProps {
|
||||
searchKeyword: string
|
||||
isExpanded: boolean
|
||||
onCloseSearch: () => void
|
||||
searchKeyword: string;
|
||||
isExpanded: boolean;
|
||||
}
|
||||
|
||||
// 高亮搜索关键词的组件
|
||||
const HighlightText = ({ text, keyword }: { text: string; keyword: string }) => {
|
||||
if (!keyword) return <span>{text}</span>
|
||||
|
||||
const parts = text.split(new RegExp(`(${keyword})`, 'gi'))
|
||||
return (
|
||||
<span>
|
||||
{parts.map((part, index) =>
|
||||
part.toLowerCase() === keyword.toLowerCase() ? (
|
||||
<span key={index} className="text-primary-variant-normal">
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
<span key={index}>{part}</span>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Person Tab中的关系列表项组件
|
||||
const PersonItem = ({
|
||||
person,
|
||||
keyword,
|
||||
onCloseSearch,
|
||||
}: {
|
||||
person: HeartbeatRelationListOutput
|
||||
keyword: string
|
||||
onCloseSearch: () => void
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const chatHref = useMemo(() => (person.aiId ? `/chat/${person.aiId}` : null), [person.aiId])
|
||||
usePrefetchRoutes(chatHref ? [chatHref] : undefined)
|
||||
|
||||
const handleClick = () => {
|
||||
if (chatHref) {
|
||||
router.push(chatHref)
|
||||
onCloseSearch()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="hover:bg-surface-element-normal flex cursor-pointer items-center gap-3 rounded-lg p-3 transition-colors"
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* 头像 */}
|
||||
<div className="relative shrink-0">
|
||||
<Avatar className="size-12">
|
||||
<AvatarImage src={person.headImg} alt={person.nickname} />
|
||||
<AvatarFallback className="bg-surface-element-normal text-txt-primary-normal txt-label-m">
|
||||
{(person.nickname || '').charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{/* 心动等级显示 */}
|
||||
</div>
|
||||
|
||||
{/* 用户信息 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="txt-label-m text-txt-primary-normal truncate font-medium">
|
||||
<HighlightText text={person.nickname || ''} keyword={keyword} />
|
||||
</span>
|
||||
{person.heartbeatLevel && person.isShow && (
|
||||
<AIRelationTag heartbeatLevel={person.heartbeatLevel} size="mini" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 心动值和角色信息 */}
|
||||
<div className="txt-body-s text-txt-secondary-normal flex items-center gap-2">
|
||||
{/* 心动值 */}
|
||||
{person.heartbeatVal !== undefined && (
|
||||
<>
|
||||
<div className="flex items-center gap-1">
|
||||
<Image src="/icons/heart.svg" alt="Heart" width={12} height={12} />
|
||||
<span className="txt-label-s">{person.heartbeatVal}℃</span>
|
||||
</div>
|
||||
|
||||
{/* 分隔线 */}
|
||||
{person.characterName && <div className="border-outline-normal h-3 w-0 border-l" />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 角色信息 */}
|
||||
{person.characterName && (
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{[getAge(person.birthday as unknown as number), person.characterName, person.tagName]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IconButton iconfont="icon-Chat" size="small" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Person列表加载骨架屏组件
|
||||
const PersonSkeleton = () => (
|
||||
<div className="flex animate-pulse items-center gap-3 p-3">
|
||||
<div className="bg-surface-nest-normal size-12 shrink-0 rounded-full" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<div className="bg-surface-nest-normal h-4 w-24 rounded" />
|
||||
<div className="bg-surface-nest-normal h-4 w-12 rounded" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="bg-surface-nest-normal h-3 w-3 rounded" />
|
||||
<div className="bg-surface-nest-normal h-3 w-8 rounded" />
|
||||
</div>
|
||||
<div className="h-3 w-0 border-l border-gray-600" />
|
||||
<div className="bg-surface-nest-normal h-3 w-16 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Person空状态组件
|
||||
const PersonEmptyState = () => (
|
||||
<div className="text-txt-secondary-normal flex items-center justify-center py-16">
|
||||
<Empty title="Nothing here… :3" />
|
||||
</div>
|
||||
)
|
||||
|
||||
const ChatSearchResults = ({
|
||||
searchKeyword,
|
||||
isExpanded,
|
||||
onCloseSearch,
|
||||
}: ChatSearchResultsProps) => {
|
||||
const conversationList = useAtomValue(conversationListAtom)
|
||||
const [activeTab, setActiveTab] = useState('message')
|
||||
const [debouncedSearchKeyword, setDebouncedSearchKeyword] = useState(searchKeyword)
|
||||
|
||||
// 防抖处理搜索关键词,避免频繁调用API
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearchKeyword(searchKeyword)
|
||||
}, 500) // 500ms防抖延迟
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchKeyword])
|
||||
const ChatSearchResults = ({ searchKeyword, isExpanded }: ChatSearchResultsProps) => {
|
||||
const channels = useStreamChatStore((state) => state.channels);
|
||||
const [activeTab, setActiveTab] = useState('message');
|
||||
|
||||
// 筛选Message搜索结果 - 对用户名或最后一条消息内容进行搜索
|
||||
const messageResults = useMemo(() => {
|
||||
if (!searchKeyword) return []
|
||||
const { nameRes, messageRes } = useMemo(() => {
|
||||
const nameRes: any[] = [];
|
||||
const messageRes: any[] = [];
|
||||
if (!searchKeyword) {
|
||||
return {
|
||||
nameRes,
|
||||
messageRes,
|
||||
};
|
||||
}
|
||||
|
||||
const conversations = Array.from(conversationList.values())
|
||||
return conversations.filter((conversation) => {
|
||||
const { name } = conversation
|
||||
const keyword = searchKeyword.toLowerCase()
|
||||
channels.forEach((chanel) => {
|
||||
const { name } = (chanel?.data as any) ?? {};
|
||||
const keyword = searchKeyword.toLowerCase();
|
||||
|
||||
// 搜索用户名
|
||||
if (name?.toLowerCase().includes(keyword)) {
|
||||
return true
|
||||
nameRes.push(chanel);
|
||||
}
|
||||
const messages = chanel?.state?.messages || [];
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
// 搜索最后一条消息内容
|
||||
if (lastMessage?.text?.toLowerCase().includes(keyword)) {
|
||||
messageRes.push(chanel);
|
||||
}
|
||||
});
|
||||
|
||||
// // 搜索最后一条消息内容
|
||||
// if (lastMessage?.text?.toLowerCase().includes(keyword)) {
|
||||
// return true;
|
||||
// }
|
||||
return {
|
||||
nameRes,
|
||||
messageRes,
|
||||
};
|
||||
}, [channels, searchKeyword]);
|
||||
|
||||
return false
|
||||
})
|
||||
}, [conversationList, searchKeyword])
|
||||
|
||||
// 使用无限查询获取Person搜索结果
|
||||
const {
|
||||
data: personData,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isLoading: isPersonLoading,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: [...imKeys.heartbeatRelationList(debouncedSearchKeyword), 'infinite'],
|
||||
queryFn: ({ pageParam = 1 }) =>
|
||||
imService.getHeartbeatRelationList({
|
||||
nickname: debouncedSearchKeyword,
|
||||
page: { pn: pageParam, ps: 20 },
|
||||
}),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
const currentPage = allPages.length
|
||||
const totalPages = Math.ceil((lastPage.tc || 0) / 20)
|
||||
return currentPage < totalPages ? currentPage + 1 : undefined
|
||||
},
|
||||
enabled: !!debouncedSearchKeyword && activeTab === 'person',
|
||||
})
|
||||
|
||||
const personResults = personData?.pages.flatMap((page) => page.datas || []) || []
|
||||
|
||||
// 判断是否正在等待防抖
|
||||
const isWaitingForDebounce = searchKeyword !== debouncedSearchKeyword
|
||||
|
||||
// 如果没有搜索关键词,返回空状态
|
||||
if (!searchKeyword) {
|
||||
return <div className="flex-1" />
|
||||
return <div className="flex-1" />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -232,25 +58,24 @@ const ChatSearchResults = ({
|
|||
<TabsList className="mb-0 h-auto w-fit justify-start gap-4 bg-transparent p-0">
|
||||
<TabsTrigger value="message" className="relative h-auto flex-col gap-1">
|
||||
<span className="txt-title-s text-txt-secondary-normal group-data-[state=active]:text-txt-primary-normal">
|
||||
Chats
|
||||
Person
|
||||
</span>
|
||||
<div className="bg-primary-normal h-1 w-5 rounded opacity-0 group-data-[state=active]:opacity-100" />
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="person" className="relative h-auto flex-col gap-1">
|
||||
<span className="txt-title-s text-txt-secondary-normal group-data-[state=active]:text-txt-primary-normal">
|
||||
My Crushes
|
||||
Message
|
||||
</span>
|
||||
<div className="bg-primary-normal h-1 w-5 rounded opacity-0 group-data-[state=active]:opacity-100" />
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Message Tab 内容 */}
|
||||
<TabsContent value="message" className="mt-0 flex-1 space-y-1 overflow-y-auto">
|
||||
{messageResults.length > 0 ? (
|
||||
messageResults.map((conversation) => (
|
||||
{nameRes.length > 0 ? (
|
||||
nameRes.map((chanel: any) => (
|
||||
<ChatSidebarItem
|
||||
key={conversation.conversationId}
|
||||
conversation={conversation}
|
||||
key={chanel.id}
|
||||
chanel={chanel}
|
||||
isExpanded={isExpanded}
|
||||
isSelected={false}
|
||||
searchKeyword={searchKeyword}
|
||||
|
|
@ -263,37 +88,26 @@ const ChatSearchResults = ({
|
|||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Person Tab 内容 */}
|
||||
<TabsContent value="person" className="mt-0 flex-1 overflow-y-auto">
|
||||
{isPersonLoading || isWaitingForDebounce ? (
|
||||
<div className="space-y-1">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<PersonSkeleton key={index} />
|
||||
))}
|
||||
</div>
|
||||
{messageRes.length > 0 ? (
|
||||
messageRes.map((chanel: any) => (
|
||||
<ChatSidebarItem
|
||||
key={chanel.id}
|
||||
chanel={chanel}
|
||||
isExpanded={isExpanded}
|
||||
isSelected={false}
|
||||
searchKeyword={searchKeyword}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<InfiniteScrollList<HeartbeatRelationListOutput>
|
||||
items={personResults}
|
||||
renderItem={(person) => (
|
||||
<PersonItem person={person} keyword={searchKeyword} onCloseSearch={onCloseSearch} />
|
||||
)}
|
||||
getItemKey={(person) => person.aiId?.toString() || 'unknown'}
|
||||
hasNextPage={!!hasNextPage}
|
||||
isLoading={isPersonLoading || isFetchingNextPage}
|
||||
fetchNextPage={fetchNextPage}
|
||||
columns={1}
|
||||
gap={1}
|
||||
className="!grid-cols-1 space-y-1" // 强制单列布局
|
||||
LoadingSkeleton={PersonSkeleton}
|
||||
EmptyComponent={PersonEmptyState}
|
||||
hasError={false}
|
||||
enabled={!!debouncedSearchKeyword}
|
||||
/>
|
||||
<div className="text-txt-secondary-normal flex items-center justify-center py-16">
|
||||
<Empty title="Nothing here… :3" />
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatSearchResults
|
||||
export default ChatSearchResults;
|
||||
|
|
|
|||
|
|
@ -6,17 +6,15 @@ import { Input } from '@/components/ui/input';
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useStreamChatStore } from '@/stores/stream-chat';
|
||||
import { useLayoutStore } from '@/stores';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
const ChatSidebar = () => {
|
||||
const ChatSidebar = ({ expand }: { expand?: boolean }) => {
|
||||
const isSidebarExpanded = useLayoutStore((s) => s.isSidebarExpanded);
|
||||
const currentChannel = useStreamChatStore((state) => state.currentChannel);
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const channels = useStreamChatStore((state) => state.channels);
|
||||
const [search, setSearch] = useState('');
|
||||
const [inSearching, setIsSearching] = useState(false);
|
||||
|
||||
const datas = Array.from(channels.values()).sort((a, b) => {
|
||||
return false;
|
||||
});
|
||||
// console.log('channels', channels);
|
||||
|
||||
// 当侧边栏收缩时,取消搜索功能
|
||||
useEffect(() => {
|
||||
|
|
@ -34,111 +32,80 @@ const ChatSidebar = () => {
|
|||
// 如果有搜索关键词,显示搜索结果
|
||||
const isShowingSearchResults = search.trim().length > 0;
|
||||
|
||||
if (!datas.length && !isShowingSearchResults) {
|
||||
if (!channels.length && !isShowingSearchResults) {
|
||||
return <div className="flex-1"></div>;
|
||||
}
|
||||
|
||||
const finalExpand = expand || isSidebarExpanded;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 分割线 */}
|
||||
<div className="mx-6 my-4">
|
||||
<div className="bg-outline-normal h-px" />
|
||||
<div className="flex min-h-0 flex-1 flex-col px-4">
|
||||
{/* 聊天标题 */}
|
||||
<div className="mb-2 flex h-10 items-center justify-between px-2 py-1">
|
||||
<span className="txt-label-s text-txt-secondary-normal">Chats</span>
|
||||
{finalExpand && (
|
||||
<ChatSidebarAction
|
||||
onSearchClick={() => setIsSearching(true)}
|
||||
onCancelSearch={handleCloseSearch}
|
||||
isSearchActive={inSearching}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col px-4">
|
||||
{/* 聊天标题 */}
|
||||
<div className="mb-2 flex h-10 items-center justify-between px-2 py-1">
|
||||
{isSidebarExpanded ? (
|
||||
<>
|
||||
<span className="txt-label-s text-txt-secondary-normal">Chats</span>
|
||||
<ChatSidebarAction
|
||||
onSearchClick={() => setIsSearching(true)}
|
||||
onCancelSearch={handleCloseSearch}
|
||||
isSearchActive={inSearching}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<span className="txt-label-s text-txt-secondary-normal w-full text-center">Chats</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 搜索框 - 根据设计稿实现 */}
|
||||
{inSearching && isSidebarExpanded && (
|
||||
<div className="relative mb-2 flex items-center gap-1 px-2 py-1">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search"
|
||||
size="small"
|
||||
autoFocus
|
||||
maxLength={50}
|
||||
prefixIcon={
|
||||
<i className="iconfont icon-Search text-txt-secondary-normal text-sm" />
|
||||
}
|
||||
className="rounded-full"
|
||||
/>
|
||||
{isShowingSearchResults && (
|
||||
<IconButton
|
||||
onClick={() => setSearch('')}
|
||||
size="mini"
|
||||
variant="tertiary"
|
||||
className="absolute top-1/2 right-3 shrink-0 -translate-y-1/2 transform"
|
||||
>
|
||||
<i className="iconfont icon-close" />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
<IconButton
|
||||
onClick={handleCloseSearch}
|
||||
variant="ghost"
|
||||
{/* 搜索框 - 根据设计稿实现 */}
|
||||
{inSearching && finalExpand && (
|
||||
<div className="relative mb-2 flex items-center gap-1 px-2 py-1">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search"
|
||||
size="small"
|
||||
className="shrink-0"
|
||||
>
|
||||
<i className="iconfont icon-close" />
|
||||
</IconButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 根据搜索状态显示不同内容 */}
|
||||
{inSearching ? (
|
||||
isShowingSearchResults ? (
|
||||
<ChatSearchResults
|
||||
searchKeyword={search}
|
||||
isExpanded={isSidebarExpanded}
|
||||
onCloseSearch={handleCloseSearch}
|
||||
autoFocus
|
||||
maxLength={50}
|
||||
prefixIcon={<i className="iconfont icon-Search text-txt-secondary-normal text-sm" />}
|
||||
className="rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1" />
|
||||
)
|
||||
{isShowingSearchResults && (
|
||||
<IconButton
|
||||
onClick={() => setSearch('')}
|
||||
size="mini"
|
||||
variant="tertiary"
|
||||
className="absolute top-1/2 right-3 shrink-0 -translate-y-1/2 transform"
|
||||
>
|
||||
<i className="iconfont icon-close" />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
<IconButton onClick={handleCloseSearch} variant="ghost" size="small" className="shrink-0">
|
||||
<i className="iconfont icon-close" />
|
||||
</IconButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 根据搜索状态显示不同内容 */}
|
||||
{inSearching ? (
|
||||
isShowingSearchResults ? (
|
||||
<ChatSearchResults searchKeyword={search} isExpanded={finalExpand} />
|
||||
) : (
|
||||
<>
|
||||
{/* 聊天项列表 */}
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div className="relative h-full">
|
||||
<div className="h-full space-y-2 overflow-x-hidden overflow-y-auto py-2">
|
||||
{datas.map((chat) => (
|
||||
<ChatSidebarItem
|
||||
key={chat.conversationId}
|
||||
conversation={chat}
|
||||
isExpanded={isSidebarExpanded}
|
||||
isSelected={selectedChat === chat.conversationId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* 底部渐变遮罩 */}
|
||||
<div
|
||||
className="pointer-events-none absolute right-0 bottom-0 left-0 h-4"
|
||||
style={{
|
||||
background: 'linear-gradient(180deg, rgba(33, 26, 43, 0) 0%, #211A2B 100%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
<div className="flex-1" />
|
||||
)
|
||||
) : (
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div className="h-full max-w-full space-y-2 overflow-x-hidden overflow-y-auto py-2">
|
||||
{channels.map((chat) => (
|
||||
<ChatSidebarItem
|
||||
key={chat.id}
|
||||
chanel={chat}
|
||||
isExpanded={finalExpand}
|
||||
isSelected={id === chat.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,9 @@
|
|||
'use client';
|
||||
import { useMemo } from 'react';
|
||||
import AIRelationTag from '@/components/features/AIRelationTag';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn, durationText, getConversationTime } from '@/lib/utils';
|
||||
import { CustomMessageType } from '@/types/im';
|
||||
import Image from 'next/image';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Channel } from 'stream-chat';
|
||||
|
||||
// 高亮搜索关键词的组件
|
||||
const HighlightText = ({ text, keyword }: { text: string; keyword?: string }) => {
|
||||
|
|
@ -30,7 +27,7 @@ const HighlightText = ({ text, keyword }: { text: string; keyword?: string }) =>
|
|||
|
||||
// 聊天项组件
|
||||
export default function ChatSidebarItem({
|
||||
conversation,
|
||||
chanel,
|
||||
isExpanded,
|
||||
isSelected = false,
|
||||
searchKeyword,
|
||||
|
|
@ -38,29 +35,27 @@ export default function ChatSidebarItem({
|
|||
isExpanded: boolean;
|
||||
isSelected?: boolean;
|
||||
searchKeyword?: string;
|
||||
chanel: Channel;
|
||||
}) {
|
||||
const { avatar, name, lastMessage, unreadCount, updateTime, serverExtension } = conversation;
|
||||
const { text, attachment } = lastMessage || {};
|
||||
const { name, id, headPortrait } = (chanel?.data as any) ?? {};
|
||||
const router = useRouter();
|
||||
|
||||
const { heartbeatVal, heartbeatLevel, isShow } = JSON.parse(serverExtension || '{}') || {};
|
||||
const lastMessage = useMemo(() => {
|
||||
const messages = chanel?.state?.messages || [];
|
||||
if (!messages.length) return null;
|
||||
return messages[messages.length - 1];
|
||||
}, [chanel]);
|
||||
|
||||
const handleChat = () => {
|
||||
router.push('/');
|
||||
router.push(`/chat/${id}`);
|
||||
};
|
||||
|
||||
const renderText = () => {
|
||||
const { raw } = attachment || {};
|
||||
const customData = JSON.parse(raw || '{}');
|
||||
const { type, duration } = customData || {};
|
||||
if (type === CustomMessageType.CALL_CANCEL) {
|
||||
return 'Call Canceled';
|
||||
} else if (type === CustomMessageType.CALL) {
|
||||
return `Call duration ${durationText(duration)}`;
|
||||
} else if (type == CustomMessageType.IMAGE) {
|
||||
return '[Image]';
|
||||
if (!lastMessage) return '';
|
||||
if (searchKeyword && lastMessage.text?.includes(searchKeyword)) {
|
||||
return <HighlightText text={lastMessage.text} keyword={searchKeyword} />;
|
||||
}
|
||||
return text;
|
||||
return lastMessage.text || '';
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -75,7 +70,7 @@ export default function ChatSidebarItem({
|
|||
<div className="relative flex-shrink-0">
|
||||
<div className="h-10 w-10 overflow-hidden rounded-full">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={avatar || ''} alt={name || ''} className="object-cover" />
|
||||
<AvatarImage src={headPortrait || ''} alt={name || ''} className="object-cover" />
|
||||
<AvatarFallback>{name?.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
|
|
@ -84,9 +79,9 @@ export default function ChatSidebarItem({
|
|||
<div className="border-primary-variant-normal absolute inset-0 rounded-full border-1 border-solid" />
|
||||
)}
|
||||
{/* 未读消息数量 */}
|
||||
{!!unreadCount && unreadCount > 0 && (
|
||||
{/* {!!unreadCount && unreadCount > 0 && (
|
||||
<Badge count={unreadCount} className="absolute top-[-4px] right-[-4px]" />
|
||||
)}
|
||||
)} */}
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
|
|
@ -97,25 +92,28 @@ export default function ChatSidebarItem({
|
|||
<div className="txt-label-m truncate">
|
||||
<HighlightText text={name || ''} keyword={searchKeyword} />
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
{/* <div className="flex-shrink-0">
|
||||
{heartbeatLevel && isShow && (
|
||||
<AIRelationTag heartbeatLevel={heartbeatLevel} size="mini" />
|
||||
)}
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
<div className="txt-body-s text-txt-secondary-normal line-clamp-1">
|
||||
{renderText()}
|
||||
</div>
|
||||
<div className="txt-body-s text-txt-secondary-normal truncate">{renderText()}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
<div className="txt-numMonotype-xs text-txt-secondary-normal text-right">
|
||||
{getConversationTime(lastMessage?.messageRefer.createTime || updateTime)}
|
||||
16:27
|
||||
{/* {getConversationTime(lastMessage?.messageRefer.createTime || updateTime)} */}
|
||||
</div>
|
||||
{!!heartbeatVal && heartbeatLevel && (
|
||||
{/* {!!heartbeatVal && heartbeatLevel && (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Image src="/icons/heart.svg" alt="heart" width={12} height={12} />
|
||||
<div className="txt-numMonotype-xs text-txt-primary-normal">{heartbeatVal}℃</div>
|
||||
</div>
|
||||
)}
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import createClient from './request'
|
||||
import createClient from './request';
|
||||
|
||||
export const editorRequest = createClient({ serviceName: 'editor' })
|
||||
export const editorRequest = createClient({ serviceName: 'editor' });
|
||||
export const chatRequest = createClient({ serviceName: 'chat' });
|
||||
|
|
|
|||
|
|
@ -1,26 +1,27 @@
|
|||
import type { AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
|
||||
import axios from 'axios'
|
||||
import Cookies from 'js-cookie'
|
||||
import { getToken, saveAuthInfo } from './auth'
|
||||
import type { AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
|
||||
import axios from 'axios';
|
||||
import Cookies from 'js-cookie';
|
||||
import { getToken, saveAuthInfo } from './auth';
|
||||
|
||||
const endpoints = {
|
||||
editor: process.env.NEXT_PUBLIC_EDITOR_API_URL,
|
||||
}
|
||||
chat: process.env.NEXT_PUBLIC_CHAT_API_URL,
|
||||
};
|
||||
|
||||
export default function createClient({ serviceName }: { serviceName: keyof typeof endpoints }) {
|
||||
const baseURL = endpoints[serviceName] || '/'
|
||||
const baseURL = endpoints[serviceName] || '/';
|
||||
const instance = axios.create({
|
||||
withCredentials: false,
|
||||
baseURL,
|
||||
validateStatus: (status) => {
|
||||
return status >= 200 && status < 500
|
||||
return status >= 200 && status < 500;
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
instance.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
|
||||
const token = await getToken()
|
||||
const token = await getToken();
|
||||
if (token) {
|
||||
config.headers.setAuthorization(`Bearer ${token}`)
|
||||
config.headers.setAuthorization(`Bearer ${token}`);
|
||||
}
|
||||
|
||||
// 从 cookie 中读取语言设置,并添加到请求头
|
||||
|
|
@ -33,8 +34,8 @@ export default function createClient({ serviceName }: { serviceName: keyof typeo
|
|||
// }
|
||||
// }
|
||||
|
||||
return config
|
||||
})
|
||||
return config;
|
||||
});
|
||||
|
||||
instance.interceptors.response.use(
|
||||
async (response: AxiosResponse): Promise<AxiosResponse> => {
|
||||
|
|
@ -57,12 +58,12 @@ export default function createClient({ serviceName }: { serviceName: keyof typeo
|
|||
// });
|
||||
}
|
||||
|
||||
return response
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
console.log('error', error)
|
||||
console.log('error', error);
|
||||
if (axios.isCancel(error)) {
|
||||
return Promise.resolve('请求取消')
|
||||
return Promise.resolve('请求取消');
|
||||
}
|
||||
|
||||
// notification.error({
|
||||
|
|
@ -70,29 +71,29 @@ export default function createClient({ serviceName }: { serviceName: keyof typeo
|
|||
// description: error,
|
||||
// });
|
||||
|
||||
return Promise.reject(error)
|
||||
return Promise.reject(error);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
type ResponseType<T = any> = {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
};
|
||||
|
||||
return async function request<T = any>(
|
||||
url: string,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<ResponseType<T>> {
|
||||
let data: any
|
||||
let data: any;
|
||||
if (config && config?.params) {
|
||||
const { params } = config
|
||||
data = Object.fromEntries(Object.entries(params).filter(([, value]) => value !== ''))
|
||||
const { params } = config;
|
||||
data = Object.fromEntries(Object.entries(params).filter(([, value]) => value !== ''));
|
||||
}
|
||||
const response = await instance<ResponseType<T>>(url, {
|
||||
...config,
|
||||
params: data,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { editorRequest } from '@/lib/client';
|
||||
import { chatRequest, editorRequest } from '@/lib/client';
|
||||
|
||||
export async function fetchCharacters({ index, limit, query }: any) {
|
||||
const { data } = await editorRequest('/api/character/list', {
|
||||
|
|
@ -9,9 +9,31 @@ export async function fetchCharacters({ index, limit, query }: any) {
|
|||
}
|
||||
|
||||
export async function fetchCharacter(params: any) {
|
||||
return editorRequest('/api/character/detail', { method: 'POST', data: params });
|
||||
const { data } = await editorRequest('/api/character/detail', { method: 'POST', data: params });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchCharacterTags(params: any = {}) {
|
||||
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 },
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,60 +1,138 @@
|
|||
'use client';
|
||||
import { Channel, StreamChat } from 'stream-chat';
|
||||
import { create } from 'zustand';
|
||||
import { getUserToken, createChannel } from '@/services/editor';
|
||||
import { parseSSEStream, parseData } from '@/utils/streamParser';
|
||||
|
||||
type Message = {
|
||||
key: string;
|
||||
role: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
interface StreamChatStore {
|
||||
client: StreamChat | null;
|
||||
user: {
|
||||
userId: string;
|
||||
userName: string;
|
||||
};
|
||||
// 连接 StreamChat 客户端
|
||||
connect: (user: any) => Promise<void>;
|
||||
// 频道
|
||||
channels: Channel[];
|
||||
currentChannel: Channel | null;
|
||||
// 创建某个角色的聊天频道, 返回channelId
|
||||
createChannel: (characterId: string) => Promise<string | false>;
|
||||
switchToChannel: (id: string) => Promise<void>;
|
||||
queryChannels: (filter: any) => Promise<void>;
|
||||
deleteChannel: (id: string) => Promise<void>;
|
||||
clearChannels: () => Promise<void>;
|
||||
getCurrentCharacter: () => any | null;
|
||||
|
||||
// 消息列表
|
||||
messages: Message[];
|
||||
setMessages: (messages: Message[]) => void;
|
||||
|
||||
// 发送消息
|
||||
sendMessage: (content: string) => Promise<void>;
|
||||
|
||||
// 清除通知
|
||||
clearNotifications: () => Promise<void>;
|
||||
}
|
||||
|
||||
let client: StreamChat | null = null;
|
||||
export const useStreamChatStore = create<StreamChatStore>((set, get) => ({
|
||||
client: null,
|
||||
user: {
|
||||
userId: '',
|
||||
userName: '',
|
||||
},
|
||||
channels: [],
|
||||
messages: [],
|
||||
setMessages: (messages: any[]) => set({ messages }),
|
||||
currentChannel: null,
|
||||
// 获取当前聊天频道中的角色id
|
||||
getCurrentCharacter() {
|
||||
const { currentChannel, user } = get();
|
||||
return (
|
||||
Object.values(currentChannel?.state?.members || {})?.find((i) => i.user?.id !== user?.userId)
|
||||
?.user?.id || null
|
||||
);
|
||||
},
|
||||
// 创建某个角色的聊天频道
|
||||
async createChannel(characterId: string) {
|
||||
const { user, client } = get();
|
||||
const { switchToChannel, queryChannels } = get();
|
||||
if (!client) {
|
||||
return false;
|
||||
}
|
||||
const { data } = await createChannel({
|
||||
userId: user.userId,
|
||||
userName: user.userName,
|
||||
characterId,
|
||||
});
|
||||
if (!data?.channelId) {
|
||||
return false;
|
||||
}
|
||||
await queryChannels({});
|
||||
switchToChannel(data.channelId);
|
||||
return data.channelId;
|
||||
},
|
||||
|
||||
async connect(user) {
|
||||
const { client } = get();
|
||||
set({ user });
|
||||
if (client) return;
|
||||
console.log('connecting stream chat', user);
|
||||
const { data } = await getUserToken(user);
|
||||
client = new StreamChat(process.env.NEXT_PUBLIC_STREAM_CHAT_API_KEY || '');
|
||||
await client.connectUser(
|
||||
const streamClient = new StreamChat(process.env.NEXT_PUBLIC_STREAM_CHAT_API_KEY || '');
|
||||
const res = await streamClient.connectUser(
|
||||
{
|
||||
id: user.userId,
|
||||
name: user.userName,
|
||||
},
|
||||
data
|
||||
);
|
||||
set({ client: streamClient });
|
||||
},
|
||||
|
||||
async switchToChannel(id: string) {
|
||||
const { channels } = get();
|
||||
const channel = channels.find((ch) => ch.id === id);
|
||||
if (channel) {
|
||||
set({ currentChannel: channel });
|
||||
// 可选:监听该频道的消息
|
||||
await channel.watch();
|
||||
} else {
|
||||
console.warn(`Channel with id ${id} not found in channels list`);
|
||||
}
|
||||
const { client, user } = get();
|
||||
const channel = client!.channel('messaging', id);
|
||||
const result = await channel.query({
|
||||
messages: { limit: 100 },
|
||||
});
|
||||
const messages = result.messages.map((i) => ({
|
||||
key: i.id,
|
||||
role: i.user?.id === user.userId ? 'user' : 'assistant',
|
||||
content: i.text!,
|
||||
}));
|
||||
set({ currentChannel: channel, messages });
|
||||
},
|
||||
async queryChannels(filter: any) {
|
||||
|
||||
async queryChannels() {
|
||||
const { user, client } = get();
|
||||
if (!client) {
|
||||
console.error('StreamChat client is not connected');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const channels = await client.queryChannels(filter, {
|
||||
last_message_at: -1,
|
||||
});
|
||||
const channels = await client.queryChannels(
|
||||
{
|
||||
members: {
|
||||
$in: [user.userId],
|
||||
},
|
||||
},
|
||||
{
|
||||
last_message_at: -1,
|
||||
},
|
||||
{
|
||||
message_limit: 1, // 返回最新的1条消息
|
||||
}
|
||||
);
|
||||
set({ channels });
|
||||
} catch (error) {
|
||||
console.error('Failed to query channels:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteChannel(id: string) {
|
||||
const { channels, currentChannel, queryChannels } = get();
|
||||
const channel = channels.find((ch) => ch.id === id);
|
||||
|
|
@ -65,11 +143,14 @@ export const useStreamChatStore = create<StreamChatStore>((set, get) => ({
|
|||
try {
|
||||
await channel.delete();
|
||||
await queryChannels({});
|
||||
set({ currentChannel: null });
|
||||
if (currentChannel?.id === id) {
|
||||
set({ currentChannel: null });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete channel ${id}:`, error);
|
||||
}
|
||||
},
|
||||
|
||||
async clearChannels() {
|
||||
const { channels } = get();
|
||||
|
||||
|
|
@ -93,4 +174,48 @@ export const useStreamChatStore = create<StreamChatStore>((set, get) => ({
|
|||
}
|
||||
},
|
||||
async clearNotifications() {},
|
||||
|
||||
// 发送消息
|
||||
sendMessage: async (content: any) => {
|
||||
const { user, currentChannel, getCurrentCharacter, setMessages, messages } = get();
|
||||
// 过滤出用户和助手的消息
|
||||
const filteredMessages = messages.filter((i) => i.role === 'user' || i.role === 'assistant');
|
||||
let finalMessages = [
|
||||
...filteredMessages,
|
||||
{ key: user.userId, role: 'user', content: content },
|
||||
{ key: 'assistant', role: 'assistant', content: '' },
|
||||
];
|
||||
setMessages(finalMessages);
|
||||
|
||||
// 发送消息到服务器
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_CHAT_API_URL}/chat-api/chat/testPrompt`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userId: user.userId,
|
||||
channelId: currentChannel?.id || '',
|
||||
message: content,
|
||||
promptTemplateId: 'default',
|
||||
characterId: getCurrentCharacter()?.id,
|
||||
modelName: 'gpt-3.5-turbo',
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
// 处理服务器返回的 SSE 流
|
||||
await parseSSEStream(response, (event: string, data: string) => {
|
||||
if (event === 'chat-message') {
|
||||
const d = parseData(data);
|
||||
const lastMsg = finalMessages[finalMessages.length - 1];
|
||||
if (lastMsg.role === 'assistant') {
|
||||
lastMsg.content = d.content || '';
|
||||
}
|
||||
setMessages([...finalMessages]);
|
||||
}
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
export type SSEHandler = (event: string, data: string) => void;
|
||||
|
||||
export const parseData = (data: string): any => {
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch (error) {
|
||||
return data;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理 SSE 流的通用函数
|
||||
* @param response fetch 返回的 Response 对象
|
||||
* @param onMessage 收到消息时的回调函数
|
||||
*/
|
||||
export async function parseSSEStream(response: Response, onMessage: SSEHandler) {
|
||||
if (!response.body) {
|
||||
throw new Error('Response body is empty');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
// 1. 解码新收到的数据并追加到 buffer
|
||||
if (value) {
|
||||
buffer += decoder.decode(value, { stream: !done });
|
||||
}
|
||||
|
||||
// 2. 按标准 SSE 分隔符 \n\n 切分消息
|
||||
const parts = buffer.split('\n\n');
|
||||
|
||||
// 3. 最后一个部分通常是不完整的,留到下一次处理
|
||||
// 但如果流已经结束(done=true),那么剩下的所有内容都必须强制处理
|
||||
buffer = parts.pop() || '';
|
||||
|
||||
if (done && buffer.trim()) {
|
||||
parts.push(buffer);
|
||||
buffer = '';
|
||||
}
|
||||
|
||||
// 4. 解析切分出来的每一条完整消息
|
||||
for (const part of parts) {
|
||||
if (!part.trim()) continue;
|
||||
|
||||
const lines = part.split('\n');
|
||||
let event = '';
|
||||
let data = '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event:')) {
|
||||
event = line.slice(6).trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
const lineData = line.slice(5);
|
||||
data = lineData;
|
||||
}
|
||||
}
|
||||
|
||||
if (data) {
|
||||
onMessage(event, data);
|
||||
}
|
||||
}
|
||||
|
||||
if (done) break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Stream parsing error:', error);
|
||||
throw error; // 继续抛出,让调用者处理
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue