feat: 增加故事和创建故事;消息列表虚拟渲染
This commit is contained in:
parent
47f86102b7
commit
4bb265746a
|
|
@ -3,7 +3,7 @@ import { useRouter } from 'next/navigation';
|
|||
import { Button } from '@/components/ui/button';
|
||||
import type React from 'react';
|
||||
import { useAsyncFn } from '@/hooks/tools';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/stream-chat';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function ChatButton({
|
||||
|
|
@ -23,7 +23,7 @@ export default function ChatButton({
|
|||
}
|
||||
return;
|
||||
}
|
||||
router.push(`/chat/${res.channelId}`);
|
||||
router.push(`/chat?id=${res.channelId}`);
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ import { useState, useRef, useEffect } from 'react';
|
|||
import { cn } from '@/lib/utils';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { useCharacter } from '@/hooks/services/character';
|
||||
import { useParams } from 'next/navigation';
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useChatParams } from './page';
|
||||
|
||||
export const CharacterAvatorAndName = ({
|
||||
name,
|
||||
|
|
@ -35,8 +35,7 @@ const ChatMessageUserHeader = React.memo(() => {
|
|||
const [isFullIntroduction, setIsFullIntroduction] = useState(false);
|
||||
const [shouldShowExpandButton, setShouldShowExpandButton] = useState(false);
|
||||
const textRef = useRef<HTMLDivElement>(null);
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const characterId = id.split('-')[2];
|
||||
const { id, characterId } = useChatParams();
|
||||
const { data: character = {} } = useCharacter(characterId);
|
||||
|
||||
// 检测文本是否超过三行
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/stream-chat';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
type FontOption = {
|
||||
|
|
@ -3,7 +3,7 @@ import { useState } from 'react';
|
|||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/stream-chat';
|
||||
|
||||
type TokenOption = {
|
||||
value: number;
|
||||
|
|
@ -9,14 +9,14 @@ import { Button, IconButton } from '@/components/ui/button';
|
|||
import React from 'react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useCharacter } from '@/hooks/services/character';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { ActiveTabType } from './index';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/stream-chat';
|
||||
import { useAsyncFn } from '@/hooks/tools';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useModels } from '@/hooks/services/chat';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useChatParams } from '../page';
|
||||
|
||||
const ChatProfilePersona = React.memo(({ onActiveTab }: ProfileProps) => {
|
||||
const t = useTranslations('chat.drawer');
|
||||
|
|
@ -89,8 +89,7 @@ type ProfileProps = {
|
|||
export default function Profile({ onActiveTab }: ProfileProps) {
|
||||
const t = useTranslations('chat.drawer.profile');
|
||||
const tCommon = useTranslations('common');
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const characterId = id.split('-')[2];
|
||||
const { characterId, id } = useChatParams();
|
||||
const {} = useModels();
|
||||
const router = useRouter();
|
||||
const { data: character = {} } = useCharacter(characterId);
|
||||
|
|
@ -105,14 +104,14 @@ export default function Profile({ onActiveTab }: ProfileProps) {
|
|||
toast.error(error);
|
||||
return;
|
||||
}
|
||||
router.push(`/chat/${channelId}`);
|
||||
router.push(`/chat?id=${channelId}`);
|
||||
});
|
||||
|
||||
const { run: deleteChannelAsync, loading: deleting } = useAsyncFn(async () => {
|
||||
const { result, newChannels } = await deleteChannel([id]);
|
||||
if (result === 'ok') {
|
||||
if (newChannels?.length) {
|
||||
router.push(`/chat/${newChannels[0].id}`);
|
||||
router.push(`/chat?id=${newChannels[0].id}`);
|
||||
} else {
|
||||
router.push(`/`);
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/stream-chat';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
type VoiceGender = 'all' | 'male' | 'female';
|
||||
|
|
@ -22,9 +22,9 @@ import { useStreamChatStore } from '../stream-chat';
|
|||
import IconFont from '@/components/ui/iconFont';
|
||||
import MaskCreate from './MaskCreate';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useParams } from 'next/navigation';
|
||||
import LikedButton from '@/components/features/LikeButton';
|
||||
import { LikeTargetType } from '@/services/editor/type';
|
||||
import { useChatParams } from '../page';
|
||||
|
||||
type SettingProps = {
|
||||
open: boolean;
|
||||
|
|
@ -45,8 +45,7 @@ export default function SettingDialog({ open, onOpenChange }: SettingProps) {
|
|||
const t = useTranslations('chat.drawer');
|
||||
const [activeTab, setActiveTab] = useState<ActiveTabType>('profile');
|
||||
const updateUserChatSetting = useStreamChatStore((store) => store.updateUserChatSetting);
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const characterId = id.split('-')[2];
|
||||
const { id, characterId } = useChatParams();
|
||||
|
||||
const titleMap = {
|
||||
mask_list: t('maskedIdentityMode'),
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { create } from 'zustand';
|
||||
import { ChatSettingType } from '@/app/(main)/chat/[id]/stream-chat';
|
||||
import { ChatSettingType } from '@/app/(main)/chat/stream-chat';
|
||||
|
||||
interface ChatDrawerStore {
|
||||
setting: ChatSettingType;
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
import { IconButton } from '@/components/ui/button';
|
||||
import { useAsyncFn } from '@/hooks/tools';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/stream-chat';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import IconFont from '@/components/ui/iconFont';
|
||||
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
'use client';
|
||||
|
||||
import CharacterHeader from './CharacterHeader';
|
||||
import AIMessage from './AIMessage';
|
||||
import UserMessage from './UserMessage';
|
||||
import VirtualList from '@/components/ui/virtual-list';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/stream-chat';
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useChatParams } from './page';
|
||||
|
||||
const MessageList = React.memo(() => {
|
||||
const { id } = useChatParams();
|
||||
const messages = useStreamChatStore((s) => s.messages);
|
||||
const itemList = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
type: 'header',
|
||||
},
|
||||
...messages.map((i) => {
|
||||
return {
|
||||
type: i.role === 'user' ? 'user-message' : 'assistant-message',
|
||||
data: i,
|
||||
};
|
||||
}),
|
||||
];
|
||||
}, [messages]);
|
||||
|
||||
const itemContent = useCallback((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 min-h-0 relative z-10">
|
||||
<VirtualList
|
||||
className="h-full"
|
||||
data={itemList}
|
||||
autoScrollToBottom
|
||||
cacheKey={id || 'empty'}
|
||||
getItemKey={(index, item) => {
|
||||
if (item.type === 'header') return 'header';
|
||||
return item.data?.key ?? `${item.type}-${index}`;
|
||||
}}
|
||||
itemContent={itemContent}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default MessageList;
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import CharacterHeader from './CharacterHeader';
|
||||
import AIMessage from './AIMessage';
|
||||
import UserMessage from './UserMessage';
|
||||
import VirtualList from '@/components/ui/virtual-list';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/[id]/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 min-h-0 relative z-10">
|
||||
<VirtualList className="h-full" data={itemList} itemContent={itemContent} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,16 +4,22 @@ import { IconButton } from '@/components/ui/button';
|
|||
import Input from './Input';
|
||||
import MessageList from './MessageList';
|
||||
import SettingDialog from './Drawer';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/stream-chat';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCharacter } from '@/hooks/services/character';
|
||||
import Background from './Background';
|
||||
import IconFont from '@/components/ui/iconFont';
|
||||
|
||||
export default function ChatPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
export const useChatParams = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const id = searchParams.get('id') as string;
|
||||
const characterId = id?.split('-')[2] || '';
|
||||
return { id, characterId };
|
||||
};
|
||||
|
||||
export default function ChatPage() {
|
||||
const { id, characterId } = useChatParams();
|
||||
const [settingOpen, setSettingOpen] = useState(false);
|
||||
const switchToChannel = useStreamChatStore((s) => s.switchToChannel);
|
||||
const client = useStreamChatStore((s) => s.client);
|
||||
|
|
@ -1,39 +1,39 @@
|
|||
'use client'
|
||||
'use client';
|
||||
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { InfiniteScrollList } from '@/components/ui/infinite-scroll-list'
|
||||
import RenderContactStatusText from './components/RenderContactStatusText'
|
||||
import { useHeartbeatRelationListInfinite } from '@/hooks/useIm'
|
||||
import { HeartbeatRelationListOutput } from '@/services/im/types'
|
||||
import { useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import AIRelationTag from '@/components/features/AIRelationTag'
|
||||
import Link from 'next/link'
|
||||
import { usePrefetchRoutes } from '@/hooks/useGlobalPrefetchRoutes'
|
||||
import Image from 'next/image'
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { InfiniteScrollList } from '@/components/ui/infinite-scroll-list';
|
||||
import RenderContactStatusText from './components/RenderContactStatusText';
|
||||
import { useHeartbeatRelationListInfinite } from '@/hooks/useIm';
|
||||
import { HeartbeatRelationListOutput } from '@/services/im/types';
|
||||
import { useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import AIRelationTag from '@/components/features/AIRelationTag';
|
||||
import Link from 'next/link';
|
||||
import { usePrefetchRoutes } from '@/hooks/useGlobalPrefetchRoutes';
|
||||
import Image from 'next/image';
|
||||
|
||||
// 联系人数据类型现在使用API返回的数据结构
|
||||
type ContactItem = HeartbeatRelationListOutput
|
||||
type ContactItem = HeartbeatRelationListOutput;
|
||||
|
||||
// 联系人卡片组件
|
||||
const ContactCard = ({ contact }: { contact: ContactItem }) => {
|
||||
const router = useRouter()
|
||||
const router = useRouter();
|
||||
|
||||
// 计算年龄
|
||||
const age = useMemo(() => {
|
||||
if (!contact.birthday) return null
|
||||
const birthYear = new Date(contact.birthday).getFullYear()
|
||||
const currentYear = new Date().getFullYear()
|
||||
return currentYear - birthYear
|
||||
}, [contact.birthday])
|
||||
if (!contact.birthday) return null;
|
||||
const birthYear = new Date(contact.birthday).getFullYear();
|
||||
const currentYear = new Date().getFullYear();
|
||||
return currentYear - birthYear;
|
||||
}, [contact.birthday]);
|
||||
|
||||
// 跳转到聊天页面
|
||||
const handleChatClick = () => {
|
||||
if (contact.aiId) {
|
||||
router.push(`/chat/${contact.aiId}`)
|
||||
router.push(`/chat?id=${contact.aiId}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
|
|
@ -93,24 +93,24 @@ const ContactCard = ({ contact }: { contact: ContactItem }) => {
|
|||
<i className="iconfont icon-Chat !text-[24px]" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const ContactsPage = () => {
|
||||
// 使用无限查询获取心动关系列表
|
||||
const { data, fetchNextPage, hasNextPage, isLoading, isFetchingNextPage, error } =
|
||||
useHeartbeatRelationListInfinite()
|
||||
useHeartbeatRelationListInfinite();
|
||||
|
||||
// 扁平化所有页面的数据
|
||||
const allContacts = useMemo(() => {
|
||||
return data?.pages.flatMap((page) => page.datas || []) || []
|
||||
}, [data])
|
||||
return data?.pages.flatMap((page) => page.datas || []) || [];
|
||||
}, [data]);
|
||||
const chatRoutes = useMemo(
|
||||
() =>
|
||||
allContacts.slice(0, 20).map((contact) => (contact?.aiId ? `/chat/${contact.aiId}` : null)),
|
||||
[allContacts]
|
||||
)
|
||||
usePrefetchRoutes(chatRoutes)
|
||||
);
|
||||
usePrefetchRoutes(chatRoutes);
|
||||
|
||||
// 加载状态骨架屏组件
|
||||
const ContactSkeleton = () => (
|
||||
|
|
@ -127,7 +127,7 @@ const ContactsPage = () => {
|
|||
</div>
|
||||
<div className="bg-surface-nest-normal h-10 w-[80px] rounded" />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
// 空状态组件
|
||||
const EmptyState = () => (
|
||||
|
|
@ -144,7 +144,7 @@ const ContactsPage = () => {
|
|||
Start chatting with AI characters to build your crushes
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-[752px] flex-col gap-6 pt-28 pb-[200px]">
|
||||
|
|
@ -171,7 +171,7 @@ const ContactsPage = () => {
|
|||
onRetry={() => window.location.reload()}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactsPage
|
||||
export default ContactsPage;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,116 @@
|
|||
'use client';
|
||||
|
||||
import ProfileLayout from '@/layout/ProfileLayout';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import z from 'zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useAsyncFn } from '@/hooks/tools';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
|
||||
export default function CreateGroupPage() {
|
||||
const router = useRouter();
|
||||
|
||||
const schema = z.object({
|
||||
nickname: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Group nickname is required')
|
||||
.min(2, 'Group nickname must be at least 2 characters'),
|
||||
worldGuide: z.string().trim().min(1, 'World guide is required'),
|
||||
});
|
||||
|
||||
const form = useForm<any>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {},
|
||||
mode: 'onChange',
|
||||
});
|
||||
|
||||
const {
|
||||
formState: { isValid, isDirty },
|
||||
} = form;
|
||||
|
||||
const { run: onSubmitFn, loading } = useAsyncFn(async (data: any) => {
|
||||
console.log('data', data);
|
||||
});
|
||||
|
||||
return (
|
||||
<ProfileLayout title="Create a Group Chat">
|
||||
<Form {...form}>
|
||||
<form className="h-full flex flex-col w-full" onSubmit={form.handleSubmit(onSubmitFn)}>
|
||||
<div className="flex-1 overflow-auto flex flex-col gap-4">
|
||||
{/* 昵称字段 */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nickname"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="txt-label-m text-txt-primary-normal">
|
||||
Group Nickname
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter group name"
|
||||
maxLength={20}
|
||||
showCount
|
||||
error={!!form.formState.errors.nickname}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="worldGuide"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="txt-label-m text-txt-primary-normal">World Guide</FormLabel>
|
||||
<FormControl>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a world guide" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">World Guide 1</SelectItem>
|
||||
<SelectItem value="2">World Guide 2</SelectItem>
|
||||
<SelectItem value="3">World Guide 3</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 py-4 justify-end gap-2">
|
||||
<Button onClick={() => router.back()} variant="tertiary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!isValid || !isDirty} loading={loading}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</ProfileLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
'use client';
|
||||
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTitle } from '@/components/ui/alert-dialog';
|
||||
import { Chip } from '@/components/ui/chip';
|
||||
import IconFont from '@/components/ui/iconFont';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
|
||||
type StoryDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
export default function StoryDialog({ open, onOpenChange }: StoryDialogProps) {
|
||||
const tags = [
|
||||
{
|
||||
label: 'CEO',
|
||||
value: 'ceo',
|
||||
},
|
||||
{
|
||||
label: 'Contract',
|
||||
value: 'contract',
|
||||
},
|
||||
{
|
||||
label: 'Lover',
|
||||
value: 'lover',
|
||||
},
|
||||
{
|
||||
label: 'Bossy',
|
||||
value: 'bossy',
|
||||
},
|
||||
{
|
||||
label: 'Billionaire',
|
||||
value: 'billionaire',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent className="max-w-[760px]">
|
||||
<AlertDialogTitle></AlertDialogTitle>
|
||||
<div className="txt-title-l my-6 text-txt-primary-normal text-center">
|
||||
The Bossy CEO’s Contract Lover
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{tags.map((tag) => (
|
||||
<Chip key={tag.value}># {tag.label}</Chip>
|
||||
))}
|
||||
</div>
|
||||
<div className="p-5 rounded-2xl mt-4 txt-label-m text-txt-tertiary-normal bg-[#403E57]">
|
||||
In an era where memories can be digitally extracted and traded, Chen Mo is the most elite
|
||||
"memory trader" on the black market. He deals in sweet first loves and moments of triumph,
|
||||
but also handles guilty memories too dark to see the light. One day, he takes on a memory
|
||||
file from a suicide victim. Inside, he sees his own face—holding the murder weapon. At the
|
||||
same time, city-wide alarms sound because of him, and the memory agents known as
|
||||
"Silencers"... have locked onto his location. To uncover the truth, he must delve into
|
||||
this fatal memory...
|
||||
</div>
|
||||
<div className="flex items-center mt-6 justify-between">
|
||||
<span className="txt-title-m">All Characters</span>
|
||||
<Link href="/create/group">
|
||||
<div className="text-primary-normal cursor-pointer flex items-center gap-2">
|
||||
<IconFont type="icon-qunliao" className="text-2xl" />
|
||||
Group Chat
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
import IconFont from '@/components/ui/iconFont';
|
||||
import ScrollBox from './ScrollBox';
|
||||
import { useState } from 'react';
|
||||
import StoryContentDialog from './Dialog';
|
||||
|
||||
interface StoryContentProps {
|
||||
story?: any;
|
||||
|
|
@ -16,29 +17,32 @@ export default function StoryContent(props: StoryContentProps) {
|
|||
const cList = [123, 123, 123, 123, 123, 123, 123, 123];
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-full min-w-0">
|
||||
<div className="flex w-full min-w-0 items-center justify-between gap-2 mb-3">
|
||||
<span className="txt-title-m line-clamp-1 min-w-0 text-[rgba(208,232,255,1)]">
|
||||
The Bossy CEO's Contract Lover
|
||||
</span>
|
||||
<div
|
||||
onClick={() => setOpen(true)}
|
||||
className="cursor-pointer flex items-center gap-1 shrink-0"
|
||||
>
|
||||
<span>More</span>
|
||||
<IconFont type="icon-jiantou" />
|
||||
</div>
|
||||
</div>
|
||||
<ScrollBox contentClassName="inline-flex w-max gap-3">
|
||||
{cList.map((item, index) => (
|
||||
<>
|
||||
<div className="w-full max-w-full min-w-0">
|
||||
<div className="flex w-full min-w-0 items-center justify-between gap-2 mb-3">
|
||||
<span className="txt-title-m line-clamp-1 min-w-0 text-[rgba(208,232,255,1)]">
|
||||
The Bossy CEO's Contract Lover
|
||||
</span>
|
||||
<div
|
||||
className="flex-shrink-0 w-40 h-55 bg-gray-800/40 rounded-lg flex items-center justify-center"
|
||||
key={`Item-${index}`}
|
||||
onClick={() => setOpen(true)}
|
||||
className="cursor-pointer flex items-center gap-1 shrink-0"
|
||||
>
|
||||
{item}
|
||||
<span className="txt-label-l">More</span>
|
||||
<IconFont type="icon-jiantou" />
|
||||
</div>
|
||||
))}
|
||||
</ScrollBox>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollBox contentClassName="inline-flex w-max gap-3">
|
||||
{cList.map((item, index) => (
|
||||
<div
|
||||
className="flex-shrink-0 w-40 h-55 bg-gray-800/40 rounded-lg flex items-center justify-center"
|
||||
key={`Item-${index}`}
|
||||
>
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</ScrollBox>
|
||||
</div>
|
||||
<StoryContentDialog open={open} onOpenChange={setOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/stream-chat';
|
||||
import { cn } from '@/lib/utils';
|
||||
// import './index.css'
|
||||
|
||||
|
|
|
|||
|
|
@ -1,64 +1,307 @@
|
|||
'use client';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import React, {
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
type ItemType<T = any> = {
|
||||
type: string;
|
||||
data?: T;
|
||||
};
|
||||
|
||||
export type VirtualListRef = {
|
||||
scrollToBottom: (behavior?: ScrollBehavior) => void;
|
||||
};
|
||||
|
||||
type VirtualListProps<T = any> = {
|
||||
data?: ItemType<T>[];
|
||||
// 虚拟渲染每一项的默认高度
|
||||
defaultItemHeight?: number;
|
||||
// 预渲染项数(在可见范围上下额外渲染的项数)
|
||||
overscan?: number;
|
||||
autoScrollToBottom?: boolean;
|
||||
// 缓存键:当此值变化时,自动清空高度缓存(用于切换数据源场景)
|
||||
cacheKey?: string | number;
|
||||
// 可见范围变化回调
|
||||
onScrollIndexChange?: (startIndex: number, endIndex: number) => void;
|
||||
// 用于高度缓存的 key(强烈建议传入稳定 key,避免插入/删除导致缓存错位)
|
||||
getItemKey?: (index: number, item: ItemType<T>) => React.Key;
|
||||
itemContent?: (index: number, item: { type: string; data: T }) => React.ReactNode;
|
||||
} & React.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export default function VirtualList<T = any>(props: VirtualListProps<T>) {
|
||||
const { data = [], itemContent = (index) => <div>{index}</div>, ...restProps } = props;
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const previousData = useRef<ItemType[]>([]);
|
||||
function clamp(n: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, n));
|
||||
}
|
||||
|
||||
// 返回最大的 i,使 offsets[i] <= value(offsets 必须单调递增)
|
||||
function findFloorIndex(offsets: number[], value: number) {
|
||||
let lo = 0;
|
||||
let hi = offsets.length - 1;
|
||||
while (lo < hi) {
|
||||
const mid = Math.floor((lo + hi + 1) / 2);
|
||||
if (offsets[mid] <= value) lo = mid;
|
||||
else hi = mid - 1;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
|
||||
type MeasuredItemProps = {
|
||||
itemKey: React.Key;
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
onHeightChange: (itemKey: React.Key, height: number) => void;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
function MeasuredItem(props: MeasuredItemProps) {
|
||||
const { itemKey, style, className, onHeightChange, children } = props;
|
||||
const elRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = elRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const emit = () => {
|
||||
const next = Math.max(1, Math.ceil(el.getBoundingClientRect().height));
|
||||
onHeightChange(itemKey, next);
|
||||
};
|
||||
|
||||
emit();
|
||||
|
||||
const ro = new ResizeObserver(() => emit());
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [itemKey, onHeightChange]);
|
||||
|
||||
return (
|
||||
<div ref={elRef} className={className} style={style}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VirtualListInner<T = any>(props: VirtualListProps<T>, ref: React.Ref<VirtualListRef>) {
|
||||
const {
|
||||
data = [],
|
||||
itemContent = (index) => <div>{index}</div>,
|
||||
defaultItemHeight = 50,
|
||||
overscan = 3,
|
||||
autoScrollToBottom = false,
|
||||
cacheKey,
|
||||
onScrollIndexChange,
|
||||
getItemKey,
|
||||
...restProps
|
||||
} = props;
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isUserAtBottom, setIsUserAtBottom] = useState(true);
|
||||
|
||||
// 检查用户是否滚动到底部附近(阈值为 50px)
|
||||
const checkIfAtBottom = () => {
|
||||
const element = ref.current;
|
||||
if (!element) return false;
|
||||
// 虚拟滚动状态:当前滚动位置和容器高度
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const [containerHeight, setContainerHeight] = useState(0);
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = element;
|
||||
const threshold = 50; // 距离底部 50px 以内认为是在底部
|
||||
return scrollHeight - scrollTop - clientHeight < threshold;
|
||||
};
|
||||
const [virtualData, fixedData] = useMemo(
|
||||
() => [data.slice(0, data.length - 2), data.slice(data.length - 2)],
|
||||
[data]
|
||||
);
|
||||
|
||||
const getKey = useMemo(() => {
|
||||
return getItemKey ?? ((index: number) => index);
|
||||
}, [getItemKey]);
|
||||
|
||||
// 高度缓存:只缓存虚拟区(不含最后两项固定渲染)
|
||||
const heightCacheRef = useRef<Map<React.Key, number>>(new Map());
|
||||
const [heightVersion, setHeightVersion] = useState(0);
|
||||
|
||||
const { offsets, totalHeight, keys } = useMemo(() => {
|
||||
const keys = virtualData.map((item, index) => getKey(index, item as any));
|
||||
const offsets: number[] = new Array(keys.length + 1);
|
||||
offsets[0] = 0;
|
||||
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const k = keys[i];
|
||||
const h = heightCacheRef.current.get(k) ?? defaultItemHeight;
|
||||
offsets[i + 1] = offsets[i] + h;
|
||||
}
|
||||
|
||||
return { offsets, totalHeight: offsets[offsets.length - 1], keys };
|
||||
}, [virtualData, getKey, defaultItemHeight, heightVersion]);
|
||||
|
||||
const { rangeStart, rangeEnd, visibleStart, visibleEnd } = useMemo(() => {
|
||||
const virtualLen = virtualData.length;
|
||||
if (!containerHeight || virtualLen === 0) {
|
||||
return { rangeStart: 0, rangeEnd: 0, visibleStart: 0, visibleEnd: 0 };
|
||||
}
|
||||
|
||||
const bottom = scrollTop + containerHeight;
|
||||
|
||||
// rangeStart/rangeEnd 是“严格可见范围”(不含 overscan)
|
||||
const startOffsetPos = findFloorIndex(offsets, scrollTop);
|
||||
const endOffsetPos = findFloorIndex(offsets, bottom);
|
||||
|
||||
const rangeStart = clamp(startOffsetPos, 0, Math.max(virtualLen - 1, 0));
|
||||
const rangeEnd = clamp(endOffsetPos + 1, 0, virtualLen); // exclusive
|
||||
|
||||
const visibleStart = clamp(rangeStart - overscan, 0, virtualLen);
|
||||
const visibleEnd = clamp(rangeEnd + overscan, 0, virtualLen); // exclusive
|
||||
|
||||
return { rangeStart, rangeEnd, visibleStart, visibleEnd };
|
||||
}, [containerHeight, offsets, overscan, scrollTop, virtualData.length]);
|
||||
|
||||
const onHeightChange = useMemo(() => {
|
||||
return (itemKey: React.Key, height: number) => {
|
||||
const prev = heightCacheRef.current.get(itemKey);
|
||||
// 避免 ResizeObserver 抖动导致的频繁 rerender
|
||||
if (prev !== undefined && Math.abs(prev - height) < 1) return;
|
||||
heightCacheRef.current.set(itemKey, height);
|
||||
setHeightVersion((v) => v + 1);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 当 cacheKey 变化时清空高度缓存
|
||||
useEffect(() => {
|
||||
setScrollTop(0);
|
||||
heightCacheRef.current.clear();
|
||||
setHeightVersion((v) => v + 1);
|
||||
}, [cacheKey]);
|
||||
|
||||
// 暴露方法给父组件
|
||||
useImperativeHandle(ref, () => ({
|
||||
scrollToBottom: (behavior: ScrollBehavior = 'instant') => {
|
||||
if (!containerRef.current) return;
|
||||
containerRef.current.scrollTo({
|
||||
top: containerRef.current.scrollHeight,
|
||||
behavior,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
// 监听用户滚动事件
|
||||
const handleScroll = () => {
|
||||
const atBottom = checkIfAtBottom();
|
||||
const element = containerRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = element;
|
||||
|
||||
// 更新虚拟滚动状态
|
||||
setScrollTop(scrollTop);
|
||||
|
||||
// 检查用户是否滚动到底部附近(阈值为 50px)
|
||||
const threshold = 50;
|
||||
const atBottom = scrollHeight - scrollTop - clientHeight < threshold;
|
||||
setIsUserAtBottom(atBottom);
|
||||
};
|
||||
|
||||
// 初始化容器高度
|
||||
useEffect(() => {
|
||||
const element = containerRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const updateSize = () => {
|
||||
setContainerHeight(element.clientHeight);
|
||||
setScrollTop(element.scrollTop);
|
||||
};
|
||||
|
||||
updateSize();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateSize);
|
||||
resizeObserver.observe(element);
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
// 可见范围变化回调
|
||||
useEffect(() => {
|
||||
if (onScrollIndexChange && containerHeight > 0) {
|
||||
onScrollIndexChange(rangeStart, rangeEnd);
|
||||
}
|
||||
}, [rangeStart, rangeEnd, onScrollIndexChange, containerHeight]);
|
||||
|
||||
// 当数据更新时,只有用户在底部才自动滚动
|
||||
useEffect(() => {
|
||||
const last = (list: ItemType[]) => (list?.length ? list[list.length - 1] : null);
|
||||
const lastChanged = last(previousData.current)?.data !== last(data)?.data;
|
||||
if (!autoScrollToBottom) return;
|
||||
if (!isUserAtBottom) return;
|
||||
|
||||
if (lastChanged && isUserAtBottom) {
|
||||
ref.current?.scrollTo({
|
||||
top: ref.current?.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const raf1 = requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'instant' });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
previousData.current = data;
|
||||
}, [data, isUserAtBottom]);
|
||||
return () => cancelAnimationFrame(raf1);
|
||||
}, [autoScrollToBottom, data, isUserAtBottom, totalHeight]);
|
||||
|
||||
// 渲染可见范围内的虚拟项(测量真实高度并缓存)
|
||||
const visibleItems = useMemo(() => {
|
||||
return virtualData.slice(visibleStart, visibleEnd).map((item, i) => {
|
||||
const actualIndex = visibleStart + i;
|
||||
const itemKey = keys[actualIndex] ?? actualIndex;
|
||||
const y = offsets[actualIndex] ?? 0;
|
||||
|
||||
return (
|
||||
<MeasuredItem
|
||||
key={itemKey}
|
||||
itemKey={itemKey}
|
||||
onHeightChange={onHeightChange}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
transform: `translateY(${y}px)`,
|
||||
willChange: 'transform',
|
||||
}}
|
||||
>
|
||||
{itemContent(actualIndex, item as any)}
|
||||
</MeasuredItem>
|
||||
);
|
||||
});
|
||||
}, [
|
||||
virtualData,
|
||||
visibleStart,
|
||||
visibleEnd,
|
||||
keys,
|
||||
offsets,
|
||||
defaultItemHeight,
|
||||
onHeightChange,
|
||||
itemContent,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...restProps}
|
||||
ref={ref}
|
||||
ref={containerRef}
|
||||
onScroll={handleScroll}
|
||||
className={cn('overflow-auto', restProps.className)}
|
||||
>
|
||||
{data.map((item, index) => (
|
||||
<div key={index}>{itemContent(index, item as any)}</div>
|
||||
))}
|
||||
{/* 虚拟渲染部分 */}
|
||||
<div className="relative" style={{ height: totalHeight }}>
|
||||
{visibleItems}
|
||||
</div>
|
||||
|
||||
{/* 固定渲染的最后两项 */}
|
||||
<div>
|
||||
{fixedData.map((item, index) => {
|
||||
const actualIndex = virtualData.length + index;
|
||||
return <div key={actualIndex}>{itemContent(actualIndex, item as any)}</div>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const VirtualList = React.forwardRef(VirtualListInner) as <T = any>(
|
||||
props: VirtualListProps<T> & { ref?: React.Ref<VirtualListRef> }
|
||||
) => ReturnType<typeof VirtualListInner>;
|
||||
|
||||
export default VirtualList;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useState, useMemo } from 'react';
|
|||
import ChatSidebarItem from './ChatSidebarItem';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import Empty from '@/components/ui/empty';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/stream-chat';
|
||||
|
||||
interface ChatSearchResultsProps {
|
||||
searchKeyword: string;
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import ChatSidebarAction from './ChatSidebarAction';
|
|||
import ChatSearchResults from './ChatSearchResults';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/stream-chat';
|
||||
import { useLayoutStore } from '@/stores';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
const ChatSidebar = ({
|
||||
|
|
@ -17,7 +17,8 @@ const ChatSidebar = ({
|
|||
showSeparator?: boolean;
|
||||
}) => {
|
||||
const isSidebarExpanded = useLayoutStore((s) => s.isSidebarExpanded);
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const searchParams = useSearchParams();
|
||||
const id = searchParams.get('id') as string;
|
||||
const t = useTranslations('chat');
|
||||
const channels = useStreamChatStore((state) => state.channels);
|
||||
const [search, setSearch] = useState('');
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import {
|
|||
} from '@/components/ui/alert-dialog';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/stream-chat';
|
||||
import { useAsyncFn } from '@/hooks/tools';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export default function ChatSidebarItem({
|
|||
}, [chanel]);
|
||||
|
||||
const handleChat = () => {
|
||||
router.push(`/chat/${id}`);
|
||||
router.push(`/chat?id=${id}`);
|
||||
};
|
||||
|
||||
const renderText = () => {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export const topbarRouteConfigs: Record<string, TopbarRouteConfig> = {
|
|||
'/profile/account': { hideOnMobile: true },
|
||||
'/character/:id': { hideOnMobile: true },
|
||||
'/chat/:id': { enableBlur: true },
|
||||
'/create/group': { hideOnMobile: true },
|
||||
'/crushcoin': { hideOnMobile: true },
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import Topbar from './Topbar';
|
|||
import { cn } from '@/lib/utils';
|
||||
// import CreateReachedLimitDialog from '../components/features/create-reached-limit-dialog';
|
||||
import BottomBar from './BottomBar';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/stream-chat';
|
||||
import { useCurrentUser } from '@/hooks/auth';
|
||||
import { useLayoutStore } from '@/stores';
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export default function ProfileLayout(props: ProfileLayoutProps) {
|
|||
const response = useLayoutStore((s) => s.response);
|
||||
|
||||
return (
|
||||
<div className="mx-auto px-4 h-full flex flex-col max-w-[752px] pt-6">
|
||||
<div className="mx-auto px-4 h-full flex flex-col max-w-[752px] pt-4 sm:pt-6">
|
||||
{/* 标题栏 */}
|
||||
<div className="mb-6 flex shrink-0 justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
|
|||
Loading…
Reference in New Issue