feat: 适配移动端

This commit is contained in:
liuyonghe0111 2025-12-17 18:13:47 +08:00
parent 8b524fbf79
commit ddff5100b6
50 changed files with 1259 additions and 1159 deletions

1
.env
View File

@ -8,6 +8,7 @@ NEXT_PUBLIC_PIGEON_API_URL=http://35.82.37.117:8082/pigeon
# A18 服务 # A18 服务
NEXT_PUBLIC_EDITOR_API_URL=http://54.223.196.180 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 NEXT_PUBLIC_DISCORD_CLIENT_ID=1448143535609217076

View File

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

View File

@ -1,13 +1,6 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_164_982)"> <circle cx="24" cy="24" r="12" fill="#505473"/>
<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 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 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 d="M19 19L21 21L19 23" stroke="#7D82AA" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<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 d="M29 19L27 21L29 23" stroke="#7D82AA" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<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> </svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 505 B

View File

@ -1,24 +1,17 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_164_1032)"> <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 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 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"/>
<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)"/> <circle cx="24" cy="24" r="12" fill="url(#paint1_linear_77_8651)"/>
<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)"/> <path d="M19 19L21 21L19 23" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</g> <path d="M29 19L27 21L29 23" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<defs> <defs>
<linearGradient id="paint0_linear_164_1032" x1="4" y1="16" x2="28" y2="16" gradientUnits="userSpaceOnUse"> <linearGradient id="paint0_linear_77_8651" x1="24" y1="24" x2="24" y2="30" gradientUnits="userSpaceOnUse">
<stop stop-color="#F264A4"/> <stop stop-color="#FFD051"/>
<stop offset="1" stop-color="#C241E6"/> <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> </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> </defs>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -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>
);
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -1,6 +1,5 @@
import { Button } from '@/components/ui/button';
import { AvatarImage, AvatarFallback, Avatar } from '@radix-ui/react-avatar'; 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 }> }) { export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
@ -27,9 +26,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
</div> </div>
</div> </div>
<div> <div>
<Link href={`/character/${id}/chat`}> <ChatButton id={id}></ChatButton>
<Button variant="primary">Chat</Button>
</Link>
</div> </div>
</header> </header>
<div className="mt-12 rounded-2xl bg-white/10 p-6"> <div className="mt-12 rounded-2xl bg-white/10 p-6">

View File

@ -1,9 +1,10 @@
'use client'; 'use client';
import ChatSidebar from '@/layout/components/ChatSidebar';
export default function ChatPage() { export default function ChatPage() {
return ( return (
<div> <div className="h-full">
<h1>Chat</h1> <ChatSidebar expand />
</div> </div>
); );
} }

View File

@ -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 }) { export default function AIMessage({ data }: { data: any }) {
return ( return (
<div className="mb-8 max-w-[90%]"> <div className="mb-8 max-w-[90%]">
<div className="bg-surface-element-normal inline-block rounded-lg p-4 backdrop-blur-2xl"> <div className="bg-surface-element-normal inline-block rounded-lg p-4 backdrop-blur-2xl">
<AITextRender text={data.text} /> <AITextRender text={data.content} />
</div> </div>
</div> </div>
) );
} }

View File

