From 4bb265746a7d8929cd0a7102c9b41ed93e7a6742 Mon Sep 17 00:00:00 2001 From: liuyonghe0111 <1763195287@qq.com> Date: Mon, 5 Jan 2026 16:38:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=95=85=E4=BA=8B?= =?UTF-8?q?=E5=92=8C=E5=88=9B=E5=BB=BA=E6=95=85=E4=BA=8B;=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E5=88=97=E8=A1=A8=E8=99=9A=E6=8B=9F=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(main)/character/[id]/ChatButton.tsx | 4 +- src/app/(main)/chat/{[id] => }/AIMessage.tsx | 0 src/app/(main)/chat/{[id] => }/Background.tsx | 0 .../chat/{[id] => }/CharacterHeader.tsx | 5 +- .../chat/{[id] => }/Drawer/Background.tsx | 0 .../chat/{[id] => }/Drawer/ChatModel.tsx | 0 .../(main)/chat/{[id] => }/Drawer/Font.tsx | 2 +- .../chat/{[id] => }/Drawer/MaskCreate.tsx | 0 .../chat/{[id] => }/Drawer/MaskList.tsx | 0 .../chat/{[id] => }/Drawer/MaxToken.tsx | 2 +- .../(main)/chat/{[id] => }/Drawer/Profile.tsx | 11 +- .../chat/{[id] => }/Drawer/VoiceActor.tsx | 2 +- .../(main)/chat/{[id] => }/Drawer/index.tsx | 5 +- .../(main)/chat/{[id] => }/Drawer/store.ts | 2 +- src/app/(main)/chat/{[id] => }/Input.tsx | 2 +- src/app/(main)/chat/MessageList.tsx | 58 ++++ .../(main)/chat/{[id] => }/UserMessage.tsx | 0 src/app/(main)/chat/[id]/MessageList.tsx | 41 --- src/app/(main)/chat/{[id] => }/page.tsx | 14 +- src/app/(main)/chat/{[id] => }/stream-chat.ts | 0 src/app/(main)/contact/contact-page.tsx | 68 ++-- src/app/(main)/create/group/page.tsx | 116 +++++++ .../features/StoryContent/Dialog.tsx | 70 +++++ .../features/StoryContent/index.tsx | 46 +-- src/components/ui/text-md.tsx | 2 +- src/components/ui/virtual-list.tsx | 297 ++++++++++++++++-- .../components/ChatSearchResults.tsx | 2 +- .../BasicLayout/components/ChatSidebar.tsx | 7 +- .../components/ChatSidebarAction.tsx | 2 +- .../components/ChatSidebarItem.tsx | 2 +- src/layout/BasicLayout/config.ts | 1 + src/layout/BasicLayout/index.tsx | 2 +- src/layout/ProfileLayout/index.tsx | 2 +- 33 files changed, 610 insertions(+), 155 deletions(-) rename src/app/(main)/chat/{[id] => }/AIMessage.tsx (100%) rename src/app/(main)/chat/{[id] => }/Background.tsx (100%) rename src/app/(main)/chat/{[id] => }/CharacterHeader.tsx (96%) rename src/app/(main)/chat/{[id] => }/Drawer/Background.tsx (100%) rename src/app/(main)/chat/{[id] => }/Drawer/ChatModel.tsx (100%) rename src/app/(main)/chat/{[id] => }/Drawer/Font.tsx (95%) rename src/app/(main)/chat/{[id] => }/Drawer/MaskCreate.tsx (100%) rename src/app/(main)/chat/{[id] => }/Drawer/MaskList.tsx (100%) rename src/app/(main)/chat/{[id] => }/Drawer/MaxToken.tsx (95%) rename src/app/(main)/chat/{[id] => }/Drawer/Profile.tsx (96%) rename src/app/(main)/chat/{[id] => }/Drawer/VoiceActor.tsx (98%) rename src/app/(main)/chat/{[id] => }/Drawer/index.tsx (96%) rename src/app/(main)/chat/{[id] => }/Drawer/store.ts (87%) rename src/app/(main)/chat/{[id] => }/Input.tsx (98%) create mode 100644 src/app/(main)/chat/MessageList.tsx rename src/app/(main)/chat/{[id] => }/UserMessage.tsx (100%) delete mode 100644 src/app/(main)/chat/[id]/MessageList.tsx rename src/app/(main)/chat/{[id] => }/page.tsx (82%) rename src/app/(main)/chat/{[id] => }/stream-chat.ts (100%) create mode 100644 src/app/(main)/create/group/page.tsx create mode 100644 src/components/features/StoryContent/Dialog.tsx diff --git a/src/app/(main)/character/[id]/ChatButton.tsx b/src/app/(main)/character/[id]/ChatButton.tsx index ddc2956..69dfb7d 100644 --- a/src/app/(main)/character/[id]/ChatButton.tsx +++ b/src/app/(main)/character/[id]/ChatButton.tsx @@ -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 ( diff --git a/src/app/(main)/chat/[id]/AIMessage.tsx b/src/app/(main)/chat/AIMessage.tsx similarity index 100% rename from src/app/(main)/chat/[id]/AIMessage.tsx rename to src/app/(main)/chat/AIMessage.tsx diff --git a/src/app/(main)/chat/[id]/Background.tsx b/src/app/(main)/chat/Background.tsx similarity index 100% rename from src/app/(main)/chat/[id]/Background.tsx rename to src/app/(main)/chat/Background.tsx diff --git a/src/app/(main)/chat/[id]/CharacterHeader.tsx b/src/app/(main)/chat/CharacterHeader.tsx similarity index 96% rename from src/app/(main)/chat/[id]/CharacterHeader.tsx rename to src/app/(main)/chat/CharacterHeader.tsx index 0c89b06..a97f671 100644 --- a/src/app/(main)/chat/[id]/CharacterHeader.tsx +++ b/src/app/(main)/chat/CharacterHeader.tsx @@ -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(null); - const { id } = useParams<{ id: string }>(); - const characterId = id.split('-')[2]; + const { id, characterId } = useChatParams(); const { data: character = {} } = useCharacter(characterId); // 检测文本是否超过三行 diff --git a/src/app/(main)/chat/[id]/Drawer/Background.tsx b/src/app/(main)/chat/Drawer/Background.tsx similarity index 100% rename from src/app/(main)/chat/[id]/Drawer/Background.tsx rename to src/app/(main)/chat/Drawer/Background.tsx diff --git a/src/app/(main)/chat/[id]/Drawer/ChatModel.tsx b/src/app/(main)/chat/Drawer/ChatModel.tsx similarity index 100% rename from src/app/(main)/chat/[id]/Drawer/ChatModel.tsx rename to src/app/(main)/chat/Drawer/ChatModel.tsx diff --git a/src/app/(main)/chat/[id]/Drawer/Font.tsx b/src/app/(main)/chat/Drawer/Font.tsx similarity index 95% rename from src/app/(main)/chat/[id]/Drawer/Font.tsx rename to src/app/(main)/chat/Drawer/Font.tsx index 17960cd..405ab59 100644 --- a/src/app/(main)/chat/[id]/Drawer/Font.tsx +++ b/src/app/(main)/chat/Drawer/Font.tsx @@ -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 = { diff --git a/src/app/(main)/chat/[id]/Drawer/MaskCreate.tsx b/src/app/(main)/chat/Drawer/MaskCreate.tsx similarity index 100% rename from src/app/(main)/chat/[id]/Drawer/MaskCreate.tsx rename to src/app/(main)/chat/Drawer/MaskCreate.tsx diff --git a/src/app/(main)/chat/[id]/Drawer/MaskList.tsx b/src/app/(main)/chat/Drawer/MaskList.tsx similarity index 100% rename from src/app/(main)/chat/[id]/Drawer/MaskList.tsx rename to src/app/(main)/chat/Drawer/MaskList.tsx diff --git a/src/app/(main)/chat/[id]/Drawer/MaxToken.tsx b/src/app/(main)/chat/Drawer/MaxToken.tsx similarity index 95% rename from src/app/(main)/chat/[id]/Drawer/MaxToken.tsx rename to src/app/(main)/chat/Drawer/MaxToken.tsx index 00e25a9..9157f99 100644 --- a/src/app/(main)/chat/[id]/Drawer/MaxToken.tsx +++ b/src/app/(main)/chat/Drawer/MaxToken.tsx @@ -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; diff --git a/src/app/(main)/chat/[id]/Drawer/Profile.tsx b/src/app/(main)/chat/Drawer/Profile.tsx similarity index 96% rename from src/app/(main)/chat/[id]/Drawer/Profile.tsx rename to src/app/(main)/chat/Drawer/Profile.tsx index 3c17ce8..48d3f71 100644 --- a/src/app/(main)/chat/[id]/Drawer/Profile.tsx +++ b/src/app/(main)/chat/Drawer/Profile.tsx @@ -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(`/`); } diff --git a/src/app/(main)/chat/[id]/Drawer/VoiceActor.tsx b/src/app/(main)/chat/Drawer/VoiceActor.tsx similarity index 98% rename from src/app/(main)/chat/[id]/Drawer/VoiceActor.tsx rename to src/app/(main)/chat/Drawer/VoiceActor.tsx index 6618f6c..20b2d49 100644 --- a/src/app/(main)/chat/[id]/Drawer/VoiceActor.tsx +++ b/src/app/(main)/chat/Drawer/VoiceActor.tsx @@ -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'; diff --git a/src/app/(main)/chat/[id]/Drawer/index.tsx b/src/app/(main)/chat/Drawer/index.tsx similarity index 96% rename from src/app/(main)/chat/[id]/Drawer/index.tsx rename to src/app/(main)/chat/Drawer/index.tsx index 7901709..7076d73 100644 --- a/src/app/(main)/chat/[id]/Drawer/index.tsx +++ b/src/app/(main)/chat/Drawer/index.tsx @@ -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('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'), diff --git a/src/app/(main)/chat/[id]/Drawer/store.ts b/src/app/(main)/chat/Drawer/store.ts similarity index 87% rename from src/app/(main)/chat/[id]/Drawer/store.ts rename to src/app/(main)/chat/Drawer/store.ts index 5edd522..50f7fa7 100644 --- a/src/app/(main)/chat/[id]/Drawer/store.ts +++ b/src/app/(main)/chat/Drawer/store.ts @@ -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; diff --git a/src/app/(main)/chat/[id]/Input.tsx b/src/app/(main)/chat/Input.tsx similarity index 98% rename from src/app/(main)/chat/[id]/Input.tsx rename to src/app/(main)/chat/Input.tsx index e1f4eb5..43e3b8b 100644 --- a/src/app/(main)/chat/[id]/Input.tsx +++ b/src/app/(main)/chat/Input.tsx @@ -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'; diff --git a/src/app/(main)/chat/MessageList.tsx b/src/app/(main)/chat/MessageList.tsx new file mode 100644 index 0000000..e92e0d8 --- /dev/null +++ b/src/app/(main)/chat/MessageList.tsx @@ -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 ; + case 'assistant-message': + return ; + case 'header': + return ; + default: + return null; + } + }, []); + + return ( +
+ { + if (item.type === 'header') return 'header'; + return item.data?.key ?? `${item.type}-${index}`; + }} + itemContent={itemContent} + /> +
+ ); +}); + +export default MessageList; diff --git a/src/app/(main)/chat/[id]/UserMessage.tsx b/src/app/(main)/chat/UserMessage.tsx similarity index 100% rename from src/app/(main)/chat/[id]/UserMessage.tsx rename to src/app/(main)/chat/UserMessage.tsx diff --git a/src/app/(main)/chat/[id]/MessageList.tsx b/src/app/(main)/chat/[id]/MessageList.tsx deleted file mode 100644 index 508e88e..0000000 --- a/src/app/(main)/chat/[id]/MessageList.tsx +++ /dev/null @@ -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 ; - case 'assistant-message': - return ; - case 'header': - return ; - default: - return null; - } - }; - - return ( -
- -
- ); -} diff --git a/src/app/(main)/chat/[id]/page.tsx b/src/app/(main)/chat/page.tsx similarity index 82% rename from src/app/(main)/chat/[id]/page.tsx rename to src/app/(main)/chat/page.tsx index 7a2e4dc..0e12e71 100644 --- a/src/app/(main)/chat/[id]/page.tsx +++ b/src/app/(main)/chat/page.tsx @@ -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); diff --git a/src/app/(main)/chat/[id]/stream-chat.ts b/src/app/(main)/chat/stream-chat.ts similarity index 100% rename from src/app/(main)/chat/[id]/stream-chat.ts rename to src/app/(main)/chat/stream-chat.ts diff --git a/src/app/(main)/contact/contact-page.tsx b/src/app/(main)/contact/contact-page.tsx index 4e9289e..bafb9ab 100644 --- a/src/app/(main)/contact/contact-page.tsx +++ b/src/app/(main)/contact/contact-page.tsx @@ -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 (
@@ -93,24 +93,24 @@ const ContactCard = ({ contact }: { contact: ContactItem }) => {
- ) -} + ); +}; 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 = () => {
- ) + ); // 空状态组件 const EmptyState = () => ( @@ -144,7 +144,7 @@ const ContactsPage = () => { Start chatting with AI characters to build your crushes

- ) + ); return (
@@ -171,7 +171,7 @@ const ContactsPage = () => { onRetry={() => window.location.reload()} />
- ) -} + ); +}; -export default ContactsPage +export default ContactsPage; diff --git a/src/app/(main)/create/group/page.tsx b/src/app/(main)/create/group/page.tsx new file mode 100644 index 0000000..25e3662 --- /dev/null +++ b/src/app/(main)/create/group/page.tsx @@ -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({ + resolver: zodResolver(schema), + defaultValues: {}, + mode: 'onChange', + }); + + const { + formState: { isValid, isDirty }, + } = form; + + const { run: onSubmitFn, loading } = useAsyncFn(async (data: any) => { + console.log('data', data); + }); + + return ( + +
+ +
+ {/* 昵称字段 */} + ( + + + Group Nickname + + + + + + + )} + /> + ( + + World Guide + + + + + + )} + /> +
+
+ + +
+
+ +
+ ); +} diff --git a/src/components/features/StoryContent/Dialog.tsx b/src/components/features/StoryContent/Dialog.tsx new file mode 100644 index 0000000..4d648b8 --- /dev/null +++ b/src/components/features/StoryContent/Dialog.tsx @@ -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 ( + + + +
+ The Bossy CEO’s Contract Lover +
+
+ {tags.map((tag) => ( + # {tag.label} + ))} +
+
+ 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... +
+
+ All Characters + +
+ + Group Chat +
+ +
+
+
+ ); +} diff --git a/src/components/features/StoryContent/index.tsx b/src/components/features/StoryContent/index.tsx index ebdd09a..9a12a96 100644 --- a/src/components/features/StoryContent/index.tsx +++ b/src/components/features/StoryContent/index.tsx @@ -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 ( -
-
- - The Bossy CEO's Contract Lover - -
setOpen(true)} - className="cursor-pointer flex items-center gap-1 shrink-0" - > - More - -
-
- - {cList.map((item, index) => ( + <> +
+
+ + The Bossy CEO's Contract Lover +
setOpen(true)} + className="cursor-pointer flex items-center gap-1 shrink-0" > - {item} + More +
- ))} - -
+
+ + {cList.map((item, index) => ( +
+ {item} +
+ ))} +
+
+ + ); } diff --git a/src/components/ui/text-md.tsx b/src/components/ui/text-md.tsx index 87d6582..5149522 100644 --- a/src/components/ui/text-md.tsx +++ b/src/components/ui/text-md.tsx @@ -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' diff --git a/src/components/ui/virtual-list.tsx b/src/components/ui/virtual-list.tsx index 3bd74a0..019e462 100644 --- a/src/components/ui/virtual-list.tsx +++ b/src/components/ui/virtual-list.tsx @@ -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 = { type: string; data?: T; }; + +export type VirtualListRef = { + scrollToBottom: (behavior?: ScrollBehavior) => void; +}; + type VirtualListProps = { data?: ItemType[]; + // 虚拟渲染每一项的默认高度 + defaultItemHeight?: number; + // 预渲染项数(在可见范围上下额外渲染的项数) + overscan?: number; + autoScrollToBottom?: boolean; + // 缓存键:当此值变化时,自动清空高度缓存(用于切换数据源场景) + cacheKey?: string | number; + // 可见范围变化回调 + onScrollIndexChange?: (startIndex: number, endIndex: number) => void; + // 用于高度缓存的 key(强烈建议传入稳定 key,避免插入/删除导致缓存错位) + getItemKey?: (index: number, item: ItemType) => React.Key; itemContent?: (index: number, item: { type: string; data: T }) => React.ReactNode; } & React.HTMLAttributes; -export default function VirtualList(props: VirtualListProps) { - const { data = [], itemContent = (index) =>
{index}
, ...restProps } = props; - const ref = useRef(null); - const previousData = useRef([]); +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(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 ( +
+ {children} +
+ ); +} + +function VirtualListInner(props: VirtualListProps, ref: React.Ref) { + const { + data = [], + itemContent = (index) =>
{index}
, + defaultItemHeight = 50, + overscan = 3, + autoScrollToBottom = false, + cacheKey, + onScrollIndexChange, + getItemKey, + ...restProps + } = props; + + const containerRef = useRef(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>(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 ( + + {itemContent(actualIndex, item as any)} + + ); + }); + }, [ + virtualData, + visibleStart, + visibleEnd, + keys, + offsets, + defaultItemHeight, + onHeightChange, + itemContent, + ]); return (
- {data.map((item, index) => ( -
{itemContent(index, item as any)}
- ))} + {/* 虚拟渲染部分 */} +
+ {visibleItems} +
+ + {/* 固定渲染的最后两项 */} +
+ {fixedData.map((item, index) => { + const actualIndex = virtualData.length + index; + return
{itemContent(actualIndex, item as any)}
; + })} +
); } + +const VirtualList = React.forwardRef(VirtualListInner) as ( + props: VirtualListProps & { ref?: React.Ref } +) => ReturnType; + +export default VirtualList; diff --git a/src/layout/BasicLayout/components/ChatSearchResults.tsx b/src/layout/BasicLayout/components/ChatSearchResults.tsx index b000fe4..2285b90 100644 --- a/src/layout/BasicLayout/components/ChatSearchResults.tsx +++ b/src/layout/BasicLayout/components/ChatSearchResults.tsx @@ -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; diff --git a/src/layout/BasicLayout/components/ChatSidebar.tsx b/src/layout/BasicLayout/components/ChatSidebar.tsx index 53d56a3..5f00a03 100644 --- a/src/layout/BasicLayout/components/ChatSidebar.tsx +++ b/src/layout/BasicLayout/components/ChatSidebar.tsx @@ -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(''); diff --git a/src/layout/BasicLayout/components/ChatSidebarAction.tsx b/src/layout/BasicLayout/components/ChatSidebarAction.tsx index 3141c52..4763a7a 100644 --- a/src/layout/BasicLayout/components/ChatSidebarAction.tsx +++ b/src/layout/BasicLayout/components/ChatSidebarAction.tsx @@ -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'; diff --git a/src/layout/BasicLayout/components/ChatSidebarItem.tsx b/src/layout/BasicLayout/components/ChatSidebarItem.tsx index 47a938d..d224691 100644 --- a/src/layout/BasicLayout/components/ChatSidebarItem.tsx +++ b/src/layout/BasicLayout/components/ChatSidebarItem.tsx @@ -47,7 +47,7 @@ export default function ChatSidebarItem({ }, [chanel]); const handleChat = () => { - router.push(`/chat/${id}`); + router.push(`/chat?id=${id}`); }; const renderText = () => { diff --git a/src/layout/BasicLayout/config.ts b/src/layout/BasicLayout/config.ts index 505e2f1..53d6b66 100644 --- a/src/layout/BasicLayout/config.ts +++ b/src/layout/BasicLayout/config.ts @@ -22,6 +22,7 @@ export const topbarRouteConfigs: Record = { '/profile/account': { hideOnMobile: true }, '/character/:id': { hideOnMobile: true }, '/chat/:id': { enableBlur: true }, + '/create/group': { hideOnMobile: true }, '/crushcoin': { hideOnMobile: true }, }; diff --git a/src/layout/BasicLayout/index.tsx b/src/layout/BasicLayout/index.tsx index 5dd9757..8aa366d 100644 --- a/src/layout/BasicLayout/index.tsx +++ b/src/layout/BasicLayout/index.tsx @@ -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'; diff --git a/src/layout/ProfileLayout/index.tsx b/src/layout/ProfileLayout/index.tsx index 30c0956..78e9d9d 100644 --- a/src/layout/ProfileLayout/index.tsx +++ b/src/layout/ProfileLayout/index.tsx @@ -16,7 +16,7 @@ export default function ProfileLayout(props: ProfileLayoutProps) { const response = useLayoutStore((s) => s.response); return ( -
+
{/* 标题栏 */}