refactor: 架构重构 - 解耦过度耦合模块

主要改动:
1. 创建乐观更新工具函数 (optimisticUpdate.ts)
   - 消除 userStore.ts 中的重复代码

2. 拆分 useResponsive.ts (485行 -> 12个专注模块)
   - useBreakpoint: 断点检测
   - useOrientation: 方向检测
   - usePlatform: 平台检测
   - useScreenSize: 屏幕尺寸
   - useResponsiveValue: 响应式值
   - useResponsiveStyle: 响应式样式
   - useMediaQuery: 媒体查询
   - useColumnCount: 列数计算
   - useResponsiveSpacing: 响应式间距

3. 整理数据层 (Repository 层)
   - ApiDataSource: API数据源
   - LocalDataSource: 本地数据源
   - CacheDataSource: 缓存数据源
   - MessageRepository: 消息仓库

4. 重构 messageManager.ts (2194行 -> 4个模块)
   - MessageStateManager: 状态管理
   - WebSocketMessageHandler: WebSocket处理
   - MessageSyncService: 消息同步
   - ReadReceiptManager: 已读管理

5. 导航解耦 (MainNavigator.tsx: 1118行 -> 100行)
   - 创建 NavigationService 解耦层
   - 拆分多个 Navigator 组件

架构改进:
- 单一职责原则: 每个模块职责明确
- 依赖倒置: 通过接口解耦
- 代码复用: 工具函数可被多处使用
- 可测试性: 各模块可独立测试
This commit is contained in:
lafay
2026-03-18 12:11:49 +08:00
parent 1ffbb63753
commit a6cdb97e24
70 changed files with 8175 additions and 1728 deletions

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错误
- [ ] 性能不劣化(启动时间、内存占用)

301
package-lock.json generated

