feat: 增加故事和创建故事;消息列表虚拟渲染

This commit is contained in:
liuyonghe0111 2026-01-05 16:38:05 +08:00
parent 47f86102b7
commit 4bb265746a
33 changed files with 610 additions and 155 deletions

View File

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

View File

@ -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);
// 检测文本是否超过三行

View File

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

View File

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

View File

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

View File

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

View File

@ -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'),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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] <= valueoffsets 必须单调递增)
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;

View File

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

View File

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

View File

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

View File

@ -47,7 +47,7 @@ export default function ChatSidebarItem({
}, [chanel]);
const handleChat = () => {
router.push(`/chat/${id}`);
router.push(`/chat?id=${id}`);
};
const renderText = () => {

View File

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

View File

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

View File

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