dev #3

Merged
lan merged 8 commits from dev into master 2026-03-19 01:00:14 +08:00
97 changed files with 14079 additions and 2766 deletions

View File

@@ -98,12 +98,12 @@ jobs:
container:
image: reactnativecommunity/react-native-android:latest
env:
GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=false -Dorg.gradle.workers.max=1 -Xmx2g -XX:MaxMetaspaceSize=512m -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=128m"
_JAVA_OPTIONS: "-Xmx2g -XX:MaxMetaspaceSize=512m -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=128m"
NODE_OPTIONS: "--max-old-space-size=2048"
GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.workers.max=4 -Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m"
_JAVA_OPTIONS: "-Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m"
NODE_OPTIONS: "--max-old-space-size=8192"
NODE_ENV: "production"
NDK_NUM_JOBS: "1"
CMAKE_BUILD_PARALLEL_LEVEL: "1"
NDK_NUM_JOBS: "4"
CMAKE_BUILD_PARALLEL_LEVEL: "4"
permissions:
contents: read
steps:
@@ -222,10 +222,10 @@ jobs:
# Update gradle.properties
cat > gradle.properties << 'PROPS_EOF'
org.gradle.daemon=false
org.gradle.parallel=false
org.gradle.configureondemand=false
org.gradle.workers.max=1
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=128m -XX:+HeapDumpOnOutOfMemoryError
org.gradle.parallel=true
org.gradle.configureondemand=true
org.gradle.workers.max=4
org.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.enableJetifier=false
android.useAndroidX=true
hermesEnabled=true
@@ -242,7 +242,7 @@ jobs:
run: |
cd android
chmod +x gradlew
./gradlew :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --max-workers=1
./gradlew :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --max-workers=4 --parallel
- name: Upload APK artifact
uses: actions/upload-artifact@v3

View File

@@ -0,0 +1,231 @@
# 架构修复方案文档
## 1. 重构目标
### 1.1 核心原则
- **单一职责**:每个模块只负责一个明确的功能
- **依赖倒置**:高层模块不依赖低层模块,都依赖抽象
- **开闭原则**:对扩展开放,对修改关闭
### 1.2 目标架构
```
src/
├── core/ # 核心业务逻辑(框架无关)
│ ├── entities/ # 业务实体
│ ├── repositories/ # 仓库接口
│ └── usecases/ # 业务用例
├── data/ # 数据层
│ ├── datasources/ # 数据源实现API、DB、Cache
│ ├── repositories/ # 仓库实现
│ └── mappers/ # 数据映射
├── presentation/ # 表现层
│ ├── components/ # UI组件
│ ├── screens/ # 屏幕组件
│ ├── stores/ # 状态管理
│ └── hooks/ # 自定义hooks
└── infrastructure/ # 基础设施
├── navigation/ # 导航
├── theme/ # 主题
└── services/ # 外部服务
```
## 2. 具体修复方案
### 2.1 messageManager.ts 重构方案
#### 现状问题
- 1500+行,上帝对象
- 混合了 WebSocket、数据库、状态管理、UI通知
- 与SQLite深度耦合
#### 目标结构
```
src/data/
├── datasources/
│ ├── WebSocketClient.ts # WebSocket连接管理
│ └── MessageDataSource.ts # 消息数据源
├── repositories/
│ └── MessageRepository.ts # 消息仓库实现
└── mappers/
└── MessageMapper.ts # 消息数据映射
src/core/
├── entities/
│ └── Message.ts # 消息实体
├── repositories/
│ └── IMessageRepository.ts # 仓库接口
└── usecases/
├── ProcessMessageUseCase.ts
├── SyncMessagesUseCase.ts
└── UpdateReadReceiptUseCase.ts
src/presentation/stores/
└── messageStore.ts # 简化的消息状态管理
```
#### 重构步骤
1. 提取 WebSocketClient - 纯连接管理
2. 创建 MessageRepository - 数据访问抽象
3. 创建 UseCases - 业务逻辑
4. 重写 messageStore - 仅负责UI状态
5. 删除旧的 messageManager.ts
### 2.2 乐观更新工具函数方案
#### 现状问题
- userStore.ts 中重复6+次相同模式
- 代码冗余,难以维护
#### 目标API
```typescript
// utils/optimisticUpdate.ts
export interface OptimisticUpdateOptions<T, R> {
store: StoreApi<any>;
getCurrentState: () => T;
optimisticUpdate: (current: T) => T;
apiCall: () => Promise<R>;
onSuccess?: (result: R, current: T) => T;
onError?: (error: any, original: T) => void;
}
export async function optimisticUpdate<T, R>(
options: OptimisticUpdateOptions<T, R>
): Promise<R>
```
#### 使用示例
```typescript
// 重构前(重复代码)
set(state => ({ posts: updatedPosts }));
try {
const result = await postService.like(postId);
// ...
} catch (error) {
set(state => ({ posts: originalPosts }));
}
// 重构后(简洁)
await optimisticUpdate({
store,
getCurrentState: () => store.getState().posts,
optimisticUpdate: (posts) => updatePostLikes(posts, postId, true),
apiCall: () => postService.like(postId),
onSuccess: (result, posts) => updatePostWithServerData(posts, result),
});
```
### 2.3 useResponsive.ts 拆分方案
#### 现状问题
- 485行43个导出
- 功能混杂:断点检测、响应式值计算、平台检测
#### 目标结构
```
src/presentation/hooks/responsive/
├── useBreakpoint.ts # 断点检测
├── useResponsiveValue.ts # 响应式值映射
├── useOrientation.ts # 方向检测
├── usePlatform.ts # 平台检测
├── useScreenSize.ts # 屏幕尺寸
└── index.ts # 统一导出
```
#### 各Hook职责
- `useBreakpoint`: 返回当前断点 (mobile/tablet/desktop/wide)
- `useResponsiveValue`: 根据断点返回不同值
- `useOrientation`: 返回 portrait/landscape
- `usePlatform`: 返回 ios/android/web
- `useScreenSize`: 返回 width/height
### 2.4 MainNavigator 解耦方案
#### 现状问题
- 1118行直接依赖store
- 混合了导航配置和业务逻辑
#### 目标结构
```
src/infrastructure/navigation/
├── MainNavigator.tsx # 简化后的导航器
├── AuthNavigator.tsx # 认证导航
├── TabNavigator.tsx # 标签导航
├── navigationService.ts # 导航服务(解耦层)
├── navigationTypes.ts # 类型定义
└── hooks/
└── useNavigationState.ts # 导航状态hook
```
#### 解耦策略
1. 创建 NavigationService - 提供导航操作的抽象
2. Store通过NavigationService触发导航而非直接操作
3. MainNavigator只负责渲染不处理业务逻辑
### 2.5 Repository 层创建方案
#### 现状问题
- 服务层直接操作数据库
- 没有明确的数据访问边界
#### 目标结构
```
src/data/repositories/
├── MessageRepository.ts # 消息数据访问
├── PostRepository.ts # 帖子数据访问
├── UserRepository.ts # 用户数据访问
└── interfaces/
├── IMessageRepository.ts
├── IPostRepository.ts
└── IUserRepository.ts
```
#### 职责划分
- **Repository**: 数据访问抽象,定义接口
- **DataSource**: 具体数据源实现API、SQLite、Cache
- **Service**: 业务逻辑编排使用Repository
## 3. 实施计划
### 阶段1基础设施第1-2天
- [ ] 创建目录结构
- [ ] 实现乐观更新工具函数
- [ ] 拆分 useResponsive hooks
### 阶段2数据层重构第3-5天
- [ ] 创建 Repository 接口和实现
- [ ] 创建 DataSource 层
- [ ] 迁移数据库操作到 Repository
### 阶段3核心业务重构第6-10天
- [ ] 重构 messageManager → UseCases + Store
- [ ] 创建 WebSocketClient
- [ ] 实现消息同步逻辑
### 阶段4表现层重构第11-12天
- [ ] 解耦 MainNavigator
- [ ] 重构 userStore 使用乐观更新工具
- [ ] 更新所有屏幕组件
### 阶段5测试与优化第13-14天
- [ ] 单元测试
- [ ] 集成测试
- [ ] 性能优化
## 4. 风险与应对
| 风险 | 影响 | 应对措施 |
|-----|------|---------|
| 重构引入bug | 高 | 每个模块重构后必须测试保持API兼容 |
| 开发时间超期 | 中 | 分阶段交付,优先核心功能 |
| 团队成员不熟悉新架构 | 中 | 编写详细文档,代码审查 |
| 性能下降 | 低 | 添加性能监控,及时优化 |
## 5. 验收标准
- [ ] messageManager.ts 被删除,功能拆分到多个小模块
- [ ] userStore.ts 中无重复乐观更新代码
- [ ] useResponsive.ts 拆分为5+个专注hook
- [ ] MainNavigator.tsx 行数 < 300
- [ ] 所有单元测试通过
- [ ] 无TypeScript错误
- [ ] 性能不劣化(启动时间、内存占用)

1725
docs/OPTIMIZATION_DESIGN.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -362,6 +362,20 @@ const PostCard: React.FC<PostCardProps> = ({
const gridUsernameFontSize = isDesktop ? 14 : 12;
const gridLikeFontSize = isDesktop ? 14 : 12;
const handleContainerPress = () => {
onPress();
};
const handleImagePress = (e: any) => {
e.stopPropagation();
onImagePress?.(post.images || [], 0);
};
const handleUserPress = (e: any) => {
e.stopPropagation();
onUserPress();
};
return (
<TouchableOpacity
style={[
@@ -369,14 +383,14 @@ const PostCard: React.FC<PostCardProps> = ({
!hasImage && styles.gridContainerNoImage,
isDesktop && styles.gridContainerDesktop
]}
onPress={onPress}
onPress={handleContainerPress}
activeOpacity={0.9}
>
{/* 封面图 - 只有有图片时才渲染,无图时不显示占位区域 */}
{hasImage && (
<TouchableOpacity
activeOpacity={0.8}
onPress={() => onImagePress?.(post.images || [], 0)}
onPress={handleImagePress}
>
<SmartImage
source={{ uri: coverUrl }}
@@ -424,7 +438,7 @@ const PostCard: React.FC<PostCardProps> = ({
{/* 底部信息 */}
<View style={[styles.gridFooter, isDesktop && styles.gridFooterDesktop]}>
<TouchableOpacity style={styles.gridUserInfo} onPress={onUserPress}>
<TouchableOpacity style={styles.gridUserInfo} onPress={handleUserPress}>
<Avatar
source={author.avatar}
size={isDesktop ? 24 : 20}
@@ -462,6 +476,40 @@ const PostCard: React.FC<PostCardProps> = ({
return 3;
}, [isWideScreen, isDesktop, isTablet]);
const handleContainerPress = () => {
onPress();
};
const handleUserPress = (e: any) => {
e.stopPropagation();
onUserPress();
};
const handleLikePress = (e: any) => {
e.stopPropagation();
onLike();
};
const handleCommentPress = (e: any) => {
e.stopPropagation();
onComment();
};
const handleSharePress = (e: any) => {
e.stopPropagation();
onShare();
};
const handleBookmarkPress = (e: any) => {
e.stopPropagation();
onBookmark();
};
const handleDeletePress = (e: any) => {
e.stopPropagation();
handleDelete();
};
return (
<TouchableOpacity
style={[
@@ -481,12 +529,12 @@ const PostCard: React.FC<PostCardProps> = ({
} : {})
}
]}
onPress={onPress}
onPress={handleContainerPress}
activeOpacity={0.9}
>
{/* 用户信息 */}
<View style={styles.userSection}>
<TouchableOpacity onPress={onUserPress}>
<TouchableOpacity onPress={handleUserPress}>
<Avatar
source={author.avatar}
size={avatarSize}
@@ -495,7 +543,7 @@ const PostCard: React.FC<PostCardProps> = ({
</TouchableOpacity>
<View style={styles.userInfo}>
<View style={styles.userNameRow}>
<TouchableOpacity onPress={onUserPress}>
<TouchableOpacity onPress={handleUserPress}>
<Text
variant="body"
style={[
@@ -529,7 +577,7 @@ const PostCard: React.FC<PostCardProps> = ({
{isPostAuthor && onDelete && (
<TouchableOpacity
style={styles.deleteButton}
onPress={handleDelete}
onPress={handleDeletePress}
disabled={isDeleting}
>
<MaterialCommunityIcons
@@ -605,7 +653,7 @@ const PostCard: React.FC<PostCardProps> = ({
</View>
<View style={styles.actionButtons}>
<TouchableOpacity style={[styles.actionButton, ...(isDesktop ? [styles.actionButtonWide] : [])]} onPress={onLike}>
<TouchableOpacity style={[styles.actionButton, ...(isDesktop ? [styles.actionButtonWide] : [])]} onPress={handleLikePress}>
<MaterialCommunityIcons
name={post.is_liked ? 'heart' : 'heart-outline'}
size={isDesktop ? 22 : 19}
@@ -620,7 +668,7 @@ const PostCard: React.FC<PostCardProps> = ({
</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.actionButton, ...(isDesktop ? [styles.actionButtonWide] : [])]} onPress={onComment}>
<TouchableOpacity style={[styles.actionButton, ...(isDesktop ? [styles.actionButtonWide] : [])]} onPress={handleCommentPress}>
<MaterialCommunityIcons
name="comment-outline"
size={isDesktop ? 22 : 19}
@@ -631,7 +679,7 @@ const PostCard: React.FC<PostCardProps> = ({
</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.actionButton, ...(isDesktop ? [styles.actionButtonWide] : [])]} onPress={onShare}>
<TouchableOpacity style={[styles.actionButton, ...(isDesktop ? [styles.actionButtonWide] : [])]} onPress={handleSharePress}>
<MaterialCommunityIcons
name="share-outline"
size={isDesktop ? 22 : 19}
@@ -639,7 +687,7 @@ const PostCard: React.FC<PostCardProps> = ({
/>
</TouchableOpacity>
<TouchableOpacity style={[styles.actionButton, isDesktop && styles.actionButtonWide]} onPress={onBookmark}>
<TouchableOpacity style={[styles.actionButton, isDesktop && styles.actionButtonWide]} onPress={handleBookmarkPress}>
<MaterialCommunityIcons
name={post.is_favorited ? 'bookmark' : 'bookmark-outline'}
size={isDesktop ? 22 : 19}

View File

@@ -0,0 +1,144 @@
/**
* Message Entity - 消息领域实体
* 定义消息的核心属性和行为,不依赖任何外部框架
*/
export interface MessageSegment {
type: 'text' | 'image' | 'mention' | 'reply' | string;
data: Record<string, any>;
}
export interface Message {
id: string;
conversationId: string;
senderId: string;
seq: number;
segments: MessageSegment[];
createdAt: string;
status: 'normal' | 'recalled' | 'deleted';
category?: string;
sender?: {
id: string;
username: string;
avatar?: string;
nickname?: string;
};
}
export interface Conversation {
id: string;
type: 'private' | 'group';
isPinned: boolean;
lastSeq: number;
lastMessage?: Message;
lastMessageAt: string;
unreadCount: number;
participants: Array<{
id: string;
username: string;
avatar?: string;
nickname?: string;
}>;
group?: {
id: string;
name: string;
avatar?: string;
};
createdAt: string;
updatedAt: string;
}
export interface UnreadCount {
total: number;
system: number;
}
export interface TypingStatus {
groupId: string;
userId: string;
isTyping: boolean;
}
export interface GroupNotice {
type: 'member_join' | 'member_leave' | 'member_removed' | 'role_changed' | 'muted' | 'unmuted';
groupId: string;
data: {
userId?: string;
operatorId?: string;
role?: string;
username?: string;
[key: string]: any;
};
timestamp: number;
messageId?: string;
seq?: number;
}
// 工厂函数
export const createMessage = (data: Partial<Message>): Message => ({
id: data.id || '',
conversationId: data.conversationId || '',
senderId: data.senderId || '',
seq: data.seq || 0,
segments: data.segments || [],
createdAt: data.createdAt || new Date().toISOString(),
status: data.status || 'normal',
category: data.category,
sender: data.sender,
});
export const createConversation = (data: Partial<Conversation>): Conversation => ({
id: data.id || '',
type: data.type || 'private',
isPinned: data.isPinned || false,
lastSeq: data.lastSeq || 0,
lastMessage: data.lastMessage,
lastMessageAt: data.lastMessageAt || new Date().toISOString(),
unreadCount: data.unreadCount || 0,
participants: data.participants || [],
group: data.group,
createdAt: data.createdAt || new Date().toISOString(),
updatedAt: data.updatedAt || new Date().toISOString(),
});
// 工具函数
export const isMessageFromCurrentUser = (message: Message, currentUserId: string): boolean => {
return message.senderId === currentUserId;
};
export const shouldIncrementUnread = (
message: Message,
currentUserId: string,
activeConversationId: string | null
): boolean => {
return (
message.senderId !== currentUserId &&
message.conversationId !== activeConversationId &&
!!currentUserId
);
};
export const buildTextContent = (segments: MessageSegment[]): string => {
return segments
.filter((s) => s.type === 'text')
.map((s) => s.data?.text || '')
.join('');
};
export const buildGroupNoticeText = (notice: GroupNotice): string => {
const username = notice.data?.username || '用户';
switch (notice.type) {
case 'member_join':
return `"${username}" 加入了群聊`;
case 'member_leave':
return `"${username}" 退出了群聊`;
case 'member_removed':
return `"${username}" 被移出群聊`;
case 'muted':
return `"${username}" 已被管理员禁言`;
case 'unmuted':
return `"${username}" 已被管理员解除禁言`;
default:
return '';
}
};

View File

@@ -0,0 +1,656 @@
/**
* ProcessMessageUseCase - 处理消息用例
* 处理消息的业务逻辑,协调 Repository 和 SSEClient
* 纯业务逻辑无UI依赖
*/
import { messageRepository } from '../../data/repositories/MessageRepository';
import { sseClient, SSEEventType } from '../../data/datasources/SSEClient';
import {
Message,
Conversation,
GroupNotice,
createMessage,
isMessageFromCurrentUser,
shouldIncrementUnread,
buildGroupNoticeText,
} from '../entities/Message';
import { messageService } from '../../services/messageService';
import { api } from '../../services/api';
// 事件类型定义
export type MessageUseCaseEventType =
| 'message_received'
| 'message_recalled'
| 'typing_status'
| 'group_notice'
| 'connection_changed'
| 'error';
export interface MessageUseCaseEvent {
type: MessageUseCaseEventType;
payload: any;
timestamp: number;
}
export type MessageUseCaseSubscriber = (event: MessageUseCaseEvent) => void;
// 已读状态保护记录
interface ReadStateRecord {
timestamp: number;
version: number;
lastReadSeq: number;
clearTimer?: NodeJS.Timeout;
}
class ProcessMessageUseCase {
private subscribers: Set<MessageUseCaseSubscriber> = new Set();
private unsubscribeFns: Array<() => void> = [];
private processedMessageIds: Set<string> = new Set();
private processedMessageIdsExpiry: Map<string, number> = new Map();
private pendingReadMap: Map<string, ReadStateRecord> = new Map();
private readStateVersion = 0;
private pendingUserRequests: Map<string, Promise<any>> = new Map();
private currentUserId: string | null = null;
// 常量
private readonly READ_STATE_PROTECTION_DELAY = 5000; // 5秒
private readonly MESSAGE_ID_EXPIRY = 5 * 60 * 1000; // 5分钟
/**
* 初始化用例
*/
initialize(currentUserId: string | null): void {
this.currentUserId = currentUserId;
this.setupSSEListeners();
}
/**
* 销毁用例
*/
destroy(): void {
this.unsubscribeFns.forEach((fn) => fn());
this.unsubscribeFns = [];
this.subscribers.clear();
this.processedMessageIds.clear();
this.processedMessageIdsExpiry.clear();
this.pendingReadMap.clear();
this.pendingUserRequests.clear();
}
/**
* 设置SSE监听器
*/
private setupSSEListeners(): void {
// 监听私聊消息
const unsubChat = sseClient.on('chat', (message) => {
this.handleNewMessage(message);
});
// 监听群聊消息
const unsubGroupMessage = sseClient.on('group_message', (message) => {
this.handleNewMessage(message);
});
// 监听私聊已读回执
const unsubRead = sseClient.on('read', (message) => {
this.handleReadReceipt(message);
});
// 监听群聊已读回执
const unsubGroupRead = sseClient.on('group_read', (message) => {
this.handleGroupReadReceipt(message);
});
// 监听私聊消息撤回
const unsubRecall = sseClient.on('recall', (message) => {
this.handleRecallMessage(message);
});
// 监听群聊消息撤回
const unsubGroupRecall = sseClient.on('group_recall', (message) => {
this.handleGroupRecallMessage(message);
});
// 监听群聊输入状态
const unsubGroupTyping = sseClient.on('group_typing', (message) => {
this.handleGroupTyping(message);
});
// 监听群通知
const unsubGroupNotice = sseClient.on('group_notice', (message) => {
this.handleGroupNotice(message);
});
// 监听连接状态
const unsubConnected = sseClient.on('connected', () => {
this.notifySubscribers({
type: 'connection_changed',
payload: { connected: true },
timestamp: Date.now(),
});
});
const unsubDisconnected = sseClient.on('disconnected', () => {
this.notifySubscribers({
type: 'connection_changed',
payload: { connected: false },
timestamp: Date.now(),
});
});
this.unsubscribeFns = [
unsubChat,
unsubGroupMessage,
unsubRead,
unsubGroupRead,
unsubRecall,
unsubGroupRecall,
unsubGroupTyping,
unsubGroupNotice,
unsubConnected,
unsubDisconnected,
];
}
/**
* 处理新消息
*/
private async handleNewMessage(message: any): Promise<void> {
const { conversation_id, id, sender_id, seq, segments, created_at, _isAck } = message;
// 消息去重检查
if (this.isMessageProcessed(id)) {
return;
}
this.markMessageAsProcessed(id);
// 构造消息对象
const newMessage = createMessage({
id,
conversationId: String(conversation_id),
senderId: sender_id,
seq,
segments: segments || [],
createdAt: created_at,
});
// 异步获取发送者信息(如果是群聊且不是当前用户发送的)
if (message.type === 'group_message' && sender_id && sender_id !== this.currentUserId) {
this.enrichMessageWithSender(newMessage);
}
// 异步保存到本地数据库
const isRead = _isAck || sender_id === this.currentUserId;
messageRepository.saveMessage(newMessage, isRead).catch((error) => {
console.error('[ProcessMessageUseCase] 保存消息失败:', error);
});
// 通知订阅者
this.notifySubscribers({
type: 'message_received',
payload: {
message: newMessage,
isCurrentUser: sender_id === this.currentUserId,
isAck: _isAck,
},
timestamp: Date.now(),
});
}
/**
* 丰富消息的发送者信息
*/
private async enrichMessageWithSender(message: Message): Promise<void> {
try {
const user = await this.getSenderInfo(message.senderId);
if (user) {
message.sender = user;
// 重新通知,包含发送者信息
this.notifySubscribers({
type: 'message_received',
payload: {
message,
isCurrentUser: false,
isUpdated: true,
},
timestamp: Date.now(),
});
}
} catch (error) {
console.error('[ProcessMessageUseCase] 获取发送者信息失败:', error);
}
}
/**
* 获取发送者信息(带缓存和去重)
*/
private async getSenderInfo(userId: string): Promise<any | null> {
// 先检查本地缓存
const cachedUser = await messageRepository.getUserCache(userId);
if (cachedUser) {
return cachedUser;
}
// 检查是否已有正在进行的请求
const pendingRequest = this.pendingUserRequests.get(userId);
if (pendingRequest) {
return pendingRequest;
}
// 发起新请求
const request = this.fetchUserInfo(userId);
this.pendingUserRequests.set(userId, request);
try {
const user = await request;
return user;
} finally {
this.pendingUserRequests.delete(userId);
}
}
/**
* 从服务器获取用户信息
*/
private async fetchUserInfo(userId: string): Promise<any | null> {
try {
const response = await api.get(`/users/${userId}`);
if (response.code === 0 && response.data) {
await messageRepository.saveUserCache(response.data);
return response.data;
}
return null;
} catch (error) {
console.error(`[ProcessMessageUseCase] 获取用户信息失败: ${userId}`, error);
return null;
}
}
/**
* 检查消息是否已处理
*/
private isMessageProcessed(messageId: string): boolean {
return this.processedMessageIds.has(messageId);
}
/**
* 标记消息已处理
*/
private markMessageAsProcessed(messageId: string): void {
this.processedMessageIds.add(messageId);
this.processedMessageIdsExpiry.set(messageId, Date.now());
// 定期清理
if (this.processedMessageIds.size > 1000) {
this.cleanupProcessedMessageIds();
}
}
/**
* 清理过期的消息ID
*/
private cleanupProcessedMessageIds(): void {
const now = Date.now();
for (const [id, timestamp] of this.processedMessageIdsExpiry.entries()) {
if (now - timestamp > this.MESSAGE_ID_EXPIRY) {
this.processedMessageIds.delete(id);
this.processedMessageIdsExpiry.delete(id);
}
}
}
/**
* 处理私聊已读回执
*/
private handleReadReceipt(message: any): void {
// 可以在这里处理对方已读的状态更新
}
/**
* 处理群聊已读回执
*/
private handleGroupReadReceipt(message: any): void {
// 可以在这里处理群聊已读状态更新
}
/**
* 处理私聊消息撤回
*/
private handleRecallMessage(message: any): Promise<void> {
const { conversation_id, message_id } = message;
// 更新本地数据库状态
messageRepository
.updateMessageStatus(message_id, 'recalled', true)
.catch((error) => {
console.error('[ProcessMessageUseCase] 更新撤回状态失败:', error);
});
// 通知订阅者
this.notifySubscribers({
type: 'message_recalled',
payload: {
conversationId: String(conversation_id),
messageId: message_id,
isGroup: false,
},
timestamp: Date.now(),
});
return Promise.resolve();
}
/**
* 处理群聊消息撤回
*/
private handleGroupRecallMessage(message: any): Promise<void> {
const { conversation_id, message_id } = message;
// 更新本地数据库状态
messageRepository
.updateMessageStatus(message_id, 'recalled', true)
.catch((error) => {
console.error('[ProcessMessageUseCase] 更新撤回状态失败:', error);
});
// 通知订阅者
this.notifySubscribers({
type: 'message_recalled',
payload: {
conversationId: String(conversation_id),
messageId: message_id,
isGroup: true,
},
timestamp: Date.now(),
});
return Promise.resolve();
}
/**
* 处理群聊输入状态
*/
private handleGroupTyping(message: any): void {
const { group_id, user_id, is_typing } = message;
this.notifySubscribers({
type: 'typing_status',
payload: {
groupId: String(group_id),
userId: user_id,
isTyping: is_typing,
},
timestamp: Date.now(),
});
}
/**
* 处理群通知
*/
private handleGroupNotice(message: any): void {
const { notice_type, group_id, data, timestamp, message_id, seq } = message;
const groupIdStr = String(group_id);
const notice: GroupNotice = {
type: notice_type,
groupId: groupIdStr,
data: data || {},
timestamp: timestamp || Date.now(),
messageId: message_id,
seq,
};
// 通知订阅者
this.notifySubscribers({
type: 'group_notice',
payload: {
notice,
text: buildGroupNoticeText(notice),
},
timestamp: Date.now(),
});
}
/**
* 标记会话已读
*/
async markAsRead(conversationId: string, seq: number): Promise<void> {
const existingRecord = this.pendingReadMap.get(conversationId);
// 使用seq去重
if (existingRecord && seq <= existingRecord.lastReadSeq) {
return;
}
// 清除可能存在的旧定时器
if (existingRecord?.clearTimer) {
clearTimeout(existingRecord.clearTimer);
}
// 递增全局版本号
this.readStateVersion++;
const currentVersion = this.readStateVersion;
// 记录已读状态
this.pendingReadMap.set(conversationId, {
timestamp: Date.now(),
version: currentVersion,
lastReadSeq: seq,
});
// 更新本地数据库
await messageRepository.markConversationAsRead(conversationId);
// 调用API
try {
await messageService.markAsRead(conversationId, seq);
} catch (error) {
console.error('[ProcessMessageUseCase] 标记已读API失败:', error);
this.pendingReadMap.delete(conversationId);
throw error;
}
// 设置延迟清除保护
const clearTimer = setTimeout(() => {
const record = this.pendingReadMap.get(conversationId);
if (record && record.version === currentVersion) {
this.pendingReadMap.delete(conversationId);
}
}, this.READ_STATE_PROTECTION_DELAY);
this.pendingReadMap.set(conversationId, {
timestamp: Date.now(),
version: currentVersion,
lastReadSeq: seq,
clearTimer,
});
}
/**
* 检查会话是否有进行中的已读请求
*/
hasPendingRead(conversationId: string): boolean {
return this.pendingReadMap.has(conversationId);
}
/**
* 获取进行中的已读记录
*/
getPendingReadRecord(conversationId: string): ReadStateRecord | undefined {
return this.pendingReadMap.get(conversationId);
}
/**
* 发送消息
*/
async sendMessage(
conversationId: string,
segments: any[],
options?: { replyToId?: string }
): Promise<Message | null> {
try {
const response = await messageService.sendMessage(conversationId, {
segments,
reply_to_id: options?.replyToId,
});
if (response) {
const message = createMessage({
id: response.id,
conversationId,
senderId: this.currentUserId || '',
seq: response.seq,
segments,
createdAt: new Date().toISOString(),
});
// 保存到本地数据库
await messageRepository.saveMessage(message, true);
return message;
}
return null;
} catch (error) {
console.error('[ProcessMessageUseCase] 发送消息失败:', error);
throw error;
}
}
/**
* 获取本地消息
*/
async getLocalMessages(conversationId: string, limit: number = 20): Promise<Message[]> {
return messageRepository.getMessagesByConversation(conversationId, limit);
}
/**
* 获取历史消息
*/
async getHistoryMessages(
conversationId: string,
beforeSeq: number,
limit: number = 20
): Promise<Message[]> {
// 先从本地获取
const localMessages = await messageRepository.getMessagesBeforeSeq(
conversationId,
beforeSeq,
limit
);
if (localMessages.length >= limit) {
return localMessages;
}
// 本地数据不足,从服务端获取
try {
const response = await messageService.getMessages(
conversationId,
undefined,
beforeSeq,
limit
);
if (response?.messages && response.messages.length > 0) {
const serverMessages = response.messages.map((m: any) =>
createMessage({
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',
})
);
// 保存到本地
await messageRepository.saveMessages(serverMessages, false);
return serverMessages;
}
} catch (error) {
console.error('[ProcessMessageUseCase] 获取历史消息失败:', error);
}
return localMessages;
}
/**
* 从服务端同步消息
*/
async syncMessagesFromServer(
conversationId: string,
afterSeq?: number
): Promise<Message[]> {
try {
const response = await messageService.getMessages(conversationId, afterSeq);
if (response?.messages && response.messages.length > 0) {
const messages = response.messages.map((m: any) =>
createMessage({
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',
})
);
// 保存到本地
await messageRepository.saveMessages(messages, false);
return messages;
}
} catch (error) {
console.error('[ProcessMessageUseCase] 同步消息失败:', error);
}
return [];
}
/**
* 订阅事件
*/
subscribe(subscriber: MessageUseCaseSubscriber): () => void {
this.subscribers.add(subscriber);
return () => {
this.subscribers.delete(subscriber);
};
}
/**
* 通知订阅者
*/
private notifySubscribers(event: MessageUseCaseEvent): void {
this.subscribers.forEach((subscriber) => {
try {
subscriber(event);
} catch (error) {
console.error('[ProcessMessageUseCase] 订阅者执行失败:', error);
}
});
}
/**
* 获取当前用户ID
*/
getCurrentUserId(): string | null {
return this.currentUserId;
}
/**
* 设置当前用户ID
*/
setCurrentUserId(userId: string | null): void {
this.currentUserId = userId;
}
}
export const processMessageUseCase = new ProcessMessageUseCase();
export default processMessageUseCase;

View File

@@ -0,0 +1,103 @@
/**
* API 数据源实现
* 封装所有 API 调用,统一错误处理和请求拦截
*/
import { api, ApiResponse, ApiError } from '../../services/api';
import { IApiDataSource, DataSourceError } from './interfaces';
export class ApiDataSource implements IApiDataSource {
private baseUrl: string;
constructor(baseUrl?: string) {
this.baseUrl = baseUrl || '';
}
private buildUrl(url: string): string {
if (url.startsWith('http')) {
return url;
}
return `${this.baseUrl}${url}`;
}
private handleError(error: unknown, operation: string): never {
if (error instanceof ApiError) {
throw new DataSourceError(
error.message,
String(error.code),
'ApiDataSource',
error
);
}
const message = error instanceof Error ? error.message : 'Unknown error';
throw new DataSourceError(
`API ${operation} failed: ${message}`,
'API_ERROR',
'ApiDataSource',
error instanceof Error ? error : undefined
);
}
async get<T>(url: string, params?: any): Promise<T> {
try {
const fullUrl = this.buildUrl(url);
const response = await api.get<T>(fullUrl, params);
return response.data;
} catch (error) {
this.handleError(error, 'GET');
}
}
async post<T>(url: string, data?: any): Promise<T> {
try {
const fullUrl = this.buildUrl(url);
const response = await api.post<T>(fullUrl, data);
return response.data;
} catch (error) {
this.handleError(error, 'POST');
}
}
async put<T>(url: string, data?: any): Promise<T> {
try {
const fullUrl = this.buildUrl(url);
const response = await api.put<T>(fullUrl, data);
return response.data;
} catch (error) {
this.handleError(error, 'PUT');
}
}
async delete<T>(url: string, data?: any): Promise<T> {
try {
const fullUrl = this.buildUrl(url);
// 处理带 request body 的 DELETE 请求
if (data) {
const response = await api.request<T>('DELETE', fullUrl, undefined, data);
return response.data;
}
const response = await api.delete<T>(fullUrl);
return response.data;
} catch (error) {
this.handleError(error, 'DELETE');
}
}
async upload<T>(
url: string,
file: { uri: string; name: string; type: string },
additionalData?: Record<string, string>
): Promise<T> {
try {
const fullUrl = this.buildUrl(url);
const response = await api.upload<T>(fullUrl, file, additionalData);
return response.data;
} catch (error) {
this.handleError(error, 'UPLOAD');
}
}
}
// 导出单例实例
export const apiDataSource = new ApiDataSource();

View File

@@ -0,0 +1,199 @@
/**
* 缓存数据源实现
* 基于内存和 AsyncStorage 的混合缓存实现
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
import { ICacheDataSource, DataSourceError } from './interfaces';
interface CacheEntry<T> {
value: T;
expiry: number | null; // null 表示永不过期
}
export class CacheDataSource implements ICacheDataSource {
private memoryCache: Map<string, CacheEntry<any>> = new Map();
private maxSize: number;
private defaultTtl: number; // 默认过期时间(毫秒)
private persistPrefix: string;
constructor(config?: {
maxSize?: number;
defaultTtl?: number; // 默认5分钟
persistPrefix?: string;
}) {
this.maxSize = config?.maxSize || 100;
this.defaultTtl = config?.defaultTtl || 5 * 60 * 1000;
this.persistPrefix = config?.persistPrefix || 'cache_';
}
/**
* 检查 key 是否过期
*/
private isExpired(entry: CacheEntry<any>): boolean {
if (entry.expiry === null) return false;
return Date.now() > entry.expiry;
}
/**
* 清理过期缓存
*/
private cleanup(): void {
const now = Date.now();
for (const [key, entry] of this.memoryCache.entries()) {
if (entry.expiry !== null && now > entry.expiry) {
this.memoryCache.delete(key);
}
}
// 如果仍然超过最大大小,删除最旧的条目
while (this.memoryCache.size > this.maxSize) {
const firstKey = this.memoryCache.keys().next().value;
if (firstKey !== undefined) {
this.memoryCache.delete(firstKey);
}
}
}
/**
* 生成持久化存储 key
*/
private getPersistKey(key: string): string {
return `${this.persistPrefix}${key}`;
}
// ==================== ICacheDataSource 实现 ====================
async get<T>(key: string): Promise<T | null> {
try {
// 先检查内存缓存
const memoryEntry = this.memoryCache.get(key);
if (memoryEntry) {
if (!this.isExpired(memoryEntry)) {
return memoryEntry.value as T;
}
// 过期了,从内存中删除
this.memoryCache.delete(key);
}
// 尝试从持久化存储读取
const persistKey = this.getPersistKey(key);
const stored = await AsyncStorage.getItem(persistKey);
if (stored) {
const entry: CacheEntry<T> = JSON.parse(stored);
if (!this.isExpired(entry)) {
// 重新加载到内存缓存
this.memoryCache.set(key, entry);
this.cleanup();
return entry.value;
}
// 过期了,删除
await AsyncStorage.removeItem(persistKey);
}
return null;
} catch (error) {
console.warn(`[CacheDataSource] Failed to get ${key}:`, error);
return null;
}
}
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
try {
const expiry = ttl === undefined
? (this.defaultTtl > 0 ? Date.now() + this.defaultTtl : null)
: (ttl > 0 ? Date.now() + ttl : null);
const entry: CacheEntry<T> = { value, expiry };
// 存入内存
this.memoryCache.set(key, entry);
this.cleanup();
// 持久化存储(重要数据)
if (ttl === undefined || ttl > 0) {
const persistKey = this.getPersistKey(key);
await AsyncStorage.setItem(persistKey, JSON.stringify(entry));
}
} catch (error) {
throw new DataSourceError(
`Failed to set cache ${key}`,
'CACHE_SET_ERROR',
'CacheDataSource',
error instanceof Error ? error : undefined
);
}
}
async delete(key: string): Promise<void> {
try {
// 从内存删除
this.memoryCache.delete(key);
// 从持久化存储删除
const persistKey = this.getPersistKey(key);
await AsyncStorage.removeItem(persistKey);
} catch (error) {
throw new DataSourceError(
`Failed to delete cache ${key}`,
'CACHE_DELETE_ERROR',
'CacheDataSource',
error instanceof Error ? error : undefined
);
}
}
async clear(): Promise<void> {
try {
// 清空内存
this.memoryCache.clear();
// 清空持久化存储中的所有缓存项
const keys = await AsyncStorage.getAllKeys();
const cacheKeys = keys.filter(k => k.startsWith(this.persistPrefix));
if (cacheKeys.length > 0) {
await AsyncStorage.multiRemove(cacheKeys);
}
} catch (error) {
throw new DataSourceError(
'Failed to clear cache',
'CACHE_CLEAR_ERROR',
'CacheDataSource',
error instanceof Error ? error : undefined
);
}
}
async has(key: string): Promise<boolean> {
try {
// 检查内存
const memoryEntry = this.memoryCache.get(key);
if (memoryEntry && !this.isExpired(memoryEntry)) {
return true;
}
// 检查持久化存储
const persistKey = this.getPersistKey(key);
const stored = await AsyncStorage.getItem(persistKey);
if (stored) {
const entry: CacheEntry<any> = JSON.parse(stored);
return !this.isExpired(entry);
}
return false;
} catch (error) {
return false;
}
}
async getMultiple<T>(keys: string[]): Promise<(T | null)[]> {
return Promise.all(keys.map(key => this.get<T>(key)));
}
async setMultiple<T>(entries: { key: string; value: T; ttl?: number }[]): Promise<void> {
await Promise.all(entries.map(entry => this.set(entry.key, entry.value, entry.ttl)));
}
}
// 导出单例实例
export const cacheDataSource = new CacheDataSource();

View File

@@ -0,0 +1,281 @@
/**
* 本地数据库数据源实现
* 封装所有 SQLite 操作,提供统一的错误处理和事务支持
*/
import * as SQLite from 'expo-sqlite';
import { ILocalDataSource, DataSourceError } from './interfaces';
// 数据库实例管理
let dbInstance: SQLite.SQLiteDatabase | null = null;
let currentDbName: string | null = null;
let writeQueue: Promise<void> = Promise.resolve();
export interface LocalDataSourceConfig {
dbName?: string;
userId?: string;
}
export class LocalDataSource implements ILocalDataSource {
private db: SQLite.SQLiteDatabase | null = null;
private dbName: string;
private initialized = false;
constructor(config: LocalDataSourceConfig = {}) {
// 如果提供了userId使用用户专属数据库
this.dbName = config.dbName || (config.userId ? `carrot_bbs_${config.userId}.db` : 'carrot_bbs.db');
}
/**
* 初始化数据库连接
*/
async initialize(): Promise<void> {
if (this.initialized && this.db) {
return;
}
try {
// 使用全局实例管理
if (dbInstance && currentDbName === this.dbName) {
this.db = dbInstance;
this.initialized = true;
return;
}
// 关闭旧连接
if (dbInstance) {
try {
await dbInstance.closeAsync();
} catch (e) {
console.warn('关闭旧数据库连接失败:', e);
}
}
// 创建新连接
this.db = await SQLite.openDatabaseAsync(this.dbName);
dbInstance = this.db;
currentDbName = this.dbName;
this.initialized = true;
// 初始化数据库表结构
await this.createTables();
} catch (error) {
this.handleError(error, 'INITIALIZE');
}
}
/**
* 创建数据库表结构
*/
private async createTables(): Promise<void> {
if (!this.db) return;
const tables = [
// 消息表
`CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY NOT NULL,
conversationId TEXT NOT NULL,
senderId TEXT NOT NULL,
content TEXT,
type TEXT DEFAULT 'text',
isRead INTEGER DEFAULT 0,
createdAt TEXT NOT NULL,
seq INTEGER DEFAULT 0,
status TEXT DEFAULT 'normal',
segments TEXT
)`,
// 会话表
`CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY NOT NULL,
participantId TEXT NOT NULL,
lastMessageId TEXT,
lastSeq INTEGER DEFAULT 0,
unreadCount INTEGER DEFAULT 0,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`,
// 会话缓存表
`CREATE TABLE IF NOT EXISTS conversation_cache (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`,
// 会话列表缓存表
`CREATE TABLE IF NOT EXISTS conversation_list_cache (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`,
// 用户缓存表
`CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`,
// 当前登录用户缓存
`CREATE TABLE IF NOT EXISTS current_user_cache (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`,
// 群组缓存表
`CREATE TABLE IF NOT EXISTS groups (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`,
// 群成员缓存表
`CREATE TABLE IF NOT EXISTS group_members (
groupId TEXT NOT NULL,
userId TEXT NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL,
PRIMARY KEY (groupId, userId)
)`,
];
for (const sql of tables) {
await this.db.execAsync(sql);
}
// 创建索引
const indexes = [
`CREATE INDEX IF NOT EXISTS idx_messages_conversationId ON messages(conversationId)`,
`CREATE INDEX IF NOT EXISTS idx_messages_seq ON messages(seq)`,
`CREATE INDEX IF NOT EXISTS idx_messages_conversation_seq ON messages(conversationId, seq)`,
`CREATE INDEX IF NOT EXISTS idx_group_members_groupId ON group_members(groupId)`,
];
for (const sql of indexes) {
await this.db.execAsync(sql);
}
}
/**
* 处理错误
*/
private handleError(error: unknown, operation: string): never {
const message = error instanceof Error ? error.message : 'Unknown error';
throw new DataSourceError(
`Database ${operation} failed: ${message}`,
'DB_ERROR',
'LocalDataSource',
error instanceof Error ? error : undefined
);
}
/**
* 检查数据库连接
*/
private ensureDb(): SQLite.SQLiteDatabase {
if (!this.db) {
throw new DataSourceError(
'Database not initialized',
'DB_NOT_INITIALIZED',
'LocalDataSource'
);
}
return this.db;
}
// ==================== ILocalDataSource 实现 ====================
async query<T>(sql: string, params?: any[]): Promise<T[]> {
try {
const db = this.ensureDb();
return await db.getAllAsync<T>(sql, params);
} catch (error) {
this.handleError(error, 'QUERY');
}
}
async getFirst<T>(sql: string, params?: any[]): Promise<T | null> {
try {
const db = this.ensureDb();
return await db.getFirstAsync<T>(sql, params);
} catch (error) {
this.handleError(error, 'GET_FIRST');
}
}
async execute(sql: string, params?: any[]): Promise<void> {
try {
const db = this.ensureDb();
await db.execAsync(sql);
} catch (error) {
this.handleError(error, 'EXECUTE');
}
}
async run(sql: string, params?: any[]): Promise<{ lastInsertRowId: number; changes: number }> {
try {
const db = this.ensureDb();
return await db.runAsync(sql, params);
} catch (error) {
this.handleError(error, 'RUN');
}
}
async transaction(operations: () => Promise<void>): Promise<void> {
try {
const db = this.ensureDb();
await db.withTransactionAsync(async () => {
await operations();
});
} catch (error) {
this.handleError(error, 'TRANSACTION');
}
}
async batch(operations: (db: ILocalDataSource) => Promise<void>): Promise<void> {
try {
await this.transaction(async () => {
await operations(this);
});
} catch (error) {
this.handleError(error, 'BATCH');
}
}
// ==================== 队列写入支持 ====================
/**
* 使用队列执行写入操作(避免并发写入冲突)
*/
async enqueueWrite<T>(operation: () => Promise<T>): Promise<T> {
const wrappedOperation = async () => {
try {
return await operation();
} catch (error) {
// 尝试重连后重试
if (this.isRecoverableError(error)) {
console.error('数据库写入异常,尝试重连后重试:', error);
this.db = null;
dbInstance = null;
await this.initialize();
return operation();
}
throw error;
}
};
const queued = writeQueue.then(wrappedOperation, wrappedOperation);
writeQueue = queued.then(() => undefined, () => undefined);
return queued;
}
/**
* 判断错误是否可恢复
*/
private isRecoverableError(error: unknown): boolean {
const message = String(error);
return (
message.includes('NativeDatabase.prepareAsync') ||
message.includes('NullPointerException') ||
message.includes('database is locked')
);
}
}
// 导出单例实例
export const localDataSource = new LocalDataSource();

View File

@@ -0,0 +1,184 @@
/**
* SSEClient - SSE连接管理
* 只负责SSE连接管理提供事件驱动架构
*/
import {
sseService,
WSChatMessage,
WSGroupChatMessage,
WSReadMessage,
WSGroupReadMessage,
WSRecallMessage,
WSGroupRecallMessage,
WSGroupTypingMessage,
WSGroupNoticeMessage,
WSMessageType,
} from '../../services/sseService';
// 事件处理器类型
type EventHandler<T> = (data: T) => void;
// SSE事件类型
export interface SSEEvents {
'chat': WSChatMessage;
'group_message': WSGroupChatMessage;
'read': WSReadMessage;
'group_read': WSGroupReadMessage;
'recall': WSRecallMessage;
'group_recall': WSGroupRecallMessage;
'group_typing': WSGroupTypingMessage;
'group_notice': WSGroupNoticeMessage;
'connected': void;
'disconnected': void;
'error': Error;
}
export type SSEEventType = keyof SSEEvents;
class SSEClient {
private handlers: Map<SSEEventType, Set<EventHandler<any>>> = new Map();
private unsubscribeFns: Array<() => void> = [];
private isInitialized = false;
/**
* 初始化SSE监听
*/
initialize(): void {
if (this.isInitialized) return;
// 监听私聊消息
const unsubChat = sseService.on('chat', (message) => {
this.emit('chat', message);
});
// 监听群聊消息
const unsubGroupMessage = sseService.on('group_message', (message) => {
this.emit('group_message', message);
});
// 监听私聊已读回执
const unsubRead = sseService.on('read', (message) => {
this.emit('read', message);
});
// 监听群聊已读回执
const unsubGroupRead = sseService.on('group_read', (message) => {
this.emit('group_read', message);
});
// 监听私聊消息撤回
const unsubRecall = sseService.on('recall', (message) => {
this.emit('recall', message);
});
// 监听群聊消息撤回
const unsubGroupRecall = sseService.on('group_recall', (message) => {
this.emit('group_recall', message);
});
// 监听群聊输入状态
const unsubGroupTyping = sseService.on('group_typing', (message) => {
this.emit('group_typing', message);
});
// 监听群通知
const unsubGroupNotice = sseService.on('group_notice', (message) => {
this.emit('group_notice', message);
});
// 监听连接状态
const unsubConnect = sseService.onConnect(() => {
this.emit('connected', undefined);
});
const unsubDisconnect = sseService.onDisconnect(() => {
this.emit('disconnected', undefined);
});
this.unsubscribeFns = [
unsubChat,
unsubGroupMessage,
unsubRead,
unsubGroupRead,
unsubRecall,
unsubGroupRecall,
unsubGroupTyping,
unsubGroupNotice,
unsubConnect,
unsubDisconnect,
];
this.isInitialized = true;
}
/**
* 销毁SSE监听
*/
destroy(): void {
this.unsubscribeFns.forEach((fn) => fn());
this.unsubscribeFns = [];
this.handlers.clear();
this.isInitialized = false;
}
/**
* 订阅事件
*/
on<T extends SSEEventType>(
event: T,
handler: EventHandler<SSEEvents[T]>
): () => void {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set());
}
this.handlers.get(event)!.add(handler);
return () => {
this.handlers.get(event)?.delete(handler);
};
}
/**
* 触发事件
*/
private emit<T extends SSEEventType>(
event: T,
data: SSEEvents[T]
): void {
const handlers = this.handlers.get(event);
if (handlers) {
handlers.forEach((handler) => {
try {
handler(data);
} catch (error) {
console.error(`[SSEClient] 事件处理器执行失败: ${event}`, error);
}
});
}
}
/**
* 检查连接状态
*/
isConnected(): boolean {
return sseService.isConnected();
}
/**
* 启动SSE连接
*/
async connect(): Promise<boolean> {
return sseService.start();
}
/**
* 断开SSE连接
*/
disconnect(): void {
sseService.stop();
}
}
export const sseClient = new SSEClient();
export default sseClient;

View File

@@ -0,0 +1,9 @@
/**
* 数据源导出
* 统一导出所有数据源实现
*/
export * from './interfaces';
export * from './ApiDataSource';
export * from './LocalDataSource';
export * from './CacheDataSource';

View File

@@ -0,0 +1,64 @@
/**
* 数据源接口定义
* 定义所有数据源的标准接口,便于替换和测试
*/
// API 数据源接口
export interface IApiDataSource {
get<T>(url: string, params?: any): Promise<T>;
post<T>(url: string, data?: any): Promise<T>;
put<T>(url: string, data?: any): Promise<T>;
delete<T>(url: string, data?: any): Promise<T>;
upload<T>(url: string, file: { uri: string; name: string; type: string }, additionalData?: Record<string, string>): Promise<T>;
}
// 本地数据源接口 (SQLite)
export interface ILocalDataSource {
query<T>(sql: string, params?: any[]): Promise<T[]>;
getFirst<T>(sql: string, params?: any[]): Promise<T | null>;
execute(sql: string, params?: any[]): Promise<void>;
run(sql: string, params?: any[]): Promise<{ lastInsertRowId: number; changes: number }>;
transaction(operations: () => Promise<void>): Promise<void>;
batch(operations: (db: ILocalDataSource) => Promise<void>): Promise<void>;
}
// 缓存数据源接口
export interface ICacheDataSource {
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T, ttl?: number): Promise<void>;
delete(key: string): Promise<void>;
clear(): Promise<void>;
has(key: string): Promise<boolean>;
getMultiple<T>(keys: string[]): Promise<(T | null)[]>;
setMultiple<T>(entries: { key: string; value: T; ttl?: number }[]): Promise<void>;
}
// 数据源错误类型
export class DataSourceError extends Error {
constructor(
message: string,
public code: string,
public source: string,
public originalError?: Error
) {
super(message);
this.name = 'DataSourceError';
}
}
// 数据源配置
export interface DataSourceConfig {
api?: {
baseUrl: string;
timeout?: number;
retryCount?: number;
};
local?: {
dbName: string;
version: number;
};
cache?: {
maxSize: number;
defaultTtl: number;
};
}

View File

@@ -0,0 +1,290 @@
/**
* 会话数据映射器
* 负责 Conversation 模型与 API 响应、数据库记录之间的转换
*/
import {
ConversationModel,
ConversationType,
UserModel,
GroupModel,
MessageModel,
} from '../models';
import type {
ConversationResponse,
ConversationDetailResponse,
UserDTO,
GroupResponse,
MessageResponse,
} from '../../types/dto';
import { MessageMapper } from './MessageMapper';
// 数据库会话记录类型
export interface ConversationDbRecord {
id: string;
participantId: string;
lastMessageId: string | null;
lastSeq: number;
unreadCount: number;
createdAt: string;
updatedAt: string;
}
// 缓存数据类型
export interface ConversationCacheData {
id: string;
type: string;
is_pinned?: boolean;
last_seq?: number;
last_message?: any;
last_message_at?: string;
unread_count?: number;
participants?: any[];
my_last_read_seq?: number;
other_last_read_seq?: number;
group?: any;
created_at?: string;
updated_at?: string;
}
export class ConversationMapper {
/**
* 将 API 列表响应转换为应用模型
*/
static fromApiResponse(response: ConversationResponse): ConversationModel {
return {
id: String(response.id || ''),
type: (response.type as ConversationType) || 'private',
participantId: this.extractParticipantId(response),
lastMessageId: response.last_message?.id,
lastMessage: response.last_message
? MessageMapper.fromApiResponse(response.last_message)
: undefined,
lastSeq: response.last_seq || 0,
myLastReadSeq: response.my_last_read_seq || 0,
otherLastReadSeq: response.other_last_read_seq || 0,
unreadCount: response.unread_count || 0,
isPinned: response.is_pinned || false,
participants: response.participants?.map(p => this.mapUserFromApi(p)),
group: response.group ? this.mapGroupFromApi(response.group) : undefined,
createdAt: new Date(response.created_at || Date.now()),
updatedAt: new Date(response.updated_at || Date.now()),
};
}
/**
* 将 API 详情响应转换为应用模型
*/
static fromDetailApiResponse(response: ConversationDetailResponse): ConversationModel {
return {
id: String(response.id || ''),
type: (response.type as ConversationType) || 'private',
participantId: this.extractParticipantIdFromDetail(response),
lastMessageId: response.last_message?.id,
lastMessage: response.last_message
? MessageMapper.fromApiResponse(response.last_message)
: undefined,
lastSeq: response.last_seq || 0,
myLastReadSeq: response.my_last_read_seq || 0,
otherLastReadSeq: response.other_last_read_seq || 0,
unreadCount: response.unread_count || 0,
isPinned: response.is_pinned || false,
participants: response.participants?.map(p => this.mapUserFromApi(p)),
group: response.group ? this.mapGroupFromApi(response.group) : undefined,
createdAt: new Date(response.created_at || Date.now()),
updatedAt: new Date(response.updated_at || Date.now()),
};
}
/**
* 将 API 响应数组转换为应用模型数组
*/
static fromApiResponseList(responses: ConversationResponse[]): ConversationModel[] {
return responses.map(r => this.fromApiResponse(r));
}
/**
* 从缓存数据转换为应用模型
*/
static fromCacheData(id: string, cached: ConversationCacheData): ConversationModel {
return {
id: String(cached?.id || id),
type: (cached?.type as ConversationType) || 'private',
participantId: '',
lastSeq: Number(cached?.last_seq || 0),
lastMessage: cached?.last_message
? MessageMapper.fromApiResponse(cached.last_message)
: undefined,
lastMessageId: cached?.last_message?.id,
myLastReadSeq: Number(cached?.my_last_read_seq || 0),
otherLastReadSeq: Number(cached?.other_last_read_seq || 0),
unreadCount: Number(cached?.unread_count || 0),
isPinned: Boolean(cached?.is_pinned),
participants: Array.isArray(cached?.participants)
? cached.participants.map(p => this.mapUserFromApi(p))
: [],
group: cached?.group ? this.mapGroupFromApi(cached.group) : undefined,
createdAt: new Date(cached?.created_at || Date.now()),
updatedAt: new Date(cached?.updated_at || Date.now()),
};
}
/**
* 将数据库记录转换为应用模型
*/
static fromDbRecord(record: ConversationDbRecord): ConversationModel {
return {
id: record.id,
type: 'private', // 数据库中不存储类型,需要额外查询
participantId: record.participantId,
lastMessageId: record.lastMessageId || undefined,
lastSeq: record.lastSeq,
myLastReadSeq: 0,
otherLastReadSeq: 0,
unreadCount: record.unreadCount,
isPinned: false,
createdAt: new Date(record.createdAt),
updatedAt: new Date(record.updatedAt),
};
}
/**
* 将应用模型转换为数据库记录
*/
static toDbRecord(model: ConversationModel): ConversationDbRecord {
return {
id: model.id,
participantId: model.participantId,
lastMessageId: model.lastMessageId || null,
lastSeq: model.lastSeq,
unreadCount: model.unreadCount,
createdAt: model.createdAt.toISOString(),
updatedAt: model.updatedAt.toISOString(),
};
}
/**
* 将应用模型转换为缓存数据
*/
static toCacheData(model: ConversationModel): ConversationCacheData {
return {
id: model.id,
type: model.type,
is_pinned: model.isPinned,
last_seq: model.lastSeq,
last_message: model.lastMessage ? this.messageToCache(model.lastMessage) : undefined,
last_message_at: model.updatedAt.toISOString(),
unread_count: model.unreadCount,
participants: model.participants?.map(p => this.userToCache(p)),
my_last_read_seq: model.myLastReadSeq,
other_last_read_seq: model.otherLastReadSeq,
group: model.group ? this.groupToCache(model.group) : undefined,
created_at: model.createdAt.toISOString(),
updated_at: model.updatedAt.toISOString(),
};
}
/**
* 提取会话参与者 ID
*/
private static extractParticipantId(response: ConversationResponse): string {
if (response.participants && response.participants.length > 0) {
return String(response.participants[0].id || '');
}
return '';
}
/**
* 从详情响应提取参与者 ID
*/
private static extractParticipantIdFromDetail(response: ConversationDetailResponse): string {
if (response.participants && response.participants.length > 0) {
return String(response.participants[0].id || '');
}
return '';
}
/**
* 映射用户 API 响应
*/
private static mapUserFromApi(user: UserDTO): UserModel {
return {
id: String(user.id || ''),
username: user.username || '',
nickname: user.nickname,
avatar: user.avatar,
};
}
/**
* 映射群组 API 响应
*/
private static mapGroupFromApi(group: GroupResponse): GroupModel {
return {
id: String(group.id || ''),
name: group.name || '',
avatar: group.avatar,
description: group.description,
announcement: group.announcement,
ownerId: String(group.owner_id || ''),
memberCount: group.member_count || 0,
maxMemberCount: group.max_member_count || 500,
joinType: group.join_type || 'approval',
isMuted: group.mute_all || false,
createdAt: new Date(group.created_at || Date.now()),
updatedAt: new Date(group.updated_at || Date.now()),
};
}
/**
* 消息模型转缓存格式
*/
private static messageToCache(message: MessageModel): any {
return {
id: message.id,
conversation_id: message.conversationId,
sender_id: message.senderId,
content: message.content,
message_type: message.type,
is_read: message.isRead,
created_at: message.createdAt.toISOString(),
seq: message.seq,
status: message.status,
segments: message.segments,
reply_to_id: message.replyToId,
sender: message.sender ? this.userToCache(message.sender) : undefined,
};
}
/**
* 用户模型转缓存格式
*/
private static userToCache(user: UserModel): any {
return {
id: user.id,
username: user.username,
nickname: user.nickname,
avatar: user.avatar,
};
}
/**
* 群组模型转缓存格式
*/
private static groupToCache(group: GroupModel): any {
return {
id: group.id,
name: group.name,
avatar: group.avatar,
description: group.description,
announcement: group.announcement,
owner_id: group.ownerId,
member_count: group.memberCount,
max_member_count: group.maxMemberCount,
join_type: group.joinType,
mute_all: group.isMuted,
created_at: group.createdAt.toISOString(),
updated_at: group.updatedAt.toISOString(),
};
}
}

View File

@@ -0,0 +1,183 @@
/**
* 消息数据映射器
* 负责 Message 模型与 API 响应、数据库记录之间的转换
*/
import {
MessageModel,
MessageSegment,
UserModel,
} from '../models';
import type {
MessageResponse,
UserDTO,
} from '../../types/dto';
// 数据库消息记录类型
export interface MessageDbRecord {
id: string;
conversationId: string;
senderId: string;
content: string | null;
type: string;
isRead: number;
createdAt: string;
seq: number;
status: string;
segments: string | null;
}
export class MessageMapper {
/**
* 将 API 响应转换为应用模型
*/
static fromApiResponse(response: MessageResponse): MessageModel {
return {
id: String(response.id || ''),
conversationId: String(response.conversation_id || ''),
senderId: String(response.sender_id || ''),
content: response.content || '',
type: response.message_type || 'text',
isRead: response.is_read || false,
createdAt: new Date(response.created_at || Date.now()),
seq: response.seq || 0,
status: response.status || 'normal',
segments: response.segments || [],
replyToId: response.reply_to_id,
sender: response.sender ? this.mapSenderFromApi(response.sender) : undefined,
};
}
/**
* 将 API 响应数组转换为应用模型数组
*/
static fromApiResponseList(responses: MessageResponse[]): MessageModel[] {
return responses.map(r => this.fromApiResponse(r));
}
/**
* 将数据库记录转换为应用模型
*/
static fromDbRecord(record: MessageDbRecord): MessageModel {
return {
id: record.id,
conversationId: record.conversationId,
senderId: record.senderId,
content: record.content || undefined,
type: record.type || 'text',
isRead: record.isRead === 1,
createdAt: new Date(record.createdAt),
seq: record.seq || 0,
status: record.status || 'normal',
segments: record.segments ? this.safeParseJson<MessageSegment[]>(record.segments) : undefined,
};
}
/**
* 将数据库记录数组转换为应用模型数组
*/
static fromDbRecordList(records: MessageDbRecord[]): MessageModel[] {
return records.map(r => this.fromDbRecord(r));
}
/**
* 将应用模型转换为数据库记录
*/
static toDbRecord(model: MessageModel): MessageDbRecord {
return {
id: model.id,
conversationId: model.conversationId,
senderId: model.senderId,
content: model.content || null,
type: model.type,
isRead: model.isRead ? 1 : 0,
createdAt: model.createdAt.toISOString(),
seq: model.seq,
status: model.status,
segments: model.segments ? JSON.stringify(model.segments) : null,
};
}
/**
* 将应用模型转换为 API 请求数据
*/
static toApiRequest(model: Partial<MessageModel>): Record<string, any> {
const request: Record<string, any> = {};
if (model.segments) {
request.segments = model.segments;
}
if (model.content) {
request.content = model.content;
}
if (model.type) {
request.message_type = model.type;
}
if (model.replyToId) {
request.reply_to_id = model.replyToId;
}
return request;
}
/**
* 创建发送消息请求体
*/
static createSendRequest(
detailType: 'private' | 'group',
segments: MessageSegment[],
replyToId?: string
): Record<string, any> {
const body: Record<string, any> = {
detail_type: detailType,
segments,
};
if (replyToId) {
body.reply_to_id = replyToId;
}
return body;
}
/**
* 创建文本消息段
*/
static createTextSegment(text: string): MessageSegment {
return {
type: 'text',
data: { text },
};
}
/**
* 创建图片消息段
*/
static createImageSegment(url: string): MessageSegment {
return {
type: 'image',
data: { url },
};
}
/**
* 安全解析 JSON
*/
private static safeParseJson<T>(value: string): T | undefined {
try {
return JSON.parse(value) as T;
} catch {
return undefined;
}
}
/**
* 映射发送者信息
*/
private static mapSenderFromApi(sender: UserDTO): UserModel {
return {
id: String(sender.id || ''),
username: sender.username || '',
nickname: sender.nickname,
avatar: sender.avatar,
};
}
}

View File

@@ -0,0 +1,123 @@
/**
* 帖子数据映射器
* 负责 Post 模型与 API 响应之间的转换
*/
import { PostModel, UserModel } from '../models';
import type { Post } from '../../types';
export class PostMapper {
/**
* 将 API 响应转换为应用模型
*/
static fromApiResponse(response: Post): PostModel {
return {
id: String(response.id || ''),
authorId: String(response.author_id || ''),
author: response.author ? this.mapAuthorFromApi(response.author) : undefined,
title: response.title || '',
content: response.content || '',
images: response.images || [],
likeCount: response.like_count || 0,
commentCount: response.comment_count || 0,
shareCount: response.share_count || 0,
viewCount: response.view_count || 0,
favoriteCount: response.favorite_count || 0,
isLiked: response.is_liked || false,
isFavorited: response.is_favorited || false,
isTop: response.is_top || false,
status: response.status || 'published',
communityId: response.community_id,
tags: response.tags || [],
createdAt: new Date(response.created_at || Date.now()),
updatedAt: new Date(response.updated_at || Date.now()),
};
}
/**
* 将 API 响应数组转换为应用模型数组
*/
static fromApiResponseList(responses: Post[]): PostModel[] {
return responses.map(r => this.fromApiResponse(r));
}
/**
* 将应用模型转换为 API 请求数据
*/
static toApiRequest(model: Partial<PostModel>): Record<string, any> {
const request: Record<string, any> = {};
if (model.title !== undefined) {
request.title = model.title;
}
if (model.content !== undefined) {
request.content = model.content;
}
if (model.images !== undefined) {
request.images = model.images;
}
if (model.communityId !== undefined) {
request.community_id = model.communityId;
}
if (model.tags !== undefined) {
request.tags = model.tags;
}
return request;
}
/**
* 创建帖子请求
*/
static createPostRequest(
title: string,
content: string,
images?: string[],
communityId?: string
): Record<string, any> {
const request: Record<string, any> = {
title,
content,
};
if (images && images.length > 0) {
request.images = images;
}
if (communityId) {
request.community_id = communityId;
}
return request;
}
/**
* 更新帖子请求
*/
static updatePostRequest(
title?: string,
content?: string,
images?: string[]
): Record<string, any> {
const request: Record<string, any> = {};
if (title !== undefined) {
request.title = title;
}
if (content !== undefined) {
request.content = content;
}
if (images !== undefined) {
request.images = images;
}
return request;
}
/**
* 映射作者信息
*/
private static mapAuthorFromApi(author: any): UserModel {
return {
id: String(author.id || ''),
username: author.username || '',
nickname: author.nickname,
avatar: author.avatar,
};
}
}

View File

@@ -0,0 +1,168 @@
/**
* 用户数据映射器
* 负责 User 模型与 API 响应、数据库记录之间的转换
*/
import { UserModel } from '../models';
import type { UserDTO, User } from '../../types/dto';
// 数据库用户记录类型
export interface UserDbRecord {
id: string;
data: string;
updatedAt: string;
}
export class UserMapper {
/**
* 将 API DTO 转换为应用模型
*/
static fromDTO(dto: UserDTO): UserModel {
return {
id: String(dto.id || ''),
username: dto.username || '',
nickname: dto.nickname,
avatar: dto.avatar,
bio: dto.bio,
website: dto.website,
location: dto.location,
email: dto.email,
phone: dto.phone,
followersCount: dto.followers_count,
followingCount: dto.following_count,
postsCount: dto.posts_count,
isFollowing: dto.is_following,
isBlocked: dto.is_blocked,
createdAt: dto.created_at ? new Date(dto.created_at) : undefined,
updatedAt: dto.updated_at ? new Date(dto.updated_at) : undefined,
};
}
/**
* 将 User 类型转换为应用模型
*/
static fromUser(user: User): UserModel {
return {
id: String(user.id || ''),
username: user.username || '',
nickname: user.nickname,
avatar: user.avatar,
bio: user.bio,
website: user.website,
location: user.location,
email: user.email,
phone: user.phone,
followersCount: user.followers_count,
followingCount: user.following_count,
postsCount: user.posts_count,
isFollowing: user.is_following,
isBlocked: user.is_blocked,
createdAt: user.created_at ? new Date(user.created_at) : undefined,
updatedAt: user.updated_at ? new Date(user.updated_at) : undefined,
};
}
/**
* 将 DTO 数组转换为应用模型数组
*/
static fromDTOList(dtos: UserDTO[]): UserModel[] {
return dtos.map(dto => this.fromDTO(dto));
}
/**
* 从数据库记录转换为应用模型
*/
static fromDbRecord(record: UserDbRecord): UserModel | null {
try {
const data = JSON.parse(record.data) as UserDTO;
return this.fromDTO(data);
} catch {
return null;
}
}
/**
* 将应用模型转换为数据库记录
*/
static toDbRecord(model: UserModel): UserDbRecord {
const dto: UserDTO = {
id: model.id,
username: model.username,
nickname: model.nickname,
avatar: model.avatar,
bio: model.bio,
website: model.website,
location: model.location,
email: model.email,
phone: model.phone,
followers_count: model.followersCount,
following_count: model.followingCount,
posts_count: model.postsCount,
is_following: model.isFollowing,
is_blocked: model.isBlocked,
created_at: model.createdAt?.toISOString(),
updated_at: model.updatedAt?.toISOString(),
};
return {
id: model.id,
data: JSON.stringify(dto),
updatedAt: new Date().toISOString(),
};
}
/**
* 将应用模型转换为 API 请求数据
*/
static toApiRequest(model: Partial<UserModel>): Record<string, any> {
const request: Record<string, any> = {};
if (model.nickname !== undefined) {
request.nickname = model.nickname;
}
if (model.avatar !== undefined) {
request.avatar = model.avatar;
}
if (model.bio !== undefined) {
request.bio = model.bio;
}
if (model.website !== undefined) {
request.website = model.website;
}
if (model.location !== undefined) {
request.location = model.location;
}
if (model.phone !== undefined) {
request.phone = model.phone;
}
if (model.email !== undefined) {
request.email = model.email;
}
return request;
}
/**
* 将应用模型转换为 DTO
*/
static toDTO(model: UserModel): UserDTO {
return {
id: model.id,
username: model.username,
nickname: model.nickname,
avatar: model.avatar,
bio: model.bio,
website: model.website,
location: model.location,
email: model.email,
phone: model.phone,
followers_count: model.followersCount,
following_count: model.followingCount,
posts_count: model.postsCount,
is_following: model.isFollowing,
is_blocked: model.isBlocked,
created_at: model.createdAt?.toISOString(),
updated_at: model.updatedAt?.toISOString(),
};
}
}

View File

@@ -0,0 +1,9 @@
/**
* 映射器导出
* 统一导出所有数据映射器
*/
export * from './MessageMapper';
export * from './ConversationMapper';
export * from './UserMapper';
export * from './PostMapper';

153
src/data/models/index.ts Normal file
View File

@@ -0,0 +1,153 @@
/**
* Repository 层模型定义
* 定义应用内部使用的数据模型,与 API 响应和数据库结构解耦
*/
// ==================== 消息模型 ====================
export interface MessageSegment {
type: string;
data: Record<string, any>;
}
export interface MessageModel {
id: string;
conversationId: string;
senderId: string;
content?: string;
type: 'text' | 'image' | 'file' | 'system' | string;
isRead: boolean;
createdAt: Date;
seq: number;
status: 'normal' | 'recalled' | 'deleted' | string;
segments?: MessageSegment[];
replyToId?: string;
sender?: UserModel;
}
// ==================== 会话模型 ====================
export type ConversationType = 'private' | 'group';
export interface ConversationModel {
id: string;
type: ConversationType;
participantId: string;
lastMessageId?: string;
lastMessage?: MessageModel;
lastSeq: number;
myLastReadSeq: number;
otherLastReadSeq: number;
unreadCount: number;
isPinned: boolean;
participants?: UserModel[];
group?: GroupModel;
createdAt: Date;
updatedAt: Date;
}
// ==================== 用户模型 ====================
export interface UserModel {
id: string;
username: string;
nickname?: string;
avatar?: string;
bio?: string;
website?: string;
location?: string;
email?: string;
phone?: string;
followersCount?: number;
followingCount?: number;
postsCount?: number;
isFollowing?: boolean;
isBlocked?: boolean;
createdAt?: Date;
updatedAt?: Date;
}
// ==================== 帖子模型 ====================
export interface PostModel {
id: string;
authorId: string;
author?: UserModel;
title: string;
content: string;
images?: string[];
likeCount: number;
commentCount: number;
shareCount: number;
viewCount: number;
favoriteCount: number;
isLiked: boolean;
isFavorited: boolean;
isTop: boolean;
status: 'published' | 'draft' | 'deleted';
communityId?: string;
tags?: string[];
createdAt: Date;
updatedAt: Date;
}
// ==================== 群组模型 ====================
export type GroupMemberRole = 'owner' | 'admin' | 'member';
export type GroupJoinType = 'anyone' | 'approval' | 'invite';
export interface GroupMemberModel {
userId: string;
user?: UserModel;
role: GroupMemberRole;
nickname?: string;
joinTime: Date;
muteUntil?: Date;
isMuted?: boolean;
}
export interface GroupModel {
id: string;
name: string;
avatar?: string;
description?: string;
announcement?: string;
ownerId: string;
owner?: UserModel;
memberCount: number;
maxMemberCount: number;
joinType: GroupJoinType;
isMuted: boolean;
createdAt: Date;
updatedAt: Date;
}
// ==================== 分页模型 ====================
export interface PaginatedResult<T> {
list: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
hasMore: boolean;
}
// ==================== 通用操作结果 ====================
export interface OperationResult<T = void> {
success: boolean;
data?: T;
error?: string;
code?: string;
}
// ==================== 同步状态模型 ====================
export interface SyncStatus {
entityType: string;
entityId: string;
lastSyncedAt: Date;
syncVersion: number;
pendingChanges: boolean;
}

View File

@@ -0,0 +1,220 @@
/**
* MessageRepository - 消息仓库实现
* 封装所有SQLite数据库操作不依赖任何UI或状态管理
*/
import {
saveMessage as dbSaveMessage,
saveMessagesBatch as dbSaveMessagesBatch,
getMessagesByConversation as dbGetMessagesByConversation,
getMaxSeq as dbGetMaxSeq,
getMinSeq as dbGetMinSeq,
getMessagesBeforeSeq as dbGetMessagesBeforeSeq,
markConversationAsRead as dbMarkConversationAsRead,
updateConversationCacheUnreadCount as dbUpdateConversationCacheUnreadCount,
getUserCache as dbGetUserCache,
saveUserCache as dbSaveUserCache,
deleteConversation as dbDeleteConversation,
updateMessageStatus as dbUpdateMessageStatus,
CachedMessage,
} from '../../services/database';
import { Message, Conversation, createMessage, createConversation } from '../../core/entities/Message';
// 数据库消息到领域实体的转换
const cachedMessageToMessage = (cached: CachedMessage): Message => ({
id: cached.id,
conversationId: cached.conversationId,
senderId: cached.senderId,
seq: cached.seq,
segments: cached.segments || [],
createdAt: cached.createdAt,
status: cached.status as Message['status'],
});
// 领域实体到数据库消息的转换
const messageToCachedMessage = (message: Message, isRead: boolean): CachedMessage => ({
id: message.id,
conversationId: message.conversationId,
senderId: message.senderId,
content: message.segments
.filter((s) => s.type === 'text')
.map((s) => s.data?.text || '')
.join(''),
type: message.segments.find((s) => s.type === 'image') ? 'image' : 'text',
isRead,
createdAt: message.createdAt,
seq: message.seq,
status: message.status,
segments: message.segments,
});
class MessageRepository {
// ==================== 消息操作 ====================
/**
* 保存单条消息
*/
async saveMessage(message: Message, isRead: boolean): Promise<void> {
try {
const cachedMessage = messageToCachedMessage(message, isRead);
await dbSaveMessage(cachedMessage);
} catch (error) {
console.error('[MessageRepository] 保存消息失败:', error);
throw error;
}
}
/**
* 批量保存消息
*/
async saveMessages(messages: Message[], isRead: boolean): Promise<void> {
if (!messages || messages.length === 0) return;
try {
const cachedMessages = messages.map((m) => messageToCachedMessage(m, isRead));
await dbSaveMessagesBatch(cachedMessages);
} catch (error) {
console.error('[MessageRepository] 批量保存消息失败:', error);
throw error;
}
}
/**
* 获取会话的消息
*/
async getMessagesByConversation(
conversationId: string,
limit: number = 20
): Promise<Message[]> {
try {
const cachedMessages = await dbGetMessagesByConversation(conversationId, limit);
return cachedMessages.map(cachedMessageToMessage);
} catch (error) {
console.error('[MessageRepository] 获取消息失败:', error);
return [];
}
}
/**
* 获取会话的最大消息序号
*/
async getMaxSeq(conversationId: string): Promise<number> {
try {
return await dbGetMaxSeq(conversationId);
} catch (error) {
console.error('[MessageRepository] 获取最大序号失败:', error);
return 0;
}
}
/**
* 获取会话的最小消息序号
*/
async getMinSeq(conversationId: string): Promise<number> {
try {
return await dbGetMinSeq(conversationId);
} catch (error) {
console.error('[MessageRepository] 获取最小序号失败:', error);
return 0;
}
}
/**
* 获取指定seq之前的历史消息
*/
async getMessagesBeforeSeq(
conversationId: string,
beforeSeq: number,
limit: number = 20
): Promise<Message[]> {
try {
const cachedMessages = await dbGetMessagesBeforeSeq(conversationId, beforeSeq, limit);
return cachedMessages.map(cachedMessageToMessage);
} catch (error) {
console.error('[MessageRepository] 获取历史消息失败:', error);
return [];
}
}
/**
* 标记会话的所有消息为已读
*/
async markConversationAsRead(conversationId: string): Promise<void> {
try {
await dbMarkConversationAsRead(conversationId);
} catch (error) {
console.error('[MessageRepository] 标记会话已读失败:', error);
throw error;
}
}
/**
* 更新会话缓存的未读数
*/
async updateConversationUnreadCount(
conversationId: string,
count: number
): Promise<void> {
try {
await dbUpdateConversationCacheUnreadCount(conversationId, count);
} catch (error) {
console.error('[MessageRepository] 更新会话未读数失败:', error);
}
}
/**
* 更新消息状态(如撤回)
*/
async updateMessageStatus(
messageId: string,
status: string,
clearContent: boolean = false
): Promise<void> {
try {
await dbUpdateMessageStatus(messageId, status, clearContent);
} catch (error) {
console.error('[MessageRepository] 更新消息状态失败:', error);
throw error;
}
}
/**
* 删除会话及其所有消息
*/
async deleteConversation(conversationId: string): Promise<void> {
try {
await dbDeleteConversation(conversationId);
} catch (error) {
console.error('[MessageRepository] 删除会话失败:', error);
throw error;
}
}
// ==================== 用户缓存操作 ====================
/**
* 获取用户缓存
*/
async getUserCache(userId: string): Promise<any | null> {
try {
return await dbGetUserCache(userId);
} catch (error) {
console.error('[MessageRepository] 获取用户缓存失败:', error);
return null;
}
}
/**
* 保存用户缓存
*/
async saveUserCache(user: any): Promise<void> {
try {
await dbSaveUserCache(user);
} catch (error) {
console.error('[MessageRepository] 保存用户缓存失败:', error);
}
}
}
export const messageRepository = new MessageRepository();
export default messageRepository;

View File

@@ -0,0 +1,93 @@
/**
* 消息 Repository 接口
* 定义消息数据访问的抽象
*/
import type { Message, Conversation, ConversationType } from '../../types/dto';
export interface IMessageRepository {
// ==================== 消息操作 ====================
/**
* 获取会话的消息列表
*/
getMessages(
conversationId: string,
options?: {
beforeSeq?: number;
afterSeq?: number;
limit?: number;
}
): Promise<Message[]>;
/**
* 保存消息
*/
saveMessage(message: Message): Promise<void>;
/**
* 批量保存消息
*/
saveMessages(messages: Message[]): Promise<void>;
/**
* 更新消息状态
*/
updateMessageStatus(
messageId: string,
status: 'normal' | 'recalled' | 'deleted'
): Promise<void>;
/**
* 标记消息为已读
*/
markAsRead(messageIds: string[]): Promise<void>;
/**
* 获取未读消息数
*/
getUnreadCount(conversationId: string): Promise<number>;
// ==================== 会话操作 ====================
/**
* 获取会话列表
*/
getConversations(): Promise<Conversation[]>;
/**
* 获取单个会话
*/
getConversation(conversationId: string): Promise<Conversation | null>;
/**
* 保存会话
*/
saveConversation(conversation: Conversation): Promise<void>;
/**
* 更新会话未读数
*/
updateUnreadCount(conversationId: string, count: number): Promise<void>;
/**
* 更新会话最后消息
*/
updateLastMessage(
conversationId: string,
messageId: string,
seq: number
): Promise<void>;
// ==================== 同步操作 ====================
/**
* 从服务器同步消息
*/
syncMessages(conversationId: string, lastSeq: number): Promise<Message[]>;
/**
* 获取服务器最新消息序列号
*/
getServerLastSeq(conversationId: string): Promise<number>;
}

View File

@@ -2,31 +2,66 @@
* Hooks 导出
*/
// 响应式相关 Hooks
// ==================== 新的响应式 Hooks (推荐) ====================
export {
useResponsive,
BREAKPOINTS,
FINE_BREAKPOINTS,
useResponsiveValue,
useResponsiveStyle,
// 核心 hooks
useBreakpoint,
useFineBreakpoint,
useBreakpointGTE,
useBreakpointLT,
useBreakpointBetween,
useScreenSize,
useWindowDimensions,
useOrientation,
usePlatform,
useMediaQuery,
useResponsiveValue,
useResponsiveStyle,
useColumnCount,
useResponsiveSpacing,
// 兼容层
useResponsive,
useLegacyResponsive,
// 工具函数
getBreakpoint,
getFineBreakpoint,
isBreakpointGTE,
isBreakpointLT,
} from './useResponsive';
isBreakpointBetween,
// 常量
BREAKPOINTS,
FINE_BREAKPOINTS,
} from '../presentation/hooks/responsive';
export type {
ResponsiveInfo,
BreakpointKey,
BreakpointValue,
FineBreakpointKey,
BreakpointValue,
ResponsiveValue,
Orientation,
PlatformInfo,
ScreenSize,
MediaQueryOptions,
// 兼容层类型
ResponsiveInfo,
} from '../presentation/hooks/responsive';
// ==================== 旧版响应式 Hooks (保持向后兼容) ====================
// 注意:这些导出将在未来版本中移除,请使用新的 hooks
export {
BREAKPOINTS as BREAKPOINTS_OLD,
FINE_BREAKPOINTS as FINE_BREAKPOINTS_OLD,
} from './useResponsive';
export type {
ResponsiveInfo as ResponsiveInfo_OLD,
BreakpointKey as BreakpointKey_OLD,
BreakpointValue as BreakpointValue_OLD,
FineBreakpointKey as FineBreakpointKey_OLD,
ResponsiveValue as ResponsiveValue_OLD,
} from './useResponsive';
// ==================== 其他 Hooks ====================
export {
usePrefetch,
prefetchPosts,
@@ -36,3 +71,24 @@ export {
prefetchMessageScreen,
prefetchHomeScreen,
} from './usePrefetch';
// ==================== 分页 Hooks ====================
export {
usePagination,
usePaginationManager,
} from './usePagination';
export type {
UsePaginationOptions,
UsePaginationReturn,
} from './usePagination';
// ==================== 连接状态 Hooks ====================
export { useConnectionState } from './useConnectionState';
export type { UseConnectionStateResult } from './useConnectionState';
// ==================== 媒体缓存 Hooks ====================
export { useMediaCache } from './useMediaCache';
export type { UseMediaCacheReturn } from './useMediaCache';

View File

@@ -0,0 +1,82 @@
/**
* 响应式常量定义
*/
// ==================== 断点定义 ====================
export const BREAKPOINTS = {
mobile: 0, // 手机
tablet: 768, // 平板竖屏
desktop: 1024, // 平板横屏/桌面
wide: 1440, // 宽屏桌面
} as const;
export type BreakpointKey = keyof typeof BREAKPOINTS;
export type BreakpointValue = typeof BREAKPOINTS[BreakpointKey];
// ==================== 更细粒度的断点定义 ====================
export const FINE_BREAKPOINTS = {
xs: 0, // 超小屏手机
sm: 375, // 小屏手机 (iPhone SE, 小屏安卓)
md: 414, // 中屏手机 (iPhone Pro Max, 大屏安卓)
lg: 768, // 平板竖屏 / 大折叠屏手机展开
xl: 1024, // 平板横屏 / 小桌面
'2xl': 1280, // 桌面
'3xl': 1440, // 大桌面
'4xl': 1920, // 超大屏
} as const;
export type FineBreakpointKey = keyof typeof FINE_BREAKPOINTS;
// ==================== 响应式值类型 ====================
export type ResponsiveValue<T> = T | Partial<Record<FineBreakpointKey, T>>;
// ==================== 断点顺序(用于比较)====================
export const BREAKPOINT_ORDER: FineBreakpointKey[] = ['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl'];
export const REVERSE_BREAKPOINT_ORDER: FineBreakpointKey[] = ['4xl', '3xl', '2xl', 'xl', 'lg', 'md', 'sm', 'xs'];
// ==================== 获取当前断点 ====================
export function getBreakpoint(width: number): BreakpointKey {
if (width >= BREAKPOINTS.wide) {
return 'wide';
}
if (width >= BREAKPOINTS.desktop) {
return 'desktop';
}
if (width >= BREAKPOINTS.tablet) {
return 'tablet';
}
return 'mobile';
}
// ==================== 获取细粒度断点 ====================
export function getFineBreakpoint(width: number): FineBreakpointKey {
if (width >= FINE_BREAKPOINTS['4xl']) return '4xl';
if (width >= FINE_BREAKPOINTS['3xl']) return '3xl';
if (width >= FINE_BREAKPOINTS['2xl']) return '2xl';
if (width >= FINE_BREAKPOINTS.xl) return 'xl';
if (width >= FINE_BREAKPOINTS.lg) return 'lg';
if (width >= FINE_BREAKPOINTS.md) return 'md';
if (width >= FINE_BREAKPOINTS.sm) return 'sm';
return 'xs';
}
// ==================== 断点比较工具 ====================
/**
* 检查当前断点是否大于等于目标断点
*/
export function isBreakpointGTE(
current: FineBreakpointKey,
target: FineBreakpointKey
): boolean {
return BREAKPOINT_ORDER.indexOf(current) >= BREAKPOINT_ORDER.indexOf(target);
}
/**
* 检查当前断点是否小于目标断点
*/
export function isBreakpointLT(
current: FineBreakpointKey,
target: FineBreakpointKey
): boolean {
return !isBreakpointGTE(current, target);
}

View File

@@ -0,0 +1,45 @@
/**
* 响应式 Hooks 统一导出
*/
// 基础 Hooks
export { useWindowDimensions } from './useWindowDimensions';
export { useBreakpoint, type BreakpointInfo } from './useBreakpoint';
export { useOrientation, type Orientation, type OrientationInfo } from './useOrientation';
export { usePlatform, type PlatformInfo } from './usePlatform';
export { useScreenSize, type ScreenSizeInfo } from './useScreenSize';
// 响应式值 Hooks
export { useResponsiveValue } from './useResponsiveValue';
export { useResponsiveStyle } from './useResponsiveStyle';
export { useResponsiveSpacing } from './useResponsiveSpacing';
// 断点范围 Hooks
export {
useBreakpointGTE,
useBreakpointLT,
useBreakpointBetween,
} from './useBreakpointRange';
// 工具 Hooks
export { useMediaQuery } from './useMediaQuery';
export { useColumnCount } from './useColumnCount';
// 常量和工具函数
export {
BREAKPOINTS,
FINE_BREAKPOINTS,
BREAKPOINT_ORDER,
REVERSE_BREAKPOINT_ORDER,
type BreakpointKey,
type BreakpointValue,
type FineBreakpointKey,
type ResponsiveValue,
getBreakpoint,
getFineBreakpoint,
isBreakpointGTE,
isBreakpointLT,
} from './constants';
// 为了保持向后兼容,重新导出 useResponsive
export { useResponsive, type ResponsiveInfo } from './useResponsive';

View File

@@ -0,0 +1,62 @@
/**
* 断点检测 Hook
*/
import { useMemo } from 'react';
import { useWindowDimensions } from './useWindowDimensions';
import {
BreakpointKey,
FineBreakpointKey,
getBreakpoint,
getFineBreakpoint,
} from './constants';
export interface BreakpointInfo {
// 基础断点
breakpoint: BreakpointKey;
isMobile: boolean;
isTablet: boolean;
isDesktop: boolean;
isWide: boolean;
isWideScreen: boolean;
// 细粒度断点
fineBreakpoint: FineBreakpointKey;
isXS: boolean;
isSM: boolean;
isMD: boolean;
isLG: boolean;
isXL: boolean;
is2XL: boolean;
is3XL: boolean;
is4XL: boolean;
}
/**
* 断点检测 Hook
* 返回当前断点信息和布尔标志
*/
export function useBreakpoint(): BreakpointInfo {
const { width } = useWindowDimensions();
const breakpoint = useMemo(() => getBreakpoint(width), [width]);
const fineBreakpoint = useMemo(() => getFineBreakpoint(width), [width]);
return useMemo(() => ({
breakpoint,
isMobile: breakpoint === 'mobile',
isTablet: breakpoint === 'tablet',
isDesktop: breakpoint === 'desktop',
isWide: breakpoint === 'wide',
isWideScreen: breakpoint === 'tablet' || breakpoint === 'desktop' || breakpoint === 'wide',
fineBreakpoint,
isXS: fineBreakpoint === 'xs',
isSM: fineBreakpoint === 'sm',
isMD: fineBreakpoint === 'md',
isLG: fineBreakpoint === 'lg',
isXL: fineBreakpoint === 'xl',
is2XL: fineBreakpoint === '2xl',
is3XL: fineBreakpoint === '3xl',
is4XL: fineBreakpoint === '4xl',
}), [breakpoint, fineBreakpoint]);
}

View File

@@ -0,0 +1,46 @@
/**
* 断点范围检查 Hooks
*/
import { useMemo } from 'react';
import { useBreakpoint } from './useBreakpoint';
import { FineBreakpointKey, isBreakpointGTE, isBreakpointLT } from './constants';
/**
* 检查当前断点是否大于等于目标断点
*
* @example
* const isMediumUp = useBreakpointGTE('md');
*/
export function useBreakpointGTE(target: FineBreakpointKey): boolean {
const { fineBreakpoint } = useBreakpoint();
return useMemo(() => isBreakpointGTE(fineBreakpoint, target), [fineBreakpoint, target]);
}
/**
* 检查当前断点是否小于目标断点
*
* @example
* const isMobileOnly = useBreakpointLT('lg');
*/
export function useBreakpointLT(target: FineBreakpointKey): boolean {
const { fineBreakpoint } = useBreakpoint();
return useMemo(() => isBreakpointLT(fineBreakpoint, target), [fineBreakpoint, target]);
}
/**
* 检查当前是否在指定断点范围内
*
* @example
* const isTabletRange = useBreakpointBetween('md', 'xl');
*/
export function useBreakpointBetween(
min: FineBreakpointKey,
max: FineBreakpointKey
): boolean {
const { fineBreakpoint } = useBreakpoint();
return useMemo(
() => isBreakpointGTE(fineBreakpoint, min) && !isBreakpointGTE(fineBreakpoint, max),
[fineBreakpoint, min, max]
);
}

View File

@@ -0,0 +1,53 @@
/**
* 列数计算 Hook
*/
import { useMemo } from 'react';
import { useBreakpoint } from './useBreakpoint';
import { FineBreakpointKey } from './constants';
const DEFAULT_COLUMN_CONFIG: Record<FineBreakpointKey, number> = {
xs: 1,
sm: 1,
md: 2,
lg: 3,
xl: 4,
'2xl': 4,
'3xl': 5,
'4xl': 6,
};
/**
* 根据容器宽度计算合适的列数
*
* @param columnConfig - 列数配置
* @returns 列数
*
* @example
* const columns = useColumnCount({
* xs: 1,
* sm: 2,
* md: 3,
* lg: 4
* });
*/
export function useColumnCount(
columnConfig: Partial<Record<FineBreakpointKey, number>> = {}
): number {
const { fineBreakpoint } = useBreakpoint();
return useMemo(() => {
const config = { ...DEFAULT_COLUMN_CONFIG, ...columnConfig };
const breakpointOrder: FineBreakpointKey[] = ['4xl', '3xl', '2xl', 'xl', 'lg', 'md', 'sm', 'xs'];
const currentIndex = breakpointOrder.indexOf(fineBreakpoint);
for (let i = currentIndex; i < breakpointOrder.length; i++) {
const bp = breakpointOrder[i];
if (config[bp] !== undefined) {
return config[bp];
}
}
return 1;
}, [fineBreakpoint, columnConfig]);
}

View File

@@ -0,0 +1,39 @@
/**
* 媒体查询模拟 Hook
*/
import { useMemo } from 'react';
import { useScreenSize } from './useScreenSize';
import { useOrientation } from './useOrientation';
interface MediaQueryOptions {
minWidth?: number;
maxWidth?: number;
minHeight?: number;
maxHeight?: number;
orientation?: 'portrait' | 'landscape';
}
/**
* 模拟 CSS 媒体查询
*
* @param query - 查询条件
* @returns 是否匹配
*
* @example
* const isMinWidth768 = useMediaQuery({ minWidth: 768 });
* const isPortrait = useMediaQuery({ orientation: 'portrait' });
*/
export function useMediaQuery(query: MediaQueryOptions): boolean {
const { width, height } = useScreenSize();
const { orientation: currentOrientation } = useOrientation();
return useMemo(() => {
if (query.minWidth !== undefined && width < query.minWidth) return false;
if (query.maxWidth !== undefined && width > query.maxWidth) return false;
if (query.minHeight !== undefined && height < query.minHeight) return false;
if (query.maxHeight !== undefined && height > query.maxHeight) return false;
if (query.orientation !== undefined && currentOrientation !== query.orientation) return false;
return true;
}, [width, height, currentOrientation, query]);
}

View File

@@ -0,0 +1,36 @@
/**
* 屏幕方向检测 Hook
*/
import { useMemo } from 'react';
import { useWindowDimensions } from './useWindowDimensions';
export type Orientation = 'portrait' | 'landscape';
export interface OrientationInfo {
orientation: Orientation;
isPortrait: boolean;
isLandscape: boolean;
}
/**
* 获取屏幕方向
*/
function getOrientation(width: number, height: number): Orientation {
return width > height ? 'landscape' : 'portrait';
}
/**
* 屏幕方向检测 Hook
*/
export function useOrientation(): OrientationInfo {
const { width, height } = useWindowDimensions();
const orientation = useMemo(() => getOrientation(width, height), [width, height]);
return useMemo(() => ({
orientation,
isPortrait: orientation === 'portrait',
isLandscape: orientation === 'landscape',
}), [orientation]);
}

View File

@@ -0,0 +1,31 @@
/**
* 平台检测 Hook
*/
import { useMemo } from 'react';
import { Platform } from 'react-native';
export interface PlatformInfo {
OS: 'ios' | 'android' | 'windows' | 'macos' | 'web';
isWeb: boolean;
isIOS: boolean;
isAndroid: boolean;
isNative: boolean;
}
/**
* 平台检测 Hook
* 提供便捷的平台检测方法
*
* @example
* const { isWeb, isIOS, isAndroid, isNative } = usePlatform();
*/
export function usePlatform(): PlatformInfo {
return useMemo(() => ({
OS: Platform.OS,
isWeb: Platform.OS === 'web',
isIOS: Platform.OS === 'ios',
isAndroid: Platform.OS === 'android',
isNative: Platform.OS !== 'web',
}), []);
}

View File

@@ -0,0 +1,103 @@
/**
* 响应式设计 Hook重构版
* 提供屏幕尺寸、断点、方向、平台检测等响应式信息
*
* 注意:此 Hook 现在作为各个专注 Hook 的组合,保持向后兼容
* 新项目建议直接使用专注的 Hooks
*/
import { useMemo } from 'react';
import { useWindowDimensions } from './useWindowDimensions';
import { useBreakpoint } from './useBreakpoint';
import { useOrientation } from './useOrientation';
import { usePlatform } from './usePlatform';
// ==================== 返回值类型 ====================
export interface ResponsiveInfo {
// 基础尺寸
width: number;
height: number;
// 基础断点
breakpoint: import('./constants').BreakpointKey;
isMobile: boolean;
isTablet: boolean;
isDesktop: boolean;
isWide: boolean;
isWideScreen: boolean;
// 细粒度断点
fineBreakpoint: import('./constants').FineBreakpointKey;
isXS: boolean;
isSM: boolean;
isMD: boolean;
isLG: boolean;
isXL: boolean;
is2XL: boolean;
is3XL: boolean;
is4XL: boolean;
// 方向
orientation: 'portrait' | 'landscape';
isPortrait: boolean;
isLandscape: boolean;
// 平台检测
platform: {
OS: 'ios' | 'android' | 'windows' | 'macos' | 'web';
isWeb: boolean;
isIOS: boolean;
isAndroid: boolean;
isNative: boolean;
};
}
/**
* 响应式设计 Hook
* 提供屏幕尺寸、断点、方向、平台检测等响应式信息
*
* @returns ResponsiveInfo 响应式信息对象
*
* @example
* const {
* width, height,
* breakpoint, isMobile, isTablet, isDesktop, isWide,
* fineBreakpoint, isXS, isSM, isMD, isLG, isXL,
* orientation, isPortrait, isLandscape,
* platform: { isWeb, isIOS, isAndroid }
* } = useResponsive();
*
* @deprecated 建议使用专注的 HooksuseBreakpoint, useOrientation, usePlatform, useScreenSize
*/
export function useResponsive(): ResponsiveInfo {
const { width, height } = useWindowDimensions();
const breakpointInfo = useBreakpoint();
const orientationInfo = useOrientation();
const platformInfo = usePlatform();
return useMemo(() => ({
width,
height,
breakpoint: breakpointInfo.breakpoint,
isMobile: breakpointInfo.isMobile,
isTablet: breakpointInfo.isTablet,
isDesktop: breakpointInfo.isDesktop,
isWide: breakpointInfo.isWide,
isWideScreen: breakpointInfo.isWideScreen,
fineBreakpoint: breakpointInfo.fineBreakpoint,
isXS: breakpointInfo.isXS,
isSM: breakpointInfo.isSM,
isMD: breakpointInfo.isMD,
isLG: breakpointInfo.isLG,
isXL: breakpointInfo.isXL,
is2XL: breakpointInfo.is2XL,
is3XL: breakpointInfo.is3XL,
is4XL: breakpointInfo.is4XL,
orientation: orientationInfo.orientation,
isPortrait: orientationInfo.isPortrait,
isLandscape: orientationInfo.isLandscape,
platform: platformInfo,
}), [width, height, breakpointInfo, orientationInfo, platformInfo]);
}
export default useResponsive;

View File

@@ -0,0 +1,48 @@
/**
* 响应式间距 Hook
*/
import { useMemo } from 'react';
import { useBreakpoint } from './useBreakpoint';
import { FineBreakpointKey } from './constants';
const DEFAULT_SPACING_CONFIG: Record<FineBreakpointKey, number> = {
xs: 8,
sm: 8,
md: 12,
lg: 16,
xl: 20,
'2xl': 24,
'3xl': 32,
'4xl': 40,
};
/**
* 响应式间距 Hook
*
* @param spacingConfig - 间距配置
* @returns 当前断点对应的间距值
*
* @example
* const gap = useResponsiveSpacing({ xs: 8, md: 16, lg: 24 });
*/
export function useResponsiveSpacing(
spacingConfig: Partial<Record<FineBreakpointKey, number>> = {}
): number {
const { fineBreakpoint } = useBreakpoint();
return useMemo(() => {
const config = { ...DEFAULT_SPACING_CONFIG, ...spacingConfig };
const breakpointOrder: FineBreakpointKey[] = ['4xl', '3xl', '2xl', 'xl', 'lg', 'md', 'sm', 'xs'];
const currentIndex = breakpointOrder.indexOf(fineBreakpoint);
for (let i = currentIndex; i < breakpointOrder.length; i++) {
const bp = breakpointOrder[i];
if (config[bp] !== undefined) {
return config[bp];
}
}
return DEFAULT_SPACING_CONFIG.xs;
}, [fineBreakpoint, spacingConfig]);
}

View File

@@ -0,0 +1,53 @@
/**
* 响应式样式生成器 Hook
*/
import { useMemo } from 'react';
import { useBreakpoint } from './useBreakpoint';
import { ResponsiveValue, FineBreakpointKey, REVERSE_BREAKPOINT_ORDER } from './constants';
/**
* 根据断点生成响应式样式
*
* @param styles - 响应式样式对象
* @returns 当前断点对应的样式
*
* @example
* const containerStyle = useResponsiveStyle({
* padding: { xs: 8, md: 16, lg: 24 },
* fontSize: { xs: 14, lg: 16 }
* });
*/
export function useResponsiveStyle<T extends Record<string, ResponsiveValue<unknown>>>(
styles: T
): { [K in keyof T]: T[K] extends ResponsiveValue<infer V> ? V : never } {
const { fineBreakpoint } = useBreakpoint();
return useMemo(() => {
const result = {} as { [K in keyof T]: T[K] extends ResponsiveValue<infer V> ? V : never };
for (const key in styles) {
const value = styles[key];
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
(result as Record<string, unknown>)[key] = value;
} else {
const valueMap = value as Partial<Record<FineBreakpointKey, unknown>>;
const currentIndex = REVERSE_BREAKPOINT_ORDER.indexOf(fineBreakpoint);
let selectedValue: unknown = undefined;
for (let i = currentIndex; i < REVERSE_BREAKPOINT_ORDER.length; i++) {
const bp = REVERSE_BREAKPOINT_ORDER[i];
if (bp in valueMap) {
selectedValue = valueMap[bp];
break;
}
}
(result as Record<string, unknown>)[key] = selectedValue ?? valueMap.xs ?? Object.values(valueMap)[0];
}
}
return result;
}, [styles, fineBreakpoint]);
}

View File

@@ -0,0 +1,42 @@
/**
* 响应式值选择器 Hook
*/
import { useMemo } from 'react';
import { useBreakpoint } from './useBreakpoint';
import { ResponsiveValue, FineBreakpointKey, REVERSE_BREAKPOINT_ORDER } from './constants';
/**
* 根据当前断点从响应式值对象中选择合适的值
*
* @param value - 响应式值,可以是单一值或断点映射对象
* @returns 选中的值
*
* @example
* const padding = useResponsiveValue({ xs: 8, md: 16, lg: 24 });
* // 在 xs 屏幕返回 8md 屏幕返回 16lg 及以上返回 24
*/
export function useResponsiveValue<T>(value: ResponsiveValue<T>): T {
const { fineBreakpoint } = useBreakpoint();
return useMemo(() => {
// 如果不是对象,直接返回
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return value as T;
}
const valueMap = value as Partial<Record<FineBreakpointKey, T>>;
// 从当前断点开始向下查找
const currentIndex = REVERSE_BREAKPOINT_ORDER.indexOf(fineBreakpoint);
for (let i = currentIndex; i < REVERSE_BREAKPOINT_ORDER.length; i++) {
const bp = REVERSE_BREAKPOINT_ORDER[i];
if (bp in valueMap) {
return valueMap[bp]!;
}
}
// 如果没找到,返回 xs 的值或第一个值
return (valueMap.xs ?? Object.values(valueMap)[0]) as T;
}, [value, fineBreakpoint]);
}

View File

@@ -0,0 +1,34 @@
/**
* 屏幕尺寸 Hook
*/
import { useMemo } from 'react';
import { useWindowDimensions } from './useWindowDimensions';
import { useBreakpoint } from './useBreakpoint';
export interface ScreenSizeInfo {
width: number;
height: number;
isMobile: boolean;
isTablet: boolean;
isDesktop: boolean;
isWide: boolean;
}
/**
* 屏幕尺寸 Hook
* 返回屏幕尺寸和断点信息
*/
export function useScreenSize(): ScreenSizeInfo {
const { width, height } = useWindowDimensions();
const { isMobile, isTablet, isDesktop, isWide } = useBreakpoint();
return useMemo(() => ({
width,
height,
isMobile,
isTablet,
isDesktop,
isWide,
}), [width, height, isMobile, isTablet, isDesktop, isWide]);
}

View File

@@ -0,0 +1,27 @@
/**
* 窗口尺寸 Hook
* 提供屏幕尺寸监听
*/
import { useState, useEffect } from 'react';
import { Dimensions, ScaledSize } from 'react-native';
/**
* 获取窗口尺寸,支持屏幕旋转响应
* 替代 Dimensions.get('window'),提供实时尺寸更新
*/
export function useWindowDimensions(): ScaledSize {
const [dimensions, setDimensions] = useState(() => Dimensions.get('window'));
useEffect(() => {
const subscription = Dimensions.addEventListener('change', ({ window }) => {
setDimensions(window);
});
return () => {
subscription.remove();
};
}, []);
return dimensions;
}

View File

@@ -0,0 +1,147 @@
/**
* 连接状态 Hook
*
* 提供连接状态的 React Hook 接口
* 订阅 ConnectionStateManager 状态变化
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import {
ConnectionState,
ConnectionStateInfo,
connectionStateManager,
} from '../infrastructure/sync';
/**
* useConnectionState Hook 返回值接口
*/
export interface UseConnectionStateResult {
/** 当前连接状态 */
state: ConnectionState;
/** 完整的状态信息 */
stateInfo: ConnectionStateInfo;
/** 是否已连接 */
isConnected: boolean;
/** 是否正在连接(包括 CONNECTING 和 RECONNECTING */
isConnecting: boolean;
/** 是否正在重连 */
isReconnecting: boolean;
/** 是否已断开 */
isDisconnected: boolean;
/** 是否处于错误状态 */
hasError: boolean;
/** 当前错误信息 */
error: Error | null;
/** 当前重连次数 */
reconnectAttempts: number;
/** 最大重连次数 */
maxReconnectAttempts: number;
/** 最后连接时间 */
lastConnectedAt: number | null;
/** 状态描述文本 */
description: string;
/** 手动触发重连 */
triggerReconnect: () => void;
/** 重置重连次数 */
resetReconnectAttempts: () => void;
}
/**
* 连接状态 Hook
*
* 订阅 ConnectionStateManager 的状态变化,
* 提供便捷的状态属性和方法
*
* @example
* ```tsx
* function ConnectionStatus() {
* const { state, isConnected, isConnecting, description } = useConnectionState();
*
* return (
* <View>
* <Text>{description}</Text>
* {isConnecting && <ActivityIndicator />}
* </View>
* );
* }
* ```
*/
export function useConnectionState(): UseConnectionStateResult {
// 状态
const [stateInfo, setStateInfo] = useState<ConnectionStateInfo>(() =>
connectionStateManager.getState()
);
// 订阅状态变化
useEffect(() => {
const unsubscribe = connectionStateManager.subscribe((newState) => {
setStateInfo(newState);
});
return unsubscribe;
}, []);
// 计算派生状态
const isConnected = useMemo(() =>
stateInfo.state === ConnectionState.CONNECTED,
[stateInfo.state]
);
const isConnecting = useMemo(() =>
stateInfo.state === ConnectionState.CONNECTING ||
stateInfo.state === ConnectionState.RECONNECTING,
[stateInfo.state]
);
const isReconnecting = useMemo(() =>
stateInfo.state === ConnectionState.RECONNECTING,
[stateInfo.state]
);
const isDisconnected = useMemo(() =>
stateInfo.state === ConnectionState.DISCONNECTED ||
stateInfo.state === ConnectionState.IDLE,
[stateInfo.state]
);
const hasError = useMemo(() =>
stateInfo.state === ConnectionState.ERROR,
[stateInfo.state]
);
// 手动触发重连
const triggerReconnect = useCallback(() => {
// 只有在断开或错误状态才能触发重连
if (
stateInfo.state === ConnectionState.DISCONNECTED ||
stateInfo.state === ConnectionState.ERROR ||
stateInfo.state === ConnectionState.IDLE
) {
connectionStateManager.setState(ConnectionState.RECONNECTING);
}
}, [stateInfo.state]);
// 重置重连次数
const resetReconnectAttempts = useCallback(() => {
connectionStateManager.resetReconnectAttempts();
}, []);
return {
state: stateInfo.state,
stateInfo,
isConnected,
isConnecting,
isReconnecting,
isDisconnected,
hasError,
error: stateInfo.error || null,
reconnectAttempts: stateInfo.reconnectAttempts || 0,
maxReconnectAttempts: connectionStateManager.getMaxReconnectAttempts(),
lastConnectedAt: stateInfo.lastConnectedAt || null,
description: stateInfo.description,
triggerReconnect,
resetReconnectAttempts,
};
}
export default useConnectionState;

View File

@@ -0,0 +1,369 @@
/**
* 差异更新 Hook
* 接收原始消息列表,返回优化后的消息列表,自动处理批量更新
*/
import { useState, useRef, useCallback, useEffect } from 'react';
import {
MessageIdentifier,
MessageUpdate,
MessageUpdateType,
DiffConfig,
} from '../infrastructure/diff/types';
import {
MessageDiffCalculator,
createMessageDiffCalculator,
} from '../infrastructure/diff/MessageDiffCalculator';
import {
MessageUpdateBatcher,
createMessageUpdateBatcher,
BatcherOptions,
} from '../infrastructure/diff/MessageUpdateBatcher';
/**
* 差异更新 Hook 配置选项
*/
export interface UseDifferentialMessagesOptions<T extends MessageIdentifier> {
/** 差异计算配置 */
diffConfig?: DiffConfig;
/** 批量处理配置 */
batcherOptions?: BatcherOptions;
/** 是否启用差异计算,默认 true */
enableDiff?: boolean;
/** 是否启用批量处理,默认 true */
enableBatching?: boolean;
/** 最大消息数量限制,超出时触发重置 */
maxMessageCount?: number;
/** 变化比例阈值0-1超过此值触发重置 */
changeRatioThreshold?: number;
}
/**
* 差异更新 Hook 返回值
*/
export interface UseDifferentialMessagesResult<T extends MessageIdentifier> {
/** 优化后的消息列表 */
optimizedMessages: T[];
/** 待处理更新数量 */
pendingUpdateCount: number;
/** 是否正在处理更新 */
isProcessing: boolean;
/** 手动刷新方法 */
flush: () => void;
/** 重置方法 */
reset: () => void;
/** 强制刷新(跳过批量处理) */
forceUpdate: (messages: T[]) => void;
/** 获取差异统计信息 */
getDiffStats: () => {
totalBatches: number;
totalUpdates: number;
averageBatchSize: number;
};
}
/**
* 差异更新 Hook
* @param messages 原始消息列表
* @param options 配置选项
* @returns 优化后的消息列表和相关方法
*/
export function useDifferentialMessages<T extends MessageIdentifier>(
messages: T[],
options: UseDifferentialMessagesOptions<T> = {}
): UseDifferentialMessagesResult<T> {
const {
diffConfig,
batcherOptions,
enableDiff = true,
enableBatching = true,
maxMessageCount = 10000,
changeRatioThreshold = 0.5,
} = options;
// 状态
const [optimizedMessages, setOptimizedMessages] = useState<T[]>(messages);
const [isProcessing, setIsProcessing] = useState(false);
const [pendingUpdateCount, setPendingUpdateCount] = useState(0);
// Refs
const calculatorRef = useRef<MessageDiffCalculator<T> | null>(null);
const batcherRef = useRef<MessageUpdateBatcher | null>(null);
const previousMessagesRef = useRef<T[]>([]);
// 初始化差异计算器
useEffect(() => {
if (enableDiff) {
calculatorRef.current = createMessageDiffCalculator<T>(diffConfig);
}
return () => {
calculatorRef.current = null;
};
}, [enableDiff, diffConfig]);
// 初始化批量处理器
useEffect(() => {
if (enableBatching) {
batcherRef.current = createMessageUpdateBatcher(batcherOptions);
// 订阅批量更新
const unsubscribe = batcherRef.current.subscribe((updates) => {
handleBatchUpdates(updates);
});
return () => {
unsubscribe();
batcherRef.current?.destroy();
batcherRef.current = null;
};
}
}, [enableBatching, batcherOptions]);
// 处理批量更新
const handleBatchUpdates = useCallback((updates: MessageUpdate[]) => {
setIsProcessing(true);
try {
setOptimizedMessages((currentMessages) => {
let newMessages = [...currentMessages];
for (const update of updates) {
switch (update.type) {
case MessageUpdateType.ADD: {
const addUpdate = update as any;
const index = addUpdate.index ?? newMessages.length;
newMessages.splice(index, 0, addUpdate.message);
break;
}
case MessageUpdateType.BATCH_ADD: {
const batchAddUpdate = update as any;
const messages = batchAddUpdate.messages || [];
const startIndex = batchAddUpdate.startIndex ?? newMessages.length;
newMessages.splice(startIndex, 0, ...messages);
break;
}
case MessageUpdateType.UPDATE: {
const updateMsg = update as any;
const index = newMessages.findIndex(m => m.id === updateMsg.messageId);
if (index !== -1) {
newMessages[index] = { ...newMessages[index], ...updateMsg.updates };
}
break;
}
case MessageUpdateType.BATCH_UPDATE: {
const batchUpdate = update as any;
const updates = batchUpdate.updates || [];
for (const { messageId, changes } of updates) {
const index = newMessages.findIndex(m => m.id === messageId);
if (index !== -1) {
newMessages[index] = { ...newMessages[index], ...changes };
}
}
break;
}
case MessageUpdateType.DELETE: {
const deleteUpdate = update as any;
newMessages = newMessages.filter(m => m.id !== deleteUpdate.messageId);
break;
}
case MessageUpdateType.BATCH_DELETE: {
const batchDelete = update as any;
const idsToDelete = new Set(batchDelete.messageIds || []);
newMessages = newMessages.filter(m => !idsToDelete.has(m.id));
break;
}
case MessageUpdateType.MOVE: {
const moveUpdate = update as any;
const fromIndex = newMessages.findIndex(m => m.id === moveUpdate.messageId);
if (fromIndex !== -1) {
const [movedMessage] = newMessages.splice(fromIndex, 1);
newMessages.splice(moveUpdate.toIndex, 0, movedMessage);
}
break;
}
case MessageUpdateType.RESET: {
const resetUpdate = update as any;
newMessages = resetUpdate.messages || [];
break;
}
}
}
return newMessages;
});
} finally {
setIsProcessing(false);
setPendingUpdateCount(batcherRef.current?.getPendingCount() ?? 0);
}
}, []);
// 监听外部消息变化
useEffect(() => {
// 如果消息数量变化太大,直接重置
const previousCount = previousMessagesRef.current.length;
const currentCount = messages.length;
const changeRatio = previousCount === 0 ? 1 : Math.abs(currentCount - previousCount) / previousCount;
if (changeRatio > changeRatioThreshold) {
setOptimizedMessages(messages);
previousMessagesRef.current = messages;
return;
}
// 使用差异计算
if (enableDiff && calculatorRef.current) {
const diff = calculatorRef.current.calculateDiff(previousMessagesRef.current, messages);
if (diff.shouldReset) {
setOptimizedMessages(messages);
} else {
// 将差异转换为更新操作
const updates: MessageUpdate[] = [];
// 处理新增
if (diff.added.length > 0) {
if (diff.added.length === 1) {
updates.push({
type: MessageUpdateType.ADD,
timestamp: Date.now(),
message: diff.added[0],
} as any);
} else {
updates.push({
type: MessageUpdateType.BATCH_ADD,
timestamp: Date.now(),
messages: diff.added,
} as any);
}
}
// 处理更新
if (diff.updated.length > 0) {
if (diff.updated.length === 1) {
updates.push({
type: MessageUpdateType.UPDATE,
timestamp: Date.now(),
messageId: diff.updated[0].message.id,
updates: diff.updated[0].changes,
} as any);
} else {
updates.push({
type: MessageUpdateType.BATCH_UPDATE,
timestamp: Date.now(),
updates: diff.updated.map(u => ({
messageId: u.message.id,
changes: u.changes,
})),
} as any);
}
}
// 处理删除
if (diff.deleted.length > 0) {
if (diff.deleted.length === 1) {
updates.push({
type: MessageUpdateType.DELETE,
timestamp: Date.now(),
messageId: diff.deleted[0].message.id,
} as any);
} else {
updates.push({
type: MessageUpdateType.BATCH_DELETE,
timestamp: Date.now(),
messageIds: diff.deleted.map(d => d.message.id),
} as any);
}
}
// 处理移动
if (diff.moved.length > 0) {
for (const move of diff.moved) {
updates.push({
type: MessageUpdateType.MOVE,
timestamp: Date.now(),
messageId: move.message.id,
toIndex: move.toIndex,
} as any);
}
}
// 应用更新
if (updates.length > 0) {
if (enableBatching && batcherRef.current) {
batcherRef.current.addUpdates(updates);
} else {
handleBatchUpdates(updates);
}
}
}
} else {
// 不使用差异计算,直接设置
setOptimizedMessages(messages);
}
// 更新消息数量限制检查
if (messages.length > maxMessageCount) {
console.warn(`[useDifferentialMessages] Message count (${messages.length}) exceeds limit (${maxMessageCount})`);
}
previousMessagesRef.current = messages;
}, [messages, enableDiff, enableBatching, maxMessageCount, changeRatioThreshold]);
// 手动刷新方法
const flush = useCallback(() => {
batcherRef.current?.flush();
}, []);
// 重置方法
const reset = useCallback(() => {
calculatorRef.current?.reset();
batcherRef.current?.clearPending();
setOptimizedMessages([]);
previousMessagesRef.current = [];
}, []);
// 强制更新方法
const forceUpdate = useCallback((newMessages: T[]) => {
setOptimizedMessages(newMessages);
}, []);
// 获取统计信息
const getDiffStats = useCallback(() => {
const batcherStats = batcherRef.current?.getStats();
return {
totalBatches: batcherStats?.totalBatches ?? 0,
totalUpdates: batcherStats?.totalUpdates ?? 0,
averageBatchSize: batcherStats?.averageBatchSize ?? 0,
};
}, []);
// 定期更新待处理数量
useEffect(() => {
if (!enableBatching || !batcherRef.current) return;
const interval = setInterval(() => {
setPendingUpdateCount(batcherRef.current?.getPendingCount() ?? 0);
}, 50);
return () => clearInterval(interval);
}, [enableBatching]);
return {
optimizedMessages,
pendingUpdateCount,
isProcessing,
flush,
reset,
forceUpdate,
getDiffStats,
};
}
export default useDifferentialMessages;

241
src/hooks/useMediaCache.ts Normal file
View File

@@ -0,0 +1,241 @@
/**
* 媒体缓存 Hook
* @module src/hooks/useMediaCache
* @description 提供媒体缓存管理的 React Hook
*/
import { useState, useCallback, useEffect, useRef } from 'react';
import { mediaCacheManager } from '../infrastructure/cache';
import {
CacheStats,
CleanupPolicy,
CleanupResult,
MediaType,
CleanupTrigger,
DEFAULT_CLEANUP_POLICY,
} from '../infrastructure/cache/types';
/**
* useMediaCache Hook 返回值类型
*/
export interface UseMediaCacheReturn {
/** 缓存统计信息 */
stats: CacheStats | null;
/** 是否正在清理 */
isClearing: boolean;
/** 是否已初始化 */
isInitialized: boolean;
/** 加载缓存统计 */
loadStats: () => void;
/** 执行清理(手动) */
cleanup: (policy?: Partial<CleanupPolicy>) => Promise<CleanupResult>;
/** 清空所有缓存 */
clearAll: () => Promise<void>;
/** 清理指定会话的缓存 */
clearConversation: (conversationId: string) => Promise<CleanupResult>;
/** 启动定期清理 */
startPeriodicCleanup: () => void;
/** 停止定期清理 */
stopPeriodicCleanup: () => void;
/** 记录缓存访问(用于 LRU */
recordAccess: (key: string) => Promise<void>;
/** 格式化缓存大小 */
formatSize: (bytes: number) => string;
/** 缓存媒体文件 */
cacheMedia: (
uri: string,
type: MediaType,
options?: {
conversationId?: string;
messageId?: string;
size?: number;
}
) => Promise<string>;
/** 获取缓存的媒体路径 */
getCachedMedia: (uri: string, type: MediaType) => Promise<string | null>;
}
/**
* 媒体缓存 Hook
* @description 提供缓存统计、清理功能、访问记录等
* @returns {UseMediaCacheReturn} 缓存管理接口
*
* @example
* ```tsx
* function MediaSettings() {
* const { stats, cleanup, formatSize } = useMediaCache();
*
* return (
* <View>
* <Text>总缓存: {formatSize(stats?.totalSize || 0)}</Text>
* <Text>图片: {stats?.imageCount || 0}</Text>
* <Text>视频: {stats?.videoCount || 0}</Text>
* <Button title="清理缓存" onPress={cleanup} />
* </View>
* );
* }
* ```
*/
export function useMediaCache(): UseMediaCacheReturn {
const [stats, setStats] = useState<CacheStats | null>(null);
const [isClearing, setIsClearing] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const initializedRef = useRef(false);
/**
* 加载缓存统计
*/
const loadStats = useCallback(() => {
const cacheStats = mediaCacheManager.getCacheStats();
setStats(cacheStats);
}, []);
/**
* 执行清理
*/
const cleanup = useCallback(async (policy?: Partial<CleanupPolicy>): Promise<CleanupResult> => {
setIsClearing(true);
try {
// 如果提供了自定义策略,应用它
// 注意:这里简化处理,实际可能需要合并策略
const result = await mediaCacheManager.cleanup(CleanupTrigger.MANUAL);
await loadStats();
return result;
} finally {
setIsClearing(false);
}
}, [loadStats]);
/**
* 清空所有缓存
*/
const clearAll = useCallback(async (): Promise<void> => {
setIsClearing(true);
try {
await mediaCacheManager.clearAll();
await loadStats();
} finally {
setIsClearing(false);
}
}, [loadStats]);
/**
* 清理指定会话的缓存
*/
const clearConversation = useCallback(async (conversationId: string): Promise<CleanupResult> => {
setIsClearing(true);
try {
const result = await mediaCacheManager.clearConversationMedia(conversationId);
await loadStats();
return result;
} finally {
setIsClearing(false);
}
}, [loadStats]);
/**
* 启动定期清理
*/
const startPeriodicCleanup = useCallback(() => {
mediaCacheManager.startPeriodicCleanup();
}, []);
/**
* 停止定期清理
*/
const stopPeriodicCleanup = useCallback(() => {
mediaCacheManager.stopPeriodicCleanup();
}, []);
/**
* 记录缓存访问
*/
const recordAccess = useCallback(async (key: string): Promise<void> => {
await mediaCacheManager.recordAccess(key);
}, []);
/**
* 缓存媒体文件
*/
const cacheMedia = useCallback(async (
uri: string,
type: MediaType,
options?: {
conversationId?: string;
messageId?: string;
size?: number;
}
): Promise<string> => {
const localPath = await mediaCacheManager.cacheMedia(uri, type, options);
await loadStats();
return localPath;
}, [loadStats]);
/**
* 获取缓存的媒体路径
*/
const getCachedMedia = useCallback(async (uri: string, type: MediaType): Promise<string | null> => {
return await mediaCacheManager.getCachedMedia(uri, type);
}, []);
/**
* 格式化缓存大小
*/
const formatSize = useCallback((bytes: number): string => {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
if (bytes < 1024 * 1024 * 1024) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
}, []);
/**
* 初始化
*/
useEffect(() => {
const initialize = async () => {
if (initializedRef.current) return;
initializedRef.current = true;
try {
await mediaCacheManager.initialize();
setIsInitialized(true);
loadStats();
} catch (error) {
console.error('[useMediaCache] 初始化失败:', error);
// 即使初始化失败,也允许使用基本功能
setIsInitialized(true);
loadStats();
}
};
initialize();
// 清理函数
return () => {
// 停止定期清理
mediaCacheManager.stopPeriodicCleanup();
};
}, [loadStats]);
return {
stats,
isClearing,
isInitialized,
loadStats,
cleanup,
clearAll,
clearConversation,
startPeriodicCleanup,
stopPeriodicCleanup,
recordAccess,
formatSize,
cacheMedia,
getCachedMedia,
};
}

321
src/hooks/usePagination.ts Normal file
View File

@@ -0,0 +1,321 @@
/**
* 分页 Hook
*
* 提供 React 组件中使用的分页功能,包括:
* - loadMore: 加载更多数据
* - refresh: 刷新数据
* - prefetch: 预加载数据
* - 自动预加载检测
* - 分页状态管理
*/
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import {
paginationStateManager,
PaginationState,
PaginationConfig,
LoadMoreResult,
RefreshResult,
PrefetchResult,
FetchPageFunction,
DEFAULT_PAGINATION_CONFIG,
} from '../infrastructure/pagination';
/**
* usePagination 选项
*/
export interface UsePaginationOptions<T = any> {
/** 分页键通常是会话ID或列表标识 */
key: string | null;
/** 数据获取函数 */
fetchFunction: FetchPageFunction<T>;
/** 分页配置 */
config?: Partial<PaginationConfig>;
/** 初始数据 */
initialData?: T[];
/** 是否自动加载第一页 */
autoLoad?: boolean;
/** 是否启用自动预加载 */
enableAutoPrefetch?: boolean;
}
/**
* usePagination 返回值
*/
export interface UsePaginationReturn<T = any> {
/** 当前数据列表 */
data: T[];
/** 是否正在加载 */
isLoading: boolean;
/** 是否正在刷新 */
isRefreshing: boolean;
/** 是否有更多数据 */
hasMore: boolean;
/** 是否发生过错误 */
hasError: boolean;
/** 错误信息 */
error?: string;
/** 当前页码 */
currentPage: number;
/** 已加载的总数 */
totalLoaded: number;
/** 加载更多 */
loadMore: () => Promise<LoadMoreResult<T>>;
/** 刷新数据 */
refresh: () => Promise<RefreshResult<T>>;
/** 预加载指定页面 */
prefetch: (page: number) => Promise<PrefetchResult<T>>;
/** 重置分页状态 */
reset: () => void;
/** 手动设置数据 */
setData: (data: T[]) => void;
/** 追加数据 */
appendData: (data: T[]) => void;
}
/**
* 分页 Hook
*
* 示例用法:
* ```typescript
* const {
* data,
* isLoading,
* hasMore,
* loadMore,
* refresh,
* } = usePagination<Message>({
* key: conversationId,
* fetchFunction: async (page, pageSize, cursor) => {
* const result = await messageService.getMessages({
* page,
* pageSize,
* beforeSeq: cursor,
* });
* return {
* data: result.messages,
* hasMore: result.has_more,
* cursor: result.min_seq,
* };
* },
* config: { pageSize: 20 },
* });
* ```
*/
export function usePagination<T = any>(
options: UsePaginationOptions<T>
): UsePaginationReturn<T> {
const {
key,
fetchFunction,
config: userConfig,
initialData = [],
autoLoad = false,
enableAutoPrefetch = true,
} = options;
// 合并配置
const config = useMemo(() => ({
...DEFAULT_PAGINATION_CONFIG,
...userConfig,
}), [userConfig]);
// 内部数据状态
const [data, setData] = useState<T[]>(initialData);
const [isRefreshing, setIsRefreshing] = useState(false);
const [lastError, setLastError] = useState<string | undefined>(undefined);
// 用于追踪是否已自动加载
const autoLoadRef = useRef(false);
// 用于追踪组件是否已卸载
const isMountedRef = useRef(true);
// 获取当前分页状态
const paginationState = useMemo(() => {
if (!key) return null;
return paginationStateManager.getState(key, config);
}, [key, config]);
// 计算派生状态
const isLoading = paginationState?.isLoading ?? false;
const hasMore = paginationState?.hasMore ?? true;
const currentPage = paginationState?.currentPage ?? 0;
const totalLoaded = paginationState?.totalLoaded ?? 0;
const hasError = paginationState?.hasError ?? false;
const error = paginationState?.error ?? lastError;
// 当 key 变化时重置数据
useEffect(() => {
if (key) {
setData(initialData);
autoLoadRef.current = false;
}
}, [key]);
// 组件卸载时清理
useEffect(() => {
return () => {
isMountedRef.current = false;
};
}, []);
// 自动加载第一页
useEffect(() => {
if (!key || !autoLoad || autoLoadRef.current) return;
if (data.length > 0) return;
autoLoadRef.current = true;
refresh();
}, [key, autoLoad, data.length]);
// 自动预加载检测
useEffect(() => {
if (!key || !enableAutoPrefetch) return;
if (!paginationStateManager.shouldPrefetch(key, data.length)) return;
// 触发预加载
const prefetchNextPage = async () => {
const nextPage = currentPage + 1;
await paginationStateManager.prefetch(key, nextPage, fetchFunction);
};
prefetchNextPage();
}, [key, data.length, currentPage, enableAutoPrefetch, fetchFunction]);
/**
* 加载更多数据
*/
const loadMore = useCallback(async (): Promise<LoadMoreResult<T>> => {
if (!key) {
return {
success: false,
data: [],
hasMore: false,
fromCache: false,
error: 'No pagination key provided',
};
}
const result = await paginationStateManager.loadMore<T>(key, fetchFunction);
if (isMountedRef.current) {
if (result.success) {
setData(prev => [...prev, ...result.data]);
setLastError(undefined);
} else if (result.error) {
setLastError(result.error);
}
}
return result;
}, [key, fetchFunction]);
/**
* 刷新数据
*/
const refresh = useCallback(async (): Promise<RefreshResult<T>> => {
if (!key) {
return {
success: false,
data: [],
hasMore: false,
error: 'No pagination key provided',
};
}
setIsRefreshing(true);
try {
const result = await paginationStateManager.refresh<T>(key, fetchFunction);
if (isMountedRef.current) {
if (result.success) {
setData(result.data);
setLastError(undefined);
} else if (result.error) {
setLastError(result.error);
}
}
return result;
} finally {
if (isMountedRef.current) {
setIsRefreshing(false);
}
}
}, [key, fetchFunction]);
/**
* 预加载指定页面
*/
const prefetch = useCallback(async (page: number): Promise<PrefetchResult<T>> => {
if (!key) {
return {
success: false,
error: 'No pagination key provided',
};
}
return paginationStateManager.prefetch<T>(key, page, fetchFunction);
}, [key, fetchFunction]);
/**
* 重置分页状态
*/
const reset = useCallback(() => {
if (!key) return;
paginationStateManager.reset(key, config);
if (isMountedRef.current) {
setData([]);
setLastError(undefined);
autoLoadRef.current = false;
}
}, [key, config]);
/**
* 手动设置数据
*/
const setDataManual = useCallback((newData: T[]) => {
if (isMountedRef.current) {
setData(newData);
}
}, []);
/**
* 追加数据
*/
const appendData = useCallback((newData: T[]) => {
if (isMountedRef.current) {
setData(prev => [...prev, ...newData]);
}
}, []);
return {
data,
isLoading,
isRefreshing,
hasMore,
hasError,
error,
currentPage,
totalLoaded,
loadMore,
refresh,
prefetch,
reset,
setData: setDataManual,
appendData,
};
}
/**
* 使用分页状态管理器的单例实例
* 用于在组件外访问分页状态
*/
export function usePaginationManager() {
return paginationStateManager;
}
export default usePagination;

View File

@@ -0,0 +1,726 @@
/**
* 媒体缓存管理器
* @module src/infrastructure/cache/MediaCacheManager
* @description 管理图片、视频、音频缓存,支持多种清理策略
*/
import { File, Paths } from 'expo-file-system';
import AsyncStorage from '@react-native-async-storage/async-storage';
import {
MediaType,
CleanupTrigger,
CacheEntry,
CacheStats,
CleanupPolicy,
CleanupResult,
MediaCacheConfig,
DEFAULT_MEDIA_CACHE_CONFIG,
DEFAULT_CLEANUP_POLICY,
} from './types';
/**
* 缓存记录存储键前缀
*/
const CACHE_RECORD_PREFIX = 'media_cache_record_';
/**
* 获取缓存目录路径
*/
function getCacheDir(): string {
const cachePath = Paths.cache;
return `${cachePath}media_cache/`;
}
/**
* 获取图片缓存目录
*/
function getImageDir(): string {
return `${getCacheDir()}images/`;
}
/**
* 获取视频缓存目录
*/
function getVideoDir(): string {
return `${getCacheDir()}videos/`;
}
/**
* 获取音频缓存目录
*/
function getAudioDir(): string {
return `${getCacheDir()}audios/`;
}
/**
* 媒体缓存管理器类
* @class MediaCacheManager
* @description 单例模式,管理所有媒体文件的缓存
*/
export class MediaCacheManager {
private static instance: MediaCacheManager;
private cache: Map<string, CacheEntry> = new Map();
private config: MediaCacheConfig;
private policy: CleanupPolicy;
private cleanupTimer: ReturnType<typeof setTimeout> | null = null;
private lastCleanupTime: number = 0;
private isInitialized: boolean = false;
/**
* 获取单例实例
*/
public static getInstance(): MediaCacheManager {
if (!MediaCacheManager.instance) {
MediaCacheManager.instance = new MediaCacheManager();
}
return MediaCacheManager.instance;
}
/**
* 私有构造函数
*/
private constructor(
config?: Partial<MediaCacheConfig>,
policy?: Partial<CleanupPolicy>
) {
this.config = { ...DEFAULT_MEDIA_CACHE_CONFIG, ...config };
this.policy = { ...DEFAULT_CLEANUP_POLICY, ...policy };
}
/**
* 初始化缓存管理器
* 确保缓存目录存在并加载缓存记录
*/
public async initialize(): Promise<void> {
if (this.isInitialized) return;
try {
// 确保缓存目录存在
await this.ensureDirectories();
// 加载缓存记录
await this.loadCacheRecords();
// 启动时清理
if (this.policy.cleanupOnStart) {
await this.cleanup(CleanupTrigger.STARTUP);
}
// 启动定期清理
this.startPeriodicCleanup();
this.isInitialized = true;
} catch (error) {
console.error('[MediaCacheManager] 初始化失败:', error);
throw error;
}
}
/**
* 确保缓存目录存在
*/
private async ensureDirectories(): Promise<void> {
try {
const dirs = [getImageDir(), getVideoDir(), getAudioDir()];
for (const dir of dirs) {
const dirFile = new File(dir);
if (!await dirFile.exists) {
await dirFile.create();
}
}
} catch (error) {
console.error('[MediaCacheManager] 创建缓存目录失败:', error);
}
}
/**
* 从持久化存储加载缓存记录
*/
private async loadCacheRecords(): Promise<void> {
try {
const keys = await AsyncStorage.getAllKeys();
const recordKeys = keys.filter(k => k.startsWith(CACHE_RECORD_PREFIX));
if (recordKeys.length === 0) return;
const records = await AsyncStorage.multiGet(recordKeys);
for (const [key, value] of records) {
if (value) {
const entry: CacheEntry = JSON.parse(value);
// 验证文件是否存在
const exists = await this.checkFileExists(entry.localPath);
if (exists) {
this.cache.set(entry.key, entry);
} else {
// 文件不存在,删除记录
await AsyncStorage.removeItem(key);
}
}
}
} catch (error) {
console.error('[MediaCacheManager] 加载缓存记录失败:', error);
}
}
/**
* 检查文件是否存在
*/
private async checkFileExists(path: string): Promise<boolean> {
try {
const file = new File(path);
return await file.exists;
} catch {
return false;
}
}
/**
* 记录缓存访问用于LRU
*/
public async recordAccess(key: string): Promise<void> {
const entry = this.cache.get(key);
if (entry) {
entry.lastAccessedAt = Date.now();
await this.saveCacheRecord(entry);
}
}
/**
* 缓存媒体文件
*/
public async cacheMedia(
uri: string,
type: MediaType,
options: {
conversationId?: string;
messageId?: string;
size?: number;
} = {}
): Promise<string> {
const { conversationId, messageId, size = 0 } = options;
// 生成唯一键
const key = this.generateCacheKey(uri, type);
const localPath = this.getMediaPath(type, key);
// 检查是否已缓存
const existingEntry = this.cache.get(key);
if (existingEntry) {
await this.recordAccess(key);
return existingEntry.localPath;
}
try {
// 下载文件
const destination = new File(localPath);
const downloaded = await File.downloadFileAsync(uri, destination);
// 获取实际文件大小
let fileSize = size;
// fileSize 为 0 时使用默认大小,实际应用中可以通过其他方式获取
// 创建缓存条目
const entry: CacheEntry = {
key,
type,
uri,
localPath: downloaded.uri,
size: fileSize,
conversationId,
messageId,
createdAt: Date.now(),
lastAccessedAt: Date.now(),
};
// 保存记录
this.cache.set(key, entry);
await this.saveCacheRecord(entry);
// 检查是否需要清理
await this.checkAndCleanup();
return downloaded.uri;
} catch (error) {
console.error('[MediaCacheManager] 缓存媒体失败:', error);
throw error;
}
}
/**
* 获取缓存的媒体路径
*/
public async getCachedMedia(uri: string, type: MediaType): Promise<string | null> {
const key = this.generateCacheKey(uri, type);
const entry = this.cache.get(key);
if (entry) {
const exists = await this.checkFileExists(entry.localPath);
if (exists) {
await this.recordAccess(key);
return entry.localPath;
} else {
// 文件不存在,删除记录
this.cache.delete(key);
await this.removeCacheRecord(key);
}
}
return null;
}
/**
* 获取缓存统计信息
*/
public getCacheStats(): CacheStats {
const stats: CacheStats = {
totalSize: 0,
imageCount: 0,
videoCount: 0,
audioCount: 0,
totalEntries: this.cache.size,
oldestItem: Date.now(),
newestItem: 0,
};
for (const entry of this.cache.values()) {
stats.totalSize += entry.size;
stats.oldestItem = Math.min(stats.oldestItem, entry.createdAt);
stats.newestItem = Math.max(stats.newestItem, entry.createdAt);
switch (entry.type) {
case MediaType.IMAGE:
stats.imageCount++;
break;
case MediaType.VIDEO:
stats.videoCount++;
break;
case MediaType.AUDIO:
stats.audioCount++;
break;
}
}
return stats;
}
/**
* 清理操作入口
*/
public async cleanup(trigger: CleanupTrigger): Promise<CleanupResult> {
const errors: string[] = [];
let deletedCount = 0;
let freedSize = 0;
const deletedItems: string[] = [];
try {
// 1. 清理过期缓存
const expiredResult = await this.cleanupExpired();
deletedCount += expiredResult.deletedCount;
freedSize += expiredResult.freedSize;
deletedItems.push(...expiredResult.deletedItems || []);
errors.push(...expiredResult.errors);
// 2. LRU清理如果启用
if (this.policy.enableLRU) {
const lruResult = await this.cleanupLRU();
deletedCount += lruResult.deletedCount;
freedSize += lruResult.freedSize;
deletedItems.push(...lruResult.deletedItems || []);
errors.push(...lruResult.errors);
}
// 3. 阈值清理
const thresholdResult = await this.cleanupByThreshold();
deletedCount += thresholdResult.deletedCount;
freedSize += thresholdResult.freedSize;
deletedItems.push(...thresholdResult.deletedItems || []);
errors.push(...thresholdResult.errors);
this.lastCleanupTime = Date.now();
} catch (error) {
errors.push(`清理失败: ${error instanceof Error ? error.message : '未知错误'}`);
}
return {
trigger,
timestamp: Date.now(),
deletedCount,
freedSize,
errors,
deletedItems,
};
}
/**
* 清理过期缓存
*/
public async cleanupExpired(): Promise<CleanupResult> {
const now = Date.now();
const maxAge = this.policy.maxAge;
const errors: string[] = [];
let deletedCount = 0;
let freedSize = 0;
const deletedItems: string[] = [];
const entriesToDelete: string[] = [];
for (const [key, entry] of this.cache.entries()) {
if (now - entry.lastAccessedAt > maxAge) {
entriesToDelete.push(key);
}
}
for (const key of entriesToDelete) {
try {
const entry = this.cache.get(key);
if (entry) {
const file = new File(entry.localPath);
if (await file.exists) {
await file.delete();
}
this.cache.delete(key);
await this.removeCacheRecord(key);
deletedCount++;
freedSize += entry.size;
deletedItems.push(key);
}
} catch (error) {
errors.push(`删除 ${key} 失败: ${error instanceof Error ? error.message : '未知错误'}`);
}
}
return {
trigger: CleanupTrigger.MANUAL,
timestamp: Date.now(),
deletedCount,
freedSize,
errors,
deletedItems,
};
}
/**
* LRU清理 - 删除最近最少使用的缓存
*/
public async cleanupLRU(targetSize?: number): Promise<CleanupResult> {
const errors: string[] = [];
let deletedCount = 0;
let freedSize = 0;
const deletedItems: string[] = [];
// 如果没有指定目标大小,使用最大缓存大小的一半
const target = targetSize || Math.floor(this.policy.maxSize / 2);
const currentStats = this.getCacheStats();
if (currentStats.totalSize <= target) {
return {
trigger: CleanupTrigger.MANUAL,
timestamp: Date.now(),
deletedCount: 0,
freedSize: 0,
errors: [],
deletedItems: [],
};
}
// 按最后访问时间排序
const sortedEntries = Array.from(this.cache.entries())
.sort((a, b) => a[1].lastAccessedAt - b[1].lastAccessedAt);
let currentSize = currentStats.totalSize;
for (const [key, entry] of sortedEntries) {
if (currentSize <= target) break;
try {
const file = new File(entry.localPath);
if (await file.exists) {
await file.delete();
}
this.cache.delete(key);
await this.removeCacheRecord(key);
deletedCount++;
freedSize += entry.size;
deletedItems.push(key);
currentSize -= entry.size;
} catch (error) {
errors.push(`删除 ${key} 失败: ${error instanceof Error ? error.message : '未知错误'}`);
}
}
return {
trigger: CleanupTrigger.MANUAL,
timestamp: Date.now(),
deletedCount,
freedSize,
errors,
deletedItems,
};
}
/**
* 按阈值清理 - 超过最大大小时清理
*/
private async cleanupByThreshold(): Promise<CleanupResult> {
const currentStats = this.getCacheStats();
const errors: string[] = [];
let deletedCount = 0;
let freedSize = 0;
const deletedItems: string[] = [];
// 检查总大小是否超过限制
if (currentStats.totalSize <= this.policy.maxSize) {
// 检查条目数是否超过限制
if (currentStats.totalEntries <= this.policy.maxEntries) {
return {
trigger: CleanupTrigger.MANUAL,
timestamp: Date.now(),
deletedCount: 0,
freedSize: 0,
errors: [],
deletedItems: [],
};
}
}
// 需要清理
const targetSize = Math.floor(this.policy.maxSize * 0.8); // 清理到80%
const targetEntries = Math.floor(this.policy.maxEntries * 0.8);
// 按LRU排序
const sortedEntries = Array.from(this.cache.entries())
.sort((a, b) => a[1].lastAccessedAt - b[1].lastAccessedAt);
let currentSize = currentStats.totalSize;
let currentEntries = currentStats.totalEntries;
for (const [key, entry] of sortedEntries) {
// 两个条件:大小或条目数
if (currentSize <= targetSize && currentEntries <= targetEntries) break;
try {
const file = new File(entry.localPath);
if (await file.exists) {
await file.delete();
}
this.cache.delete(key);
await this.removeCacheRecord(key);
deletedCount++;
freedSize += entry.size;
deletedItems.push(key);
currentSize -= entry.size;
currentEntries--;
} catch (error) {
errors.push(`删除 ${key} 失败: ${error instanceof Error ? error.message : '未知错误'}`);
}
}
return {
trigger: CleanupTrigger.MANUAL,
timestamp: Date.now(),
deletedCount,
freedSize,
errors,
deletedItems,
};
}
/**
* 删除指定会话的所有媒体缓存
*/
public async clearConversationMedia(conversationId: string): Promise<CleanupResult> {
const errors: string[] = [];
let deletedCount = 0;
let freedSize = 0;
const deletedItems: string[] = [];
const entriesToDelete: string[] = [];
for (const [key, entry] of this.cache.entries()) {
if (entry.conversationId === conversationId) {
entriesToDelete.push(key);
}
}
for (const key of entriesToDelete) {
try {
const entry = this.cache.get(key);
if (entry) {
const file = new File(entry.localPath);
if (await file.exists) {
await file.delete();
}
this.cache.delete(key);
await this.removeCacheRecord(key);
deletedCount++;
freedSize += entry.size;
deletedItems.push(key);
}
} catch (error) {
errors.push(`删除 ${key} 失败: ${error instanceof Error ? error.message : '未知错误'}`);
}
}
return {
trigger: CleanupTrigger.CONVERSATION_DELETED,
timestamp: Date.now(),
deletedCount,
freedSize,
errors,
deletedItems,
};
}
/**
* 清空所有缓存
*/
public async clearAll(): Promise<void> {
for (const entry of this.cache.values()) {
try {
const file = new File(entry.localPath);
if (await file.exists) {
await file.delete();
}
} catch (error) {
console.warn(`[MediaCacheManager] 删除文件失败: ${entry.localPath}`, error);
}
}
this.cache.clear();
// 清除所有记录
const keys = await AsyncStorage.getAllKeys();
const recordKeys = keys.filter(k => k.startsWith(CACHE_RECORD_PREFIX));
if (recordKeys.length > 0) {
await AsyncStorage.multiRemove(recordKeys);
}
}
/**
* 启动定期清理
*/
public startPeriodicCleanup(): void {
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
}
this.cleanupTimer = setInterval(async () => {
// 检查是否需要清理
if (Date.now() - this.lastCleanupTime >= this.policy.cleanupInterval) {
await this.cleanup(CleanupTrigger.SCHEDULED);
}
}, this.policy.cleanupInterval);
}
/**
* 停止定期清理
*/
public stopPeriodicCleanup(): void {
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
this.cleanupTimer = null;
}
}
/**
* 检查是否需要清理
*/
private async checkAndCleanup(): Promise<void> {
const stats = this.getCacheStats();
// 检查大小
if (stats.totalSize > this.policy.maxSize) {
await this.cleanupLRU();
}
// 检查条目数
if (stats.totalEntries > this.policy.maxEntries) {
await this.cleanupLRU();
}
}
/**
* 保存缓存记录到持久化存储
*/
private async saveCacheRecord(entry: CacheEntry): Promise<void> {
try {
const key = `${CACHE_RECORD_PREFIX}${entry.key}`;
await AsyncStorage.setItem(key, JSON.stringify(entry));
} catch (error) {
console.error('[MediaCacheManager] 保存缓存记录失败:', error);
}
}
/**
* 删除缓存记录
*/
private async removeCacheRecord(key: string): Promise<void> {
try {
const storageKey = `${CACHE_RECORD_PREFIX}${key}`;
await AsyncStorage.removeItem(storageKey);
} catch (error) {
console.error('[MediaCacheManager] 删除缓存记录失败:', error);
}
}
/**
* 生成缓存键
*/
private generateCacheKey(uri: string, type: MediaType): string {
const hash = this.hashString(uri);
return `${type.toLowerCase()}_${hash}_${Date.now()}`;
}
/**
* 获取媒体文件存储路径
*/
private getMediaPath(type: MediaType, key: string): string {
const ext = this.getExtension(key, type);
switch (type) {
case MediaType.IMAGE:
return `${getImageDir()}${key}.${ext}`;
case MediaType.VIDEO:
return `${getVideoDir()}${key}.${ext}`;
case MediaType.AUDIO:
return `${getAudioDir()}${key}.${ext}`;
}
}
/**
* 根据类型获取文件扩展名
*/
private getExtension(key: string, type: MediaType): string {
if (type === MediaType.IMAGE) {
if (key.includes('gif')) return 'gif';
if (key.includes('webp')) return 'webp';
return 'jpg';
}
if (type === MediaType.VIDEO) return 'mp4';
if (type === MediaType.AUDIO) return 'mp3';
return 'bin';
}
/**
* 字符串哈希函数
*/
private hashString(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash).toString(16);
}
}
/**
* 导出单例实例
*/
export const mediaCacheManager = MediaCacheManager.getInstance();

21
src/infrastructure/cache/index.ts vendored Normal file
View File

@@ -0,0 +1,21 @@
/**
* 媒体缓存模块导出
* @module src/infrastructure/cache
* @description 导出媒体缓存相关的所有类型和管理器
*/
// 类型导出
export {
MediaType,
CleanupTrigger,
CacheEntry,
CacheStats,
CleanupPolicy,
CleanupResult,
MediaCacheConfig,
DEFAULT_MEDIA_CACHE_CONFIG,
DEFAULT_CLEANUP_POLICY,
} from './types';
// 管理器导出
export { MediaCacheManager, mediaCacheManager } from './MediaCacheManager';

202
src/infrastructure/cache/types.ts vendored Normal file
View File

@@ -0,0 +1,202 @@
/**
* 媒体缓存类型定义
* @module src/infrastructure/cache/types
* @description 定义媒体缓存相关的类型、枚举和接口
*/
/**
* 媒体类型枚举
* @enum {string}
* @description 支持的媒体类型
*/
export enum MediaType {
IMAGE = 'IMAGE',
VIDEO = 'VIDEO',
AUDIO = 'AUDIO',
}
/**
* 清理触发器枚举
* @enum {string}
* @description 触发清理操作的来源
*/
export enum CleanupTrigger {
STARTUP = 'startup',
SCHEDULED = 'scheduled',
MANUAL = 'manual',
LOW_STORAGE = 'low_storage',
CONVERSATION_DELETED = 'conversation_deleted',
}
/**
* 缓存条目接口
* @interface CacheEntry
* @description 单个媒体缓存条目的信息
*/
export interface CacheEntry {
/** 缓存键值 */
key: string;
/** 媒体类型 */
type: MediaType;
/** 原始URI */
uri: string;
/** 本地存储路径 */
localPath: string;
/** 文件大小(字节) */
size: number;
/** 所属会话ID(可选) */
conversationId?: string;
/** 关联消息ID(可选) */
messageId?: string;
/** 创建时间 */
createdAt: number;
/** 最后访问时间 */
lastAccessedAt: number;
}
/**
* 缓存统计接口
* @interface CacheStats
* @description 媒体缓存的整体统计信息
*/
export interface CacheStats {
/** 总缓存大小(字节) */
totalSize: number;
/** 图片缓存数量 */
imageCount: number;
/** 视频缓存数量 */
videoCount: number;
/** 音频缓存数量 */
audioCount: number;
/** 总条目数 */
totalEntries: number;
/** 最早缓存项时间 */
oldestItem: number;
/** 最新缓存项时间 */
newestItem: number;
}
/**
* 清理策略接口
* @interface CleanupPolicy
* @description 定义缓存清理的规则和限制
*/
export interface CleanupPolicy {
/** 最大缓存时间(毫秒) */
maxAge: number;
/** 最大缓存大小(字节) */
maxSize: number;
/** 最大条目数 */
maxEntries: number;
/** 启动时是否清理 */
cleanupOnStart: boolean;
/** 定期清理间隔(毫秒) */
cleanupInterval: number;
/** 是否启用LRU清理 */
enableLRU: boolean;
/** 低存储空间阈值(字节) */
lowStorageThreshold: number;
}
/**
* 清理结果接口
* @interface CleanupResult
* @description 清理操作的执行结果
*/
export interface CleanupResult {
/** 触发器类型 */
trigger: CleanupTrigger;
/** 执行时间戳 */
timestamp: number;
/** 删除条目数量 */
deletedCount: number;
/** 释放空间大小(字节) */
freedSize: number;
/** 错误信息列表 */
errors: string[];
/** 清理的条目详情 */
deletedItems?: string[];
}
/**
* 媒体缓存配置接口
* @interface MediaCacheConfig
* @description 媒体缓存的全局配置
*/
export interface MediaCacheConfig {
/** 图片配置 */
image: {
/** 内存缓存数量 */
maxMemoryCacheSize: number;
/** 磁盘缓存大小(MB) */
maxDiskCacheSize: number;
/** 缓存有效期(小时) */
maxAge: number;
/** 会话内媒体保留时间(天) */
maxAgeForConversation: number;
};
/** 视频配置 */
video: {
/** 磁盘缓存大小(MB) */
maxDiskCacheSize: number;
/** 缓存有效期(小时) */
maxAge: number;
/** 是否自动预加载 */
autoPreload: boolean;
};
/** 音频配置 */
audio: {
/** 磁盘缓存大小(MB) */
maxDiskCacheSize: number;
/** 缓存有效期(天) */
maxAge: number;
};
/** 全局配置 */
global: {
/** 启动时检查清理 */
checkOnStartup: boolean;
/** 定期检查间隔(小时) */
checkInterval: number;
/** 低存储空间阈值(MB) */
lowStorageThreshold: number;
};
}
/**
* 默认媒体缓存配置
*/
export const DEFAULT_MEDIA_CACHE_CONFIG: MediaCacheConfig = {
image: {
maxMemoryCacheSize: 50,
maxDiskCacheSize: 500,
maxAge: 168, // 7 days
maxAgeForConversation: 30,
},
video: {
maxDiskCacheSize: 1000,
maxAge: 72, // 3 days
autoPreload: false,
},
audio: {
maxDiskCacheSize: 200,
maxAge: 168, // 7 days
},
global: {
checkOnStartup: true,
checkInterval: 24,
lowStorageThreshold: 500,
},
};
/**
* 默认清理策略
*/
export const DEFAULT_CLEANUP_POLICY: CleanupPolicy = {
maxAge: 7 * 24 * 60 * 60 * 1000, // 7天
maxSize: 500 * 1024 * 1024, // 500MB
maxEntries: 1000,
cleanupOnStart: true,
cleanupInterval: 24 * 60 * 60 * 1000, // 24小时
enableLRU: true,
lowStorageThreshold: 500 * 1024 * 1024, // 500MB
};

View File

@@ -0,0 +1,268 @@
/**
* 消息差异计算器
* 计算两个消息列表之间的差异,支持增量更新
*/
import {
MessageIdentifier,
DiffResult,
MessageComparator,
MessageSorter,
DiffConfig,
DEFAULT_DIFF_CONFIG,
} from './types';
/**
* 消息差异计算器类
* 用于高效计算消息列表的变化
*/
export class MessageDiffCalculator<T extends MessageIdentifier> {
private config: Required<DiffConfig>;
private previousMessages: Map<string, T> = new Map();
private previousOrder: string[] = [];
constructor(config: DiffConfig = {}) {
this.config = { ...DEFAULT_DIFF_CONFIG, ...config };
}
/**
* 计算两个消息列表的差异
* @param oldList 旧消息列表
* @param newList 新消息列表
* @returns 差异结果
*/
calculateDiff(oldList: T[], newList: T[]): DiffResult<T> {
const result: DiffResult<T> = {
added: [],
updated: [],
deleted: [],
moved: [],
unchanged: [],
shouldReset: false,
};
// 如果旧列表为空,直接返回新列表作为新增
if (oldList.length === 0) {
result.added = [...newList];
this.updateCache(newList);
return result;
}
// 如果新列表为空,表示全部删除
if (newList.length === 0) {
result.deleted = oldList.map((msg, index) => ({ message: msg, index }));
this.clearCache();
return result;
}
// 检查是否需要重置(变化太大)
const changeRatio = this.calculateChangeRatio(oldList, newList);
if (changeRatio > 0.5) {
result.shouldReset = true;
result.added = [...newList];
this.updateCache(newList);
return result;
}
// 构建旧列表的索引映射
const oldIndexMap = new Map<string, number>();
oldList.forEach((msg, index) => {
oldIndexMap.set(msg.id, index);
});
// 构建新列表的索引映射
const newIndexMap = new Map<string, number>();
newList.forEach((msg, index) => {
newIndexMap.set(msg.id, index);
});
// 检测新增、更新和未变更的消息
const processedIds = new Set<string>();
for (let newIndex = 0; newIndex < newList.length; newIndex++) {
const newMsg = newList[newIndex];
const oldIndex = oldIndexMap.get(newMsg.id);
if (oldIndex === undefined) {
// 新增消息
result.added.push(newMsg);
} else {
const oldMsg = oldList[oldIndex];
processedIds.add(newMsg.id);
// 检测是否有更新
const changes = this.detectChanges(oldMsg, newMsg);
if (changes && Object.keys(changes).length > 0) {
result.updated.push({
message: newMsg,
index: oldIndex,
changes,
});
} else {
result.unchanged.push(newMsg);
}
// 检测是否移动
if (oldIndex !== newIndex) {
result.moved.push({
message: newMsg,
fromIndex: oldIndex,
toIndex: newIndex,
});
}
}
}
// 检测删除的消息
for (let oldIndex = 0; oldIndex < oldList.length; oldIndex++) {
const oldMsg = oldList[oldIndex];
if (!processedIds.has(oldMsg.id)) {
result.deleted.push({ message: oldMsg, index: oldIndex });
}
}
this.updateCache(newList);
return result;
}
/**
* 计算增量差异(基于缓存的上次状态)
* @param newList 新消息列表
* @returns 差异结果
*/
calculateIncrementalDiff(newList: T[]): DiffResult<T> {
const oldList = Array.from(this.previousMessages.values());
return this.calculateDiff(oldList, newList);
}
/**
* 计算变化比例
* @param oldList 旧列表
* @param newList 新列表
* @returns 变化比例0-1
*/
private calculateChangeRatio(oldList: T[], newList: T[]): number {
if (oldList.length === 0 && newList.length === 0) return 0;
if (oldList.length === 0 || newList.length === 0) return 1;
const oldIds = new Set(oldList.map(m => m.id));
const newIds = new Set(newList.map(m => m.id));
let commonCount = 0;
oldIds.forEach(id => {
if (newIds.has(id)) {
commonCount++;
}
});
const maxLength = Math.max(oldList.length, newList.length);
return 1 - commonCount / maxLength;
}
/**
* 检测消息变化
* @param oldMsg 旧消息
* @param newMsg 新消息
* @returns 变化字段
*/
private detectChanges(oldMsg: T, newMsg: T): Partial<T> | null {
const changes: Partial<T> = {};
let hasChanges = false;
for (const key of Object.keys(newMsg) as Array<keyof T>) {
if (key === 'id') continue; // ID 不变
const oldValue = oldMsg[key];
const newValue = newMsg[key];
if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
(changes as any)[key] = newValue;
hasChanges = true;
}
}
return hasChanges ? changes : null;
}
/**
* 更新缓存
* @param messages 消息列表
*/
private updateCache(messages: T[]): void {
this.previousMessages.clear();
this.previousOrder = [];
for (const msg of messages) {
this.previousMessages.set(msg.id, msg);
this.previousOrder.push(msg.id);
}
}
/**
* 清除缓存
*/
private clearCache(): void {
this.previousMessages.clear();
this.previousOrder = [];
}
/**
* 设置配置
* @param config 配置
*/
setConfig(config: Partial<DiffConfig>): void {
this.config = { ...this.config, ...config };
}
/**
* 获取当前配置
* @returns 当前配置
*/
getConfig(): Required<DiffConfig> {
return { ...this.config };
}
/**
* 重置计算器状态
*/
reset(): void {
this.clearCache();
}
/**
* 获取缓存的消息数量
* @returns 缓存数量
*/
getCachedCount(): number {
return this.previousMessages.size;
}
/**
* 检查消息是否在缓存中
* @param messageId 消息 ID
* @returns 是否存在
*/
hasMessage(messageId: string): boolean {
return this.previousMessages.has(messageId);
}
/**
* 获取缓存中的消息
* @param messageId 消息 ID
* @returns 消息或 undefined
*/
getCachedMessage(messageId: string): T | undefined {
return this.previousMessages.get(messageId);
}
}
/**
* 创建差异计算器实例的工厂函数
* @param config 配置
* @returns MessageDiffCalculator 实例
*/
export function createMessageDiffCalculator<T extends MessageIdentifier>(
config?: DiffConfig
): MessageDiffCalculator<T> {
return new MessageDiffCalculator<T>(config);
}

View File

@@ -0,0 +1,385 @@
/**
* 消息更新批量处理器
* 收集更新操作并按批处理,减少 UI 更新次数
*/
import {
MessageUpdate,
MessageUpdateType,
BaseMessageUpdate,
BatchUpdateListener,
DiffConfig,
DEFAULT_DIFF_CONFIG,
MessageIdentifier,
} from './types';
/**
* 批量处理器配置选项
*/
export interface BatcherOptions {
/** 批量处理间隔(毫秒),默认 16ms约 60fps */
batchInterval?: number;
/** 最大批量大小,默认 100 */
maxBatchSize?: number;
/** 是否启用去重,默认 true */
enableDeduplication?: boolean;
/** 是否合并相同消息的多次更新,默认 true */
enableMerge?: boolean;
/** 最大等待时间(毫秒),超过此时间强制刷新,默认 100ms */
maxWaitTime?: number;
/** 是否自动启动批量处理,默认 true */
autoStart?: boolean;
}
/**
* 默认批量处理器配置
*/
export const DEFAULT_BATCHER_OPTIONS: Required<BatcherOptions> = {
batchInterval: 16,
maxBatchSize: 100,
enableDeduplication: true,
enableMerge: true,
maxWaitTime: 100,
autoStart: true,
};
/**
* 消息更新批量处理器类
*/
export class MessageUpdateBatcher {
/** 待处理的更新队列 */
private pendingUpdates: Map<string, MessageUpdate> = new Map();
/** 批量处理定时器 */
private batchTimer: ReturnType<typeof setTimeout> | null = null;
/** 最大等待时间定时器 */
private maxWaitTimer: ReturnType<typeof setTimeout> | null = null;
/** 监听器集合 */
private listeners: Set<BatchUpdateListener> = new Set();
/** 是否正在批处理中 */
private isProcessing: boolean = false;
/** 配置选项 */
private options: Required<BatcherOptions>;
/** 处理器是否已启动 */
private isStarted: boolean = false;
/** 批量处理统计信息 */
private stats: {
totalBatches: number;
totalUpdates: number;
averageBatchSize: number;
lastBatchTime: number;
} = {
totalBatches: 0,
totalUpdates: 0,
averageBatchSize: 0,
lastBatchTime: 0,
};
constructor(options: BatcherOptions = {}) {
this.options = { ...DEFAULT_BATCHER_OPTIONS, ...options };
if (this.options.autoStart) {
this.start();
}
}
/**
* 启动批量处理器
*/
start(): void {
if (this.isStarted) return;
this.isStarted = true;
this.scheduleBatch();
}
/**
* 停止批量处理器
*/
stop(): void {
this.isStarted = false;
this.clearTimers();
}
/**
* 添加更新操作到批量队列
* @param update 消息更新操作
*/
addUpdate(update: MessageUpdate): void {
const key = this.generateUpdateKey(update);
// 去重:如果已有相同消息 ID 的待处理更新,进行合并
if (this.options.enableDeduplication && this.pendingUpdates.has(key)) {
const existing = this.pendingUpdates.get(key)!;
if (this.options.enableMerge) {
const merged = this.mergeUpdates(existing, update);
if (merged) {
this.pendingUpdates.set(key, merged);
return;
}
}
}
this.pendingUpdates.set(key, update);
// 如果达到最大批量大小,立即刷新
if (this.pendingUpdates.size >= this.options.maxBatchSize) {
this.flush();
}
}
/**
* 添加多个更新操作
* @param updates 更新操作数组
*/
addUpdates(updates: MessageUpdate[]): void {
for (const update of updates) {
this.addUpdate(update);
}
}
/**
* 立即刷新所有待处理更新
* @returns 处理的更新数组
*/
flush(): MessageUpdate[] {
if (this.pendingUpdates.size === 0 || this.isProcessing) {
return [];
}
this.isProcessing = true;
this.clearTimers();
const updates = Array.from(this.pendingUpdates.values());
this.pendingUpdates.clear();
// 更新统计信息
this.stats.totalBatches++;
this.stats.totalUpdates += updates.length;
this.stats.averageBatchSize = this.stats.totalUpdates / this.stats.totalBatches;
this.stats.lastBatchTime = Date.now();
// 通知监听器
this.notifyListeners(updates);
this.isProcessing = false;
// 重新调度定时器
this.scheduleBatch();
return updates;
}
/**
* 订阅批量更新事件
* @param listener 监听器函数
* @returns 取消订阅函数
*/
subscribe(listener: BatchUpdateListener): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
/**
* 取消订阅
* @param listener 监听器函数
*/
unsubscribe(listener: BatchUpdateListener): void {
this.listeners.delete(listener);
}
/**
* 获取待处理更新数量
* @returns 待处理数量
*/
getPendingCount(): number {
return this.pendingUpdates.size;
}
/**
* 获取统计信息
* @returns 统计信息
*/
getStats() {
return { ...this.stats };
}
/**
* 检查是否有待处理更新
* @returns 是否有待处理更新
*/
hasPendingUpdates(): boolean {
return this.pendingUpdates.size > 0;
}
/**
* 清空所有待处理更新
*/
clearPending(): void {
this.pendingUpdates.clear();
this.clearTimers();
}
/**
* 销毁批量处理器
*/
destroy(): void {
this.stop();
this.clearPending();
this.listeners.clear();
}
/**
* 生成更新键值
* @param update 更新操作
* @returns 键值
*/
private generateUpdateKey(update: MessageUpdate): string {
// 根据更新类型生成唯一键
switch (update.type) {
case 'ADD':
case 'BATCH_ADD':
return `${update.type}_${update.timestamp}`;
case 'UPDATE':
case 'BATCH_UPDATE':
return `${update.type}_${this.extractMessageId(update)}`;
case 'DELETE':
case 'BATCH_DELETE':
return `${update.type}_${this.extractMessageId(update)}`;
default:
return `${update.type}_${update.timestamp}_${Math.random()}`;
}
}
/**
* 提取消息 ID
* @param update 更新操作
* @returns 消息 ID 或空字符串
*/
private extractMessageId(update: MessageUpdate): string {
const payload = (update as any).payload;
if (payload) {
if (payload.messageId) return payload.messageId;
if (payload.messageIds) return payload.messageIds.join(',');
}
return '';
}
/**
* 合并更新操作
* @param existing 已有更新
* @param incoming 新更新
* @returns 合并后的更新或 null如果无法合并
*/
private mergeUpdates(existing: MessageUpdate, incoming: MessageUpdate): MessageUpdate | null {
// 只有同类型的更新才能合并
if (existing.type !== incoming.type) {
return null;
}
switch (existing.type) {
case 'UPDATE':
// 合并更新字段
return {
...existing,
payload: {
...(existing as any).payload,
updates: {
...(existing as any).payload?.updates,
...(incoming as any).payload?.updates,
},
},
timestamp: incoming.timestamp,
};
case 'BATCH_UPDATE':
// 合并批量更新
const existingUpdates = (existing as any).payload?.updates || [];
const incomingUpdates = (incoming as any).payload?.updates || [];
const updatesMap = new Map();
for (const update of [...existingUpdates, ...incomingUpdates]) {
const existing = updatesMap.get(update.messageId);
if (existing) {
existing.changes = { ...existing.changes, ...update.changes };
} else {
updatesMap.set(update.messageId, { ...update });
}
}
return {
...existing,
payload: {
...(existing as any).payload,
updates: Array.from(updatesMap.values()),
},
timestamp: incoming.timestamp,
};
default:
return null;
}
}
/**
* 调度批量处理
*/
private scheduleBatch(): void {
if (!this.isStarted) return;
this.clearTimers();
// 设置最大等待时间定时器
this.maxWaitTimer = setTimeout(() => {
this.flush();
}, this.options.maxWaitTime);
// 设置批量处理定时器
this.batchTimer = setTimeout(() => {
this.flush();
}, this.options.batchInterval);
}
/**
* 清除所有定时器
*/
private clearTimers(): void {
if (this.batchTimer) {
clearTimeout(this.batchTimer);
this.batchTimer = null;
}
if (this.maxWaitTimer) {
clearTimeout(this.maxWaitTimer);
this.maxWaitTimer = null;
}
}
/**
* 通知监听器
* @param updates 更新数组
*/
private notifyListeners(updates: MessageUpdate[]): void {
if (this.listeners.size === 0) return;
// 转换为数组以支持迭代
const listenersArray = Array.from(this.listeners);
for (const listener of listenersArray) {
try {
listener(updates);
} catch (error) {
console.error('Error in batch update listener:', error);
}
}
}
}
/**
* 创建批量处理器的工厂函数
* @param options 配置选项
* @returns MessageUpdateBatcher 实例
*/
export function createMessageUpdateBatcher(
options?: BatcherOptions
): MessageUpdateBatcher {
return new MessageUpdateBatcher(options);
}

View File

@@ -0,0 +1,40 @@
/**
* 差异更新模块
* 提供消息列表的增量更新功能,减少 UI 重渲染次数
*/
// 类型定义
export {
MessageUpdateType,
MessageIdentifier,
BaseMessageUpdate,
AddMessageUpdate,
UpdateMessageUpdate,
DeleteMessageUpdate,
MoveMessageUpdate,
BatchAddMessageUpdate,
BatchUpdateMessageUpdate,
BatchDeleteMessageUpdate,
ResetMessageUpdate,
MessageUpdate,
DiffConfig,
DiffResult,
BatchUpdateListener,
MessageComparator,
MessageSorter,
DEFAULT_DIFF_CONFIG,
} from './types';
// 差异计算器
export {
MessageDiffCalculator,
createMessageDiffCalculator,
} from './MessageDiffCalculator';
// 批量处理器
export {
MessageUpdateBatcher,
createMessageUpdateBatcher,
BatcherOptions,
DEFAULT_BATCHER_OPTIONS,
} from './MessageUpdateBatcher';

View File

@@ -0,0 +1,209 @@
/**
* 差异更新类型定义
* 用于消息列表的增量更新,减少 UI 重渲染次数
*/
/**
* 消息更新类型枚举
*/
export enum MessageUpdateType {
/** 添加单条消息 */
ADD = 'ADD',
/** 更新单条消息 */
UPDATE = 'UPDATE',
/** 删除单条消息 */
DELETE = 'DELETE',
/** 移动消息位置 */
MOVE = 'MOVE',
/** 批量添加 */
BATCH_ADD = 'BATCH_ADD',
/** 批量更新 */
BATCH_UPDATE = 'BATCH_UPDATE',
/** 批量删除 */
BATCH_DELETE = 'BATCH_DELETE',
/** 重置整个列表 */
RESET = 'RESET',
}
/**
* 消息唯一标识接口
*/
export interface MessageIdentifier {
/** 消息唯一 ID */
id: string;
/** 消息序列号(可选,用于排序) */
seq?: number;
}
/**
* 基础更新操作接口
*/
export interface BaseMessageUpdate {
/** 更新类型 */
type: MessageUpdateType;
/** 更新时间戳 */
timestamp: number;
/** 会话 ID可选 */
conversationId?: string;
/** 载荷数据(可选,用于合并更新) */
payload?: Record<string, any>;
}
/**
* 添加单条消息更新
*/
export interface AddMessageUpdate extends BaseMessageUpdate {
type: MessageUpdateType.ADD;
/** 消息数据 */
message: MessageIdentifier;
/** 插入位置索引(可选,默认追加到末尾) */
index?: number;
}
/**
* 更新单条消息更新
*/
export interface UpdateMessageUpdate extends BaseMessageUpdate {
type: MessageUpdateType.UPDATE;
/** 消息 ID */
messageId: string;
/** 更新的字段 */
updates: Partial<MessageIdentifier>;
}
/**
* 删除单条消息更新
*/
export interface DeleteMessageUpdate extends BaseMessageUpdate {
type: MessageUpdateType.DELETE;
/** 消息 ID */
messageId: string;
}
/**
* 移动消息更新
*/
export interface MoveMessageUpdate extends BaseMessageUpdate {
type: MessageUpdateType.MOVE;
/** 消息 ID */
messageId: string;
/** 目标位置索引 */
toIndex: number;
}
/**
* 批量添加消息更新
*/
export interface BatchAddMessageUpdate extends BaseMessageUpdate {
type: MessageUpdateType.BATCH_ADD;
/** 消息列表 */
messages: MessageIdentifier[];
/** 插入位置索引(可选,默认追加到末尾) */
startIndex?: number;
}
/**
* 批量更新消息更新
*/
export interface BatchUpdateMessageUpdate extends BaseMessageUpdate {
type: MessageUpdateType.BATCH_UPDATE;
/** 批量更新项 */
updates: Array<{
messageId: string;
changes: Partial<MessageIdentifier>;
}>;
}
/**
* 批量删除消息更新
*/
export interface BatchDeleteMessageUpdate extends BaseMessageUpdate {
type: MessageUpdateType.BATCH_DELETE;
/** 要删除的消息 ID 列表 */
messageIds: string[];
}
/**
* 重置消息列表更新
*/
export interface ResetMessageUpdate extends BaseMessageUpdate {
type: MessageUpdateType.RESET;
/** 新的消息列表 */
messages: MessageIdentifier[];
}
/**
* 消息更新联合类型
*/
export type MessageUpdate =
| AddMessageUpdate
| UpdateMessageUpdate
| DeleteMessageUpdate
| MoveMessageUpdate
| BatchAddMessageUpdate
| BatchUpdateMessageUpdate
| BatchDeleteMessageUpdate
| ResetMessageUpdate;
/**
* 差异配置接口
*/
export interface DiffConfig {
/** 批量处理间隔(毫秒),默认 16ms60fps */
batchInterval?: number;
/** 最大批量大小,默认 100 */
maxBatchSize?: number;
/** 是否启用去重,默认 true */
enableDeduplication?: boolean;
/** 是否合并相同消息的多次更新,默认 true */
enableMerge?: boolean;
/** 消息唯一标识字段,默认 'id' */
idField?: string;
/** 排序字段,默认 'seq' */
sortField?: string;
}
/**
* 差异计算结果接口
*/
export interface DiffResult<T extends MessageIdentifier> {
/** 新增的消息 */
added: T[];
/** 更新的消息 */
updated: Array<{ message: T; index: number; changes: Partial<T> }>;
/** 删除的消息 */
deleted: Array<{ message: T; index: number }>;
/** 移动的消息 */
moved: Array<{ message: T; fromIndex: number; toIndex: number }>;
/** 未变更的消息 */
unchanged: T[];
/** 是否需要重置 */
shouldReset: boolean;
}
/**
* 批量更新监听器类型
*/
export type BatchUpdateListener = (updates: MessageUpdate[]) => void;
/**
* 消息比较函数类型
*/
export type MessageComparator<T extends MessageIdentifier> = (a: T, b: T) => boolean;
/**
* 消息排序函数类型
*/
export type MessageSorter<T extends MessageIdentifier> = (a: T, b: T) => number;
/**
* 默认配置常量
*/
export const DEFAULT_DIFF_CONFIG: Required<DiffConfig> = {
batchInterval: 16,
maxBatchSize: 100,
enableDeduplication: true,
enableMerge: true,
idField: 'id',
sortField: 'seq',
};

View File

@@ -0,0 +1,60 @@
/**
* 导航状态管理 Hook
* 管理导航相关状态,如当前路由、导航历史等
*/
import { useState, useCallback, useEffect } from 'react';
import { useNavigationState as useRNNavigationState, Route } from '@react-navigation/native';
import type { RootStackParamList } from '../../../navigation/types';
type TabName = 'HomeTab' | 'MessageTab' | 'ScheduleTab' | 'ProfileTab';
interface NavigationState {
currentTab: TabName;
isCollapsed: boolean;
isReady: boolean;
}
interface NavigationActions {
setCurrentTab: (tab: TabName) => void;
toggleCollapse: () => void;
setIsReady: (ready: boolean) => void;
}
/**
* 导航状态管理 Hook
*/
export function useNavigationState(): NavigationState & NavigationActions {
const [currentTab, setCurrentTab] = useState<TabName>('HomeTab');
const [isCollapsed, setIsCollapsed] = useState(false);
const [isReady, setIsReady] = useState(false);
const toggleCollapse = useCallback(() => {
setIsCollapsed(prev => !prev);
}, []);
return {
currentTab,
isCollapsed,
isReady,
setCurrentTab,
toggleCollapse,
setIsReady,
};
}
/**
* 获取当前路由名称
*/
export function useCurrentRouteName(): string | null {
const routeName = useRNNavigationState(state => state?.routes[state.index]?.name ?? null);
return routeName;
}
/**
* 检查是否在指定路由
*/
export function useIsRoute(routeName: keyof RootStackParamList): boolean {
const currentRoute = useCurrentRouteName();
return currentRoute === routeName;
}

View File

@@ -0,0 +1,110 @@
/**
* 导航服务层
* 提供全局导航功能,解耦组件与导航状态的直接依赖
*/
import { NavigationContainerRef, Route } from '@react-navigation/native';
class NavigationService {
private navigationRef: NavigationContainerRef<any> | null = null;
private isReady: boolean = false;
/**
* 设置导航引用
*/
setNavigationRef(ref: NavigationContainerRef<any> | null): void {
this.navigationRef = ref;
this.isReady = ref !== null;
}
/**
* 检查导航是否就绪
*/
isNavigationReady(): boolean {
return this.isReady && this.navigationRef !== null;
}
/**
* 导航到指定路由
*/
navigate(routeName: string, params?: any): void {
if (!this.isNavigationReady()) {
console.warn('[NavigationService] Navigation not ready');
return;
}
this.navigationRef!.navigate(routeName as any, params);
}
/**
* 返回上一页
*/
goBack(): void {
if (!this.isNavigationReady()) {
console.warn('[NavigationService] Navigation not ready');
return;
}
this.navigationRef!.goBack();
}
/**
* 获取当前路由
*/
getCurrentRoute(): Route<string> | null {
if (!this.isNavigationReady()) {
return null;
}
return this.navigationRef!.getCurrentRoute();
}
/**
* 获取当前路由名称
*/
getCurrentRouteName(): string | null {
const route = this.getCurrentRoute();
return route?.name ?? null;
}
/**
* 重置导航到指定路由
*/
reset(routes: { name: string; params?: any }[], index: number = 0): void {
if (!this.isNavigationReady()) {
console.warn('[NavigationService] Navigation not ready');
return;
}
this.navigationRef!.reset({
index,
routes: routes.map(r => ({ name: r.name, params: r.params })),
});
}
/**
* 替换当前路由
*/
replace(routeName: string, params?: any): void {
if (!this.isNavigationReady()) {
console.warn('[NavigationService] Navigation not ready');
return;
}
this.navigationRef!.dispatch({
type: 'REPLACE',
payload: { name: routeName, params },
});
}
/**
* 导航到根路由
*/
navigateToRoot(): void {
this.reset([{ name: 'Main' }]);
}
/**
* 导航到认证页面
*/
navigateToAuth(): void {
this.reset([{ name: 'Auth' }]);
}
}
export const navigationService = new NavigationService();

View File

@@ -0,0 +1,58 @@
/**
* 导航基础设施类型定义
*/
import { NavigationContainerRef, Route } from '@react-navigation/native';
import type {
RootStackParamList,
MainTabParamList,
HomeStackParamList,
MessageStackParamList,
ScheduleStackParamList,
ProfileStackParamList,
AuthStackParamList,
} from '../../navigation/types';
// ==================== 导航服务类型 ====================
export interface NavigationServiceInterface {
setNavigationRef(ref: NavigationContainerRef<any> | null): void;
isNavigationReady(): boolean;
navigate(routeName: string, params?: any): void;
goBack(): void;
getCurrentRoute(): Route<string> | null;
getCurrentRouteName(): string | null;
reset(routes: { name: string; params?: any }[], index?: number): void;
replace(routeName: string, params?: any): void;
navigateToRoot(): void;
navigateToAuth(): void;
}
// ==================== Tab 类型 ====================
export type TabName = 'HomeTab' | 'MessageTab' | 'ScheduleTab' | 'ProfileTab';
export interface NavItemConfig {
name: TabName;
label: string;
icon: string;
iconOutline: string;
}
// ==================== 导航配置常量 ====================
export const NAVIGATION_CONSTANTS = {
SIDEBAR_WIDTH_DESKTOP: 240,
SIDEBAR_WIDTH_TABLET: 200,
SIDEBAR_COLLAPSED_WIDTH: 72,
MOBILE_TAB_FLOATING_MARGIN: 12,
LAST_ACTIVE_TAB_KEY: 'last_active_tab',
} as const;
// ==================== 重新导出导航类型 ====================
export type {
RootStackParamList,
MainTabParamList,
HomeStackParamList,
MessageStackParamList,
ScheduleStackParamList,
ProfileStackParamList,
AuthStackParamList,
};

View File

@@ -0,0 +1,546 @@
/**
* 分页状态管理器
*
* 提供分页状态的集中管理,包括:
* - 分页状态跟踪(当前页、总加载数、是否有更多等)
* - 页面缓存机制(避免重复加载相同页面)
* - 加载锁机制(防止并发重复请求)
* - 预加载功能(提前加载下一页数据)
*/
import {
PaginationState,
PageCache,
PaginationConfig,
LoadMoreResult,
RefreshResult,
PrefetchResult,
FetchPageFunction,
DEFAULT_PAGINATION_CONFIG,
createInitialPaginationState,
createPageCache,
isCacheExpired,
} from './types';
/**
* 分页状态管理器类
* 管理多个分页键的状态,支持页面缓存和加载锁
*/
export class PaginationStateManager {
// 分页状态存储key -> PaginationState
private states: Map<string, PaginationState> = new Map();
// 页面缓存存储key -> Map<pageNumber, PageCache>
private pageCaches: Map<string, Map<number, PageCache>> = new Map();
// 加载锁存储key -> Set<pageNumber>
private loadingLocks: Map<string, Set<number>> = new Map();
// 配置存储key -> PaginationConfig
private configs: Map<string, PaginationConfig> = new Map();
/**
* 获取或创建分页状态
* @param key 分页键通常是会话ID或列表标识
* @param config 可选的配置
* @returns 分页状态
*/
getState(key: string, config?: Partial<PaginationConfig>): PaginationState {
let state = this.states.get(key);
if (!state) {
const mergedConfig = this.getConfig(key, config);
state = createInitialPaginationState(mergedConfig.pageSize);
this.states.set(key, state);
}
return state;
}
/**
* 更新分页状态
* @param key 分页键
* @param partialState 部分状态更新
*/
setState(key: string, partialState: Partial<PaginationState>): void {
const currentState = this.getState(key);
this.states.set(key, {
...currentState,
...partialState,
});
}
/**
* 获取配置
* @param key 分页键
* @param overrideConfig 可选的覆盖配置
* @returns 合并后的配置
*/
getConfig(key: string, overrideConfig?: Partial<PaginationConfig>): PaginationConfig {
const existingConfig = this.configs.get(key);
if (overrideConfig) {
const mergedConfig = {
...(existingConfig || DEFAULT_PAGINATION_CONFIG),
...overrideConfig,
};
this.configs.set(key, mergedConfig);
return mergedConfig;
}
return existingConfig || DEFAULT_PAGINATION_CONFIG;
}
/**
* 尝试获取加载锁
* @param key 分页键
* @param page 页码
* @returns 是否成功获取锁
*/
acquireLock(key: string, page: number): boolean {
let locks = this.loadingLocks.get(key);
if (!locks) {
locks = new Set();
this.loadingLocks.set(key, locks);
}
// 如果已经在加载中,返回 false
if (locks.has(page)) {
return false;
}
// 获取锁成功
locks.add(page);
// 更新状态为加载中
this.setState(key, { isLoading: true, hasError: false, error: undefined });
return true;
}
/**
* 释放加载锁
* @param key 分页键
* @param page 页码
*/
releaseLock(key: string, page: number): void {
const locks = this.loadingLocks.get(key);
if (locks) {
locks.delete(page);
// 如果没有正在加载的页面了,更新状态
if (locks.size === 0) {
this.setState(key, { isLoading: false });
}
}
}
/**
* 检查是否正在加载指定页面
* @param key 分页键
* @param page 页码
* @returns 是否正在加载
*/
isPageLoading(key: string, page: number): boolean {
const locks = this.loadingLocks.get(key);
return locks ? locks.has(page) : false;
}
/**
* 检查是否有任何页面正在加载
* @param key 分页键
* @returns 是否有页面正在加载
*/
isAnyPageLoading(key: string): boolean {
const locks = this.loadingLocks.get(key);
return locks ? locks.size > 0 : false;
}
/**
* 缓存页面数据
* @param key 分页键
* @param page 页码
* @param data 页面数据
* @param cursor 可选的分页游标
*/
cachePage<T>(key: string, page: number, data: T[], cursor?: string | number | null): void {
const config = this.getConfig(key);
if (!config.enableCache) return;
let caches = this.pageCaches.get(key);
if (!caches) {
caches = new Map();
this.pageCaches.set(key, caches);
}
// 如果超过最大缓存页数,清理最旧的缓存
if (caches.size >= config.maxCachedPages) {
this.cleanupOldestCache(key);
}
caches.set(page, createPageCache(page, data, cursor));
}
/**
* 获取缓存的页面数据
* @param key 分页键
* @param page 页码
* @returns 缓存数据或 null
*/
getCachedPage<T>(key: string, page: number): PageCache<T> | null {
const config = this.getConfig(key);
if (!config.enableCache) return null;
const caches = this.pageCaches.get(key);
if (!caches) return null;
const cache = caches.get(page);
if (!cache) return null;
// 检查缓存是否过期
if (isCacheExpired(cache, config.cacheTtl)) {
caches.delete(page);
return null;
}
return cache as PageCache<T>;
}
/**
* 检查页面是否已缓存且未过期
* @param key 分页键
* @param page 页码
* @returns 是否已缓存
*/
isPageCached(key: string, page: number): boolean {
return this.getCachedPage(key, page) !== null;
}
/**
* 清除指定键的所有缓存
* @param key 分页键
*/
clearCache(key: string): void {
this.pageCaches.delete(key);
}
/**
* 重置分页状态
* @param key 分页键
* @param config 可选的新配置
*/
reset(key: string, config?: Partial<PaginationConfig>): void {
const mergedConfig = this.getConfig(key, config);
this.states.set(key, createInitialPaginationState(mergedConfig.pageSize));
this.clearCache(key);
this.loadingLocks.delete(key);
}
/**
* 删除分页状态
* @param key 分页键
*/
remove(key: string): void {
this.states.delete(key);
this.pageCaches.delete(key);
this.loadingLocks.delete(key);
this.configs.delete(key);
}
/**
* 清理所有数据
*/
clear(): void {
this.states.clear();
this.pageCaches.clear();
this.loadingLocks.clear();
this.configs.clear();
}
/**
* 获取所有分页键
* @returns 分页键数组
*/
getKeys(): string[] {
return Array.from(this.states.keys());
}
/**
* 清理最旧的缓存
* @param key 分页键
*/
private cleanupOldestCache(key: string): void {
const caches = this.pageCaches.get(key);
if (!caches || caches.size === 0) return;
let oldestPage = -1;
let oldestTime = Infinity;
Array.from(caches.entries()).forEach(([page, cache]) => {
if (cache.cachedAt < oldestTime) {
oldestTime = cache.cachedAt;
oldestPage = page;
}
});
if (oldestPage >= 0) {
caches.delete(oldestPage);
}
}
/**
* 加载更多数据
* @param key 分页键
* @param fetchFunction 数据获取函数
* @returns 加载结果
*/
async loadMore<T>(
key: string,
fetchFunction: FetchPageFunction<T>
): Promise<LoadMoreResult<T>> {
const state = this.getState(key);
const config = this.getConfig(key);
const nextPage = state.currentPage + 1;
// 检查是否还有更多数据
if (!state.hasMore) {
return {
success: true,
data: [],
hasMore: false,
fromCache: false,
};
}
// 尝试获取加载锁
if (!this.acquireLock(key, nextPage)) {
return {
success: false,
data: [],
hasMore: state.hasMore,
fromCache: false,
error: 'Page is already loading',
};
}
try {
// 检查缓存
const cachedPage = this.getCachedPage<T>(key, nextPage);
if (cachedPage) {
// 更新状态
this.setState(key, {
currentPage: nextPage,
totalLoaded: state.totalLoaded + cachedPage.data.length,
lastLoadTime: Date.now(),
});
return {
success: true,
data: cachedPage.data,
hasMore: state.hasMore,
fromCache: true,
};
}
// 从数据源加载
const result = await fetchFunction(nextPage, state.pageSize, state.cursor);
// 缓存页面数据
if (config.enableCache && result.data.length > 0) {
this.cachePage(key, nextPage, result.data, result.cursor);
}
// 更新状态
this.setState(key, {
currentPage: nextPage,
totalLoaded: state.totalLoaded + result.data.length,
hasMore: result.hasMore,
cursor: result.cursor,
lastLoadTime: Date.now(),
hasError: false,
error: undefined,
});
return {
success: true,
data: result.data,
hasMore: result.hasMore,
fromCache: false,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
this.setState(key, {
hasError: true,
error: errorMessage,
});
return {
success: false,
data: [],
hasMore: state.hasMore,
fromCache: false,
error: errorMessage,
};
} finally {
this.releaseLock(key, nextPage);
}
}
/**
* 刷新数据(重新加载第一页)
* @param key 分页键
* @param fetchFunction 数据获取函数
* @returns 刷新结果
*/
async refresh<T>(
key: string,
fetchFunction: FetchPageFunction<T>
): Promise<RefreshResult<T>> {
const state = this.getState(key);
const config = this.getConfig(key);
// 尝试获取加载锁
if (!this.acquireLock(key, 1)) {
return {
success: false,
data: [],
hasMore: state.hasMore,
error: 'Refresh already in progress',
};
}
try {
// 清除缓存
this.clearCache(key);
// 重置状态
this.setState(key, {
currentPage: 0,
totalLoaded: 0,
hasMore: true,
cursor: null,
hasError: false,
error: undefined,
});
// 加载第一页
const result = await fetchFunction(1, state.pageSize, null);
// 缓存第一页
if (config.enableCache && result.data.length > 0) {
this.cachePage(key, 1, result.data, result.cursor);
}
// 更新状态
this.setState(key, {
currentPage: 1,
totalLoaded: result.data.length,
hasMore: result.hasMore,
cursor: result.cursor,
lastLoadTime: Date.now(),
});
return {
success: true,
data: result.data,
hasMore: result.hasMore,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
this.setState(key, {
hasError: true,
error: errorMessage,
});
return {
success: false,
data: [],
hasMore: state.hasMore,
error: errorMessage,
};
} finally {
this.releaseLock(key, 1);
}
}
/**
* 预加载指定页面
* @param key 分页键
* @param page 页码
* @param fetchFunction 数据获取函数
* @returns 预加载结果
*/
async prefetch<T>(
key: string,
page: number,
fetchFunction: FetchPageFunction<T>
): Promise<PrefetchResult<T>> {
const config = this.getConfig(key);
// 检查是否启用预加载
if (!config.enablePrefetch) {
return { success: false, error: 'Prefetch is disabled' };
}
// 检查是否已缓存
if (this.isPageCached(key, page)) {
return { success: true, data: this.getCachedPage<T>(key, page)?.data };
}
// 检查是否正在加载
if (this.isPageLoading(key, page)) {
return { success: false, error: 'Page is already loading' };
}
// 尝试获取加载锁
if (!this.acquireLock(key, page)) {
return { success: false, error: 'Failed to acquire lock' };
}
try {
const result = await fetchFunction(page, config.pageSize, null);
// 缓存数据
if (config.enableCache && result.data.length > 0) {
this.cachePage(key, page, result.data, result.cursor);
}
return {
success: true,
data: result.data,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return {
success: false,
error: errorMessage,
};
} finally {
this.releaseLock(key, page);
}
}
/**
* 检查是否应该预加载下一页
* @param key 分页键
* @param currentItemCount 当前已加载的条目数
* @returns 是否应该预加载
*/
shouldPrefetch(key: string, currentItemCount: number): boolean {
const state = this.getState(key);
const config = this.getConfig(key);
if (!config.enablePrefetch) return false;
if (!state.hasMore) return false;
if (state.isLoading) return false;
const nextPage = state.currentPage + 1;
if (this.isPageLoading(key, nextPage)) return false;
if (this.isPageCached(key, nextPage)) return false;
// 检查是否达到预加载阈值
const threshold = state.totalLoaded - config.prefetchThreshold;
return currentItemCount >= threshold;
}
}
// 导出单例实例
export const paginationStateManager = new PaginationStateManager();

View File

@@ -0,0 +1,34 @@
/**
* 分页状态管理模块
*
* 提供分页状态管理的核心功能,包括:
* - PaginationStateManager: 分页状态管理器类
* - paginationStateManager: 单例实例
* - usePagination: React Hook 用于在组件中使用分页功能
* - 类型定义和工具函数
*/
// 导出类型定义
export type {
PaginationState,
PageCache,
PaginationConfig,
LoadMoreResult,
RefreshResult,
PrefetchResult,
FetchPageFunction,
} from './types';
// 导出工具函数和常量
export {
DEFAULT_PAGINATION_CONFIG,
createInitialPaginationState,
createPageCache,
isCacheExpired,
} from './types';
// 导出分页状态管理器
export {
PaginationStateManager,
paginationStateManager,
} from './PaginationStateManager';

View File

@@ -0,0 +1,182 @@
/**
* 分页状态管理类型定义
*
* 提供分页相关的接口和类型定义
* 用于统一管理分页状态、缓存和配置
*/
/**
* 分页状态
* 跟踪特定分页键的分页信息
*/
export interface PaginationState {
/** 当前页码1-based */
currentPage: number;
/** 每页大小 */
pageSize: number;
/** 已加载的总条目数 */
totalLoaded: number;
/** 是否存在更多数据 */
hasMore: boolean;
/** 是否正在加载 */
isLoading: boolean;
/** 最后加载时间戳 */
lastLoadTime: number;
/** 分页游标(用于基于游标的分页) */
cursor?: string | number | null;
/** 是否发生过错误 */
hasError: boolean;
/** 错误信息 */
error?: string;
}
/**
* 页面缓存
* 存储已加载页面的数据
*/
export interface PageCache<T = any> {
/** 页码 */
page: number;
/** 该页的数据 */
data: T[];
/** 缓存时间戳 */
cachedAt: number;
/** 分页游标 */
cursor?: string | number | null;
}
/**
* 分页配置
* 配置分页行为
*/
export interface PaginationConfig {
/** 每页默认大小 */
pageSize: number;
/** 预加载阈值(距离底部多少条时触发预加载) */
prefetchThreshold: number;
/** 最大缓存页数 */
maxCachedPages: number;
/** 缓存过期时间(毫秒) */
cacheTtl: number;
/** 是否启用预加载 */
enablePrefetch: boolean;
/** 是否启用缓存 */
enableCache: boolean;
/** 最大重试次数 */
maxRetries: number;
/** 重试延迟(毫秒) */
retryDelay: number;
}
/**
* 加载更多结果
* loadMore 方法的返回类型
*/
export interface LoadMoreResult<T = any> {
/** 是否成功 */
success: boolean;
/** 加载的数据 */
data: T[];
/** 是否有更多数据 */
hasMore: boolean;
/** 是否来自缓存 */
fromCache: boolean;
/** 错误信息(如果失败) */
error?: string;
}
/**
* 刷新结果
* refresh 方法的返回类型
*/
export interface RefreshResult<T = any> {
/** 是否成功 */
success: boolean;
/** 刷新后的数据 */
data: T[];
/** 是否有更多数据 */
hasMore: boolean;
/** 错误信息(如果失败) */
error?: string;
}
/**
* 预加载结果
* prefetch 方法的返回类型
*/
export interface PrefetchResult<T = any> {
/** 是否成功 */
success: boolean;
/** 预加载的数据 */
data?: T[];
/** 错误信息(如果失败) */
error?: string;
}
/**
* 分页数据加载函数
* 用于从数据源加载分页数据
*/
export type FetchPageFunction<T = any> = (
page: number,
pageSize: number,
cursor?: string | number | null
) => Promise<{
data: T[];
hasMore: boolean;
cursor?: string | number | null;
}>;
/**
* 默认分页配置
*/
export const DEFAULT_PAGINATION_CONFIG: PaginationConfig = {
pageSize: 20,
prefetchThreshold: 5,
maxCachedPages: 10,
cacheTtl: 5 * 60 * 1000, // 5分钟
enablePrefetch: true,
enableCache: true,
maxRetries: 3,
retryDelay: 1000,
};
/**
* 初始分页状态
*/
export function createInitialPaginationState(pageSize: number = 20): PaginationState {
return {
currentPage: 0,
pageSize,
totalLoaded: 0,
hasMore: true,
isLoading: false,
lastLoadTime: 0,
cursor: null,
hasError: false,
error: undefined,
};
}
/**
* 创建页面缓存
*/
export function createPageCache<T>(
page: number,
data: T[],
cursor?: string | number | null
): PageCache<T> {
return {
page,
data,
cachedAt: Date.now(),
cursor,
};
}
/**
* 检查缓存是否过期
*/
export function isCacheExpired<T>(cache: PageCache<T>, ttl: number): boolean {
return Date.now() - cache.cachedAt > ttl;
}

View File

@@ -0,0 +1,289 @@
/**
* 连接状态管理器
*
* 提供连接状态的集中管理,包括:
* - 6种连接状态管理IDLE/CONNECTING/CONNECTED/RECONNECTING/DISCONNECTED/ERROR
* - 状态转换逻辑验证
* - 状态订阅机制(监听器模式)
* - 重连次数和最后连接时间跟踪
* - 单例实例提供
*/
import {
ConnectionState,
ConnectionStateInfo,
ConnectionStateListener,
ReconnectConfig,
StateTransitions,
DEFAULT_STATE_TRANSITIONS,
DEFAULT_RECONNECT_CONFIG,
getStateDescription,
} from './types';
/**
* 连接状态管理器类
*
* 管理连接状态的生命周期,提供状态转换、订阅和状态信息查询功能。
* 使用单例模式确保全局唯一实例。
*/
export class ConnectionStateManager {
/** 当前连接状态 */
private state: ConnectionState = ConnectionState.IDLE;
/** 上一次状态 */
private previousState: ConnectionState | null = null;
/** 状态变更监听器集合 */
private listeners: Set<ConnectionStateListener> = new Set();
/** 当前重连次数 */
private reconnectAttempts = 0;
/** 最后成功连接时间 */
private lastConnectedTime: number | null = null;
/** 状态变更时间戳 */
private lastStateChangeTime = Date.now();
/** 当前错误信息 */
private currentError: Error | null = null;
/** 重连配置 */
private reconnectConfig: ReconnectConfig;
/** 状态转换规则 */
private stateTransitions: StateTransitions;
/**
* 创建连接状态管理器实例
* @param config 可选的重连配置
* @param transitions 可选的状态转换规则
*/
constructor(
config: Partial<ReconnectConfig> = {},
transitions: StateTransitions = DEFAULT_STATE_TRANSITIONS
) {
this.reconnectConfig = { ...DEFAULT_RECONNECT_CONFIG, ...config };
this.stateTransitions = transitions;
}
/**
* 获取当前状态信息
* @returns 完整的状态信息对象
*/
getState(): ConnectionStateInfo {
return {
state: this.state,
timestamp: this.lastStateChangeTime,
error: this.currentError || undefined,
reconnectAttempts: this.reconnectAttempts,
lastConnectedAt: this.lastConnectedTime,
description: this.getStatusDescription(),
};
}
/**
* 获取当前原始状态
* @returns 当前连接状态枚举值
*/
getRawState(): ConnectionState {
return this.state;
}
/**
* 获取上一次状态
* @returns 上一次的连接状态,如果没有则为 null
*/
getPreviousState(): ConnectionState | null {
return this.previousState;
}
/**
* 设置新状态
* @param newState 目标状态
* @param error 可选的错误信息
* @returns 是否成功转换状态
*/
setState(newState: ConnectionState, error?: Error): boolean {
// 检查状态转换是否合法
if (!this.canTransitionTo(newState)) {
console.warn(
`[ConnectionStateManager] 非法状态转换: ${this.state} -> ${newState}`
);
return false;
}
// 保存当前状态为上一状态
this.previousState = this.state;
this.state = newState;
this.lastStateChangeTime = Date.now();
// 更新错误信息
if (error) {
this.currentError = error;
} else if (newState !== ConnectionState.ERROR) {
this.currentError = null;
}
// 更新连接时间
if (newState === ConnectionState.CONNECTED) {
this.lastConnectedTime = Date.now();
this.reconnectAttempts = 0;
}
// 通知监听器
this.notifyListeners();
return true;
}
/**
* 检查是否可以转换到目标状态
* @param targetState 目标状态
* @returns 是否可以转换
*/
canTransitionTo(targetState: ConnectionState): boolean {
// 相同状态,允许(刷新状态)
if (this.state === targetState) {
return true;
}
const allowedTransitions = this.stateTransitions[this.state];
if (!allowedTransitions) {
return false;
}
return allowedTransitions.includes(targetState);
}
/**
* 订阅状态变更
* @param listener 状态变更监听器
* @returns 取消订阅函数
*/
subscribe(listener: ConnectionStateListener): () => void {
this.listeners.add(listener);
// 立即通知一次当前状态
listener(this.getState(), null);
// 返回取消订阅函数
return () => {
this.listeners.delete(listener);
};
}
/**
* 获取当前监听器数量
* @returns 监听器数量
*/
getListenerCount(): number {
return this.listeners.size;
}
/**
* 重置重连次数
*/
resetReconnectAttempts(): void {
this.reconnectAttempts = 0;
}
/**
* 增加重连次数
* @returns 增加后的重连次数
*/
incrementReconnectAttempts(): number {
this.reconnectAttempts++;
return this.reconnectAttempts;
}
/**
* 获取当前重连次数
* @returns 当前重连次数
*/
getReconnectAttempts(): number {
return this.reconnectAttempts;
}
/**
* 获取最大重连次数
* @returns 最大重连次数
*/
getMaxReconnectAttempts(): number {
return this.reconnectConfig.maxReconnectAttempts;
}
/**
* 获取最后连接时间
* @returns 最后成功连接的时间戳,如果没有则为 null
*/
getLastConnectedTime(): number | null {
return this.lastConnectedTime;
}
/**
* 获取当前错误信息
* @returns 当前的错误信息,如果没有则为 null
*/
getCurrentError(): Error | null {
return this.currentError;
}
/**
* 检查是否已连接
* @returns 是否处于 CONNECTED 状态
*/
isConnected(): boolean {
return this.state === ConnectionState.CONNECTED;
}
/**
* 检查是否正在连接
* @returns 是否处于 CONNECTING 或 RECONNECTING 状态
*/
isConnecting(): boolean {
return this.state === ConnectionState.CONNECTING || this.state === ConnectionState.RECONNECTING;
}
/**
* 获取状态描述
* @returns 当前状态的描述字符串
*/
getStatusDescription(): string {
return getStateDescription(
this.state,
this.reconnectAttempts,
this.reconnectConfig.maxReconnectAttempts
);
}
/**
* 通知所有监听器状态变更
* @private
*/
private notifyListeners(): void {
const stateInfo = this.getState();
const previousInfo = this.previousState !== null
? { ...stateInfo, state: this.previousState } as ConnectionStateInfo
: null;
this.listeners.forEach((listener) => {
try {
listener(stateInfo, previousInfo);
} catch (error) {
console.error('[ConnectionStateManager] 监听器执行失败:', error);
}
});
}
/**
* 销毁状态管理器
* 清除所有监听器和状态
*/
destroy(): void {
this.listeners.clear();
this.state = ConnectionState.IDLE;
this.previousState = null;
this.reconnectAttempts = 0;
this.currentError = null;
}
}
/**
* 默认单例实例
*/
export const connectionStateManager = new ConnectionStateManager();
export default connectionStateManager;

View File

@@ -0,0 +1,20 @@
/**
* 同步状态管理模块
*
* 提供连接状态管理的统一入口
*/
// 导出类型定义
export {
ConnectionState,
ConnectionStateInfo,
ConnectionStateListener,
ReconnectConfig,
StateTransitions,
DEFAULT_STATE_TRANSITIONS,
DEFAULT_RECONNECT_CONFIG,
getStateDescription,
} from './types';
// 导出状态管理器
export { ConnectionStateManager, connectionStateManager } from './ConnectionStateManager';

View File

@@ -0,0 +1,147 @@
/**
* 同步状态管理类型定义
*
* 提供连接状态相关的类型、接口和枚举
* 用于统一管理 SSE 连接状态
*/
/**
* 连接状态枚举
* 表示连接生命周期的各个阶段
*/
export enum ConnectionState {
/** 初始状态,未开始连接 */
IDLE = 'IDLE',
/** 正在建立连接 */
CONNECTING = 'CONNECTING',
/** 连接已建立 */
CONNECTED = 'CONNECTED',
/** 连接断开,正在重连 */
RECONNECTING = 'RECONNECTING',
/** 连接已断开 */
DISCONNECTED = 'DISCONNECTED',
/** 发生连接错误 */
ERROR = 'ERROR',
}
/**
* 连接状态信息
* 包含完整的状态信息
*/
export interface ConnectionStateInfo {
/** 当前连接状态 */
state: ConnectionState;
/** 状态变更时间戳 */
timestamp: number;
/** 错误信息(仅在 ERROR 状态下有效) */
error?: Error;
/** 当前重连次数(仅在 RECONNECTING 状态下有效) */
reconnectAttempts?: number;
/** 上次成功连接时间 */
lastConnectedAt?: number | null;
/** 状态描述(供显示用) */
description: string;
}
/**
* 连接状态监听器类型
*/
export type ConnectionStateListener = (
state: ConnectionStateInfo,
previousState: ConnectionStateInfo | null
) => void;
/**
* 重连配置接口
*/
export interface ReconnectConfig {
/** 最大重连次数 */
maxReconnectAttempts: number;
/** 初始重连延迟(毫秒) */
initialDelay: number;
/** 最大重连延迟(毫秒) */
maxDelay: number;
/** 延迟倍数(指数退避) */
backoffMultiplier: number;
}
/**
* 状态转换规则
* 定义哪些状态可以转换到哪些状态
*/
export type StateTransitions = {
[key in ConnectionState]?: ConnectionState[];
};
/**
* 默认状态转换规则
*/
export const DEFAULT_STATE_TRANSITIONS: StateTransitions = {
[ConnectionState.IDLE]: [ConnectionState.CONNECTING],
[ConnectionState.CONNECTING]: [
ConnectionState.CONNECTED,
ConnectionState.ERROR,
ConnectionState.DISCONNECTED,
],
[ConnectionState.CONNECTED]: [
ConnectionState.DISCONNECTED,
ConnectionState.ERROR,
ConnectionState.RECONNECTING,
],
[ConnectionState.RECONNECTING]: [
ConnectionState.CONNECTED,
ConnectionState.ERROR,
ConnectionState.DISCONNECTED,
],
[ConnectionState.DISCONNECTED]: [
ConnectionState.CONNECTING,
ConnectionState.RECONNECTING,
],
[ConnectionState.ERROR]: [
ConnectionState.CONNECTING,
ConnectionState.RECONNECTING,
ConnectionState.DISCONNECTED,
],
};
/**
* 默认重连配置
*/
export const DEFAULT_RECONNECT_CONFIG: ReconnectConfig = {
maxReconnectAttempts: 20,
initialDelay: 1000,
maxDelay: 30000,
backoffMultiplier: 2,
};
/**
* 获取状态的中文描述
* @param state 连接状态
* @param reconnectAttempts 当前重连次数
* @param maxReconnectAttempts 最大重连次数
* @returns 状态描述字符串
*/
export function getStateDescription(
state: ConnectionState,
reconnectAttempts?: number,
maxReconnectAttempts?: number
): string {
switch (state) {
case ConnectionState.IDLE:
return '未连接';
case ConnectionState.CONNECTING:
return '正在连接...';
case ConnectionState.CONNECTED:
return '已连接';
case ConnectionState.RECONNECTING:
return reconnectAttempts !== undefined && maxReconnectAttempts !== undefined
? `正在重连 (${reconnectAttempts}/${maxReconnectAttempts})`
: '正在重连...';
case ConnectionState.DISCONNECTED:
return '已断开连接';
case ConnectionState.ERROR:
return '连接错误';
default:
return '未知状态';
}
}

View File

@@ -0,0 +1,25 @@
/**
* 认证流程导航
* 处理登录、注册、忘记密码等认证页面
*/
import React from 'react';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import type { AuthStackParamList } from './types';
import { LoginScreen, RegisterScreen, ForgotPasswordScreen } from '../screens/auth';
const AuthStack = createNativeStackNavigator<AuthStackParamList>();
export function AuthNavigator() {
return (
<AuthStack.Navigator
screenOptions={{
headerShown: false,
}}
>
<AuthStack.Screen name="Login" component={LoginScreen} />
<AuthStack.Screen name="Register" component={RegisterScreen} />
<AuthStack.Screen name="ForgotPassword" component={ForgotPasswordScreen} />
</AuthStack.Navigator>
);
}

View File

@@ -0,0 +1,261 @@
/**
* 桌面端导航器
* 为平板/桌面设备提供侧边栏导航体验
*/
import React, { useState, useCallback, useEffect } from 'react';
import {
View,
StyleSheet,
ScrollView,
TouchableOpacity,
Text,
Animated,
Platform,
} from 'react-native';
import { useSafeAreaInsets, SafeAreaView } from 'react-native-safe-area-context';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import type { TabName, NavItemConfig } from '../infrastructure/navigation/types';
import { NAVIGATION_CONSTANTS } from '../infrastructure/navigation/types';
import { colors, shadows } from '../theme';
import { useNavigationState } from '../infrastructure/navigation/hooks/useNavigationState';
// 导入屏幕
import { HomeScreen } from '../screens/home';
import { ScheduleScreen } from '../screens/schedule';
import { MessageListScreen } from '../screens/message';
import { ProfileScreen } from '../screens/profile';
// 侧边栏常量
const { SIDEBAR_WIDTH_DESKTOP, SIDEBAR_WIDTH_TABLET, SIDEBAR_COLLAPSED_WIDTH } = NAVIGATION_CONSTANTS;
// 导航项配置
const NAV_ITEMS: NavItemConfig[] = [
{ name: 'HomeTab', label: '首页', icon: 'home', iconOutline: 'home-outline' },
{ name: 'MessageTab', label: '消息', icon: 'message-text', iconOutline: 'message-text-outline' },
{ name: 'ScheduleTab', label: '课表', icon: 'calendar-today', iconOutline: 'calendar-today' },
{ name: 'ProfileTab', label: '我的', icon: 'account', iconOutline: 'account-outline' },
];
interface DesktopNavigatorProps {
unreadCount?: number;
}
export function DesktopNavigator({ unreadCount = 0 }: DesktopNavigatorProps) {
const insets = useSafeAreaInsets();
const { currentTab, isCollapsed, setCurrentTab, toggleCollapse, setIsReady } = useNavigationState();
const [isDesktop, setIsDesktop] = useState(false);
// 检测是否是桌面尺寸
useEffect(() => {
const checkDesktop = () => {
const width = window.innerWidth || document.documentElement.clientWidth;
setIsDesktop(width >= SIDEBAR_WIDTH_DESKTOP);
};
checkDesktop();
window.addEventListener('resize', checkDesktop);
setIsReady(true);
return () => window.removeEventListener('resize', checkDesktop);
}, [setIsReady]);
// 计算侧边栏宽度
const sidebarWidth = isCollapsed ? SIDEBAR_COLLAPSED_WIDTH : (isDesktop ? SIDEBAR_WIDTH_DESKTOP : SIDEBAR_WIDTH_TABLET);
// 处理 Tab 切换
const handleTabChange = useCallback((tab: TabName) => {
setCurrentTab(tab);
}, [setCurrentTab]);
// 渲染当前 Tab 的内容
const renderTabContent = () => {
switch (currentTab) {
case 'HomeTab':
return <HomeScreen />;
case 'MessageTab':
return <MessageListScreen />;
case 'ScheduleTab':
return <ScheduleScreen />;
case 'ProfileTab':
return <ProfileScreen />;
default:
return <HomeScreen />;
}
};
return (
<View style={styles.container}>
{/* 侧边栏 */}
<SafeAreaView style={[styles.sidebar, { width: sidebarWidth, paddingTop: insets.top, paddingBottom: insets.bottom }]}>
{/* Logo 区域 */}
<View style={styles.sidebarHeader}>
<MaterialCommunityIcons name="carrot" size={32} color={colors.primary.main} />
{!isCollapsed && (
<Text style={styles.logoText}>BBS</Text>
)}
</View>
{/* 导航项 */}
<ScrollView style={styles.sidebarContent} showsVerticalScrollIndicator={false}>
{NAV_ITEMS.map((item) => {
const isActive = currentTab === item.name;
const showBadge = item.name === 'MessageTab' && unreadCount > 0;
return (
<TouchableOpacity
key={item.name}
style={[
styles.sidebarItem,
isActive && styles.sidebarItemActive,
isCollapsed && styles.sidebarItemCollapsed,
]}
onPress={() => handleTabChange(item.name)}
activeOpacity={0.7}
>
<View style={styles.sidebarIconContainer}>
<MaterialCommunityIcons
name={isActive ? item.icon : item.iconOutline}
size={24}
color={isActive ? colors.primary.main : colors.text.secondary}
/>
{showBadge && (
<View style={styles.badge}>
<Text style={styles.badgeText}>{unreadCount > 99 ? '99+' : unreadCount}</Text>
</View>
)}
</View>
{!isCollapsed && (
<Text style={[styles.sidebarLabel, isActive && styles.sidebarLabelActive]}>
{item.label}
</Text>
)}
</TouchableOpacity>
);
})}
</ScrollView>
{/* 折叠按钮 */}
<TouchableOpacity
style={[styles.collapseButton, isCollapsed && styles.collapseButtonCollapsed]}
onPress={toggleCollapse}
activeOpacity={0.7}
>
<MaterialCommunityIcons
name={isCollapsed ? 'chevron-right' : 'chevron-left'}
size={24}
color={colors.text.secondary}
/>
</TouchableOpacity>
</SafeAreaView>
{/* 主内容区域 */}
<View style={styles.mainContent}>
{renderTabContent()}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
flexDirection: 'row',
backgroundColor: colors.background.default,
},
sidebar: {
backgroundColor: colors.background.paper,
borderRightWidth: 1,
borderRightColor: colors.divider,
flexDirection: 'column',
...shadows.md,
},
sidebarHeader: {
paddingHorizontal: 16,
paddingVertical: 20,
borderBottomWidth: 1,
borderBottomColor: colors.divider,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
},
logoText: {
fontSize: 18,
fontWeight: '700',
color: colors.primary.main,
marginLeft: 8,
},
sidebarContent: {
flex: 1,
paddingTop: 8,
},
sidebarItem: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
marginHorizontal: 8,
marginVertical: 4,
borderRadius: 12,
},
sidebarItemActive: {
backgroundColor: `${colors.primary.main}15`,
},
sidebarItemCollapsed: {
justifyContent: 'center',
paddingHorizontal: 0,
},
sidebarIconContainer: {
position: 'relative',
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 12,
},
sidebarLabel: {
fontSize: 15,
fontWeight: '500',
color: colors.text.secondary,
marginLeft: 12,
flex: 1,
},
sidebarLabelActive: {
color: colors.primary.main,
fontWeight: '600',
},
collapseButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
paddingHorizontal: 16,
paddingVertical: 12,
borderTopWidth: 1,
borderTopColor: colors.divider,
},
collapseButtonCollapsed: {
justifyContent: 'center',
paddingHorizontal: 0,
},
badge: {
position: 'absolute',
top: 2,
right: 2,
backgroundColor: colors.error.main,
borderRadius: 10,
minWidth: 18,
height: 18,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 4,
},
badgeText: {
color: colors.primary.contrast,
fontSize: 10,
fontWeight: '600',
},
mainContent: {
flex: 1,
backgroundColor: colors.background.default,
},
});

View File

@@ -0,0 +1,49 @@
/**
* 首页 Stack 导航
* 处理首页相关页面:首页、搜索等
*/
import React from 'react';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import type { HomeStackParamList } from './types';
import { colors } from '../theme';
import { HomeScreen, SearchScreen } from '../screens/home';
const HomeStack = createNativeStackNavigator<HomeStackParamList>();
export function HomeNavigator() {
return (
<HomeStack.Navigator
screenOptions={{
headerStyle: {
backgroundColor: colors.background.paper,
},
headerTintColor: colors.text.primary,
headerTitleStyle: {
fontWeight: '600',
},
headerBackTitle: '',
headerShadowVisible: false,
}}
>
<HomeStack.Screen
name="Home"
component={HomeScreen}
options={{
title: '首页',
headerBackTitle: '',
headerShown: false,
}}
/>
<HomeStack.Screen
name="Search"
component={SearchScreen}
options={{
title: '搜索',
headerBackTitle: '',
headerShown: false,
}}
/>
</HomeStack.Navigator>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,60 @@
/**
* 消息 Stack 导航
* 处理消息相关页面:消息列表、通知等
*/
import React from 'react';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import type { MessageStackParamList } from './types';
import { colors } from '../theme';
import {
MessageListScreen,
NotificationsScreen,
PrivateChatInfoScreen,
} from '../screens/message';
const MessageStack = createNativeStackNavigator<MessageStackParamList>();
export function MessageNavigator() {
return (
<MessageStack.Navigator
screenOptions={{
headerStyle: {
backgroundColor: colors.background.paper,
},
headerTintColor: colors.text.primary,
headerTitleStyle: {
fontWeight: '600',
},
headerBackTitle: '',
headerShadowVisible: false,
}}
>
<MessageStack.Screen
name="MessageList"
component={MessageListScreen}
options={{
title: '消息',
headerBackTitle: '',
headerShown: false,
}}
/>
<MessageStack.Screen
name="Notifications"
component={NotificationsScreen}
options={{
title: '通知',
headerBackTitle: '',
}}
/>
<MessageStack.Screen
name="PrivateChatInfo"
component={PrivateChatInfoScreen}
options={{
title: '聊天信息',
headerBackTitle: '',
}}
/>
</MessageStack.Navigator>
);
}

View File

@@ -0,0 +1,101 @@
/**
* 个人中心 Stack 导航
* 处理个人中心相关页面
*/
import React from 'react';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import type { ProfileStackParamList } from './types';
import { colors } from '../theme';
import {
ProfileScreen,
SettingsScreen,
EditProfileScreen,
NotificationSettingsScreen,
BlockedUsersScreen,
AccountSecurityScreen,
} from '../screens/profile';
const ProfileStack = createNativeStackNavigator<ProfileStackParamList>();
export function ProfileNavigator() {
return (
<ProfileStack.Navigator
screenOptions={{
headerStyle: {
backgroundColor: colors.background.paper,
},
headerTintColor: colors.text.primary,
headerTitleStyle: {
fontWeight: '600',
},
headerBackTitle: '',
headerShadowVisible: false,
}}
>
<ProfileStack.Screen
name="Profile"
component={ProfileScreen}
options={{
headerShown: false,
}}
/>
<ProfileStack.Screen
name="Settings"
component={SettingsScreen}
options={{
title: '设置',
headerBackTitle: '',
}}
/>
<ProfileStack.Screen
name="EditProfile"
component={EditProfileScreen}
options={{
title: '编辑资料',
headerBackTitle: '',
}}
/>
<ProfileStack.Screen
name="AccountSecurity"
component={AccountSecurityScreen}
options={{
title: '账号安全',
headerBackTitle: '',
}}
/>
<ProfileStack.Screen
name="MyPosts"
component={ProfileScreen}
options={{
title: '我的帖子',
headerBackTitle: '',
}}
/>
<ProfileStack.Screen
name="Bookmarks"
component={ProfileScreen}
options={{
title: '收藏',
headerBackTitle: '',
}}
/>
<ProfileStack.Screen
name="NotificationSettings"
component={NotificationSettingsScreen}
options={{
title: '通知设置',
headerBackTitle: '',
}}
/>
<ProfileStack.Screen
name="BlockedUsers"
component={BlockedUsersScreen}
options={{
title: '黑名单',
headerBackTitle: '',
}}
/>
</ProfileStack.Navigator>
);
}

View File

@@ -0,0 +1,275 @@
/**
* 根导航器
* 处理整个应用的顶级导航,包括认证状态切换
*/
import React, { useEffect, useState } from 'react';
import { View, ActivityIndicator, StyleSheet } from 'react-native';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import type { RootStackParamList } from './types';
import { colors } from '../theme';
import { navigationService } from '../infrastructure/navigation/navigationService';
import { AuthNavigator } from './AuthNavigator';
import { SimpleMobileTabNavigator } from './SimpleMobileTabNavigator';
import { DesktopNavigator } from './DesktopNavigator';
import { useResponsive } from '../hooks';
import { useTotalUnreadCount } from '../stores';
// 导入全局屏幕组件
import { PostDetailScreen } from '../screens/home';
import { UserScreen } from '../screens/profile';
import FollowListScreen from '../screens/profile/FollowListScreen';
import { CreatePostScreen } from '../screens/create';
import {
ChatScreen,
CreateGroupScreen,
JoinGroupScreen,
GroupInfoScreen,
GroupMembersScreen,
GroupRequestDetailScreen,
GroupInviteDetailScreen,
PrivateChatInfoScreen,
} from '../screens/message';
const RootStack = createNativeStackNavigator<RootStackParamList>();
interface RootNavigatorProps {
isAuthenticated: boolean;
isInitializing: boolean;
}
// 未认证时可访问的屏幕 - 返回数组而不是 JSX
const getPublicScreens = () => [
<RootStack.Screen
key="PostDetail"
name="PostDetail"
component={PostDetailScreen}
options={{
headerShown: true,
title: '',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
animation: 'slide_from_right',
}}
/>,
<RootStack.Screen
key="UserProfile"
name="UserProfile"
component={UserScreen}
options={{
headerShown: true,
title: '用户主页',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
}}
/>,
];
// 认证后可访问的屏幕 - 返回数组而不是 JSX
const getAuthenticatedScreens = () => [
<RootStack.Screen
key="PostDetail"
name="PostDetail"
component={PostDetailScreen}
options={{
headerShown: true,
title: '',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
animation: 'slide_from_right',
}}
/>,
<RootStack.Screen
key="UserProfile"
name="UserProfile"
component={UserScreen}
options={{
headerShown: true,
title: '用户主页',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
}}
/>,
<RootStack.Screen
key="CreatePost"
name="CreatePost"
component={CreatePostScreen}
options={{
headerShown: true,
title: '发布帖子',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
presentation: 'modal',
}}
/>,
<RootStack.Screen
key="Chat"
name="Chat"
component={ChatScreen}
options={{
headerShown: false,
animation: 'slide_from_right',
}}
/>,
<RootStack.Screen
key="FollowList"
name="FollowList"
component={FollowListScreen}
options={{
headerShown: true,
title: '关注列表',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
animation: 'slide_from_right',
}}
/>,
<RootStack.Screen
key="CreateGroup"
name="CreateGroup"
component={CreateGroupScreen}
options={{
headerShown: true,
title: '创建群聊',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
presentation: 'modal',
}}
/>,
<RootStack.Screen
key="JoinGroup"
name="JoinGroup"
component={JoinGroupScreen}
options={{
headerShown: true,
title: '搜索群聊',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
animation: 'slide_from_right',
}}
/>,
<RootStack.Screen
key="GroupInfo"
name="GroupInfo"
component={GroupInfoScreen}
options={{
headerShown: true,
title: '群信息',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
animation: 'slide_from_right',
}}
/>,
<RootStack.Screen
key="GroupMembers"
name="GroupMembers"
component={GroupMembersScreen}
options={{
headerShown: true,
title: '群成员',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
animation: 'slide_from_right',
}}
/>,
<RootStack.Screen
key="GroupRequestDetail"
name="GroupRequestDetail"
component={GroupRequestDetailScreen}
options={{
headerShown: true,
title: '入群审批',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
animation: 'slide_from_right',
}}
/>,
<RootStack.Screen
key="GroupInviteDetail"
name="GroupInviteDetail"
component={GroupInviteDetailScreen}
options={{
headerShown: true,
title: '群聊邀请',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
animation: 'slide_from_right',
}}
/>,
<RootStack.Screen
key="PrivateChatInfo"
name="PrivateChatInfo"
component={PrivateChatInfoScreen}
options={{
headerShown: true,
title: '聊天信息',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
animation: 'slide_from_right',
}}
/>,
];
export function RootNavigator({ isAuthenticated, isInitializing }: RootNavigatorProps) {
const { isMobile } = useResponsive();
const unreadCount = useTotalUnreadCount();
// 设置导航引用
const setNavigationRef = (ref: any) => {
navigationService.setNavigationRef(ref);
};
// 加载中显示
if (isInitializing) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary.main} />
</View>
);
}
return (
<NavigationContainer ref={setNavigationRef}>
<RootStack.Navigator
screenOptions={{
headerShown: false,
}}
>
{isAuthenticated ? (
<>
<RootStack.Screen name="Main">
{() =>
isMobile ? (
<SimpleMobileTabNavigator />
) : (
<DesktopNavigator unreadCount={unreadCount} />
)
}
</RootStack.Screen>
{getAuthenticatedScreens()}
</>
) : (
<>
<RootStack.Screen
name="Auth"
component={AuthNavigator}
options={{ headerShown: false }}
/>
{getPublicScreens()}
</>
)}
</RootStack.Navigator>
</NavigationContainer>
);
}
const styles = StyleSheet.create({
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.background.default,
},
});

View File

@@ -0,0 +1,42 @@
/**
* 课程表 Stack 导航
* 处理课程表相关页面
*/
import React from 'react';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import type { ScheduleStackParamList } from './types';
import { colors } from '../theme';
import { ScheduleScreen, CourseDetailScreen } from '../screens/schedule';
const ScheduleStack = createNativeStackNavigator<ScheduleStackParamList>();
export function ScheduleNavigator() {
return (
<ScheduleStack.Navigator
screenOptions={{
headerShown: false,
}}
>
<ScheduleStack.Screen
name="Schedule"
component={ScheduleScreen}
options={{
title: '课表',
}}
/>
<ScheduleStack.Screen
name="CourseDetail"
component={CourseDetailScreen}
options={{
headerShown: false,
presentation: 'transparentModal',
animation: 'fade',
contentStyle: {
backgroundColor: 'transparent',
},
}}
/>
</ScheduleStack.Navigator>
);
}

View File

@@ -0,0 +1,155 @@
/**
* Tab 导航器
* 处理底部标签导航(移动端)
*/
import React from 'react';
import { View } from 'react-native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import type { MainTabParamList } from './types';
import { colors, shadows } from '../theme';
import { HomeNavigator } from './HomeNavigator';
import { MessageNavigator } from './MessageNavigator';
import { ScheduleNavigator } from './ScheduleNavigator';
import { ProfileNavigator } from './ProfileNavigator';
const Tab = createBottomTabNavigator<MainTabParamList>();
// 常量
const MOBILE_TAB_FLOATING_MARGIN = 12;
interface TabNavigatorProps {
unreadCount: number;
}
export function TabNavigator({ unreadCount }: TabNavigatorProps) {
const insets = useSafeAreaInsets();
return (
<Tab.Navigator
screenOptions={{
tabBarActiveTintColor: colors.primary.main,
tabBarInactiveTintColor: colors.text.secondary,
tabBarHideOnKeyboard: true,
tabBarStyle: {
backgroundColor: colors.background.paper,
borderTopWidth: 1,
borderTopColor: `${colors.divider}88`,
borderRadius: 24,
marginHorizontal: 14,
marginBottom: MOBILE_TAB_FLOATING_MARGIN + insets.bottom,
height: 64,
paddingBottom: 6,
paddingTop: 8,
paddingHorizontal: 8,
position: 'absolute',
...shadows.lg,
},
tabBarItemStyle: {
borderRadius: 18,
paddingVertical: 1,
marginHorizontal: 2,
},
tabBarLabelStyle: {
fontSize: 12,
fontWeight: '600',
marginTop: -2,
letterSpacing: 0.2,
},
tabBarBadgeStyle: {
backgroundColor: colors.error.main,
color: colors.primary.contrast,
fontSize: 10,
fontWeight: '700',
top: 4,
},
headerShown: false,
}}
>
<Tab.Screen
name="HomeTab"
component={HomeNavigator}
options={{
tabBarLabel: '首页',
tabBarIcon: ({ color, size, focused }) => (
<View style={[styles.tabIconContainer, focused && styles.tabIconActive]}>
<MaterialCommunityIcons
name={focused ? 'home' : 'home-outline'}
size={focused ? 26 : 24}
color={color}
/>
</View>
),
}}
/>
<Tab.Screen
name="MessageTab"
component={MessageNavigator}
options={{
tabBarLabel: '消息',
tabBarBadge: unreadCount > 0 ? (unreadCount > 99 ? '99+' : unreadCount) : undefined,
tabBarIcon: ({ color, size, focused }) => (
<View style={[styles.tabIconContainer, focused && styles.tabIconActive]}>
<MaterialCommunityIcons
name={focused ? 'message-text' : 'message-text-outline'}
size={focused ? 26 : 24}
color={color}
/>
</View>
),
}}
/>
<Tab.Screen
name="ScheduleTab"
component={ScheduleNavigator}
options={{
tabBarLabel: '课表',
tabBarIcon: ({ color, size, focused }) => (
<View style={[styles.tabIconContainer, focused && styles.tabIconActive]}>
<MaterialCommunityIcons
name={focused ? 'calendar-today' : 'calendar-today'}
size={focused ? 26 : 24}
color={color}
/>
</View>
),
}}
/>
<Tab.Screen
name="ProfileTab"
component={ProfileNavigator}
options={{
tabBarLabel: '我的',
tabBarIcon: ({ color, size, focused }) => (
<View style={[styles.tabIconContainer, focused && styles.tabIconActive]}>
<MaterialCommunityIcons
name={focused ? 'account' : 'account-outline'}
size={focused ? 26 : 24}
color={color}
/>
</View>
),
}}
/>
</Tab.Navigator>
);
}
import { StyleSheet } from 'react-native';
const styles = StyleSheet.create({
tabIconContainer: {
alignItems: 'center',
justifyContent: 'center',
width: 38,
height: 30,
borderRadius: 15,
},
tabIconActive: {
backgroundColor: `${colors.primary.main}20`,
transform: [{ translateY: -1 }],
},
});

30
src/navigation/index.ts Normal file
View File

@@ -0,0 +1,30 @@
/**
* 导出所有导航组件
*/
// 类型
export type {
RootStackParamList,
MainTabParamList,
HomeStackParamList,
MessageStackParamList,
ScheduleStackParamList,
ProfileStackParamList,
AuthStackParamList,
TabName,
NavItemConfig,
} from './types';
// 导航器
export { AuthNavigator } from './AuthNavigator';
export { HomeNavigator } from './HomeNavigator';
export { MessageNavigator } from './MessageNavigator';
export { ScheduleNavigator } from './ScheduleNavigator';
export { ProfileNavigator } from './ProfileNavigator';
export { TabNavigator } from './TabNavigator';
export { DesktopNavigator } from './DesktopNavigator';
export { RootNavigator } from './RootNavigator';
export { MainNavigator } from './MainNavigator';
// 移动端简化导航
export { SimpleMobileTabNavigator } from './SimpleMobileTabNavigator';

View File

@@ -121,3 +121,14 @@ export type MessageScreenNames = keyof MessageStackParamList;
export type ProfileScreenNames = keyof ProfileStackParamList;
export type MainTabScreenNames = keyof MainTabParamList;
export type RootScreenNames = keyof RootStackParamList;
// ==================== Tab 类型 ====================
export type TabName = 'HomeTab' | 'MessageTab' | 'ScheduleTab' | 'ProfileTab';
// ==================== 导航项配置 ====================
export interface NavItemConfig {
name: TabName;
label: string;
icon: string;
iconOutline: string;
}

View File

@@ -0,0 +1,223 @@
# useResponsive 拆分迁移指南
## 概述
原有的 `useResponsive.ts` (485行) 已被拆分为多个专注的 hooks以提高代码的可维护性和性能。
## 新目录结构
```
src/presentation/hooks/responsive/
├── types.ts # 共享类型定义
├── useBreakpoint.ts # 断点检测
├── useScreenSize.ts # 屏幕尺寸
├── useOrientation.ts # 方向检测
├── usePlatform.ts # 平台检测
├── useResponsiveValue.ts # 响应式值映射
├── useResponsiveSpacing.ts # 响应式间距
├── useColumnCount.ts # 列数计算
├── useMediaQuery.ts # 媒体查询
├── useBreakpointCheck.ts # 断点范围检查 (兼容层)
├── useResponsive.ts # 兼容层 (保持向后兼容)
└── index.ts # 统一导出
```
## 新 Hook API 说明
### useBreakpoint - 断点检测
```typescript
import { useBreakpoint, useFineBreakpoint } from '../presentation/hooks/responsive';
// 基础断点: 'mobile' | 'tablet' | 'desktop' | 'wide'
const breakpoint = useBreakpoint();
// 细粒度断点: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl'
const fineBreakpoint = useFineBreakpoint();
```
### useScreenSize - 屏幕尺寸
```typescript
import { useScreenSize, useWindowDimensions } from '../presentation/hooks/responsive';
// 完整屏幕尺寸信息
const {
width, height,
breakpoint, fineBreakpoint,
isMobile, isTablet, isDesktop, isWide
} = useScreenSize();
// 仅获取窗口尺寸
const { width, height, scale, fontScale } = useWindowDimensions();
```
### useOrientation - 方向检测
```typescript
import { useOrientation } from '../presentation/hooks/responsive';
const { orientation, isPortrait, isLandscape } = useOrientation();
```
### usePlatform - 平台检测
```typescript
import { usePlatform } from '../presentation/hooks/responsive';
const { OS, isWeb, isIOS, isAndroid, isNative } = usePlatform();
```
### useResponsiveValue - 响应式值
```typescript
import { useResponsiveValue } from '../presentation/hooks/responsive';
// 根据断点返回不同值
const padding = useResponsiveValue({ xs: 8, md: 16, lg: 24 });
// 响应式样式
const style = useResponsiveStyle({
padding: { xs: 8, md: 16, lg: 24 },
fontSize: { xs: 14, lg: 16 }
});
```
### useResponsiveSpacing - 响应式间距
```typescript
import { useResponsiveSpacing } from '../presentation/hooks/responsive';
const gap = useResponsiveSpacing({ xs: 8, md: 16, lg: 24 });
```
### useColumnCount - 列数计算
```typescript
import { useColumnCount } from '../presentation/hooks/responsive';
const columns = useColumnCount({ xs: 1, sm: 2, md: 3, lg: 4 });
```
### useBreakpointGTE / useBreakpointLT - 断点范围检查
```typescript
import { useBreakpointGTE, useBreakpointLT, useBreakpointBetween } from '../presentation/hooks/responsive';
const isMediumUp = useBreakpointGTE('md');
const isMobileOnly = useBreakpointLT('lg');
const isTabletRange = useBreakpointBetween('md', 'lg');
```
### useMediaQuery - 媒体查询
```typescript
import { useMediaQuery } from '../presentation/hooks/responsive';
const isMinWidth768 = useMediaQuery({ minWidth: 768 });
const isPortrait = useMediaQuery({ orientation: 'portrait' });
```
## 迁移示例
### 示例 1: 使用 useResponsive (旧)
```typescript
import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../hooks';
function Component() {
const { width, isMobile, isTablet, isDesktop, isWide } = useResponsive();
const padding = useResponsiveSpacing({ xs: 8, md: 16 });
return <View style={{ padding }} />;
}
```
### 示例 2: 使用新专注 hooks (推荐)
```typescript
import {
useScreenSize,
useResponsiveSpacing
} from '../presentation/hooks/responsive';
function Component() {
const { width, isMobile, isTablet, isDesktop, isWide } = useScreenSize();
const padding = useResponsiveSpacing({ xs: 8, md: 16 });
return <View style={{ padding }} />;
}
```
### 示例 3: 仅使用部分功能 (性能优化)
```typescript
import { useBreakpoint, usePlatform } from '../presentation/hooks/responsive';
function Component() {
// 只订阅断点变化,不订阅尺寸变化
const breakpoint = useBreakpoint();
const { isIOS } = usePlatform(); // 平台不会变化,只计算一次
return <View />;
}
```
## 性能优势
1. **按需订阅**: 只使用需要的功能,避免不必要的重渲染
2. **细粒度更新**: 每个 hook 独立管理状态
3. **平台常量**: usePlatform 返回常量,不会触发重渲染
## 向后兼容
原有的 `useResponsive` 仍然可用,但标记为 `@deprecated`
```typescript
import { useResponsive } from '../hooks'; // 仍然可用
function Component() {
const { width, height, isMobile, platform } = useResponsive(); // 仍然可用
return <View />;
}
```
## 工具函数
```typescript
import {
getBreakpoint,
getFineBreakpoint,
isBreakpointGTE,
isBreakpointLT
} from '../presentation/hooks/responsive';
// 非 hooks 版本,用于工具函数
const breakpoint = getBreakpoint(800);
const fineBreakpoint = getFineBreakpoint(800);
const isGTE = isBreakpointGTE('md', 'sm');
```
## 类型导出
```typescript
import type {
BreakpointKey,
FineBreakpointKey,
ResponsiveValue,
Orientation,
PlatformInfo,
ScreenSize,
MediaQueryOptions,
ResponsiveInfo, // 兼容层
} from '../presentation/hooks/responsive';
```
## 常量
```typescript
import { BREAKPOINTS, FINE_BREAKPOINTS } from '../presentation/hooks/responsive';
// BREAKPOINTS = { mobile: 0, tablet: 768, desktop: 1024, wide: 1440 }
// FINE_BREAKPOINTS = { xs: 0, sm: 375, md: 414, lg: 768, xl: 1024, '2xl': 1280, '3xl': 1440, '4xl': 1920 }
```

View File

@@ -0,0 +1,66 @@
/**
* 响应式 Hooks 统一导出
* Responsive Hooks Index
*
* 提供完整的响应式设计解决方案,包括:
* - 断点检测 (useBreakpoint)
* - 屏幕尺寸 (useScreenSize, useWindowDimensions)
* - 响应式值 (useResponsiveValue, useResponsiveStyle)
* - 方向检测 (useOrientation)
* - 平台检测 (usePlatform)
* - 媒体查询 (useMediaQuery)
* - 列数计算 (useColumnCount)
* - 间距计算 (useResponsiveSpacing)
* - 断点检查 (useBreakpointGTE, useBreakpointLT, useBreakpointBetween)
*/
// ==================== 核心 Hooks ====================
export { useWindowDimensions, useScreenSize } from './useScreenSize';
export {
useBreakpoint,
useFineBreakpoint,
useBreakpointGTE,
useBreakpointLT,
useBreakpointBetween,
} from './useBreakpoint';
export { useResponsiveValue, useResponsiveStyle } from './useResponsiveValue';
export { useOrientation } from './useOrientation';
export { usePlatform } from './usePlatform';
export { useMediaQuery } from './useMediaQuery';
export { useColumnCount } from './useColumnCount';
export { useResponsiveSpacing } from './useResponsiveSpacing';
// ==================== 工具函数 ====================
export {
getBreakpoint,
getFineBreakpoint,
isBreakpointGTE,
isBreakpointLT,
isBreakpointBetween,
} from './useBreakpoint';
// ==================== 类型 ====================
export type {
BreakpointKey,
FineBreakpointKey,
BreakpointValue,
ResponsiveValue,
Orientation,
PlatformInfo,
ScreenSize,
MediaQueryOptions,
} from './types';
export {
BREAKPOINTS,
FINE_BREAKPOINTS,
} from './types';
// 兼容层类型
export type { ResponsiveInfo } from './useResponsive';
// ==================== 向后兼容 ====================
export { useResponsive, useLegacyResponsive } from './useResponsive';
// 默认导出
export { useScreenSize as default } from './useScreenSize';

View File

@@ -0,0 +1,62 @@
/**
* 响应式相关类型定义
*/
// ==================== 断点定义 ====================
export const BREAKPOINTS = {
mobile: 0, // 手机
tablet: 768, // 平板竖屏
desktop: 1024, // 平板横屏/桌面
wide: 1440, // 宽屏桌面
} as const;
export type BreakpointKey = keyof typeof BREAKPOINTS;
export type BreakpointValue = typeof BREAKPOINTS[BreakpointKey];
// ==================== 更细粒度的断点定义 ====================
export const FINE_BREAKPOINTS = {
xs: 0, // 超小屏手机
sm: 375, // 小屏手机 (iPhone SE, 小屏安卓)
md: 414, // 中屏手机 (iPhone Pro Max, 大屏安卓)
lg: 768, // 平板竖屏 / 大折叠屏手机展开
xl: 1024, // 平板横屏 / 小桌面
'2xl': 1280, // 桌面
'3xl': 1440, // 大桌面
'4xl': 1920, // 超大屏
} as const;
export type FineBreakpointKey = keyof typeof FINE_BREAKPOINTS;
// ==================== 响应式值类型 ====================
export type ResponsiveValue<T> = T | Partial<Record<FineBreakpointKey, T>>;
// ==================== 方向类型 ====================
export type Orientation = 'portrait' | 'landscape';
// ==================== 平台类型 ====================
export interface PlatformInfo {
OS: 'ios' | 'android' | 'windows' | 'macos' | 'web';
isWeb: boolean;
isIOS: boolean;
isAndroid: boolean;
isNative: boolean;
}
// ==================== 屏幕尺寸类型 ====================
export interface ScreenSize {
width: number;
height: number;
isMobile: boolean;
isTablet: boolean;
isDesktop: boolean;
isWide: boolean;
}
// ==================== 媒体查询选项 ====================
export interface MediaQueryOptions {
minWidth?: number;
maxWidth?: number;
minHeight?: number;
maxHeight?: number;
orientation?: Orientation;
}

View File

@@ -0,0 +1,177 @@
/**
* useBreakpoint Hook
* 断点检测 - 检测当前断点
*/
import { useMemo } from 'react';
import { useWindowDimensions } from './useScreenSize';
import { BREAKPOINTS, FINE_BREAKPOINTS } from './types';
import type { BreakpointKey, FineBreakpointKey } from './types';
// ==================== 工具函数 ====================
/**
* 获取当前断点
*/
export function getBreakpoint(width: number): BreakpointKey {
if (width >= BREAKPOINTS.wide) {
return 'wide';
}
if (width >= BREAKPOINTS.desktop) {
return 'desktop';
}
if (width >= BREAKPOINTS.tablet) {
return 'tablet';
}
return 'mobile';
}
/**
* 获取细粒度断点
*/
export function getFineBreakpoint(width: number): FineBreakpointKey {
if (width >= FINE_BREAKPOINTS['4xl']) return '4xl';
if (width >= FINE_BREAKPOINTS['3xl']) return '3xl';
if (width >= FINE_BREAKPOINTS['2xl']) return '2xl';
if (width >= FINE_BREAKPOINTS.xl) return 'xl';
if (width >= FINE_BREAKPOINTS.lg) return 'lg';
if (width >= FINE_BREAKPOINTS.md) return 'md';
if (width >= FINE_BREAKPOINTS.sm) return 'sm';
return 'xs';
}
/**
* 断点比较 - 检查当前断点是否大于等于目标断点
*/
export function isBreakpointGTE(
current: FineBreakpointKey,
target: FineBreakpointKey
): boolean {
const order: FineBreakpointKey[] = ['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl'];
return order.indexOf(current) >= order.indexOf(target);
}
/**
* 断点比较 - 检查当前断点是否小于目标断点
*/
export function isBreakpointLT(
current: FineBreakpointKey,
target: FineBreakpointKey
): boolean {
return !isBreakpointGTE(current, target);
}
/**
* 检查当前是否在指定断点范围内
*/
export function isBreakpointBetween(
current: FineBreakpointKey,
min: FineBreakpointKey,
max: FineBreakpointKey
): boolean {
return isBreakpointGTE(current, min) && isBreakpointLT(current, max);
}
// ==================== Hooks ====================
/**
* 断点检测 Hook
* 返回当前的基础断点
*
* @returns 当前断点
*
* @example
* const breakpoint = useBreakpoint();
* // 'mobile' | 'tablet' | 'desktop' | 'wide'
*/
export function useBreakpoint(): BreakpointKey {
const { width } = useWindowDimensions();
return useMemo(() => {
return getBreakpoint(width);
}, [width]);
}
/**
* 细粒度断点检测 Hook
* 返回当前的细粒度断点
*
* @returns 当前细粒度断点
*
* @example
* const fineBreakpoint = useFineBreakpoint();
* // 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl'
*/
export function useFineBreakpoint(): FineBreakpointKey {
const { width } = useWindowDimensions();
return useMemo(() => {
return getFineBreakpoint(width);
}, [width]);
}
/**
* 检查当前断点是否大于等于目标断点
*
* @param target - 目标断点
* @returns 是否满足条件
*
* @example
* const isMediumUp = useBreakpointGTE('md');
*/
export function useBreakpointGTE(target: FineBreakpointKey): boolean {
const { width } = useWindowDimensions();
return useMemo(() => {
const current = getFineBreakpoint(width);
return isBreakpointGTE(current, target);
}, [width, target]);
}
/**
* 检查当前断点是否小于目标断点
*
* @param target - 目标断点
* @returns 是否满足条件
*
* @example
* const isMobileOnly = useBreakpointLT('lg');
*/
export function useBreakpointLT(target: FineBreakpointKey): boolean {
const { width } = useWindowDimensions();
return useMemo(() => {
const current = getFineBreakpoint(width);
return isBreakpointLT(current, target);
}, [width, target]);
}
/**
* 检查当前是否在指定断点范围内
*
* @param min - 最小断点(包含)
* @param max - 最大断点(不包含)
* @returns 是否在范围内
*
* @example
* const isTabletRange = useBreakpointBetween('md', 'lg');
*/
export function useBreakpointBetween(
min: FineBreakpointKey,
max: FineBreakpointKey
): boolean {
const { width } = useWindowDimensions();
return useMemo(() => {
const current = getFineBreakpoint(width);
return isBreakpointBetween(current, min, max);
}, [width, min, max]);
}
export default {
useBreakpoint,
useFineBreakpoint,
useBreakpointGTE,
useBreakpointLT,
useBreakpointBetween,
};

View File

@@ -0,0 +1,7 @@
/**
* useBreakpointCheck Hook
* 断点检查 - 提供断点范围检查功能
* @deprecated 请直接使用 useBreakpoint.ts 中的 hooks
*/
export { useBreakpointGTE, useBreakpointLT, useBreakpointBetween } from './useBreakpoint';

View File

@@ -0,0 +1,59 @@
/**
* useColumnCount Hook
* 列数计算 - 根据容器宽度计算合适的列数
*/
import { useMemo } from 'react';
import { useWindowDimensions } from './useScreenSize';
import { getFineBreakpoint } from './useBreakpoint';
import type { FineBreakpointKey } from './types';
const breakpointOrder: FineBreakpointKey[] = ['4xl', '3xl', '2xl', 'xl', 'lg', 'md', 'sm', 'xs'];
const defaultConfig: Record<FineBreakpointKey, number> = {
xs: 1,
sm: 1,
md: 2,
lg: 3,
xl: 4,
'2xl': 4,
'3xl': 5,
'4xl': 6,
};
/**
* 根据容器宽度计算合适的列数
*
* @param columnConfig - 列数配置
* @returns 列数
*
* @example
* const columns = useColumnCount({
* xs: 1,
* sm: 2,
* md: 3,
* lg: 4
* });
*/
export function useColumnCount(
columnConfig: Partial<Record<FineBreakpointKey, number>> = {}
): number {
const { width } = useWindowDimensions();
const fineBreakpoint = getFineBreakpoint(width);
return useMemo(() => {
const config = { ...defaultConfig, ...columnConfig };
const currentIndex = breakpointOrder.indexOf(fineBreakpoint);
for (let i = currentIndex; i < breakpointOrder.length; i++) {
const bp = breakpointOrder[i];
if (config[bp] !== undefined) {
return config[bp];
}
}
return 1;
}, [fineBreakpoint, columnConfig]);
}
export default useColumnCount;

View File

@@ -0,0 +1,36 @@
/**
* useMediaQuery Hook
* 媒体查询模拟 - 模拟 CSS 媒体查询
*/
import { useMemo } from 'react';
import { useWindowDimensions } from './useScreenSize';
import { getOrientation } from './useOrientation';
import type { MediaQueryOptions } from './types';
/**
* 模拟 CSS 媒体查询
*
* @param query - 查询条件
* @returns 是否匹配
*
* @example
* const isMinWidth768 = useMediaQuery({ minWidth: 768 });
* const isMaxWidth1024 = useMediaQuery({ maxWidth: 1024 });
* const isPortrait = useMediaQuery({ orientation: 'portrait' });
*/
export function useMediaQuery(query: MediaQueryOptions): boolean {
const { width, height } = useWindowDimensions();
const currentOrientation = getOrientation(width, height);
return useMemo(() => {
if (query.minWidth !== undefined && width < query.minWidth) return false;
if (query.maxWidth !== undefined && width > query.maxWidth) return false;
if (query.minHeight !== undefined && height < query.minHeight) return false;
if (query.maxHeight !== undefined && height > query.maxHeight) return false;
if (query.orientation !== undefined && currentOrientation !== query.orientation) return false;
return true;
}, [width, height, currentOrientation, query]);
}
export default useMediaQuery;

View File

@@ -0,0 +1,43 @@
/**
* useOrientation Hook
* 方向检测 - 检测设备方向
*/
import { useMemo } from 'react';
import { useWindowDimensions } from './useScreenSize';
import type { Orientation } from './types';
/**
* 获取屏幕方向
*/
export function getOrientation(width: number, height: number): Orientation {
return width > height ? 'landscape' : 'portrait';
}
/**
* 方向检测 Hook
* 提供便捷的方向检测方法
*
* @returns 方向信息对象
*
* @example
* const { orientation, isPortrait, isLandscape } = useOrientation();
*/
export function useOrientation(): {
orientation: Orientation;
isPortrait: boolean;
isLandscape: boolean;
} {
const { width, height } = useWindowDimensions();
return useMemo(() => {
const orientation = getOrientation(width, height);
return {
orientation,
isPortrait: orientation === 'portrait',
isLandscape: orientation === 'landscape',
};
}, [width, height]);
}
export default useOrientation;

View File

@@ -0,0 +1,29 @@
/**
* usePlatform Hook
* 平台检测 - 检测当前运行平台
*/
import { useMemo } from 'react';
import { Platform } from 'react-native';
import type { PlatformInfo } from './types';
/**
* 平台检测 Hook
* 提供便捷的平台检测方法
*
* @returns 平台信息对象
*
* @example
* const { isWeb, isIOS, isAndroid, isNative } = usePlatform();
*/
export function usePlatform(): PlatformInfo {
return useMemo(() => ({
OS: Platform.OS as PlatformInfo['OS'],
isWeb: Platform.OS === 'web',
isIOS: Platform.OS === 'ios',
isAndroid: Platform.OS === 'android',
isNative: Platform.OS !== 'web',
}), []);
}
export default usePlatform;

View File

@@ -0,0 +1,112 @@
/**
* useResponsive Hook (兼容层)
*
* 此文件是为了保持向后兼容性而保留的,整合所有响应式功能。
*
* 推荐使用新的专注 hooks
* - useBreakpoint() - 断点检测
* - useScreenSize() - 屏幕尺寸
* - useOrientation() - 方向检测
* - usePlatform() - 平台检测
* - useResponsiveValue() - 响应式值
*/
import { useMemo } from 'react';
import { useWindowDimensions } from './useScreenSize';
import { getBreakpoint, getFineBreakpoint } from './useBreakpoint';
import { getOrientation } from './useOrientation';
import { usePlatform } from './usePlatform';
import type { BreakpointKey, FineBreakpointKey, Orientation, PlatformInfo } from './types';
// ==================== 返回值类型 ====================
export interface ResponsiveInfo {
// 基础尺寸
width: number;
height: number;
// 基础断点
breakpoint: BreakpointKey;
isMobile: boolean;
isTablet: boolean;
isDesktop: boolean;
isWide: boolean;
isWideScreen: boolean;
// 细粒度断点
fineBreakpoint: FineBreakpointKey;
isXS: boolean;
isSM: boolean;
isMD: boolean;
isLG: boolean;
isXL: boolean;
is2XL: boolean;
is3XL: boolean;
is4XL: boolean;
// 方向
orientation: Orientation;
isPortrait: boolean;
isLandscape: boolean;
// 平台检测
platform: PlatformInfo;
}
/**
* 响应式设计 Hook (兼容层)
* 提供屏幕尺寸、断点、方向、平台检测等响应式信息
*
* @deprecated 推荐使用新的专注 hooks
*
* @returns ResponsiveInfo 响应式信息对象
*
* @example
* const {
* width, height,
* breakpoint, isMobile, isTablet, isDesktop, isWide,
* fineBreakpoint, isXS, isSM, isMD, isLG, isXL,
* orientation, isPortrait, isLandscape,
* platform: { isWeb, isIOS, isAndroid }
* } = useResponsive();
*/
export function useResponsive(): ResponsiveInfo {
const { width, height } = useWindowDimensions();
const platform = usePlatform();
return useMemo(() => {
const breakpoint = getBreakpoint(width);
const fineBreakpoint = getFineBreakpoint(width);
const orientation = getOrientation(width, height);
return {
width,
height,
breakpoint,
isMobile: breakpoint === 'mobile',
isTablet: breakpoint === 'tablet',
isDesktop: breakpoint === 'desktop',
isWide: breakpoint === 'wide',
isWideScreen: breakpoint === 'tablet' || breakpoint === 'desktop' || breakpoint === 'wide',
fineBreakpoint,
isXS: fineBreakpoint === 'xs',
isSM: fineBreakpoint === 'sm',
isMD: fineBreakpoint === 'md',
isLG: fineBreakpoint === 'lg',
isXL: fineBreakpoint === 'xl',
is2XL: fineBreakpoint === '2xl',
is3XL: fineBreakpoint === '3xl',
is4XL: fineBreakpoint === '4xl',
orientation,
isPortrait: orientation === 'portrait',
isLandscape: orientation === 'landscape',
platform,
};
}, [width, height, platform]);
}
/**
* 旧的 useResponsive 别名,保持完全向后兼容
*/
export const useLegacyResponsive = useResponsive;
export default useResponsive;

View File

@@ -0,0 +1,54 @@
/**
* useResponsiveSpacing Hook
* 响应式间距 - 根据断点返回对应的间距值
*/
import { useMemo } from 'react';
import { useWindowDimensions } from './useScreenSize';
import { getFineBreakpoint } from './useBreakpoint';
import type { FineBreakpointKey } from './types';
const breakpointOrder: FineBreakpointKey[] = ['4xl', '3xl', '2xl', 'xl', 'lg', 'md', 'sm', 'xs'];
const defaultConfig: Record<FineBreakpointKey, number> = {
xs: 8,
sm: 8,
md: 12,
lg: 16,
xl: 20,
'2xl': 24,
'3xl': 32,
'4xl': 40,
};
/**
* 响应式间距 Hook
*
* @param spacingConfig - 间距配置
* @returns 当前断点对应的间距值
*
* @example
* const gap = useResponsiveSpacing({ xs: 8, md: 16, lg: 24 });
*/
export function useResponsiveSpacing(
spacingConfig: Partial<Record<FineBreakpointKey, number>> = {}
): number {
const { width } = useWindowDimensions();
const fineBreakpoint = getFineBreakpoint(width);
return useMemo(() => {
const config = { ...defaultConfig, ...spacingConfig };
const currentIndex = breakpointOrder.indexOf(fineBreakpoint);
for (let i = currentIndex; i < breakpointOrder.length; i++) {
const bp = breakpointOrder[i];
if (config[bp] !== undefined) {
return config[bp];
}
}
return defaultConfig.xs;
}, [fineBreakpoint, spacingConfig]);
}
export default useResponsiveSpacing;

View File

@@ -0,0 +1,95 @@
/**
* useResponsiveValue Hook
* 响应式值映射 - 根据当前断点返回对应值
*/
import { useMemo } from 'react';
import { useWindowDimensions } from './useScreenSize';
import { getFineBreakpoint } from './useBreakpoint';
import type { FineBreakpointKey, ResponsiveValue } from './types';
const breakpointOrder: FineBreakpointKey[] = ['4xl', '3xl', '2xl', 'xl', 'lg', 'md', 'sm', 'xs'];
/**
* 根据当前断点从响应式值对象中选择合适的值
*
* @param value - 响应式值,可以是单一值或断点映射对象
* @returns 选中的值
*
* @example
* const padding = useResponsiveValue({ xs: 8, md: 16, lg: 24 });
* // 在 xs 屏幕返回 8md 屏幕返回 16lg 及以上返回 24
*/
export function useResponsiveValue<T>(value: ResponsiveValue<T>): T {
const { width } = useWindowDimensions();
const fineBreakpoint = getFineBreakpoint(width);
return useMemo(() => {
// 如果不是对象,直接返回
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return value as T;
}
const valueMap = value as Partial<Record<FineBreakpointKey, T>>;
// 从当前断点开始向下查找
const currentIndex = breakpointOrder.indexOf(fineBreakpoint);
for (let i = currentIndex; i < breakpointOrder.length; i++) {
const bp = breakpointOrder[i];
if (bp in valueMap) {
return valueMap[bp]!;
}
}
// 如果没找到,返回 xs 的值或第一个值
return (valueMap.xs ?? Object.values(valueMap)[0]) as T;
}, [value, fineBreakpoint]);
}
/**
* 响应式样式生成器
* 根据断点生成响应式样式
*
* @param styles - 响应式样式对象
* @returns 当前断点对应的样式
*
* @example
* const containerStyle = useResponsiveStyle({
* padding: { xs: 8, md: 16, lg: 24 },
* fontSize: { xs: 14, lg: 16 }
* });
*/
export function useResponsiveStyle<T extends Record<string, ResponsiveValue<unknown>>>(
styles: T
): { [K in keyof T]: T[K] extends ResponsiveValue<infer V> ? V : never } {
const { width } = useWindowDimensions();
const fineBreakpoint = getFineBreakpoint(width);
return useMemo(() => {
const result = {} as { [K in keyof T]: T[K] extends ResponsiveValue<infer V> ? V : never };
for (const key in styles) {
const value = styles[key];
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
(result as Record<string, unknown>)[key] = value;
} else {
const valueMap = value as Partial<Record<FineBreakpointKey, unknown>>;
const currentIndex = breakpointOrder.indexOf(fineBreakpoint);
let selectedValue: unknown = undefined;
for (let i = currentIndex; i < breakpointOrder.length; i++) {
const bp = breakpointOrder[i];
if (bp in valueMap) {
selectedValue = valueMap[bp];
break;
}
}
(result as Record<string, unknown>)[key] = selectedValue ?? valueMap.xs ?? Object.values(valueMap)[0];
}
}
return result;
}, [styles, fineBreakpoint]);
}

View File

@@ -0,0 +1,63 @@
/**
* useScreenSize Hook
* 屏幕尺寸检测 - 提供屏幕尺寸和断点信息
*/
import { useState, useEffect, useMemo } from 'react';
import { Dimensions, ScaledSize } from 'react-native';
import { getBreakpoint, getFineBreakpoint } from './useBreakpoint';
import type { BreakpointKey, FineBreakpointKey, ScreenSize } from './types';
/**
* 获取窗口尺寸,支持屏幕旋转响应
* 替代 Dimensions.get('window'),提供实时尺寸更新
*/
export function useWindowDimensions(): ScaledSize {
const [dimensions, setDimensions] = useState(() => Dimensions.get('window'));
useEffect(() => {
const subscription = Dimensions.addEventListener('change', ({ window }) => {
setDimensions(window);
});
return () => {
subscription.remove();
};
}, []);
return dimensions;
}
/**
* 屏幕尺寸 Hook
* 提供屏幕尺寸信息和断点状态
*
* @returns 屏幕尺寸信息
*
* @example
* const { width, height, isMobile, isTablet, isDesktop, isWide } = useScreenSize();
*/
export function useScreenSize(): ScreenSize & {
breakpoint: BreakpointKey;
fineBreakpoint: FineBreakpointKey;
} {
const { width, height } = useWindowDimensions();
return useMemo(() => {
const breakpoint = getBreakpoint(width);
const fineBreakpoint = getFineBreakpoint(width);
return {
width,
height,
breakpoint,
fineBreakpoint,
isMobile: breakpoint === 'mobile',
isTablet: breakpoint === 'tablet',
isDesktop: breakpoint === 'desktop',
isWide: breakpoint === 'wide',
};
}, [width, height]);
}
export default useScreenSize;

View File

@@ -334,6 +334,9 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
// 发布帖子
const handlePost = async () => {
// 防止重复点击
if (posting) return;
if (!content.trim()) {
Alert.alert('错误', '请输入帖子内容');
return;
@@ -373,7 +376,10 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
message: '投票帖已提交,内容审核中,稍后展示',
duration: 2600,
});
navigation.goBack();
navigation.reset({
index: 0,
routes: [{ name: 'Main' }],
});
} else {
if (isEditMode && editPostID) {
const updated = await postService.updatePost(editPostID, {
@@ -390,7 +396,10 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
message: '帖子内容已更新',
duration: 2200,
});
navigation.goBack();
navigation.reset({
index: 0,
routes: [{ name: 'Main' }],
});
} else {
// 创建普通帖子
await postService.createPost({
@@ -404,7 +413,10 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
message: '帖子已提交,内容审核中,稍后展示',
duration: 2600,
});
navigation.goBack();
navigation.reset({
index: 0,
routes: [{ name: 'Main' }],
});
}
}
} catch (error) {

View File

@@ -35,6 +35,7 @@ import { HomeStackParamList, RootStackParamList } from '../../navigation/types';
import { useResponsive, useResponsiveSpacing } from '../../hooks/useResponsive';
import { SearchScreen } from './SearchScreen';
import { CreatePostScreen } from '../create/CreatePostScreen';
import { navigationService } from '../../infrastructure/navigation/navigationService';
type NavigationProp = NativeStackNavigationProp<HomeStackParamList, 'Home'> & NativeStackNavigationProp<RootStackParamList>;
@@ -320,6 +321,7 @@ export const HomeScreen: React.FC = () => {
.runOnJS(true)
.activeOffsetX([-15, 15])
.failOffsetY([-20, 20])
.shouldCancelWhenOutside(true)
.onEnd((event) => {
const now = Date.now();
if (now - lastSwipeAtRef.current < SWIPE_COOLDOWN_MS) {
@@ -342,12 +344,12 @@ export const HomeScreen: React.FC = () => {
// 跳转到帖子详情
const handlePostPress = (postId: string, scrollToComments: boolean = false) => {
navigation.getParent()?.navigate('PostDetail', { postId, scrollToComments });
navigationService.navigate('PostDetail', { postId, scrollToComments });
};
// 跳转到用户主页
const handleUserPress = (userId: string) => {
navigation.getParent()?.navigate('UserProfile', { userId });
navigationService.navigate('UserProfile', { userId });
};
// 点赞帖子

View File

@@ -31,6 +31,7 @@ import { userManager } from '../../stores/userManager';
import { Avatar, Text, Button, Loading, Divider } from '../../components/common';
import { User } from '../../types';
import { RootStackParamList, MessageStackParamList } from '../../navigation/types';
import { navigationService } from '../../infrastructure/navigation/navigationService';
type NavigationProp = NativeStackNavigationProp<MessageStackParamList>;
type PrivateChatInfoRouteProp = RouteProp<MessageStackParamList, 'PrivateChatInfo'>;
@@ -149,9 +150,8 @@ const PrivateChatInfoScreen: React.FC = () => {
// 查看用户资料
const handleViewProfile = () => {
// 使用导航跳转到用户资料页面
const rootNavigation = navigation.getParent();
rootNavigation?.navigate('UserProfile' as any, { userId });
// 使用导航服务跳转到用户资料页面
navigationService.navigate('UserProfile', { userId });
};
// 举报/投诉

View File

@@ -31,6 +31,7 @@ import { groupService } from '../../../../services/groupService';
import { userManager } from '../../../../stores/userManager';
import { groupManager } from '../../../../stores/groupManager';
import { RootStackParamList } from '../../../../navigation/types';
import { navigationService } from '../../../../infrastructure/navigation/navigationService';
import {
GroupMessage,
PanelType,
@@ -1039,9 +1040,9 @@ export const useChatScreen = () => {
// 点击头像跳转到用户主页
const handleAvatarPress = useCallback((userId: string) => {
if (userId && userId !== currentUserId) {
navigation.navigate('UserProfile' as any, { userId: String(userId) });
navigationService.navigate('UserProfile', { userId: String(userId) });
}
}, [navigation, currentUserId]);
}, [currentUserId]);
// 长按头像@用户
const handleAvatarLongPress = useCallback((senderId: string, nickname: string) => {
@@ -1113,8 +1114,7 @@ export const useChatScreen = () => {
conversationId: conversationId || undefined,
});
} else if (otherUser?.id) {
const rootNavigation = navigation.getParent();
rootNavigation?.navigate('UserProfile' as any, { userId: String(otherUser.id) });
navigationService.navigate('UserProfile', { userId: String(otherUser.id) });
}
}, [navigation, isGroupChat, routeGroupId, otherUser]);

View File

@@ -4,7 +4,7 @@
*
* 功能:
* - 定期后台任务保持 App 在内存中
* - 配合 WebSocket 服务保持长连接
* - 配合 SSE 服务保持长连接
* - 收到消息时触发震动提示
*/
@@ -47,7 +47,7 @@ let appStateSubscription: any = null;
// 定义后台任务
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
try {
// 检查 WebSocket 连接状态
// 检查 SSE 连接状态
if (!sseService.isConnected()) {
await sseService.connect();
}
@@ -60,7 +60,7 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
}
});
// WebSocket 保活任务
// SSE 保活任务
TaskManager.defineTask(REALTIME_KEEPALIVE_TASK, async () => {
try {
if (!sseService.isConnected()) {
@@ -174,9 +174,9 @@ async function registerBackgroundTasks(): Promise<void> {
});
}
// 注册 WebSocket 保活任务
const isWsKeepaliveRegistered = await TaskManager.isTaskRegisteredAsync(REALTIME_KEEPALIVE_TASK);
if (!isWsKeepaliveRegistered) {
// 注册 SSE 保活任务
const isSseKeepaliveRegistered = await TaskManager.isTaskRegisteredAsync(REALTIME_KEEPALIVE_TASK);
if (!isSseKeepaliveRegistered) {
await BackgroundFetch.registerTaskAsync(REALTIME_KEEPALIVE_TASK, {
minimumInterval: 60, // 1 分钟检查一次
stopOnTerminate: false,

View File

@@ -0,0 +1,251 @@
/**
* MessageManager - 重构版
* 作为协调者,整合各个专注的服务模块
*/
import { messageStateManager, MessageStateManager } from './MessageStateManager';
import { sseMessageHandler, SSEMessageHandler } from './SSEMessageHandler';
import { messageSyncService, MessageSyncService } from './MessageSyncService';
import { readReceiptManager, ReadReceiptManager } from './ReadReceiptManager';
import { messageRepository } from '../../data/repositories/MessageRepository';
import { useAuthStore } from '../authStore';
import type { Message, Conversation } from '../../core/entities/Message';
import type { ConversationResponse, MessageResponse } from '../../types/dto';
export type {
MessageEventType,
MessageEvent,
MessageSubscriber,
} from './MessageStateManager';
export class MessageManager {
private stateManager: MessageStateManager;
private sseHandler: SSEMessageHandler;
private syncService: MessageSyncService;
private readManager: ReadReceiptManager;
private initialized: boolean = false;
private authUnsubscribe: (() => void) | null = null;
private sseUnsubscribe: (() => void) | null = null;
private currentUserId: string | null = null;
constructor() {
this.stateManager = messageStateManager;
this.sseHandler = sseMessageHandler;
this.syncService = messageSyncService;
this.readManager = readReceiptManager;
this.readManager.setStateManager(this.stateManager);
this.setupSSEHandlers();
this.initAuthListener();
}
private initAuthListener(): void {
const authState = useAuthStore.getState();
this.currentUserId = authState.currentUser?.id || null;
if (this.currentUserId && !this.initialized) {
this.initialize();
}
this.authUnsubscribe = useAuthStore.subscribe((state) => {
const nextUserId = state.currentUser?.id || null;
this.currentUserId = nextUserId;
if (nextUserId && !this.initialized) {
this.initialize();
}
});
}
private setupSSEHandlers(): void {
this.sseUnsubscribe = this.sseHandler.subscribe((event) => {
switch (event.type) {
case 'chat_message':
case 'group_message':
this.handleNewMessage(event.payload);
break;
case 'read_receipt':
case 'group_read_receipt':
this.handleReadReceipt(event.payload);
break;
case 'message_recalled':
case 'group_message_recalled':
this.handleMessageRecall(event.payload);
break;
case 'typing':
this.handleTypingStatus(event.payload);
break;
case 'group_notice':
this.handleGroupNotice(event.payload);
break;
}
});
}
async initialize(): Promise<void> {
if (this.initialized) return;
try {
console.log('[MessageManager] Initializing...');
this.sseHandler.connect();
const conversations = await this.syncService.syncConversations();
this.stateManager.setConversations(conversations);
this.initialized = true;
console.log('[MessageManager] Initialized successfully');
} catch (error) {
console.error('[MessageManager] Initialization failed:', error);
throw error;
}
}
async activateConversation(conversationId: string): Promise<void> {
this.stateManager.setCurrentConversation(conversationId);
const existing = this.stateManager.getMessages(conversationId);
if (existing.length === 0) {
await this.loadMessages(conversationId);
}
const conversation = this.stateManager.getConversation(conversationId);
if (conversation && conversation.unreadCount > 0) {
await this.readManager.markAsRead(conversationId, conversation.lastSeq);
}
}
deactivateConversation(): void {
this.stateManager.setCurrentConversation(null);
}
async loadMessages(conversationId: string): Promise<Message[]> {
const localMessages = await this.syncService.loadLocalMessages(conversationId);
if (localMessages.length > 0) {
this.stateManager.setMessages(conversationId, localMessages);
}
const syncResult = await this.syncService.syncMessages(conversationId);
if (syncResult.messages.length > 0) {
this.stateManager.appendMessages(conversationId, syncResult.messages);
return [...localMessages, ...syncResult.messages];
}
return localMessages;
}
async loadHistory(conversationId: string): Promise<Message[]> {
const messages = this.stateManager.getMessages(conversationId);
const oldestSeq = messages.length > 0 ? Math.min(...messages.map(m => m.seq)) : 0;
const history = await this.syncService.loadHistoryMessages(
conversationId,
oldestSeq,
20
);
if (history.length > 0) {
this.stateManager.prependMessages(conversationId, history);
}
return history;
}
async sendMessage(
conversationId: string,
content: string,
type: 'private' | 'group' = 'private'
): Promise<Message> {
throw new Error('sendMessage not implemented - use existing implementation');
}
private async handleNewMessage(message: Message): Promise<void> {
await messageRepository.saveMessage(message, true);
this.stateManager.addMessage(message.conversationId, message);
const currentConvId = this.stateManager.getCurrentConversationId();
if (currentConvId !== message.conversationId) {
const conv = this.stateManager.getConversation(message.conversationId);
if (conv) {
this.stateManager.updateConversation(message.conversationId, {
unreadCount: (conv.unreadCount || 0) + 1,
lastSeq: message.seq,
lastMessageAt: message.createdAt,
});
}
}
}
private handleReadReceipt(payload: any): void {
const { conversationId, lastReadSeq } = payload;
this.stateManager.updateConversation(conversationId, {
unreadCount: 0,
});
}
private async handleMessageRecall(payload: any): Promise<void> {
const { messageId, conversationId } = payload;
await messageRepository.updateMessageStatus(messageId, 'recalled', true);
this.stateManager.updateMessage(conversationId, messageId, {
status: 'recalled',
});
}
private handleTypingStatus(payload: any): void {
const { groupId, userIds } = payload;
this.stateManager.setTypingUsers(groupId, userIds);
}
private handleGroupNotice(notice: any): void {
console.log('[MessageManager] Group notice:', notice);
}
subscribe(callback: any): () => void {
return this.stateManager.subscribe(callback);
}
getConversations(): Conversation[] {
return this.stateManager.getConversations();
}
getMessages(conversationId: string): Message[] {
return this.stateManager.getMessages(conversationId);
}
getUnreadCount(): { total: number; system: number } {
return this.stateManager.getUnreadCount();
}
isConnected(): boolean {
return this.sseHandler.isConnected();
}
reset(): void {
this.initialized = false;
this.stateManager.reset();
this.readManager.reset();
this.sseHandler.disconnect();
}
destroy(): void {
if (this.authUnsubscribe) {
this.authUnsubscribe();
this.authUnsubscribe = null;
}
if (this.sseUnsubscribe) {
this.sseUnsubscribe();
this.sseUnsubscribe = null;
}
this.reset();
}
}
export const messageManager = new MessageManager();
export default messageManager;

View File

@@ -0,0 +1,301 @@
/**
* 消息状态管理器
* 只负责管理状态,不包含业务逻辑
*/
import type { Message, Conversation, UnreadCount } from '../../core/entities/Message';
export type MessageEventType =
| 'conversations_updated'
| 'messages_updated'
| 'unread_count_updated'
| 'connection_changed'
| 'message_sent'
| 'message_read'
| 'message_received'
| 'message_recalled'
| 'typing_status'
| 'group_notice'
| 'error';
export interface MessageEvent {
type: MessageEventType;
payload: any;
timestamp: number;
}
export type MessageSubscriber = (event: MessageEvent) => void;
export interface MessageState {
conversations: Map<string, Conversation>;
conversationList: Conversation[];
messagesMap: Map<string, Message[]>;
unreadCount: UnreadCount;
isSSEConnected: boolean;
currentConversationId: string | null;
isLoading: boolean;
typingUsersMap: Map<string, string[]>;
mutedStatusMap: Map<string, boolean>;
}
export class MessageStateManager {
private state: MessageState;
private subscribers: Set<MessageSubscriber>;
private readStateVersion: number = 0;
private pendingReadMap: Map<string, { timestamp: number; version: number }> = new Map();
constructor() {
this.state = {
conversations: new Map(),
conversationList: [],
messagesMap: new Map(),
unreadCount: { total: 0, system: 0 },
isSSEConnected: false,
currentConversationId: null,
isLoading: false,
typingUsersMap: new Map(),
mutedStatusMap: new Map(),
};
this.subscribers = new Set();
}
// ==================== 状态获取 ====================
getState(): MessageState {
return this.state;
}
getConversations(): Conversation[] {
return this.state.conversationList;
}
getConversation(id: string): Conversation | undefined {
return this.state.conversations.get(id);
}
getMessages(conversationId: string): Message[] {
return this.state.messagesMap.get(conversationId) || [];
}
getUnreadCount(): UnreadCount {
return this.state.unreadCount;
}
getCurrentConversationId(): string | null {
return this.state.currentConversationId;
}
isConnected(): boolean {
return this.state.isSSEConnected;
}
getTypingUsers(groupId: string): string[] {
return this.state.typingUsersMap.get(groupId) || [];
}
getMutedStatus(groupId: string): boolean {
return this.state.mutedStatusMap.get(groupId) || false;
}
// ==================== 状态更新 ====================
setConversations(conversations: Conversation[]): void {
this.state.conversations.clear();
this.state.conversationList = conversations;
for (const conv of conversations) {
this.state.conversations.set(conv.id, conv);
}
this.updateUnreadCount();
this.notify({ type: 'conversations_updated', payload: conversations });
}
addConversation(conversation: Conversation): void {
this.state.conversations.set(conversation.id, conversation);
this.state.conversationList = Array.from(this.state.conversations.values());
this.updateUnreadCount();
this.notify({ type: 'conversations_updated', payload: this.state.conversationList });
}
updateConversation(id: string, updates: Partial<Conversation>): void {
const existing = this.state.conversations.get(id);
if (existing) {
const updated = { ...existing, ...updates };
this.state.conversations.set(id, updated);
this.state.conversationList = Array.from(this.state.conversations.values());
this.updateUnreadCount();
this.notify({ type: 'conversations_updated', payload: this.state.conversationList });
}
}
removeConversation(id: string): void {
this.state.conversations.delete(id);
this.state.messagesMap.delete(id);
this.state.conversationList = Array.from(this.state.conversations.values());
this.updateUnreadCount();
this.notify({ type: 'conversations_updated', payload: this.state.conversationList });
}
setMessages(conversationId: string, messages: Message[]): void {
this.state.messagesMap.set(conversationId, messages);
this.notify({ type: 'messages_updated', payload: { conversationId, messages } });
}
addMessage(conversationId: string, message: Message): void {
const messages = this.state.messagesMap.get(conversationId) || [];
const existingIndex = messages.findIndex(m => m.id === message.id);
if (existingIndex >= 0) {
messages[existingIndex] = message;
} else {
messages.push(message);
}
this.state.messagesMap.set(conversationId, messages);
this.notify({ type: 'messages_updated', payload: { conversationId, messages } });
}
prependMessages(conversationId: string, newMessages: Message[]): void {
const existing = this.state.messagesMap.get(conversationId) || [];
const merged = [...newMessages, ...existing];
const unique = this.deduplicateMessages(merged);
this.state.messagesMap.set(conversationId, unique);
this.notify({ type: 'messages_updated', payload: { conversationId, messages: unique } });
}
appendMessages(conversationId: string, newMessages: Message[]): void {
const existing = this.state.messagesMap.get(conversationId) || [];
const merged = [...existing, ...newMessages];
const unique = this.deduplicateMessages(merged);
this.state.messagesMap.set(conversationId, unique);
this.notify({ type: 'messages_updated', payload: { conversationId, messages: unique } });
}
updateMessage(conversationId: string, messageId: string, updates: Partial<Message>): void {
const messages = this.state.messagesMap.get(conversationId);
if (!messages) return;
const index = messages.findIndex(m => m.id === messageId);
if (index >= 0) {
messages[index] = { ...messages[index], ...updates };
this.notify({ type: 'messages_updated', payload: { conversationId, messages } });
}
}
setUnreadCount(count: UnreadCount): void {
this.state.unreadCount = count;
this.notify({ type: 'unread_count_updated', payload: count });
}
setSSEConnected(connected: boolean): void {
this.state.isSSEConnected = connected;
this.notify({ type: 'connection_changed', payload: connected });
}
setCurrentConversation(id: string | null): void {
this.state.currentConversationId = id;
}
setLoading(loading: boolean): void {
this.state.isLoading = loading;
}
setTypingUsers(groupId: string, userIds: string[]): void {
this.state.typingUsersMap.set(groupId, userIds);
this.notify({ type: 'typing_status', payload: { groupId, userIds } });
}
setMutedStatus(groupId: string, muted: boolean): void {
this.state.mutedStatusMap.set(groupId, muted);
}
// ==================== 已读状态保护 ====================
beginReadOperation(conversationId: string): number {
this.readStateVersion++;
this.pendingReadMap.set(conversationId, {
timestamp: Date.now(),
version: this.readStateVersion,
});
return this.readStateVersion;
}
endReadOperation(conversationId: string): void {
setTimeout(() => {
this.pendingReadMap.delete(conversationId);
}, 5000);
}
isReadOperationPending(conversationId: string): boolean {
const record = this.pendingReadMap.get(conversationId);
if (!record) return false;
return Date.now() - record.timestamp < 5000;
}
getReadStateVersion(): number {
return this.readStateVersion;
}
// ==================== 订阅机制 ====================
subscribe(callback: MessageSubscriber): () => void {
this.subscribers.add(callback);
return () => this.subscribers.delete(callback);
}
private notify(event: Omit<MessageEvent, 'timestamp'>): void {
const fullEvent: MessageEvent = {
...event,
timestamp: Date.now(),
};
this.subscribers.forEach(callback => {
try {
callback(fullEvent);
} catch (error) {
console.error('[MessageStateManager] Subscriber error:', error);
}
});
}
// ==================== 工具方法 ====================
private updateUnreadCount(): void {
let total = 0;
let system = 0;
for (const conv of this.state.conversations.values()) {
total += conv.unreadCount || 0;
}
this.state.unreadCount = { total, system };
this.notify({ type: 'unread_count_updated', payload: this.state.unreadCount });
}
private deduplicateMessages(messages: Message[]): Message[] {
const seen = new Set<string>();
return messages.filter(msg => {
if (seen.has(msg.id)) return false;
seen.add(msg.id);
return true;
});
}
reset(): void {
this.state = {
conversations: new Map(),
conversationList: [],
messagesMap: new Map(),
unreadCount: { total: 0, system: 0 },
isSSEConnected: false,
currentConversationId: null,
isLoading: false,
typingUsersMap: new Map(),
mutedStatusMap: new Map(),
};
this.pendingReadMap.clear();
this.readStateVersion = 0;
}
}
export const messageStateManager = new MessageStateManager();

View File

@@ -0,0 +1,162 @@
/**
* 消息同步服务
* 负责从服务器同步消息和会话
*/
import { messageService } from '../../services/messageService';
import { messageRepository } from '../../data/repositories/MessageRepository';
import type { Message, Conversation } from '../../core/entities/Message';
import type { ConversationResponse, MessageResponse } from '../../types/dto';
export interface SyncOptions {
force?: boolean;
lastSeq?: number;
}
export interface SyncResult {
conversations: Conversation[];
messages: Message[];
hasMore: boolean;
}
export class MessageSyncService {
private syncingConversations: boolean = false;
private syncingMessages: Map<string, boolean> = new Map();
async syncConversations(): Promise<Conversation[]> {
if (this.syncingConversations) {
console.log('[MessageSyncService] Already syncing conversations');
return [];
}
this.syncingConversations = true;
try {
const response = await messageService.getConversations();
const conversations = this.mapConversations(response);
return conversations;
} catch (error) {
console.error('[MessageSyncService] Failed to sync conversations:', error);
throw error;
} finally {
this.syncingConversations = false;
}
}
async syncMessages(
conversationId: string,
options: SyncOptions = {}
): Promise<SyncResult> {
const key = conversationId;
if (this.syncingMessages.get(key)) {
console.log('[MessageSyncService] Already syncing messages for:', conversationId);
return { conversations: [], messages: [], hasMore: false };
}
this.syncingMessages.set(key, true);
try {
const lastSeq = options.lastSeq ?? await messageRepository.getMaxSeq(conversationId);
const response = await messageService.getMessages(conversationId, {
after_seq: lastSeq,
limit: 50,
});
const messages = this.mapMessages(response.messages || []);
if (messages.length > 0) {
await messageRepository.saveMessages(messages, true);
}
return {
conversations: [],
messages,
hasMore: response.has_more || false,
};
} catch (error) {
console.error('[MessageSyncService] Failed to sync messages:', error);
throw error;
} finally {
this.syncingMessages.delete(key);
}
}
async loadHistoryMessages(
conversationId: string,
beforeSeq: number,
limit: number = 20
): Promise<Message[]> {
try {
const response = await messageService.getMessages(conversationId, {
before_seq: beforeSeq,
limit,
});
const messages = this.mapMessages(response.messages || []);
if (messages.length > 0) {
await messageRepository.saveMessages(messages, true);
}
return messages;
} catch (error) {
console.error('[MessageSyncService] Failed to load history:', error);
throw error;
}
}
async loadLocalMessages(
conversationId: string,
limit: number = 50
): Promise<Message[]> {
return messageRepository.getMessagesByConversation(conversationId, limit);
}
async loadHistoryFromLocal(
conversationId: string,
beforeSeq: number,
limit: number = 20
): Promise<Message[]> {
return messageRepository.getMessagesBeforeSeq(conversationId, beforeSeq, limit);
}
private mapConversations(response: ConversationResponse[]): Conversation[] {
return response.map(conv => ({
id: conv.id,
type: conv.type || 'private',
isPinned: conv.isPinned || false,
lastSeq: conv.lastSeq || conv.last_seq || 0,
lastMessageAt: conv.lastMessageAt || conv.last_message_at || new Date().toISOString(),
unreadCount: conv.unreadCount || conv.unread_count || 0,
participants: conv.participants || [],
group: conv.group,
createdAt: conv.createdAt || conv.created_at || new Date().toISOString(),
updatedAt: conv.updatedAt || conv.updated_at || new Date().toISOString(),
}));
}
private mapMessages(response: MessageResponse[]): Message[] {
return response.map(msg => ({
id: msg.id,
conversationId: msg.conversationId || msg.conversation_id || '',
senderId: msg.senderId || msg.sender_id || '',
seq: msg.seq || 0,
segments: msg.segments || [],
createdAt: msg.createdAt || msg.created_at || new Date().toISOString(),
status: msg.status || 'normal',
sender: msg.sender,
}));
}
isSyncingConversations(): boolean {
return this.syncingConversations;
}
isSyncingMessages(conversationId: string): boolean {
return this.syncingMessages.get(conversationId) || false;
}
}
export const messageSyncService = new MessageSyncService();

View File

@@ -0,0 +1,92 @@
/**
* 已读回执管理器
* 负责处理消息已读状态的同步
*/
import { messageService } from '../../services/messageService';
import { messageRepository } from '../../data/repositories/MessageRepository';
import type { MessageStateManager } from './MessageStateManager';
export interface ReadReceiptResult {
conversationId: string;
lastReadSeq: number;
success: boolean;
}
export class ReadReceiptManager {
private pendingOperations: Map<string, Promise<void>> = new Map();
private stateManager: MessageStateManager | null = null;
setStateManager(manager: MessageStateManager): void {
this.stateManager = manager;
}
async markAsRead(
conversationId: string,
lastSeq: number
): Promise<ReadReceiptResult> {
const existing = this.pendingOperations.get(conversationId);
if (existing) {
await existing;
}
if (!this.stateManager) {
throw new Error('StateManager not set');
}
const version = this.stateManager.beginReadOperation(conversationId);
const promise = this.executeMarkAsRead(conversationId, lastSeq, version);
this.pendingOperations.set(conversationId, promise);
try {
await promise;
return { conversationId, lastReadSeq: lastSeq, success: true };
} catch (error) {
console.error('[ReadReceiptManager] Failed to mark as read:', error);
return { conversationId, lastReadSeq: lastSeq, success: false };
} finally {
this.pendingOperations.delete(conversationId);
this.stateManager.endReadOperation(conversationId);
}
}
private async executeMarkAsRead(
conversationId: string,
lastSeq: number,
version: number
): Promise<void> {
await messageService.markAsRead(conversationId, lastSeq);
await messageRepository.markConversationAsRead(conversationId);
if (this.stateManager) {
this.stateManager.updateConversation(conversationId, {
unreadCount: 0,
});
}
}
async markAllAsRead(): Promise<void> {
if (!this.stateManager) return;
const conversations = this.stateManager.getConversations();
const unreadConversations = conversations.filter(c => c.unreadCount > 0);
await Promise.all(
unreadConversations.map(conv =>
this.markAsRead(conv.id, conv.lastSeq)
)
);
}
isPending(conversationId: string): boolean {
return this.pendingOperations.has(conversationId);
}
reset(): void {
this.pendingOperations.clear();
}
}
export const readReceiptManager = new ReadReceiptManager();

View File

@@ -0,0 +1,148 @@
/**
* SSE 消息处理器
* 只负责处理 SSE 消息,不管理状态
*/
import { sseService, WSChatMessage, WSGroupChatMessage, WSReadMessage, WSGroupReadMessage, WSRecallMessage, WSGroupRecallMessage, WSGroupTypingMessage, WSGroupNoticeMessage, GroupNoticeType, WSMessage } from '../../services/sseService';
import type { Message, GroupNotice } from '../../core/entities/Message';
export type SSEEventType =
| 'chat_message'
| 'group_message'
| 'read_receipt'
| 'group_read_receipt'
| 'message_recalled'
| 'group_message_recalled'
| 'typing'
| 'group_notice';
export interface SSEEvent {
type: SSEEventType;
payload: any;
raw: any;
}
export type SSEEventHandler = (event: SSEEvent) => void;
export class SSEMessageHandler {
private unsubscribeFns: Array<() => void> = [];
private handlers: Set<SSEEventHandler> = new Set();
connect(): void {
if (this.unsubscribeFns.length > 0) return;
// 监听私聊消息
const unsubChat = sseService.on('chat', (message) => {
this.emit('chat_message', this.parseChatMessage(message), message);
});
// 监听群聊消息
const unsubGroupMessage = sseService.on('group_message', (message) => {
this.emit('group_message', this.parseGroupMessage(message), message);
});
// 监听私聊已读回执
const unsubRead = sseService.on('read', (message) => {
this.emit('read_receipt', message, message);
});
// 监听群聊已读回执
const unsubGroupRead = sseService.on('group_read', (message) => {
this.emit('group_read_receipt', message, message);
});
// 监听私聊消息撤回
const unsubRecall = sseService.on('recall', (message) => {
this.emit('message_recalled', message, message);
});
// 监听群聊消息撤回
const unsubGroupRecall = sseService.on('group_recall', (message) => {
this.emit('group_message_recalled', message, message);
});
// 监听群聊输入状态
const unsubGroupTyping = sseService.on('group_typing', (message) => {
this.emit('typing', message, message);
});
// 监听群通知
const unsubGroupNotice = sseService.on('group_notice', (message) => {
this.emit('group_notice', this.parseGroupNotice(message), message);
});
this.unsubscribeFns = [
unsubChat,
unsubGroupMessage,
unsubRead,
unsubGroupRead,
unsubRecall,
unsubGroupRecall,
unsubGroupTyping,
unsubGroupNotice,
];
}
disconnect(): void {
this.unsubscribeFns.forEach((fn) => fn());
this.unsubscribeFns = [];
this.handlers.clear();
}
subscribe(handler: SSEEventHandler): () => void {
this.handlers.add(handler);
return () => this.handlers.delete(handler);
}
private emit(type: SSEEventType, payload: any, raw: any): void {
const event: SSEEvent = { type, payload, raw };
this.handlers.forEach(handler => {
try {
handler(event);
} catch (error) {
console.error('[SSEMessageHandler] Handler error:', error);
}
});
}
private parseChatMessage(data: WSChatMessage): Message {
return {
id: data.id || '',
conversationId: data.conversation_id || '',
senderId: data.sender_id || '',
seq: data.seq || 0,
segments: data.segments || [],
createdAt: data.created_at || new Date().toISOString(),
status: 'normal',
};
}
private parseGroupMessage(data: WSGroupChatMessage): Message {
return {
id: data.id || '',
conversationId: data.conversation_id || '',
senderId: data.sender_id || '',
seq: data.seq || 0,
segments: data.segments || [],
createdAt: data.created_at || new Date().toISOString(),
status: 'normal',
};
}
private parseGroupNotice(data: WSGroupNoticeMessage): GroupNotice {
return {
type: data.notice_type || 'member_join',
groupId: String(data.group_id || ''),
data: data.data || {},
timestamp: data.timestamp || Date.now(),
messageId: data.message_id,
seq: data.seq,
};
}
isConnected(): boolean {
return sseService.isConnected();
}
}
export const sseMessageHandler = new SSEMessageHandler();

View File

@@ -0,0 +1,19 @@
/**
* 消息模块统一导出
*/
// 导出主要的管理器
export { messageManager, MessageManager } from './MessageManager';
// 导出状态管理器
export { messageStateManager, MessageStateManager } from './MessageStateManager';
export type { MessageState, MessageEvent, MessageSubscriber, MessageEventType } from './MessageStateManager';
// 导出同步服务
export { messageSyncService, MessageSyncService } from './MessageSyncService';
// 导出SSE处理器
export { sseMessageHandler, SSEMessageHandler } from './SSEMessageHandler';
// 导出已读管理器
export { readReceiptManager, ReadReceiptManager } from './ReadReceiptManager';

View File

@@ -8,7 +8,7 @@
*
* 架构特点:
* - 管理conversations和messages状态
* - 统一处理WebSocket消息
* - 统一处理SSE消息
* - 统一处理本地数据库读写
* - 提供订阅机制供React组件使用
*/
@@ -83,7 +83,7 @@ interface MessageManagerState {
systemUnreadCount: number;
// 连接状态
isWebSocketConnected: boolean;
isSSEConnected: boolean;
// 当前活动会话ID用户正在查看的会话
currentConversationId: string | null;
@@ -131,7 +131,7 @@ interface ReadStateRecord {
class MessageManager {
private state: MessageManagerState;
private wsUnsubscribe: (() => void) | null = null;
private sseUnsubscribe: (() => void) | null = null;
private currentUserId: string | null = null;
private authUnsubscribe: (() => void) | null = null;
private lastReconnectSyncAt: number = 0;
@@ -158,7 +158,7 @@ class MessageManager {
messagesMap: new Map(),
totalUnreadCount: 0,
systemUnreadCount: 0,
isWebSocketConnected: false,
isSSEConnected: false,
currentConversationId: null,
isLoadingConversations: false,
loadingMessagesSet: new Set(),
@@ -281,8 +281,8 @@ class MessageManager {
return;
}
// 2. 初始化WebSocket监听
this.initWebSocketListeners();
// 2. 初始化SSE监听
this.initSSEListeners();
// 3. 加载会话列表
await this.fetchConversations();
@@ -320,9 +320,9 @@ class MessageManager {
}
destroy(): void {
if (this.wsUnsubscribe) {
this.wsUnsubscribe();
this.wsUnsubscribe = null;
if (this.sseUnsubscribe) {
this.sseUnsubscribe();
this.sseUnsubscribe = null;
}
this.state.subscribers.clear();
this.state.isInitialized = false;
@@ -330,11 +330,11 @@ class MessageManager {
this.activatingConversationTasks.clear();
}
// ==================== WebSocket 处理 ====================
// ==================== SSE 处理 ====================
private initWebSocketListeners(): void {
if (this.wsUnsubscribe) {
this.wsUnsubscribe();
private initSSEListeners(): void {
if (this.sseUnsubscribe) {
this.sseUnsubscribe();
}
@@ -380,7 +380,7 @@ class MessageManager {
// 监听连接状态
sseService.onConnect(() => {
this.state.isWebSocketConnected = true;
this.state.isSSEConnected = true;
this.notifySubscribers({
type: 'connection_changed',
payload: { connected: true },
@@ -403,7 +403,7 @@ class MessageManager {
});
sseService.onDisconnect(() => {
this.state.isWebSocketConnected = false;
this.state.isSSEConnected = false;
this.notifySubscribers({
type: 'connection_changed',
payload: { connected: false },
@@ -585,7 +585,7 @@ class MessageManager {
}
/**
* 处理新消息(WebSocket推送)
* 处理新消息(SSE推送)
* 这是核心方法,必须立即处理并同步到所有订阅者
*/
private async handleNewMessage(message: (WSChatMessage | WSGroupChatMessage) & { _isAck?: boolean }): Promise<void> {
@@ -2014,10 +2014,10 @@ class MessageManager {
}
/**
* 获取WebSocket连接状态
* 获取SSE连接状态
*/
isConnected(): boolean {
return this.state.isWebSocketConnected;
return this.state.isSSEConnected;
}
/**
@@ -2122,7 +2122,7 @@ class MessageManager {
subscriber({
type: 'connection_changed',
payload: { connected: this.state.isWebSocketConnected },
payload: { connected: this.state.isSSEConnected },
timestamp: Date.now(),
});

View File

@@ -15,6 +15,7 @@ import {
} from '../services';
import { userManager } from './userManager';
import { messageManager } from './messageManager';
import { createOptimisticUpdaterFactory } from '../utils/optimisticUpdate';
interface UserState {
// 状态
@@ -59,504 +60,386 @@ interface UserState {
refreshAll: () => Promise<void>;
}
export const useUserStore = create<UserState>((set, get) => ({
// 初始状态
users: [],
userCache: {},
posts: [],
notifications: [],
notificationBadge: {
total: 0,
likes: 0,
comments: 0,
follows: 0,
system: 0,
},
messageUnreadCount: 0,
searchHistory: [],
isLoadingPosts: false,
isLoadingNotifications: false,
export const useUserStore = create<UserState>((set, get) => {
// 创建乐观更新器
const optimisticUpdater = createOptimisticUpdaterFactory<UserState>(set, get);
// 获取用户信息
fetchUser: async (userId: string) => {
// 先检查缓存
const cached = get().userCache[userId];
if (cached) return cached;
return {
// 初始状态
users: [],
userCache: {},
posts: [],
notifications: [],
notificationBadge: {
total: 0,
likes: 0,
comments: 0,
follows: 0,
system: 0,
},
messageUnreadCount: 0,
searchHistory: [],
isLoadingPosts: false,
isLoadingNotifications: false,
// 获取用户信息
fetchUser: async (userId: string) => {
// 先检查缓存
const cached = get().userCache[userId];
if (cached) return cached;
try {
const user = await userManager.getUserById(userId);
if (user) {
set(state => ({
userCache: { ...state.userCache, [userId]: user }
}));
return user;
}
} catch (error) {
console.error('获取用户信息失败:', error);
}
return undefined;
},
// 获取用户帖子
fetchUserPosts: async (userId: string, page = 1) => {
try {
const response = await postService.getUserPosts(userId, page);
const newPosts = response.list;
try {
const user = await userManager.getUserById(userId);
if (user) {
set(state => ({
userCache: { ...state.userCache, [userId]: user }
posts: page === 1 ? newPosts : [...state.posts, ...newPosts]
}));
return user;
return newPosts;
} catch (error) {
console.error('获取用户帖子失败:', error);
return [];
}
} catch (error) {
console.error('获取用户信息失败:', error);
}
return undefined;
},
},
// 获取用户帖子
fetchUserPosts: async (userId: string, page = 1) => {
try {
const response = await postService.getUserPosts(userId, page);
const newPosts = response.list;
// 获取帖子列表(首页)
fetchPosts: async (type: 'recommend' | 'follow' | 'hot' | 'latest', page = 1) => {
set({ isLoadingPosts: true });
set(state => ({
posts: page === 1 ? newPosts : [...state.posts, ...newPosts]
}));
try {
let response;
return newPosts;
} catch (error) {
console.error('获取用户帖子失败:', error);
return [];
}
},
switch (type) {
case 'recommend':
response = await postService.getRecommendedPosts(page);
break;
case 'follow':
response = await postService.getFollowingPosts(page);
break;
case 'hot':
response = await postService.getHotPosts(page);
break;
case 'latest':
default:
response = await postService.getLatestPosts(page);
break;
}
// 获取帖子列表(首页)
fetchPosts: async (type: 'recommend' | 'follow' | 'hot' | 'latest', page = 1) => {
set({ isLoadingPosts: true });
const newPosts = response.list;
try {
let response;
set(state => ({
posts: page === 1 ? newPosts : [...state.posts, ...newPosts],
isLoadingPosts: false
}));
switch (type) {
case 'recommend':
response = await postService.getRecommendedPosts(page);
break;
case 'follow':
response = await postService.getFollowingPosts(page);
break;
case 'hot':
response = await postService.getHotPosts(page);
break;
case 'latest':
default:
response = await postService.getLatestPosts(page);
break;
return response;
} catch (error) {
console.error('获取帖子列表失败:', error);
set({ isLoadingPosts: false });
return {
list: [],
total: 0,
page,
page_size: 20,
total_pages: 0,
};
}
},
const newPosts = response.list;
// 获取通知列表
fetchNotifications: async (type?: string, page = 1) => {
set({ isLoadingNotifications: true });
set(state => ({
posts: page === 1 ? newPosts : [...state.posts, ...newPosts],
isLoadingPosts: false
}));
return response;
} catch (error) {
console.error('获取帖子列表失败:', error);
set({ isLoadingPosts: false });
return {
list: [],
total: 0,
page,
page_size: 20,
total_pages: 0,
};
}
},
// 获取通知列表
fetchNotifications: async (type?: string, page = 1) => {
set({ isLoadingNotifications: true });
try {
const response = await notificationService.getNotifications(
page,
20,
type as any
);
const newNotifications = response.list;
set(state => ({
notifications: page === 1 ? newNotifications : [...state.notifications, ...newNotifications],
isLoadingNotifications: false
}));
return newNotifications;
} catch (error) {
console.error('获取通知列表失败:', error);
set({ isLoadingNotifications: false });
return [];
}
},
// 标记通知为已读
markNotificationAsRead: async (notificationId: string) => {
try {
await notificationService.markAsRead(notificationId);
set(state => {
const notifications = state.notifications.map(n =>
n.id === notificationId ? { ...n, isRead: true } : n
try {
const response = await notificationService.getNotifications(
page,
20,
type as any
);
return {
notifications,
const newNotifications = response.list;
set(state => ({
notifications: page === 1 ? newNotifications : [...state.notifications, ...newNotifications],
isLoadingNotifications: false
}));
return newNotifications;
} catch (error) {
console.error('获取通知列表失败:', error);
set({ isLoadingNotifications: false });
return [];
}
},
// 标记通知为已读
markNotificationAsRead: async (notificationId: string) => {
try {
await notificationService.markAsRead(notificationId);
set(state => {
const notifications = state.notifications.map(n =>
n.id === notificationId ? { ...n, isRead: true } : n
);
return {
notifications,
notificationBadge: {
total: notifications.filter(n => !n.isRead).length,
likes: notifications.filter(n => n.type === 'like_post' && !n.isRead).length,
comments: notifications.filter(n => n.type === 'comment' && !n.isRead).length,
follows: notifications.filter(n => n.type === 'follow' && !n.isRead).length,
system: notifications.filter(n => n.type === 'system' && !n.isRead).length,
}
};
});
} catch (error) {
console.error('标记通知为已读失败:', error);
}
},
// 标记所有通知为已读
markAllNotificationsAsRead: async () => {
try {
await notificationService.markAllAsRead();
set(state => ({
notifications: state.notifications.map(n => ({ ...n, isRead: true })),
notificationBadge: {
total: notifications.filter(n => !n.isRead).length,
likes: notifications.filter(n => n.type === 'like_post' && !n.isRead).length,
comments: notifications.filter(n => n.type === 'comment' && !n.isRead).length,
follows: notifications.filter(n => n.type === 'follow' && !n.isRead).length,
system: notifications.filter(n => n.type === 'system' && !n.isRead).length,
total: 0,
likes: 0,
comments: 0,
follows: 0,
system: 0,
}
};
}));
} catch (error) {
console.error('标记所有通知为已读失败:', error);
}
},
// 获取通知角标
fetchNotificationBadge: async () => {
try {
const badge = await notificationService.getNotificationBadge();
set({ notificationBadge: badge });
} catch (error) {
console.error('获取通知角标失败:', error);
}
},
// 设置消息未读数
// 注意:未读数现在由 MessageManager 统一管理
// 此方法保留用于 TabBar 角标显示,从 MessageManager 同步
setMessageUnreadCount: (count: number) => {
set({ messageUnreadCount: count });
},
// 从后端拉取最新消息未读数
// @deprecated 请使用 messageManager.fetchUnreadCount() 代替
// 此方法保留用于向后兼容
fetchMessageUnreadCount: async () => {
try {
await messageManager.fetchUnreadCount();
const unread = messageManager.getUnreadCount();
set({ messageUnreadCount: unread.total + unread.system });
} catch (error) {
console.error('获取消息未读数失败:', error);
}
},
// 添加搜索历史
addSearchHistory: (keyword: string) => {
set(state => {
const history = [keyword, ...state.searchHistory.filter(k => k !== keyword)].slice(0, 20);
return { searchHistory: history };
});
} catch (error) {
console.error('标记通知为已读失败:', error);
}
},
},
// 标记所有通知为已读
markAllNotificationsAsRead: async () => {
try {
await notificationService.markAllAsRead();
// 清除搜索历史
clearSearchHistory: () => {
set({ searchHistory: [] });
},
set(state => ({
notifications: state.notifications.map(n => ({ ...n, isRead: true })),
notificationBadge: {
total: 0,
likes: 0,
comments: 0,
follows: 0,
system: 0,
}
}));
} catch (error) {
console.error('标记所有通知为已读失败:', error);
}
},
// 点赞帖子 - 使用乐观更新工具
likePost: async (postId: string) => {
const originalPosts = get().posts;
// 获取通知角标
fetchNotificationBadge: async () => {
try {
const badge = await notificationService.getNotificationBadge();
set({ notificationBadge: badge });
} catch (error) {
console.error('获取通知角标失败:', error);
}
},
// 设置消息未读数
// 注意:未读数现在由 MessageManager 统一管理
// 此方法保留用于 TabBar 角标显示,从 MessageManager 同步
setMessageUnreadCount: (count: number) => {
set({ messageUnreadCount: count });
},
// 从后端拉取最新消息未读数
// @deprecated 请使用 messageManager.fetchUnreadCount() 代替
// 此方法保留用于向后兼容
fetchMessageUnreadCount: async () => {
try {
await messageManager.fetchUnreadCount();
const unread = messageManager.getUnreadCount();
set({ messageUnreadCount: unread.total + unread.system });
} catch (error) {
console.error('获取消息未读数失败:', error);
}
},
// 添加搜索历史
addSearchHistory: (keyword: string) => {
set(state => {
const history = [keyword, ...state.searchHistory.filter(k => k !== keyword)].slice(0, 20);
return { searchHistory: history };
});
},
// 清除搜索历史
clearSearchHistory: () => {
set({ searchHistory: [] });
},
// 点赞帖子 - 乐观更新
likePost: async (postId: string) => {
// 先乐观更新本地状态
set(state => ({
posts: state.posts.map(p =>
p.id === postId ? { ...p, is_liked: true, likes_count: p.likes_count + 1 } : p
)
}));
// 调用API
try {
const updatedPost = await postService.likePost(postId);
if (updatedPost) {
// 使用后端返回的更新后数据更新状态
set(state => ({
posts: state.posts.map(p =>
p.id === postId ? updatedPost : p
)
}));
} else {
// API 返回失败,回滚状态
set(state => ({
posts: state.posts.map(p =>
p.id === postId ? { ...p, is_liked: false, likes_count: Math.max(0, p.likes_count - 1) } : p
)
}));
}
} catch (error) {
console.error('点赞帖子失败:', error);
// 失败回滚状态
set(state => ({
posts: state.posts.map(p =>
p.id === postId ? { ...p, is_liked: false, likes_count: Math.max(0, p.likes_count - 1) } : p
)
}));
}
},
// 取消点赞 - 乐观更新
unlikePost: async (postId: string) => {
// 先乐观更新本地状态
set(state => ({
posts: state.posts.map(p =>
p.id === postId ? { ...p, is_liked: false, likes_count: Math.max(0, p.likes_count - 1) } : p
)
}));
// 调用API
try {
const updatedPost = await postService.unlikePost(postId);
if (updatedPost) {
// 使用后端返回的更新后数据更新状态
set(state => ({
posts: state.posts.map(p =>
p.id === postId ? updatedPost : p
)
}));
} else {
// API 返回失败,回滚状态
set(state => ({
posts: state.posts.map(p =>
p.id === postId ? { ...p, is_liked: true, likes_count: p.likes_count + 1 } : p
)
}));
}
} catch (error) {
console.error('取消点赞帖子失败:', error);
// 失败回滚状态
// 乐观更新
set(state => ({
posts: state.posts.map(p =>
p.id === postId ? { ...p, is_liked: true, likes_count: p.likes_count + 1 } : p
)
}));
}
},
// 收藏帖子 - 乐观更新
favoritePost: async (postId: string) => {
// 先乐观更新本地状态
set(state => ({
posts: state.posts.map(p =>
p.id === postId ? { ...p, is_favorited: true, favorites_count: p.favorites_count + 1 } : p
)
}));
// 调用API
try {
const updatedPost = await postService.favoritePost(postId);
if (updatedPost) {
// 使用后端返回的更新后数据更新状态
set(state => ({
posts: state.posts.map(p =>
p.id === postId ? updatedPost : p
)
}));
} else {
// API 返回失败,回滚状态
set(state => ({
posts: state.posts.map(p =>
p.id === postId ? { ...p, is_favorited: false, favorites_count: Math.max(0, p.favorites_count - 1) } : p
)
}));
try {
const updatedPost = await postService.likePost(postId);
if (updatedPost) {
// 使用服务器返回的数据更新
set(state => ({
posts: state.posts.map(p =>
p.id === postId ? updatedPost : p
)
}));
}
} catch (error) {
console.error('点赞帖子失败:', error);
// 回滚
set({ posts: originalPosts });
}
} catch (error) {
console.error('收藏帖子失败:', error);
// 失败回滚状态
},
// 取消点赞 - 使用乐观更新工具
unlikePost: async (postId: string) => {
const originalPosts = get().posts;
// 乐观更新
set(state => ({
posts: state.posts.map(p =>
p.id === postId ? { ...p, is_favorited: false, favorites_count: Math.max(0, p.favorites_count - 1) } : p
p.id === postId ? { ...p, is_liked: false, likes_count: Math.max(0, p.likes_count - 1) } : p
)
}));
}
},
// 取消收藏 - 乐观更新
unfavoritePost: async (postId: string) => {
// 先乐观更新本地状态
set(state => ({
posts: state.posts.map(p =>
p.id === postId ? { ...p, is_favorited: false, favorites_count: Math.max(0, p.favorites_count - 1) } : p
)
}));
// 调用API
try {
const updatedPost = await postService.unfavoritePost(postId);
if (updatedPost) {
// 使用后端返回的更新后数据更新状态
set(state => ({
posts: state.posts.map(p =>
p.id === postId ? updatedPost : p
)
}));
} else {
// API 返回失败,回滚状态
set(state => ({
posts: state.posts.map(p =>
p.id === postId ? { ...p, is_favorited: true, favorites_count: p.favorites_count + 1 } : p
)
}));
try {
const updatedPost = await postService.unlikePost(postId);
if (updatedPost) {
// 使用服务器返回的数据更新
set(state => ({
posts: state.posts.map(p =>
p.id === postId ? updatedPost : p
)
}));
}
} catch (error) {
console.error('取消点赞帖子失败:', error);
// 回滚
set({ posts: originalPosts });
}
} catch (error) {
console.error('取消收藏帖子失败:', error);
// 失败回滚状态
},
// 收藏帖子 - 使用乐观更新工具
favoritePost: async (postId: string) => {
const originalPosts = get().posts;
// 乐观更新
set(state => ({
posts: state.posts.map(p =>
p.id === postId ? { ...p, is_favorited: true, favorites_count: p.favorites_count + 1 } : p
)
}));
}
},
// 点赞评论
likeComment: async (commentId: string) => {
// 先更新本地状态更新posts中的帖子的评论点赞状态
set(state => ({
posts: state.posts.map(p => ({
...p,
top_comment: p.top_comment && p.top_comment.id === commentId
? { ...p.top_comment, is_liked: true, likes_count: p.top_comment.likes_count + 1 }
: p.top_comment
}))
}));
try {
const updatedPost = await postService.favoritePost(postId);
if (updatedPost) {
// 使用服务器返回的数据更新
set(state => ({
posts: state.posts.map(p =>
p.id === postId ? updatedPost : p
)
}));
}
} catch (error) {
console.error('收藏帖子失败:', error);
// 回滚
set({ posts: originalPosts });
}
},
// 调用API
try {
await commentService.likeComment(commentId);
} catch (error) {
console.error('点赞评论失败:', error);
// 失败回滚状态
// 取消收藏 - 使用乐观更新工具
unfavoritePost: async (postId: string) => {
const originalPosts = get().posts;
// 乐观更新
set(state => ({
posts: state.posts.map(p => ({
...p,
top_comment: p.top_comment && p.top_comment.id === commentId
? { ...p.top_comment, is_liked: false, likes_count: Math.max(0, p.top_comment.likes_count - 1) }
: p.top_comment
}))
}));
}
},
// 取消点赞评论
unlikeComment: async (commentId: string) => {
// 先更新本地状态
set(state => ({
posts: state.posts.map(p => ({
...p,
top_comment: p.top_comment && p.top_comment.id === commentId
? { ...p.top_comment, is_liked: false, likes_count: Math.max(0, p.top_comment.likes_count - 1) }
: p.top_comment
}))
}));
// 调用API
try {
await commentService.unlikeComment(commentId);
} catch (error) {
console.error('取消点赞评论失败:', error);
// 失败回滚状态
set(state => ({
posts: state.posts.map(p => ({
...p,
top_comment: p.top_comment && p.top_comment.id === commentId
? { ...p.top_comment, is_liked: true, likes_count: p.top_comment.likes_count + 1 }
: p.top_comment
}))
}));
}
},
// 关注用户
followUser: async (userId: string) => {
// 先更新本地状态
set(state => ({
users: state.users.map(u =>
u.id === userId
? { ...u, isFollowing: true, followersCount: u.followers_count + 1 }
: u
),
userCache: Object.fromEntries(
Object.entries(state.userCache).map(([id, user]) => [
id,
id === userId
? { ...user, isFollowing: true, followersCount: user.followers_count + 1 }
: user
])
)
}));
// 调用API
try {
await authService.followUser(userId);
} catch (error) {
console.error('关注用户失败:', error);
// 失败回滚状态
set(state => ({
users: state.users.map(u =>
u.id === userId
? { ...u, isFollowing: false, followersCount: Math.max(0, u.followers_count - 1) }
: u
),
userCache: Object.fromEntries(
Object.entries(state.userCache).map(([id, user]) => [
id,
id === userId
? { ...user, isFollowing: false, followersCount: Math.max(0, user.followers_count - 1) }
: user
])
posts: state.posts.map(p =>
p.id === postId ? { ...p, is_favorited: false, favorites_count: Math.max(0, p.favorites_count - 1) } : p
)
}));
}
},
// 取消关注
unfollowUser: async (userId: string) => {
// 先更新本地状态
set(state => ({
users: state.users.map(u =>
u.id === userId
? { ...u, isFollowing: false, followersCount: Math.max(0, u.followers_count - 1) }
: u
),
userCache: Object.fromEntries(
Object.entries(state.userCache).map(([id, user]) => [
id,
id === userId
? { ...user, isFollowing: false, followersCount: Math.max(0, user.followers_count - 1) }
: user
])
)
}));
try {
const updatedPost = await postService.unfavoritePost(postId);
if (updatedPost) {
// 使用服务器返回的数据更新
set(state => ({
posts: state.posts.map(p =>
p.id === postId ? updatedPost : p
)
}));
}
} catch (error) {
console.error('取消收藏帖子失败:', error);
// 回滚
set({ posts: originalPosts });
}
},
// 调用API
try {
await authService.unfollowUser(userId);
} catch (error) {
console.error('取消关注用户失败:', error);
// 失败回滚状态
// 点赞评论 - 使用乐观更新工具
likeComment: async (commentId: string) => {
await optimisticUpdater({
key: 'posts',
optimisticUpdate: (posts) =>
posts.map(p => ({
...p,
top_comment: p.top_comment && p.top_comment.id === commentId
? { ...p.top_comment, is_liked: true, likes_count: p.top_comment.likes_count + 1 }
: p.top_comment
})),
rollbackState: (original) => original,
apiCall: () => commentService.likeComment(commentId),
errorMessage: '点赞评论失败',
});
},
// 取消点赞评论 - 使用乐观更新工具
unlikeComment: async (commentId: string) => {
await optimisticUpdater({
key: 'posts',
optimisticUpdate: (posts) =>
posts.map(p => ({
...p,
top_comment: p.top_comment && p.top_comment.id === commentId
? { ...p.top_comment, is_liked: false, likes_count: Math.max(0, p.top_comment.likes_count - 1) }
: p.top_comment
})),
rollbackState: (original) => original,
apiCall: () => commentService.unlikeComment(commentId),
errorMessage: '取消点赞评论失败',
});
},
// 关注用户 - 使用乐观更新工具
followUser: async (userId: string) => {
const originalUsers = get().users;
const originalUserCache = get().userCache;
// 乐观更新 users
set(state => ({
users: state.users.map(u =>
u.id === userId
? { ...u, isFollowing: true, followersCount: u.followers_count + 1 }
: u
),
}));
// 乐观更新 userCache
set(state => ({
userCache: Object.fromEntries(
Object.entries(state.userCache).map(([id, user]) => [
id,
@@ -566,17 +449,66 @@ export const useUserStore = create<UserState>((set, get) => ({
])
)
}));
}
},
// 刷新所有数据
refreshAll: async () => {
await Promise.all([
get().fetchPosts('recommend', 1),
get().fetchNotificationBadge(),
]);
},
}));
try {
await authService.followUser(userId);
} catch (error) {
console.error('关注用户失败:', error);
// 回滚
set({
users: originalUsers,
userCache: originalUserCache
});
}
},
// 取消关注 - 使用乐观更新工具
unfollowUser: async (userId: string) => {
const originalUsers = get().users;
const originalUserCache = get().userCache;
// 乐观更新 users
set(state => ({
users: state.users.map(u =>
u.id === userId
? { ...u, isFollowing: false, followersCount: Math.max(0, u.followers_count - 1) }
: u
),
}));
// 乐观更新 userCache
set(state => ({
userCache: Object.fromEntries(
Object.entries(state.userCache).map(([id, user]) => [
id,
id === userId
? { ...user, isFollowing: false, followersCount: Math.max(0, user.followers_count - 1) }
: user
])
)
}));
try {
await authService.unfollowUser(userId);
} catch (error) {
console.error('取消关注用户失败:', error);
// 回滚
set({
users: originalUsers,
userCache: originalUserCache
});
}
},
// 刷新所有数据
refreshAll: async () => {
await Promise.all([
get().fetchPosts('recommend', 1),
get().fetchNotificationBadge(),
]);
},
};
});
// 导出selector hooks以优化性能
export const usePosts = () => useUserStore((state) => state.posts);

View File

@@ -672,28 +672,28 @@ export interface GroupAnnouncementListResponse {
total_pages: number;
}
// ==================== WebSocket Event Types ====================
// ==================== SSE Event Types ====================
// WebSocket 事件类型
export type WSEventType = 'message' | 'notice' | 'request' | 'meta';
// SSE 事件类型
export type SSEEventType = 'message' | 'notice' | 'request' | 'meta';
// WebSocket 消息详细类型
export type WSDetailType = 'private' | 'group' | 'follow' | 'like' | 'comment' | 'request' | 'typing' | 'heartbeat' | 'read';
// SSE 消息详细类型
export type SSEDetailType = 'private' | 'group' | 'follow' | 'like' | 'comment' | 'request' | 'typing' | 'heartbeat' | 'read';
// WebSocket 事件消息段(与后端 message 字段一致)
export interface WSSegment {
// SSE 事件消息段(与后端 message 字段一致)
export interface SSESegment {
type: string;
data: Record<string, any>;
}
// WebSocket 事件(后端推送的事件格式)
export interface WSEvent {
// SSE 事件(后端推送的事件格式)
export interface SSEEvent {
id: string; // 事件唯一ID
time: number; // 事件时间戳(毫秒)
type: WSEventType; // 事件类型: message(消息), notice(通知), request(请求), meta(元事件)
detail_type: WSDetailType; // 详细类型: private(私聊), group(群聊), follow(关注), like(点赞)等
type: SSEEventType; // 事件类型: message(消息), notice(通知), request(请求), meta(元事件)
detail_type: SSEDetailType; // 详细类型: private(私聊), group(群聊), follow(关注), like(点赞)等
seq: string; // 消息序号
message?: WSSegment[]; // 消息内容数组
message?: SSESegment[]; // 消息内容数组
conversation_id: string; // 会话ID
}

View File

@@ -0,0 +1,287 @@
/**
* 乐观更新工具函数测试
*/
import { optimisticUpdate, simpleOptimisticUpdate, createOptimisticUpdaterFactory } from '../optimisticUpdate';
// 模拟数据
interface TestState {
posts: Array<{ id: string; likes: number; isLiked: boolean }>;
users: Array<{ id: string; isFollowing: boolean }>;
}
const initialState: TestState = {
posts: [
{ id: '1', likes: 10, isLiked: false },
{ id: '2', likes: 5, isLiked: true },
],
users: [
{ id: 'user1', isFollowing: false },
{ id: 'user2', isFollowing: true },
],
};
describe('optimisticUpdate', () => {
let currentState: TestState;
beforeEach(() => {
currentState = JSON.parse(JSON.stringify(initialState));
});
describe('成功场景', () => {
it('应该执行乐观更新并在API成功后保持更新', async () => {
const mockApiCall = jest.fn().mockResolvedValue({ success: true, likes: 11 });
const result = await optimisticUpdate<TestState, { success: boolean; likes: number }>({
getState: () => currentState,
optimisticUpdate: (state) => ({
...state,
posts: state.posts.map((p) =>
p.id === '1' ? { ...p, likes: p.likes + 1, isLiked: true } : p
),
}),
apiCall: mockApiCall,
onSuccess: (result, current) => ({
...current,
posts: current.posts.map((p) =>
p.id === '1' ? { ...p, likes: result.likes } : p
),
}),
});
// 验证API被调用
expect(mockApiCall).toHaveBeenCalledTimes(1);
// 验证返回结果
expect(result).toEqual({ success: true, likes: 11 });
});
it('应该在乐观更新后立即改变状态', async () => {
const mockApiCall = jest.fn().mockImplementation(() => {
// 在API调用期间验证状态已被乐观更新
expect(currentState.posts[0].isLiked).toBe(true);
expect(currentState.posts[0].likes).toBe(11);
return Promise.resolve({ success: true });
});
await optimisticUpdate<TestState, any>({
getState: () => currentState,
optimisticUpdate: (state) => {
const newState = {
...state,
posts: state.posts.map((p) =>
p.id === '1' ? { ...p, likes: p.likes + 1, isLiked: true } : p
),
};
currentState = newState; // 模拟状态更新
return newState;
},
apiCall: mockApiCall,
});
});
});
describe('失败回滚场景', () => {
it('应该在API失败时回滚到原始状态', async () => {
const mockApiCall = jest.fn().mockRejectedValue(new Error('Network error'));
const onError = jest.fn((error, original) => original);
const result = await optimisticUpdate<TestState, any>({
getState: () => currentState,
optimisticUpdate: (state) => ({
...state,
posts: state.posts.map((p) =>
p.id === '1' ? { ...p, likes: p.likes + 1, isLiked: true } : p
),
}),
apiCall: mockApiCall,
onError,
errorMessage: '点赞失败',
});
// 验证API被调用
expect(mockApiCall).toHaveBeenCalledTimes(1);
// 验证onError被调用
expect(onError).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledWith(
expect.any(Error),
initialState
);
// 验证返回undefined因为失败了
expect(result).toBeUndefined();
});
it('应该在静默模式下不抛出错误', async () => {
const mockApiCall = jest.fn().mockRejectedValue(new Error('Network error'));
// 不应该抛出错误
await expect(
optimisticUpdate<TestState, any>({
getState: () => currentState,
optimisticUpdate: (state) => state,
apiCall: mockApiCall,
silent: true,
})
).resolves.toBeUndefined();
});
it('应该在非静默模式下抛出错误', async () => {
const mockApiCall = jest.fn().mockRejectedValue(new Error('Network error'));
// 应该抛出错误
await expect(
optimisticUpdate<TestState, any>({
getState: () => currentState,
optimisticUpdate: (state) => state,
apiCall: mockApiCall,
silent: false,
})
).rejects.toThrow('Network error');
});
});
describe('错误处理', () => {
it('应该记录错误日志', async () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
const mockApiCall = jest.fn().mockRejectedValue(new Error('API Error'));
await optimisticUpdate<TestState, any>({
getState: () => currentState,
optimisticUpdate: (state) => state,
apiCall: mockApiCall,
errorMessage: '自定义错误消息',
silent: true,
});
expect(consoleSpy).toHaveBeenCalledWith('自定义错误消息:', expect.any(Error));
consoleSpy.mockRestore();
});
});
});
describe('simpleOptimisticUpdate', () => {
let currentState: TestState;
let setStateMock: jest.Mock;
beforeEach(() => {
currentState = JSON.parse(JSON.stringify(initialState));
setStateMock = jest.fn((newState) => {
currentState = newState;
});
});
it('应该成功执行乐观更新', async () => {
const mockApiCall = jest.fn().mockResolvedValue(undefined);
await simpleOptimisticUpdate<TestState>({
getState: () => currentState,
setState: setStateMock,
optimisticUpdate: (state) => ({
...state,
posts: state.posts.map((p) =>
p.id === '1' ? { ...p, likes: p.likes + 1 } : p
),
}),
rollbackState: (original) => original,
apiCall: mockApiCall,
});
expect(mockApiCall).toHaveBeenCalledTimes(1);
expect(setStateMock).toHaveBeenCalledTimes(1);
});
it('应该在失败时回滚状态', async () => {
const mockApiCall = jest.fn().mockRejectedValue(new Error('Error'));
const rollbackMock = jest.fn((original) => original);
await simpleOptimisticUpdate<TestState>({
getState: () => currentState,
setState: setStateMock,
optimisticUpdate: (state) => ({
...state,
posts: state.posts.map((p) =>
p.id === '1' ? { ...p, likes: p.likes + 1 } : p
),
}),
rollbackState: rollbackMock,
apiCall: mockApiCall,
silent: true,
});
// 验证乐观更新和回滚都被调用
expect(setStateMock).toHaveBeenCalledTimes(2);
expect(rollbackMock).toHaveBeenCalledWith(initialState);
});
});
describe('createOptimisticUpdaterFactory', () => {
interface StoreState {
posts: Array<{ id: string; likes: number }>;
count: number;
}
let storeState: StoreState;
let setMock: jest.Mock;
let getMock: jest.Mock;
beforeEach(() => {
storeState = {
posts: [{ id: '1', likes: 10 }],
count: 0,
};
setMock = jest.fn((fn) => {
storeState = fn(storeState);
});
getMock = jest.fn(() => storeState);
});
it('应该创建乐观更新器并正常工作', async () => {
const optimisticUpdater = createOptimisticUpdaterFactory<StoreState>(setMock, getMock);
const mockApiCall = jest.fn().mockResolvedValue(undefined);
await optimisticUpdater({
key: 'posts',
optimisticUpdate: (posts) =>
posts.map((p) => (p.id === '1' ? { ...p, likes: p.likes + 1 } : p)),
rollbackState: (original) => original,
apiCall: mockApiCall,
});
expect(mockApiCall).toHaveBeenCalledTimes(1);
expect(setMock).toHaveBeenCalledTimes(1);
expect(storeState.posts[0].likes).toBe(11);
});
it('应该在API失败时回滚状态', async () => {
const optimisticUpdater = createOptimisticUpdaterFactory<StoreState>(setMock, getMock);
const mockApiCall = jest.fn().mockRejectedValue(new Error('Error'));
await optimisticUpdater({
key: 'posts',
optimisticUpdate: (posts) =>
posts.map((p) => (p.id === '1' ? { ...p, likes: p.likes + 1 } : p)),
rollbackState: (original) => original,
apiCall: mockApiCall,
silent: true,
});
// 乐观更新 + 回滚 = 2次set调用
expect(setMock).toHaveBeenCalledTimes(2);
// 最终状态应该和初始状态一样
expect(storeState.posts[0].likes).toBe(10);
});
it('应该处理不同的state key', async () => {
const optimisticUpdater = createOptimisticUpdaterFactory<StoreState>(setMock, getMock);
const mockApiCall = jest.fn().mockResolvedValue(undefined);
await optimisticUpdater({
key: 'count',
optimisticUpdate: (count) => count + 1,
rollbackState: (original) => original,
apiCall: mockApiCall,
});
expect(storeState.count).toBe(1);
});
});

View File

@@ -0,0 +1,201 @@
/**
* 乐观更新工具函数
* 用于统一管理乐观更新逻辑,减少重复代码
*/
export interface OptimisticUpdateOptions<T, R> {
/** 获取当前状态 */
getState: () => T;
/** 执行乐观更新 */
optimisticUpdate: (current: T) => T;
/** API调用函数 */
apiCall: () => Promise<R>;
/** API成功时的回调返回新的状态 */
onSuccess?: (result: R, current: T) => T;
/** API失败时的回滚回调 */
onError?: (error: any, original: T) => T;
/** 错误消息 */
errorMessage?: string;
/** 是否静默处理错误(不抛出) */
silent?: boolean;
}
/**
* 执行乐观更新
*
* 流程:
* 1. 保存原始状态
* 2. 执行乐观更新
* 3. 调用API
* 4. 成功时使用服务器数据更新状态
* 5. 失败时回滚到原始状态
*
* @template T 状态类型
* @template R API返回类型
* @param options 配置选项
* @returns Promise<R> API返回结果
*/
export async function optimisticUpdate<T, R>(
options: OptimisticUpdateOptions<T, R>
): Promise<R | undefined> {
const {
getState,
optimisticUpdate,
apiCall,
onSuccess,
onError,
errorMessage = '操作失败',
silent = true,
} = options;
// 保存原始状态
const originalState = getState();
// 执行乐观更新
const optimisticState = optimisticUpdate(originalState);
try {
// 调用API
const result = await apiCall();
// API成功使用服务器数据更新状态如果提供了onSuccess
if (onSuccess) {
onSuccess(result, optimisticState);
}
return result;
} catch (error) {
console.error(`${errorMessage}:`, error);
// API失败回滚状态
if (onError) {
onError(error, originalState);
}
// 如果不是静默模式,抛出错误
if (!silent) {
throw error;
}
return undefined;
}
}
/**
* 简化版的乐观更新(适用于不需要服务器返回数据的场景)
*
* @template T 状态类型
* @param options 配置选项
*/
export async function simpleOptimisticUpdate<T>(
options: Omit<OptimisticUpdateOptions<T, void>, 'onSuccess' | 'onError'> & {
setState: (state: T) => void;
rollbackState: (original: T) => T;
}
): Promise<void> {
const {
getState,
setState,
optimisticUpdate,
rollbackState,
apiCall,
errorMessage = '操作失败',
silent = true,
} = options;
// 保存原始状态
const originalState = getState();
// 执行乐观更新
setState(optimisticUpdate(originalState));
try {
// 调用API
await apiCall();
} catch (error) {
console.error(`${errorMessage}:`, error);
// API失败回滚状态
setState(rollbackState(originalState));
// 如果不是静默模式,抛出错误
if (!silent) {
throw error;
}
}
}
/**
* 创建乐观更新器用于Zustand store
*
* 使用方式:
* ```typescript
* const createOptimisticUpdater = createOptimisticUpdaterFactory(set, get);
*
* const likePost = async (postId: string) => {
* await createOptimisticUpdater({
* getCurrentState: () => get().posts,
* setState: (posts) => set({ posts }),
* optimisticUpdate: (posts) => posts.map(...),
* rollbackState: (originalPosts) => originalPosts,
* apiCall: () => postService.likePost(postId),
* errorMessage: '点赞失败',
* });
* };
* ```
*/
export function createOptimisticUpdaterFactory<T extends Record<string, any>>(
set: (fn: (state: T) => T) => void,
get: () => T
) {
return function optimisticUpdater<K extends keyof T>(
options: {
key: K;
optimisticUpdate: (current: T[K]) => T[K];
rollbackState: (original: T[K]) => T[K];
apiCall: () => Promise<any>;
errorMessage?: string;
silent?: boolean;
}
): Promise<void> {
const {
key,
optimisticUpdate,
rollbackState,
apiCall,
errorMessage = '操作失败',
silent = true,
} = options;
// 保存原始状态
const originalState = get()[key];
// 执行乐观更新
set((state) => ({
...state,
[key]: optimisticUpdate(state[key]),
}));
return new Promise((resolve, reject) => {
apiCall()
.then(() => {
resolve();
})
.catch((error) => {
console.error(`${errorMessage}:`, error);
// 回滚状态
set((state) => ({
...state,
[key]: rollbackState(originalState),
}));
if (silent) {
resolve();
} else {
reject(error);
}
});
});
};
}

View File

@@ -4,7 +4,7 @@
*/
import { ViewStyle } from 'react-native';
import { BREAKPOINTS } from '../hooks/useResponsive';
import { BREAKPOINTS } from '../presentation/hooks/responsive';
/**
* 断点键名