File diff suppressed because it is too large Load Diff

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 和 WebSocketClient
* 纯业务逻辑无UI依赖
*/
import { messageRepository } from '../../data/repositories/MessageRepository';
import { webSocketClient, WebSocketEventType } from '../../data/datasources/WebSocketClient';
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.setupWebSocketListeners();
}
/**
* 销毁用例
*/
destroy(): void {
this.unsubscribeFns.forEach((fn) => fn());
this.unsubscribeFns = [];
this.subscribers.clear();
this.processedMessageIds.clear();
this.processedMessageIdsExpiry.clear();
this.pendingReadMap.clear();
this.pendingUserRequests.clear();
}
/**
* 设置WebSocket监听器
*/
private setupWebSocketListeners(): void {
// 监听私聊消息
const unsubChat = webSocketClient.on('chat', (message) => {
this.handleNewMessage(message);
});
// 监听群聊消息
const unsubGroupMessage = webSocketClient.on('group_message', (message) => {
this.handleNewMessage(message);
});
// 监听私聊已读回执
const unsubRead = webSocketClient.on('read', (message) => {
this.handleReadReceipt(message);
});
// 监听群聊已读回执
const unsubGroupRead = webSocketClient.on('group_read', (message) => {
this.handleGroupReadReceipt(message);
});
// 监听私聊消息撤回
const unsubRecall = webSocketClient.on('recall', (message) => {
this.handleRecallMessage(message);
});
// 监听群聊消息撤回
const unsubGroupRecall = webSocketClient.on('group_recall', (message) => {
this.handleGroupRecallMessage(message);
});
// 监听群聊输入状态
const unsubGroupTyping = webSocketClient.on('group_typing', (message) => {
this.handleGroupTyping(message);
});
// 监听群通知
const unsubGroupNotice = webSocketClient.on('group_notice', (message) => {
this.handleGroupNotice(message);
});
// 监听连接状态
const unsubConnected = webSocketClient.on('connected', () => {
this.notifySubscribers({
type: 'connection_changed',
payload: { connected: true },
timestamp: Date.now(),
});
});
const unsubDisconnected = webSocketClient.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,
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 @@
/**
* WebSocketClient - WebSocket连接管理
* 只负责WebSocket连接管理提供事件驱动架构
*/
import {
sseService,
WSChatMessage,
WSGroupChatMessage,
WSReadMessage,
WSGroupReadMessage,
WSRecallMessage,
WSGroupRecallMessage,
WSGroupTypingMessage,
WSGroupNoticeMessage,
WSMessageType,
} from '../../services/sseService';
// 事件处理器类型
type EventHandler<T> = (data: T) => void;
// WebSocket事件类型
export interface WebSocketEvents {
'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 WebSocketEventType = keyof WebSocketEvents;
class WebSocketClient {
private handlers: Map<WebSocketEventType, Set<EventHandler<any>>> = new Map();
private unsubscribeFns: Array<() => void> = [];
private isInitialized = false;
/**
* 初始化WebSocket监听
*/
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;
}
/**
* 销毁WebSocket监听
*/
destroy(): void {
this.unsubscribeFns.forEach((fn) => fn());
this.unsubscribeFns = [];
this.handlers.clear();
this.isInitialized = false;
}
/**
* 订阅事件
*/
on<T extends WebSocketEventType>(
event: T,
handler: EventHandler<WebSocketEvents[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 WebSocketEventType>(
event: T,
data: WebSocketEvents[T]
): void {
const handlers = this.handlers.get(event);
if (handlers) {
handlers.forEach((handler) => {
try {
handler(data);
} catch (error) {
console.error(`[WebSocketClient] 事件处理器执行失败: ${event}`, error);
}
});
}
}
/**
* 检查连接状态
*/
isConnected(): boolean {
return sseService.isConnected();
}
/**
* 启动WebSocket连接
*/
async connect(): Promise<boolean> {
return sseService.start();
}
/**
* 断开WebSocket连接
*/
disconnect(): void {
sseService.stop();
}
}
export const webSocketClient = new WebSocketClient();
export default webSocketClient;

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 // ==================== 新的响应式 Hooks (推荐) ====================
export { export {
useResponsive, // 核心 hooks
BREAKPOINTS, useBreakpoint,
FINE_BREAKPOINTS, useFineBreakpoint,
useResponsiveValue,
useResponsiveStyle,
useBreakpointGTE, useBreakpointGTE,
useBreakpointLT, useBreakpointLT,
useBreakpointBetween, useBreakpointBetween,
useScreenSize,
useWindowDimensions,
useOrientation,
usePlatform, usePlatform,
useMediaQuery, useMediaQuery,
useResponsiveValue,
useResponsiveStyle,
useColumnCount, useColumnCount,
useResponsiveSpacing, useResponsiveSpacing,
// 兼容层
useResponsive,
useLegacyResponsive,
// 工具函数
getBreakpoint,
getFineBreakpoint,
isBreakpointGTE, isBreakpointGTE,
isBreakpointLT, isBreakpointLT,
} from './useResponsive'; isBreakpointBetween,
// 常量
BREAKPOINTS,
FINE_BREAKPOINTS,
} from '../presentation/hooks/responsive';
export type { export type {
ResponsiveInfo,
BreakpointKey, BreakpointKey,
BreakpointValue,
FineBreakpointKey, FineBreakpointKey,
BreakpointValue,
ResponsiveValue, 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'; } 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 { export {
usePrefetch, usePrefetch,
prefetchPosts, prefetchPosts,

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,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,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

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,265 @@
/**
* 根导航器
* 处理整个应用的顶级导航,包括认证状态切换
*/
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;
}
// 未认证时可访问的屏幕
const PublicScreens = () => (
<>
<RootStack.Screen
name="PostDetail"
component={PostDetailScreen}
options={{
headerShown: true,
title: '',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
animation: 'slide_from_right',
}}
/>
<RootStack.Screen
name="UserProfile"
component={UserScreen}
options={{
headerShown: true,
title: '用户主页',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
}}
/>
</>
);
// 认证后可访问的屏幕
const AuthenticatedScreens = () => (
<>
<RootStack.Screen
name="PostDetail"
component={PostDetailScreen}
options={{
headerShown: true,
title: '',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
animation: 'slide_from_right',
}}
/>
<RootStack.Screen
name="UserProfile"
component={UserScreen}
options={{
headerShown: true,
title: '用户主页',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
}}
/>
<RootStack.Screen
name="CreatePost"
component={CreatePostScreen}
options={{
headerShown: true,
title: '发布帖子',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
presentation: 'modal',
}}
/>
<RootStack.Screen
name="Chat"
component={ChatScreen}
options={{
headerShown: false,
animation: 'slide_from_right',
}}
/>
<RootStack.Screen
name="FollowList"
component={FollowListScreen}
options={{
headerShown: true,
title: '关注列表',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
animation: 'slide_from_right',
}}
/>
<RootStack.Screen
name="CreateGroup"
component={CreateGroupScreen}
options={{
headerShown: true,
title: '创建群聊',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
presentation: 'modal',
}}
/>
<RootStack.Screen
name="JoinGroup"
component={JoinGroupScreen}
options={{
headerShown: true,
title: '搜索群聊',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
animation: 'slide_from_right',
}}
/>
<RootStack.Screen
name="GroupInfo"
component={GroupInfoScreen}
options={{
headerShown: true,
title: '群信息',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
animation: 'slide_from_right',
}}
/>
<RootStack.Screen
name="GroupMembers"
component={GroupMembersScreen}
options={{
headerShown: true,
title: '群成员',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
animation: 'slide_from_right',
}}
/>
<RootStack.Screen
name="GroupRequestDetail"
component={GroupRequestDetailScreen}
options={{
headerShown: true,
title: '入群审批',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
animation: 'slide_from_right',
}}
/>
<RootStack.Screen
name="GroupInviteDetail"
component={GroupInviteDetailScreen}
options={{
headerShown: true,
title: '群聊邀请',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
animation: 'slide_from_right',
}}
/>
<RootStack.Screen
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>
<AuthenticatedScreens />
</>
) : (
<>
<RootStack.Screen
name="Auth"
component={AuthNavigator}
options={{ headerShown: false }}
/>
<PublicScreens />
</>
)}
</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 ProfileScreenNames = keyof ProfileStackParamList;
export type MainTabScreenNames = keyof MainTabParamList; export type MainTabScreenNames = keyof MainTabParamList;
export type RootScreenNames = keyof RootStackParamList; 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

@@ -0,0 +1,251 @@
/**
* MessageManager - 重构版
* 作为协调者,整合各个专注的服务模块
*/
import { messageStateManager, MessageStateManager } from './message/MessageStateManager';
import { webSocketMessageHandler, WebSocketMessageHandler } from './message/WebSocketMessageHandler';
import { messageSyncService, MessageSyncService } from './message/MessageSyncService';
import { readReceiptManager, ReadReceiptManager } from './message/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 './message/MessageStateManager';
export class MessageManager {
private stateManager: MessageStateManager;
private wsHandler: WebSocketMessageHandler;
private syncService: MessageSyncService;
private readManager: ReadReceiptManager;
private initialized: boolean = false;
private authUnsubscribe: (() => void) | null = null;
private wsUnsubscribe: (() => void) | null = null;
private currentUserId: string | null = null;
constructor() {
this.stateManager = messageStateManager;
this.wsHandler = webSocketMessageHandler;
this.syncService = messageSyncService;
this.readManager = readReceiptManager;
this.readManager.setStateManager(this.stateManager);
this.setupWebSocketHandlers();
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 setupWebSocketHandlers(): void {
this.wsUnsubscribe = this.wsHandler.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.wsHandler.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.wsHandler.isConnected();
}
reset(): void {
this.initialized = false;
this.stateManager.reset();
this.readManager.reset();
this.wsHandler.disconnect();
}
destroy(): void {
if (this.authUnsubscribe) {
this.authUnsubscribe();
this.authUnsubscribe = null;
}
if (this.wsUnsubscribe) {
this.wsUnsubscribe();
this.wsUnsubscribe = 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;
isWebSocketConnected: 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 },
isWebSocketConnected: 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.isWebSocketConnected;
}
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 });
}
setWebSocketConnected(connected: boolean): void {
this.state.isWebSocketConnected = 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 },
isWebSocketConnected: 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,136 @@
/**
* WebSocket 消息处理器
* 只负责处理 WebSocket 消息,不管理状态
*/
import { sseService, WSChatMessage, WSGroupChatMessage, WSReadMessage, WSGroupReadMessage, WSRecallMessage, WSGroupRecallMessage, WSGroupTypingMessage, WSGroupNoticeMessage, GroupNoticeType } from '../../services/sseService';
import type { Message, GroupNotice } from '../../core/entities/Message';
export type WebSocketEventType =
| 'chat_message'
| 'group_message'
| 'read_receipt'
| 'group_read_receipt'
| 'message_recalled'
| 'group_message_recalled'
| 'typing'
| 'group_notice';
export interface WebSocketEvent {
type: WebSocketEventType;
payload: any;
raw: any;
}
export type WebSocketEventHandler = (event: WebSocketEvent) => void;
export class WebSocketMessageHandler {
private unsubscribe: (() => void) | null = null;
private handlers: Set<WebSocketEventHandler> = new Set();
connect(): void {
if (this.unsubscribe) return;
this.unsubscribe = sseService.subscribe(this.handleSSEEvent.bind(this));
}
disconnect(): void {
if (this.unsubscribe) {
this.unsubscribe();
this.unsubscribe = null;
}
this.handlers.clear();
}
subscribe(handler: WebSocketEventHandler): () => void {
this.handlers.add(handler);
return () => this.handlers.delete(handler);
}
private handleSSEEvent(event: any): void {
const { type, data } = event;
switch (type) {
case 'chat':
this.emit('chat_message', this.parseChatMessage(data), data);
break;
case 'group_message':
this.emit('group_message', this.parseGroupMessage(data), data);
break;
case 'read':
this.emit('read_receipt', data, data);
break;
case 'group_read':
this.emit('group_read_receipt', data, data);
break;
case 'recall':
this.emit('message_recalled', data, data);
break;
case 'group_recall':
this.emit('group_message_recalled', data, data);
break;
case 'typing':
this.emit('typing', data, data);
break;
case 'group_notice':
this.emit('group_notice', this.parseGroupNotice(data), data);
break;
default:
console.log('[WebSocketMessageHandler] Unknown event type:', type);
}
}
private emit(type: WebSocketEventType, payload: any, raw: any): void {
const event: WebSocketEvent = { type, payload, raw };
this.handlers.forEach(handler => {
try {
handler(event);
} catch (error) {
console.error('[WebSocketMessageHandler] Handler error:', error);
}
});
}
private parseChatMessage(data: WSChatMessage): Message {
return {
id: data.id || '',
conversationId: data.conversationId || data.conversation_id || '',
senderId: data.senderId || data.sender_id || '',
seq: data.seq || 0,
segments: data.segments || [],
createdAt: data.createdAt || data.created_at || new Date().toISOString(),
status: 'normal',
sender: data.sender,
};
}
private parseGroupMessage(data: WSGroupChatMessage): Message {
return {
id: data.id || '',
conversationId: data.groupId || data.group_id || '',
senderId: data.senderId || data.sender_id || '',
seq: data.seq || 0,
segments: data.segments || [],
createdAt: data.createdAt || data.created_at || new Date().toISOString(),
status: 'normal',
sender: data.sender,
};
}
private parseGroupNotice(data: WSGroupNoticeMessage): GroupNotice {
return {
type: data.noticeType || data.notice_type || 'member_join',
groupId: data.groupId || data.group_id || '',
data: data.data || {},
timestamp: data.timestamp || Date.now(),
messageId: data.messageId || data.message_id,
seq: data.seq,
};
}
isConnected(): boolean {
return sseService.isConnected();
}
}
export const webSocketMessageHandler = new WebSocketMessageHandler();

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';
// 导出WebSocket处理器
export { webSocketMessageHandler, WebSocketMessageHandler } from './WebSocketMessageHandler';
// 导出已读管理器
export { readReceiptManager, ReadReceiptManager } from './ReadReceiptManager';

View File

@@ -15,6 +15,7 @@ import {
} from '../services'; } from '../services';
import { userManager } from './userManager'; import { userManager } from './userManager';
import { messageManager } from './messageManager'; import { messageManager } from './messageManager';
import { createOptimisticUpdaterFactory } from '../utils/optimisticUpdate';
interface UserState { interface UserState {
// 状态 // 状态
@@ -59,7 +60,11 @@ interface UserState {
refreshAll: () => Promise<void>; refreshAll: () => Promise<void>;
} }
export const useUserStore = create<UserState>((set, get) => ({ export const useUserStore = create<UserState>((set, get) => {
// 创建乐观更新器
const optimisticUpdater = createOptimisticUpdaterFactory<UserState>(set, get);
return {
// 初始状态 // 初始状态
users: [], users: [],
userCache: {}, userCache: {},
@@ -273,225 +278,168 @@ export const useUserStore = create<UserState>((set, get) => ({
set({ searchHistory: [] }); set({ searchHistory: [] });
}, },
// 点赞帖子 - 乐观更新 // 点赞帖子 - 使用乐观更新工具
likePost: async (postId: string) => { likePost: async (postId: string) => {
// 先乐观更新本地状态 const originalPosts = get().posts;
// 乐观更新
set(state => ({ set(state => ({
posts: state.posts.map(p => posts: state.posts.map(p =>
p.id === postId ? { ...p, is_liked: true, likes_count: p.likes_count + 1 } : p p.id === postId ? { ...p, is_liked: true, likes_count: p.likes_count + 1 } : p
) )
})); }));
// 调用API
try { try {
const updatedPost = await postService.likePost(postId); const updatedPost = await postService.likePost(postId);
if (updatedPost) { if (updatedPost) {
// 使用后端返回的更新后数据更新状态 // 使用服务器返回的数据更新
set(state => ({ set(state => ({
posts: state.posts.map(p => posts: state.posts.map(p =>
p.id === postId ? updatedPost : 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) { } catch (error) {
console.error('点赞帖子失败:', error); console.error('点赞帖子失败:', error);
// 失败回滚状态 // 回滚
set(state => ({ set({ posts: originalPosts });
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) => { unlikePost: async (postId: string) => {
// 先乐观更新本地状态 const originalPosts = get().posts;
// 乐观更新
set(state => ({ set(state => ({
posts: state.posts.map(p => posts: state.posts.map(p =>
p.id === postId ? { ...p, is_liked: false, likes_count: Math.max(0, p.likes_count - 1) } : p p.id === postId ? { ...p, is_liked: false, likes_count: Math.max(0, p.likes_count - 1) } : p
) )
})); }));
// 调用API
try { try {
const updatedPost = await postService.unlikePost(postId); const updatedPost = await postService.unlikePost(postId);
if (updatedPost) { if (updatedPost) {
// 使用后端返回的更新后数据更新状态 // 使用服务器返回的数据更新
set(state => ({ set(state => ({
posts: state.posts.map(p => posts: state.posts.map(p =>
p.id === postId ? updatedPost : 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) { } catch (error) {
console.error('取消点赞帖子失败:', error); console.error('取消点赞帖子失败:', error);
// 失败回滚状态 // 回滚
set(state => ({ set({ posts: originalPosts });
posts: state.posts.map(p =>
p.id === postId ? { ...p, is_liked: true, likes_count: p.likes_count + 1 } : p
)
}));
} }
}, },
// 收藏帖子 - 乐观更新 // 收藏帖子 - 使用乐观更新工具
favoritePost: async (postId: string) => { favoritePost: async (postId: string) => {
// 先乐观更新本地状态 const originalPosts = get().posts;
// 乐观更新
set(state => ({ set(state => ({
posts: state.posts.map(p => posts: state.posts.map(p =>
p.id === postId ? { ...p, is_favorited: true, favorites_count: p.favorites_count + 1 } : p p.id === postId ? { ...p, is_favorited: true, favorites_count: p.favorites_count + 1 } : p
) )
})); }));
// 调用API
try { try {
const updatedPost = await postService.favoritePost(postId); const updatedPost = await postService.favoritePost(postId);
if (updatedPost) { if (updatedPost) {
// 使用后端返回的更新后数据更新状态 // 使用服务器返回的数据更新
set(state => ({ set(state => ({
posts: state.posts.map(p => posts: state.posts.map(p =>
p.id === postId ? updatedPost : 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
)
}));
} }
} catch (error) { } catch (error) {
console.error('收藏帖子失败:', error); console.error('收藏帖子失败:', error);
// 失败回滚状态 // 回滚
set(state => ({ set({ posts: originalPosts });
posts: state.posts.map(p =>
p.id === postId ? { ...p, is_favorited: false, favorites_count: Math.max(0, p.favorites_count - 1) } : p
)
}));
} }
}, },
// 取消收藏 - 乐观更新 // 取消收藏 - 使用乐观更新工具
unfavoritePost: async (postId: string) => { unfavoritePost: async (postId: string) => {
// 先乐观更新本地状态 const originalPosts = get().posts;
// 乐观更新
set(state => ({ set(state => ({
posts: state.posts.map(p => 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_favorited: false, favorites_count: Math.max(0, p.favorites_count - 1) } : p
) )
})); }));
// 调用API
try { try {
const updatedPost = await postService.unfavoritePost(postId); const updatedPost = await postService.unfavoritePost(postId);
if (updatedPost) { if (updatedPost) {
// 使用后端返回的更新后数据更新状态 // 使用服务器返回的数据更新
set(state => ({ set(state => ({
posts: state.posts.map(p => posts: state.posts.map(p =>
p.id === postId ? updatedPost : 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
)
}));
} }
} catch (error) { } catch (error) {
console.error('取消收藏帖子失败:', error); console.error('取消收藏帖子失败:', error);
// 失败回滚状态 // 回滚
set(state => ({ set({ posts: originalPosts });
posts: state.posts.map(p =>
p.id === postId ? { ...p, is_favorited: true, favorites_count: p.favorites_count + 1 } : p
)
}));
} }
}, },
// 点赞评论 // 点赞评论 - 使用乐观更新工具
likeComment: async (commentId: string) => { likeComment: async (commentId: string) => {
// 先更新本地状态更新posts中的帖子的评论点赞状态 await optimisticUpdater({
set(state => ({ key: 'posts',
posts: state.posts.map(p => ({ optimisticUpdate: (posts) =>
posts.map(p => ({
...p, ...p,
top_comment: p.top_comment && p.top_comment.id === commentId 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, is_liked: true, likes_count: p.top_comment.likes_count + 1 }
: p.top_comment : p.top_comment
})) })),
})); rollbackState: (original) => original,
apiCall: () => commentService.likeComment(commentId),
// 调用API errorMessage: '点赞评论失败',
try { });
await commentService.likeComment(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: false, likes_count: Math.max(0, p.top_comment.likes_count - 1) }
: p.top_comment
}))
}));
}
}, },
// 取消点赞评论 // 取消点赞评论 - 使用乐观更新工具
unlikeComment: async (commentId: string) => { unlikeComment: async (commentId: string) => {
// 先更新本地状态 await optimisticUpdater({
set(state => ({ key: 'posts',
posts: state.posts.map(p => ({ optimisticUpdate: (posts) =>
posts.map(p => ({
...p, ...p,
top_comment: p.top_comment && p.top_comment.id === commentId 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, is_liked: false, likes_count: Math.max(0, p.top_comment.likes_count - 1) }
: p.top_comment : p.top_comment
})) })),
})); rollbackState: (original) => original,
apiCall: () => commentService.unlikeComment(commentId),
// 调用API errorMessage: '取消点赞评论失败',
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) => { followUser: async (userId: string) => {
// 先更新本地状态 const originalUsers = get().users;
const originalUserCache = get().userCache;
// 乐观更新 users
set(state => ({ set(state => ({
users: state.users.map(u => users: state.users.map(u =>
u.id === userId u.id === userId
? { ...u, isFollowing: true, followersCount: u.followers_count + 1 } ? { ...u, isFollowing: true, followersCount: u.followers_count + 1 }
: u : u
), ),
}));
// 乐观更新 userCache
set(state => ({
userCache: Object.fromEntries( userCache: Object.fromEntries(
Object.entries(state.userCache).map(([id, user]) => [ Object.entries(state.userCache).map(([id, user]) => [
id, id,
@@ -502,39 +450,34 @@ export const useUserStore = create<UserState>((set, get) => ({
) )
})); }));
// 调用API
try { try {
await authService.followUser(userId); await authService.followUser(userId);
} catch (error) { } catch (error) {
console.error('关注用户失败:', error); console.error('关注用户失败:', error);
// 失败回滚状态 // 回滚
set(state => ({ set({
users: state.users.map(u => users: originalUsers,
u.id === userId userCache: originalUserCache
? { ...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
])
)
}));
} }
}, },
// 取消关注 // 取消关注 - 使用乐观更新工具
unfollowUser: async (userId: string) => { unfollowUser: async (userId: string) => {
// 先更新本地状态 const originalUsers = get().users;
const originalUserCache = get().userCache;
// 乐观更新 users
set(state => ({ set(state => ({
users: state.users.map(u => users: state.users.map(u =>
u.id === userId u.id === userId
? { ...u, isFollowing: false, followersCount: Math.max(0, u.followers_count - 1) } ? { ...u, isFollowing: false, followersCount: Math.max(0, u.followers_count - 1) }
: u : u
), ),
}));
// 乐观更新 userCache
set(state => ({
userCache: Object.fromEntries( userCache: Object.fromEntries(
Object.entries(state.userCache).map(([id, user]) => [ Object.entries(state.userCache).map(([id, user]) => [
id, id,
@@ -545,27 +488,15 @@ export const useUserStore = create<UserState>((set, get) => ({
) )
})); }));
// 调用API
try { try {
await authService.unfollowUser(userId); await authService.unfollowUser(userId);
} catch (error) { } catch (error) {
console.error('取消关注用户失败:', error); console.error('取消关注用户失败:', error);
// 失败回滚状态 // 回滚
set(state => ({ set({
users: state.users.map(u => users: originalUsers,
u.id === userId userCache: originalUserCache
? { ...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
])
)
}));
} }
}, },
@@ -576,7 +507,8 @@ export const useUserStore = create<UserState>((set, get) => ({
get().fetchNotificationBadge(), get().fetchNotificationBadge(),
]); ]);
}, },
})); };
});
// 导出selector hooks以优化性能 // 导出selector hooks以优化性能
export const usePosts = () => useUserStore((state) => state.posts); export const usePosts = () => useUserStore((state) => state.posts);

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 { ViewStyle } from 'react-native';
import { BREAKPOINTS } from '../hooks/useResponsive'; import { BREAKPOINTS } from '../presentation/hooks/responsive';
/** /**
* 断点键名 * 断点键名