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:
231
ARCHITECTURE_REFACTOR_PLAN.md
Normal file
231
ARCHITECTURE_REFACTOR_PLAN.md
Normal 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
301
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
144
src/core/entities/Message.ts
Normal file
144
src/core/entities/Message.ts
Normal 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 '';
|
||||
}
|
||||
};
|
||||
656
src/core/usecases/ProcessMessageUseCase.ts
Normal file
656
src/core/usecases/ProcessMessageUseCase.ts
Normal 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;
|
||||
103
src/data/datasources/ApiDataSource.ts
Normal file
103
src/data/datasources/ApiDataSource.ts
Normal 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();
|
||||
199
src/data/datasources/CacheDataSource.ts
Normal file
199
src/data/datasources/CacheDataSource.ts
Normal 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();
|
||||
281
src/data/datasources/LocalDataSource.ts
Normal file
281
src/data/datasources/LocalDataSource.ts
Normal 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();
|
||||
184
src/data/datasources/WebSocketClient.ts
Normal file
184
src/data/datasources/WebSocketClient.ts
Normal 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;
|
||||
9
src/data/datasources/index.ts
Normal file
9
src/data/datasources/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 数据源导出
|
||||
* 统一导出所有数据源实现
|
||||
*/
|
||||
|
||||
export * from './interfaces';
|
||||
export * from './ApiDataSource';
|
||||
export * from './LocalDataSource';
|
||||
export * from './CacheDataSource';
|
||||
64
src/data/datasources/interfaces.ts
Normal file
64
src/data/datasources/interfaces.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
290
src/data/mappers/ConversationMapper.ts
Normal file
290
src/data/mappers/ConversationMapper.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
183
src/data/mappers/MessageMapper.ts
Normal file
183
src/data/mappers/MessageMapper.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
123
src/data/mappers/PostMapper.ts
Normal file
123
src/data/mappers/PostMapper.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
168
src/data/mappers/UserMapper.ts
Normal file
168
src/data/mappers/UserMapper.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
9
src/data/mappers/index.ts
Normal file
9
src/data/mappers/index.ts
Normal 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
153
src/data/models/index.ts
Normal 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;
|
||||
}
|
||||
220
src/data/repositories/MessageRepository.ts
Normal file
220
src/data/repositories/MessageRepository.ts
Normal 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;
|
||||
93
src/data/repositories/interfaces/IMessageRepository.ts
Normal file
93
src/data/repositories/interfaces/IMessageRepository.ts
Normal 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>;
|
||||
}
|
||||
@@ -2,31 +2,66 @@
|
||||
* Hooks 导出
|
||||
*/
|
||||
|
||||
// 响应式相关 Hooks
|
||||
// ==================== 新的响应式 Hooks (推荐) ====================
|
||||
export {
|
||||
useResponsive,
|
||||
BREAKPOINTS,
|
||||
FINE_BREAKPOINTS,
|
||||
useResponsiveValue,
|
||||
useResponsiveStyle,
|
||||
// 核心 hooks
|
||||
useBreakpoint,
|
||||
useFineBreakpoint,
|
||||
useBreakpointGTE,
|
||||
useBreakpointLT,
|
||||
useBreakpointBetween,
|
||||
useScreenSize,
|
||||
useWindowDimensions,
|
||||
useOrientation,
|
||||
usePlatform,
|
||||
useMediaQuery,
|
||||
useResponsiveValue,
|
||||
useResponsiveStyle,
|
||||
useColumnCount,
|
||||
useResponsiveSpacing,
|
||||
// 兼容层
|
||||
useResponsive,
|
||||
useLegacyResponsive,
|
||||
// 工具函数
|
||||
getBreakpoint,
|
||||
getFineBreakpoint,
|
||||
isBreakpointGTE,
|
||||
isBreakpointLT,
|
||||
} from './useResponsive';
|
||||
isBreakpointBetween,
|
||||
// 常量
|
||||
BREAKPOINTS,
|
||||
FINE_BREAKPOINTS,
|
||||
} from '../presentation/hooks/responsive';
|
||||
|
||||
export type {
|
||||
ResponsiveInfo,
|
||||
BreakpointKey,
|
||||
BreakpointValue,
|
||||
FineBreakpointKey,
|
||||
BreakpointValue,
|
||||
ResponsiveValue,
|
||||
Orientation,
|
||||
PlatformInfo,
|
||||
ScreenSize,
|
||||
MediaQueryOptions,
|
||||
// 兼容层类型
|
||||
ResponsiveInfo,
|
||||
} from '../presentation/hooks/responsive';
|
||||
|
||||
// ==================== 旧版响应式 Hooks (保持向后兼容) ====================
|
||||
// 注意:这些导出将在未来版本中移除,请使用新的 hooks
|
||||
export {
|
||||
BREAKPOINTS as BREAKPOINTS_OLD,
|
||||
FINE_BREAKPOINTS as FINE_BREAKPOINTS_OLD,
|
||||
} from './useResponsive';
|
||||
|
||||
export type {
|
||||
ResponsiveInfo as ResponsiveInfo_OLD,
|
||||
BreakpointKey as BreakpointKey_OLD,
|
||||
BreakpointValue as BreakpointValue_OLD,
|
||||
FineBreakpointKey as FineBreakpointKey_OLD,
|
||||
ResponsiveValue as ResponsiveValue_OLD,
|
||||
} from './useResponsive';
|
||||
|
||||
// ==================== 其他 Hooks ====================
|
||||
export {
|
||||
usePrefetch,
|
||||
prefetchPosts,
|
||||
|
||||
82
src/hooks/responsive/constants.ts
Normal file
82
src/hooks/responsive/constants.ts
Normal 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);
|
||||
}
|
||||
45
src/hooks/responsive/index.ts
Normal file
45
src/hooks/responsive/index.ts
Normal 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';
|
||||
62
src/hooks/responsive/useBreakpoint.ts
Normal file
62
src/hooks/responsive/useBreakpoint.ts
Normal 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]);
|
||||
}
|
||||
46
src/hooks/responsive/useBreakpointRange.ts
Normal file
46
src/hooks/responsive/useBreakpointRange.ts
Normal 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]
|
||||
);
|
||||
}
|
||||
53
src/hooks/responsive/useColumnCount.ts
Normal file
53
src/hooks/responsive/useColumnCount.ts
Normal 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]);
|
||||
}
|
||||
39
src/hooks/responsive/useMediaQuery.ts
Normal file
39
src/hooks/responsive/useMediaQuery.ts
Normal 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]);
|
||||
}
|
||||
36
src/hooks/responsive/useOrientation.ts
Normal file
36
src/hooks/responsive/useOrientation.ts
Normal 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]);
|
||||
}
|
||||
31
src/hooks/responsive/usePlatform.ts
Normal file
31
src/hooks/responsive/usePlatform.ts
Normal 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',
|
||||
}), []);
|
||||
}
|
||||
103
src/hooks/responsive/useResponsive.ts
Normal file
103
src/hooks/responsive/useResponsive.ts
Normal 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 建议使用专注的 Hooks:useBreakpoint, 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;
|
||||
48
src/hooks/responsive/useResponsiveSpacing.ts
Normal file
48
src/hooks/responsive/useResponsiveSpacing.ts
Normal 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]);
|
||||
}
|
||||
53
src/hooks/responsive/useResponsiveStyle.ts
Normal file
53
src/hooks/responsive/useResponsiveStyle.ts
Normal 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]);
|
||||
}
|
||||
42
src/hooks/responsive/useResponsiveValue.ts
Normal file
42
src/hooks/responsive/useResponsiveValue.ts
Normal 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 屏幕返回 8,md 屏幕返回 16,lg 及以上返回 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]);
|
||||
}
|
||||
34
src/hooks/responsive/useScreenSize.ts
Normal file
34
src/hooks/responsive/useScreenSize.ts
Normal 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]);
|
||||
}
|
||||
27
src/hooks/responsive/useWindowDimensions.ts
Normal file
27
src/hooks/responsive/useWindowDimensions.ts
Normal 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;
|
||||
}
|
||||
60
src/infrastructure/navigation/hooks/useNavigationState.ts
Normal file
60
src/infrastructure/navigation/hooks/useNavigationState.ts
Normal 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;
|
||||
}
|
||||
110
src/infrastructure/navigation/navigationService.ts
Normal file
110
src/infrastructure/navigation/navigationService.ts
Normal 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();
|
||||
58
src/infrastructure/navigation/types.ts
Normal file
58
src/infrastructure/navigation/types.ts
Normal 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,
|
||||
};
|
||||
25
src/navigation/AuthNavigator.tsx
Normal file
25
src/navigation/AuthNavigator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
261
src/navigation/DesktopNavigator.tsx
Normal file
261
src/navigation/DesktopNavigator.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
49
src/navigation/HomeNavigator.tsx
Normal file
49
src/navigation/HomeNavigator.tsx
Normal 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
60
src/navigation/MessageNavigator.tsx
Normal file
60
src/navigation/MessageNavigator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
src/navigation/ProfileNavigator.tsx
Normal file
101
src/navigation/ProfileNavigator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
265
src/navigation/RootNavigator.tsx
Normal file
265
src/navigation/RootNavigator.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
42
src/navigation/ScheduleNavigator.tsx
Normal file
42
src/navigation/ScheduleNavigator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
155
src/navigation/TabNavigator.tsx
Normal file
155
src/navigation/TabNavigator.tsx
Normal 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
30
src/navigation/index.ts
Normal 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';
|
||||
@@ -121,3 +121,14 @@ export type MessageScreenNames = keyof MessageStackParamList;
|
||||
export type ProfileScreenNames = keyof ProfileStackParamList;
|
||||
export type MainTabScreenNames = keyof MainTabParamList;
|
||||
export type RootScreenNames = keyof RootStackParamList;
|
||||
|
||||
// ==================== Tab 类型 ====================
|
||||
export type TabName = 'HomeTab' | 'MessageTab' | 'ScheduleTab' | 'ProfileTab';
|
||||
|
||||
// ==================== 导航项配置 ====================
|
||||
export interface NavItemConfig {
|
||||
name: TabName;
|
||||
label: string;
|
||||
icon: string;
|
||||
iconOutline: string;
|
||||
}
|
||||
|
||||
223
src/presentation/hooks/responsive/MIGRATION.md
Normal file
223
src/presentation/hooks/responsive/MIGRATION.md
Normal 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 }
|
||||
```
|
||||
66
src/presentation/hooks/responsive/index.ts
Normal file
66
src/presentation/hooks/responsive/index.ts
Normal 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';
|
||||
62
src/presentation/hooks/responsive/types.ts
Normal file
62
src/presentation/hooks/responsive/types.ts
Normal 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;
|
||||
}
|
||||
177
src/presentation/hooks/responsive/useBreakpoint.ts
Normal file
177
src/presentation/hooks/responsive/useBreakpoint.ts
Normal 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,
|
||||
};
|
||||
7
src/presentation/hooks/responsive/useBreakpointCheck.ts
Normal file
7
src/presentation/hooks/responsive/useBreakpointCheck.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* useBreakpointCheck Hook
|
||||
* 断点检查 - 提供断点范围检查功能
|
||||
* @deprecated 请直接使用 useBreakpoint.ts 中的 hooks
|
||||
*/
|
||||
|
||||
export { useBreakpointGTE, useBreakpointLT, useBreakpointBetween } from './useBreakpoint';
|
||||
59
src/presentation/hooks/responsive/useColumnCount.ts
Normal file
59
src/presentation/hooks/responsive/useColumnCount.ts
Normal 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;
|
||||
36
src/presentation/hooks/responsive/useMediaQuery.ts
Normal file
36
src/presentation/hooks/responsive/useMediaQuery.ts
Normal 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;
|
||||
43
src/presentation/hooks/responsive/useOrientation.ts
Normal file
43
src/presentation/hooks/responsive/useOrientation.ts
Normal 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;
|
||||
29
src/presentation/hooks/responsive/usePlatform.ts
Normal file
29
src/presentation/hooks/responsive/usePlatform.ts
Normal 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;
|
||||
112
src/presentation/hooks/responsive/useResponsive.ts
Normal file
112
src/presentation/hooks/responsive/useResponsive.ts
Normal 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;
|
||||
54
src/presentation/hooks/responsive/useResponsiveSpacing.ts
Normal file
54
src/presentation/hooks/responsive/useResponsiveSpacing.ts
Normal 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;
|
||||
95
src/presentation/hooks/responsive/useResponsiveValue.ts
Normal file
95
src/presentation/hooks/responsive/useResponsiveValue.ts
Normal 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 屏幕返回 8,md 屏幕返回 16,lg 及以上返回 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]);
|
||||
}
|
||||
63
src/presentation/hooks/responsive/useScreenSize.ts
Normal file
63
src/presentation/hooks/responsive/useScreenSize.ts
Normal 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;
|
||||
251
src/stores/message/MessageManager.ts
Normal file
251
src/stores/message/MessageManager.ts
Normal 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;
|
||||
301
src/stores/message/MessageStateManager.ts
Normal file
301
src/stores/message/MessageStateManager.ts
Normal 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();
|
||||
162
src/stores/message/MessageSyncService.ts
Normal file
162
src/stores/message/MessageSyncService.ts
Normal 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();
|
||||
92
src/stores/message/ReadReceiptManager.ts
Normal file
92
src/stores/message/ReadReceiptManager.ts
Normal 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();
|
||||
136
src/stores/message/WebSocketMessageHandler.ts
Normal file
136
src/stores/message/WebSocketMessageHandler.ts
Normal 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();
|
||||
19
src/stores/message/index.ts
Normal file
19
src/stores/message/index.ts
Normal 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';
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '../services';
|
||||
import { userManager } from './userManager';
|
||||
import { messageManager } from './messageManager';
|
||||
import { createOptimisticUpdaterFactory } from '../utils/optimisticUpdate';
|
||||
|
||||
interface UserState {
|
||||
// 状态
|
||||
@@ -59,504 +60,386 @@ interface UserState {
|
||||
refreshAll: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useUserStore = create<UserState>((set, get) => ({
|
||||
// 初始状态
|
||||
users: [],
|
||||
userCache: {},
|
||||
posts: [],
|
||||
notifications: [],
|
||||
notificationBadge: {
|
||||
total: 0,
|
||||
likes: 0,
|
||||
comments: 0,
|
||||
follows: 0,
|
||||
system: 0,
|
||||
},
|
||||
messageUnreadCount: 0,
|
||||
searchHistory: [],
|
||||
isLoadingPosts: false,
|
||||
isLoadingNotifications: false,
|
||||
|
||||
// 获取用户信息
|
||||
fetchUser: async (userId: string) => {
|
||||
// 先检查缓存
|
||||
const cached = get().userCache[userId];
|
||||
if (cached) return cached;
|
||||
export const useUserStore = create<UserState>((set, get) => {
|
||||
// 创建乐观更新器
|
||||
const optimisticUpdater = createOptimisticUpdaterFactory<UserState>(set, get);
|
||||
|
||||
return {
|
||||
// 初始状态
|
||||
users: [],
|
||||
userCache: {},
|
||||
posts: [],
|
||||
notifications: [],
|
||||
notificationBadge: {
|
||||
total: 0,
|
||||
likes: 0,
|
||||
comments: 0,
|
||||
follows: 0,
|
||||
system: 0,
|
||||
},
|
||||
messageUnreadCount: 0,
|
||||
searchHistory: [],
|
||||
isLoadingPosts: false,
|
||||
isLoadingNotifications: false,
|
||||
|
||||
try {
|
||||
const user = await userManager.getUserById(userId);
|
||||
if (user) {
|
||||
// 获取用户信息
|
||||
fetchUser: async (userId: string) => {
|
||||
// 先检查缓存
|
||||
const cached = get().userCache[userId];
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const user = await userManager.getUserById(userId);
|
||||
if (user) {
|
||||
set(state => ({
|
||||
userCache: { ...state.userCache, [userId]: user }
|
||||
}));
|
||||
return user;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取用户信息失败:', error);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
// 获取用户帖子
|
||||
fetchUserPosts: async (userId: string, page = 1) => {
|
||||
try {
|
||||
const response = await postService.getUserPosts(userId, page);
|
||||
const newPosts = response.list;
|
||||
|
||||
set(state => ({
|
||||
userCache: { ...state.userCache, [userId]: user }
|
||||
posts: page === 1 ? newPosts : [...state.posts, ...newPosts]
|
||||
}));
|
||||
return user;
|
||||
|
||||
return newPosts;
|
||||
} catch (error) {
|
||||
console.error('获取用户帖子失败:', error);
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取用户信息失败:', error);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
// 获取用户帖子
|
||||
fetchUserPosts: async (userId: string, page = 1) => {
|
||||
try {
|
||||
const response = await postService.getUserPosts(userId, page);
|
||||
const newPosts = response.list;
|
||||
|
||||
set(state => ({
|
||||
posts: page === 1 ? newPosts : [...state.posts, ...newPosts]
|
||||
}));
|
||||
|
||||
return newPosts;
|
||||
} catch (error) {
|
||||
console.error('获取用户帖子失败:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
// 获取帖子列表(首页)
|
||||
fetchPosts: async (type: 'recommend' | 'follow' | 'hot' | 'latest', page = 1) => {
|
||||
set({ isLoadingPosts: true });
|
||||
},
|
||||
|
||||
try {
|
||||
let response;
|
||||
// 获取帖子列表(首页)
|
||||
fetchPosts: async (type: 'recommend' | 'follow' | 'hot' | 'latest', page = 1) => {
|
||||
set({ isLoadingPosts: true });
|
||||
|
||||
switch (type) {
|
||||
case 'recommend':
|
||||
response = await postService.getRecommendedPosts(page);
|
||||
break;
|
||||
case 'follow':
|
||||
response = await postService.getFollowingPosts(page);
|
||||
break;
|
||||
case 'hot':
|
||||
response = await postService.getHotPosts(page);
|
||||
break;
|
||||
case 'latest':
|
||||
default:
|
||||
response = await postService.getLatestPosts(page);
|
||||
break;
|
||||
try {
|
||||
let response;
|
||||
|
||||
switch (type) {
|
||||
case 'recommend':
|
||||
response = await postService.getRecommendedPosts(page);
|
||||
break;
|
||||
case 'follow':
|
||||
response = await postService.getFollowingPosts(page);
|
||||
break;
|
||||
case 'hot':
|
||||
response = await postService.getHotPosts(page);
|
||||
break;
|
||||
case 'latest':
|
||||
default:
|
||||
response = await postService.getLatestPosts(page);
|
||||
break;
|
||||
}
|
||||
|
||||
const newPosts = response.list;
|
||||
|
||||
set(state => ({
|
||||
posts: page === 1 ? newPosts : [...state.posts, ...newPosts],
|
||||
isLoadingPosts: false
|
||||
}));
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('获取帖子列表失败:', error);
|
||||
set({ isLoadingPosts: false });
|
||||
return {
|
||||
list: [],
|
||||
total: 0,
|
||||
page,
|
||||
page_size: 20,
|
||||
total_pages: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const newPosts = response.list;
|
||||
|
||||
set(state => ({
|
||||
posts: page === 1 ? newPosts : [...state.posts, ...newPosts],
|
||||
isLoadingPosts: false
|
||||
}));
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('获取帖子列表失败:', error);
|
||||
set({ isLoadingPosts: false });
|
||||
return {
|
||||
list: [],
|
||||
total: 0,
|
||||
page,
|
||||
page_size: 20,
|
||||
total_pages: 0,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
// 获取通知列表
|
||||
fetchNotifications: async (type?: string, page = 1) => {
|
||||
set({ isLoadingNotifications: true });
|
||||
},
|
||||
|
||||
try {
|
||||
const response = await notificationService.getNotifications(
|
||||
page,
|
||||
20,
|
||||
type as any
|
||||
);
|
||||
// 获取通知列表
|
||||
fetchNotifications: async (type?: string, page = 1) => {
|
||||
set({ isLoadingNotifications: true });
|
||||
|
||||
const newNotifications = response.list;
|
||||
|
||||
set(state => ({
|
||||
notifications: page === 1 ? newNotifications : [...state.notifications, ...newNotifications],
|
||||
isLoadingNotifications: false
|
||||
}));
|
||||
|
||||
return newNotifications;
|
||||
} catch (error) {
|
||||
console.error('获取通知列表失败:', error);
|
||||
set({ isLoadingNotifications: false });
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
// 标记通知为已读
|
||||
markNotificationAsRead: async (notificationId: string) => {
|
||||
try {
|
||||
await notificationService.markAsRead(notificationId);
|
||||
|
||||
set(state => {
|
||||
const notifications = state.notifications.map(n =>
|
||||
n.id === notificationId ? { ...n, isRead: true } : n
|
||||
try {
|
||||
const response = await notificationService.getNotifications(
|
||||
page,
|
||||
20,
|
||||
type as any
|
||||
);
|
||||
|
||||
return {
|
||||
notifications,
|
||||
const newNotifications = response.list;
|
||||
|
||||
set(state => ({
|
||||
notifications: page === 1 ? newNotifications : [...state.notifications, ...newNotifications],
|
||||
isLoadingNotifications: false
|
||||
}));
|
||||
|
||||
return newNotifications;
|
||||
} catch (error) {
|
||||
console.error('获取通知列表失败:', error);
|
||||
set({ isLoadingNotifications: false });
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
// 标记通知为已读
|
||||
markNotificationAsRead: async (notificationId: string) => {
|
||||
try {
|
||||
await notificationService.markAsRead(notificationId);
|
||||
|
||||
set(state => {
|
||||
const notifications = state.notifications.map(n =>
|
||||
n.id === notificationId ? { ...n, isRead: true } : n
|
||||
);
|
||||
|
||||
return {
|
||||
notifications,
|
||||
notificationBadge: {
|
||||
total: notifications.filter(n => !n.isRead).length,
|
||||
likes: notifications.filter(n => n.type === 'like_post' && !n.isRead).length,
|
||||
comments: notifications.filter(n => n.type === 'comment' && !n.isRead).length,
|
||||
follows: notifications.filter(n => n.type === 'follow' && !n.isRead).length,
|
||||
system: notifications.filter(n => n.type === 'system' && !n.isRead).length,
|
||||
}
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('标记通知为已读失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 标记所有通知为已读
|
||||
markAllNotificationsAsRead: async () => {
|
||||
try {
|
||||
await notificationService.markAllAsRead();
|
||||
|
||||
set(state => ({
|
||||
notifications: state.notifications.map(n => ({ ...n, isRead: true })),
|
||||
notificationBadge: {
|
||||
total: notifications.filter(n => !n.isRead).length,
|
||||
likes: notifications.filter(n => n.type === 'like_post' && !n.isRead).length,
|
||||
comments: notifications.filter(n => n.type === 'comment' && !n.isRead).length,
|
||||
follows: notifications.filter(n => n.type === 'follow' && !n.isRead).length,
|
||||
system: notifications.filter(n => n.type === 'system' && !n.isRead).length,
|
||||
total: 0,
|
||||
likes: 0,
|
||||
comments: 0,
|
||||
follows: 0,
|
||||
system: 0,
|
||||
}
|
||||
};
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('标记所有通知为已读失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 获取通知角标
|
||||
fetchNotificationBadge: async () => {
|
||||
try {
|
||||
const badge = await notificationService.getNotificationBadge();
|
||||
set({ notificationBadge: badge });
|
||||
} catch (error) {
|
||||
console.error('获取通知角标失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 设置消息未读数
|
||||
// 注意:未读数现在由 MessageManager 统一管理
|
||||
// 此方法保留用于 TabBar 角标显示,从 MessageManager 同步
|
||||
setMessageUnreadCount: (count: number) => {
|
||||
set({ messageUnreadCount: count });
|
||||
},
|
||||
|
||||
// 从后端拉取最新消息未读数
|
||||
// @deprecated 请使用 messageManager.fetchUnreadCount() 代替
|
||||
// 此方法保留用于向后兼容
|
||||
fetchMessageUnreadCount: async () => {
|
||||
try {
|
||||
await messageManager.fetchUnreadCount();
|
||||
const unread = messageManager.getUnreadCount();
|
||||
set({ messageUnreadCount: unread.total + unread.system });
|
||||
} catch (error) {
|
||||
console.error('获取消息未读数失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 添加搜索历史
|
||||
addSearchHistory: (keyword: string) => {
|
||||
set(state => {
|
||||
const history = [keyword, ...state.searchHistory.filter(k => k !== keyword)].slice(0, 20);
|
||||
return { searchHistory: history };
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('标记通知为已读失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 标记所有通知为已读
|
||||
markAllNotificationsAsRead: async () => {
|
||||
try {
|
||||
await notificationService.markAllAsRead();
|
||||
},
|
||||
|
||||
// 清除搜索历史
|
||||
clearSearchHistory: () => {
|
||||
set({ searchHistory: [] });
|
||||
},
|
||||
|
||||
// 点赞帖子 - 使用乐观更新工具
|
||||
likePost: async (postId: string) => {
|
||||
const originalPosts = get().posts;
|
||||
|
||||
set(state => ({
|
||||
notifications: state.notifications.map(n => ({ ...n, isRead: true })),
|
||||
notificationBadge: {
|
||||
total: 0,
|
||||
likes: 0,
|
||||
comments: 0,
|
||||
follows: 0,
|
||||
system: 0,
|
||||
}
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('标记所有通知为已读失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 获取通知角标
|
||||
fetchNotificationBadge: async () => {
|
||||
try {
|
||||
const badge = await notificationService.getNotificationBadge();
|
||||
set({ notificationBadge: badge });
|
||||
} catch (error) {
|
||||
console.error('获取通知角标失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 设置消息未读数
|
||||
// 注意:未读数现在由 MessageManager 统一管理
|
||||
// 此方法保留用于 TabBar 角标显示,从 MessageManager 同步
|
||||
setMessageUnreadCount: (count: number) => {
|
||||
set({ messageUnreadCount: count });
|
||||
},
|
||||
|
||||
// 从后端拉取最新消息未读数
|
||||
// @deprecated 请使用 messageManager.fetchUnreadCount() 代替
|
||||
// 此方法保留用于向后兼容
|
||||
fetchMessageUnreadCount: async () => {
|
||||
try {
|
||||
await messageManager.fetchUnreadCount();
|
||||
const unread = messageManager.getUnreadCount();
|
||||
set({ messageUnreadCount: unread.total + unread.system });
|
||||
} catch (error) {
|
||||
console.error('获取消息未读数失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 添加搜索历史
|
||||
addSearchHistory: (keyword: string) => {
|
||||
set(state => {
|
||||
const history = [keyword, ...state.searchHistory.filter(k => k !== keyword)].slice(0, 20);
|
||||
return { searchHistory: history };
|
||||
});
|
||||
},
|
||||
|
||||
// 清除搜索历史
|
||||
clearSearchHistory: () => {
|
||||
set({ searchHistory: [] });
|
||||
},
|
||||
|
||||
// 点赞帖子 - 乐观更新
|
||||
likePost: async (postId: string) => {
|
||||
// 先乐观更新本地状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_liked: true, likes_count: p.likes_count + 1 } : p
|
||||
)
|
||||
}));
|
||||
|
||||
// 调用API
|
||||
try {
|
||||
const updatedPost = await postService.likePost(postId);
|
||||
if (updatedPost) {
|
||||
// 使用后端返回的更新后数据更新状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? updatedPost : p
|
||||
)
|
||||
}));
|
||||
} else {
|
||||
// API 返回失败,回滚状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_liked: false, likes_count: Math.max(0, p.likes_count - 1) } : p
|
||||
)
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('点赞帖子失败:', error);
|
||||
// 失败回滚状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_liked: false, likes_count: Math.max(0, p.likes_count - 1) } : p
|
||||
)
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
// 取消点赞 - 乐观更新
|
||||
unlikePost: async (postId: string) => {
|
||||
// 先乐观更新本地状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_liked: false, likes_count: Math.max(0, p.likes_count - 1) } : p
|
||||
)
|
||||
}));
|
||||
|
||||
// 调用API
|
||||
try {
|
||||
const updatedPost = await postService.unlikePost(postId);
|
||||
if (updatedPost) {
|
||||
// 使用后端返回的更新后数据更新状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? updatedPost : p
|
||||
)
|
||||
}));
|
||||
} else {
|
||||
// API 返回失败,回滚状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_liked: true, likes_count: p.likes_count + 1 } : p
|
||||
)
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('取消点赞帖子失败:', error);
|
||||
// 失败回滚状态
|
||||
// 乐观更新
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_liked: true, likes_count: p.likes_count + 1 } : p
|
||||
)
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
// 收藏帖子 - 乐观更新
|
||||
favoritePost: async (postId: string) => {
|
||||
// 先乐观更新本地状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_favorited: true, favorites_count: p.favorites_count + 1 } : p
|
||||
)
|
||||
}));
|
||||
|
||||
// 调用API
|
||||
try {
|
||||
const updatedPost = await postService.favoritePost(postId);
|
||||
if (updatedPost) {
|
||||
// 使用后端返回的更新后数据更新状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? updatedPost : p
|
||||
)
|
||||
}));
|
||||
} else {
|
||||
// API 返回失败,回滚状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_favorited: false, favorites_count: Math.max(0, p.favorites_count - 1) } : p
|
||||
)
|
||||
}));
|
||||
|
||||
try {
|
||||
const updatedPost = await postService.likePost(postId);
|
||||
if (updatedPost) {
|
||||
// 使用服务器返回的数据更新
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? updatedPost : p
|
||||
)
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('点赞帖子失败:', error);
|
||||
// 回滚
|
||||
set({ posts: originalPosts });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('收藏帖子失败:', error);
|
||||
// 失败回滚状态
|
||||
},
|
||||
|
||||
// 取消点赞 - 使用乐观更新工具
|
||||
unlikePost: async (postId: string) => {
|
||||
const originalPosts = get().posts;
|
||||
|
||||
// 乐观更新
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_favorited: false, favorites_count: Math.max(0, p.favorites_count - 1) } : p
|
||||
p.id === postId ? { ...p, is_liked: false, likes_count: Math.max(0, p.likes_count - 1) } : p
|
||||
)
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
// 取消收藏 - 乐观更新
|
||||
unfavoritePost: async (postId: string) => {
|
||||
// 先乐观更新本地状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_favorited: false, favorites_count: Math.max(0, p.favorites_count - 1) } : p
|
||||
)
|
||||
}));
|
||||
|
||||
// 调用API
|
||||
try {
|
||||
const updatedPost = await postService.unfavoritePost(postId);
|
||||
if (updatedPost) {
|
||||
// 使用后端返回的更新后数据更新状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? updatedPost : p
|
||||
)
|
||||
}));
|
||||
} else {
|
||||
// API 返回失败,回滚状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_favorited: true, favorites_count: p.favorites_count + 1 } : p
|
||||
)
|
||||
}));
|
||||
|
||||
try {
|
||||
const updatedPost = await postService.unlikePost(postId);
|
||||
if (updatedPost) {
|
||||
// 使用服务器返回的数据更新
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? updatedPost : p
|
||||
)
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('取消点赞帖子失败:', error);
|
||||
// 回滚
|
||||
set({ posts: originalPosts });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('取消收藏帖子失败:', error);
|
||||
// 失败回滚状态
|
||||
},
|
||||
|
||||
// 收藏帖子 - 使用乐观更新工具
|
||||
favoritePost: async (postId: string) => {
|
||||
const originalPosts = get().posts;
|
||||
|
||||
// 乐观更新
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_favorited: true, favorites_count: p.favorites_count + 1 } : p
|
||||
)
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
// 点赞评论
|
||||
likeComment: async (commentId: string) => {
|
||||
// 先更新本地状态(更新posts中的帖子的评论点赞状态)
|
||||
set(state => ({
|
||||
posts: state.posts.map(p => ({
|
||||
...p,
|
||||
top_comment: p.top_comment && p.top_comment.id === commentId
|
||||
? { ...p.top_comment, is_liked: true, likes_count: p.top_comment.likes_count + 1 }
|
||||
: p.top_comment
|
||||
}))
|
||||
}));
|
||||
|
||||
try {
|
||||
const updatedPost = await postService.favoritePost(postId);
|
||||
if (updatedPost) {
|
||||
// 使用服务器返回的数据更新
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? updatedPost : p
|
||||
)
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('收藏帖子失败:', error);
|
||||
// 回滚
|
||||
set({ posts: originalPosts });
|
||||
}
|
||||
},
|
||||
|
||||
// 调用API
|
||||
try {
|
||||
await commentService.likeComment(commentId);
|
||||
} catch (error) {
|
||||
console.error('点赞评论失败:', error);
|
||||
// 失败回滚状态
|
||||
// 取消收藏 - 使用乐观更新工具
|
||||
unfavoritePost: async (postId: string) => {
|
||||
const originalPosts = get().posts;
|
||||
|
||||
// 乐观更新
|
||||
set(state => ({
|
||||
posts: state.posts.map(p => ({
|
||||
...p,
|
||||
top_comment: p.top_comment && p.top_comment.id === commentId
|
||||
? { ...p.top_comment, is_liked: false, likes_count: Math.max(0, p.top_comment.likes_count - 1) }
|
||||
: p.top_comment
|
||||
}))
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
// 取消点赞评论
|
||||
unlikeComment: async (commentId: string) => {
|
||||
// 先更新本地状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p => ({
|
||||
...p,
|
||||
top_comment: p.top_comment && p.top_comment.id === commentId
|
||||
? { ...p.top_comment, is_liked: false, likes_count: Math.max(0, p.top_comment.likes_count - 1) }
|
||||
: p.top_comment
|
||||
}))
|
||||
}));
|
||||
|
||||
// 调用API
|
||||
try {
|
||||
await commentService.unlikeComment(commentId);
|
||||
} catch (error) {
|
||||
console.error('取消点赞评论失败:', error);
|
||||
// 失败回滚状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p => ({
|
||||
...p,
|
||||
top_comment: p.top_comment && p.top_comment.id === commentId
|
||||
? { ...p.top_comment, is_liked: true, likes_count: p.top_comment.likes_count + 1 }
|
||||
: p.top_comment
|
||||
}))
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
// 关注用户
|
||||
followUser: async (userId: string) => {
|
||||
// 先更新本地状态
|
||||
set(state => ({
|
||||
users: state.users.map(u =>
|
||||
u.id === userId
|
||||
? { ...u, isFollowing: true, followersCount: u.followers_count + 1 }
|
||||
: u
|
||||
),
|
||||
userCache: Object.fromEntries(
|
||||
Object.entries(state.userCache).map(([id, user]) => [
|
||||
id,
|
||||
id === userId
|
||||
? { ...user, isFollowing: true, followersCount: user.followers_count + 1 }
|
||||
: user
|
||||
])
|
||||
)
|
||||
}));
|
||||
|
||||
// 调用API
|
||||
try {
|
||||
await authService.followUser(userId);
|
||||
} catch (error) {
|
||||
console.error('关注用户失败:', error);
|
||||
// 失败回滚状态
|
||||
set(state => ({
|
||||
users: state.users.map(u =>
|
||||
u.id === userId
|
||||
? { ...u, isFollowing: false, followersCount: Math.max(0, u.followers_count - 1) }
|
||||
: u
|
||||
),
|
||||
userCache: Object.fromEntries(
|
||||
Object.entries(state.userCache).map(([id, user]) => [
|
||||
id,
|
||||
id === userId
|
||||
? { ...user, isFollowing: false, followersCount: Math.max(0, user.followers_count - 1) }
|
||||
: user
|
||||
])
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_favorited: false, favorites_count: Math.max(0, p.favorites_count - 1) } : p
|
||||
)
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
// 取消关注
|
||||
unfollowUser: async (userId: string) => {
|
||||
// 先更新本地状态
|
||||
set(state => ({
|
||||
users: state.users.map(u =>
|
||||
u.id === userId
|
||||
? { ...u, isFollowing: false, followersCount: Math.max(0, u.followers_count - 1) }
|
||||
: u
|
||||
),
|
||||
userCache: Object.fromEntries(
|
||||
Object.entries(state.userCache).map(([id, user]) => [
|
||||
id,
|
||||
id === userId
|
||||
? { ...user, isFollowing: false, followersCount: Math.max(0, user.followers_count - 1) }
|
||||
: user
|
||||
])
|
||||
)
|
||||
}));
|
||||
|
||||
try {
|
||||
const updatedPost = await postService.unfavoritePost(postId);
|
||||
if (updatedPost) {
|
||||
// 使用服务器返回的数据更新
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? updatedPost : p
|
||||
)
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('取消收藏帖子失败:', error);
|
||||
// 回滚
|
||||
set({ posts: originalPosts });
|
||||
}
|
||||
},
|
||||
|
||||
// 调用API
|
||||
try {
|
||||
await authService.unfollowUser(userId);
|
||||
} catch (error) {
|
||||
console.error('取消关注用户失败:', error);
|
||||
// 失败回滚状态
|
||||
// 点赞评论 - 使用乐观更新工具
|
||||
likeComment: async (commentId: string) => {
|
||||
await optimisticUpdater({
|
||||
key: 'posts',
|
||||
optimisticUpdate: (posts) =>
|
||||
posts.map(p => ({
|
||||
...p,
|
||||
top_comment: p.top_comment && p.top_comment.id === commentId
|
||||
? { ...p.top_comment, is_liked: true, likes_count: p.top_comment.likes_count + 1 }
|
||||
: p.top_comment
|
||||
})),
|
||||
rollbackState: (original) => original,
|
||||
apiCall: () => commentService.likeComment(commentId),
|
||||
errorMessage: '点赞评论失败',
|
||||
});
|
||||
},
|
||||
|
||||
// 取消点赞评论 - 使用乐观更新工具
|
||||
unlikeComment: async (commentId: string) => {
|
||||
await optimisticUpdater({
|
||||
key: 'posts',
|
||||
optimisticUpdate: (posts) =>
|
||||
posts.map(p => ({
|
||||
...p,
|
||||
top_comment: p.top_comment && p.top_comment.id === commentId
|
||||
? { ...p.top_comment, is_liked: false, likes_count: Math.max(0, p.top_comment.likes_count - 1) }
|
||||
: p.top_comment
|
||||
})),
|
||||
rollbackState: (original) => original,
|
||||
apiCall: () => commentService.unlikeComment(commentId),
|
||||
errorMessage: '取消点赞评论失败',
|
||||
});
|
||||
},
|
||||
|
||||
// 关注用户 - 使用乐观更新工具
|
||||
followUser: async (userId: string) => {
|
||||
const originalUsers = get().users;
|
||||
const originalUserCache = get().userCache;
|
||||
|
||||
// 乐观更新 users
|
||||
set(state => ({
|
||||
users: state.users.map(u =>
|
||||
u.id === userId
|
||||
? { ...u, isFollowing: true, followersCount: u.followers_count + 1 }
|
||||
: u
|
||||
),
|
||||
}));
|
||||
|
||||
// 乐观更新 userCache
|
||||
set(state => ({
|
||||
userCache: Object.fromEntries(
|
||||
Object.entries(state.userCache).map(([id, user]) => [
|
||||
id,
|
||||
@@ -566,17 +449,66 @@ export const useUserStore = create<UserState>((set, get) => ({
|
||||
])
|
||||
)
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
// 刷新所有数据
|
||||
refreshAll: async () => {
|
||||
await Promise.all([
|
||||
get().fetchPosts('recommend', 1),
|
||||
get().fetchNotificationBadge(),
|
||||
]);
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
await authService.followUser(userId);
|
||||
} catch (error) {
|
||||
console.error('关注用户失败:', error);
|
||||
// 回滚
|
||||
set({
|
||||
users: originalUsers,
|
||||
userCache: originalUserCache
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 取消关注 - 使用乐观更新工具
|
||||
unfollowUser: async (userId: string) => {
|
||||
const originalUsers = get().users;
|
||||
const originalUserCache = get().userCache;
|
||||
|
||||
// 乐观更新 users
|
||||
set(state => ({
|
||||
users: state.users.map(u =>
|
||||
u.id === userId
|
||||
? { ...u, isFollowing: false, followersCount: Math.max(0, u.followers_count - 1) }
|
||||
: u
|
||||
),
|
||||
}));
|
||||
|
||||
// 乐观更新 userCache
|
||||
set(state => ({
|
||||
userCache: Object.fromEntries(
|
||||
Object.entries(state.userCache).map(([id, user]) => [
|
||||
id,
|
||||
id === userId
|
||||
? { ...user, isFollowing: false, followersCount: Math.max(0, user.followers_count - 1) }
|
||||
: user
|
||||
])
|
||||
)
|
||||
}));
|
||||
|
||||
try {
|
||||
await authService.unfollowUser(userId);
|
||||
} catch (error) {
|
||||
console.error('取消关注用户失败:', error);
|
||||
// 回滚
|
||||
set({
|
||||
users: originalUsers,
|
||||
userCache: originalUserCache
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 刷新所有数据
|
||||
refreshAll: async () => {
|
||||
await Promise.all([
|
||||
get().fetchPosts('recommend', 1),
|
||||
get().fetchNotificationBadge(),
|
||||
]);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// 导出selector hooks以优化性能
|
||||
export const usePosts = () => useUserStore((state) => state.posts);
|
||||
|
||||
287
src/utils/__tests__/optimisticUpdate.test.ts
Normal file
287
src/utils/__tests__/optimisticUpdate.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
201
src/utils/optimisticUpdate.ts
Normal file
201
src/utils/optimisticUpdate.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { ViewStyle } from 'react-native';
|
||||
import { BREAKPOINTS } from '../hooks/useResponsive';
|
||||
import { BREAKPOINTS } from '../presentation/hooks/responsive';
|
||||
|
||||
/**
|
||||
* 断点键名
|
||||
|
||||
Reference in New Issue
Block a user