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 { Button } from '@/components/ui/button';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { useAsyncFn } from '@/hooks/tools';
|
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';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export default function ChatButton({
|
export default function ChatButton({
|
||||||
|
|
@ -23,7 +23,7 @@ export default function ChatButton({
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.push(`/chat/${res.channelId}`);
|
router.push(`/chat?id=${res.channelId}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@ 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 { useCharacter } from '@/hooks/services/character';
|
||||||
import { useParams } from 'next/navigation';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useChatParams } from './page';
|
||||||
|
|
||||||
export const CharacterAvatorAndName = ({
|
export const CharacterAvatorAndName = ({
|
||||||
name,
|
name,
|
||||||
|
|
@ -35,8 +35,7 @@ const ChatMessageUserHeader = React.memo(() => {
|
||||||
const [isFullIntroduction, setIsFullIntroduction] = useState(false);
|
const [isFullIntroduction, setIsFullIntroduction] = useState(false);
|
||||||
const [shouldShowExpandButton, setShouldShowExpandButton] = useState(false);
|
const [shouldShowExpandButton, setShouldShowExpandButton] = useState(false);
|
||||||
const textRef = useRef<HTMLDivElement>(null);
|
const textRef = useRef<HTMLDivElement>(null);
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id, characterId } = useChatParams();
|
||||||
const characterId = id.split('-')[2];
|
|
||||||
const { data: character = {} } = useCharacter(characterId);
|
const { data: character = {} } = useCharacter(characterId);
|
||||||
|
|
||||||
// 检测文本是否超过三行
|
// 检测文本是否超过三行
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { cn } from '@/lib/utils';
|
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';
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
type FontOption = {
|
type FontOption = {
|
||||||
|
|
@ -3,7 +3,7 @@ import { useState } from 'react';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
import { useStreamChatStore } from '@/app/(main)/chat/stream-chat';
|
||||||
|
|
||||||
type TokenOption = {
|
type TokenOption = {
|
||||||
value: number;
|
value: number;
|
||||||
|
|
@ -9,14 +9,14 @@ import { Button, IconButton } from '@/components/ui/button';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { useCharacter } from '@/hooks/services/character';
|
import { useCharacter } from '@/hooks/services/character';
|
||||||
import { useParams } from 'next/navigation';
|
|
||||||
import { ActiveTabType } from './index';
|
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 { useAsyncFn } from '@/hooks/tools';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useModels } from '@/hooks/services/chat';
|
import { useModels } from '@/hooks/services/chat';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useChatParams } from '../page';
|
||||||
|
|
||||||
const ChatProfilePersona = React.memo(({ onActiveTab }: ProfileProps) => {
|
const ChatProfilePersona = React.memo(({ onActiveTab }: ProfileProps) => {
|
||||||
const t = useTranslations('chat.drawer');
|
const t = useTranslations('chat.drawer');
|
||||||
|
|
@ -89,8 +89,7 @@ type ProfileProps = {
|
||||||
export default function Profile({ onActiveTab }: ProfileProps) {
|
export default function Profile({ onActiveTab }: ProfileProps) {
|
||||||
const t = useTranslations('chat.drawer.profile');
|
const t = useTranslations('chat.drawer.profile');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const { id } = useParams<{ id: string }>();
|
const { characterId, id } = useChatParams();
|
||||||
const characterId = id.split('-')[2];
|
|
||||||
const {} = useModels();
|
const {} = useModels();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data: character = {} } = useCharacter(characterId);
|
const { data: character = {} } = useCharacter(characterId);
|
||||||
|
|
@ -105,14 +104,14 @@ export default function Profile({ onActiveTab }: ProfileProps) {
|
||||||
toast.error(error);
|
toast.error(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.push(`/chat/${channelId}`);
|
router.push(`/chat?id=${channelId}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
const { run: deleteChannelAsync, loading: deleting } = useAsyncFn(async () => {
|
const { run: deleteChannelAsync, loading: deleting } = useAsyncFn(async () => {
|
||||||
const { result, newChannels } = await deleteChannel([id]);
|
const { result, newChannels } = await deleteChannel([id]);
|
||||||
if (result === 'ok') {
|
if (result === 'ok') {
|
||||||
if (newChannels?.length) {
|
if (newChannels?.length) {
|
||||||
router.push(`/chat/${newChannels[0].id}`);
|
router.push(`/chat?id=${newChannels[0].id}`);
|
||||||
} else {
|
} else {
|
||||||
router.push(`/`);
|
router.push(`/`);
|
||||||
}
|
}
|
||||||
|
|
@ -4,7 +4,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
import { useStreamChatStore } from '@/app/(main)/chat/stream-chat';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
type VoiceGender = 'all' | 'male' | 'female';
|
type VoiceGender = 'all' | 'male' | 'female';
|
||||||
|
|
@ -22,9 +22,9 @@ import { useStreamChatStore } from '../stream-chat';
|
||||||
import IconFont from '@/components/ui/iconFont';
|
import IconFont from '@/components/ui/iconFont';
|
||||||
import MaskCreate from './MaskCreate';
|
import MaskCreate from './MaskCreate';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useParams } from 'next/navigation';
|
|
||||||
import LikedButton from '@/components/features/LikeButton';
|
import LikedButton from '@/components/features/LikeButton';
|
||||||
import { LikeTargetType } from '@/services/editor/type';
|
import { LikeTargetType } from '@/services/editor/type';
|
||||||
|
import { useChatParams } from '../page';
|
||||||
|
|
||||||
type SettingProps = {
|
type SettingProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -45,8 +45,7 @@ export default function SettingDialog({ open, onOpenChange }: SettingProps) {
|
||||||
const t = useTranslations('chat.drawer');
|
const t = useTranslations('chat.drawer');
|
||||||
const [activeTab, setActiveTab] = useState<ActiveTabType>('profile');
|
const [activeTab, setActiveTab] = useState<ActiveTabType>('profile');
|
||||||
const updateUserChatSetting = useStreamChatStore((store) => store.updateUserChatSetting);
|
const updateUserChatSetting = useStreamChatStore((store) => store.updateUserChatSetting);
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id, characterId } = useChatParams();
|
||||||
const characterId = id.split('-')[2];
|
|
||||||
|
|
||||||
const titleMap = {
|
const titleMap = {
|
||||||
mask_list: t('maskedIdentityMode'),
|
mask_list: t('maskedIdentityMode'),
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { ChatSettingType } from '@/app/(main)/chat/[id]/stream-chat';
|
import { ChatSettingType } from '@/app/(main)/chat/stream-chat';
|
||||||
|
|
||||||
interface ChatDrawerStore {
|
interface ChatDrawerStore {
|
||||||
setting: ChatSettingType;
|
setting: ChatSettingType;
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { IconButton } from '@/components/ui/button';
|
import { IconButton } from '@/components/ui/button';
|
||||||
import { useAsyncFn } from '@/hooks/tools';
|
import { useAsyncFn } from '@/hooks/tools';
|
||||||
import { cn } from '@/lib/utils';
|
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 { useState, useRef, useEffect } from 'react';
|
||||||
import IconFont from '@/components/ui/iconFont';
|
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 Input from './Input';
|
||||||
import MessageList from './MessageList';
|
import MessageList from './MessageList';
|
||||||
import SettingDialog from './Drawer';
|
import SettingDialog from './Drawer';
|
||||||
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
import { useStreamChatStore } from '@/app/(main)/chat/stream-chat';
|
||||||
import { useParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useCharacter } from '@/hooks/services/character';
|
import { useCharacter } from '@/hooks/services/character';
|
||||||
import Background from './Background';
|
import Background from './Background';
|
||||||
import IconFont from '@/components/ui/iconFont';
|
import IconFont from '@/components/ui/iconFont';
|
||||||
|
|
||||||
export default function ChatPage() {
|
export const useChatParams = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const searchParams = useSearchParams();
|
||||||
|
const id = searchParams.get('id') as string;
|
||||||
const characterId = id?.split('-')[2] || '';
|
const characterId = id?.split('-')[2] || '';
|
||||||
|
return { id, characterId };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ChatPage() {
|
||||||
|
const { id, characterId } = useChatParams();
|
||||||
const [settingOpen, setSettingOpen] = useState(false);
|
const [settingOpen, setSettingOpen] = useState(false);
|
||||||
const switchToChannel = useStreamChatStore((s) => s.switchToChannel);
|
const switchToChannel = useStreamChatStore((s) => s.switchToChannel);
|
||||||
const client = useStreamChatStore((s) => s.client);
|
const client = useStreamChatStore((s) => s.client);
|
||||||
|
|
@ -1,39 +1,39 @@
|
||||||
'use client'
|
'use client';
|
||||||
|
|
||||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button';
|
||||||
import { InfiniteScrollList } from '@/components/ui/infinite-scroll-list'
|
import { InfiniteScrollList } from '@/components/ui/infinite-scroll-list';
|
||||||
import RenderContactStatusText from './components/RenderContactStatusText'
|
import RenderContactStatusText from './components/RenderContactStatusText';
|
||||||
import { useHeartbeatRelationListInfinite } from '@/hooks/useIm'
|
import { useHeartbeatRelationListInfinite } from '@/hooks/useIm';
|
||||||
import { HeartbeatRelationListOutput } from '@/services/im/types'
|
import { HeartbeatRelationListOutput } from '@/services/im/types';
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react';
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation';
|
||||||
import AIRelationTag from '@/components/features/AIRelationTag'
|
import AIRelationTag from '@/components/features/AIRelationTag';
|
||||||
import Link from 'next/link'
|
import Link from 'next/link';
|
||||||
import { usePrefetchRoutes } from '@/hooks/useGlobalPrefetchRoutes'
|
import { usePrefetchRoutes } from '@/hooks/useGlobalPrefetchRoutes';
|
||||||
import Image from 'next/image'
|
import Image from 'next/image';
|
||||||
|
|
||||||
// 联系人数据类型现在使用API返回的数据结构
|
// 联系人数据类型现在使用API返回的数据结构
|
||||||
type ContactItem = HeartbeatRelationListOutput
|
type ContactItem = HeartbeatRelationListOutput;
|
||||||
|
|
||||||
// 联系人卡片组件
|
// 联系人卡片组件
|
||||||
const ContactCard = ({ contact }: { contact: ContactItem }) => {
|
const ContactCard = ({ contact }: { contact: ContactItem }) => {
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
|
|
||||||
// 计算年龄
|
// 计算年龄
|
||||||
const age = useMemo(() => {
|
const age = useMemo(() => {
|
||||||
if (!contact.birthday) return null
|
if (!contact.birthday) return null;
|
||||||
const birthYear = new Date(contact.birthday).getFullYear()
|
const birthYear = new Date(contact.birthday).getFullYear();
|
||||||
const currentYear = new Date().getFullYear()
|
const currentYear = new Date().getFullYear();
|
||||||
return currentYear - birthYear
|
return currentYear - birthYear;
|
||||||
}, [contact.birthday])
|
}, [contact.birthday]);
|
||||||
|
|
||||||
// 跳转到聊天页面
|
// 跳转到聊天页面
|
||||||
const handleChatClick = () => {
|
const handleChatClick = () => {
|
||||||
if (contact.aiId) {
|
if (contact.aiId) {
|
||||||
router.push(`/chat/${contact.aiId}`)
|
router.push(`/chat?id=${contact.aiId}`);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full items-center justify-between gap-4">
|
<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]" />
|
<i className="iconfont icon-Chat !text-[24px]" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const ContactsPage = () => {
|
const ContactsPage = () => {
|
||||||
// 使用无限查询获取心动关系列表
|
// 使用无限查询获取心动关系列表
|
||||||
const { data, fetchNextPage, hasNextPage, isLoading, isFetchingNextPage, error } =
|
const { data, fetchNextPage, hasNextPage, isLoading, isFetchingNextPage, error } =
|
||||||
useHeartbeatRelationListInfinite()
|
useHeartbeatRelationListInfinite();
|
||||||
|
|
||||||
// 扁平化所有页面的数据
|
// 扁平化所有页面的数据
|
||||||
const allContacts = useMemo(() => {
|
const allContacts = useMemo(() => {
|
||||||
return data?.pages.flatMap((page) => page.datas || []) || []
|
return data?.pages.flatMap((page) => page.datas || []) || [];
|
||||||
}, [data])
|
}, [data]);
|
||||||
const chatRoutes = useMemo(
|
const chatRoutes = useMemo(
|
||||||
() =>
|
() =>
|
||||||
allContacts.slice(0, 20).map((contact) => (contact?.aiId ? `/chat/${contact.aiId}` : null)),
|
allContacts.slice(0, 20).map((contact) => (contact?.aiId ? `/chat/${contact.aiId}` : null)),
|
||||||
[allContacts]
|
[allContacts]
|
||||||
)
|
);
|
||||||
usePrefetchRoutes(chatRoutes)
|
usePrefetchRoutes(chatRoutes);
|
||||||
|
|
||||||
// 加载状态骨架屏组件
|
// 加载状态骨架屏组件
|
||||||
const ContactSkeleton = () => (
|
const ContactSkeleton = () => (
|
||||||
|
|
@ -127,7 +127,7 @@ const ContactsPage = () => {
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-surface-nest-normal h-10 w-[80px] rounded" />
|
<div className="bg-surface-nest-normal h-10 w-[80px] rounded" />
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
|
|
||||||
// 空状态组件
|
// 空状态组件
|
||||||
const EmptyState = () => (
|
const EmptyState = () => (
|
||||||
|
|
@ -144,7 +144,7 @@ const ContactsPage = () => {
|
||||||
Start chatting with AI characters to build your crushes
|
Start chatting with AI characters to build your crushes
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-[752px] flex-col gap-6 pt-28 pb-[200px]">
|
<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()}
|
onRetry={() => window.location.reload()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 IconFont from '@/components/ui/iconFont';
|
||||||
import ScrollBox from './ScrollBox';
|
import ScrollBox from './ScrollBox';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import StoryContentDialog from './Dialog';
|
||||||
|
|
||||||
interface StoryContentProps {
|
interface StoryContentProps {
|
||||||
story?: any;
|
story?: any;
|
||||||
|
|
@ -16,6 +17,7 @@ export default function StoryContent(props: StoryContentProps) {
|
||||||
const cList = [123, 123, 123, 123, 123, 123, 123, 123];
|
const cList = [123, 123, 123, 123, 123, 123, 123, 123];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="w-full max-w-full min-w-0">
|
<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">
|
<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)]">
|
<span className="txt-title-m line-clamp-1 min-w-0 text-[rgba(208,232,255,1)]">
|
||||||
|
|
@ -25,7 +27,7 @@ export default function StoryContent(props: StoryContentProps) {
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
className="cursor-pointer flex items-center gap-1 shrink-0"
|
className="cursor-pointer flex items-center gap-1 shrink-0"
|
||||||
>
|
>
|
||||||
<span>More</span>
|
<span className="txt-label-l">More</span>
|
||||||
<IconFont type="icon-jiantou" />
|
<IconFont type="icon-jiantou" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -40,5 +42,7 @@ export default function StoryContent(props: StoryContentProps) {
|
||||||
))}
|
))}
|
||||||
</ScrollBox>
|
</ScrollBox>
|
||||||
</div>
|
</div>
|
||||||
|
<StoryContentDialog open={open} onOpenChange={setOpen} />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React from 'react';
|
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 { cn } from '@/lib/utils';
|
||||||
// import './index.css'
|
// import './index.css'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,64 +1,307 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
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 ItemType<T = any> = {
|
||||||
type: string;
|
type: string;
|
||||||
data?: T;
|
data?: T;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type VirtualListRef = {
|
||||||
|
scrollToBottom: (behavior?: ScrollBehavior) => void;
|
||||||
|
};
|
||||||
|
|
||||||
type VirtualListProps<T = any> = {
|
type VirtualListProps<T = any> = {
|
||||||
data?: ItemType<T>[];
|
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;
|
itemContent?: (index: number, item: { type: string; data: T }) => React.ReactNode;
|
||||||
} & React.HTMLAttributes<HTMLDivElement>;
|
} & React.HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
export default function VirtualList<T = any>(props: VirtualListProps<T>) {
|
function clamp(n: number, min: number, max: number) {
|
||||||
const { data = [], itemContent = (index) => <div>{index}</div>, ...restProps } = props;
|
return Math.max(min, Math.min(max, n));
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
}
|
||||||
const previousData = useRef<ItemType[]>([]);
|
|
||||||
|
// 返回最大的 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);
|
const [isUserAtBottom, setIsUserAtBottom] = useState(true);
|
||||||
|
|
||||||
// 检查用户是否滚动到底部附近(阈值为 50px)
|
// 虚拟滚动状态:当前滚动位置和容器高度
|
||||||
const checkIfAtBottom = () => {
|
const [scrollTop, setScrollTop] = useState(0);
|
||||||
const element = ref.current;
|
const [containerHeight, setContainerHeight] = useState(0);
|
||||||
if (!element) return false;
|
|
||||||
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = element;
|
const [virtualData, fixedData] = useMemo(
|
||||||
const threshold = 50; // 距离底部 50px 以内认为是在底部
|
() => [data.slice(0, data.length - 2), data.slice(data.length - 2)],
|
||||||
return scrollHeight - scrollTop - clientHeight < threshold;
|
[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 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);
|
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(() => {
|
useEffect(() => {
|
||||||
const last = (list: ItemType[]) => (list?.length ? list[list.length - 1] : null);
|
if (!autoScrollToBottom) return;
|
||||||
const lastChanged = last(previousData.current)?.data !== last(data)?.data;
|
if (!isUserAtBottom) return;
|
||||||
|
|
||||||
if (lastChanged && isUserAtBottom) {
|
const el = containerRef.current;
|
||||||
ref.current?.scrollTo({
|
if (!el) return;
|
||||||
top: ref.current?.scrollHeight,
|
|
||||||
behavior: 'smooth',
|
const raf1 = requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el.scrollTo({ top: el.scrollHeight, behavior: 'instant' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
previousData.current = data;
|
return () => cancelAnimationFrame(raf1);
|
||||||
}, [data, isUserAtBottom]);
|
}, [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 (
|
return (
|
||||||
<div
|
<div
|
||||||
{...restProps}
|
{...restProps}
|
||||||
ref={ref}
|
ref={containerRef}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
className={cn('overflow-auto', restProps.className)}
|
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>
|
</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 ChatSidebarItem from './ChatSidebarItem';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
import Empty from '@/components/ui/empty';
|
import Empty from '@/components/ui/empty';
|
||||||
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
import { useStreamChatStore } from '@/app/(main)/chat/stream-chat';
|
||||||
|
|
||||||
interface ChatSearchResultsProps {
|
interface ChatSearchResultsProps {
|
||||||
searchKeyword: string;
|
searchKeyword: string;
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@ import ChatSidebarAction from './ChatSidebarAction';
|
||||||
import ChatSearchResults from './ChatSearchResults';
|
import ChatSearchResults from './ChatSearchResults';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
import { useStreamChatStore } from '@/app/(main)/chat/stream-chat';
|
||||||
import { useLayoutStore } from '@/stores';
|
import { useLayoutStore } from '@/stores';
|
||||||
import { useParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
const ChatSidebar = ({
|
const ChatSidebar = ({
|
||||||
|
|
@ -17,7 +17,8 @@ const ChatSidebar = ({
|
||||||
showSeparator?: boolean;
|
showSeparator?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const isSidebarExpanded = useLayoutStore((s) => s.isSidebarExpanded);
|
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 t = useTranslations('chat');
|
||||||
const channels = useStreamChatStore((state) => state.channels);
|
const channels = useStreamChatStore((state) => state.channels);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import {
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
import { useStreamChatStore } from '@/app/(main)/chat/stream-chat';
|
||||||
import { useAsyncFn } from '@/hooks/tools';
|
import { useAsyncFn } from '@/hooks/tools';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ export default function ChatSidebarItem({
|
||||||
}, [chanel]);
|
}, [chanel]);
|
||||||
|
|
||||||
const handleChat = () => {
|
const handleChat = () => {
|
||||||
router.push(`/chat/${id}`);
|
router.push(`/chat?id=${id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderText = () => {
|
const renderText = () => {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ export const topbarRouteConfigs: Record<string, TopbarRouteConfig> = {
|
||||||
'/profile/account': { hideOnMobile: true },
|
'/profile/account': { hideOnMobile: true },
|
||||||
'/character/:id': { hideOnMobile: true },
|
'/character/:id': { hideOnMobile: true },
|
||||||
'/chat/:id': { enableBlur: true },
|
'/chat/:id': { enableBlur: true },
|
||||||
|
'/create/group': { hideOnMobile: true },
|
||||||
'/crushcoin': { hideOnMobile: true },
|
'/crushcoin': { hideOnMobile: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import Topbar from './Topbar';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
// import CreateReachedLimitDialog from '../components/features/create-reached-limit-dialog';
|
// import CreateReachedLimitDialog from '../components/features/create-reached-limit-dialog';
|
||||||
import BottomBar from './BottomBar';
|
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 { useCurrentUser } from '@/hooks/auth';
|
||||||
import { useLayoutStore } from '@/stores';
|
import { useLayoutStore } from '@/stores';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ export default function ProfileLayout(props: ProfileLayoutProps) {
|
||||||
const response = useLayoutStore((s) => s.response);
|
const response = useLayoutStore((s) => s.response);
|
||||||
|
|
||||||
return (
|
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="mb-6 flex shrink-0 justify-between items-center">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue