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