From 6610d2f1733150f262ff60a205d5b9c386a17fd2 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Sun, 12 Apr 2026 18:14:29 +0800 Subject: [PATCH] refactor(core): introduce EventBus and refactor store infrastructure - Add EventBus for decoupled event-driven communication between services - Add EventSubscriber component for centralized event handling - Add requestDedupe utility for shared request deduplication - Refactor api/wsService to use eventBus instead of direct router navigation - Extract MessageMapper.toCachedMessages for consistent message mapping - Remove deprecated BaseManager and CacheBus classes - Improve @mention rendering with memberMap support in segment rendering - Update hooks to use useRef instead of useState for fetch tracking --- app/_layout.tsx | 7 +- src/core/events/EventBus.ts | 31 +++++ src/core/events/index.ts | 2 + src/core/events/useEventBus.ts | 8 ++ src/core/usecases/ProcessMessageUseCase.ts | 30 ++--- src/data/mappers/MessageMapper.ts | 20 +++ src/infrastructure/EventSubscriber.tsx | 44 ++++++ src/screens/message/ChatScreen.tsx | 12 ++ .../components/ChatScreen/LongPressMenu.tsx | 6 +- .../components/ChatScreen/SegmentRenderer.tsx | 70 +++++++--- .../message/components/ChatScreen/types.ts | 3 +- .../message/components/EmbeddedChat.tsx | 12 ++ src/services/api.ts | 44 +----- src/services/webrtc/WebRTCManager.ts | 4 +- src/services/wsService.ts | 18 +-- src/stores/baseManager.ts | 127 ------------------ src/stores/cacheBus.ts | 52 ------- src/stores/group/GroupManager.ts | 27 ++-- src/stores/group/hooks.ts | 48 +++---- .../message/services/MessageSyncService.ts | 62 ++------- .../message/services/WSMessageHandler.ts | 25 ++-- src/stores/message/types.ts | 2 +- src/stores/post/PostManager.ts | 50 +++---- src/stores/post/hooks.ts | 35 ++--- src/stores/user/UserManager.ts | 23 +--- src/stores/user/hooks.ts | 61 ++++----- src/stores/utils/requestDedupe.ts | 58 ++++++++ src/types/dto.ts | 9 +- 28 files changed, 378 insertions(+), 512 deletions(-) create mode 100644 src/core/events/EventBus.ts create mode 100644 src/core/events/index.ts create mode 100644 src/core/events/useEventBus.ts create mode 100644 src/infrastructure/EventSubscriber.tsx delete mode 100644 src/stores/baseManager.ts delete mode 100644 src/stores/cacheBus.ts create mode 100644 src/stores/utils/requestDedupe.ts diff --git a/app/_layout.tsx b/app/_layout.tsx index e06725e..07b90d8 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -12,6 +12,7 @@ import * as SystemUI from 'expo-system-ui'; import { registerNotificationPresentationHandler } from '../src/services/notificationPreferences'; import { api } from '../src/services/api'; import { wsService } from '../src/services/wsService'; +import { EventSubscriber } from '../src/infrastructure/EventSubscriber'; import { ThemeBootstrap, useAppColors, @@ -185,15 +186,11 @@ function ThemedStack() { const colors = useAppColors(); const resolved = useResolvedColorScheme(); - useEffect(() => { - api.setExpoRouter(router); - wsService.setRouter(router); - }, [router]); - return ( <> + diff --git a/src/core/events/EventBus.ts b/src/core/events/EventBus.ts new file mode 100644 index 0000000..f151f33 --- /dev/null +++ b/src/core/events/EventBus.ts @@ -0,0 +1,31 @@ +type Subscriber = (event: T) => void; + +export type AppEvent = + | { type: 'NAVIGATE'; payload: { path: string; params?: Record } } + | { type: 'NAVIGATE_BACK' } + | { type: 'SHOW_VERIFICATION_MODAL' } + | { type: 'VIBRATE'; payload: { type: 'message' | 'group_message' | 'call' } } + | { type: 'SHOW_TOAST'; payload: { message: string; type: 'success' | 'error' | 'info' } } + | { type: 'AUTH_LOGOUT' } + | { type: 'AUTH_LOGIN'; payload: { userId: string } }; + +class EventBusClass { + private subscribers: Set> = new Set(); + + emit(event: AppEvent): void { + this.subscribers.forEach(subscriber => { + try { + subscriber(event); + } catch (error) { + console.error('[EventBus] Subscriber error:', error); + } + }); + } + + subscribe(subscriber: Subscriber): () => void { + this.subscribers.add(subscriber); + return () => this.subscribers.delete(subscriber); + } +} + +export const eventBus = new EventBusClass(); diff --git a/src/core/events/index.ts b/src/core/events/index.ts new file mode 100644 index 0000000..2f57480 --- /dev/null +++ b/src/core/events/index.ts @@ -0,0 +1,2 @@ +export { eventBus, type AppEvent } from './EventBus'; +export { useEventBus } from './useEventBus'; diff --git a/src/core/events/useEventBus.ts b/src/core/events/useEventBus.ts new file mode 100644 index 0000000..c56915a --- /dev/null +++ b/src/core/events/useEventBus.ts @@ -0,0 +1,8 @@ +import { useEffect } from 'react'; +import { eventBus, AppEvent } from './EventBus'; + +export function useEventBus(handler: (event: AppEvent) => void): void { + useEffect(() => { + return eventBus.subscribe(handler); + }, [handler]); +} diff --git a/src/core/usecases/ProcessMessageUseCase.ts b/src/core/usecases/ProcessMessageUseCase.ts index ad66519..2dd7b9c 100644 --- a/src/core/usecases/ProcessMessageUseCase.ts +++ b/src/core/usecases/ProcessMessageUseCase.ts @@ -6,6 +6,7 @@ import { messageRepository, userCacheRepository, CachedMessage } from '@/database'; import { wsClient, WSEventType } from '../../data/datasources/WSClient'; +import { MessageMapper } from '@/data/mappers'; import { Message, Conversation, @@ -50,7 +51,7 @@ interface ReadStateRecord { timestamp: number; version: number; lastReadSeq: number; - clearTimer?: NodeJS.Timeout; + clearTimer?: ReturnType; } class ProcessMessageUseCase { @@ -596,16 +597,9 @@ class ProcessMessageUseCase { ); // 保存到本地 - await messageRepository.saveMessagesBatch(serverMessages.map((m: any) => ({ - id: m.id, - conversationId: m.conversation_id || conversationId, - senderId: m.sender_id, - seq: m.seq, - segments: m.segments || [], - createdAt: m.created_at, - status: m.status || 'normal', - isRead: false, - }))); + await messageRepository.saveMessagesBatch( + MessageMapper.toCachedMessages(serverMessages, conversationId) + ); return serverMessages; } @@ -639,17 +633,9 @@ class ProcessMessageUseCase { }) ); - // 保存到本地 - await messageRepository.saveMessagesBatch(messages.map((m: any) => ({ - id: m.id, - conversationId: m.conversation_id || conversationId, - senderId: m.sender_id, - seq: m.seq, - segments: m.segments || [], - createdAt: m.created_at, - status: m.status || 'normal', - isRead: false, - }))); + await messageRepository.saveMessagesBatch( + MessageMapper.toCachedMessages(response.messages, conversationId) + ); return messages; } diff --git a/src/data/mappers/MessageMapper.ts b/src/data/mappers/MessageMapper.ts index 44a2742..86a2834 100644 --- a/src/data/mappers/MessageMapper.ts +++ b/src/data/mappers/MessageMapper.ts @@ -12,6 +12,7 @@ import type { MessageResponse, UserDTO, } from '../../types/dto'; +import type { CachedMessage } from '../../database/types/CachedMessage'; // 数据库消息记录类型 export interface MessageDbRecord { @@ -180,4 +181,23 @@ export class MessageMapper { avatar: sender.avatar || undefined, }; } + + static toCachedMessage(apiMessage: any, conversationId?: string): CachedMessage { + return { + id: apiMessage.id, + conversationId: apiMessage.conversation_id || conversationId || '', + senderId: apiMessage.sender_id, + content: apiMessage.content, + type: apiMessage.type || 'text', + isRead: apiMessage.is_read || false, + createdAt: apiMessage.created_at, + seq: apiMessage.seq, + status: apiMessage.status || 'normal', + segments: apiMessage.segments, + }; + } + + static toCachedMessages(apiMessages: any[], conversationId?: string): CachedMessage[] { + return apiMessages.map(m => this.toCachedMessage(m, conversationId)); + } } diff --git a/src/infrastructure/EventSubscriber.tsx b/src/infrastructure/EventSubscriber.tsx new file mode 100644 index 0000000..4e0b007 --- /dev/null +++ b/src/infrastructure/EventSubscriber.tsx @@ -0,0 +1,44 @@ +import { useEffect } from 'react'; +import { router } from 'expo-router'; +import * as Haptics from 'expo-haptics'; +import { eventBus, AppEvent } from '@/core/events/EventBus'; +import { hrefVerificationGuide } from '@/navigation/hrefs'; + +let verificationModalShown = false; + +export function EventSubscriber() { + useEffect(() => { + return eventBus.subscribe((event: AppEvent) => { + switch (event.type) { + case 'NAVIGATE': + router.push(event.payload.path); + break; + + case 'NAVIGATE_BACK': + router.back(); + break; + + case 'SHOW_VERIFICATION_MODAL': + if (verificationModalShown) return; + verificationModalShown = true; + router.push(hrefVerificationGuide()); + setTimeout(() => { + verificationModalShown = false; + }, 3000); + break; + + case 'VIBRATE': + Haptics.notificationAsync( + event.payload.type === 'message' + ? Haptics.NotificationFeedbackType.Success + : event.payload.type === 'group_message' + ? Haptics.NotificationFeedbackType.Warning + : Haptics.NotificationFeedbackType.Error + ).catch(() => {}); + break; + } + }); + }, []); + + return null; +} diff --git a/src/screens/message/ChatScreen.tsx b/src/screens/message/ChatScreen.tsx index b737cc3..64c3537 100644 --- a/src/screens/message/ChatScreen.tsx +++ b/src/screens/message/ChatScreen.tsx @@ -211,6 +211,17 @@ export const ChatScreen: React.FC = () => { } = useChatScreen(); const displayMessages = useMemo(() => [...messages].reverse(), [messages]); + const longPressMenuMemberMap = useMemo(() => { + const map = new Map(); + groupMembers.forEach(m => { + map.set(m.user_id, { + nickname: m.nickname || m.user?.nickname || '用户', + user_id: m.user_id, + }); + }); + return map; + }, [groupMembers]); + useEffect(() => { if (!loadingMore && showEdgeLoadingIndicator) { setShowEdgeLoadingIndicator(false); @@ -629,6 +640,7 @@ export const ChatScreen: React.FC = () => { onReply={handleReplyMessage} onRecall={handleRecall} onDelete={handleDeleteMessage} + memberMap={longPressMenuMemberMap} /> {/* 图片查看器 */} diff --git a/src/screens/message/components/ChatScreen/LongPressMenu.tsx b/src/screens/message/components/ChatScreen/LongPressMenu.tsx index eb368a4..dfc4554 100644 --- a/src/screens/message/components/ChatScreen/LongPressMenu.tsx +++ b/src/screens/message/components/ChatScreen/LongPressMenu.tsx @@ -34,6 +34,8 @@ export const LongPressMenu: React.FC = ({ onRecall, onDelete, onAddSticker, + onReport, + memberMap, }) => { const styles = useChatScreenStyles(); const scaleAnimation = useRef(new Animated.Value(0)).current; @@ -90,7 +92,7 @@ export const LongPressMenu: React.FC = ({ // 复制文本到剪贴板 const handleCopy = () => { - const text = extractTextFromSegments(message.segments); + const text = extractTextFromSegments(message.segments, memberMap); if (text) { Clipboard.setString(text); onClose(); @@ -99,7 +101,7 @@ export const LongPressMenu: React.FC = ({ // 多选功能(这里简化为复制全部) const handleMultiSelect = () => { - const text = extractTextFromSegments(message.segments); + const text = extractTextFromSegments(message.segments, memberMap); if (text) { Clipboard.setString(text); onClose(); diff --git a/src/screens/message/components/ChatScreen/SegmentRenderer.tsx b/src/screens/message/components/ChatScreen/SegmentRenderer.tsx index 589d7d5..2ca2698 100644 --- a/src/screens/message/components/ChatScreen/SegmentRenderer.tsx +++ b/src/screens/message/components/ChatScreen/SegmentRenderer.tsx @@ -141,7 +141,7 @@ export const renderSegment = ( }; /** - * 渲染文本 Segment + * 渲染文本 Segment(非内联,块级使用) */ const TextSegmentBody: React.FC<{ data: TextSegmentData; isMe: boolean }> = ({ data, isMe }) => { const styles = useSegmentStyles(); @@ -392,7 +392,7 @@ const AtSegmentBody: React.FC<{ return ( { if (chunk.kind === 'inline') { return ( - - {chunk.parts.map((segment, j) => ( - - {renderSegment(segment, renderProps)} - - ))} - + + {chunk.parts.map((segment, j) => { + if (segment.type === 'text') { + const data = segment.data as TextSegmentData; + return data.content ?? data.text ?? ''; + } + if (segment.type === 'at') { + const data = segment.data as AtSegmentData; + const isAtMe = data.user_id === currentUserId || data.user_id === 'all'; + let displayName: string; + if (data.user_id === 'all') { + displayName = '所有人'; + } else { + const member = memberMap?.get(data.user_id); + displayName = member?.nickname || data.nickname || '用户'; + } + return ( + data.user_id !== 'all' && onAtPress?.(data.user_id)} + > + @{displayName} + + ); + } + if (segment.type === 'face') { + const data = segment.data as FaceSegmentData; + if (data.url) { + return ( + + ); + } + return `[${data.name || `表情${data.id}`}]`; + } + return null; + })} + ); } if (chunk.kind === 'images') { @@ -836,11 +868,11 @@ function createSegmentStyles(colors: AppColors, fontSize: number = 16) { flexDirection: 'column', width: '100%', }, - inlineChunk: { - flexDirection: 'row', - flexWrap: 'wrap', - alignItems: 'flex-end', - width: '100%', + inlineChunkBase: { + fontSize, + lineHeight: Math.round(fontSize * 1.43), + letterSpacing: 0.1, + fontWeight: '400', }, imagesChunk: { width: '100%', @@ -874,11 +906,7 @@ function createSegmentStyles(colors: AppColors, fontSize: number = 16) { color: colors.chat.textPrimary, }, - // @提及 - 与普通文本字号一致,仅颜色区分 - atText: { - fontSize, - lineHeight: Math.round(fontSize * 1.43), - }, + // @提及 - 仅颜色区分,继承父元素字号 atTextMe: { color: '#1A5F9E', }, diff --git a/src/screens/message/components/ChatScreen/types.ts b/src/screens/message/components/ChatScreen/types.ts index 20a1389..a33f891 100644 --- a/src/screens/message/components/ChatScreen/types.ts +++ b/src/screens/message/components/ChatScreen/types.ts @@ -166,7 +166,8 @@ export interface LongPressMenuProps { onRecall: (messageId: string) => void; onDelete: (messageId: string) => void; onAddSticker?: (imageUrl: string) => void; - onReport?: (message: GroupMessage) => void; // 举报消息回调 + onReport?: (message: GroupMessage) => void; + memberMap?: Map; } // 聊天头部 Props diff --git a/src/screens/message/components/EmbeddedChat.tsx b/src/screens/message/components/EmbeddedChat.tsx index 56d98e6..9397b5a 100644 --- a/src/screens/message/components/EmbeddedChat.tsx +++ b/src/screens/message/components/EmbeddedChat.tsx @@ -405,6 +405,17 @@ export const EmbeddedChat: React.FC = ({ conversation, onBack // 群成员列表 const [groupMembers, setGroupMembers] = useState([]); + + const embeddedMemberMap = useMemo(() => { + const map = new Map(); + groupMembers.forEach(m => { + map.set(m.user_id, { + nickname: m.nickname || m.user?.nickname || '用户', + user_id: m.user_id, + }); + }); + return map; + }, [groupMembers]); // 格式化时间 const formatTime = useCallback((dateString: string): string => { @@ -1023,6 +1034,7 @@ export const EmbeddedChat: React.FC = ({ conversation, onBack onReply={handleReply} onRecall={handleRecall} onDelete={handleDeleteMessage} + memberMap={embeddedMemberMap} /> {/* 图片查看器 */} diff --git a/src/services/api.ts b/src/services/api.ts index 98a3253..e4834da 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -6,11 +6,10 @@ */ import AsyncStorage from '@react-native-async-storage/async-storage'; -import { CommonActions } from '@react-navigation/native'; import Constants from 'expo-constants'; import { Platform } from 'react-native'; -import { hrefVerificationGuide } from '../navigation/hrefs'; +import { eventBus } from '@/core/events/EventBus'; // 生产地址 https://bbs.littlelan.cn const getBaseUrl = () => { @@ -74,37 +73,27 @@ interface JwtPayload { // API 客户端类 class ApiClient { private baseUrl: string; - private navigation: any = null; - private expoRouter: any = null; private verificationModalShown = false; - // 刷新锁:防止并发刷新 private refreshLock: Promise | null = null; constructor(baseUrl: string) { this.baseUrl = baseUrl; } - setExpoRouter(router: any) { - this.expoRouter = router; - } - private handleVerificationRequired() { if (this.verificationModalShown) return; this.verificationModalShown = true; - if (this.expoRouter) { - this.expoRouter.push(hrefVerificationGuide()); - } + eventBus.emit({ type: 'SHOW_VERIFICATION_MODAL' }); setTimeout(() => { this.verificationModalShown = false; }, 3000); } - // 设置导航对象(用于处理401跳转) - setNavigation(navigation: any) { - this.navigation = navigation; + private navigateToLogin() { + eventBus.emit({ type: 'NAVIGATE', payload: { path: '/(auth)/login' } }); + eventBus.emit({ type: 'AUTH_LOGOUT' }); } - // 解析 JWT token 获取 payload private parseJwt(token: string): JwtPayload | null { try { const parts = token.split('.'); @@ -208,19 +197,10 @@ class ApiClient { if (this.isTokenExpiringSoon(token)) { const refreshed = await this.refreshToken(); if (!refreshed) { - // 刷新失败,清除 token 并跳转登录 await this.clearToken(); - if (this.navigation) { - this.navigation.dispatch( - CommonActions.reset({ - index: 0, - routes: [{ name: 'Login' }], - }) - ); - } + this.navigateToLogin(); throw new ApiError(401, '登录已过期,请重新登录'); } - // 刷新成功,使用新 token const newToken = await this.getToken(); if (newToken) { headers['Authorization'] = `Bearer ${newToken}`; @@ -246,23 +226,13 @@ class ApiClient { // 处理 401 未授权 if (response.status === 401) { - // 尝试刷新 token const refreshed = await this.refreshToken(); if (!refreshed) { - // 刷新失败,清除 token 并跳转登录 await this.clearToken(); - if (this.navigation) { - this.navigation.dispatch( - CommonActions.reset({ - index: 0, - routes: [{ name: 'Login' }], - }) - ); - } + this.navigateToLogin(); throw new ApiError(401, '登录已过期,请重新登录'); } - // 刷新成功后重试请求 return this.request(method, path, params, body); } diff --git a/src/services/webrtc/WebRTCManager.ts b/src/services/webrtc/WebRTCManager.ts index 00f4bc9..1a5ecf5 100644 --- a/src/services/webrtc/WebRTCManager.ts +++ b/src/services/webrtc/WebRTCManager.ts @@ -38,8 +38,8 @@ class WebRTCManager { // ICE restart 相关状态 private reconnectAttempts = 0; private readonly MAX_RECONNECT_ATTEMPTS = 3; - private reconnectTimer: NodeJS.Timeout | null = null; - private disconnectTimer: NodeJS.Timeout | null = null; + private reconnectTimer: ReturnType | null = null; + private disconnectTimer: ReturnType | null = null; async initialize(iceServers: ICEServer[] = []): Promise { if (this.peerConnection) { diff --git a/src/services/wsService.ts b/src/services/wsService.ts index aa10eb1..d067d5f 100644 --- a/src/services/wsService.ts +++ b/src/services/wsService.ts @@ -1,7 +1,7 @@ import { AppState, AppStateStatus } from 'react-native'; import { api, WS_URL } from './api'; -import { hrefVerificationGuide } from '../navigation/hrefs'; +import { eventBus } from '@/core/events/EventBus'; import { MessageCategory, SystemMessageType, SystemMessageExtraData, MessageSegment } from '../types/dto'; import { systemNotificationService } from './systemNotificationService'; @@ -300,8 +300,8 @@ class WebSocketService { private reconnectAttempts = 0; private maxReconnectAttempts = 20; private reconnectDelay = 3000; - private reconnectTimer: NodeJS.Timeout | null = null; - private heartbeatTimer: NodeJS.Timeout | null = null; + private reconnectTimer: ReturnType | null = null; + private heartbeatTimer: ReturnType | null = null; private heartbeatInterval = 30000; // 30秒心跳 private messageHandlers: Map = new Map(); private connectionHandlers: ConnectionHandler[] = []; @@ -318,7 +318,7 @@ class WebSocketService { // 降级状态 private isFallbackMode = false; - private fallbackCheckTimer: NodeJS.Timeout | null = null; + private fallbackCheckTimer: ReturnType | null = null; private getWSUrl(token: string | null): string { return `${WS_URL}?token=${encodeURIComponent(token || '')}`; @@ -1122,20 +1122,12 @@ class WebSocketService { private handleVerificationRequired(): void { if (this.verificationModalShown) return; this.verificationModalShown = true; - if (this.routerRef) { - this.routerRef.push(hrefVerificationGuide()); - } + eventBus.emit({ type: 'SHOW_VERIFICATION_MODAL' }); setTimeout(() => { this.verificationModalShown = false; }, 3000); } - private routerRef: any = null; - - setRouter(router: any): void { - this.routerRef = router; - } - private startHeartbeat(): void { this.stopHeartbeat(); this.heartbeatTimer = setInterval(() => { diff --git a/src/stores/baseManager.ts b/src/stores/baseManager.ts deleted file mode 100644 index b1ff9c9..0000000 --- a/src/stores/baseManager.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * BaseManager - 通用 Manager 基类 - * - * 提取自 postManager/groupManager/userManager 的重复逻辑: - * - 请求去重 (dedupe) - * - 缓存条目管理 (CacheEntry) - * - 过期检查 (isExpired) - */ - -import { CacheBus, CacheEvent } from './cacheBus'; - -/** 缓存条目结构 */ -export interface CacheEntry { - data: T; - timestamp: number; - ttl: number; -} - -/** 创建缓存条目 */ -export function createCacheEntry(data: T, ttl: number): CacheEntry { - return { data, timestamp: Date.now(), ttl }; -} - -/** 检查缓存是否过期 */ -export function isCacheExpired(entry: CacheEntry): boolean { - return Date.now() - entry.timestamp > entry.ttl; -} - -/** - * 通用 Manager 基类 - * - * 子类只需关注业务逻辑,无需重复实现缓存和去重机制 - */ -export abstract class BaseManager< - TSnapshot, - TEvent extends CacheEvent = CacheEvent -> extends CacheBus { - /** 待处理请求 Map,用于去重 */ - protected pendingRequests = new Map>(); - - /** - * 请求去重:相同 key 的并发请求会被合并为一个 - * @param key 唯一标识 - * @param fetcher 实际请求函数 - */ - protected async dedupe(key: string, fetcher: () => Promise): Promise { - const pending = this.pendingRequests.get(key) as Promise | undefined; - if (pending) return pending; - - const request = fetcher().finally(() => { - this.pendingRequests.delete(key); - }); - this.pendingRequests.set(key, request); - return request; - } - - /** 清除所有待处理请求 */ - protected clearPendingRequests(): void { - this.pendingRequests.clear(); - } -} - -/** - * 带缓存的 Manager 基类 - * - * 进一步封装内存缓存逻辑 - */ -export abstract class CachedManager< - TSnapshot, - TEvent extends CacheEvent = CacheEvent -> extends BaseManager { - /** 内存缓存 Map */ - protected memoryCache = new Map>(); - - /** - * 获取缓存 - * @param key 缓存 key - * @returns 缓存数据,过期或不存在返回 null - */ - protected getFromCache(key: string): T | null { - const entry = this.memoryCache.get(key) as CacheEntry | undefined; - if (!entry) return null; - if (isCacheExpired(entry)) { - this.memoryCache.delete(key); - return null; - } - return entry.data; - } - - /** - * 设置缓存 - * @param key 缓存 key - * @param data 数据 - * @param ttl 过期时间(毫秒) - */ - protected setCache(key: string, data: T, ttl: number): void { - this.memoryCache.set(key, createCacheEntry(data, ttl)); - } - - /** - * 检查缓存是否存在且未过期 - */ - protected hasValidCache(key: string): boolean { - const entry = this.memoryCache.get(key); - if (!entry) return false; - return !isCacheExpired(entry); - } - - /** - * 检查缓存是否存在但已过期 - */ - protected hasExpiredCache(key: string): boolean { - const entry = this.memoryCache.get(key); - if (!entry) return false; - return isCacheExpired(entry); - } - - /** 清除所有缓存 */ - protected clearCache(): void { - this.memoryCache.clear(); - } - - /** 清除指定 key 的缓存 */ - protected deleteCache(key: string): void { - this.memoryCache.delete(key); - } -} diff --git a/src/stores/cacheBus.ts b/src/stores/cacheBus.ts deleted file mode 100644 index 8356900..0000000 --- a/src/stores/cacheBus.ts +++ /dev/null @@ -1,52 +0,0 @@ -export type CacheEventType = - | 'list_updated' - | 'detail_updated' - | 'cache_state_updated' - | 'error'; - -export interface CacheEvent { - type: CacheEventType; - payload: TPayload; - timestamp: number; -} - -export type CacheSubscriber = (event: TEvent) => void; - -/** - * 统一缓存总线基座: - * - 单一订阅入口 - * - 统一事件通知 - * - 新订阅立即收到快照 - */ -export abstract class CacheBus { - private subscribers = new Set>(); - - protected abstract getSnapshot(): TSnapshot; - - protected notify(event: TEvent): void { - this.subscribers.forEach((subscriber) => { - try { - subscriber(event); - } catch (error) { - console.error('[CacheBus] subscriber error:', error); - } - }); - } - - subscribe(subscriber: CacheSubscriber): () => void { - this.subscribers.add(subscriber); - this.emitInitialSnapshot(subscriber); - return () => { - this.subscribers.delete(subscriber); - }; - } - - protected emitInitialSnapshot(subscriber: CacheSubscriber): void { - subscriber({ - type: 'cache_state_updated', - payload: this.getSnapshot(), - timestamp: Date.now(), - } as TEvent); - } -} - diff --git a/src/stores/group/GroupManager.ts b/src/stores/group/GroupManager.ts index 502441e..e4b9a94 100644 --- a/src/stores/group/GroupManager.ts +++ b/src/stores/group/GroupManager.ts @@ -8,6 +8,7 @@ import { groupService } from '../../services/groupService'; import { groupCacheRepository, userCacheRepository } from '@/database'; import { useGroupManagerStore } from './groupStore'; import { createCacheEntry, isCacheExpired, DEFAULT_TTL } from '../utils/cache'; +import { createDedupe } from '../utils/requestDedupe'; import { IGroupListPagedSource, IGroupMemberListPagedSource, @@ -18,6 +19,8 @@ import { GROUP_MEMBER_LIST_PAGE_SIZE, } from '../groupListSources'; +const dedupe = createDedupe(() => useGroupManagerStore); + function buildMembersKey(groupId: string, page: number, pageSize: number): string { return `members:${groupId}:${page}:${pageSize}`; } @@ -74,7 +77,7 @@ class GroupManager { } } - return this.dedupe(`groups:list:${page}:${pageSize}`, async () => { + return dedupe(`groups:list:${page}:${pageSize}`, async () => { const store = useGroupManagerStore.getState(); const response = await groupService.getGroups(page, pageSize); const groups = response.list || []; @@ -87,7 +90,7 @@ class GroupManager { } private refreshGroupsInBackground(page = 1, pageSize = GROUP_LIST_PAGE_SIZE): void { - this.dedupe(`groups:bg:list:${page}:${pageSize}`, async () => { + dedupe(`groups:bg:list:${page}:${pageSize}`, async () => { const store = useGroupManagerStore.getState(); const response = await groupService.getGroups(page, pageSize); const groups = response.list || []; @@ -130,7 +133,7 @@ class GroupManager { } } - return this.dedupe(`groups:detail:${groupId}`, async () => { + return dedupe(`groups:detail:${groupId}`, async () => { const store = useGroupManagerStore.getState(); const group = await groupService.getGroup(groupId); store.setGroup(groupId, group); @@ -140,7 +143,7 @@ class GroupManager { } private refreshGroupInBackground(groupId: string): void { - this.dedupe(`groups:detail:bg:${groupId}`, async () => { + dedupe(`groups:detail:bg:${groupId}`, async () => { const store = useGroupManagerStore.getState(); const group = await groupService.getGroup(groupId); store.setGroup(groupId, group); @@ -179,7 +182,7 @@ class GroupManager { } } - return this.dedupe(`groups:members:${key}`, async () => { + return dedupe(`groups:members:${key}`, async () => { const store = useGroupManagerStore.getState(); const response = await groupService.getMembers(groupId, page, pageSize); const members = response.list || []; @@ -202,7 +205,7 @@ class GroupManager { pageSize = GROUP_MEMBER_LIST_PAGE_SIZE ): void { const key = buildMembersKey(groupId, page, pageSize); - this.dedupe(`groups:members:bg:${key}`, async () => { + dedupe(`groups:members:bg:${key}`, async () => { const store = useGroupManagerStore.getState(); const response = await groupService.getMembers(groupId, page, pageSize); const members = response.list || []; @@ -240,18 +243,6 @@ class GroupManager { clear(): void { useGroupManagerStore.getState().reset(); } - - private async dedupe(key: string, fetcher: () => Promise): Promise { - const store = useGroupManagerStore.getState(); - const pending = store.getPendingRequest(key); - if (pending) return pending; - - const request = fetcher().finally(() => { - useGroupManagerStore.getState().deletePendingRequest(key); - }); - store.setPendingRequest(key, request); - return request; - } } export const groupManager = new GroupManager(); diff --git a/src/stores/group/hooks.ts b/src/stores/group/hooks.ts index 7ae76a4..8ea5b52 100644 --- a/src/stores/group/hooks.ts +++ b/src/stores/group/hooks.ts @@ -1,38 +1,29 @@ -/** - * 群组相关 React Hooks - */ - -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { useGroupManagerStore } from './groupStore'; import { groupManager } from './GroupManager'; import type { GroupResponse, GroupMemberResponse } from '../../types/dto'; import { GROUP_LIST_PAGE_SIZE, GROUP_MEMBER_LIST_PAGE_SIZE } from '../groupListSources'; -// ==================== useGroups ==================== - export interface UseGroupsResult { groups: GroupResponse[]; isLoading: boolean; refresh: () => Promise; } -/** - * 获取群组列表 hook - */ export function useGroups(): UseGroupsResult { const groups = useGroupManagerStore(state => state.groupsListEntry?.data ?? []); const isLoading = useGroupManagerStore(state => state.isLoadingGroups); - const [hasFetched, setHasFetched] = useState(false); + const hasFetchedRef = useRef(false); useEffect(() => { - if (!hasFetched) { - setHasFetched(true); + if (!hasFetchedRef.current) { + hasFetchedRef.current = true; useGroupManagerStore.getState().setLoadingGroups(true); groupManager.getGroups(1, GROUP_LIST_PAGE_SIZE).finally(() => { useGroupManagerStore.getState().setLoadingGroups(false); }); } - }, [hasFetched]); + }, []); const refresh = useCallback(async () => { useGroupManagerStore.getState().setLoadingGroups(true); @@ -46,17 +37,12 @@ export function useGroups(): UseGroupsResult { return { groups, isLoading, refresh }; } -// ==================== useGroup ==================== - export interface UseGroupResult { group: GroupResponse | null; isLoading: boolean; refresh: () => Promise; } -/** - * 获取单个群组 hook - */ export function useGroup(groupId: string | undefined | null): UseGroupResult { const group = useGroupManagerStore(state => groupId ? (state.groupsMap.get(groupId)?.data ?? null) : null @@ -64,18 +50,18 @@ export function useGroup(groupId: string | undefined | null): UseGroupResult { const isLoading = useGroupManagerStore(state => groupId ? state.loadingGroupIds.has(groupId) : false ); - const [hasFetched, setHasFetched] = useState(false); + const lastFetchedRef = useRef(null); useEffect(() => { if (!groupId) return; - if (!hasFetched) { - setHasFetched(true); + if (lastFetchedRef.current !== groupId) { + lastFetchedRef.current = groupId; useGroupManagerStore.getState().setLoadingGroup(groupId, true); groupManager.getGroup(groupId).finally(() => { useGroupManagerStore.getState().setLoadingGroup(groupId, false); }); } - }, [groupId, hasFetched]); + }, [groupId]); const refresh = useCallback(async () => { if (!groupId) return null; @@ -90,17 +76,12 @@ export function useGroup(groupId: string | undefined | null): UseGroupResult { return { group, isLoading, refresh }; } -// ==================== useGroupMembers ==================== - export interface UseGroupMembersResult { members: GroupMemberResponse[]; isLoading: boolean; refresh: () => Promise; } -/** - * 获取群组成员 hook - */ export function useGroupMembers( groupId: string | undefined | null, page = 1, @@ -113,18 +94,19 @@ export function useGroupMembers( const isLoading = useGroupManagerStore(state => membersKey ? state.loadingMemberKeys.has(membersKey) : false ); - const [hasFetched, setHasFetched] = useState(false); + const lastFetchedRef = useRef(null); useEffect(() => { if (!groupId) return; - if (!hasFetched) { - setHasFetched(true); + const key = `${groupId}:${page}:${pageSize}`; + if (lastFetchedRef.current !== key) { + lastFetchedRef.current = key; useGroupManagerStore.getState().setLoadingMembers(groupId, true, page, pageSize); groupManager.getMembers(groupId, page, pageSize).finally(() => { useGroupManagerStore.getState().setLoadingMembers(groupId, false, page, pageSize); }); } - }, [groupId, page, pageSize, hasFetched]); + }, [groupId, page, pageSize]); const refresh = useCallback(async () => { if (!groupId) return; @@ -137,4 +119,4 @@ export function useGroupMembers( }, [groupId, page, pageSize]); return { members, isLoading, refresh }; -} +} \ No newline at end of file diff --git a/src/stores/message/services/MessageSyncService.ts b/src/stores/message/services/MessageSyncService.ts index efbd660..dac0dc9 100644 --- a/src/stores/message/services/MessageSyncService.ts +++ b/src/stores/message/services/MessageSyncService.ts @@ -10,6 +10,7 @@ import type { ConversationResponse, MessageResponse } from '../../../types/dto'; import { messageService } from '../../../services/messageService'; import { messageRepository, conversationRepository } from '@/database'; +import { MessageMapper } from '@/data/mappers'; import { type IConversationListPagedSource, SqliteConversationListPagedSource, @@ -242,19 +243,9 @@ export class MessageSyncService implements IMessageSyncService { const mergedSnapshot = mergeMessagesById(existingMessages, snapshotMessages); store.setMessages(conversationId, mergedSnapshot); - // 持久化到本地 - messageRepository.saveMessagesBatch(snapshotMessages.map((m: any) => ({ - id: m.id, - conversationId: m.conversation_id || conversationId, - senderId: m.sender_id, - content: m.content, - type: m.type || 'text', - isRead: m.is_read || false, - createdAt: m.created_at, - seq: m.seq, - status: m.status || 'normal', - segments: m.segments, - }))).catch(error => { + messageRepository.saveMessagesBatch( + MessageMapper.toCachedMessages(snapshotMessages, conversationId) + ).catch(error => { console.error('[MessageSyncService] 保存快照消息到本地失败:', error); }); } @@ -270,18 +261,9 @@ export class MessageSyncService implements IMessageSyncService { const mergedMessages = mergeMessagesById(existingMessages, newMessages); store.setMessages(conversationId, mergedMessages); - messageRepository.saveMessagesBatch(newMessages.map((m: any) => ({ - id: m.id, - conversationId: m.conversation_id || conversationId, - senderId: m.sender_id, - content: m.content, - type: m.type || 'text', - isRead: m.is_read || false, - createdAt: m.created_at, - seq: m.seq, - status: m.status || 'normal', - segments: m.segments, - }))).catch(error => { + messageRepository.saveMessagesBatch( + MessageMapper.toCachedMessages(newMessages, conversationId) + ).catch(error => { console.error('[MessageSyncService] 保存增量消息到本地失败:', error); }); } @@ -300,18 +282,9 @@ export class MessageSyncService implements IMessageSyncService { store.setMessages(conversationId, mergedMessages); - messageRepository.saveMessagesBatch(newMessages.map((m: any) => ({ - id: m.id, - conversationId: m.conversation_id || conversationId, - senderId: m.sender_id, - content: m.content, - type: m.type || 'text', - isRead: m.is_read || false, - createdAt: m.created_at, - seq: m.seq, - status: m.status || 'normal', - segments: m.segments, - }))).catch(error => { + messageRepository.saveMessagesBatch( + MessageMapper.toCachedMessages(newMessages, conversationId) + ).catch(error => { console.error('[MessageSyncService] 保存消息到本地失败:', error); }); } @@ -368,18 +341,9 @@ export class MessageSyncService implements IMessageSyncService { if (response?.messages && response.messages.length > 0) { const serverMessages = response.messages; - await messageRepository.saveMessagesBatch(serverMessages.map((m: any) => ({ - id: m.id, - conversationId: m.conversation_id || conversationId, - senderId: m.sender_id, - content: m.content, - type: m.type || 'text', - isRead: m.is_read || false, - createdAt: m.created_at, - seq: m.seq, - status: m.status || 'normal', - segments: m.segments, - }))); + await messageRepository.saveMessagesBatch( + MessageMapper.toCachedMessages(serverMessages, conversationId) + ); const existingMessages = store.getMessages(conversationId); const mergedMessages = this.mergeOlderMessages(existingMessages, serverMessages); diff --git a/src/stores/message/services/WSMessageHandler.ts b/src/stores/message/services/WSMessageHandler.ts index f66a436..58e34b8 100644 --- a/src/stores/message/services/WSMessageHandler.ts +++ b/src/stores/message/services/WSMessageHandler.ts @@ -18,7 +18,7 @@ import type { WSGroupNoticeMessage, } from '../../../services/wsService'; import { wsService, GroupNoticeType } from '../../../services/wsService'; -import { vibrateOnMessage } from '../../../services/messageVibrationService'; +import { eventBus } from '@/core/events/EventBus'; import { messageRepository } from '@/database'; import type { MessageResponse, ConversationResponse } from '../../../types/dto'; import type { @@ -75,29 +75,24 @@ export class WSMessageHandler implements IWSMessageHandler { this.sseUnsubscribe(); } - const store = useMessageStore.getState(); - - // 监听私聊消息 wsService.on('chat', (message: WSChatMessage) => { - if (!store.getState().isInitialized || this.isBootstrapping) { + if (!useMessageStore.getState().isInitialized || this.isBootstrapping) { this.bufferedChatMessages.push(message); return; } this.handleNewMessage(message); }); - // 监听群聊消息 wsService.on('group_message', (message: WSGroupChatMessage) => { - if (!store.getState().isInitialized || this.isBootstrapping) { + if (!useMessageStore.getState().isInitialized || this.isBootstrapping) { this.bufferedChatMessages.push(message); return; } this.handleNewMessage(message); }); - // 监听私聊已读回执 wsService.on('read', (message: WSReadMessage) => { - if (!store.getState().isInitialized || this.isBootstrapping) { + if (!useMessageStore.getState().isInitialized || this.isBootstrapping) { this.bufferedReadReceipts.push(message); return; } @@ -106,7 +101,7 @@ export class WSMessageHandler implements IWSMessageHandler { // 监听群聊已读回执 wsService.on('group_read', (message: WSGroupReadMessage) => { - if (!store.getState().isInitialized || this.isBootstrapping) { + if (!useMessageStore.getState().isInitialized || this.isBootstrapping) { this.bufferedReadReceipts.push(message); return; } @@ -135,13 +130,13 @@ export class WSMessageHandler implements IWSMessageHandler { // 监听连接状态 wsService.onConnect(() => { - store.setSSEConnected(true); + useMessageStore.getState().setSSEConnected(true); // 冷启动/重连兜底 const now = Date.now(); if (now - this.lastReconnectSyncAt > 1500) { this.lastReconnectSyncAt = now; - const activeConversation = store.getActiveConversation(); + const activeConversation = useMessageStore.getState().getActiveConversation(); if (!activeConversation) { this.fetchConversationsCallback(true, 'sse-reconnect').catch(error => { @@ -162,7 +157,7 @@ export class WSMessageHandler implements IWSMessageHandler { }); wsService.onDisconnect(() => { - store.setSSEConnected(false); + useMessageStore.getState().setSSEConnected(false); }); } @@ -305,8 +300,8 @@ export class WSMessageHandler implements IWSMessageHandler { if (shouldIncrementUnread) { if (!suppressVibration) { - const vibrationType = message.type === 'group_message' ? 'group_message' : 'chat'; - vibrateOnMessage(vibrationType).catch(() => {}); + const vibrationType = message.type === 'group_message' ? 'group_message' : 'message'; + eventBus.emit({ type: 'VIBRATE', payload: { type: vibrationType } }); } this.incrementUnreadCount(normalizedConversationId); } diff --git a/src/stores/message/types.ts b/src/stores/message/types.ts index 7758359..39d5c39 100644 --- a/src/stores/message/types.ts +++ b/src/stores/message/types.ts @@ -96,7 +96,7 @@ export interface ReadStateRecord { /** 已上报的已读序号(用于去重) */ lastReadSeq: number; /** 清除保护的定时器ID */ - clearTimer?: NodeJS.Timeout; + clearTimer?: ReturnType; } // ==================== 依赖注入接口 ==================== diff --git a/src/stores/post/PostManager.ts b/src/stores/post/PostManager.ts index 0a4875b..948f56a 100644 --- a/src/stores/post/PostManager.ts +++ b/src/stores/post/PostManager.ts @@ -1,14 +1,15 @@ /** - * PostManager - 帖子管理核心模块(重构版? + * PostManager - 帖子管理核心模块(重构版�? * - * 使用 Zustand store 进行状态管? - * 保持与旧 API 的向后兼? + * 使用 Zustand store 进行状态管�? + * 保持与旧 API 的向后兼�? */ import type { Post } from '../../types'; import { postService } from '../../services/postService'; import { usePostManagerStore } from './postStore'; import { createCacheEntry, isCacheExpired, DEFAULT_TTL } from '../utils/cache'; +import { createDedupe } from '../utils/requestDedupe'; import { IPostListPagedSource, PostListTab, @@ -17,13 +18,13 @@ import { POST_LIST_PAGE_SIZE, } from '../postListSources'; -// ==================== 辅助函数 ==================== - function buildListKey(type: string, page: number, pageSize: number): string { return `list:${type}:${page}:${pageSize}`; } -// ==================== PostManager ?==================== +const dedupe = createDedupe(() => usePostManagerStore); + +// ==================== PostManager �?==================== class PostManager { // ==================== 列表相关 ==================== @@ -41,19 +42,19 @@ class PostManager { const store = usePostManagerStore.getState(); const entry = store.listsMap.get(key); - // 检查有效缓? + // 检查有效缓�? if (!forceRefresh && entry && !isCacheExpired(entry)) { return entry.data; } - // 过期缓存:后台刷新,立即返回旧数? + // 过期缓存:后台刷新,立即返回旧数�? if (!forceRefresh && entry && isCacheExpired(entry)) { this.refreshPostsInBackground(key, type, page, pageSize); return entry.data; } // 无缓存:发起请求 - return this.dedupe(`posts:${key}`, async () => { + return dedupe(`posts:${key}`, async () => { const store = usePostManagerStore.getState(); const response = await postService.getPosts(page, pageSize, type); const posts = response.list || []; @@ -68,7 +69,7 @@ class PostManager { page: number, pageSize: number ): void { - this.dedupe(`posts:bg:${key}`, async () => { + dedupe(`posts:bg:${key}`, async () => { const store = usePostManagerStore.getState(); const response = await postService.getPosts(page, pageSize, type); const posts = response.list || []; @@ -80,7 +81,7 @@ class PostManager { } /** - * 创建帖子列表数据源(用于 Sources 模式? + * 创建帖子列表数据源(用于 Sources 模式�? */ createPostListSource( options: { tab?: PostListTab; channelId?: string; pageSize?: number } = {}, @@ -103,14 +104,14 @@ class PostManager { return entry.data; } - // 过期缓存:后台刷? + // 过期缓存:后台刷�? if (!forceRefresh && entry && isCacheExpired(entry) && entry.data) { this.refreshPostDetailInBackground(postId); return entry.data; } // 无缓存:发起请求 - return this.dedupe(`posts:detail:${postId}`, async () => { + return dedupe(`posts:detail:${postId}`, async () => { const store = usePostManagerStore.getState(); const post = await postService.getPost(postId); store.setPostDetail(postId, post); @@ -119,7 +120,7 @@ class PostManager { } private refreshPostDetailInBackground(postId: string): void { - this.dedupe(`posts:detail:bg:${postId}`, async () => { + dedupe(`posts:detail:bg:${postId}`, async () => { const store = usePostManagerStore.getState(); const post = await postService.getPost(postId); store.setPostDetail(postId, post); @@ -132,7 +133,7 @@ class PostManager { // ==================== 缓存管理 ==================== /** - * 使缓存失? + * 使缓存失�? */ invalidate(postId?: string): void { if (postId) { @@ -143,32 +144,15 @@ class PostManager { } /** - * 清除所有数? + * 清除所有数�? */ clear(): void { usePostManagerStore.getState().reset(); } - - // ==================== 请求去重 ==================== - - private async dedupe(key: string, fetcher: () => Promise): Promise { - const store = usePostManagerStore.getState(); - const pending = store.getPendingRequest(key); - if (pending) return pending; - - const request = fetcher().finally(() => { - usePostManagerStore.getState().deletePendingRequest(key); - }); - store.setPendingRequest(key, request); - return request; - } } -// ==================== 单例导出 ==================== - export const postManager = new PostManager(); -// 导出类以支持类型检查和测试 export { PostManager }; export default postManager; diff --git a/src/stores/post/hooks.ts b/src/stores/post/hooks.ts index afca93d..4ce752e 100644 --- a/src/stores/post/hooks.ts +++ b/src/stores/post/hooks.ts @@ -1,25 +1,16 @@ -/** - * 帖子相关 React Hooks - */ - -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { usePostManagerStore } from './postStore'; import { postManager } from './PostManager'; import type { Post } from '../../types'; import { POST_LIST_PAGE_SIZE } from '../postListSources'; import type { PostListTab } from '../postListSources'; -// ==================== usePostList ==================== - export interface UsePostListResult { posts: Post[]; isLoading: boolean; refresh: () => Promise; } -/** - * 获取帖子列表 hook - */ export function usePostList( type: PostListTab = 'hot', page = 1, @@ -33,17 +24,18 @@ export function usePostList( const key = `list:${type}:${page}:${pageSize}`; return state.loadingListKeys.has(key); }); - const [hasFetched, setHasFetched] = useState(false); + const lastFetchedRef = useRef(null); useEffect(() => { - if (!hasFetched) { - setHasFetched(true); + const key = `${type}:${page}:${pageSize}`; + if (lastFetchedRef.current !== key) { + lastFetchedRef.current = key; usePostManagerStore.getState().setLoadingList(type, page, pageSize, true); postManager.getPosts(type, page, pageSize).finally(() => { usePostManagerStore.getState().setLoadingList(type, page, pageSize, false); }); } - }, [type, page, pageSize, hasFetched]); + }, [type, page, pageSize]); const refresh = useCallback(async () => { usePostManagerStore.getState().setLoadingList(type, page, pageSize, true); @@ -57,17 +49,12 @@ export function usePostList( return { posts, isLoading, refresh }; } -// ==================== usePostDetail ==================== - export interface UsePostDetailResult { post: Post | null; isLoading: boolean; refresh: () => Promise; } -/** - * 获取帖子详情 hook - */ export function usePostDetail(postId: string | undefined | null): UsePostDetailResult { const post = usePostManagerStore(state => postId ? (state.detailsMap.get(postId)?.data ?? null) : null @@ -75,18 +62,18 @@ export function usePostDetail(postId: string | undefined | null): UsePostDetailR const isLoading = usePostManagerStore(state => postId ? state.loadingDetailIds.has(postId) : false ); - const [hasFetched, setHasFetched] = useState(false); + const lastFetchedRef = useRef(null); useEffect(() => { if (!postId) return; - if (!hasFetched) { - setHasFetched(true); + if (lastFetchedRef.current !== postId) { + lastFetchedRef.current = postId; usePostManagerStore.getState().setLoadingDetail(postId, true); postManager.getPostDetail(postId).finally(() => { usePostManagerStore.getState().setLoadingDetail(postId, false); }); } - }, [postId, hasFetched]); + }, [postId]); const refresh = useCallback(async () => { if (!postId) return null; @@ -99,4 +86,4 @@ export function usePostDetail(postId: string | undefined | null): UsePostDetailR }, [postId]); return { post, isLoading, refresh }; -} +} \ No newline at end of file diff --git a/src/stores/user/UserManager.ts b/src/stores/user/UserManager.ts index fc3d9ff..9958570 100644 --- a/src/stores/user/UserManager.ts +++ b/src/stores/user/UserManager.ts @@ -10,6 +10,9 @@ import { authService } from '../../services/authService'; import { userCacheRepository } from '@/database'; import { useUserManagerStore } from './userStore'; import { createCacheEntry, isCacheExpired, DEFAULT_TTL } from '../utils/cache'; +import { createDedupe } from '../utils/requestDedupe'; + +const dedupe = createDedupe(() => useUserManagerStore); // ==================== UserManager 类 ==================== @@ -37,7 +40,7 @@ class UserManager { } } - return this.dedupe('users:current', async () => { + return dedupe('users:current', async () => { const user = await authService.fetchCurrentUserFromAPI(); const newEntry = createCacheEntry(user, DEFAULT_TTL.USER); store.setCurrentUserEntry(newEntry); @@ -51,7 +54,7 @@ class UserManager { } private refreshCurrentUserInBackground(): void { - this.dedupe('users:current:bg', async () => { + dedupe('users:current:bg', async () => { const store = useUserManagerStore.getState(); const user = await authService.fetchCurrentUserFromAPI(); const newEntry = createCacheEntry(user, DEFAULT_TTL.USER); @@ -90,7 +93,7 @@ class UserManager { } } - return this.dedupe(`users:detail:${userId}`, async () => { + return dedupe(`users:detail:${userId}`, async () => { const store = useUserManagerStore.getState(); const user = await authService.fetchUserByIdFromAPI(userId); const newEntry = createCacheEntry(user, DEFAULT_TTL.USER); @@ -104,7 +107,7 @@ class UserManager { } private refreshUserByIdInBackground(userId: string): void { - this.dedupe(`users:detail:bg:${userId}`, async () => { + dedupe(`users:detail:bg:${userId}`, async () => { const store = useUserManagerStore.getState(); const user = await authService.fetchUserByIdFromAPI(userId); const newEntry = createCacheEntry(user, DEFAULT_TTL.USER); @@ -122,18 +125,6 @@ class UserManager { invalidateUsers(): void { useUserManagerStore.getState().invalidateAll(); } - - private async dedupe(key: string, fetcher: () => Promise): Promise { - const store = useUserManagerStore.getState(); - const pending = store.getPendingRequest(key); - if (pending) return pending; - - const request = fetcher().finally(() => { - useUserManagerStore.getState().deletePendingRequest(key); - }); - store.setPendingRequest(key, request); - return request; - } } export const userManager = new UserManager(); diff --git a/src/stores/user/hooks.ts b/src/stores/user/hooks.ts index 8933feb..2078fcd 100644 --- a/src/stores/user/hooks.ts +++ b/src/stores/user/hooks.ts @@ -1,13 +1,8 @@ -/** - * 用户相关 React Hooks - */ - -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { useUserManagerStore } from './userStore'; import { userManager } from './UserManager'; import type { UserDTO } from '../../types/dto'; - -// ==================== useCurrentUser ==================== +import { useAuthStore } from '../authStore'; export interface UseCurrentUserResult { user: UserDTO | null; @@ -15,24 +10,30 @@ export interface UseCurrentUserResult { refresh: () => Promise; } -/** - * 获取当前用户 hook - * 自动加载,支持缓存和后台刷新 - */ export function useCurrentUser(): UseCurrentUserResult { const user = useUserManagerStore(state => state.currentUser); const isLoading = useUserManagerStore(state => state.isLoadingCurrentUser); - const [hasFetched, setHasFetched] = useState(false); + const lastFetchedRef = useRef(null); useEffect(() => { - if (!hasFetched) { - setHasFetched(true); + const authUser = useAuthStore.getState().currentUser; + const userId = authUser?.id; + if (userId && lastFetchedRef.current !== userId) { + lastFetchedRef.current = userId; useUserManagerStore.getState().setLoadingCurrentUser(true); userManager.getCurrentUser().finally(() => { useUserManagerStore.getState().setLoadingCurrentUser(false); }); } - }, [hasFetched]); + }, []); + + useEffect(() => { + return useAuthStore.subscribe((state, prev) => { + if (!state.currentUser && prev.currentUser) { + lastFetchedRef.current = null; + } + }); + }, []); const refresh = useCallback(async () => { useUserManagerStore.getState().setLoadingCurrentUser(true); @@ -46,18 +47,12 @@ export function useCurrentUser(): UseCurrentUserResult { return { user, isLoading, refresh }; } -// ==================== useUser ==================== - export interface UseUserResult { user: UserDTO | null; isLoading: boolean; refresh: () => Promise; } -/** - * 根据 ID 获取用户 hook - * 自动加载,支持缓存和后台刷新 - */ export function useUser(userId: string | undefined | null): UseUserResult { const user = useUserManagerStore(state => userId ? (state.usersMap.get(userId)?.data ?? null) : null @@ -65,18 +60,18 @@ export function useUser(userId: string | undefined | null): UseUserResult { const isLoading = useUserManagerStore(state => userId ? state.loadingUserIds.has(userId) : false ); - const [hasFetched, setHasFetched] = useState(false); + const lastFetchedRef = useRef(null); useEffect(() => { if (!userId) return; - if (!hasFetched) { - setHasFetched(true); + if (lastFetchedRef.current !== userId) { + lastFetchedRef.current = userId; useUserManagerStore.getState().setLoadingUser(userId, true); userManager.getUserById(userId).finally(() => { useUserManagerStore.getState().setLoadingUser(userId, false); }); } - }, [userId, hasFetched]); + }, [userId]); const refresh = useCallback(async () => { if (!userId) return null; @@ -91,33 +86,27 @@ export function useUser(userId: string | undefined | null): UseUserResult { return { user, isLoading, refresh }; } -// ==================== useUsers ==================== - export interface UseUsersResult { getUser: (id: string) => UserDTO | null; refreshUser: (id: string) => Promise; } -/** - * 多用户获?hook - * 用于批量获取多个用户信息 - */ export function useUsers(userIds: string[]): UseUsersResult { const usersMap = useUserManagerStore(state => state.usersMap); const loadingUserIds = useUserManagerStore(state => state.loadingUserIds); - const [fetchedIds, setFetchedIds] = useState>(new Set()); + const fetchedIdsRef = useRef>(new Set()); useEffect(() => { userIds.forEach(id => { - if (!fetchedIds.has(id) && !loadingUserIds.has(id)) { - setFetchedIds(prev => new Set(prev).add(id)); + if (!fetchedIdsRef.current.has(id) && !loadingUserIds.has(id)) { + fetchedIdsRef.current = new Set(fetchedIdsRef.current).add(id); useUserManagerStore.getState().setLoadingUser(id, true); userManager.getUserById(id).finally(() => { useUserManagerStore.getState().setLoadingUser(id, false); }); } }); - }, [userIds, fetchedIds, loadingUserIds]); + }, [userIds, loadingUserIds]); const getUser = useCallback((id: string) => { return usersMap.get(id)?.data ?? null; @@ -133,4 +122,4 @@ export function useUsers(userIds: string[]): UseUsersResult { }, []); return { getUser, refreshUser }; -} +} \ No newline at end of file diff --git a/src/stores/utils/requestDedupe.ts b/src/stores/utils/requestDedupe.ts new file mode 100644 index 0000000..d3e8d1c --- /dev/null +++ b/src/stores/utils/requestDedupe.ts @@ -0,0 +1,58 @@ +import type { StoreApi } from 'zustand'; + +export interface PendingRequestsSlice { + pendingRequests: Map>; + getPendingRequest: (key: string) => Promise | undefined; + setPendingRequest: (key: string, request: Promise) => void; + deletePendingRequest: (key: string) => void; + clearPendingRequests: () => void; +} + +export function createPendingRequestsSlice( + set: (partial: any) => void, + get: () => any +): PendingRequestsSlice { + return { + pendingRequests: new Map(), + + getPendingRequest: (key: string) => { + return get().pendingRequests.get(key) as Promise | undefined; + }, + + setPendingRequest: (key: string, request: Promise) => { + set((state: { pendingRequests: Map> }) => { + const newPendingRequests = new Map(state.pendingRequests); + newPendingRequests.set(key, request); + return { pendingRequests: newPendingRequests }; + }); + }, + + deletePendingRequest: (key: string) => { + set((state: { pendingRequests: Map> }) => { + const newPendingRequests = new Map(state.pendingRequests); + newPendingRequests.delete(key); + return { pendingRequests: newPendingRequests }; + }); + }, + + clearPendingRequests: () => { + set({ pendingRequests: new Map() }); + }, + }; +} + +export function createDedupe( + getStore: () => StoreApi +) { + return async function dedupe(key: string, fetcher: () => Promise): Promise { + const store = getStore().getState(); + const pending = store.getPendingRequest(key); + if (pending) return pending; + + const request = fetcher().finally(() => { + getStore().getState().deletePendingRequest(key); + }); + store.setPendingRequest(key, request); + return request; + }; +} diff --git a/src/types/dto.ts b/src/types/dto.ts index 332d7fe..4364f5a 100644 --- a/src/types/dto.ts +++ b/src/types/dto.ts @@ -721,7 +721,7 @@ export interface SSEEvent extends WSEvent {} * 从消息segments中提取纯文本内容 * 用于消息列表显示、通知等场景 */ -export function extractTextFromSegments(segments?: MessageSegment[]): string { +export function extractTextFromSegments(segments?: MessageSegment[], memberMap?: Map): string { if (!segments || segments.length === 0) { return ''; } @@ -731,7 +731,6 @@ export function extractTextFromSegments(segments?: MessageSegment[]): string { for (const segment of segments) { switch (segment.type) { case 'text': - // 兼容 content 和 text 两种字段 const textData = segment.data as TextSegmentData; result += textData.content ?? textData.text ?? ''; break; @@ -739,10 +738,10 @@ export function extractTextFromSegments(segments?: MessageSegment[]): string { const atData = segment.data as AtSegmentData; if (atData.user_id === 'all') { result += '@所有人 '; - } else if (atData.nickname) { - result += `@${atData.nickname} `; } else { - result += `@${atData.user_id} `; + const member = memberMap?.get(atData.user_id); + const displayName = member?.nickname || atData.nickname || atData.user_id; + result += `@${displayName} `; } break; case 'image':