+
Check-in{' '}
{
void
- setSelectedTags: (selectedTags: string[]) => void
+ tab: 'story' | 'character';
+ selectedTags: string[];
+ setTab: (tab: 'story' | 'character') => void;
+ setSelectedTags: (selectedTags: string[]) => void;
}
export const useHomeStore = create((set) => ({
- tab: 'story',
+ tab: 'character',
setTab: (tab: 'story' | 'character') => set({ tab }),
selectedTags: [],
setSelectedTags: (selectedTags: string[]) => set({ selectedTags }),
-}))
+}));
diff --git a/src/app/(main)/profile/components/ProfileDropdown.tsx b/src/app/(main)/profile/components/ProfileDropdown.tsx
index 31d8ddb..8f0b5a4 100644
--- a/src/app/(main)/profile/components/ProfileDropdown.tsx
+++ b/src/app/(main)/profile/components/ProfileDropdown.tsx
@@ -9,20 +9,11 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { useLogout } from '@/hooks/auth';
-import { useSetAtom } from 'jotai';
-import {
- conversationListAtom,
- msgListAtom,
- userListAtom,
- imSyncedAtom,
- imReconnectStatusAtom,
- IMReconnectStatus,
- selectedConversationIdAtom,
-} from '@/atoms/im';
-import { QueueMap } from '@/lib/queue';
import Link from 'next/link';
import { useState } from 'react';
import { useLayoutStore } from '@/stores';
+import { useStreamChatStore } from '../../chat/[id]/stream-chat';
+import { useAsyncFn } from '@/hooks/tools';
const ProfileDropdownItem = ({
icon,
@@ -49,65 +40,20 @@ const ProfileDropdownItem = ({
const ProfileDropdown = () => {
const { mutateAsync: logout } = useLogout();
- // const { clearAllConversations } = useNimConversation();
const [isLogoutDialogOpen, setIsLogoutDialogOpen] = useState(false);
- const [isLogoutDialogLoading, setIsLogoutDialogLoading] = useState(false);
- const { isSidebarExpanded, setSidebarExpanded } = useLayoutStore();
+ const setSidebarExpanded = useLayoutStore((s) => s.setSidebarExpanded);
+ const clearClient = useStreamChatStore((s) => s.clearClient);
- // IM相关状态重置
- const setConversationList = useSetAtom(conversationListAtom);
- const setMsgList = useSetAtom(msgListAtom);
- const setUserList = useSetAtom(userListAtom);
- const setImSynced = useSetAtom(imSyncedAtom);
- const setImReconnectStatus = useSetAtom(imReconnectStatusAtom);
- const setSelectedConversationId = useSetAtom(selectedConversationIdAtom);
-
- const handleLogout = async () => {
+ const { run: handleLogout, loading } = useAsyncFn(async () => {
try {
- setIsLogoutDialogLoading(true);
-
- // 1. 断开IM连接
- try {
- console.log('开始断开IM连接...');
- // await nim.V2NIMLoginService.logout();
- console.log('IM连接已断开');
- } catch (imError) {
- console.error('断开IM连接失败:', imError);
- // 即使IM断开失败,也继续执行后续步骤
- }
-
- // 2. 清除所有聊天数据
- try {
- console.log('开始清除聊天历史数据...');
- // await clearAllConversations();
- console.log('聊天历史数据已清除');
- } catch (clearError) {
- console.error('清除聊天数据失败:', clearError);
- // 即使清除失败,也继续执行后续步骤
- }
-
- // 3. 重置所有IM相关的本地状态
- setConversationList(new Map());
- setMsgList(new QueueMap(20, 'rightToLeft'));
- setUserList(new Map());
- setImSynced(false);
- setImReconnectStatus(IMReconnectStatus.DISCONNECTED);
- setSelectedConversationId(null);
-
- if (isSidebarExpanded) {
- setSidebarExpanded(false);
- }
-
- // 4. 执行用户登出
+ await clearClient();
+ setSidebarExpanded(false);
await logout();
-
setIsLogoutDialogOpen(false);
- setIsLogoutDialogLoading(false);
} catch (error) {
console.error('登出过程中发生错误:', error);
- setIsLogoutDialogLoading(false);
}
- };
+ });
// 菜单项配置
const items: Array<
@@ -199,11 +145,7 @@ const ProfileDropdown = () => {
Are you sure you want to log out?
Cancel
-
+
Log out
diff --git a/src/components/ui/virtual-list.tsx b/src/components/ui/virtual-list.tsx
index b8cf037..3bd74a0 100644
--- a/src/components/ui/virtual-list.tsx
+++ b/src/components/ui/virtual-list.tsx
@@ -1,57 +1,64 @@
'use client';
-import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
-import { useRef, useState } from 'react';
+import { cn } from '@/lib/utils';
+import { useEffect, useRef, useState } from 'react';
+
+type ItemType = {
+ type: string;
+ data?: T;
+};
type VirtualListProps = {
- data?: { type: string; data?: T }[];
+ data?: ItemType[];
itemContent?: (index: number, item: { type: string; data: T }) => React.ReactNode;
- virtuosoProps?: React.ComponentProps;
} & React.HTMLAttributes;
export default function VirtualList(props: VirtualListProps) {
- const {
- data = [],
- itemContent = (index) => {index}
,
- virtuosoProps,
- ...restProps
- } = props;
- const virtuosoRef = useRef(null);
- // const [showScrollButton, setShowScrollButton] = useState(false);
+ const { data = [], itemContent = (index) => {index}
, ...restProps } = props;
+ const ref = useRef(null);
+ const previousData = useRef([]);
+ const [isUserAtBottom, setIsUserAtBottom] = useState(true);
- // // 滚动到最新消息
- // const scrollToBottom = () => {
- // virtuosoRef.current?.scrollToIndex({
- // index: data.length - 1,
- // behavior: 'smooth',
- // });
- // };
+ // 检查用户是否滚动到底部附近(阈值为 50px)
+ const checkIfAtBottom = () => {
+ const element = ref.current;
+ if (!element) return false;
+
+ const { scrollTop, scrollHeight, clientHeight } = element;
+ const threshold = 50; // 距离底部 50px 以内认为是在底部
+ return scrollHeight - scrollTop - clientHeight < threshold;
+ };
+
+ // 监听用户滚动事件
+ const handleScroll = () => {
+ const atBottom = checkIfAtBottom();
+ setIsUserAtBottom(atBottom);
+ };
+
+ // 当数据更新时,只有用户在底部才自动滚动
+ useEffect(() => {
+ const last = (list: ItemType[]) => (list?.length ? list[list.length - 1] : null);
+ const lastChanged = last(previousData.current)?.data !== last(data)?.data;
+
+ if (lastChanged && isUserAtBottom) {
+ ref.current?.scrollTo({
+ top: ref.current?.scrollHeight,
+ behavior: 'smooth',
+ });
+ }
+
+ previousData.current = data;
+ }, [data, isUserAtBottom]);
return (
-
-
{
- // // 当不在底部时显示按钮,在底部时隐藏
- // setShowScrollButton(!atBottom);
- // }}
- itemContent={(index, item) => itemContent(index, item as any)}
- />
-
- {/* 回到底部按钮 */}
- {/* {showScrollButton && (
-
- scroll
-
- )} */}
+
+ {data.map((item, index) => (
+
{itemContent(index, item as any)}
+ ))}
);
}
diff --git a/src/hooks/services/chat.ts b/src/hooks/services/chat.ts
new file mode 100644
index 0000000..aaf8270
--- /dev/null
+++ b/src/hooks/services/chat.ts
@@ -0,0 +1,9 @@
+import { fetchModels } from '@/services/chat';
+import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+
+export function useModels() {
+ return useQuery({
+ queryKey: ['models'],
+ queryFn: () => fetchModels(),
+ });
+}
diff --git a/src/layout/BasicLayout.tsx b/src/layout/BasicLayout.tsx
index b7ca733..42ef571 100644
--- a/src/layout/BasicLayout.tsx
+++ b/src/layout/BasicLayout.tsx
@@ -10,7 +10,7 @@ import { cn } from '@/lib/utils';
import CreateReachedLimitDialog from '../components/features/create-reached-limit-dialog';
import { useMedia } from '@/hooks/tools';
import BottomBar from './BottomBar';
-import { useStreamChatStore } from '@/stores/stream-chat';
+import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
import { useCurrentUser } from '@/hooks/auth';
interface ConditionalLayoutProps {
@@ -20,20 +20,14 @@ interface ConditionalLayoutProps {
const useInitChat = () => {
const { data } = useCurrentUser();
const connect = useStreamChatStore((state) => state.connect);
- const queryChannels = useStreamChatStore((state) => state.queryChannels);
- const initChat = async () => {
+ useEffect(() => {
if (data) {
- await connect({
+ connect({
userId: data.userId + '',
userName: data.nickname,
});
- await queryChannels({});
}
- };
-
- useEffect(() => {
- initChat();
}, [data]);
};
diff --git a/src/layout/Sidebar.tsx b/src/layout/Sidebar.tsx
index ee6b479..c8d3ba2 100644
--- a/src/layout/Sidebar.tsx
+++ b/src/layout/Sidebar.tsx
@@ -8,8 +8,6 @@ import { Badge } from '../components/ui/badge';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useLayoutStore } from '@/stores';
-import { useCurrentUser } from '@/hooks/auth';
-import Notice from './components/Notice';
// 菜单项接口
interface IMenuItem {
@@ -27,7 +25,6 @@ function Sidebar() {
const isSidebarExpanded = useLayoutStore((s) => s.isSidebarExpanded);
const setSidebarExpanded = useLayoutStore((s) => s.setSidebarExpanded);
const setHydrated = useLayoutStore((s) => s.setHydrated);
- const { data: user } = useCurrentUser();
// 在客户端挂载后,从 localStorage 恢复侧边栏状态
useEffect(() => {
@@ -103,7 +100,6 @@ function Sidebar() {
-
);
diff --git a/src/layout/Topbar.tsx b/src/layout/Topbar.tsx
index d11389a..9906beb 100644
--- a/src/layout/Topbar.tsx
+++ b/src/layout/Topbar.tsx
@@ -8,6 +8,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '../components/ui/avatar';
import Link from 'next/link';
import { usePathname, useSearchParams, useRouter } from 'next/navigation';
import { useMedia } from '@/hooks/tools';
+import Notice from './components/Notice';
import { items } from './BottomBar';
const mobileHidenMenus = ['/profile/edit', '/profile/account'];
@@ -79,18 +80,21 @@ function Topbar() {
);
return (
-
-
-
- {user.nickname?.slice(0, 1)}
-
-
+
+
+
+
+
+ {user.nickname?.slice(0, 1)}
+
+
+
);
};
diff --git a/src/layout/components/ChatSearchResults.tsx b/src/layout/components/ChatSearchResults.tsx
index b7e0908..b000fe4 100644
--- a/src/layout/components/ChatSearchResults.tsx
+++ b/src/layout/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 '@/stores/stream-chat';
+import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
interface ChatSearchResultsProps {
searchKeyword: string;
diff --git a/src/layout/components/ChatSidebar.tsx b/src/layout/components/ChatSidebar.tsx
index fac8481..c46c078 100644
--- a/src/layout/components/ChatSidebar.tsx
+++ b/src/layout/components/ChatSidebar.tsx
@@ -4,7 +4,7 @@ import ChatSidebarAction from './ChatSidebarAction';
import ChatSearchResults from './ChatSearchResults';
import { Input } from '@/components/ui/input';
import { useState, useEffect, useCallback } from 'react';
-import { useStreamChatStore } from '@/stores/stream-chat';
+import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
import { useLayoutStore } from '@/stores';
import { useParams } from 'next/navigation';
diff --git a/src/layout/components/ChatSidebarAction.tsx b/src/layout/components/ChatSidebarAction.tsx
index 7e6d082..16fd799 100644
--- a/src/layout/components/ChatSidebarAction.tsx
+++ b/src/layout/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 '@/stores/stream-chat';
+import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
import { useAsyncFn } from '@/hooks/tools';
interface ChatSidebarActionProps {
@@ -33,14 +33,17 @@ const ChatSidebarAction = ({
isSearchActive = false,
}: ChatSidebarActionProps) => {
const clearNotifications = useStreamChatStore((state) => state.clearNotifications);
- const clearChannels = useStreamChatStore((state) => state.clearChannels);
+ const clearChannels = useStreamChatStore((state) => state.deleteChannel);
+ const channels = useStreamChatStore((state) => state.channels);
const [isDeleteMessageDialogOpen, setIsDeleteMessageDialogOpen] = useState(false);
const router = useRouter();
const { run: handleClearChannels, loading } = useAsyncFn(async () => {
- await clearChannels();
- setIsDeleteMessageDialogOpen(false);
- router.replace('/');
+ const { result } = await clearChannels(channels.map((ch) => ch.id!));
+ if (result === 'ok') {
+ router.replace('/');
+ setIsDeleteMessageDialogOpen(false);
+ }
});
return (
diff --git a/src/layout/components/Notice.tsx b/src/layout/components/Notice.tsx
index bdb5b8e..319f609 100644
--- a/src/layout/components/Notice.tsx
+++ b/src/layout/components/Notice.tsx
@@ -1,19 +1,19 @@
-import { Badge } from '@/components/ui/badge'
-import { useCurrentUser, useUserNoticeStat } from '@/hooks/auth'
-import Image from 'next/image'
-import { useState, useEffect } from 'react'
-import { usePathname } from 'next/navigation'
-import { useQueryClient } from '@tanstack/react-query'
-import { userKeys } from '@/lib/query-keys'
-import NoticeDrawer from './NoticeDrawer'
+import { Badge } from '@/components/ui/badge';
+import { useCurrentUser, useUserNoticeStat } from '@/hooks/auth';
+import Image from 'next/image';
+import { useState, useEffect } from 'react';
+import { usePathname } from 'next/navigation';
+import { useQueryClient } from '@tanstack/react-query';
+import { userKeys } from '@/lib/query-keys';
+import NoticeDrawer from './NoticeDrawer';
-const Notice = ({ actualIsExpanded }: { actualIsExpanded: boolean }) => {
- const { data: user } = useCurrentUser()
- const [isDrawerOpen, setIsDrawerOpen] = useState(false)
- const pathname = usePathname()
- const queryClient = useQueryClient()
+const Notice = () => {
+ const { data: user } = useCurrentUser();
+ const [isDrawerOpen, setIsDrawerOpen] = useState(false);
+ const pathname = usePathname();
+ const queryClient = useQueryClient();
- const { data } = useUserNoticeStat()
+ const { data } = useUserNoticeStat();
// 监听路径变化,刷新通知统计
useEffect(() => {
@@ -21,28 +21,20 @@ const Notice = ({ actualIsExpanded }: { actualIsExpanded: boolean }) => {
// 当路径变化时,无效化并重新获取通知统计数据
queryClient.invalidateQueries({
queryKey: userKeys.noticeStat(),
- })
+ });
}
- }, [pathname])
+ }, [pathname]);
useEffect(() => {
if (isDrawerOpen && user) {
queryClient.invalidateQueries({
queryKey: userKeys.noticeStat(),
- })
+ });
}
- }, [isDrawerOpen])
+ }, [isDrawerOpen]);
return (
-
- {/* 分割线 */}
- {user && (
-
- )}
-
- {/* 底部通知 */}
+ <>
{user && (
{
- {actualIsExpanded && (
+ {/* {actualIsExpanded && (
Notice
- )}
+ )} */}
)}
-
- {/* 通知抽屉 */}
-
- )
-}
+ >
+ );
+};
-export default Notice
+export default Notice;
diff --git a/src/lib/protect.ts b/src/lib/protect.ts
new file mode 100644
index 0000000..af48a5e
--- /dev/null
+++ b/src/lib/protect.ts
@@ -0,0 +1,61 @@
+type ProtectOptions = {
+ maxRetries?: number;
+ delay?: number;
+ onRetry?: (error: any, attempt: number) => void;
+};
+
+/**
+ * 保护函数,用于包裹异步请求并在失败时自动重试
+ * @param fn 需要执行的异步函数
+ * @param options 配置项
+ * @param options.maxRetries 最大重试次数,默认为 3
+ * @param options.delay 重试间隔时间(毫秒),默认为 1000
+ * @param options.onRetry 重试时的回调函数
+ * @returns 返回函数执行结果
+ */
+export async function protect