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':