@ -1,51 +1,48 @@
'use client' 'use client';
import { Tag } from '@/components/ui/tag' import { Tag } from '@/components/ui/tag';
import { useState, useRef, useEffect } from 'react' import { useState, useRef, useEffect } from 'react';
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useCharacter } from '@/hooks/services/character';
import { useParams } from 'next/navigation';
// import CrushLevelAvatar from './CrushLevelAvatar' // import CrushLevelAvatar from './CrushLevelAvatar'
export const CharacterAvatorAndName = () => { export const CharacterAvatorAndName = ({ name, avator }: { name: string; avator: string }) => {
const { introduction, characterName, tagName } = {
introduction: 'introduction introduction introduction introduction introduction',
characterName: 'characterName',
tagName: 'tagName',
}
return ( return (
<div className="flex flex-col items-center gap-6"> <div className="flex flex-col items-center gap-6">
<Avatar className="h-20 w-20"> <Avatar className="h-20 w-20">
<AvatarImage src="https://picsum.photos/200/300" /> <AvatarImage src={avator} />
<AvatarFallback>{characterName?.slice(0, 1)}</AvatarFallback> <AvatarFallback>{name?.slice(0, 1)}</AvatarFallback>
</Avatar> </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> </div>
) );
} };
const ChatMessageUserHeader = () => { const ChatMessageUserHeader = () => {
const [isFullIntroduction, setIsFullIntroduction] = useState(false) const [isFullIntroduction, setIsFullIntroduction] = useState(false);
const [shouldShowExpandButton, setShouldShowExpandButton] = useState(false) const [shouldShowExpandButton, setShouldShowExpandButton] = useState(false);
const textRef = useRef<HTMLDivElement>(null) const textRef = useRef<HTMLDivElement>(null);
const { introduction, characterName, tagName } = { const { id } = useParams<{ id: string }>();
const { data: character = {} } = useCharacter(id.split('-')[2]);
const { introduction } = {
introduction: 'introduction introduction introduction introduction introduction', introduction: 'introduction introduction introduction introduction introduction',
characterName: 'characterName', };
tagName: 'tagName',
}
// 检测文本是否超过三行 // 检测文本是否超过三行
useEffect(() => { useEffect(() => {
if (textRef.current && introduction) { if (textRef.current && introduction) {
// 直接比较滚动高度和可见高度 // 直接比较滚动高度和可见高度
// 如果内容的实际高度大于容器的可见高度,说明内容被截断了 // 如果内容的实际高度大于容器的可见高度,说明内容被截断了
const isOverflowing = textRef.current.scrollHeight > textRef.current.clientHeight const isOverflowing = textRef.current.scrollHeight > textRef.current.clientHeight;
setShouldShowExpandButton(isOverflowing) setShouldShowExpandButton(isOverflowing);
} }
}, [introduction]) }, [introduction]);
return ( return (
<div className="flex flex-col items-center gap-6"> <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 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 <div
@ -63,12 +60,11 @@ const ChatMessageUserHeader = () => {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Tag variant="purple" size="small"> {character?.tags?.slice(0, 2)?.map((tag: any, index: number) => (
{characterName} <Tag key={tag.tagId} variant={index ? 'purple' : 'magenta'} size="small">
</Tag> {tag.name}
<Tag variant="magenta" size="small">
{tagName}
</Tag> </Tag>
))}
</div> </div>
{shouldShowExpandButton && !isFullIntroduction && ( {shouldShowExpandButton && !isFullIntroduction && (
<div <div
@ -88,7 +84,7 @@ const ChatMessageUserHeader = () => {
<div className="txt-body-m text-txt-secondary-normal">Content generated by AI</div> <div className="txt-body-m text-txt-secondary-normal">Content generated by AI</div>
</div> </div>
) );
} };
export default ChatMessageUserHeader export default ChatMessageUserHeader;

View File

@ -2,6 +2,7 @@
import { IconButton } from '@/components/ui/button'; import { IconButton } from '@/components/ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useStreamChatStore } from '@/stores/stream-chat';
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
const AuthHeightTextarea = (props: React.ComponentProps<'textarea'> & { maxHeight?: number }) => { const AuthHeightTextarea = (props: React.ComponentProps<'textarea'> & { maxHeight?: number }) => {
@ -59,6 +60,7 @@ const AuthHeightTextarea = (props: React.ComponentProps<'textarea'> & { maxHeigh
export default function Input() { export default function Input() {
const [isRecording, setIsRecording] = useState(false); const [isRecording, setIsRecording] = useState(false);
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const sendMessage = useStreamChatStore((state) => state.sendMessage);
return ( return (
<div className="flex flex-col mb-6 items-end gap-4"> <div className="flex flex-col mb-6 items-end gap-4">
@ -85,7 +87,7 @@ export default function Input() {
<IconButton <IconButton
variant="ghost" variant="ghost"
size="small" size="small"
onClick={() => {}} onClick={() => null}
className={cn('bg-surface-element-hover flex-shrink-0')} className={cn('bg-surface-element-hover flex-shrink-0')}
iconfont="icon-prompt" iconfont="icon-prompt"
/> />
@ -94,7 +96,7 @@ export default function Input() {
size="large" size="large"
loading={false} loading={false}
iconfont="icon-icon-send" iconfont="icon-icon-send"
onClick={() => {}} onClick={() => sendMessage(inputValue)}
disabled={false} disabled={false}
className="flex-shrink-0" className="flex-shrink-0"
/> />

View File

@ -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>
);
}

View File

@ -1,21 +1,20 @@
'use client' 'use client';
import { SiderHeader } from '.' import { useChatStore } from '../store';
import { useChatStore } from '../store' import { Button, IconButton } from '@/components/ui/button';
import { Button, IconButton } from '@/components/ui/button' import { cn } from '@/lib/utils';
import { cn } from '@/lib/utils' import Image from 'next/image';
import Image from 'next/image' import { Checkbox } from '@/components/ui/checkbox';
import { Checkbox } from '@/components/ui/checkbox' import React, { useState } from 'react';
import React, { useState } from 'react' import { Tag } from '@/components/ui/tag';
import { Tag } from '@/components/ui/tag' import { ImageViewer } from '@/components/ui/image-viewer';
import { ImageViewer } from '@/components/ui/image-viewer' import { useImageViewer } from '@/hooks/useImageViewer';
import { useImageViewer } from '@/hooks/useImageViewer'
type BackgroundItem = { type BackgroundItem = {
backgroundId: number backgroundId: number;
imgUrl: string imgUrl: string;
isDefault: boolean isDefault: boolean;
inUse?: boolean inUse?: boolean;
} };
const BackgroundImageViewerAction = ({ const BackgroundImageViewerAction = ({
datas, datas,
@ -23,18 +22,18 @@ const BackgroundImageViewerAction = ({
isSelected, isSelected,
onChange, onChange,
}: { }: {
datas: BackgroundItem[] datas: BackgroundItem[];
backgroundId: number backgroundId: number;
isSelected: boolean isSelected: boolean;
onChange: (backgroundId: number) => void onChange: (backgroundId: number) => void;
}) => { }) => {
const handleSelect = () => { const handleSelect = () => {
// 如果只有一张背景且当前已选中,不允许取消选中 // 如果只有一张背景且当前已选中,不允许取消选中
if (datas.length === 1 && isSelected) { if (datas.length === 1 && isSelected) {
return return;
}
onChange(backgroundId)
} }
onChange(backgroundId);
};
return ( return (
<> <>
@ -47,8 +46,8 @@ const BackgroundImageViewerAction = ({
<div className="txt-label-s">Select</div> <div className="txt-label-s">Select</div>
</div> </div>
</> </>
) );
} };
const BackgroundItemCard = ({ const BackgroundItemCard = ({
item, item,
@ -58,20 +57,20 @@ const BackgroundItemCard = ({
onImagePreview, onImagePreview,
totalCount, totalCount,
}: { }: {
item: BackgroundItem item: BackgroundItem;
selected: boolean selected: boolean;
inUse: boolean inUse: boolean;
onClick: () => void onClick: () => void;
onImagePreview: () => void onImagePreview: () => void;
totalCount: number totalCount: number;
}) => { }) => {
const handleClick = () => { const handleClick = () => {
// 如果只有一张背景且当前已选中,不允许取消选中 // 如果只有一张背景且当前已选中,不允许取消选中
if (totalCount === 1 && selected) { if (totalCount === 1 && selected) {
return return;
}
onClick()
} }
onClick();
};
return ( return (
<div className="group relative cursor-pointer" onClick={handleClick}> <div className="group relative cursor-pointer" onClick={handleClick}>
@ -94,20 +93,20 @@ const BackgroundItemCard = ({
size="xs" size="xs"
variant="contrast" variant="contrast"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation();
onImagePreview() onImagePreview();
}} }}
> >
<i className="iconfont icon-icon-fullImage" /> <i className="iconfont icon-icon-fullImage" />
</IconButton> </IconButton>
</div> </div>
) );
} };
export default function Background() { export default function Background() {
const setSideBar = useChatStore((store) => store.setSideBar) const setSideBar = useChatStore((store) => store.setSideBar);
const [selectId, setSelectId] = useState<number | undefined>(1) const [selectId, setSelectId] = useState<number | undefined>(1);
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
// 静态数据:模拟背景图片列表 // 静态数据:模拟背景图片列表
const backgroundList: BackgroundItem[] = [ const backgroundList: BackgroundItem[] = [
@ -132,13 +131,13 @@ export default function Background() {
imgUrl: 'https://picsum.photos/400/600?random=4', imgUrl: 'https://picsum.photos/400/600?random=4',
isDefault: false, 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 { const {
@ -147,35 +146,33 @@ export default function Background() {
openViewer, openViewer,
closeViewer, closeViewer,
handleIndexChange, handleIndexChange,
} = useImageViewer() } = useImageViewer();
const handleConfirm = async () => { const handleConfirm = async () => {
setLoading(true) setLoading(true);
try { try {
// TODO: 调用实际的 API // TODO: 调用实际的 API
// await updateChatBackground({ aiId, backgroundId: selectId }) // 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) { } catch (error) {
console.error(error) console.error(error);
} finally { } finally {
setLoading(false) setLoading(false);
}
} }
};
const handleImagePreview = (index: number) => { const handleImagePreview = (index: number) => {
openViewer(backgroundList?.map((item) => item.imgUrl || '') || [], index) openViewer(backgroundList?.map((item) => item.imgUrl || '') || [], index);
} };
return ( return (
<> <>
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<SiderHeader title="Chat Background" />
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
{backgroundList?.map((item, index) => ( {backgroundList?.map((item, index) => (
@ -191,15 +188,6 @@ export default function Background() {
))} ))}
</div> </div>
</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> </div>
<ImageViewer <ImageViewer
@ -217,9 +205,9 @@ export default function Background() {
isSelected={selectId === backgroundList?.[viewerIndex]?.backgroundId} isSelected={selectId === backgroundList?.[viewerIndex]?.backgroundId}
onChange={setSelectId} onChange={setSelectId}
/> />
) );
}} }}
/> />
</> </>
) );
} }

View File

@ -1,17 +1,15 @@
'use client' 'use client';
import { SiderHeader } from '.' import { useChatStore } from '../store';
import { useChatStore } from '../store' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Button, IconButton } from '@/components/ui/button';
import { Button, IconButton } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox';
import { Checkbox } from '@/components/ui/checkbox' import Image from 'next/image';
import Image from 'next/image'
export default function ChatModel() { export default function ChatModel() {
const setSideBar = useChatStore((store) => store.setSideBar) const setSideBar = useChatStore((store) => store.setSideBar);
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<SiderHeader title="Chat Model" />
<div className="flex-1"> <div className="flex-1">
<div className="bg-surface-element-normal overflow-hidden rounded-lg p-4"> <div className="bg-surface-element-normal overflow-hidden rounded-lg p-4">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
@ -68,12 +66,6 @@ export default function ChatModel() {
</div> </div>
<div className="txt-body-m text-txt-secondary-normal mt-6">Stay tuned for more models</div> <div className="txt-body-m text-txt-secondary-normal mt-6">Stay tuned for more models</div>
</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>
</div> );
)
} }

View File

@ -1,19 +1,18 @@
'use client' 'use client';
import { SiderHeader } from '.' import { useChatStore } from '../store';
import { useChatStore } from '../store' import { useState } from 'react';
import { useState } from 'react' import { Checkbox } from '@/components/ui/checkbox';
import { Checkbox } from '@/components/ui/checkbox' import { Button } from '@/components/ui/button';
import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils';
import { cn } from '@/lib/utils'
type FontOption = { type FontOption = {
value: number value: number;
label: string label: string;
isStandard?: boolean isStandard?: boolean;
} };
export default function Font() { export default function Font() {
const setSideBar = useChatStore((store) => store.setSideBar) const setSideBar = useChatStore((store) => store.setSideBar);
// 字体大小选项 // 字体大小选项
const fontOptions: FontOption[] = [ const fontOptions: FontOption[] = [
@ -22,33 +21,31 @@ export default function Font() {
{ value: 16, label: 'A 16', isStandard: true }, { value: 16, label: 'A 16', isStandard: true },
{ value: 18, label: 'A 18' }, { value: 18, label: 'A 18' },
{ value: 20, label: 'A 20' }, { value: 20, label: 'A 20' },
] ];
const [selectedFont, setSelectedFont] = useState(16) const [selectedFont, setSelectedFont] = useState(16);
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
const handleConfirm = async () => { const handleConfirm = async () => {
setLoading(true) setLoading(true);
try { try {
// TODO: 调用实际的 API 保存字体设置 // TODO: 调用实际的 API 保存字体设置
// await updateFontSize({ fontSize: selectedFont }) // 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) { } catch (error) {
console.error(error) console.error(error);
} finally { } finally {
setLoading(false) setLoading(false);
}
} }
};
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<SiderHeader title="Font" />
<div className="flex-1"> <div className="flex-1">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{fontOptions.map((option) => ( {fontOptions.map((option) => (
@ -71,15 +68,6 @@ export default function Font() {
))} ))}
</div> </div>
</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>
</div> );
)
} }

View File

@ -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>
);
}

View File

@ -1,18 +1,17 @@
'use client' 'use client';
import { SiderHeader } from '.' import { useChatStore } from '../store';
import { useChatStore } from '../store' import { useState } from 'react';
import { useState } from 'react' import { Checkbox } from '@/components/ui/checkbox';
import { Checkbox } from '@/components/ui/checkbox' import { Button } from '@/components/ui/button';
import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils';
import { cn } from '@/lib/utils'
type TokenOption = { type TokenOption = {
value: number value: number;
label: string label: string;
} };
export default function MaxToken() { export default function MaxToken() {
const setSideBar = useChatStore((store) => store.setSideBar) const setSideBar = useChatStore((store) => store.setSideBar);
// 最大回复数选项 // 最大回复数选项
const tokenOptions: TokenOption[] = [ const tokenOptions: TokenOption[] = [
@ -20,33 +19,31 @@ export default function MaxToken() {
{ value: 1000, label: '1000' }, { value: 1000, label: '1000' },
{ value: 1200, label: '1200' }, { value: 1200, label: '1200' },
{ value: 1500, label: '1500' }, { value: 1500, label: '1500' },
] ];
const [selectedToken, setSelectedToken] = useState(800) const [selectedToken, setSelectedToken] = useState(800);
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
const handleConfirm = async () => { const handleConfirm = async () => {
setLoading(true) setLoading(true);
try { try {
// TODO: 调用实际的 API 保存最大回复数设置 // TODO: 调用实际的 API 保存最大回复数设置
// await updateMaxToken({ maxToken: selectedToken }) // 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) { } catch (error) {
console.error(error) console.error(error);
} finally { } finally {
setLoading(false) setLoading(false);
}
} }
};
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<SiderHeader title="Maximum Replies" />
<div className="flex-1"> <div className="flex-1">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{tokenOptions.map((option) => ( {tokenOptions.map((option) => (
@ -64,15 +61,6 @@ export default function MaxToken() {
))} ))}
</div> </div>
</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>
</div> );
)
} }

View File

@ -1,9 +1,8 @@
'use client' 'use client';
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react';
import { SiderHeader } from '.' import { useChatStore } from '../store';
import { useChatStore } from '../store' import { z } from 'zod';
import { z } from 'zod' import dayjs from 'dayjs';
import dayjs from 'dayjs'
import { import {
Form, Form,
FormControl, FormControl,
@ -11,22 +10,22 @@ import {
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage, FormMessage,
} from '@/components/ui/form' } from '@/components/ui/form';
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form';
import { Gender } from '@/types/user' import { Gender } from '@/types/user';
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input';
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select';
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label';
import { calculateAge, getDaysInMonth } from '@/lib/utils' import { calculateAge, getDaysInMonth } from '@/lib/utils';
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button';
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -36,12 +35,12 @@ import {
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog';
const currentYear = dayjs().year() const currentYear = dayjs().year();
const years = Array.from({ length: currentYear - 1950 + 1 }, (_, i) => `${1950 + i}`) const years = Array.from({ length: currentYear - 1950 + 1 }, (_, i) => `${1950 + i}`);
const months = Array.from({ length: 12 }, (_, i) => `${i + 1}`.padStart(2, '0')) 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 monthTexts = Array.from({ length: 12 }, (_, i) => dayjs().month(i).format('MMM'));
const characterFormSchema = z const characterFormSchema = z
.object({ .object({
@ -59,8 +58,8 @@ const characterFormSchema = z
}) })
.refine( .refine(
(data) => { (data) => {
const age = calculateAge(data.year, data.month, data.day) const age = calculateAge(data.year, data.month, data.day);
return age >= 18 return age >= 18;
}, },
{ {
message: 'Character age must be at least 18 years old', message: 'Character age must be at least 18 years old',
@ -71,22 +70,22 @@ const characterFormSchema = z
(data) => { (data) => {
if (data.profile) { if (data.profile) {
if (data.profile.trim().length > 300) { 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', message: 'At least 10 characters',
path: ['profile'], path: ['profile'],
} }
) );
export default function Personal() { export default function Personal() {
const setSideBar = useChatStore((store) => store.setSideBar) const setSideBar = useChatStore((store) => store.setSideBar);
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
const [showConfirmDialog, setShowConfirmDialog] = useState(false) const [showConfirmDialog, setShowConfirmDialog] = useState(false);
// 静态数据,模拟从接口获取的数据 // 静态数据,模拟从接口获取的数据
const chatSettingData = { const chatSettingData = {
@ -94,9 +93,9 @@ export default function Personal() {
sex: Gender.MALE, sex: Gender.MALE,
birthday: dayjs('1995-06-15').valueOf(), birthday: dayjs('1995-06-15').valueOf(),
whoAmI: 'A creative and passionate developer', 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>>({ const form = useForm<z.infer<typeof characterFormSchema>>({
resolver: zodResolver(characterFormSchema), resolver: zodResolver(characterFormSchema),
@ -111,40 +110,40 @@ export default function Personal() {
day: birthday?.date().toString().padStart(2, '0') || undefined, day: birthday?.date().toString().padStart(2, '0') || undefined,
profile: chatSettingData?.whoAmI || '', profile: chatSettingData?.whoAmI || '',
}, },
}) });
// 处理返回的逻辑 // 处理返回的逻辑
const handleGoBack = useCallback(() => { const handleGoBack = useCallback(() => {
if (form.formState.isDirty) { if (form.formState.isDirty) {
setShowConfirmDialog(true) setShowConfirmDialog(true);
} else { } else {
setSideBar('profile') setSideBar('profile');
} }
}, [form.formState.isDirty, setSideBar]) }, [form.formState.isDirty, setSideBar]);
// 确认放弃修改 // 确认放弃修改
const handleConfirmDiscard = useCallback(() => { const handleConfirmDiscard = useCallback(() => {
form.reset() form.reset();
setShowConfirmDialog(false) setShowConfirmDialog(false);
setSideBar('profile') setSideBar('profile');
}, [form, setSideBar]) }, [form, setSideBar]);
async function onSubmit(data: z.infer<typeof characterFormSchema>) { async function onSubmit(data: z.infer<typeof characterFormSchema>) {
if (!form.formState.isDirty) { if (!form.formState.isDirty) {
setSideBar('profile') setSideBar('profile');
return return;
} }
setLoading(true) setLoading(true);
try { try {
// TODO: 这里应该调用实际的 API // TODO: 这里应该调用实际的 API
// 模拟检查昵称是否存在 // 模拟检查昵称是否存在
const isExist = false // await checkNickname({ nickname: data.nickname.trim() }) const isExist = false; // await checkNickname({ nickname: data.nickname.trim() })
if (isExist) { if (isExist) {
form.setError('nickname', { form.setError('nickname', {
message: 'This nickname is already taken', message: 'This nickname is already taken',
}) });
return return;
} }
// TODO: 这里应该调用实际的保存 API // TODO: 这里应该调用实际的保存 API
@ -159,19 +158,19 @@ export default function Personal() {
nickname: data.nickname, nickname: data.nickname,
birthday: new Date(`${data.year}-${data.month}-${data.day}`).getTime(), birthday: new Date(`${data.year}-${data.month}-${data.day}`).getTime(),
whoAmI: data.profile || '', whoAmI: data.profile || '',
}) });
setSideBar('profile') setSideBar('profile');
} catch (error) { } catch (error) {
console.error(error) console.error(error);
} finally { } finally {
setLoading(false) setLoading(false);
} }
} }
const selectedYear = form.watch('year') const selectedYear = form.watch('year');
const selectedMonth = form.watch('month') const selectedMonth = form.watch('month');
const days = selectedYear && selectedMonth ? getDaysInMonth(selectedYear, selectedMonth) : [] const days = selectedYear && selectedMonth ? getDaysInMonth(selectedYear, selectedMonth) : [];
const genderTexts = [ const genderTexts = [
{ {
@ -186,16 +185,14 @@ export default function Personal() {
value: Gender.OTHER, value: Gender.OTHER,
label: 'Other', label: 'Other',
}, },
] ];
const gender = form.watch('sex') const gender = form.watch('sex');
const genderText = genderTexts.find((text) => text.value === gender)?.label const genderText = genderTexts.find((text) => text.value === gender)?.label;
return ( return (
<> <>
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<SiderHeader title="My Chat Persona" />
<Form {...form}> <Form {...form}>
<form className="space-y-8"> <form className="space-y-8">
<FormField <FormField
@ -331,7 +328,7 @@ export default function Personal() {
</form> </form>
</Form> </Form>
<div className="flex gap-3"> {/* <div className="flex gap-3">
<Button variant="tertiary" size="large" className="flex-1" onClick={handleGoBack}> <Button variant="tertiary" size="large" className="flex-1" onClick={handleGoBack}>
Cancel Cancel
</Button> </Button>
@ -344,7 +341,7 @@ export default function Personal() {
> >
Save Save
</Button> </Button>
</div> </div> */}
</div> </div>
{/* 确认放弃修改的对话框 */} {/* 确认放弃修改的对话框 */}
@ -368,5 +365,5 @@ export default function Personal() {
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</> </>
) );
} }

View File

@ -1,24 +1,26 @@
'use client' 'use client';
import { getAge } from '@/lib/utils' import { getAge } from '@/lib/utils';
import { CharacterAvatorAndName } from '../CharacterHeader' import { CharacterAvatorAndName } from '../CharacterHeader';
import { Tag } from '@/components/ui/tag' import { Tag } from '@/components/ui/tag';
import Image from 'next/image' import Image from 'next/image';
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils';
import { useChatStore } from '../store' import { useChatStore } from '../store';
import { IconButton } from '@/components/ui/button' import { Button, IconButton } from '@/components/ui/button';
import React from 'react' import React from 'react';
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch';
import { useCharacter } from '@/hooks/services/character';
import { useParams } from 'next/navigation';
import { ActiveTabType } from './index';
const genderMap = { const genderMap = {
0: '/icons/male.svg', 0: '/icons/male.svg',
1: '/icons/female.svg', 1: '/icons/female.svg',
2: '/icons/gender-neutral.svg', 2: '/icons/gender-neutral.svg',
} };
const ChatProfilePersona = React.memo(() => { const ChatProfilePersona = React.memo(({ onActiveTab }: ProfileProps) => {
const setSideBar = useChatStore((store) => store.setSideBar) const whoAmI = 'whoAmI';
const whoAmI = 'whoAmI'
return ( return (
<div className="flex w-full flex-col gap-3"> <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-title-s">My Chat Persona</div>
<div <div
className="txt-label-m text-primary-variant-normal cursor-pointer" className="txt-label-m text-primary-variant-normal cursor-pointer"
onClick={() => setSideBar('personal')} onClick={() => onActiveTab('personal')}
> >
Edit Edit
</div> </div>
</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="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-label-l text-txt-secondary-normal">Nickname</div>
<div className="txt-body-l text-txt-primary-normal flex-1 truncate text-right">{''}</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> </div>
</div> </div>
) );
}) });
type SettingItem = { type SettingItem = {
onClick: () => void onClick: () => void;
label: string label: string;
value?: React.ReactNode value?: React.ReactNode;
} };
export default function Profile() { type ProfileProps = {
const setSideBar = useChatStore((store) => store.setSideBar) 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[][] = [ const chatSettingItems: SettingItem[][] = [
[ [
{ {
onClick: () => setSideBar('model'), onClick: () => onActiveTab('model'),
label: 'Chat Model', label: 'Chat Model',
value: 'Role-Playing', value: 'Role-Playing',
}, },
@ -87,34 +104,34 @@ export default function Profile() {
], ],
[ [
{ {
onClick: () => setSideBar('max_token'), onClick: () => onActiveTab('max_token'),
label: 'Maximum Replies', label: 'Maximum Replies',
value: '1200', value: '1200',
}, },
], ],
[ [
{ {
onClick: () => setSideBar('font'), onClick: () => onActiveTab('font'),
label: 'Font', label: 'Font',
value: '17px', value: '17px',
}, },
{ {
onClick: () => setSideBar('background'), onClick: () => onActiveTab('background'),
label: 'Chat Background', label: 'Chat Background',
value: '17px', value: '17px',
}, },
], ],
] ];
const voiceSettingItems: SettingItem[][] = [ const voiceSettingItems: SettingItem[][] = [
[ [
{ {
onClick: () => setSideBar('voice_actor'), onClick: () => onActiveTab('voice_actor'),
label: 'Voice Artist', label: 'Voice Artist',
value: 'Default', value: 'Default',
}, },
], ],
] ];
const bundleRender = (title: string, items: SettingItem[][]) => { const bundleRender = (title: string, items: SettingItem[][]) => {
return ( return (
@ -122,7 +139,7 @@ export default function Profile() {
<div className="txt-title-s w-full text-left">{title}</div> <div className="txt-title-s w-full text-left">{title}</div>
<div className="flex w-full flex-col items-start justify-start gap-4"> <div className="flex w-full flex-col items-start justify-start gap-4">
{items.map((list, index) => ( {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) => ( {list.map((item, itemIndex) => (
<div <div
key={`item_${itemIndex}`} key={`item_${itemIndex}`}
@ -149,12 +166,21 @@ export default function Profile() {
))} ))}
</div> </div>
</div> </div>
) );
} };
return ( return (
<div className="flex flex-col gap-4"> <div className="flex h-full flex-col">
<CharacterAvatorAndName /> {/* <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 */} {/* Tags */}
<div className="flex w-full flex-col items-start justify-start gap-4"> <div className="flex w-full flex-col items-start justify-start gap-4">
@ -173,11 +199,22 @@ export default function Profile() {
</div> </div>
</div> </div>
<ChatProfilePersona /> <ChatProfilePersona onActiveTab={onActiveTab} />
{bundleRender('Preference', preferenceItems)}
{bundleRender('Chat Setting', chatSettingItems)} {bundleRender('Chat Setting', chatSettingItems)}
{bundleRender('Voice Setting', voiceSettingItems)} {bundleRender('Voice Setting', voiceSettingItems)}
</div> </div>
) <div className="flex flex-col gap-2 mt-2">
<Button variant="tertiary" className="w-full">
Detele
</Button>
<Button variant="primary" className="w-full">
+ New Chat
</Button>
</div>
</div>
);
} }

View File

@ -1,24 +1,23 @@
'use client' 'use client';
import { SiderHeader } from '.' import { useChatStore } from '../store';
import { useChatStore } from '../store' import { useState } from 'react';
import { useState } from 'react' import { Checkbox } from '@/components/ui/checkbox';
import { Checkbox } from '@/components/ui/checkbox' import { Button } from '@/components/ui/button';
import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils';
import { cn } from '@/lib/utils' import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
type VoiceGender = 'all' | 'male' | 'female' type VoiceGender = 'all' | 'male' | 'female';
type VoiceActorItem = { type VoiceActorItem = {
id: number id: number;
name: string name: string;
description: string description: string;
avatarUrl: string avatarUrl: string;
gender: 'male' | 'female' gender: 'male' | 'female';
} };
export default function VoiceActor() { export default function VoiceActor() {
const setSideBar = useChatStore((store) => store.setSideBar) const setSideBar = useChatStore((store) => store.setSideBar);
// 语音演员列表(静态数据) // 语音演员列表(静态数据)
const voiceActors: VoiceActorItem[] = [ const voiceActors: VoiceActorItem[] = [
@ -71,40 +70,38 @@ export default function VoiceActor() {
avatarUrl: 'https://i.pravatar.cc/150?img=7', avatarUrl: 'https://i.pravatar.cc/150?img=7',
gender: 'male', gender: 'male',
}, },
] ];
const [selectedGender, setSelectedGender] = useState<VoiceGender>('all') const [selectedGender, setSelectedGender] = useState<VoiceGender>('all');
const [selectedActorId, setSelectedActorId] = useState(1) const [selectedActorId, setSelectedActorId] = useState(1);
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
// 根据性别过滤演员列表 // 根据性别过滤演员列表
const filteredActors = voiceActors.filter((actor) => { const filteredActors = voiceActors.filter((actor) => {
if (selectedGender === 'all') return true if (selectedGender === 'all') return true;
return actor.gender === selectedGender return actor.gender === selectedGender;
}) });
const handleConfirm = async () => { const handleConfirm = async () => {
setLoading(true) setLoading(true);
try { try {
// TODO: 调用实际的 API 保存语音演员设置 // TODO: 调用实际的 API 保存语音演员设置
// await updateVoiceActor({ voiceActorId: selectedActorId }) // 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) { } catch (error) {
console.error(error) console.error(error);
} finally { } finally {
setLoading(false) setLoading(false);
}
} }
};
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<SiderHeader title="Voice Artist" />
{/* Gender Tabs */} {/* Gender Tabs */}
<div className="mb-6 flex gap-6"> <div className="mb-6 flex gap-6">
{[ {[
@ -157,14 +154,14 @@ export default function VoiceActor() {
</div> </div>
{/* Footer Buttons */} {/* 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')}> <Button variant="tertiary" size="large" onClick={() => setSideBar('profile')}>
Cancel Cancel
</Button> </Button>
<Button size="large" variant="primary" loading={loading} onClick={handleConfirm}> <Button size="large" variant="primary" loading={loading} onClick={handleConfirm}>
Select Select
</Button> </Button>
</div> */}
</div> </div>
</div> );
)
} }

View File

@ -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>
);
}

View File

@ -1,10 +1,10 @@
'use client' 'use client';
export default function UserMessage({ data }: { data: any }) { export default function UserMessage({ data }: { data: any }) {
return ( return (
<div className="mb-8 flex justify-end"> <div className="mb-8 flex justify-end">
<div className="bg-primary-normal/20 inline-block max-w-[90%] rounded-lg p-4 backdrop-blur-2xl"> <div className="bg-primary-normal/20 inline-block max-w-[90%] rounded-lg p-4 backdrop-blur-2xl">
{data.text} {data.content}
</div> </div>
</div> </div>
) );
} }

View File

@ -3,12 +3,22 @@
import { IconButton } from '@/components/ui/button'; import { IconButton } from '@/components/ui/button';
import Input from './Input'; import Input from './Input';
import MessageList from './MessageList'; import MessageList from './MessageList';
import { useChatStore } from './store'; import SettingDialog from './Sider';
import Sider from './Sider'; import { useStreamChatStore } from '@/stores/stream-chat';
import { useParams } from 'next/navigation';
import { useEffect, useState } from 'react';
export default function ChatPage() { export default function ChatPage() {
const isSidebarOpen = useChatStore((store) => store.isSidebarOpen); const { id } = useParams();
const setIsSidebarOpen = useChatStore((store) => store.setIsSidebarOpen); 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 ( return (
<div className="flex h-full"> <div className="flex h-full">
@ -18,7 +28,7 @@ export default function ChatPage() {
<Input /> <Input />
</div> </div>
<IconButton <IconButton
onClick={() => setIsSidebarOpen(!isSidebarOpen)} onClick={() => setSettingOpen(!settingOpen)}
className="absolute top-1 right-1" className="absolute top-1 right-1"
variant="ghost" variant="ghost"
size="small" size="small"
@ -26,8 +36,7 @@ export default function ChatPage() {
<i className="iconfont-v2 iconv2-zhedie" /> <i className="iconfont-v2 iconv2-zhedie" />
</IconButton> </IconButton>
</div> </div>
<SettingDialog open={settingOpen} onOpenChange={setSettingOpen} />
{isSidebarOpen && <Sider />}
</div> </div>
); );
} }

View File

@ -1,4 +1,4 @@
import { create } from 'zustand' import { create } from 'zustand';
type SideBar = type SideBar =
| 'profile' | 'profile'
@ -9,11 +9,12 @@ type SideBar =
| 'max_token' | 'max_token'
| 'background' | 'background'
| 'model' | 'model'
| 'language';
interface ChatStore { interface ChatStore {
isSidebarOpen: boolean isSidebarOpen: boolean;
setIsSidebarOpen: (isSidebarOpen: boolean) => void setIsSidebarOpen: (isSidebarOpen: boolean) => void;
sideBar: SideBar sideBar: SideBar;
setSideBar: (sideBar: SideBar) => void setSideBar: (sideBar: SideBar) => void;
} }
export const useChatStore = create<ChatStore>((set) => ({ export const useChatStore = create<ChatStore>((set) => ({
@ -21,4 +22,4 @@ export const useChatStore = create<ChatStore>((set) => ({
setIsSidebarOpen: (isSidebarOpen: boolean) => set({ isSidebarOpen }), setIsSidebarOpen: (isSidebarOpen: boolean) => set({ isSidebarOpen }),
sideBar: 'profile', sideBar: 'profile',
setSideBar: (sideBar: SideBar) => set({ sideBar }), setSideBar: (sideBar: SideBar) => set({ sideBar }),
})) }));

View File

@ -1,52 +1,52 @@
'use client' 'use client';
import { useGetSevenDaysSignList, useSignIn } from '@/hooks/useHome' // import { useGetSevenDaysSignList, useSignIn } from '@/hooks/useHome'
import { SignInListOutput } from '@/services/home/types' // import { SignInListOutput } from '@/services/home/types'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query';
import { homeKeys } from '@/lib/query-keys' import { homeKeys } from '@/lib/query-keys';
import { CheckInCard } from './CheckInCard' import { CheckInCard } from './CheckInCard';
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react';
import { toast } from 'sonner' import { toast } from 'sonner';
export function CheckInGrid() { export function CheckInGrid() {
const queryClient = useQueryClient() const queryClient = useQueryClient();
const { data: signListData, isLoading } = useGetSevenDaysSignList() // const { data: signListData, isLoading } = useGetSevenDaysSignList()
const signInMutation = useSignIn() // const signInMutation = useSignIn()
const hasSignRef = useRef(false) const hasSignRef = useRef(false);
useEffect(() => { useEffect(() => {
const initializeCheckIn = async () => { const initializeCheckIn = async () => {
if (hasSignRef.current) return if (hasSignRef.current) return;
hasSignRef.current = true hasSignRef.current = true;
try { try {
// 先进行签到 // 先进行签到
const resp = await signInMutation.mutateAsync() const resp = await signInMutation.mutateAsync();
if (resp) { if (resp) {
toast.success('Check-in Successful') toast.success('Check-in Successful');
} }
// 签到成功后再获取列表数据 // 签到成功后再获取列表数据
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
queryKey: homeKeys.getSevenDaysSignList(), queryKey: homeKeys.getSevenDaysSignList(),
}) });
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
queryKey: ['wallet'], queryKey: ['wallet'],
}) });
} catch (error) { } catch (error) {
console.error('初始化签到失败:', error) console.error('初始化签到失败:', error);
// 即使签到失败,也要获取列表数据显示界面 // 即使签到失败,也要获取列表数据显示界面
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: homeKeys.getSevenDaysSignList(), queryKey: homeKeys.getSevenDaysSignList(),
}) });
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ['wallet'], queryKey: ['wallet'],
}) });
}
} }
};
if (signListData) { if (signListData) {
initializeCheckIn() initializeCheckIn();
} }
}, [signListData]) }, [signListData]);
if (isLoading) { if (isLoading) {
return ( return (
@ -58,38 +58,38 @@ export function CheckInGrid() {
/> />
))} ))}
</div> </div>
) );
} }
const signList = signListData?.list || [] const signList = signListData?.list || [];
const today = new Date() const today = new Date();
const todayStr = today.toISOString().split('T')[0] // yyyy-MM-dd 格式 const todayStr = today.toISOString().split('T')[0]; // yyyy-MM-dd 格式
// 确保有7天的数据如果不足则补充默认数据 // 确保有7天的数据如果不足则补充默认数据
const fullSignList: (SignInListOutput & { day: number })[] = Array.from( const fullSignList: (SignInListOutput & { day: number })[] = Array.from(
{ length: 7 }, { length: 7 },
(_, index) => { (_, index) => {
const day = index + 1 const day = index + 1;
const existingData = signList.find((item, itemIndex) => itemIndex === index) const existingData = signList.find((item, itemIndex) => itemIndex === index);
return { return {
day, day,
coinNum: existingData?.coinNum || [5, 10, 15, 20, 30, 50, 80][index], coinNum: existingData?.coinNum || [5, 10, 15, 20, 30, 50, 80][index],
dayStr: existingData?.dayStr || '', dayStr: existingData?.dayStr || '',
signIn: existingData?.signIn || false, signIn: existingData?.signIn || false,
};
} }
} );
)
// 找到今天应该签到的是第几天 // 找到今天应该签到的是第几天
const todayIndex = fullSignList.findIndex((item) => { const todayIndex = fullSignList.findIndex((item) => {
if (!item.dayStr) return false if (!item.dayStr) return false;
return item.dayStr === todayStr return item.dayStr === todayStr;
}) });
// 如果没有找到今天的数据,假设是按顺序签到,找到第一个未签到的 // 如果没有找到今天的数据,假设是按顺序签到,找到第一个未签到的
const currentDayIndex = const currentDayIndex =
todayIndex >= 0 ? todayIndex : fullSignList.findIndex((item) => !item.signIn) todayIndex >= 0 ? todayIndex : fullSignList.findIndex((item) => !item.signIn);
return ( return (
<div className="grid grid-cols-4 grid-rows-2 gap-4"> <div className="grid grid-cols-4 grid-rows-2 gap-4">
@ -105,7 +105,7 @@ export function CheckInGrid() {
loading={signInMutation.isPending} loading={signInMutation.isPending}
className="col-span-1 row-span-2" className="col-span-1 row-span-2"
/> />
) );
} }
if (index < 3) { if (index < 3) {
return ( return (
@ -118,7 +118,7 @@ export function CheckInGrid() {
loading={signInMutation.isPending} loading={signInMutation.isPending}
className="col-span-1 row-span-1" className="col-span-1 row-span-1"
/> />
) );
} else { } else {
return ( return (
<CheckInCard <CheckInCard
@ -130,11 +130,11 @@ export function CheckInGrid() {
loading={signInMutation.isPending} loading={signInMutation.isPending}
className="col-span-1 row-span-1" className="col-span-1 row-span-1"
/> />
) );
} }
})} })}
</div> </div>
) );
} }
export default CheckInGrid export default CheckInGrid;

View File

@ -6,13 +6,35 @@ import { Chip } from '@/components/ui/chip';
import { useHomeStore } from '../store'; import { useHomeStore } from '../store';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { fetchCharacterTags } from '@/services/editor'; import { fetchCharacterTags } from '@/services/editor';
import { useRef } from 'react';
const Filter = () => { const Filter = () => {
const tab = useHomeStore((state) => state.tab); const tab = useHomeStore((state) => state.tab);
const setTab = useHomeStore((state) => state.setTab); const setTab = useHomeStore((state) => state.setTab);
const ref = useRef<HTMLDivElement>(null);
const selectedTags = useHomeStore((state) => state.selectedTags); const selectedTags = useHomeStore((state) => state.selectedTags);
const setSelectedTags = useHomeStore((state) => state.setSelectedTags); 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({ const { data: tags = [] } = useQuery({
queryKey: ['tags', tab], queryKey: ['tags', tab],
queryFn: async () => { queryFn: async () => {
@ -39,8 +61,16 @@ const Filter = () => {
}, },
] as const; ] as const;
const handleSelect = (tagId: string) => {
if (selectedTags.includes(tagId)) {
setSelectedTags(selectedTags.filter((id) => id !== tagId));
} else {
setSelectedTags([...selectedTags, tagId]);
}
};
return ( return (
<div className="sticky top-0 z-10"> <div ref={ref}>
<div className="flex mb-6 gap-12"> <div className="flex mb-6 gap-12">
{tabs.map((item) => { {tabs.map((item) => {
const active = tab === item.value; const active = tab === item.value;
@ -71,7 +101,7 @@ const Filter = () => {
size="small" size="small"
className="px-4" className="px-4"
state={selectedTags.includes(tag.id) ? 'active' : 'inactive'} state={selectedTags.includes(tag.id) ? 'active' : 'inactive'}
onClick={() => setSelectedTags([tag.id])} onClick={() => handleSelect(tag.id)}
> >
# {tag.name} # {tag.name}
</Chip> </Chip>

View File

@ -8,16 +8,27 @@ import { useMedia } from '@/hooks/tools';
const Header = React.memo(() => { const Header = React.memo(() => {
const response = useMedia(); const response = useMedia();
return ( return (
<Link href="/crushcoin">
<div <div
className="flex items-center justify-center" className="h-50 rounded-4xl px-6 mb-12 flex items-center justify-between"
style={{ style={{
backgroundImage: 'url(/images/home/bg-star.png)', background:
backgroundSize: 'contain', 'linear-gradient(90deg, rgba(255, 255, 255, 0.1) 0%, rgba(202, 153, 255, 0.2) 100%)',
backgroundPosition: 'center',
}} }}
> >
<div className="flex-1 pt-12 pb-8 text-center"> <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 <Image
src="/images/home/left-star.png" src="/images/home/left-star.png"
className="h-12 w-12 object-cover" className="h-12 w-12 object-cover"
@ -25,9 +36,10 @@ const Header = React.memo(() => {
width={48} width={48}
height={48} height={48}
/> />
<h1 className="txt-headline-m mt-3">Spicyxx.ai</h1> </div>
<h1 <div className="flex gap-5 items-center">
className="txt-headline-m mt-3" <span
className="txt-headline-s bg-clip-text text-transparent"
style={{ style={{
background: background:
'linear-gradient(109.2deg, rgba(211, 123, 235, 1) 37.08%, rgba(147, 123, 235, 1) 128.91%)', 'linear-gradient(109.2deg, rgba(211, 123, 235, 1) 37.08%, rgba(147, 123, 235, 1) 128.91%)',
@ -36,33 +48,22 @@ const Header = React.memo(() => {
backgroundClip: 'text', backgroundClip: 'text',
}} }}
> >
A Different World Daily Free crush coinsh
</h1> </span>
<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" /> <IconButton iconfont="icon-arrow-right-border" size="small" variant="primary" />
</div>
</div>
</div>
{response?.lg && (
<Image <Image
src="/images/home/icon-crush-free.png" src="/images/home/banner-header.png"
className="absolute -top-5 left-1" alt="banner-header"
alt="icon-crush-free" width={250}
width={60} height={250}
height={60}
/> />
</div>
</Link>
</div>
{response?.sm && (
<Image src="/images/home/banner-header.png" alt="banner-header" width={400} height={400} />
)} )}
</div> </div>
</Link>
); );
}); });

View File

@ -1,7 +1,7 @@
'use client' 'use client';
const Story = () => { const Story = () => {
return <div>Story</div> return <div>Story</div>;
} };
export default Story export default Story;

View File

@ -13,16 +13,16 @@ const HomePage = () => {
const response = useMedia(); const response = useMedia();
return ( return (
<div className="h-full"> <>
<div className="text-txt-primary-normal relative px-4 sm:px-16 pb-32"> <div className="h-full text-txt-primary-normal relative px-4 sm:px-16">
<div className="mx-auto max-w-[1136px]"> <div className="mx-auto max-w-[1136px] pb-32">
<Header /> <Header />
<Filter /> <Filter />
{tab === 'story' ? <Story /> : <Character />} {tab === 'story' ? <Story /> : <Character />}
</div> </div>
</div> </div>
{response?.sm && <HomePageFooter />} {response?.sm && <HomePageFooter />}
</div> </>
); );
}; };

View File

@ -17,6 +17,25 @@ body {
background: #888; 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 { @utility text-gradient {
background: linear-gradient( background: linear-gradient(
135deg, 135deg,

View File

@ -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 })

View File

@ -1,28 +1,28 @@
'use client' 'use client';
import * as React from 'react' import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog' import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button';
// 关闭图标组件 // 关闭图标组件
const CloseIcon = ({ className }: { className?: string }) => ( const CloseIcon = ({ className }: { className?: string }) => (
<i className={cn('iconfont icon-close', className)} /> <i className={cn('iconfont icon-close', className)} />
) );
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) { 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({ function AlertDialogTrigger({
...props ...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) { }: 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>) { 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({ function AlertDialogOverlay({
@ -38,7 +38,7 @@ function AlertDialogOverlay({
)} )}
{...props} {...props}
/> />
) );
} }
function AlertDialogContent({ function AlertDialogContent({
@ -47,8 +47,8 @@ function AlertDialogContent({
showOverlay = true, showOverlay = true,
...props ...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & { }: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
showCloseButton?: boolean showCloseButton?: boolean;
showOverlay?: boolean showOverlay?: boolean;
}) { }) {
return ( return (
<AlertDialogPortal> <AlertDialogPortal>
@ -71,7 +71,7 @@ function AlertDialogContent({
)} )}
</AlertDialogPrimitive.Content> </AlertDialogPrimitive.Content>
</AlertDialogPortal> </AlertDialogPortal>
) );
} }
function AlertDialogHeader({ className, ...props }: React.ComponentProps<'div'>) { 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)} className={cn('mb-4 flex flex-col gap-4 text-left', className)}
{...props} {...props}
/> />
) );
} }
function AlertDialogFooter({ function AlertDialogFooter({
@ -89,7 +89,7 @@ function AlertDialogFooter({
variant = 'horizontal', variant = 'horizontal',
...props ...props
}: React.ComponentProps<'div'> & { }: React.ComponentProps<'div'> & {
variant?: 'horizontal' | 'vertical' variant?: 'horizontal' | 'vertical';
}) { }) {
return ( return (
<div <div
@ -101,7 +101,7 @@ function AlertDialogFooter({
)} )}
{...props} {...props}
/> />
) );
} }
function AlertDialogTitle({ function AlertDialogTitle({
@ -109,14 +109,14 @@ function AlertDialogTitle({
...props ...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) { }: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return ( return (
<div className="flex items-start justify-between pr-10"> <div className="flex items-start justify-between">
<AlertDialogPrimitive.Title <AlertDialogPrimitive.Title
data-slot="alert-dialog-title" data-slot="alert-dialog-title"
className={cn('txt-title-l text-txt-primary-normal w-full break-words', className)} className={cn('txt-title-l text-txt-primary-normal w-full break-words', className)}
{...props} {...props}
/> />
</div> </div>
) );
} }
function AlertDialogDescription({ function AlertDialogDescription({
@ -129,7 +129,7 @@ function AlertDialogDescription({
className={cn('txt-body-l text-txt-primary-normal w-full break-words', className)} className={cn('txt-body-l text-txt-primary-normal w-full break-words', className)}
{...props} {...props}
/> />
) );
} }
function AlertDialogIcon({ className, children, ...props }: React.ComponentProps<'div'>) { 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 className="flex h-[148px] w-[148px] items-center justify-center">{children}</div>
</div> </div>
) );
} }
function AlertDialogAction({ function AlertDialogAction({
@ -150,14 +150,14 @@ function AlertDialogAction({
loading, loading,
...props ...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> & { }: React.ComponentProps<typeof AlertDialogPrimitive.Action> & {
variant?: 'primary' | 'secondary' | 'tertiary' | 'destructive' variant?: 'primary' | 'secondary' | 'tertiary' | 'destructive';
loading?: boolean loading?: boolean;
}) { }) {
return ( return (
<AlertDialogPrimitive.Action asChild> <AlertDialogPrimitive.Action asChild>
<Button variant={variant} loading={loading} className={cn(className)} {...props} /> <Button variant={variant} loading={loading} className={cn(className)} {...props} />
</AlertDialogPrimitive.Action> </AlertDialogPrimitive.Action>
) );
} }
function AlertDialogCancel({ function AlertDialogCancel({
@ -166,14 +166,14 @@ function AlertDialogCancel({
loading, loading,
...props ...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> & { }: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> & {
variant?: 'primary' | 'secondary' | 'tertiary' | 'destructive' variant?: 'primary' | 'secondary' | 'tertiary' | 'destructive';
loading?: boolean loading?: boolean;
}) { }) {
return ( return (
<AlertDialogPrimitive.Cancel asChild> <AlertDialogPrimitive.Cancel asChild>
<Button variant={variant} loading={loading} className={cn(className)} {...props} /> <Button variant={variant} loading={loading} className={cn(className)} {...props} />
</AlertDialogPrimitive.Cancel> </AlertDialogPrimitive.Cancel>
) );
} }
export { export {
@ -189,4 +189,4 @@ export {
AlertDialogIcon, AlertDialogIcon,
AlertDialogAction, AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
} };

View File

@ -205,7 +205,7 @@ export function InfiniteScrollList<T>({
</div> </div>
{/* 加载更多触发器 - 只在没有错误时显示 */} {/* 加载更多触发器 - 只在没有错误时显示 */}
{hasNextPage && !hasError && ( {hasNextPage && !hasError && (
<div ref={loadMoreRef} className="mt-8 flex justify-center"> <div ref={loadMoreRef} className="h-8 flex justify-center">
{LoadingMore ? ( {LoadingMore ? (
<LoadingMore /> <LoadingMore />
) : ( ) : (

View File

@ -1,12 +1,12 @@
'use client' 'use client';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso' import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { useRef, useState } from 'react' import { useRef, useState } from 'react';
type VirtualListProps<T = any> = { type VirtualListProps<T = any> = {
data?: { type: string; data?: T }[] data?: { type: string; data?: T }[];
itemContent?: (index: number, item: { type: string; data: T }) => React.ReactNode itemContent?: (index: number, item: { type: string; data: T }) => React.ReactNode;
virtuosoProps?: React.ComponentProps<typeof Virtuoso> virtuosoProps?: React.ComponentProps<typeof Virtuoso>;
} & React.HTMLAttributes<HTMLDivElement> } & React.HTMLAttributes<HTMLDivElement>;
export default function VirtualList<T = any>(props: VirtualListProps<T>) { export default function VirtualList<T = any>(props: VirtualListProps<T>) {
const { const {
@ -14,17 +14,17 @@ export default function VirtualList<T = any>(props: VirtualListProps<T>) {
itemContent = (index) => <div>{index}</div>, itemContent = (index) => <div>{index}</div>,
virtuosoProps, virtuosoProps,
...restProps ...restProps
} = props } = props;
const virtuosoRef = useRef<VirtuosoHandle>(null) const virtuosoRef = useRef<VirtuosoHandle>(null);
const [showScrollButton, setShowScrollButton] = useState(false) // const [showScrollButton, setShowScrollButton] = useState(false);
// 滚动到最新消息 // // 滚动到最新消息
const scrollToBottom = () => { // const scrollToBottom = () => {
virtuosoRef.current?.scrollToIndex({ // virtuosoRef.current?.scrollToIndex({
index: data.length - 1, // index: data.length - 1,
behavior: 'smooth', // behavior: 'smooth',
}) // });
} // };
return ( return (
<div {...restProps}> <div {...restProps}>
@ -36,22 +36,22 @@ export default function VirtualList<T = any>(props: VirtualListProps<T>) {
data={data} data={data}
followOutput="smooth" followOutput="smooth"
initialTopMostItemIndex={data.length - 1} initialTopMostItemIndex={data.length - 1}
atBottomStateChange={(atBottom) => { // atBottomStateChange={(atBottom) => {
// 当不在底部时显示按钮,在底部时隐藏 // // 当不在底部时显示按钮,在底部时隐藏
setShowScrollButton(!atBottom) // setShowScrollButton(!atBottom);
}} // }}
itemContent={(index, item) => itemContent(index, item as any)} itemContent={(index, item) => itemContent(index, item as any)}
/> />
{/* 回到底部按钮 */} {/* 回到底部按钮 */}
{showScrollButton && ( {/* {showScrollButton && (
<div <div
onClick={scrollToBottom} 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" 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 scroll
</div> </div>
)} )} */}
</div> </div>
) );
} }

View File

@ -92,16 +92,16 @@
--glo-color-purple-70: #6e0098; --glo-color-purple-70: #6e0098;
--glo-color-purple-80: #520073; --glo-color-purple-80: #520073;
--glo-color-purple-90: #36004d; --glo-color-purple-90: #36004d;
--glo-color-magenta-0: #fbdeff; --glo-color-magenta-0: #e5eaff;
--glo-color-magenta-10: #fdb6d3; --glo-color-magenta-10: #cbd5ff;
--glo-color-magenta-20: #f98dbc; --glo-color-magenta-20: #b1c1ff;
--glo-color-magenta-30: #f264a4; --glo-color-magenta-30: #96acff;
--glo-color-magenta-40: rgb(107, 134, 255); --glo-color-magenta-40: #6b86ff;
--glo-color-magenta-50: #d21f77; --glo-color-magenta-50: #4861f5;
--glo-color-magenta-60: #b80761; --glo-color-magenta-60: #3348dd;
--glo-color-magenta-70: #980050; --glo-color-magenta-70: #2536bf;
--glo-color-magenta-80: #73003e; --glo-color-magenta-80: #1a2898;
--glo-color-magenta-90: #4d002a; --glo-color-magenta-90: #121d72;
--glo-color-red-0: #ffdede; --glo-color-red-0: #ffdede;
--glo-color-red-10: #ffbcbc; --glo-color-red-10: #ffbcbc;
--glo-color-red-20: #ff9696; --glo-color-red-20: #ff9696;

View File

@ -20,7 +20,6 @@ export function useLogin() {
return useMutation({ return useMutation({
mutationFn: (data: LoginRequest): Promise<LoginResponse> => authService.login(data), mutationFn: (data: LoginRequest): Promise<LoginResponse> => authService.login(data),
onSuccess: (response: LoginResponse) => { onSuccess: (response: LoginResponse) => {
console.log('useLogin onSuccess save token', response.token);
// 保存token到cookie // 保存token到cookie
tokenManager.setToken(response.token); tokenManager.setToken(response.token);
// 刷新当前用户信息 // 刷新当前用户信息

View File

@ -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,
});
}

View File

@ -72,6 +72,7 @@ export function useInfiniteScroll({
observerRef.current = new IntersectionObserver((entries) => { observerRef.current = new IntersectionObserver((entries) => {
const [entry] = entries; const [entry] = entries;
if (entry.isIntersecting) { if (entry.isIntersecting) {
loadMore(); loadMore();
} }
@ -87,7 +88,7 @@ export function useInfiniteScroll({
observerRef.current.unobserve(currentRef); observerRef.current.unobserve(currentRef);
} }
}; };
}, [enabled, threshold, loadMore]); }, [enabled, threshold, loadMore, !!loadMoreRef.current]);
// 清理observer // 清理observer
useEffect(() => { useEffect(() => {

View File

@ -11,20 +11,23 @@ import CreateReachedLimitDialog from '../components/features/create-reached-limi
import { useMedia } from '@/hooks/tools'; import { useMedia } from '@/hooks/tools';
import BottomBar from './BottomBar'; import BottomBar from './BottomBar';
import { useStreamChatStore } from '@/stores/stream-chat'; import { useStreamChatStore } from '@/stores/stream-chat';
import { useLogin } from '@/hooks/auth'; import { useCurrentUser } from '@/hooks/auth';
interface ConditionalLayoutProps { interface ConditionalLayoutProps {
children: React.ReactNode; children: React.ReactNode;
} }
const useInitChat = () => { const useInitChat = () => {
const { data } = useLogin(); const { data } = useCurrentUser();
const connect = useStreamChatStore((state) => state.connect); const connect = useStreamChatStore((state) => state.connect);
const queryChannels = useStreamChatStore((state) => state.queryChannels); const queryChannels = useStreamChatStore((state) => state.queryChannels);
const initChat = async () => { const initChat = async () => {
if (data) { if (data) {
await connect(data); await connect({
userId: data.userId + '',
userName: data.nickname,
});
await queryChannels({}); await queryChannels({});
} }
}; };
@ -58,7 +61,7 @@ export default function ConditionalLayout({ children }: ConditionalLayoutProps)
{response?.sm && <Sidebar />} {response?.sm && <Sidebar />}
<div ref={mainContentRef} className={cn('relative flex flex-1 flex-col')}> <div ref={mainContentRef} className={cn('relative flex flex-1 flex-col')}>
<Topbar /> <Topbar />
<main id="main-content" className="overflow-auto flex-1 pt-16"> <main id="main-content" className="overflow-auto flex-1">
{children} {children}
</main> </main>
{response && !response.sm && <BottomBar />} {response && !response.sm && <BottomBar />}

View File

@ -3,7 +3,7 @@ import { useEffect } from 'react';
import { MenuItem } from '@/types/global'; import { MenuItem } from '@/types/global';
import Image from 'next/image'; import Image from 'next/image';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
// import ChatSidebar from './components/ChatSidebar' import ChatSidebar from './components/ChatSidebar';
import { Badge } from '../components/ui/badge'; import { Badge } from '../components/ui/badge';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
@ -98,8 +98,11 @@ function Sidebar() {
); );
})} })}
</div> </div>
{/* 分割线 */}
{/* <ChatSidebar /> */} <div className="mx-6 my-4">
<div className="bg-outline-normal h-px" />
</div>
<ChatSidebar />
<Notice actualIsExpanded={isSidebarExpanded} /> <Notice actualIsExpanded={isSidebarExpanded} />
</div> </div>
</aside> </aside>

View File

@ -10,6 +10,8 @@ import { usePathname, useSearchParams, useRouter } from 'next/navigation';
import { useMedia } from '@/hooks/tools'; import { useMedia } from '@/hooks/tools';
import { items } from './BottomBar'; import { items } from './BottomBar';
const mobileHidenMenus = ['/profile/edit', '/profile/account'];
function Topbar() { function Topbar() {
const [isBlur, setIsBlur] = useState(false); const [isBlur, setIsBlur] = useState(false);
const { data: user } = useCurrentUser(); const { data: user } = useCurrentUser();
@ -92,14 +94,13 @@ function Topbar() {
); );
}; };
if (response && !response.sm && mobileHidenMenus.some((item) => item === pathname)) return null;
return ( return (
<header <header
className={cn( className={cn('flex h-16 w-full items-center justify-between px-4 sm:px-8 transition-all', {
'absolute z-40 flex h-16 w-full items-center justify-between px-4 sm:px-8 transition-all',
{
'backdrop-blur-[10px]': isBlur, 'backdrop-blur-[10px]': isBlur,
} })}
)}
> >
{isBlur && <div className="bg-background-default absolute inset-0 opacity-85" />} {isBlur && <div className="bg-background-default absolute inset-0 opacity-85" />}
<div className="relative inset-0 flex w-full items-center justify-between"> <div className="relative inset-0 flex w-full items-center justify-between">

View File

@ -1,228 +1,54 @@
'use client' 'use client';
import { useState, useMemo, useEffect } from 'react' import { useState, useMemo } from 'react';
import { useAtomValue } from 'jotai' import ChatSidebarItem from './ChatSidebarItem';
import { conversationListAtom } from '@/atoms/im' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { useInfiniteQuery } from '@tanstack/react-query' import Empty from '@/components/ui/empty';
import { imService } from '@/services/im' import { useStreamChatStore } from '@/stores/stream-chat';
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'
interface ChatSearchResultsProps { interface ChatSearchResultsProps {
searchKeyword: string searchKeyword: string;
isExpanded: boolean isExpanded: boolean;
onCloseSearch: () => void
} }
// 高亮搜索关键词的组件 const ChatSearchResults = ({ searchKeyword, isExpanded }: ChatSearchResultsProps) => {
const HighlightText = ({ text, keyword }: { text: string; keyword: string }) => { const channels = useStreamChatStore((state) => state.channels);
if (!keyword) return <span>{text}</span> const [activeTab, setActiveTab] = useState('message');
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])
// 筛选Message搜索结果 - 对用户名或最后一条消息内容进行搜索 // 筛选Message搜索结果 - 对用户名或最后一条消息内容进行搜索
const messageResults = useMemo(() => { const { nameRes, messageRes } = useMemo(() => {
if (!searchKeyword) return [] const nameRes: any[] = [];
const messageRes: any[] = [];
if (!searchKeyword) {
return {
nameRes,
messageRes,
};
}
const conversations = Array.from(conversationList.values()) channels.forEach((chanel) => {
return conversations.filter((conversation) => { const { name } = (chanel?.data as any) ?? {};
const { name } = conversation const keyword = searchKeyword.toLowerCase();
const keyword = searchKeyword.toLowerCase()
// 搜索用户名 // 搜索用户名
if (name?.toLowerCase().includes(keyword)) { 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);
}
});
// // 搜索最后一条消息内容 return {
// if (lastMessage?.text?.toLowerCase().includes(keyword)) { nameRes,
// return true; 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) { if (!searchKeyword) {
return <div className="flex-1" /> return <div className="flex-1" />;
} }
return ( return (
@ -232,25 +58,24 @@ const ChatSearchResults = ({
<TabsList className="mb-0 h-auto w-fit justify-start gap-4 bg-transparent p-0"> <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"> <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"> <span className="txt-title-s text-txt-secondary-normal group-data-[state=active]:text-txt-primary-normal">
Chats Person
</span> </span>
<div className="bg-primary-normal h-1 w-5 rounded opacity-0 group-data-[state=active]:opacity-100" /> <div className="bg-primary-normal h-1 w-5 rounded opacity-0 group-data-[state=active]:opacity-100" />
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="person" className="relative h-auto flex-col gap-1"> <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"> <span className="txt-title-s text-txt-secondary-normal group-data-[state=active]:text-txt-primary-normal">
My Crushes Message
</span> </span>
<div className="bg-primary-normal h-1 w-5 rounded opacity-0 group-data-[state=active]:opacity-100" /> <div className="bg-primary-normal h-1 w-5 rounded opacity-0 group-data-[state=active]:opacity-100" />
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
{/* Message Tab 内容 */}
<TabsContent value="message" className="mt-0 flex-1 space-y-1 overflow-y-auto"> <TabsContent value="message" className="mt-0 flex-1 space-y-1 overflow-y-auto">
{messageResults.length > 0 ? ( {nameRes.length > 0 ? (
messageResults.map((conversation) => ( nameRes.map((chanel: any) => (
<ChatSidebarItem <ChatSidebarItem
key={conversation.conversationId} key={chanel.id}
conversation={conversation} chanel={chanel}
isExpanded={isExpanded} isExpanded={isExpanded}
isSelected={false} isSelected={false}
searchKeyword={searchKeyword} searchKeyword={searchKeyword}
@ -263,37 +88,26 @@ const ChatSearchResults = ({
)} )}
</TabsContent> </TabsContent>
{/* Person Tab 内容 */}
<TabsContent value="person" className="mt-0 flex-1 overflow-y-auto"> <TabsContent value="person" className="mt-0 flex-1 overflow-y-auto">
{isPersonLoading || isWaitingForDebounce ? ( {messageRes.length > 0 ? (
<div className="space-y-1"> messageRes.map((chanel: any) => (
{Array.from({ length: 6 }).map((_, index) => ( <ChatSidebarItem
<PersonSkeleton key={index} /> key={chanel.id}
))} chanel={chanel}
</div> isExpanded={isExpanded}
) : ( isSelected={false}
<InfiniteScrollList<HeartbeatRelationListOutput> searchKeyword={searchKeyword}
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> </TabsContent>
</Tabs> </Tabs>
</div> </div>
) );
} };
export default ChatSearchResults export default ChatSearchResults;

View File

@ -6,17 +6,15 @@ import { Input } from '@/components/ui/input';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useStreamChatStore } from '@/stores/stream-chat'; import { useStreamChatStore } from '@/stores/stream-chat';
import { useLayoutStore } from '@/stores'; import { useLayoutStore } from '@/stores';
import { useParams } from 'next/navigation';
const ChatSidebar = () => { const ChatSidebar = ({ expand }: { expand?: boolean }) => {
const isSidebarExpanded = useLayoutStore((s) => s.isSidebarExpanded); const isSidebarExpanded = useLayoutStore((s) => s.isSidebarExpanded);
const currentChannel = useStreamChatStore((state) => state.currentChannel); const { id } = useParams<{ id: string }>();
const channels = useStreamChatStore((state) => state.channels); const channels = useStreamChatStore((state) => state.channels);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [inSearching, setIsSearching] = useState(false); const [inSearching, setIsSearching] = useState(false);
// console.log('channels', channels);
const datas = Array.from(channels.values()).sort((a, b) => {
return false;
});
// 当侧边栏收缩时,取消搜索功能 // 当侧边栏收缩时,取消搜索功能
useEffect(() => { useEffect(() => {
@ -34,35 +32,28 @@ const ChatSidebar = () => {
// 如果有搜索关键词,显示搜索结果 // 如果有搜索关键词,显示搜索结果
const isShowingSearchResults = search.trim().length > 0; const isShowingSearchResults = search.trim().length > 0;
if (!datas.length && !isShowingSearchResults) { if (!channels.length && !isShowingSearchResults) {
return <div className="flex-1"></div>; return <div className="flex-1"></div>;
} }
const finalExpand = expand || isSidebarExpanded;
return ( return (
<>
{/* 分割线 */}
<div className="mx-6 my-4">
<div className="bg-outline-normal h-px" />
</div>
<div className="flex min-h-0 flex-1 flex-col px-4"> <div className="flex min-h-0 flex-1 flex-col px-4">
{/* 聊天标题 */} {/* 聊天标题 */}
<div className="mb-2 flex h-10 items-center justify-between px-2 py-1"> <div className="mb-2 flex h-10 items-center justify-between px-2 py-1">
{isSidebarExpanded ? (
<>
<span className="txt-label-s text-txt-secondary-normal">Chats</span> <span className="txt-label-s text-txt-secondary-normal">Chats</span>
{finalExpand && (
<ChatSidebarAction <ChatSidebarAction
onSearchClick={() => setIsSearching(true)} onSearchClick={() => setIsSearching(true)}
onCancelSearch={handleCloseSearch} onCancelSearch={handleCloseSearch}
isSearchActive={inSearching} isSearchActive={inSearching}
/> />
</>
) : (
<span className="txt-label-s text-txt-secondary-normal w-full text-center">Chats</span>
)} )}
</div> </div>
{/* 搜索框 - 根据设计稿实现 */} {/* 搜索框 - 根据设计稿实现 */}
{inSearching && isSidebarExpanded && ( {inSearching && finalExpand && (
<div className="relative mb-2 flex items-center gap-1 px-2 py-1"> <div className="relative mb-2 flex items-center gap-1 px-2 py-1">
<div className="relative flex-1"> <div className="relative flex-1">
<Input <Input
@ -73,9 +64,7 @@ const ChatSidebar = () => {
size="small" size="small"
autoFocus autoFocus
maxLength={50} maxLength={50}
prefixIcon={ prefixIcon={<i className="iconfont icon-Search text-txt-secondary-normal text-sm" />}
<i className="iconfont icon-Search text-txt-secondary-normal text-sm" />
}
className="rounded-full" className="rounded-full"
/> />
{isShowingSearchResults && ( {isShowingSearchResults && (
@ -89,12 +78,7 @@ const ChatSidebar = () => {
</IconButton> </IconButton>
)} )}
</div> </div>
<IconButton <IconButton onClick={handleCloseSearch} variant="ghost" size="small" className="shrink-0">
onClick={handleCloseSearch}
variant="ghost"
size="small"
className="shrink-0"
>
<i className="iconfont icon-close" /> <i className="iconfont icon-close" />
</IconButton> </IconButton>
</div> </div>
@ -103,42 +87,25 @@ const ChatSidebar = () => {
{/* 根据搜索状态显示不同内容 */} {/* 根据搜索状态显示不同内容 */}
{inSearching ? ( {inSearching ? (
isShowingSearchResults ? ( isShowingSearchResults ? (
<ChatSearchResults <ChatSearchResults searchKeyword={search} isExpanded={finalExpand} />
searchKeyword={search}
isExpanded={isSidebarExpanded}
onCloseSearch={handleCloseSearch}
/>
) : ( ) : (
<div className="flex-1" /> <div className="flex-1" />
) )
) : ( ) : (
<>
{/* 聊天项列表 */}
<div className="relative min-h-0 flex-1"> <div className="relative min-h-0 flex-1">
<div className="relative h-full"> <div className="h-full max-w-full space-y-2 overflow-x-hidden overflow-y-auto py-2">
<div className="h-full space-y-2 overflow-x-hidden overflow-y-auto py-2"> {channels.map((chat) => (
{datas.map((chat) => (
<ChatSidebarItem <ChatSidebarItem
key={chat.conversationId} key={chat.id}
conversation={chat} chanel={chat}
isExpanded={isSidebarExpanded} isExpanded={finalExpand}
isSelected={selectedChat === chat.conversationId} isSelected={id === chat.id}
/> />
))} ))}
</div> </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> </div>
</>
); );
}; };

View File

@ -1,12 +1,9 @@
'use client'; 'use client';
import { useMemo } from 'react'; import { useMemo } from 'react';
import AIRelationTag from '@/components/features/AIRelationTag';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge'; import { cn } from '@/lib/utils';
import { cn, durationText, getConversationTime } from '@/lib/utils';
import { CustomMessageType } from '@/types/im';
import Image from 'next/image';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Channel } from 'stream-chat';
// 高亮搜索关键词的组件 // 高亮搜索关键词的组件
const HighlightText = ({ text, keyword }: { text: string; keyword?: string }) => { const HighlightText = ({ text, keyword }: { text: string; keyword?: string }) => {
@ -30,7 +27,7 @@ const HighlightText = ({ text, keyword }: { text: string; keyword?: string }) =>
// 聊天项组件 // 聊天项组件
export default function ChatSidebarItem({ export default function ChatSidebarItem({
conversation, chanel,
isExpanded, isExpanded,
isSelected = false, isSelected = false,
searchKeyword, searchKeyword,
@ -38,29 +35,27 @@ export default function ChatSidebarItem({
isExpanded: boolean; isExpanded: boolean;
isSelected?: boolean; isSelected?: boolean;
searchKeyword?: string; searchKeyword?: string;
chanel: Channel;
}) { }) {
const { avatar, name, lastMessage, unreadCount, updateTime, serverExtension } = conversation; const { name, id, headPortrait } = (chanel?.data as any) ?? {};
const { text, attachment } = lastMessage || {};
const router = useRouter(); 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 = () => { const handleChat = () => {
router.push('/'); router.push(`/chat/${id}`);
}; };
const renderText = () => { const renderText = () => {
const { raw } = attachment || {}; if (!lastMessage) return '';
const customData = JSON.parse(raw || '{}'); if (searchKeyword && lastMessage.text?.includes(searchKeyword)) {
const { type, duration } = customData || {}; return <HighlightText text={lastMessage.text} keyword={searchKeyword} />;
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]';
} }
return text; return lastMessage.text || '';
}; };
return ( return (
@ -75,7 +70,7 @@ export default function ChatSidebarItem({
<div className="relative flex-shrink-0"> <div className="relative flex-shrink-0">
<div className="h-10 w-10 overflow-hidden rounded-full"> <div className="h-10 w-10 overflow-hidden rounded-full">
<Avatar className="h-10 w-10"> <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> <AvatarFallback>{name?.charAt(0)}</AvatarFallback>
</Avatar> </Avatar>
</div> </div>
@ -84,9 +79,9 @@ export default function ChatSidebarItem({
<div className="border-primary-variant-normal absolute inset-0 rounded-full border-1 border-solid" /> <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]" /> <Badge count={unreadCount} className="absolute top-[-4px] right-[-4px]" />
)} )} */}
</div> </div>
{isExpanded && ( {isExpanded && (
@ -97,25 +92,28 @@ export default function ChatSidebarItem({
<div className="txt-label-m truncate"> <div className="txt-label-m truncate">
<HighlightText text={name || ''} keyword={searchKeyword} /> <HighlightText text={name || ''} keyword={searchKeyword} />
</div> </div>
<div className="flex-shrink-0"> {/* <div className="flex-shrink-0">
{heartbeatLevel && isShow && ( {heartbeatLevel && isShow && (
<AIRelationTag heartbeatLevel={heartbeatLevel} size="mini" /> <AIRelationTag heartbeatLevel={heartbeatLevel} size="mini" />
)} )}
</div> */}
</div> </div>
<div className="txt-body-s text-txt-secondary-normal line-clamp-1">
{renderText()}
</div> </div>
<div className="txt-body-s text-txt-secondary-normal truncate">{renderText()}</div>
</div> </div>
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<div className="txt-numMonotype-xs text-txt-secondary-normal text-right"> <div className="txt-numMonotype-xs text-txt-secondary-normal text-right">
{getConversationTime(lastMessage?.messageRefer.createTime || updateTime)} 16:27
{/* {getConversationTime(lastMessage?.messageRefer.createTime || updateTime)} */}
</div> </div>
{!!heartbeatVal && heartbeatLevel && ( {/* {!!heartbeatVal && heartbeatLevel && (
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
<Image src="/icons/heart.svg" alt="heart" width={12} height={12} /> <Image src="/icons/heart.svg" alt="heart" width={12} height={12} />
<div className="txt-numMonotype-xs text-txt-primary-normal">{heartbeatVal}</div> <div className="txt-numMonotype-xs text-txt-primary-normal">{heartbeatVal}</div>
</div> </div>
)} )} */}
</div> </div>
</div> </div>
</div> </div>

View File

@ -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' });

View File

@ -1,26 +1,27 @@
import type { AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios' import type { AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import axios from 'axios' import axios from 'axios';
import Cookies from 'js-cookie' import Cookies from 'js-cookie';
import { getToken, saveAuthInfo } from './auth' import { getToken, saveAuthInfo } from './auth';
const endpoints = { const endpoints = {
editor: process.env.NEXT_PUBLIC_EDITOR_API_URL, 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 }) { export default function createClient({ serviceName }: { serviceName: keyof typeof endpoints }) {
const baseURL = endpoints[serviceName] || '/' const baseURL = endpoints[serviceName] || '/';
const instance = axios.create({ const instance = axios.create({
withCredentials: false, withCredentials: false,
baseURL, baseURL,
validateStatus: (status) => { validateStatus: (status) => {
return status >= 200 && status < 500 return status >= 200 && status < 500;
}, },
}) });
instance.interceptors.request.use(async (config: InternalAxiosRequestConfig) => { instance.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
const token = await getToken() const token = await getToken();
if (token) { if (token) {
config.headers.setAuthorization(`Bearer ${token}`) config.headers.setAuthorization(`Bearer ${token}`);
} }
// 从 cookie 中读取语言设置,并添加到请求头 // 从 cookie 中读取语言设置,并添加到请求头
@ -33,8 +34,8 @@ export default function createClient({ serviceName }: { serviceName: keyof typeo
// } // }
// } // }
return config return config;
}) });
instance.interceptors.response.use( instance.interceptors.response.use(
async (response: AxiosResponse): Promise<AxiosResponse> => { async (response: AxiosResponse): Promise<AxiosResponse> => {
@ -57,12 +58,12 @@ export default function createClient({ serviceName }: { serviceName: keyof typeo
// }); // });
} }
return response return response;
}, },
(error) => { (error) => {
console.log('error', error) console.log('error', error);
if (axios.isCancel(error)) { if (axios.isCancel(error)) {
return Promise.resolve('请求取消') return Promise.resolve('请求取消');
} }
// notification.error({ // notification.error({
@ -70,29 +71,29 @@ export default function createClient({ serviceName }: { serviceName: keyof typeo
// description: error, // description: error,
// }); // });
return Promise.reject(error) return Promise.reject(error);
} }
) );
type ResponseType<T = any> = { type ResponseType<T = any> = {
code: number code: number;
message: string message: string;
data: T data: T;
} };
return async function request<T = any>( return async function request<T = any>(
url: string, url: string,
config?: AxiosRequestConfig config?: AxiosRequestConfig
): Promise<ResponseType<T>> { ): Promise<ResponseType<T>> {
let data: any let data: any;
if (config && config?.params) { if (config && config?.params) {
const { params } = config const { params } = config;
data = Object.fromEntries(Object.entries(params).filter(([, value]) => value !== '')) data = Object.fromEntries(Object.entries(params).filter(([, value]) => value !== ''));
} }
const response = await instance<ResponseType<T>>(url, { const response = await instance<ResponseType<T>>(url, {
...config, ...config,
params: data, params: data,
}) });
return response.data return response.data;
} };
} }

View File

@ -1,4 +1,4 @@
import { editorRequest } from '@/lib/client'; import { chatRequest, editorRequest } from '@/lib/client';
export async function fetchCharacters({ index, limit, query }: any) { export async function fetchCharacters({ index, limit, query }: any) {
const { data } = await editorRequest('/api/character/list', { const { data } = await editorRequest('/api/character/list', {
@ -9,9 +9,31 @@ export async function fetchCharacters({ index, limit, query }: any) {
} }
export async function fetchCharacter(params: 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 = {}) { export async function fetchCharacterTags(params: any = {}) {
return editorRequest('/api/tag/list', { method: 'POST', data: params }); return editorRequest('/api/tag/list', { method: 'POST', data: params });
} }
export async function getUserToken(data: { userId: string; userName: string }) {
return await chatRequest('/chat-api/v1/im/user/createOrGet', {
method: 'post',
data: data,
});
}
export async function createChannel(data: any) {
return await chatRequest('/chat-api/v1/im/user/conversation/create', {
method: 'post',
data: data,
});
}
export async function deleteChannel(chanelId: string) {
return await chatRequest('/chat-api/v1/im/user/conversation/delete', {
method: 'post',
data: { chanelId },
});
}

View File

@ -1,60 +1,138 @@
'use client'; 'use client';
import { Channel, StreamChat } from 'stream-chat'; import { Channel, StreamChat } from 'stream-chat';
import { create } from 'zustand'; import { create } from 'zustand';
import { getUserToken, createChannel } from '@/services/editor';
import { parseSSEStream, parseData } from '@/utils/streamParser';
type Message = {
key: string;
role: string;
content: string;
};
interface StreamChatStore { interface StreamChatStore {
client: StreamChat | null;
user: {
userId: string;
userName: string;
};
// 连接 StreamChat 客户端
connect: (user: any) => Promise<void>; connect: (user: any) => Promise<void>;
// 频道
channels: Channel[]; channels: Channel[];
currentChannel: Channel | null; currentChannel: Channel | null;
// 创建某个角色的聊天频道, 返回channelId
createChannel: (characterId: string) => Promise<string | false>;
switchToChannel: (id: string) => Promise<void>; switchToChannel: (id: string) => Promise<void>;
queryChannels: (filter: any) => Promise<void>; queryChannels: (filter: any) => Promise<void>;
deleteChannel: (id: string) => Promise<void>; deleteChannel: (id: string) => Promise<void>;
clearChannels: () => Promise<void>; clearChannels: () => Promise<void>;
getCurrentCharacter: () => any | null;
// 消息列表
messages: Message[];
setMessages: (messages: Message[]) => void;
// 发送消息
sendMessage: (content: string) => Promise<void>;
// 清除通知
clearNotifications: () => Promise<void>; clearNotifications: () => Promise<void>;
} }
let client: StreamChat | null = null;
export const useStreamChatStore = create<StreamChatStore>((set, get) => ({ export const useStreamChatStore = create<StreamChatStore>((set, get) => ({
client: null,
user: {
userId: '',
userName: '',
},
channels: [], channels: [],
messages: [],
setMessages: (messages: any[]) => set({ messages }),
currentChannel: null, 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) { async connect(user) {
const { client } = get();
set({ user });
if (client) return; if (client) return;
console.log('connecting stream chat', user);
const { data } = await getUserToken(user); const { data } = await getUserToken(user);
client = new StreamChat(process.env.NEXT_PUBLIC_STREAM_CHAT_API_KEY || ''); const streamClient = new StreamChat(process.env.NEXT_PUBLIC_STREAM_CHAT_API_KEY || '');
await client.connectUser( const res = await streamClient.connectUser(
{ {
id: user.userId, id: user.userId,
name: user.userName, name: user.userName,
}, },
data data
); );
set({ client: streamClient });
}, },
async switchToChannel(id: string) { async switchToChannel(id: string) {
const { channels } = get(); const { client, user } = get();
const channel = channels.find((ch) => ch.id === id); const channel = client!.channel('messaging', id);
if (channel) { const result = await channel.query({
set({ currentChannel: channel }); messages: { limit: 100 },
// 可选:监听该频道的消息 });
await channel.watch(); const messages = result.messages.map((i) => ({
} else { key: i.id,
console.warn(`Channel with id ${id} not found in channels list`); 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) { if (!client) {
console.error('StreamChat client is not connected'); console.error('StreamChat client is not connected');
return; return;
} }
try { try {
const channels = await client.queryChannels(filter, { const channels = await client.queryChannels(
{
members: {
$in: [user.userId],
},
},
{
last_message_at: -1, last_message_at: -1,
}); },
{
message_limit: 1, // 返回最新的1条消息
}
);
set({ channels }); set({ channels });
} catch (error) { } catch (error) {
console.error('Failed to query channels:', error); console.error('Failed to query channels:', error);
} }
}, },
async deleteChannel(id: string) { async deleteChannel(id: string) {
const { channels, currentChannel, queryChannels } = get(); const { channels, currentChannel, queryChannels } = get();
const channel = channels.find((ch) => ch.id === id); const channel = channels.find((ch) => ch.id === id);
@ -65,11 +143,14 @@ export const useStreamChatStore = create<StreamChatStore>((set, get) => ({
try { try {
await channel.delete(); await channel.delete();
await queryChannels({}); await queryChannels({});
if (currentChannel?.id === id) {
set({ currentChannel: null }); set({ currentChannel: null });
}
} catch (error) { } catch (error) {
console.error(`Failed to delete channel ${id}:`, error); console.error(`Failed to delete channel ${id}:`, error);
} }
}, },
async clearChannels() { async clearChannels() {
const { channels } = get(); const { channels } = get();
@ -93,4 +174,48 @@ export const useStreamChatStore = create<StreamChatStore>((set, get) => ({
} }
}, },
async clearNotifications() {}, 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]);
}
});
},
})); }));

76
src/utils/streamParser.ts Normal file
View File

@ -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();
}
}