dev #3
@@ -98,12 +98,12 @@ jobs:
|
||||
container:
|
||||
image: reactnativecommunity/react-native-android:latest
|
||||
env:
|
||||
GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=false -Dorg.gradle.workers.max=1 -Xmx2g -XX:MaxMetaspaceSize=512m -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=128m"
|
||||
_JAVA_OPTIONS: "-Xmx2g -XX:MaxMetaspaceSize=512m -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=128m"
|
||||
NODE_OPTIONS: "--max-old-space-size=2048"
|
||||
GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.workers.max=4 -Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m"
|
||||
_JAVA_OPTIONS: "-Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m"
|
||||
NODE_OPTIONS: "--max-old-space-size=8192"
|
||||
NODE_ENV: "production"
|
||||
NDK_NUM_JOBS: "1"
|
||||
CMAKE_BUILD_PARALLEL_LEVEL: "1"
|
||||
NDK_NUM_JOBS: "4"
|
||||
CMAKE_BUILD_PARALLEL_LEVEL: "4"
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
@@ -222,10 +222,10 @@ jobs:
|
||||
# Update gradle.properties
|
||||
cat > gradle.properties << 'PROPS_EOF'
|
||||
org.gradle.daemon=false
|
||||
org.gradle.parallel=false
|
||||
org.gradle.configureondemand=false
|
||||
org.gradle.workers.max=1
|
||||
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=128m -XX:+HeapDumpOnOutOfMemoryError
|
||||
org.gradle.parallel=true
|
||||
org.gradle.configureondemand=true
|
||||
org.gradle.workers.max=4
|
||||
org.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.enableJetifier=false
|
||||
android.useAndroidX=true
|
||||
hermesEnabled=true
|
||||
@@ -242,7 +242,7 @@ jobs:
|
||||
run: |
|
||||
cd android
|
||||
chmod +x gradlew
|
||||
./gradlew :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --max-workers=1
|
||||
./gradlew :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --max-workers=4 --parallel
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
|
||||
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错误
|
||||
- [ ] 性能不劣化(启动时间、内存占用)
|
||||
1725
docs/OPTIMIZATION_DESIGN.md
Normal file
1725
docs/OPTIMIZATION_DESIGN.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -362,6 +362,20 @@ const PostCard: React.FC<PostCardProps> = ({
|
||||
const gridUsernameFontSize = isDesktop ? 14 : 12;
|
||||
const gridLikeFontSize = isDesktop ? 14 : 12;
|
||||
|
||||
const handleContainerPress = () => {
|
||||
onPress();
|
||||
};
|
||||
|
||||
const handleImagePress = (e: any) => {
|
||||
e.stopPropagation();
|
||||
onImagePress?.(post.images || [], 0);
|
||||
};
|
||||
|
||||
const handleUserPress = (e: any) => {
|
||||
e.stopPropagation();
|
||||
onUserPress();
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
@@ -369,14 +383,14 @@ const PostCard: React.FC<PostCardProps> = ({
|
||||
!hasImage && styles.gridContainerNoImage,
|
||||
isDesktop && styles.gridContainerDesktop
|
||||
]}
|
||||
onPress={onPress}
|
||||
onPress={handleContainerPress}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
{/* 封面图 - 只有有图片时才渲染,无图时不显示占位区域 */}
|
||||
{hasImage && (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.8}
|
||||
onPress={() => onImagePress?.(post.images || [], 0)}
|
||||
onPress={handleImagePress}
|
||||
>
|
||||
<SmartImage
|
||||
source={{ uri: coverUrl }}
|
||||
@@ -424,7 +438,7 @@ const PostCard: React.FC<PostCardProps> = ({
|
||||
|
||||
{/* 底部信息 */}
|
||||
<View style={[styles.gridFooter, isDesktop && styles.gridFooterDesktop]}>
|
||||
<TouchableOpacity style={styles.gridUserInfo} onPress={onUserPress}>
|
||||
<TouchableOpacity style={styles.gridUserInfo} onPress={handleUserPress}>
|
||||
<Avatar
|
||||
source={author.avatar}
|
||||
size={isDesktop ? 24 : 20}
|
||||
@@ -462,6 +476,40 @@ const PostCard: React.FC<PostCardProps> = ({
|
||||
return 3;
|
||||
}, [isWideScreen, isDesktop, isTablet]);
|
||||
|
||||
const handleContainerPress = () => {
|
||||
onPress();
|
||||
};
|
||||
|
||||
const handleUserPress = (e: any) => {
|
||||
e.stopPropagation();
|
||||
onUserPress();
|
||||
};
|
||||
|
||||
const handleLikePress = (e: any) => {
|
||||
e.stopPropagation();
|
||||
onLike();
|
||||
};
|
||||
|
||||
const handleCommentPress = (e: any) => {
|
||||
e.stopPropagation();
|
||||
onComment();
|
||||
};
|
||||
|
||||
const handleSharePress = (e: any) => {
|
||||
e.stopPropagation();
|
||||
onShare();
|
||||
};
|
||||
|
||||
const handleBookmarkPress = (e: any) => {
|
||||
e.stopPropagation();
|
||||
onBookmark();
|
||||
};
|
||||
|
||||
const handleDeletePress = (e: any) => {
|
||||
e.stopPropagation();
|
||||
handleDelete();
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
@@ -481,12 +529,12 @@ const PostCard: React.FC<PostCardProps> = ({
|
||||
} : {})
|
||||
}
|
||||
]}
|
||||
onPress={onPress}
|
||||
onPress={handleContainerPress}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
{/* 用户信息 */}
|
||||
<View style={styles.userSection}>
|
||||
<TouchableOpacity onPress={onUserPress}>
|
||||
<TouchableOpacity onPress={handleUserPress}>
|
||||
<Avatar
|
||||
source={author.avatar}
|
||||
size={avatarSize}
|
||||
@@ -495,7 +543,7 @@ const PostCard: React.FC<PostCardProps> = ({
|
||||
</TouchableOpacity>
|
||||
<View style={styles.userInfo}>
|
||||
<View style={styles.userNameRow}>
|
||||
<TouchableOpacity onPress={onUserPress}>
|
||||
<TouchableOpacity onPress={handleUserPress}>
|
||||
<Text
|
||||
variant="body"
|
||||
style={[
|
||||
@@ -529,7 +577,7 @@ const PostCard: React.FC<PostCardProps> = ({
|
||||
{isPostAuthor && onDelete && (
|
||||
<TouchableOpacity
|
||||
style={styles.deleteButton}
|
||||
onPress={handleDelete}
|
||||
onPress={handleDeletePress}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
@@ -605,7 +653,7 @@ const PostCard: React.FC<PostCardProps> = ({
|
||||
</View>
|
||||
|
||||
<View style={styles.actionButtons}>
|
||||
<TouchableOpacity style={[styles.actionButton, ...(isDesktop ? [styles.actionButtonWide] : [])]} onPress={onLike}>
|
||||
<TouchableOpacity style={[styles.actionButton, ...(isDesktop ? [styles.actionButtonWide] : [])]} onPress={handleLikePress}>
|
||||
<MaterialCommunityIcons
|
||||
name={post.is_liked ? 'heart' : 'heart-outline'}
|
||||
size={isDesktop ? 22 : 19}
|
||||
@@ -620,7 +668,7 @@ const PostCard: React.FC<PostCardProps> = ({
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={[styles.actionButton, ...(isDesktop ? [styles.actionButtonWide] : [])]} onPress={onComment}>
|
||||
<TouchableOpacity style={[styles.actionButton, ...(isDesktop ? [styles.actionButtonWide] : [])]} onPress={handleCommentPress}>
|
||||
<MaterialCommunityIcons
|
||||
name="comment-outline"
|
||||
size={isDesktop ? 22 : 19}
|
||||
@@ -631,7 +679,7 @@ const PostCard: React.FC<PostCardProps> = ({
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={[styles.actionButton, ...(isDesktop ? [styles.actionButtonWide] : [])]} onPress={onShare}>
|
||||
<TouchableOpacity style={[styles.actionButton, ...(isDesktop ? [styles.actionButtonWide] : [])]} onPress={handleSharePress}>
|
||||
<MaterialCommunityIcons
|
||||
name="share-outline"
|
||||
size={isDesktop ? 22 : 19}
|
||||
@@ -639,7 +687,7 @@ const PostCard: React.FC<PostCardProps> = ({
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={[styles.actionButton, isDesktop && styles.actionButtonWide]} onPress={onBookmark}>
|
||||
<TouchableOpacity style={[styles.actionButton, isDesktop && styles.actionButtonWide]} onPress={handleBookmarkPress}>
|
||||
<MaterialCommunityIcons
|
||||
name={post.is_favorited ? 'bookmark' : 'bookmark-outline'}
|
||||
size={isDesktop ? 22 : 19}
|
||||
|
||||
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 和 SSEClient
|
||||
* 纯业务逻辑,无UI依赖
|
||||
*/
|
||||
|
||||
import { messageRepository } from '../../data/repositories/MessageRepository';
|
||||
import { sseClient, SSEEventType } from '../../data/datasources/SSEClient';
|
||||
import {
|
||||
Message,
|
||||
Conversation,
|
||||
GroupNotice,
|
||||
createMessage,
|
||||
isMessageFromCurrentUser,
|
||||
shouldIncrementUnread,
|
||||
buildGroupNoticeText,
|
||||
} from '../entities/Message';
|
||||
import { messageService } from '../../services/messageService';
|
||||
import { api } from '../../services/api';
|
||||
|
||||
// 事件类型定义
|
||||
export type MessageUseCaseEventType =
|
||||
| 'message_received'
|
||||
| 'message_recalled'
|
||||
| 'typing_status'
|
||||
| 'group_notice'
|
||||
| 'connection_changed'
|
||||
| 'error';
|
||||
|
||||
export interface MessageUseCaseEvent {
|
||||
type: MessageUseCaseEventType;
|
||||
payload: any;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export type MessageUseCaseSubscriber = (event: MessageUseCaseEvent) => void;
|
||||
|
||||
// 已读状态保护记录
|
||||
interface ReadStateRecord {
|
||||
timestamp: number;
|
||||
version: number;
|
||||
lastReadSeq: number;
|
||||
clearTimer?: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
class ProcessMessageUseCase {
|
||||
private subscribers: Set<MessageUseCaseSubscriber> = new Set();
|
||||
private unsubscribeFns: Array<() => void> = [];
|
||||
private processedMessageIds: Set<string> = new Set();
|
||||
private processedMessageIdsExpiry: Map<string, number> = new Map();
|
||||
private pendingReadMap: Map<string, ReadStateRecord> = new Map();
|
||||
private readStateVersion = 0;
|
||||
private pendingUserRequests: Map<string, Promise<any>> = new Map();
|
||||
private currentUserId: string | null = null;
|
||||
|
||||
// 常量
|
||||
private readonly READ_STATE_PROTECTION_DELAY = 5000; // 5秒
|
||||
private readonly MESSAGE_ID_EXPIRY = 5 * 60 * 1000; // 5分钟
|
||||
|
||||
/**
|
||||
* 初始化用例
|
||||
*/
|
||||
initialize(currentUserId: string | null): void {
|
||||
this.currentUserId = currentUserId;
|
||||
this.setupSSEListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁用例
|
||||
*/
|
||||
destroy(): void {
|
||||
this.unsubscribeFns.forEach((fn) => fn());
|
||||
this.unsubscribeFns = [];
|
||||
this.subscribers.clear();
|
||||
this.processedMessageIds.clear();
|
||||
this.processedMessageIdsExpiry.clear();
|
||||
this.pendingReadMap.clear();
|
||||
this.pendingUserRequests.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置SSE监听器
|
||||
*/
|
||||
private setupSSEListeners(): void {
|
||||
// 监听私聊消息
|
||||
const unsubChat = sseClient.on('chat', (message) => {
|
||||
this.handleNewMessage(message);
|
||||
});
|
||||
|
||||
// 监听群聊消息
|
||||
const unsubGroupMessage = sseClient.on('group_message', (message) => {
|
||||
this.handleNewMessage(message);
|
||||
});
|
||||
|
||||
// 监听私聊已读回执
|
||||
const unsubRead = sseClient.on('read', (message) => {
|
||||
this.handleReadReceipt(message);
|
||||
});
|
||||
|
||||
// 监听群聊已读回执
|
||||
const unsubGroupRead = sseClient.on('group_read', (message) => {
|
||||
this.handleGroupReadReceipt(message);
|
||||
});
|
||||
|
||||
// 监听私聊消息撤回
|
||||
const unsubRecall = sseClient.on('recall', (message) => {
|
||||
this.handleRecallMessage(message);
|
||||
});
|
||||
|
||||
// 监听群聊消息撤回
|
||||
const unsubGroupRecall = sseClient.on('group_recall', (message) => {
|
||||
this.handleGroupRecallMessage(message);
|
||||
});
|
||||
|
||||
// 监听群聊输入状态
|
||||
const unsubGroupTyping = sseClient.on('group_typing', (message) => {
|
||||
this.handleGroupTyping(message);
|
||||
});
|
||||
|
||||
// 监听群通知
|
||||
const unsubGroupNotice = sseClient.on('group_notice', (message) => {
|
||||
this.handleGroupNotice(message);
|
||||
});
|
||||
|
||||
// 监听连接状态
|
||||
const unsubConnected = sseClient.on('connected', () => {
|
||||
this.notifySubscribers({
|
||||
type: 'connection_changed',
|
||||
payload: { connected: true },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
const unsubDisconnected = sseClient.on('disconnected', () => {
|
||||
this.notifySubscribers({
|
||||
type: 'connection_changed',
|
||||
payload: { connected: false },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
this.unsubscribeFns = [
|
||||
unsubChat,
|
||||
unsubGroupMessage,
|
||||
unsubRead,
|
||||
unsubGroupRead,
|
||||
unsubRecall,
|
||||
unsubGroupRecall,
|
||||
unsubGroupTyping,
|
||||
unsubGroupNotice,
|
||||
unsubConnected,
|
||||
unsubDisconnected,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理新消息
|
||||
*/
|
||||
private async handleNewMessage(message: any): Promise<void> {
|
||||
const { conversation_id, id, sender_id, seq, segments, created_at, _isAck } = message;
|
||||
|
||||
// 消息去重检查
|
||||
if (this.isMessageProcessed(id)) {
|
||||
return;
|
||||
}
|
||||
this.markMessageAsProcessed(id);
|
||||
|
||||
// 构造消息对象
|
||||
const newMessage = createMessage({
|
||||
id,
|
||||
conversationId: String(conversation_id),
|
||||
senderId: sender_id,
|
||||
seq,
|
||||
segments: segments || [],
|
||||
createdAt: created_at,
|
||||
});
|
||||
|
||||
// 异步获取发送者信息(如果是群聊且不是当前用户发送的)
|
||||
if (message.type === 'group_message' && sender_id && sender_id !== this.currentUserId) {
|
||||
this.enrichMessageWithSender(newMessage);
|
||||
}
|
||||
|
||||
// 异步保存到本地数据库
|
||||
const isRead = _isAck || sender_id === this.currentUserId;
|
||||
messageRepository.saveMessage(newMessage, isRead).catch((error) => {
|
||||
console.error('[ProcessMessageUseCase] 保存消息失败:', error);
|
||||
});
|
||||
|
||||
// 通知订阅者
|
||||
this.notifySubscribers({
|
||||
type: 'message_received',
|
||||
payload: {
|
||||
message: newMessage,
|
||||
isCurrentUser: sender_id === this.currentUserId,
|
||||
isAck: _isAck,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 丰富消息的发送者信息
|
||||
*/
|
||||
private async enrichMessageWithSender(message: Message): Promise<void> {
|
||||
try {
|
||||
const user = await this.getSenderInfo(message.senderId);
|
||||
if (user) {
|
||||
message.sender = user;
|
||||
// 重新通知,包含发送者信息
|
||||
this.notifySubscribers({
|
||||
type: 'message_received',
|
||||
payload: {
|
||||
message,
|
||||
isCurrentUser: false,
|
||||
isUpdated: true,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ProcessMessageUseCase] 获取发送者信息失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取发送者信息(带缓存和去重)
|
||||
*/
|
||||
private async getSenderInfo(userId: string): Promise<any | null> {
|
||||
// 先检查本地缓存
|
||||
const cachedUser = await messageRepository.getUserCache(userId);
|
||||
if (cachedUser) {
|
||||
return cachedUser;
|
||||
}
|
||||
|
||||
// 检查是否已有正在进行的请求
|
||||
const pendingRequest = this.pendingUserRequests.get(userId);
|
||||
if (pendingRequest) {
|
||||
return pendingRequest;
|
||||
}
|
||||
|
||||
// 发起新请求
|
||||
const request = this.fetchUserInfo(userId);
|
||||
this.pendingUserRequests.set(userId, request);
|
||||
|
||||
try {
|
||||
const user = await request;
|
||||
return user;
|
||||
} finally {
|
||||
this.pendingUserRequests.delete(userId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从服务器获取用户信息
|
||||
*/
|
||||
private async fetchUserInfo(userId: string): Promise<any | null> {
|
||||
try {
|
||||
const response = await api.get(`/users/${userId}`);
|
||||
if (response.code === 0 && response.data) {
|
||||
await messageRepository.saveUserCache(response.data);
|
||||
return response.data;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`[ProcessMessageUseCase] 获取用户信息失败: ${userId}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查消息是否已处理
|
||||
*/
|
||||
private isMessageProcessed(messageId: string): boolean {
|
||||
return this.processedMessageIds.has(messageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记消息已处理
|
||||
*/
|
||||
private markMessageAsProcessed(messageId: string): void {
|
||||
this.processedMessageIds.add(messageId);
|
||||
this.processedMessageIdsExpiry.set(messageId, Date.now());
|
||||
|
||||
// 定期清理
|
||||
if (this.processedMessageIds.size > 1000) {
|
||||
this.cleanupProcessedMessageIds();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期的消息ID
|
||||
*/
|
||||
private cleanupProcessedMessageIds(): void {
|
||||
const now = Date.now();
|
||||
for (const [id, timestamp] of this.processedMessageIdsExpiry.entries()) {
|
||||
if (now - timestamp > this.MESSAGE_ID_EXPIRY) {
|
||||
this.processedMessageIds.delete(id);
|
||||
this.processedMessageIdsExpiry.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理私聊已读回执
|
||||
*/
|
||||
private handleReadReceipt(message: any): void {
|
||||
// 可以在这里处理对方已读的状态更新
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理群聊已读回执
|
||||
*/
|
||||
private handleGroupReadReceipt(message: any): void {
|
||||
// 可以在这里处理群聊已读状态更新
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理私聊消息撤回
|
||||
*/
|
||||
private handleRecallMessage(message: any): Promise<void> {
|
||||
const { conversation_id, message_id } = message;
|
||||
|
||||
// 更新本地数据库状态
|
||||
messageRepository
|
||||
.updateMessageStatus(message_id, 'recalled', true)
|
||||
.catch((error) => {
|
||||
console.error('[ProcessMessageUseCase] 更新撤回状态失败:', error);
|
||||
});
|
||||
|
||||
// 通知订阅者
|
||||
this.notifySubscribers({
|
||||
type: 'message_recalled',
|
||||
payload: {
|
||||
conversationId: String(conversation_id),
|
||||
messageId: message_id,
|
||||
isGroup: false,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理群聊消息撤回
|
||||
*/
|
||||
private handleGroupRecallMessage(message: any): Promise<void> {
|
||||
const { conversation_id, message_id } = message;
|
||||
|
||||
// 更新本地数据库状态
|
||||
messageRepository
|
||||
.updateMessageStatus(message_id, 'recalled', true)
|
||||
.catch((error) => {
|
||||
console.error('[ProcessMessageUseCase] 更新撤回状态失败:', error);
|
||||
});
|
||||
|
||||
// 通知订阅者
|
||||
this.notifySubscribers({
|
||||
type: 'message_recalled',
|
||||
payload: {
|
||||
conversationId: String(conversation_id),
|
||||
messageId: message_id,
|
||||
isGroup: true,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理群聊输入状态
|
||||
*/
|
||||
private handleGroupTyping(message: any): void {
|
||||
const { group_id, user_id, is_typing } = message;
|
||||
|
||||
this.notifySubscribers({
|
||||
type: 'typing_status',
|
||||
payload: {
|
||||
groupId: String(group_id),
|
||||
userId: user_id,
|
||||
isTyping: is_typing,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理群通知
|
||||
*/
|
||||
private handleGroupNotice(message: any): void {
|
||||
const { notice_type, group_id, data, timestamp, message_id, seq } = message;
|
||||
const groupIdStr = String(group_id);
|
||||
|
||||
const notice: GroupNotice = {
|
||||
type: notice_type,
|
||||
groupId: groupIdStr,
|
||||
data: data || {},
|
||||
timestamp: timestamp || Date.now(),
|
||||
messageId: message_id,
|
||||
seq,
|
||||
};
|
||||
|
||||
// 通知订阅者
|
||||
this.notifySubscribers({
|
||||
type: 'group_notice',
|
||||
payload: {
|
||||
notice,
|
||||
text: buildGroupNoticeText(notice),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记会话已读
|
||||
*/
|
||||
async markAsRead(conversationId: string, seq: number): Promise<void> {
|
||||
const existingRecord = this.pendingReadMap.get(conversationId);
|
||||
|
||||
// 使用seq去重
|
||||
if (existingRecord && seq <= existingRecord.lastReadSeq) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 清除可能存在的旧定时器
|
||||
if (existingRecord?.clearTimer) {
|
||||
clearTimeout(existingRecord.clearTimer);
|
||||
}
|
||||
|
||||
// 递增全局版本号
|
||||
this.readStateVersion++;
|
||||
const currentVersion = this.readStateVersion;
|
||||
|
||||
// 记录已读状态
|
||||
this.pendingReadMap.set(conversationId, {
|
||||
timestamp: Date.now(),
|
||||
version: currentVersion,
|
||||
lastReadSeq: seq,
|
||||
});
|
||||
|
||||
// 更新本地数据库
|
||||
await messageRepository.markConversationAsRead(conversationId);
|
||||
|
||||
// 调用API
|
||||
try {
|
||||
await messageService.markAsRead(conversationId, seq);
|
||||
} catch (error) {
|
||||
console.error('[ProcessMessageUseCase] 标记已读API失败:', error);
|
||||
this.pendingReadMap.delete(conversationId);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 设置延迟清除保护
|
||||
const clearTimer = setTimeout(() => {
|
||||
const record = this.pendingReadMap.get(conversationId);
|
||||
if (record && record.version === currentVersion) {
|
||||
this.pendingReadMap.delete(conversationId);
|
||||
}
|
||||
}, this.READ_STATE_PROTECTION_DELAY);
|
||||
|
||||
this.pendingReadMap.set(conversationId, {
|
||||
timestamp: Date.now(),
|
||||
version: currentVersion,
|
||||
lastReadSeq: seq,
|
||||
clearTimer,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查会话是否有进行中的已读请求
|
||||
*/
|
||||
hasPendingRead(conversationId: string): boolean {
|
||||
return this.pendingReadMap.has(conversationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取进行中的已读记录
|
||||
*/
|
||||
getPendingReadRecord(conversationId: string): ReadStateRecord | undefined {
|
||||
return this.pendingReadMap.get(conversationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
*/
|
||||
async sendMessage(
|
||||
conversationId: string,
|
||||
segments: any[],
|
||||
options?: { replyToId?: string }
|
||||
): Promise<Message | null> {
|
||||
try {
|
||||
const response = await messageService.sendMessage(conversationId, {
|
||||
segments,
|
||||
reply_to_id: options?.replyToId,
|
||||
});
|
||||
|
||||
if (response) {
|
||||
const message = createMessage({
|
||||
id: response.id,
|
||||
conversationId,
|
||||
senderId: this.currentUserId || '',
|
||||
seq: response.seq,
|
||||
segments,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 保存到本地数据库
|
||||
await messageRepository.saveMessage(message, true);
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('[ProcessMessageUseCase] 发送消息失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本地消息
|
||||
*/
|
||||
async getLocalMessages(conversationId: string, limit: number = 20): Promise<Message[]> {
|
||||
return messageRepository.getMessagesByConversation(conversationId, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取历史消息
|
||||
*/
|
||||
async getHistoryMessages(
|
||||
conversationId: string,
|
||||
beforeSeq: number,
|
||||
limit: number = 20
|
||||
): Promise<Message[]> {
|
||||
// 先从本地获取
|
||||
const localMessages = await messageRepository.getMessagesBeforeSeq(
|
||||
conversationId,
|
||||
beforeSeq,
|
||||
limit
|
||||
);
|
||||
|
||||
if (localMessages.length >= limit) {
|
||||
return localMessages;
|
||||
}
|
||||
|
||||
// 本地数据不足,从服务端获取
|
||||
try {
|
||||
const response = await messageService.getMessages(
|
||||
conversationId,
|
||||
undefined,
|
||||
beforeSeq,
|
||||
limit
|
||||
);
|
||||
|
||||
if (response?.messages && response.messages.length > 0) {
|
||||
const serverMessages = response.messages.map((m: any) =>
|
||||
createMessage({
|
||||
id: m.id,
|
||||
conversationId: m.conversation_id || conversationId,
|
||||
senderId: m.sender_id,
|
||||
seq: m.seq,
|
||||
segments: m.segments || [],
|
||||
createdAt: m.created_at,
|
||||
status: m.status || 'normal',
|
||||
})
|
||||
);
|
||||
|
||||
// 保存到本地
|
||||
await messageRepository.saveMessages(serverMessages, false);
|
||||
|
||||
return serverMessages;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ProcessMessageUseCase] 获取历史消息失败:', error);
|
||||
}
|
||||
|
||||
return localMessages;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从服务端同步消息
|
||||
*/
|
||||
async syncMessagesFromServer(
|
||||
conversationId: string,
|
||||
afterSeq?: number
|
||||
): Promise<Message[]> {
|
||||
try {
|
||||
const response = await messageService.getMessages(conversationId, afterSeq);
|
||||
|
||||
if (response?.messages && response.messages.length > 0) {
|
||||
const messages = response.messages.map((m: any) =>
|
||||
createMessage({
|
||||
id: m.id,
|
||||
conversationId: m.conversation_id || conversationId,
|
||||
senderId: m.sender_id,
|
||||
seq: m.seq,
|
||||
segments: m.segments || [],
|
||||
createdAt: m.created_at,
|
||||
status: m.status || 'normal',
|
||||
})
|
||||
);
|
||||
|
||||
// 保存到本地
|
||||
await messageRepository.saveMessages(messages, false);
|
||||
|
||||
return messages;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ProcessMessageUseCase] 同步消息失败:', error);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅事件
|
||||
*/
|
||||
subscribe(subscriber: MessageUseCaseSubscriber): () => void {
|
||||
this.subscribers.add(subscriber);
|
||||
|
||||
return () => {
|
||||
this.subscribers.delete(subscriber);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知订阅者
|
||||
*/
|
||||
private notifySubscribers(event: MessageUseCaseEvent): void {
|
||||
this.subscribers.forEach((subscriber) => {
|
||||
try {
|
||||
subscriber(event);
|
||||
} catch (error) {
|
||||
console.error('[ProcessMessageUseCase] 订阅者执行失败:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户ID
|
||||
*/
|
||||
getCurrentUserId(): string | null {
|
||||
return this.currentUserId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前用户ID
|
||||
*/
|
||||
setCurrentUserId(userId: string | null): void {
|
||||
this.currentUserId = userId;
|
||||
}
|
||||
}
|
||||
|
||||
export const processMessageUseCase = new ProcessMessageUseCase();
|
||||
export default processMessageUseCase;
|
||||
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/SSEClient.ts
Normal file
184
src/data/datasources/SSEClient.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* SSEClient - SSE连接管理
|
||||
* 只负责SSE连接管理,提供事件驱动架构
|
||||
*/
|
||||
|
||||
import {
|
||||
sseService,
|
||||
WSChatMessage,
|
||||
WSGroupChatMessage,
|
||||
WSReadMessage,
|
||||
WSGroupReadMessage,
|
||||
WSRecallMessage,
|
||||
WSGroupRecallMessage,
|
||||
WSGroupTypingMessage,
|
||||
WSGroupNoticeMessage,
|
||||
WSMessageType,
|
||||
} from '../../services/sseService';
|
||||
|
||||
// 事件处理器类型
|
||||
type EventHandler<T> = (data: T) => void;
|
||||
|
||||
// SSE事件类型
|
||||
export interface SSEEvents {
|
||||
'chat': WSChatMessage;
|
||||
'group_message': WSGroupChatMessage;
|
||||
'read': WSReadMessage;
|
||||
'group_read': WSGroupReadMessage;
|
||||
'recall': WSRecallMessage;
|
||||
'group_recall': WSGroupRecallMessage;
|
||||
'group_typing': WSGroupTypingMessage;
|
||||
'group_notice': WSGroupNoticeMessage;
|
||||
'connected': void;
|
||||
'disconnected': void;
|
||||
'error': Error;
|
||||
}
|
||||
|
||||
export type SSEEventType = keyof SSEEvents;
|
||||
|
||||
class SSEClient {
|
||||
private handlers: Map<SSEEventType, Set<EventHandler<any>>> = new Map();
|
||||
private unsubscribeFns: Array<() => void> = [];
|
||||
private isInitialized = false;
|
||||
|
||||
/**
|
||||
* 初始化SSE监听
|
||||
*/
|
||||
initialize(): void {
|
||||
if (this.isInitialized) return;
|
||||
|
||||
// 监听私聊消息
|
||||
const unsubChat = sseService.on('chat', (message) => {
|
||||
this.emit('chat', message);
|
||||
});
|
||||
|
||||
// 监听群聊消息
|
||||
const unsubGroupMessage = sseService.on('group_message', (message) => {
|
||||
this.emit('group_message', message);
|
||||
});
|
||||
|
||||
// 监听私聊已读回执
|
||||
const unsubRead = sseService.on('read', (message) => {
|
||||
this.emit('read', message);
|
||||
});
|
||||
|
||||
// 监听群聊已读回执
|
||||
const unsubGroupRead = sseService.on('group_read', (message) => {
|
||||
this.emit('group_read', message);
|
||||
});
|
||||
|
||||
// 监听私聊消息撤回
|
||||
const unsubRecall = sseService.on('recall', (message) => {
|
||||
this.emit('recall', message);
|
||||
});
|
||||
|
||||
// 监听群聊消息撤回
|
||||
const unsubGroupRecall = sseService.on('group_recall', (message) => {
|
||||
this.emit('group_recall', message);
|
||||
});
|
||||
|
||||
// 监听群聊输入状态
|
||||
const unsubGroupTyping = sseService.on('group_typing', (message) => {
|
||||
this.emit('group_typing', message);
|
||||
});
|
||||
|
||||
// 监听群通知
|
||||
const unsubGroupNotice = sseService.on('group_notice', (message) => {
|
||||
this.emit('group_notice', message);
|
||||
});
|
||||
|
||||
// 监听连接状态
|
||||
const unsubConnect = sseService.onConnect(() => {
|
||||
this.emit('connected', undefined);
|
||||
});
|
||||
|
||||
const unsubDisconnect = sseService.onDisconnect(() => {
|
||||
this.emit('disconnected', undefined);
|
||||
});
|
||||
|
||||
this.unsubscribeFns = [
|
||||
unsubChat,
|
||||
unsubGroupMessage,
|
||||
unsubRead,
|
||||
unsubGroupRead,
|
||||
unsubRecall,
|
||||
unsubGroupRecall,
|
||||
unsubGroupTyping,
|
||||
unsubGroupNotice,
|
||||
unsubConnect,
|
||||
unsubDisconnect,
|
||||
];
|
||||
|
||||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁SSE监听
|
||||
*/
|
||||
destroy(): void {
|
||||
this.unsubscribeFns.forEach((fn) => fn());
|
||||
this.unsubscribeFns = [];
|
||||
this.handlers.clear();
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅事件
|
||||
*/
|
||||
on<T extends SSEEventType>(
|
||||
event: T,
|
||||
handler: EventHandler<SSEEvents[T]>
|
||||
): () => void {
|
||||
if (!this.handlers.has(event)) {
|
||||
this.handlers.set(event, new Set());
|
||||
}
|
||||
this.handlers.get(event)!.add(handler);
|
||||
|
||||
return () => {
|
||||
this.handlers.get(event)?.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发事件
|
||||
*/
|
||||
private emit<T extends SSEEventType>(
|
||||
event: T,
|
||||
data: SSEEvents[T]
|
||||
): void {
|
||||
const handlers = this.handlers.get(event);
|
||||
if (handlers) {
|
||||
handlers.forEach((handler) => {
|
||||
try {
|
||||
handler(data);
|
||||
} catch (error) {
|
||||
console.error(`[SSEClient] 事件处理器执行失败: ${event}`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查连接状态
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return sseService.isConnected();
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动SSE连接
|
||||
*/
|
||||
async connect(): Promise<boolean> {
|
||||
return sseService.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开SSE连接
|
||||
*/
|
||||
disconnect(): void {
|
||||
sseService.stop();
|
||||
}
|
||||
}
|
||||
|
||||
export const sseClient = new SSEClient();
|
||||
export default sseClient;
|
||||
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,
|
||||
@@ -36,3 +71,24 @@ export {
|
||||
prefetchMessageScreen,
|
||||
prefetchHomeScreen,
|
||||
} from './usePrefetch';
|
||||
|
||||
// ==================== 分页 Hooks ====================
|
||||
export {
|
||||
usePagination,
|
||||
usePaginationManager,
|
||||
} from './usePagination';
|
||||
|
||||
export type {
|
||||
UsePaginationOptions,
|
||||
UsePaginationReturn,
|
||||
} from './usePagination';
|
||||
|
||||
// ==================== 连接状态 Hooks ====================
|
||||
export { useConnectionState } from './useConnectionState';
|
||||
|
||||
export type { UseConnectionStateResult } from './useConnectionState';
|
||||
|
||||
// ==================== 媒体缓存 Hooks ====================
|
||||
export { useMediaCache } from './useMediaCache';
|
||||
|
||||
export type { UseMediaCacheReturn } from './useMediaCache';
|
||||
|
||||
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;
|
||||
}
|
||||
147
src/hooks/useConnectionState.ts
Normal file
147
src/hooks/useConnectionState.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 连接状态 Hook
|
||||
*
|
||||
* 提供连接状态的 React Hook 接口
|
||||
* 订阅 ConnectionStateManager 状态变化
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
ConnectionState,
|
||||
ConnectionStateInfo,
|
||||
connectionStateManager,
|
||||
} from '../infrastructure/sync';
|
||||
|
||||
/**
|
||||
* useConnectionState Hook 返回值接口
|
||||
*/
|
||||
export interface UseConnectionStateResult {
|
||||
/** 当前连接状态 */
|
||||
state: ConnectionState;
|
||||
/** 完整的状态信息 */
|
||||
stateInfo: ConnectionStateInfo;
|
||||
/** 是否已连接 */
|
||||
isConnected: boolean;
|
||||
/** 是否正在连接(包括 CONNECTING 和 RECONNECTING) */
|
||||
isConnecting: boolean;
|
||||
/** 是否正在重连 */
|
||||
isReconnecting: boolean;
|
||||
/** 是否已断开 */
|
||||
isDisconnected: boolean;
|
||||
/** 是否处于错误状态 */
|
||||
hasError: boolean;
|
||||
/** 当前错误信息 */
|
||||
error: Error | null;
|
||||
/** 当前重连次数 */
|
||||
reconnectAttempts: number;
|
||||
/** 最大重连次数 */
|
||||
maxReconnectAttempts: number;
|
||||
/** 最后连接时间 */
|
||||
lastConnectedAt: number | null;
|
||||
/** 状态描述文本 */
|
||||
description: string;
|
||||
/** 手动触发重连 */
|
||||
triggerReconnect: () => void;
|
||||
/** 重置重连次数 */
|
||||
resetReconnectAttempts: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接状态 Hook
|
||||
*
|
||||
* 订阅 ConnectionStateManager 的状态变化,
|
||||
* 提供便捷的状态属性和方法
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function ConnectionStatus() {
|
||||
* const { state, isConnected, isConnecting, description } = useConnectionState();
|
||||
*
|
||||
* return (
|
||||
* <View>
|
||||
* <Text>{description}</Text>
|
||||
* {isConnecting && <ActivityIndicator />}
|
||||
* </View>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useConnectionState(): UseConnectionStateResult {
|
||||
// 状态
|
||||
const [stateInfo, setStateInfo] = useState<ConnectionStateInfo>(() =>
|
||||
connectionStateManager.getState()
|
||||
);
|
||||
|
||||
// 订阅状态变化
|
||||
useEffect(() => {
|
||||
const unsubscribe = connectionStateManager.subscribe((newState) => {
|
||||
setStateInfo(newState);
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
// 计算派生状态
|
||||
const isConnected = useMemo(() =>
|
||||
stateInfo.state === ConnectionState.CONNECTED,
|
||||
[stateInfo.state]
|
||||
);
|
||||
|
||||
const isConnecting = useMemo(() =>
|
||||
stateInfo.state === ConnectionState.CONNECTING ||
|
||||
stateInfo.state === ConnectionState.RECONNECTING,
|
||||
[stateInfo.state]
|
||||
);
|
||||
|
||||
const isReconnecting = useMemo(() =>
|
||||
stateInfo.state === ConnectionState.RECONNECTING,
|
||||
[stateInfo.state]
|
||||
);
|
||||
|
||||
const isDisconnected = useMemo(() =>
|
||||
stateInfo.state === ConnectionState.DISCONNECTED ||
|
||||
stateInfo.state === ConnectionState.IDLE,
|
||||
[stateInfo.state]
|
||||
);
|
||||
|
||||
const hasError = useMemo(() =>
|
||||
stateInfo.state === ConnectionState.ERROR,
|
||||
[stateInfo.state]
|
||||
);
|
||||
|
||||
// 手动触发重连
|
||||
const triggerReconnect = useCallback(() => {
|
||||
// 只有在断开或错误状态才能触发重连
|
||||
if (
|
||||
stateInfo.state === ConnectionState.DISCONNECTED ||
|
||||
stateInfo.state === ConnectionState.ERROR ||
|
||||
stateInfo.state === ConnectionState.IDLE
|
||||
) {
|
||||
connectionStateManager.setState(ConnectionState.RECONNECTING);
|
||||
}
|
||||
}, [stateInfo.state]);
|
||||
|
||||
// 重置重连次数
|
||||
const resetReconnectAttempts = useCallback(() => {
|
||||
connectionStateManager.resetReconnectAttempts();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
state: stateInfo.state,
|
||||
stateInfo,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
isReconnecting,
|
||||
isDisconnected,
|
||||
hasError,
|
||||
error: stateInfo.error || null,
|
||||
reconnectAttempts: stateInfo.reconnectAttempts || 0,
|
||||
maxReconnectAttempts: connectionStateManager.getMaxReconnectAttempts(),
|
||||
lastConnectedAt: stateInfo.lastConnectedAt || null,
|
||||
description: stateInfo.description,
|
||||
triggerReconnect,
|
||||
resetReconnectAttempts,
|
||||
};
|
||||
}
|
||||
|
||||
export default useConnectionState;
|
||||
369
src/hooks/useDifferentialMessages.ts
Normal file
369
src/hooks/useDifferentialMessages.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* 差异更新 Hook
|
||||
* 接收原始消息列表,返回优化后的消息列表,自动处理批量更新
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
MessageIdentifier,
|
||||
MessageUpdate,
|
||||
MessageUpdateType,
|
||||
DiffConfig,
|
||||
} from '../infrastructure/diff/types';
|
||||
import {
|
||||
MessageDiffCalculator,
|
||||
createMessageDiffCalculator,
|
||||
} from '../infrastructure/diff/MessageDiffCalculator';
|
||||
import {
|
||||
MessageUpdateBatcher,
|
||||
createMessageUpdateBatcher,
|
||||
BatcherOptions,
|
||||
} from '../infrastructure/diff/MessageUpdateBatcher';
|
||||
|
||||
/**
|
||||
* 差异更新 Hook 配置选项
|
||||
*/
|
||||
export interface UseDifferentialMessagesOptions<T extends MessageIdentifier> {
|
||||
/** 差异计算配置 */
|
||||
diffConfig?: DiffConfig;
|
||||
/** 批量处理配置 */
|
||||
batcherOptions?: BatcherOptions;
|
||||
/** 是否启用差异计算,默认 true */
|
||||
enableDiff?: boolean;
|
||||
/** 是否启用批量处理,默认 true */
|
||||
enableBatching?: boolean;
|
||||
/** 最大消息数量限制,超出时触发重置 */
|
||||
maxMessageCount?: number;
|
||||
/** 变化比例阈值(0-1),超过此值触发重置 */
|
||||
changeRatioThreshold?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 差异更新 Hook 返回值
|
||||
*/
|
||||
export interface UseDifferentialMessagesResult<T extends MessageIdentifier> {
|
||||
/** 优化后的消息列表 */
|
||||
optimizedMessages: T[];
|
||||
/** 待处理更新数量 */
|
||||
pendingUpdateCount: number;
|
||||
/** 是否正在处理更新 */
|
||||
isProcessing: boolean;
|
||||
/** 手动刷新方法 */
|
||||
flush: () => void;
|
||||
/** 重置方法 */
|
||||
reset: () => void;
|
||||
/** 强制刷新(跳过批量处理) */
|
||||
forceUpdate: (messages: T[]) => void;
|
||||
/** 获取差异统计信息 */
|
||||
getDiffStats: () => {
|
||||
totalBatches: number;
|
||||
totalUpdates: number;
|
||||
averageBatchSize: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 差异更新 Hook
|
||||
* @param messages 原始消息列表
|
||||
* @param options 配置选项
|
||||
* @returns 优化后的消息列表和相关方法
|
||||
*/
|
||||
export function useDifferentialMessages<T extends MessageIdentifier>(
|
||||
messages: T[],
|
||||
options: UseDifferentialMessagesOptions<T> = {}
|
||||
): UseDifferentialMessagesResult<T> {
|
||||
const {
|
||||
diffConfig,
|
||||
batcherOptions,
|
||||
enableDiff = true,
|
||||
enableBatching = true,
|
||||
maxMessageCount = 10000,
|
||||
changeRatioThreshold = 0.5,
|
||||
} = options;
|
||||
|
||||
// 状态
|
||||
const [optimizedMessages, setOptimizedMessages] = useState<T[]>(messages);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [pendingUpdateCount, setPendingUpdateCount] = useState(0);
|
||||
|
||||
// Refs
|
||||
const calculatorRef = useRef<MessageDiffCalculator<T> | null>(null);
|
||||
const batcherRef = useRef<MessageUpdateBatcher | null>(null);
|
||||
const previousMessagesRef = useRef<T[]>([]);
|
||||
|
||||
// 初始化差异计算器
|
||||
useEffect(() => {
|
||||
if (enableDiff) {
|
||||
calculatorRef.current = createMessageDiffCalculator<T>(diffConfig);
|
||||
}
|
||||
return () => {
|
||||
calculatorRef.current = null;
|
||||
};
|
||||
}, [enableDiff, diffConfig]);
|
||||
|
||||
// 初始化批量处理器
|
||||
useEffect(() => {
|
||||
if (enableBatching) {
|
||||
batcherRef.current = createMessageUpdateBatcher(batcherOptions);
|
||||
|
||||
// 订阅批量更新
|
||||
const unsubscribe = batcherRef.current.subscribe((updates) => {
|
||||
handleBatchUpdates(updates);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
batcherRef.current?.destroy();
|
||||
batcherRef.current = null;
|
||||
};
|
||||
}
|
||||
}, [enableBatching, batcherOptions]);
|
||||
|
||||
// 处理批量更新
|
||||
const handleBatchUpdates = useCallback((updates: MessageUpdate[]) => {
|
||||
setIsProcessing(true);
|
||||
|
||||
try {
|
||||
setOptimizedMessages((currentMessages) => {
|
||||
let newMessages = [...currentMessages];
|
||||
|
||||
for (const update of updates) {
|
||||
switch (update.type) {
|
||||
case MessageUpdateType.ADD: {
|
||||
const addUpdate = update as any;
|
||||
const index = addUpdate.index ?? newMessages.length;
|
||||
newMessages.splice(index, 0, addUpdate.message);
|
||||
break;
|
||||
}
|
||||
|
||||
case MessageUpdateType.BATCH_ADD: {
|
||||
const batchAddUpdate = update as any;
|
||||
const messages = batchAddUpdate.messages || [];
|
||||
const startIndex = batchAddUpdate.startIndex ?? newMessages.length;
|
||||
newMessages.splice(startIndex, 0, ...messages);
|
||||
break;
|
||||
}
|
||||
|
||||
case MessageUpdateType.UPDATE: {
|
||||
const updateMsg = update as any;
|
||||
const index = newMessages.findIndex(m => m.id === updateMsg.messageId);
|
||||
if (index !== -1) {
|
||||
newMessages[index] = { ...newMessages[index], ...updateMsg.updates };
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case MessageUpdateType.BATCH_UPDATE: {
|
||||
const batchUpdate = update as any;
|
||||
const updates = batchUpdate.updates || [];
|
||||
for (const { messageId, changes } of updates) {
|
||||
const index = newMessages.findIndex(m => m.id === messageId);
|
||||
if (index !== -1) {
|
||||
newMessages[index] = { ...newMessages[index], ...changes };
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case MessageUpdateType.DELETE: {
|
||||
const deleteUpdate = update as any;
|
||||
newMessages = newMessages.filter(m => m.id !== deleteUpdate.messageId);
|
||||
break;
|
||||
}
|
||||
|
||||
case MessageUpdateType.BATCH_DELETE: {
|
||||
const batchDelete = update as any;
|
||||
const idsToDelete = new Set(batchDelete.messageIds || []);
|
||||
newMessages = newMessages.filter(m => !idsToDelete.has(m.id));
|
||||
break;
|
||||
}
|
||||
|
||||
case MessageUpdateType.MOVE: {
|
||||
const moveUpdate = update as any;
|
||||
const fromIndex = newMessages.findIndex(m => m.id === moveUpdate.messageId);
|
||||
if (fromIndex !== -1) {
|
||||
const [movedMessage] = newMessages.splice(fromIndex, 1);
|
||||
newMessages.splice(moveUpdate.toIndex, 0, movedMessage);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case MessageUpdateType.RESET: {
|
||||
const resetUpdate = update as any;
|
||||
newMessages = resetUpdate.messages || [];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newMessages;
|
||||
});
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setPendingUpdateCount(batcherRef.current?.getPendingCount() ?? 0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 监听外部消息变化
|
||||
useEffect(() => {
|
||||
// 如果消息数量变化太大,直接重置
|
||||
const previousCount = previousMessagesRef.current.length;
|
||||
const currentCount = messages.length;
|
||||
const changeRatio = previousCount === 0 ? 1 : Math.abs(currentCount - previousCount) / previousCount;
|
||||
|
||||
if (changeRatio > changeRatioThreshold) {
|
||||
setOptimizedMessages(messages);
|
||||
previousMessagesRef.current = messages;
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用差异计算
|
||||
if (enableDiff && calculatorRef.current) {
|
||||
const diff = calculatorRef.current.calculateDiff(previousMessagesRef.current, messages);
|
||||
|
||||
if (diff.shouldReset) {
|
||||
setOptimizedMessages(messages);
|
||||
} else {
|
||||
// 将差异转换为更新操作
|
||||
const updates: MessageUpdate[] = [];
|
||||
|
||||
// 处理新增
|
||||
if (diff.added.length > 0) {
|
||||
if (diff.added.length === 1) {
|
||||
updates.push({
|
||||
type: MessageUpdateType.ADD,
|
||||
timestamp: Date.now(),
|
||||
message: diff.added[0],
|
||||
} as any);
|
||||
} else {
|
||||
updates.push({
|
||||
type: MessageUpdateType.BATCH_ADD,
|
||||
timestamp: Date.now(),
|
||||
messages: diff.added,
|
||||
} as any);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理更新
|
||||
if (diff.updated.length > 0) {
|
||||
if (diff.updated.length === 1) {
|
||||
updates.push({
|
||||
type: MessageUpdateType.UPDATE,
|
||||
timestamp: Date.now(),
|
||||
messageId: diff.updated[0].message.id,
|
||||
updates: diff.updated[0].changes,
|
||||
} as any);
|
||||
} else {
|
||||
updates.push({
|
||||
type: MessageUpdateType.BATCH_UPDATE,
|
||||
timestamp: Date.now(),
|
||||
updates: diff.updated.map(u => ({
|
||||
messageId: u.message.id,
|
||||
changes: u.changes,
|
||||
})),
|
||||
} as any);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理删除
|
||||
if (diff.deleted.length > 0) {
|
||||
if (diff.deleted.length === 1) {
|
||||
updates.push({
|
||||
type: MessageUpdateType.DELETE,
|
||||
timestamp: Date.now(),
|
||||
messageId: diff.deleted[0].message.id,
|
||||
} as any);
|
||||
} else {
|
||||
updates.push({
|
||||
type: MessageUpdateType.BATCH_DELETE,
|
||||
timestamp: Date.now(),
|
||||
messageIds: diff.deleted.map(d => d.message.id),
|
||||
} as any);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理移动
|
||||
if (diff.moved.length > 0) {
|
||||
for (const move of diff.moved) {
|
||||
updates.push({
|
||||
type: MessageUpdateType.MOVE,
|
||||
timestamp: Date.now(),
|
||||
messageId: move.message.id,
|
||||
toIndex: move.toIndex,
|
||||
} as any);
|
||||
}
|
||||
}
|
||||
|
||||
// 应用更新
|
||||
if (updates.length > 0) {
|
||||
if (enableBatching && batcherRef.current) {
|
||||
batcherRef.current.addUpdates(updates);
|
||||
} else {
|
||||
handleBatchUpdates(updates);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 不使用差异计算,直接设置
|
||||
setOptimizedMessages(messages);
|
||||
}
|
||||
|
||||
// 更新消息数量限制检查
|
||||
if (messages.length > maxMessageCount) {
|
||||
console.warn(`[useDifferentialMessages] Message count (${messages.length}) exceeds limit (${maxMessageCount})`);
|
||||
}
|
||||
|
||||
previousMessagesRef.current = messages;
|
||||
}, [messages, enableDiff, enableBatching, maxMessageCount, changeRatioThreshold]);
|
||||
|
||||
// 手动刷新方法
|
||||
const flush = useCallback(() => {
|
||||
batcherRef.current?.flush();
|
||||
}, []);
|
||||
|
||||
// 重置方法
|
||||
const reset = useCallback(() => {
|
||||
calculatorRef.current?.reset();
|
||||
batcherRef.current?.clearPending();
|
||||
setOptimizedMessages([]);
|
||||
previousMessagesRef.current = [];
|
||||
}, []);
|
||||
|
||||
// 强制更新方法
|
||||
const forceUpdate = useCallback((newMessages: T[]) => {
|
||||
setOptimizedMessages(newMessages);
|
||||
}, []);
|
||||
|
||||
// 获取统计信息
|
||||
const getDiffStats = useCallback(() => {
|
||||
const batcherStats = batcherRef.current?.getStats();
|
||||
return {
|
||||
totalBatches: batcherStats?.totalBatches ?? 0,
|
||||
totalUpdates: batcherStats?.totalUpdates ?? 0,
|
||||
averageBatchSize: batcherStats?.averageBatchSize ?? 0,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 定期更新待处理数量
|
||||
useEffect(() => {
|
||||
if (!enableBatching || !batcherRef.current) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setPendingUpdateCount(batcherRef.current?.getPendingCount() ?? 0);
|
||||
}, 50);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [enableBatching]);
|
||||
|
||||
return {
|
||||
optimizedMessages,
|
||||
pendingUpdateCount,
|
||||
isProcessing,
|
||||
flush,
|
||||
reset,
|
||||
forceUpdate,
|
||||
getDiffStats,
|
||||
};
|
||||
}
|
||||
|
||||
export default useDifferentialMessages;
|
||||
241
src/hooks/useMediaCache.ts
Normal file
241
src/hooks/useMediaCache.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* 媒体缓存 Hook
|
||||
* @module src/hooks/useMediaCache
|
||||
* @description 提供媒体缓存管理的 React Hook
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { mediaCacheManager } from '../infrastructure/cache';
|
||||
import {
|
||||
CacheStats,
|
||||
CleanupPolicy,
|
||||
CleanupResult,
|
||||
MediaType,
|
||||
CleanupTrigger,
|
||||
DEFAULT_CLEANUP_POLICY,
|
||||
} from '../infrastructure/cache/types';
|
||||
|
||||
/**
|
||||
* useMediaCache Hook 返回值类型
|
||||
*/
|
||||
export interface UseMediaCacheReturn {
|
||||
/** 缓存统计信息 */
|
||||
stats: CacheStats | null;
|
||||
/** 是否正在清理 */
|
||||
isClearing: boolean;
|
||||
/** 是否已初始化 */
|
||||
isInitialized: boolean;
|
||||
/** 加载缓存统计 */
|
||||
loadStats: () => void;
|
||||
/** 执行清理(手动) */
|
||||
cleanup: (policy?: Partial<CleanupPolicy>) => Promise<CleanupResult>;
|
||||
/** 清空所有缓存 */
|
||||
clearAll: () => Promise<void>;
|
||||
/** 清理指定会话的缓存 */
|
||||
clearConversation: (conversationId: string) => Promise<CleanupResult>;
|
||||
/** 启动定期清理 */
|
||||
startPeriodicCleanup: () => void;
|
||||
/** 停止定期清理 */
|
||||
stopPeriodicCleanup: () => void;
|
||||
/** 记录缓存访问(用于 LRU) */
|
||||
recordAccess: (key: string) => Promise<void>;
|
||||
/** 格式化缓存大小 */
|
||||
formatSize: (bytes: number) => string;
|
||||
/** 缓存媒体文件 */
|
||||
cacheMedia: (
|
||||
uri: string,
|
||||
type: MediaType,
|
||||
options?: {
|
||||
conversationId?: string;
|
||||
messageId?: string;
|
||||
size?: number;
|
||||
}
|
||||
) => Promise<string>;
|
||||
/** 获取缓存的媒体路径 */
|
||||
getCachedMedia: (uri: string, type: MediaType) => Promise<string | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 媒体缓存 Hook
|
||||
* @description 提供缓存统计、清理功能、访问记录等
|
||||
* @returns {UseMediaCacheReturn} 缓存管理接口
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MediaSettings() {
|
||||
* const { stats, cleanup, formatSize } = useMediaCache();
|
||||
*
|
||||
* return (
|
||||
* <View>
|
||||
* <Text>总缓存: {formatSize(stats?.totalSize || 0)}</Text>
|
||||
* <Text>图片: {stats?.imageCount || 0}</Text>
|
||||
* <Text>视频: {stats?.videoCount || 0}</Text>
|
||||
* <Button title="清理缓存" onPress={cleanup} />
|
||||
* </View>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useMediaCache(): UseMediaCacheReturn {
|
||||
const [stats, setStats] = useState<CacheStats | null>(null);
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
/**
|
||||
* 加载缓存统计
|
||||
*/
|
||||
const loadStats = useCallback(() => {
|
||||
const cacheStats = mediaCacheManager.getCacheStats();
|
||||
setStats(cacheStats);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 执行清理
|
||||
*/
|
||||
const cleanup = useCallback(async (policy?: Partial<CleanupPolicy>): Promise<CleanupResult> => {
|
||||
setIsClearing(true);
|
||||
try {
|
||||
// 如果提供了自定义策略,应用它
|
||||
// 注意:这里简化处理,实际可能需要合并策略
|
||||
const result = await mediaCacheManager.cleanup(CleanupTrigger.MANUAL);
|
||||
await loadStats();
|
||||
return result;
|
||||
} finally {
|
||||
setIsClearing(false);
|
||||
}
|
||||
}, [loadStats]);
|
||||
|
||||
/**
|
||||
* 清空所有缓存
|
||||
*/
|
||||
const clearAll = useCallback(async (): Promise<void> => {
|
||||
setIsClearing(true);
|
||||
try {
|
||||
await mediaCacheManager.clearAll();
|
||||
await loadStats();
|
||||
} finally {
|
||||
setIsClearing(false);
|
||||
}
|
||||
}, [loadStats]);
|
||||
|
||||
/**
|
||||
* 清理指定会话的缓存
|
||||
*/
|
||||
const clearConversation = useCallback(async (conversationId: string): Promise<CleanupResult> => {
|
||||
setIsClearing(true);
|
||||
try {
|
||||
const result = await mediaCacheManager.clearConversationMedia(conversationId);
|
||||
await loadStats();
|
||||
return result;
|
||||
} finally {
|
||||
setIsClearing(false);
|
||||
}
|
||||
}, [loadStats]);
|
||||
|
||||
/**
|
||||
* 启动定期清理
|
||||
*/
|
||||
const startPeriodicCleanup = useCallback(() => {
|
||||
mediaCacheManager.startPeriodicCleanup();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 停止定期清理
|
||||
*/
|
||||
const stopPeriodicCleanup = useCallback(() => {
|
||||
mediaCacheManager.stopPeriodicCleanup();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 记录缓存访问
|
||||
*/
|
||||
const recordAccess = useCallback(async (key: string): Promise<void> => {
|
||||
await mediaCacheManager.recordAccess(key);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 缓存媒体文件
|
||||
*/
|
||||
const cacheMedia = useCallback(async (
|
||||
uri: string,
|
||||
type: MediaType,
|
||||
options?: {
|
||||
conversationId?: string;
|
||||
messageId?: string;
|
||||
size?: number;
|
||||
}
|
||||
): Promise<string> => {
|
||||
const localPath = await mediaCacheManager.cacheMedia(uri, type, options);
|
||||
await loadStats();
|
||||
return localPath;
|
||||
}, [loadStats]);
|
||||
|
||||
/**
|
||||
* 获取缓存的媒体路径
|
||||
*/
|
||||
const getCachedMedia = useCallback(async (uri: string, type: MediaType): Promise<string | null> => {
|
||||
return await mediaCacheManager.getCachedMedia(uri, type);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 格式化缓存大小
|
||||
*/
|
||||
const formatSize = useCallback((bytes: number): string => {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 初始化
|
||||
*/
|
||||
useEffect(() => {
|
||||
const initialize = async () => {
|
||||
if (initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
|
||||
try {
|
||||
await mediaCacheManager.initialize();
|
||||
setIsInitialized(true);
|
||||
loadStats();
|
||||
} catch (error) {
|
||||
console.error('[useMediaCache] 初始化失败:', error);
|
||||
// 即使初始化失败,也允许使用基本功能
|
||||
setIsInitialized(true);
|
||||
loadStats();
|
||||
}
|
||||
};
|
||||
|
||||
initialize();
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
// 停止定期清理
|
||||
mediaCacheManager.stopPeriodicCleanup();
|
||||
};
|
||||
}, [loadStats]);
|
||||
|
||||
return {
|
||||
stats,
|
||||
isClearing,
|
||||
isInitialized,
|
||||
loadStats,
|
||||
cleanup,
|
||||
clearAll,
|
||||
clearConversation,
|
||||
startPeriodicCleanup,
|
||||
stopPeriodicCleanup,
|
||||
recordAccess,
|
||||
formatSize,
|
||||
cacheMedia,
|
||||
getCachedMedia,
|
||||
};
|
||||
}
|
||||
321
src/hooks/usePagination.ts
Normal file
321
src/hooks/usePagination.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* 分页 Hook
|
||||
*
|
||||
* 提供 React 组件中使用的分页功能,包括:
|
||||
* - loadMore: 加载更多数据
|
||||
* - refresh: 刷新数据
|
||||
* - prefetch: 预加载数据
|
||||
* - 自动预加载检测
|
||||
* - 分页状态管理
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
|
||||
import {
|
||||
paginationStateManager,
|
||||
PaginationState,
|
||||
PaginationConfig,
|
||||
LoadMoreResult,
|
||||
RefreshResult,
|
||||
PrefetchResult,
|
||||
FetchPageFunction,
|
||||
DEFAULT_PAGINATION_CONFIG,
|
||||
} from '../infrastructure/pagination';
|
||||
|
||||
/**
|
||||
* usePagination 选项
|
||||
*/
|
||||
export interface UsePaginationOptions<T = any> {
|
||||
/** 分页键(通常是会话ID或列表标识) */
|
||||
key: string | null;
|
||||
/** 数据获取函数 */
|
||||
fetchFunction: FetchPageFunction<T>;
|
||||
/** 分页配置 */
|
||||
config?: Partial<PaginationConfig>;
|
||||
/** 初始数据 */
|
||||
initialData?: T[];
|
||||
/** 是否自动加载第一页 */
|
||||
autoLoad?: boolean;
|
||||
/** 是否启用自动预加载 */
|
||||
enableAutoPrefetch?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* usePagination 返回值
|
||||
*/
|
||||
export interface UsePaginationReturn<T = any> {
|
||||
/** 当前数据列表 */
|
||||
data: T[];
|
||||
/** 是否正在加载 */
|
||||
isLoading: boolean;
|
||||
/** 是否正在刷新 */
|
||||
isRefreshing: boolean;
|
||||
/** 是否有更多数据 */
|
||||
hasMore: boolean;
|
||||
/** 是否发生过错误 */
|
||||
hasError: boolean;
|
||||
/** 错误信息 */
|
||||
error?: string;
|
||||
/** 当前页码 */
|
||||
currentPage: number;
|
||||
/** 已加载的总数 */
|
||||
totalLoaded: number;
|
||||
/** 加载更多 */
|
||||
loadMore: () => Promise<LoadMoreResult<T>>;
|
||||
/** 刷新数据 */
|
||||
refresh: () => Promise<RefreshResult<T>>;
|
||||
/** 预加载指定页面 */
|
||||
prefetch: (page: number) => Promise<PrefetchResult<T>>;
|
||||
/** 重置分页状态 */
|
||||
reset: () => void;
|
||||
/** 手动设置数据 */
|
||||
setData: (data: T[]) => void;
|
||||
/** 追加数据 */
|
||||
appendData: (data: T[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页 Hook
|
||||
*
|
||||
* 示例用法:
|
||||
* ```typescript
|
||||
* const {
|
||||
* data,
|
||||
* isLoading,
|
||||
* hasMore,
|
||||
* loadMore,
|
||||
* refresh,
|
||||
* } = usePagination<Message>({
|
||||
* key: conversationId,
|
||||
* fetchFunction: async (page, pageSize, cursor) => {
|
||||
* const result = await messageService.getMessages({
|
||||
* page,
|
||||
* pageSize,
|
||||
* beforeSeq: cursor,
|
||||
* });
|
||||
* return {
|
||||
* data: result.messages,
|
||||
* hasMore: result.has_more,
|
||||
* cursor: result.min_seq,
|
||||
* };
|
||||
* },
|
||||
* config: { pageSize: 20 },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function usePagination<T = any>(
|
||||
options: UsePaginationOptions<T>
|
||||
): UsePaginationReturn<T> {
|
||||
const {
|
||||
key,
|
||||
fetchFunction,
|
||||
config: userConfig,
|
||||
initialData = [],
|
||||
autoLoad = false,
|
||||
enableAutoPrefetch = true,
|
||||
} = options;
|
||||
|
||||
// 合并配置
|
||||
const config = useMemo(() => ({
|
||||
...DEFAULT_PAGINATION_CONFIG,
|
||||
...userConfig,
|
||||
}), [userConfig]);
|
||||
|
||||
// 内部数据状态
|
||||
const [data, setData] = useState<T[]>(initialData);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [lastError, setLastError] = useState<string | undefined>(undefined);
|
||||
|
||||
// 用于追踪是否已自动加载
|
||||
const autoLoadRef = useRef(false);
|
||||
|
||||
// 用于追踪组件是否已卸载
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
// 获取当前分页状态
|
||||
const paginationState = useMemo(() => {
|
||||
if (!key) return null;
|
||||
return paginationStateManager.getState(key, config);
|
||||
}, [key, config]);
|
||||
|
||||
// 计算派生状态
|
||||
const isLoading = paginationState?.isLoading ?? false;
|
||||
const hasMore = paginationState?.hasMore ?? true;
|
||||
const currentPage = paginationState?.currentPage ?? 0;
|
||||
const totalLoaded = paginationState?.totalLoaded ?? 0;
|
||||
const hasError = paginationState?.hasError ?? false;
|
||||
const error = paginationState?.error ?? lastError;
|
||||
|
||||
// 当 key 变化时重置数据
|
||||
useEffect(() => {
|
||||
if (key) {
|
||||
setData(initialData);
|
||||
autoLoadRef.current = false;
|
||||
}
|
||||
}, [key]);
|
||||
|
||||
// 组件卸载时清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 自动加载第一页
|
||||
useEffect(() => {
|
||||
if (!key || !autoLoad || autoLoadRef.current) return;
|
||||
if (data.length > 0) return;
|
||||
|
||||
autoLoadRef.current = true;
|
||||
refresh();
|
||||
}, [key, autoLoad, data.length]);
|
||||
|
||||
// 自动预加载检测
|
||||
useEffect(() => {
|
||||
if (!key || !enableAutoPrefetch) return;
|
||||
if (!paginationStateManager.shouldPrefetch(key, data.length)) return;
|
||||
|
||||
// 触发预加载
|
||||
const prefetchNextPage = async () => {
|
||||
const nextPage = currentPage + 1;
|
||||
await paginationStateManager.prefetch(key, nextPage, fetchFunction);
|
||||
};
|
||||
|
||||
prefetchNextPage();
|
||||
}, [key, data.length, currentPage, enableAutoPrefetch, fetchFunction]);
|
||||
|
||||
/**
|
||||
* 加载更多数据
|
||||
*/
|
||||
const loadMore = useCallback(async (): Promise<LoadMoreResult<T>> => {
|
||||
if (!key) {
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
hasMore: false,
|
||||
fromCache: false,
|
||||
error: 'No pagination key provided',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await paginationStateManager.loadMore<T>(key, fetchFunction);
|
||||
|
||||
if (isMountedRef.current) {
|
||||
if (result.success) {
|
||||
setData(prev => [...prev, ...result.data]);
|
||||
setLastError(undefined);
|
||||
} else if (result.error) {
|
||||
setLastError(result.error);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [key, fetchFunction]);
|
||||
|
||||
/**
|
||||
* 刷新数据
|
||||
*/
|
||||
const refresh = useCallback(async (): Promise<RefreshResult<T>> => {
|
||||
if (!key) {
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
hasMore: false,
|
||||
error: 'No pagination key provided',
|
||||
};
|
||||
}
|
||||
|
||||
setIsRefreshing(true);
|
||||
|
||||
try {
|
||||
const result = await paginationStateManager.refresh<T>(key, fetchFunction);
|
||||
|
||||
if (isMountedRef.current) {
|
||||
if (result.success) {
|
||||
setData(result.data);
|
||||
setLastError(undefined);
|
||||
} else if (result.error) {
|
||||
setLastError(result.error);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}
|
||||
}, [key, fetchFunction]);
|
||||
|
||||
/**
|
||||
* 预加载指定页面
|
||||
*/
|
||||
const prefetch = useCallback(async (page: number): Promise<PrefetchResult<T>> => {
|
||||
if (!key) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No pagination key provided',
|
||||
};
|
||||
}
|
||||
|
||||
return paginationStateManager.prefetch<T>(key, page, fetchFunction);
|
||||
}, [key, fetchFunction]);
|
||||
|
||||
/**
|
||||
* 重置分页状态
|
||||
*/
|
||||
const reset = useCallback(() => {
|
||||
if (!key) return;
|
||||
|
||||
paginationStateManager.reset(key, config);
|
||||
if (isMountedRef.current) {
|
||||
setData([]);
|
||||
setLastError(undefined);
|
||||
autoLoadRef.current = false;
|
||||
}
|
||||
}, [key, config]);
|
||||
|
||||
/**
|
||||
* 手动设置数据
|
||||
*/
|
||||
const setDataManual = useCallback((newData: T[]) => {
|
||||
if (isMountedRef.current) {
|
||||
setData(newData);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 追加数据
|
||||
*/
|
||||
const appendData = useCallback((newData: T[]) => {
|
||||
if (isMountedRef.current) {
|
||||
setData(prev => [...prev, ...newData]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading,
|
||||
isRefreshing,
|
||||
hasMore,
|
||||
hasError,
|
||||
error,
|
||||
currentPage,
|
||||
totalLoaded,
|
||||
loadMore,
|
||||
refresh,
|
||||
prefetch,
|
||||
reset,
|
||||
setData: setDataManual,
|
||||
appendData,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用分页状态管理器的单例实例
|
||||
* 用于在组件外访问分页状态
|
||||
*/
|
||||
export function usePaginationManager() {
|
||||
return paginationStateManager;
|
||||
}
|
||||
|
||||
export default usePagination;
|
||||
726
src/infrastructure/cache/MediaCacheManager.ts
vendored
Normal file
726
src/infrastructure/cache/MediaCacheManager.ts
vendored
Normal file
@@ -0,0 +1,726 @@
|
||||
/**
|
||||
* 媒体缓存管理器
|
||||
* @module src/infrastructure/cache/MediaCacheManager
|
||||
* @description 管理图片、视频、音频缓存,支持多种清理策略
|
||||
*/
|
||||
|
||||
import { File, Paths } from 'expo-file-system';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import {
|
||||
MediaType,
|
||||
CleanupTrigger,
|
||||
CacheEntry,
|
||||
CacheStats,
|
||||
CleanupPolicy,
|
||||
CleanupResult,
|
||||
MediaCacheConfig,
|
||||
DEFAULT_MEDIA_CACHE_CONFIG,
|
||||
DEFAULT_CLEANUP_POLICY,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* 缓存记录存储键前缀
|
||||
*/
|
||||
const CACHE_RECORD_PREFIX = 'media_cache_record_';
|
||||
|
||||
/**
|
||||
* 获取缓存目录路径
|
||||
*/
|
||||
function getCacheDir(): string {
|
||||
const cachePath = Paths.cache;
|
||||
return `${cachePath}media_cache/`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图片缓存目录
|
||||
*/
|
||||
function getImageDir(): string {
|
||||
return `${getCacheDir()}images/`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取视频缓存目录
|
||||
*/
|
||||
function getVideoDir(): string {
|
||||
return `${getCacheDir()}videos/`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取音频缓存目录
|
||||
*/
|
||||
function getAudioDir(): string {
|
||||
return `${getCacheDir()}audios/`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 媒体缓存管理器类
|
||||
* @class MediaCacheManager
|
||||
* @description 单例模式,管理所有媒体文件的缓存
|
||||
*/
|
||||
export class MediaCacheManager {
|
||||
private static instance: MediaCacheManager;
|
||||
|
||||
private cache: Map<string, CacheEntry> = new Map();
|
||||
private config: MediaCacheConfig;
|
||||
private policy: CleanupPolicy;
|
||||
private cleanupTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private lastCleanupTime: number = 0;
|
||||
private isInitialized: boolean = false;
|
||||
|
||||
/**
|
||||
* 获取单例实例
|
||||
*/
|
||||
public static getInstance(): MediaCacheManager {
|
||||
if (!MediaCacheManager.instance) {
|
||||
MediaCacheManager.instance = new MediaCacheManager();
|
||||
}
|
||||
return MediaCacheManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 私有构造函数
|
||||
*/
|
||||
private constructor(
|
||||
config?: Partial<MediaCacheConfig>,
|
||||
policy?: Partial<CleanupPolicy>
|
||||
) {
|
||||
this.config = { ...DEFAULT_MEDIA_CACHE_CONFIG, ...config };
|
||||
this.policy = { ...DEFAULT_CLEANUP_POLICY, ...policy };
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化缓存管理器
|
||||
* 确保缓存目录存在并加载缓存记录
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
if (this.isInitialized) return;
|
||||
|
||||
try {
|
||||
// 确保缓存目录存在
|
||||
await this.ensureDirectories();
|
||||
|
||||
// 加载缓存记录
|
||||
await this.loadCacheRecords();
|
||||
|
||||
// 启动时清理
|
||||
if (this.policy.cleanupOnStart) {
|
||||
await this.cleanup(CleanupTrigger.STARTUP);
|
||||
}
|
||||
|
||||
// 启动定期清理
|
||||
this.startPeriodicCleanup();
|
||||
|
||||
this.isInitialized = true;
|
||||
} catch (error) {
|
||||
console.error('[MediaCacheManager] 初始化失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保缓存目录存在
|
||||
*/
|
||||
private async ensureDirectories(): Promise<void> {
|
||||
try {
|
||||
const dirs = [getImageDir(), getVideoDir(), getAudioDir()];
|
||||
for (const dir of dirs) {
|
||||
const dirFile = new File(dir);
|
||||
if (!await dirFile.exists) {
|
||||
await dirFile.create();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MediaCacheManager] 创建缓存目录失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从持久化存储加载缓存记录
|
||||
*/
|
||||
private async loadCacheRecords(): Promise<void> {
|
||||
try {
|
||||
const keys = await AsyncStorage.getAllKeys();
|
||||
const recordKeys = keys.filter(k => k.startsWith(CACHE_RECORD_PREFIX));
|
||||
|
||||
if (recordKeys.length === 0) return;
|
||||
|
||||
const records = await AsyncStorage.multiGet(recordKeys);
|
||||
|
||||
for (const [key, value] of records) {
|
||||
if (value) {
|
||||
const entry: CacheEntry = JSON.parse(value);
|
||||
// 验证文件是否存在
|
||||
const exists = await this.checkFileExists(entry.localPath);
|
||||
if (exists) {
|
||||
this.cache.set(entry.key, entry);
|
||||
} else {
|
||||
// 文件不存在,删除记录
|
||||
await AsyncStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MediaCacheManager] 加载缓存记录失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文件是否存在
|
||||
*/
|
||||
private async checkFileExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
const file = new File(path);
|
||||
return await file.exists;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录缓存访问(用于LRU)
|
||||
*/
|
||||
public async recordAccess(key: string): Promise<void> {
|
||||
const entry = this.cache.get(key);
|
||||
if (entry) {
|
||||
entry.lastAccessedAt = Date.now();
|
||||
await this.saveCacheRecord(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存媒体文件
|
||||
*/
|
||||
public async cacheMedia(
|
||||
uri: string,
|
||||
type: MediaType,
|
||||
options: {
|
||||
conversationId?: string;
|
||||
messageId?: string;
|
||||
size?: number;
|
||||
} = {}
|
||||
): Promise<string> {
|
||||
const { conversationId, messageId, size = 0 } = options;
|
||||
|
||||
// 生成唯一键
|
||||
const key = this.generateCacheKey(uri, type);
|
||||
const localPath = this.getMediaPath(type, key);
|
||||
|
||||
// 检查是否已缓存
|
||||
const existingEntry = this.cache.get(key);
|
||||
if (existingEntry) {
|
||||
await this.recordAccess(key);
|
||||
return existingEntry.localPath;
|
||||
}
|
||||
|
||||
try {
|
||||
// 下载文件
|
||||
const destination = new File(localPath);
|
||||
const downloaded = await File.downloadFileAsync(uri, destination);
|
||||
|
||||
// 获取实际文件大小
|
||||
let fileSize = size;
|
||||
// fileSize 为 0 时使用默认大小,实际应用中可以通过其他方式获取
|
||||
|
||||
// 创建缓存条目
|
||||
const entry: CacheEntry = {
|
||||
key,
|
||||
type,
|
||||
uri,
|
||||
localPath: downloaded.uri,
|
||||
size: fileSize,
|
||||
conversationId,
|
||||
messageId,
|
||||
createdAt: Date.now(),
|
||||
lastAccessedAt: Date.now(),
|
||||
};
|
||||
|
||||
// 保存记录
|
||||
this.cache.set(key, entry);
|
||||
await this.saveCacheRecord(entry);
|
||||
|
||||
// 检查是否需要清理
|
||||
await this.checkAndCleanup();
|
||||
|
||||
return downloaded.uri;
|
||||
} catch (error) {
|
||||
console.error('[MediaCacheManager] 缓存媒体失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存的媒体路径
|
||||
*/
|
||||
public async getCachedMedia(uri: string, type: MediaType): Promise<string | null> {
|
||||
const key = this.generateCacheKey(uri, type);
|
||||
const entry = this.cache.get(key);
|
||||
|
||||
if (entry) {
|
||||
const exists = await this.checkFileExists(entry.localPath);
|
||||
if (exists) {
|
||||
await this.recordAccess(key);
|
||||
return entry.localPath;
|
||||
} else {
|
||||
// 文件不存在,删除记录
|
||||
this.cache.delete(key);
|
||||
await this.removeCacheRecord(key);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存统计信息
|
||||
*/
|
||||
public getCacheStats(): CacheStats {
|
||||
const stats: CacheStats = {
|
||||
totalSize: 0,
|
||||
imageCount: 0,
|
||||
videoCount: 0,
|
||||
audioCount: 0,
|
||||
totalEntries: this.cache.size,
|
||||
oldestItem: Date.now(),
|
||||
newestItem: 0,
|
||||
};
|
||||
|
||||
for (const entry of this.cache.values()) {
|
||||
stats.totalSize += entry.size;
|
||||
stats.oldestItem = Math.min(stats.oldestItem, entry.createdAt);
|
||||
stats.newestItem = Math.max(stats.newestItem, entry.createdAt);
|
||||
|
||||
switch (entry.type) {
|
||||
case MediaType.IMAGE:
|
||||
stats.imageCount++;
|
||||
break;
|
||||
case MediaType.VIDEO:
|
||||
stats.videoCount++;
|
||||
break;
|
||||
case MediaType.AUDIO:
|
||||
stats.audioCount++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理操作入口
|
||||
*/
|
||||
public async cleanup(trigger: CleanupTrigger): Promise<CleanupResult> {
|
||||
const errors: string[] = [];
|
||||
let deletedCount = 0;
|
||||
let freedSize = 0;
|
||||
const deletedItems: string[] = [];
|
||||
|
||||
try {
|
||||
// 1. 清理过期缓存
|
||||
const expiredResult = await this.cleanupExpired();
|
||||
deletedCount += expiredResult.deletedCount;
|
||||
freedSize += expiredResult.freedSize;
|
||||
deletedItems.push(...expiredResult.deletedItems || []);
|
||||
errors.push(...expiredResult.errors);
|
||||
|
||||
// 2. LRU清理(如果启用)
|
||||
if (this.policy.enableLRU) {
|
||||
const lruResult = await this.cleanupLRU();
|
||||
deletedCount += lruResult.deletedCount;
|
||||
freedSize += lruResult.freedSize;
|
||||
deletedItems.push(...lruResult.deletedItems || []);
|
||||
errors.push(...lruResult.errors);
|
||||
}
|
||||
|
||||
// 3. 阈值清理
|
||||
const thresholdResult = await this.cleanupByThreshold();
|
||||
deletedCount += thresholdResult.deletedCount;
|
||||
freedSize += thresholdResult.freedSize;
|
||||
deletedItems.push(...thresholdResult.deletedItems || []);
|
||||
errors.push(...thresholdResult.errors);
|
||||
|
||||
this.lastCleanupTime = Date.now();
|
||||
} catch (error) {
|
||||
errors.push(`清理失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
}
|
||||
|
||||
return {
|
||||
trigger,
|
||||
timestamp: Date.now(),
|
||||
deletedCount,
|
||||
freedSize,
|
||||
errors,
|
||||
deletedItems,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期缓存
|
||||
*/
|
||||
public async cleanupExpired(): Promise<CleanupResult> {
|
||||
const now = Date.now();
|
||||
const maxAge = this.policy.maxAge;
|
||||
const errors: string[] = [];
|
||||
let deletedCount = 0;
|
||||
let freedSize = 0;
|
||||
const deletedItems: string[] = [];
|
||||
|
||||
const entriesToDelete: string[] = [];
|
||||
|
||||
for (const [key, entry] of this.cache.entries()) {
|
||||
if (now - entry.lastAccessedAt > maxAge) {
|
||||
entriesToDelete.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of entriesToDelete) {
|
||||
try {
|
||||
const entry = this.cache.get(key);
|
||||
if (entry) {
|
||||
const file = new File(entry.localPath);
|
||||
if (await file.exists) {
|
||||
await file.delete();
|
||||
}
|
||||
this.cache.delete(key);
|
||||
await this.removeCacheRecord(key);
|
||||
|
||||
deletedCount++;
|
||||
freedSize += entry.size;
|
||||
deletedItems.push(key);
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`删除 ${key} 失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
trigger: CleanupTrigger.MANUAL,
|
||||
timestamp: Date.now(),
|
||||
deletedCount,
|
||||
freedSize,
|
||||
errors,
|
||||
deletedItems,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* LRU清理 - 删除最近最少使用的缓存
|
||||
*/
|
||||
public async cleanupLRU(targetSize?: number): Promise<CleanupResult> {
|
||||
const errors: string[] = [];
|
||||
let deletedCount = 0;
|
||||
let freedSize = 0;
|
||||
const deletedItems: string[] = [];
|
||||
|
||||
// 如果没有指定目标大小,使用最大缓存大小的一半
|
||||
const target = targetSize || Math.floor(this.policy.maxSize / 2);
|
||||
const currentStats = this.getCacheStats();
|
||||
|
||||
if (currentStats.totalSize <= target) {
|
||||
return {
|
||||
trigger: CleanupTrigger.MANUAL,
|
||||
timestamp: Date.now(),
|
||||
deletedCount: 0,
|
||||
freedSize: 0,
|
||||
errors: [],
|
||||
deletedItems: [],
|
||||
};
|
||||
}
|
||||
|
||||
// 按最后访问时间排序
|
||||
const sortedEntries = Array.from(this.cache.entries())
|
||||
.sort((a, b) => a[1].lastAccessedAt - b[1].lastAccessedAt);
|
||||
|
||||
let currentSize = currentStats.totalSize;
|
||||
|
||||
for (const [key, entry] of sortedEntries) {
|
||||
if (currentSize <= target) break;
|
||||
|
||||
try {
|
||||
const file = new File(entry.localPath);
|
||||
if (await file.exists) {
|
||||
await file.delete();
|
||||
}
|
||||
this.cache.delete(key);
|
||||
await this.removeCacheRecord(key);
|
||||
|
||||
deletedCount++;
|
||||
freedSize += entry.size;
|
||||
deletedItems.push(key);
|
||||
currentSize -= entry.size;
|
||||
} catch (error) {
|
||||
errors.push(`删除 ${key} 失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
trigger: CleanupTrigger.MANUAL,
|
||||
timestamp: Date.now(),
|
||||
deletedCount,
|
||||
freedSize,
|
||||
errors,
|
||||
deletedItems,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 按阈值清理 - 超过最大大小时清理
|
||||
*/
|
||||
private async cleanupByThreshold(): Promise<CleanupResult> {
|
||||
const currentStats = this.getCacheStats();
|
||||
const errors: string[] = [];
|
||||
let deletedCount = 0;
|
||||
let freedSize = 0;
|
||||
const deletedItems: string[] = [];
|
||||
|
||||
// 检查总大小是否超过限制
|
||||
if (currentStats.totalSize <= this.policy.maxSize) {
|
||||
// 检查条目数是否超过限制
|
||||
if (currentStats.totalEntries <= this.policy.maxEntries) {
|
||||
return {
|
||||
trigger: CleanupTrigger.MANUAL,
|
||||
timestamp: Date.now(),
|
||||
deletedCount: 0,
|
||||
freedSize: 0,
|
||||
errors: [],
|
||||
deletedItems: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 需要清理
|
||||
const targetSize = Math.floor(this.policy.maxSize * 0.8); // 清理到80%
|
||||
const targetEntries = Math.floor(this.policy.maxEntries * 0.8);
|
||||
|
||||
// 按LRU排序
|
||||
const sortedEntries = Array.from(this.cache.entries())
|
||||
.sort((a, b) => a[1].lastAccessedAt - b[1].lastAccessedAt);
|
||||
|
||||
let currentSize = currentStats.totalSize;
|
||||
let currentEntries = currentStats.totalEntries;
|
||||
|
||||
for (const [key, entry] of sortedEntries) {
|
||||
// 两个条件:大小或条目数
|
||||
if (currentSize <= targetSize && currentEntries <= targetEntries) break;
|
||||
|
||||
try {
|
||||
const file = new File(entry.localPath);
|
||||
if (await file.exists) {
|
||||
await file.delete();
|
||||
}
|
||||
this.cache.delete(key);
|
||||
await this.removeCacheRecord(key);
|
||||
|
||||
deletedCount++;
|
||||
freedSize += entry.size;
|
||||
deletedItems.push(key);
|
||||
currentSize -= entry.size;
|
||||
currentEntries--;
|
||||
} catch (error) {
|
||||
errors.push(`删除 ${key} 失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
trigger: CleanupTrigger.MANUAL,
|
||||
timestamp: Date.now(),
|
||||
deletedCount,
|
||||
freedSize,
|
||||
errors,
|
||||
deletedItems,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除指定会话的所有媒体缓存
|
||||
*/
|
||||
public async clearConversationMedia(conversationId: string): Promise<CleanupResult> {
|
||||
const errors: string[] = [];
|
||||
let deletedCount = 0;
|
||||
let freedSize = 0;
|
||||
const deletedItems: string[] = [];
|
||||
|
||||
const entriesToDelete: string[] = [];
|
||||
|
||||
for (const [key, entry] of this.cache.entries()) {
|
||||
if (entry.conversationId === conversationId) {
|
||||
entriesToDelete.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of entriesToDelete) {
|
||||
try {
|
||||
const entry = this.cache.get(key);
|
||||
if (entry) {
|
||||
const file = new File(entry.localPath);
|
||||
if (await file.exists) {
|
||||
await file.delete();
|
||||
}
|
||||
this.cache.delete(key);
|
||||
await this.removeCacheRecord(key);
|
||||
|
||||
deletedCount++;
|
||||
freedSize += entry.size;
|
||||
deletedItems.push(key);
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`删除 ${key} 失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
trigger: CleanupTrigger.CONVERSATION_DELETED,
|
||||
timestamp: Date.now(),
|
||||
deletedCount,
|
||||
freedSize,
|
||||
errors,
|
||||
deletedItems,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有缓存
|
||||
*/
|
||||
public async clearAll(): Promise<void> {
|
||||
for (const entry of this.cache.values()) {
|
||||
try {
|
||||
const file = new File(entry.localPath);
|
||||
if (await file.exists) {
|
||||
await file.delete();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[MediaCacheManager] 删除文件失败: ${entry.localPath}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
this.cache.clear();
|
||||
|
||||
// 清除所有记录
|
||||
const keys = await AsyncStorage.getAllKeys();
|
||||
const recordKeys = keys.filter(k => k.startsWith(CACHE_RECORD_PREFIX));
|
||||
if (recordKeys.length > 0) {
|
||||
await AsyncStorage.multiRemove(recordKeys);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动定期清理
|
||||
*/
|
||||
public startPeriodicCleanup(): void {
|
||||
if (this.cleanupTimer) {
|
||||
clearInterval(this.cleanupTimer);
|
||||
}
|
||||
|
||||
this.cleanupTimer = setInterval(async () => {
|
||||
// 检查是否需要清理
|
||||
if (Date.now() - this.lastCleanupTime >= this.policy.cleanupInterval) {
|
||||
await this.cleanup(CleanupTrigger.SCHEDULED);
|
||||
}
|
||||
}, this.policy.cleanupInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止定期清理
|
||||
*/
|
||||
public stopPeriodicCleanup(): void {
|
||||
if (this.cleanupTimer) {
|
||||
clearInterval(this.cleanupTimer);
|
||||
this.cleanupTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否需要清理
|
||||
*/
|
||||
private async checkAndCleanup(): Promise<void> {
|
||||
const stats = this.getCacheStats();
|
||||
|
||||
// 检查大小
|
||||
if (stats.totalSize > this.policy.maxSize) {
|
||||
await this.cleanupLRU();
|
||||
}
|
||||
|
||||
// 检查条目数
|
||||
if (stats.totalEntries > this.policy.maxEntries) {
|
||||
await this.cleanupLRU();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存缓存记录到持久化存储
|
||||
*/
|
||||
private async saveCacheRecord(entry: CacheEntry): Promise<void> {
|
||||
try {
|
||||
const key = `${CACHE_RECORD_PREFIX}${entry.key}`;
|
||||
await AsyncStorage.setItem(key, JSON.stringify(entry));
|
||||
} catch (error) {
|
||||
console.error('[MediaCacheManager] 保存缓存记录失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除缓存记录
|
||||
*/
|
||||
private async removeCacheRecord(key: string): Promise<void> {
|
||||
try {
|
||||
const storageKey = `${CACHE_RECORD_PREFIX}${key}`;
|
||||
await AsyncStorage.removeItem(storageKey);
|
||||
} catch (error) {
|
||||
console.error('[MediaCacheManager] 删除缓存记录失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成缓存键
|
||||
*/
|
||||
private generateCacheKey(uri: string, type: MediaType): string {
|
||||
const hash = this.hashString(uri);
|
||||
return `${type.toLowerCase()}_${hash}_${Date.now()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取媒体文件存储路径
|
||||
*/
|
||||
private getMediaPath(type: MediaType, key: string): string {
|
||||
const ext = this.getExtension(key, type);
|
||||
switch (type) {
|
||||
case MediaType.IMAGE:
|
||||
return `${getImageDir()}${key}.${ext}`;
|
||||
case MediaType.VIDEO:
|
||||
return `${getVideoDir()}${key}.${ext}`;
|
||||
case MediaType.AUDIO:
|
||||
return `${getAudioDir()}${key}.${ext}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据类型获取文件扩展名
|
||||
*/
|
||||
private getExtension(key: string, type: MediaType): string {
|
||||
if (type === MediaType.IMAGE) {
|
||||
if (key.includes('gif')) return 'gif';
|
||||
if (key.includes('webp')) return 'webp';
|
||||
return 'jpg';
|
||||
}
|
||||
if (type === MediaType.VIDEO) return 'mp4';
|
||||
if (type === MediaType.AUDIO) return 'mp3';
|
||||
return 'bin';
|
||||
}
|
||||
|
||||
/**
|
||||
* 字符串哈希函数
|
||||
*/
|
||||
private hashString(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash;
|
||||
}
|
||||
return Math.abs(hash).toString(16);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出单例实例
|
||||
*/
|
||||
export const mediaCacheManager = MediaCacheManager.getInstance();
|
||||
21
src/infrastructure/cache/index.ts
vendored
Normal file
21
src/infrastructure/cache/index.ts
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 媒体缓存模块导出
|
||||
* @module src/infrastructure/cache
|
||||
* @description 导出媒体缓存相关的所有类型和管理器
|
||||
*/
|
||||
|
||||
// 类型导出
|
||||
export {
|
||||
MediaType,
|
||||
CleanupTrigger,
|
||||
CacheEntry,
|
||||
CacheStats,
|
||||
CleanupPolicy,
|
||||
CleanupResult,
|
||||
MediaCacheConfig,
|
||||
DEFAULT_MEDIA_CACHE_CONFIG,
|
||||
DEFAULT_CLEANUP_POLICY,
|
||||
} from './types';
|
||||
|
||||
// 管理器导出
|
||||
export { MediaCacheManager, mediaCacheManager } from './MediaCacheManager';
|
||||
202
src/infrastructure/cache/types.ts
vendored
Normal file
202
src/infrastructure/cache/types.ts
vendored
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* 媒体缓存类型定义
|
||||
* @module src/infrastructure/cache/types
|
||||
* @description 定义媒体缓存相关的类型、枚举和接口
|
||||
*/
|
||||
|
||||
/**
|
||||
* 媒体类型枚举
|
||||
* @enum {string}
|
||||
* @description 支持的媒体类型
|
||||
*/
|
||||
export enum MediaType {
|
||||
IMAGE = 'IMAGE',
|
||||
VIDEO = 'VIDEO',
|
||||
AUDIO = 'AUDIO',
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理触发器枚举
|
||||
* @enum {string}
|
||||
* @description 触发清理操作的来源
|
||||
*/
|
||||
export enum CleanupTrigger {
|
||||
STARTUP = 'startup',
|
||||
SCHEDULED = 'scheduled',
|
||||
MANUAL = 'manual',
|
||||
LOW_STORAGE = 'low_storage',
|
||||
CONVERSATION_DELETED = 'conversation_deleted',
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存条目接口
|
||||
* @interface CacheEntry
|
||||
* @description 单个媒体缓存条目的信息
|
||||
*/
|
||||
export interface CacheEntry {
|
||||
/** 缓存键值 */
|
||||
key: string;
|
||||
/** 媒体类型 */
|
||||
type: MediaType;
|
||||
/** 原始URI */
|
||||
uri: string;
|
||||
/** 本地存储路径 */
|
||||
localPath: string;
|
||||
/** 文件大小(字节) */
|
||||
size: number;
|
||||
/** 所属会话ID(可选) */
|
||||
conversationId?: string;
|
||||
/** 关联消息ID(可选) */
|
||||
messageId?: string;
|
||||
/** 创建时间 */
|
||||
createdAt: number;
|
||||
/** 最后访问时间 */
|
||||
lastAccessedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存统计接口
|
||||
* @interface CacheStats
|
||||
* @description 媒体缓存的整体统计信息
|
||||
*/
|
||||
export interface CacheStats {
|
||||
/** 总缓存大小(字节) */
|
||||
totalSize: number;
|
||||
/** 图片缓存数量 */
|
||||
imageCount: number;
|
||||
/** 视频缓存数量 */
|
||||
videoCount: number;
|
||||
/** 音频缓存数量 */
|
||||
audioCount: number;
|
||||
/** 总条目数 */
|
||||
totalEntries: number;
|
||||
/** 最早缓存项时间 */
|
||||
oldestItem: number;
|
||||
/** 最新缓存项时间 */
|
||||
newestItem: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理策略接口
|
||||
* @interface CleanupPolicy
|
||||
* @description 定义缓存清理的规则和限制
|
||||
*/
|
||||
export interface CleanupPolicy {
|
||||
/** 最大缓存时间(毫秒) */
|
||||
maxAge: number;
|
||||
/** 最大缓存大小(字节) */
|
||||
maxSize: number;
|
||||
/** 最大条目数 */
|
||||
maxEntries: number;
|
||||
/** 启动时是否清理 */
|
||||
cleanupOnStart: boolean;
|
||||
/** 定期清理间隔(毫秒) */
|
||||
cleanupInterval: number;
|
||||
/** 是否启用LRU清理 */
|
||||
enableLRU: boolean;
|
||||
/** 低存储空间阈值(字节) */
|
||||
lowStorageThreshold: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理结果接口
|
||||
* @interface CleanupResult
|
||||
* @description 清理操作的执行结果
|
||||
*/
|
||||
export interface CleanupResult {
|
||||
/** 触发器类型 */
|
||||
trigger: CleanupTrigger;
|
||||
/** 执行时间戳 */
|
||||
timestamp: number;
|
||||
/** 删除条目数量 */
|
||||
deletedCount: number;
|
||||
/** 释放空间大小(字节) */
|
||||
freedSize: number;
|
||||
/** 错误信息列表 */
|
||||
errors: string[];
|
||||
/** 清理的条目详情 */
|
||||
deletedItems?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 媒体缓存配置接口
|
||||
* @interface MediaCacheConfig
|
||||
* @description 媒体缓存的全局配置
|
||||
*/
|
||||
export interface MediaCacheConfig {
|
||||
/** 图片配置 */
|
||||
image: {
|
||||
/** 内存缓存数量 */
|
||||
maxMemoryCacheSize: number;
|
||||
/** 磁盘缓存大小(MB) */
|
||||
maxDiskCacheSize: number;
|
||||
/** 缓存有效期(小时) */
|
||||
maxAge: number;
|
||||
/** 会话内媒体保留时间(天) */
|
||||
maxAgeForConversation: number;
|
||||
};
|
||||
/** 视频配置 */
|
||||
video: {
|
||||
/** 磁盘缓存大小(MB) */
|
||||
maxDiskCacheSize: number;
|
||||
/** 缓存有效期(小时) */
|
||||
maxAge: number;
|
||||
/** 是否自动预加载 */
|
||||
autoPreload: boolean;
|
||||
};
|
||||
/** 音频配置 */
|
||||
audio: {
|
||||
/** 磁盘缓存大小(MB) */
|
||||
maxDiskCacheSize: number;
|
||||
/** 缓存有效期(天) */
|
||||
maxAge: number;
|
||||
};
|
||||
/** 全局配置 */
|
||||
global: {
|
||||
/** 启动时检查清理 */
|
||||
checkOnStartup: boolean;
|
||||
/** 定期检查间隔(小时) */
|
||||
checkInterval: number;
|
||||
/** 低存储空间阈值(MB) */
|
||||
lowStorageThreshold: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认媒体缓存配置
|
||||
*/
|
||||
export const DEFAULT_MEDIA_CACHE_CONFIG: MediaCacheConfig = {
|
||||
image: {
|
||||
maxMemoryCacheSize: 50,
|
||||
maxDiskCacheSize: 500,
|
||||
maxAge: 168, // 7 days
|
||||
maxAgeForConversation: 30,
|
||||
},
|
||||
video: {
|
||||
maxDiskCacheSize: 1000,
|
||||
maxAge: 72, // 3 days
|
||||
autoPreload: false,
|
||||
},
|
||||
audio: {
|
||||
maxDiskCacheSize: 200,
|
||||
maxAge: 168, // 7 days
|
||||
},
|
||||
global: {
|
||||
checkOnStartup: true,
|
||||
checkInterval: 24,
|
||||
lowStorageThreshold: 500,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 默认清理策略
|
||||
*/
|
||||
export const DEFAULT_CLEANUP_POLICY: CleanupPolicy = {
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7天
|
||||
maxSize: 500 * 1024 * 1024, // 500MB
|
||||
maxEntries: 1000,
|
||||
cleanupOnStart: true,
|
||||
cleanupInterval: 24 * 60 * 60 * 1000, // 24小时
|
||||
enableLRU: true,
|
||||
lowStorageThreshold: 500 * 1024 * 1024, // 500MB
|
||||
};
|
||||
268
src/infrastructure/diff/MessageDiffCalculator.ts
Normal file
268
src/infrastructure/diff/MessageDiffCalculator.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* 消息差异计算器
|
||||
* 计算两个消息列表之间的差异,支持增量更新
|
||||
*/
|
||||
|
||||
import {
|
||||
MessageIdentifier,
|
||||
DiffResult,
|
||||
MessageComparator,
|
||||
MessageSorter,
|
||||
DiffConfig,
|
||||
DEFAULT_DIFF_CONFIG,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* 消息差异计算器类
|
||||
* 用于高效计算消息列表的变化
|
||||
*/
|
||||
export class MessageDiffCalculator<T extends MessageIdentifier> {
|
||||
private config: Required<DiffConfig>;
|
||||
private previousMessages: Map<string, T> = new Map();
|
||||
private previousOrder: string[] = [];
|
||||
|
||||
constructor(config: DiffConfig = {}) {
|
||||
this.config = { ...DEFAULT_DIFF_CONFIG, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算两个消息列表的差异
|
||||
* @param oldList 旧消息列表
|
||||
* @param newList 新消息列表
|
||||
* @returns 差异结果
|
||||
*/
|
||||
calculateDiff(oldList: T[], newList: T[]): DiffResult<T> {
|
||||
const result: DiffResult<T> = {
|
||||
added: [],
|
||||
updated: [],
|
||||
deleted: [],
|
||||
moved: [],
|
||||
unchanged: [],
|
||||
shouldReset: false,
|
||||
};
|
||||
|
||||
// 如果旧列表为空,直接返回新列表作为新增
|
||||
if (oldList.length === 0) {
|
||||
result.added = [...newList];
|
||||
this.updateCache(newList);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 如果新列表为空,表示全部删除
|
||||
if (newList.length === 0) {
|
||||
result.deleted = oldList.map((msg, index) => ({ message: msg, index }));
|
||||
this.clearCache();
|
||||
return result;
|
||||
}
|
||||
|
||||
// 检查是否需要重置(变化太大)
|
||||
const changeRatio = this.calculateChangeRatio(oldList, newList);
|
||||
if (changeRatio > 0.5) {
|
||||
result.shouldReset = true;
|
||||
result.added = [...newList];
|
||||
this.updateCache(newList);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 构建旧列表的索引映射
|
||||
const oldIndexMap = new Map<string, number>();
|
||||
oldList.forEach((msg, index) => {
|
||||
oldIndexMap.set(msg.id, index);
|
||||
});
|
||||
|
||||
// 构建新列表的索引映射
|
||||
const newIndexMap = new Map<string, number>();
|
||||
newList.forEach((msg, index) => {
|
||||
newIndexMap.set(msg.id, index);
|
||||
});
|
||||
|
||||
// 检测新增、更新和未变更的消息
|
||||
const processedIds = new Set<string>();
|
||||
|
||||
for (let newIndex = 0; newIndex < newList.length; newIndex++) {
|
||||
const newMsg = newList[newIndex];
|
||||
const oldIndex = oldIndexMap.get(newMsg.id);
|
||||
|
||||
if (oldIndex === undefined) {
|
||||
// 新增消息
|
||||
result.added.push(newMsg);
|
||||
} else {
|
||||
const oldMsg = oldList[oldIndex];
|
||||
processedIds.add(newMsg.id);
|
||||
|
||||
// 检测是否有更新
|
||||
const changes = this.detectChanges(oldMsg, newMsg);
|
||||
if (changes && Object.keys(changes).length > 0) {
|
||||
result.updated.push({
|
||||
message: newMsg,
|
||||
index: oldIndex,
|
||||
changes,
|
||||
});
|
||||
} else {
|
||||
result.unchanged.push(newMsg);
|
||||
}
|
||||
|
||||
// 检测是否移动
|
||||
if (oldIndex !== newIndex) {
|
||||
result.moved.push({
|
||||
message: newMsg,
|
||||
fromIndex: oldIndex,
|
||||
toIndex: newIndex,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检测删除的消息
|
||||
for (let oldIndex = 0; oldIndex < oldList.length; oldIndex++) {
|
||||
const oldMsg = oldList[oldIndex];
|
||||
if (!processedIds.has(oldMsg.id)) {
|
||||
result.deleted.push({ message: oldMsg, index: oldIndex });
|
||||
}
|
||||
}
|
||||
|
||||
this.updateCache(newList);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算增量差异(基于缓存的上次状态)
|
||||
* @param newList 新消息列表
|
||||
* @returns 差异结果
|
||||
*/
|
||||
calculateIncrementalDiff(newList: T[]): DiffResult<T> {
|
||||
const oldList = Array.from(this.previousMessages.values());
|
||||
return this.calculateDiff(oldList, newList);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算变化比例
|
||||
* @param oldList 旧列表
|
||||
* @param newList 新列表
|
||||
* @returns 变化比例(0-1)
|
||||
*/
|
||||
private calculateChangeRatio(oldList: T[], newList: T[]): number {
|
||||
if (oldList.length === 0 && newList.length === 0) return 0;
|
||||
if (oldList.length === 0 || newList.length === 0) return 1;
|
||||
|
||||
const oldIds = new Set(oldList.map(m => m.id));
|
||||
const newIds = new Set(newList.map(m => m.id));
|
||||
|
||||
let commonCount = 0;
|
||||
oldIds.forEach(id => {
|
||||
if (newIds.has(id)) {
|
||||
commonCount++;
|
||||
}
|
||||
});
|
||||
|
||||
const maxLength = Math.max(oldList.length, newList.length);
|
||||
return 1 - commonCount / maxLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测消息变化
|
||||
* @param oldMsg 旧消息
|
||||
* @param newMsg 新消息
|
||||
* @returns 变化字段
|
||||
*/
|
||||
private detectChanges(oldMsg: T, newMsg: T): Partial<T> | null {
|
||||
const changes: Partial<T> = {};
|
||||
let hasChanges = false;
|
||||
|
||||
for (const key of Object.keys(newMsg) as Array<keyof T>) {
|
||||
if (key === 'id') continue; // ID 不变
|
||||
|
||||
const oldValue = oldMsg[key];
|
||||
const newValue = newMsg[key];
|
||||
|
||||
if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
|
||||
(changes as any)[key] = newValue;
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
return hasChanges ? changes : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新缓存
|
||||
* @param messages 消息列表
|
||||
*/
|
||||
private updateCache(messages: T[]): void {
|
||||
this.previousMessages.clear();
|
||||
this.previousOrder = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
this.previousMessages.set(msg.id, msg);
|
||||
this.previousOrder.push(msg.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除缓存
|
||||
*/
|
||||
private clearCache(): void {
|
||||
this.previousMessages.clear();
|
||||
this.previousOrder = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置配置
|
||||
* @param config 配置
|
||||
*/
|
||||
setConfig(config: Partial<DiffConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前配置
|
||||
* @returns 当前配置
|
||||
*/
|
||||
getConfig(): Required<DiffConfig> {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置计算器状态
|
||||
*/
|
||||
reset(): void {
|
||||
this.clearCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存的消息数量
|
||||
* @returns 缓存数量
|
||||
*/
|
||||
getCachedCount(): number {
|
||||
return this.previousMessages.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查消息是否在缓存中
|
||||
* @param messageId 消息 ID
|
||||
* @returns 是否存在
|
||||
*/
|
||||
hasMessage(messageId: string): boolean {
|
||||
return this.previousMessages.has(messageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存中的消息
|
||||
* @param messageId 消息 ID
|
||||
* @returns 消息或 undefined
|
||||
*/
|
||||
getCachedMessage(messageId: string): T | undefined {
|
||||
return this.previousMessages.get(messageId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建差异计算器实例的工厂函数
|
||||
* @param config 配置
|
||||
* @returns MessageDiffCalculator 实例
|
||||
*/
|
||||
export function createMessageDiffCalculator<T extends MessageIdentifier>(
|
||||
config?: DiffConfig
|
||||
): MessageDiffCalculator<T> {
|
||||
return new MessageDiffCalculator<T>(config);
|
||||
}
|
||||
385
src/infrastructure/diff/MessageUpdateBatcher.ts
Normal file
385
src/infrastructure/diff/MessageUpdateBatcher.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* 消息更新批量处理器
|
||||
* 收集更新操作并按批处理,减少 UI 更新次数
|
||||
*/
|
||||
|
||||
import {
|
||||
MessageUpdate,
|
||||
MessageUpdateType,
|
||||
BaseMessageUpdate,
|
||||
BatchUpdateListener,
|
||||
DiffConfig,
|
||||
DEFAULT_DIFF_CONFIG,
|
||||
MessageIdentifier,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* 批量处理器配置选项
|
||||
*/
|
||||
export interface BatcherOptions {
|
||||
/** 批量处理间隔(毫秒),默认 16ms(约 60fps) */
|
||||
batchInterval?: number;
|
||||
/** 最大批量大小,默认 100 */
|
||||
maxBatchSize?: number;
|
||||
/** 是否启用去重,默认 true */
|
||||
enableDeduplication?: boolean;
|
||||
/** 是否合并相同消息的多次更新,默认 true */
|
||||
enableMerge?: boolean;
|
||||
/** 最大等待时间(毫秒),超过此时间强制刷新,默认 100ms */
|
||||
maxWaitTime?: number;
|
||||
/** 是否自动启动批量处理,默认 true */
|
||||
autoStart?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认批量处理器配置
|
||||
*/
|
||||
export const DEFAULT_BATCHER_OPTIONS: Required<BatcherOptions> = {
|
||||
batchInterval: 16,
|
||||
maxBatchSize: 100,
|
||||
enableDeduplication: true,
|
||||
enableMerge: true,
|
||||
maxWaitTime: 100,
|
||||
autoStart: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* 消息更新批量处理器类
|
||||
*/
|
||||
export class MessageUpdateBatcher {
|
||||
/** 待处理的更新队列 */
|
||||
private pendingUpdates: Map<string, MessageUpdate> = new Map();
|
||||
/** 批量处理定时器 */
|
||||
private batchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** 最大等待时间定时器 */
|
||||
private maxWaitTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** 监听器集合 */
|
||||
private listeners: Set<BatchUpdateListener> = new Set();
|
||||
/** 是否正在批处理中 */
|
||||
private isProcessing: boolean = false;
|
||||
/** 配置选项 */
|
||||
private options: Required<BatcherOptions>;
|
||||
/** 处理器是否已启动 */
|
||||
private isStarted: boolean = false;
|
||||
/** 批量处理统计信息 */
|
||||
private stats: {
|
||||
totalBatches: number;
|
||||
totalUpdates: number;
|
||||
averageBatchSize: number;
|
||||
lastBatchTime: number;
|
||||
} = {
|
||||
totalBatches: 0,
|
||||
totalUpdates: 0,
|
||||
averageBatchSize: 0,
|
||||
lastBatchTime: 0,
|
||||
};
|
||||
|
||||
constructor(options: BatcherOptions = {}) {
|
||||
this.options = { ...DEFAULT_BATCHER_OPTIONS, ...options };
|
||||
if (this.options.autoStart) {
|
||||
this.start();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动批量处理器
|
||||
*/
|
||||
start(): void {
|
||||
if (this.isStarted) return;
|
||||
this.isStarted = true;
|
||||
this.scheduleBatch();
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止批量处理器
|
||||
*/
|
||||
stop(): void {
|
||||
this.isStarted = false;
|
||||
this.clearTimers();
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加更新操作到批量队列
|
||||
* @param update 消息更新操作
|
||||
*/
|
||||
addUpdate(update: MessageUpdate): void {
|
||||
const key = this.generateUpdateKey(update);
|
||||
|
||||
// 去重:如果已有相同消息 ID 的待处理更新,进行合并
|
||||
if (this.options.enableDeduplication && this.pendingUpdates.has(key)) {
|
||||
const existing = this.pendingUpdates.get(key)!;
|
||||
|
||||
if (this.options.enableMerge) {
|
||||
const merged = this.mergeUpdates(existing, update);
|
||||
if (merged) {
|
||||
this.pendingUpdates.set(key, merged);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.pendingUpdates.set(key, update);
|
||||
|
||||
// 如果达到最大批量大小,立即刷新
|
||||
if (this.pendingUpdates.size >= this.options.maxBatchSize) {
|
||||
this.flush();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加多个更新操作
|
||||
* @param updates 更新操作数组
|
||||
*/
|
||||
addUpdates(updates: MessageUpdate[]): void {
|
||||
for (const update of updates) {
|
||||
this.addUpdate(update);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 立即刷新所有待处理更新
|
||||
* @returns 处理的更新数组
|
||||
*/
|
||||
flush(): MessageUpdate[] {
|
||||
if (this.pendingUpdates.size === 0 || this.isProcessing) {
|
||||
return [];
|
||||
}
|
||||
|
||||
this.isProcessing = true;
|
||||
this.clearTimers();
|
||||
|
||||
const updates = Array.from(this.pendingUpdates.values());
|
||||
this.pendingUpdates.clear();
|
||||
|
||||
// 更新统计信息
|
||||
this.stats.totalBatches++;
|
||||
this.stats.totalUpdates += updates.length;
|
||||
this.stats.averageBatchSize = this.stats.totalUpdates / this.stats.totalBatches;
|
||||
this.stats.lastBatchTime = Date.now();
|
||||
|
||||
// 通知监听器
|
||||
this.notifyListeners(updates);
|
||||
|
||||
this.isProcessing = false;
|
||||
|
||||
// 重新调度定时器
|
||||
this.scheduleBatch();
|
||||
|
||||
return updates;
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅批量更新事件
|
||||
* @param listener 监听器函数
|
||||
* @returns 取消订阅函数
|
||||
*/
|
||||
subscribe(listener: BatchUpdateListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消订阅
|
||||
* @param listener 监听器函数
|
||||
*/
|
||||
unsubscribe(listener: BatchUpdateListener): void {
|
||||
this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取待处理更新数量
|
||||
* @returns 待处理数量
|
||||
*/
|
||||
getPendingCount(): number {
|
||||
return this.pendingUpdates.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取统计信息
|
||||
* @returns 统计信息
|
||||
*/
|
||||
getStats() {
|
||||
return { ...this.stats };
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有待处理更新
|
||||
* @returns 是否有待处理更新
|
||||
*/
|
||||
hasPendingUpdates(): boolean {
|
||||
return this.pendingUpdates.size > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有待处理更新
|
||||
*/
|
||||
clearPending(): void {
|
||||
this.pendingUpdates.clear();
|
||||
this.clearTimers();
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁批量处理器
|
||||
*/
|
||||
destroy(): void {
|
||||
this.stop();
|
||||
this.clearPending();
|
||||
this.listeners.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成更新键值
|
||||
* @param update 更新操作
|
||||
* @returns 键值
|
||||
*/
|
||||
private generateUpdateKey(update: MessageUpdate): string {
|
||||
// 根据更新类型生成唯一键
|
||||
switch (update.type) {
|
||||
case 'ADD':
|
||||
case 'BATCH_ADD':
|
||||
return `${update.type}_${update.timestamp}`;
|
||||
case 'UPDATE':
|
||||
case 'BATCH_UPDATE':
|
||||
return `${update.type}_${this.extractMessageId(update)}`;
|
||||
case 'DELETE':
|
||||
case 'BATCH_DELETE':
|
||||
return `${update.type}_${this.extractMessageId(update)}`;
|
||||
default:
|
||||
return `${update.type}_${update.timestamp}_${Math.random()}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取消息 ID
|
||||
* @param update 更新操作
|
||||
* @returns 消息 ID 或空字符串
|
||||
*/
|
||||
private extractMessageId(update: MessageUpdate): string {
|
||||
const payload = (update as any).payload;
|
||||
if (payload) {
|
||||
if (payload.messageId) return payload.messageId;
|
||||
if (payload.messageIds) return payload.messageIds.join(',');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并更新操作
|
||||
* @param existing 已有更新
|
||||
* @param incoming 新更新
|
||||
* @returns 合并后的更新或 null(如果无法合并)
|
||||
*/
|
||||
private mergeUpdates(existing: MessageUpdate, incoming: MessageUpdate): MessageUpdate | null {
|
||||
// 只有同类型的更新才能合并
|
||||
if (existing.type !== incoming.type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (existing.type) {
|
||||
case 'UPDATE':
|
||||
// 合并更新字段
|
||||
return {
|
||||
...existing,
|
||||
payload: {
|
||||
...(existing as any).payload,
|
||||
updates: {
|
||||
...(existing as any).payload?.updates,
|
||||
...(incoming as any).payload?.updates,
|
||||
},
|
||||
},
|
||||
timestamp: incoming.timestamp,
|
||||
};
|
||||
|
||||
case 'BATCH_UPDATE':
|
||||
// 合并批量更新
|
||||
const existingUpdates = (existing as any).payload?.updates || [];
|
||||
const incomingUpdates = (incoming as any).payload?.updates || [];
|
||||
const updatesMap = new Map();
|
||||
|
||||
for (const update of [...existingUpdates, ...incomingUpdates]) {
|
||||
const existing = updatesMap.get(update.messageId);
|
||||
if (existing) {
|
||||
existing.changes = { ...existing.changes, ...update.changes };
|
||||
} else {
|
||||
updatesMap.set(update.messageId, { ...update });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...existing,
|
||||
payload: {
|
||||
...(existing as any).payload,
|
||||
updates: Array.from(updatesMap.values()),
|
||||
},
|
||||
timestamp: incoming.timestamp,
|
||||
};
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调度批量处理
|
||||
*/
|
||||
private scheduleBatch(): void {
|
||||
if (!this.isStarted) return;
|
||||
|
||||
this.clearTimers();
|
||||
|
||||
// 设置最大等待时间定时器
|
||||
this.maxWaitTimer = setTimeout(() => {
|
||||
this.flush();
|
||||
}, this.options.maxWaitTime);
|
||||
|
||||
// 设置批量处理定时器
|
||||
this.batchTimer = setTimeout(() => {
|
||||
this.flush();
|
||||
}, this.options.batchInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有定时器
|
||||
*/
|
||||
private clearTimers(): void {
|
||||
if (this.batchTimer) {
|
||||
clearTimeout(this.batchTimer);
|
||||
this.batchTimer = null;
|
||||
}
|
||||
if (this.maxWaitTimer) {
|
||||
clearTimeout(this.maxWaitTimer);
|
||||
this.maxWaitTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知监听器
|
||||
* @param updates 更新数组
|
||||
*/
|
||||
private notifyListeners(updates: MessageUpdate[]): void {
|
||||
if (this.listeners.size === 0) return;
|
||||
|
||||
// 转换为数组以支持迭代
|
||||
const listenersArray = Array.from(this.listeners);
|
||||
for (const listener of listenersArray) {
|
||||
try {
|
||||
listener(updates);
|
||||
} catch (error) {
|
||||
console.error('Error in batch update listener:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建批量处理器的工厂函数
|
||||
* @param options 配置选项
|
||||
* @returns MessageUpdateBatcher 实例
|
||||
*/
|
||||
export function createMessageUpdateBatcher(
|
||||
options?: BatcherOptions
|
||||
): MessageUpdateBatcher {
|
||||
return new MessageUpdateBatcher(options);
|
||||
}
|
||||
40
src/infrastructure/diff/index.ts
Normal file
40
src/infrastructure/diff/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 差异更新模块
|
||||
* 提供消息列表的增量更新功能,减少 UI 重渲染次数
|
||||
*/
|
||||
|
||||
// 类型定义
|
||||
export {
|
||||
MessageUpdateType,
|
||||
MessageIdentifier,
|
||||
BaseMessageUpdate,
|
||||
AddMessageUpdate,
|
||||
UpdateMessageUpdate,
|
||||
DeleteMessageUpdate,
|
||||
MoveMessageUpdate,
|
||||
BatchAddMessageUpdate,
|
||||
BatchUpdateMessageUpdate,
|
||||
BatchDeleteMessageUpdate,
|
||||
ResetMessageUpdate,
|
||||
MessageUpdate,
|
||||
DiffConfig,
|
||||
DiffResult,
|
||||
BatchUpdateListener,
|
||||
MessageComparator,
|
||||
MessageSorter,
|
||||
DEFAULT_DIFF_CONFIG,
|
||||
} from './types';
|
||||
|
||||
// 差异计算器
|
||||
export {
|
||||
MessageDiffCalculator,
|
||||
createMessageDiffCalculator,
|
||||
} from './MessageDiffCalculator';
|
||||
|
||||
// 批量处理器
|
||||
export {
|
||||
MessageUpdateBatcher,
|
||||
createMessageUpdateBatcher,
|
||||
BatcherOptions,
|
||||
DEFAULT_BATCHER_OPTIONS,
|
||||
} from './MessageUpdateBatcher';
|
||||
209
src/infrastructure/diff/types.ts
Normal file
209
src/infrastructure/diff/types.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* 差异更新类型定义
|
||||
* 用于消息列表的增量更新,减少 UI 重渲染次数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 消息更新类型枚举
|
||||
*/
|
||||
export enum MessageUpdateType {
|
||||
/** 添加单条消息 */
|
||||
ADD = 'ADD',
|
||||
/** 更新单条消息 */
|
||||
UPDATE = 'UPDATE',
|
||||
/** 删除单条消息 */
|
||||
DELETE = 'DELETE',
|
||||
/** 移动消息位置 */
|
||||
MOVE = 'MOVE',
|
||||
/** 批量添加 */
|
||||
BATCH_ADD = 'BATCH_ADD',
|
||||
/** 批量更新 */
|
||||
BATCH_UPDATE = 'BATCH_UPDATE',
|
||||
/** 批量删除 */
|
||||
BATCH_DELETE = 'BATCH_DELETE',
|
||||
/** 重置整个列表 */
|
||||
RESET = 'RESET',
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息唯一标识接口
|
||||
*/
|
||||
export interface MessageIdentifier {
|
||||
/** 消息唯一 ID */
|
||||
id: string;
|
||||
/** 消息序列号(可选,用于排序) */
|
||||
seq?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 基础更新操作接口
|
||||
*/
|
||||
export interface BaseMessageUpdate {
|
||||
/** 更新类型 */
|
||||
type: MessageUpdateType;
|
||||
/** 更新时间戳 */
|
||||
timestamp: number;
|
||||
/** 会话 ID(可选) */
|
||||
conversationId?: string;
|
||||
/** 载荷数据(可选,用于合并更新) */
|
||||
payload?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加单条消息更新
|
||||
*/
|
||||
export interface AddMessageUpdate extends BaseMessageUpdate {
|
||||
type: MessageUpdateType.ADD;
|
||||
/** 消息数据 */
|
||||
message: MessageIdentifier;
|
||||
/** 插入位置索引(可选,默认追加到末尾) */
|
||||
index?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新单条消息更新
|
||||
*/
|
||||
export interface UpdateMessageUpdate extends BaseMessageUpdate {
|
||||
type: MessageUpdateType.UPDATE;
|
||||
/** 消息 ID */
|
||||
messageId: string;
|
||||
/** 更新的字段 */
|
||||
updates: Partial<MessageIdentifier>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除单条消息更新
|
||||
*/
|
||||
export interface DeleteMessageUpdate extends BaseMessageUpdate {
|
||||
type: MessageUpdateType.DELETE;
|
||||
/** 消息 ID */
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动消息更新
|
||||
*/
|
||||
export interface MoveMessageUpdate extends BaseMessageUpdate {
|
||||
type: MessageUpdateType.MOVE;
|
||||
/** 消息 ID */
|
||||
messageId: string;
|
||||
/** 目标位置索引 */
|
||||
toIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量添加消息更新
|
||||
*/
|
||||
export interface BatchAddMessageUpdate extends BaseMessageUpdate {
|
||||
type: MessageUpdateType.BATCH_ADD;
|
||||
/** 消息列表 */
|
||||
messages: MessageIdentifier[];
|
||||
/** 插入位置索引(可选,默认追加到末尾) */
|
||||
startIndex?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新消息更新
|
||||
*/
|
||||
export interface BatchUpdateMessageUpdate extends BaseMessageUpdate {
|
||||
type: MessageUpdateType.BATCH_UPDATE;
|
||||
/** 批量更新项 */
|
||||
updates: Array<{
|
||||
messageId: string;
|
||||
changes: Partial<MessageIdentifier>;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除消息更新
|
||||
*/
|
||||
export interface BatchDeleteMessageUpdate extends BaseMessageUpdate {
|
||||
type: MessageUpdateType.BATCH_DELETE;
|
||||
/** 要删除的消息 ID 列表 */
|
||||
messageIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置消息列表更新
|
||||
*/
|
||||
export interface ResetMessageUpdate extends BaseMessageUpdate {
|
||||
type: MessageUpdateType.RESET;
|
||||
/** 新的消息列表 */
|
||||
messages: MessageIdentifier[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息更新联合类型
|
||||
*/
|
||||
export type MessageUpdate =
|
||||
| AddMessageUpdate
|
||||
| UpdateMessageUpdate
|
||||
| DeleteMessageUpdate
|
||||
| MoveMessageUpdate
|
||||
| BatchAddMessageUpdate
|
||||
| BatchUpdateMessageUpdate
|
||||
| BatchDeleteMessageUpdate
|
||||
| ResetMessageUpdate;
|
||||
|
||||
/**
|
||||
* 差异配置接口
|
||||
*/
|
||||
export interface DiffConfig {
|
||||
/** 批量处理间隔(毫秒),默认 16ms(60fps) */
|
||||
batchInterval?: number;
|
||||
/** 最大批量大小,默认 100 */
|
||||
maxBatchSize?: number;
|
||||
/** 是否启用去重,默认 true */
|
||||
enableDeduplication?: boolean;
|
||||
/** 是否合并相同消息的多次更新,默认 true */
|
||||
enableMerge?: boolean;
|
||||
/** 消息唯一标识字段,默认 'id' */
|
||||
idField?: string;
|
||||
/** 排序字段,默认 'seq' */
|
||||
sortField?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 差异计算结果接口
|
||||
*/
|
||||
export interface DiffResult<T extends MessageIdentifier> {
|
||||
/** 新增的消息 */
|
||||
added: T[];
|
||||
/** 更新的消息 */
|
||||
updated: Array<{ message: T; index: number; changes: Partial<T> }>;
|
||||
/** 删除的消息 */
|
||||
deleted: Array<{ message: T; index: number }>;
|
||||
/** 移动的消息 */
|
||||
moved: Array<{ message: T; fromIndex: number; toIndex: number }>;
|
||||
/** 未变更的消息 */
|
||||
unchanged: T[];
|
||||
/** 是否需要重置 */
|
||||
shouldReset: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新监听器类型
|
||||
*/
|
||||
export type BatchUpdateListener = (updates: MessageUpdate[]) => void;
|
||||
|
||||
/**
|
||||
* 消息比较函数类型
|
||||
*/
|
||||
export type MessageComparator<T extends MessageIdentifier> = (a: T, b: T) => boolean;
|
||||
|
||||
/**
|
||||
* 消息排序函数类型
|
||||
*/
|
||||
export type MessageSorter<T extends MessageIdentifier> = (a: T, b: T) => number;
|
||||
|
||||
/**
|
||||
* 默认配置常量
|
||||
*/
|
||||
export const DEFAULT_DIFF_CONFIG: Required<DiffConfig> = {
|
||||
batchInterval: 16,
|
||||
maxBatchSize: 100,
|
||||
enableDeduplication: true,
|
||||
enableMerge: true,
|
||||
idField: 'id',
|
||||
sortField: 'seq',
|
||||
};
|
||||
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,
|
||||
};
|
||||
546
src/infrastructure/pagination/PaginationStateManager.ts
Normal file
546
src/infrastructure/pagination/PaginationStateManager.ts
Normal file
@@ -0,0 +1,546 @@
|
||||
/**
|
||||
* 分页状态管理器
|
||||
*
|
||||
* 提供分页状态的集中管理,包括:
|
||||
* - 分页状态跟踪(当前页、总加载数、是否有更多等)
|
||||
* - 页面缓存机制(避免重复加载相同页面)
|
||||
* - 加载锁机制(防止并发重复请求)
|
||||
* - 预加载功能(提前加载下一页数据)
|
||||
*/
|
||||
|
||||
import {
|
||||
PaginationState,
|
||||
PageCache,
|
||||
PaginationConfig,
|
||||
LoadMoreResult,
|
||||
RefreshResult,
|
||||
PrefetchResult,
|
||||
FetchPageFunction,
|
||||
DEFAULT_PAGINATION_CONFIG,
|
||||
createInitialPaginationState,
|
||||
createPageCache,
|
||||
isCacheExpired,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* 分页状态管理器类
|
||||
* 管理多个分页键的状态,支持页面缓存和加载锁
|
||||
*/
|
||||
export class PaginationStateManager {
|
||||
// 分页状态存储:key -> PaginationState
|
||||
private states: Map<string, PaginationState> = new Map();
|
||||
|
||||
// 页面缓存存储:key -> Map<pageNumber, PageCache>
|
||||
private pageCaches: Map<string, Map<number, PageCache>> = new Map();
|
||||
|
||||
// 加载锁存储:key -> Set<pageNumber>
|
||||
private loadingLocks: Map<string, Set<number>> = new Map();
|
||||
|
||||
// 配置存储:key -> PaginationConfig
|
||||
private configs: Map<string, PaginationConfig> = new Map();
|
||||
|
||||
/**
|
||||
* 获取或创建分页状态
|
||||
* @param key 分页键(通常是会话ID或列表标识)
|
||||
* @param config 可选的配置
|
||||
* @returns 分页状态
|
||||
*/
|
||||
getState(key: string, config?: Partial<PaginationConfig>): PaginationState {
|
||||
let state = this.states.get(key);
|
||||
if (!state) {
|
||||
const mergedConfig = this.getConfig(key, config);
|
||||
state = createInitialPaginationState(mergedConfig.pageSize);
|
||||
this.states.set(key, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新分页状态
|
||||
* @param key 分页键
|
||||
* @param partialState 部分状态更新
|
||||
*/
|
||||
setState(key: string, partialState: Partial<PaginationState>): void {
|
||||
const currentState = this.getState(key);
|
||||
this.states.set(key, {
|
||||
...currentState,
|
||||
...partialState,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置
|
||||
* @param key 分页键
|
||||
* @param overrideConfig 可选的覆盖配置
|
||||
* @returns 合并后的配置
|
||||
*/
|
||||
getConfig(key: string, overrideConfig?: Partial<PaginationConfig>): PaginationConfig {
|
||||
const existingConfig = this.configs.get(key);
|
||||
if (overrideConfig) {
|
||||
const mergedConfig = {
|
||||
...(existingConfig || DEFAULT_PAGINATION_CONFIG),
|
||||
...overrideConfig,
|
||||
};
|
||||
this.configs.set(key, mergedConfig);
|
||||
return mergedConfig;
|
||||
}
|
||||
return existingConfig || DEFAULT_PAGINATION_CONFIG;
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试获取加载锁
|
||||
* @param key 分页键
|
||||
* @param page 页码
|
||||
* @returns 是否成功获取锁
|
||||
*/
|
||||
acquireLock(key: string, page: number): boolean {
|
||||
let locks = this.loadingLocks.get(key);
|
||||
if (!locks) {
|
||||
locks = new Set();
|
||||
this.loadingLocks.set(key, locks);
|
||||
}
|
||||
|
||||
// 如果已经在加载中,返回 false
|
||||
if (locks.has(page)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 获取锁成功
|
||||
locks.add(page);
|
||||
|
||||
// 更新状态为加载中
|
||||
this.setState(key, { isLoading: true, hasError: false, error: undefined });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放加载锁
|
||||
* @param key 分页键
|
||||
* @param page 页码
|
||||
*/
|
||||
releaseLock(key: string, page: number): void {
|
||||
const locks = this.loadingLocks.get(key);
|
||||
if (locks) {
|
||||
locks.delete(page);
|
||||
|
||||
// 如果没有正在加载的页面了,更新状态
|
||||
if (locks.size === 0) {
|
||||
this.setState(key, { isLoading: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否正在加载指定页面
|
||||
* @param key 分页键
|
||||
* @param page 页码
|
||||
* @returns 是否正在加载
|
||||
*/
|
||||
isPageLoading(key: string, page: number): boolean {
|
||||
const locks = this.loadingLocks.get(key);
|
||||
return locks ? locks.has(page) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有任何页面正在加载
|
||||
* @param key 分页键
|
||||
* @returns 是否有页面正在加载
|
||||
*/
|
||||
isAnyPageLoading(key: string): boolean {
|
||||
const locks = this.loadingLocks.get(key);
|
||||
return locks ? locks.size > 0 : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存页面数据
|
||||
* @param key 分页键
|
||||
* @param page 页码
|
||||
* @param data 页面数据
|
||||
* @param cursor 可选的分页游标
|
||||
*/
|
||||
cachePage<T>(key: string, page: number, data: T[], cursor?: string | number | null): void {
|
||||
const config = this.getConfig(key);
|
||||
if (!config.enableCache) return;
|
||||
|
||||
let caches = this.pageCaches.get(key);
|
||||
if (!caches) {
|
||||
caches = new Map();
|
||||
this.pageCaches.set(key, caches);
|
||||
}
|
||||
|
||||
// 如果超过最大缓存页数,清理最旧的缓存
|
||||
if (caches.size >= config.maxCachedPages) {
|
||||
this.cleanupOldestCache(key);
|
||||
}
|
||||
|
||||
caches.set(page, createPageCache(page, data, cursor));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存的页面数据
|
||||
* @param key 分页键
|
||||
* @param page 页码
|
||||
* @returns 缓存数据或 null
|
||||
*/
|
||||
getCachedPage<T>(key: string, page: number): PageCache<T> | null {
|
||||
const config = this.getConfig(key);
|
||||
if (!config.enableCache) return null;
|
||||
|
||||
const caches = this.pageCaches.get(key);
|
||||
if (!caches) return null;
|
||||
|
||||
const cache = caches.get(page);
|
||||
if (!cache) return null;
|
||||
|
||||
// 检查缓存是否过期
|
||||
if (isCacheExpired(cache, config.cacheTtl)) {
|
||||
caches.delete(page);
|
||||
return null;
|
||||
}
|
||||
|
||||
return cache as PageCache<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查页面是否已缓存且未过期
|
||||
* @param key 分页键
|
||||
* @param page 页码
|
||||
* @returns 是否已缓存
|
||||
*/
|
||||
isPageCached(key: string, page: number): boolean {
|
||||
return this.getCachedPage(key, page) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定键的所有缓存
|
||||
* @param key 分页键
|
||||
*/
|
||||
clearCache(key: string): void {
|
||||
this.pageCaches.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置分页状态
|
||||
* @param key 分页键
|
||||
* @param config 可选的新配置
|
||||
*/
|
||||
reset(key: string, config?: Partial<PaginationConfig>): void {
|
||||
const mergedConfig = this.getConfig(key, config);
|
||||
this.states.set(key, createInitialPaginationState(mergedConfig.pageSize));
|
||||
this.clearCache(key);
|
||||
this.loadingLocks.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除分页状态
|
||||
* @param key 分页键
|
||||
*/
|
||||
remove(key: string): void {
|
||||
this.states.delete(key);
|
||||
this.pageCaches.delete(key);
|
||||
this.loadingLocks.delete(key);
|
||||
this.configs.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有数据
|
||||
*/
|
||||
clear(): void {
|
||||
this.states.clear();
|
||||
this.pageCaches.clear();
|
||||
this.loadingLocks.clear();
|
||||
this.configs.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有分页键
|
||||
* @returns 分页键数组
|
||||
*/
|
||||
getKeys(): string[] {
|
||||
return Array.from(this.states.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理最旧的缓存
|
||||
* @param key 分页键
|
||||
*/
|
||||
private cleanupOldestCache(key: string): void {
|
||||
const caches = this.pageCaches.get(key);
|
||||
if (!caches || caches.size === 0) return;
|
||||
|
||||
let oldestPage = -1;
|
||||
let oldestTime = Infinity;
|
||||
|
||||
Array.from(caches.entries()).forEach(([page, cache]) => {
|
||||
if (cache.cachedAt < oldestTime) {
|
||||
oldestTime = cache.cachedAt;
|
||||
oldestPage = page;
|
||||
}
|
||||
});
|
||||
|
||||
if (oldestPage >= 0) {
|
||||
caches.delete(oldestPage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载更多数据
|
||||
* @param key 分页键
|
||||
* @param fetchFunction 数据获取函数
|
||||
* @returns 加载结果
|
||||
*/
|
||||
async loadMore<T>(
|
||||
key: string,
|
||||
fetchFunction: FetchPageFunction<T>
|
||||
): Promise<LoadMoreResult<T>> {
|
||||
const state = this.getState(key);
|
||||
const config = this.getConfig(key);
|
||||
const nextPage = state.currentPage + 1;
|
||||
|
||||
// 检查是否还有更多数据
|
||||
if (!state.hasMore) {
|
||||
return {
|
||||
success: true,
|
||||
data: [],
|
||||
hasMore: false,
|
||||
fromCache: false,
|
||||
};
|
||||
}
|
||||
|
||||
// 尝试获取加载锁
|
||||
if (!this.acquireLock(key, nextPage)) {
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
hasMore: state.hasMore,
|
||||
fromCache: false,
|
||||
error: 'Page is already loading',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查缓存
|
||||
const cachedPage = this.getCachedPage<T>(key, nextPage);
|
||||
if (cachedPage) {
|
||||
// 更新状态
|
||||
this.setState(key, {
|
||||
currentPage: nextPage,
|
||||
totalLoaded: state.totalLoaded + cachedPage.data.length,
|
||||
lastLoadTime: Date.now(),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: cachedPage.data,
|
||||
hasMore: state.hasMore,
|
||||
fromCache: true,
|
||||
};
|
||||
}
|
||||
|
||||
// 从数据源加载
|
||||
const result = await fetchFunction(nextPage, state.pageSize, state.cursor);
|
||||
|
||||
// 缓存页面数据
|
||||
if (config.enableCache && result.data.length > 0) {
|
||||
this.cachePage(key, nextPage, result.data, result.cursor);
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
this.setState(key, {
|
||||
currentPage: nextPage,
|
||||
totalLoaded: state.totalLoaded + result.data.length,
|
||||
hasMore: result.hasMore,
|
||||
cursor: result.cursor,
|
||||
lastLoadTime: Date.now(),
|
||||
hasError: false,
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
hasMore: result.hasMore,
|
||||
fromCache: false,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
this.setState(key, {
|
||||
hasError: true,
|
||||
error: errorMessage,
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
hasMore: state.hasMore,
|
||||
fromCache: false,
|
||||
error: errorMessage,
|
||||
};
|
||||
} finally {
|
||||
this.releaseLock(key, nextPage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新数据(重新加载第一页)
|
||||
* @param key 分页键
|
||||
* @param fetchFunction 数据获取函数
|
||||
* @returns 刷新结果
|
||||
*/
|
||||
async refresh<T>(
|
||||
key: string,
|
||||
fetchFunction: FetchPageFunction<T>
|
||||
): Promise<RefreshResult<T>> {
|
||||
const state = this.getState(key);
|
||||
const config = this.getConfig(key);
|
||||
|
||||
// 尝试获取加载锁
|
||||
if (!this.acquireLock(key, 1)) {
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
hasMore: state.hasMore,
|
||||
error: 'Refresh already in progress',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// 清除缓存
|
||||
this.clearCache(key);
|
||||
|
||||
// 重置状态
|
||||
this.setState(key, {
|
||||
currentPage: 0,
|
||||
totalLoaded: 0,
|
||||
hasMore: true,
|
||||
cursor: null,
|
||||
hasError: false,
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
// 加载第一页
|
||||
const result = await fetchFunction(1, state.pageSize, null);
|
||||
|
||||
// 缓存第一页
|
||||
if (config.enableCache && result.data.length > 0) {
|
||||
this.cachePage(key, 1, result.data, result.cursor);
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
this.setState(key, {
|
||||
currentPage: 1,
|
||||
totalLoaded: result.data.length,
|
||||
hasMore: result.hasMore,
|
||||
cursor: result.cursor,
|
||||
lastLoadTime: Date.now(),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
hasMore: result.hasMore,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
this.setState(key, {
|
||||
hasError: true,
|
||||
error: errorMessage,
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
hasMore: state.hasMore,
|
||||
error: errorMessage,
|
||||
};
|
||||
} finally {
|
||||
this.releaseLock(key, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载指定页面
|
||||
* @param key 分页键
|
||||
* @param page 页码
|
||||
* @param fetchFunction 数据获取函数
|
||||
* @returns 预加载结果
|
||||
*/
|
||||
async prefetch<T>(
|
||||
key: string,
|
||||
page: number,
|
||||
fetchFunction: FetchPageFunction<T>
|
||||
): Promise<PrefetchResult<T>> {
|
||||
const config = this.getConfig(key);
|
||||
|
||||
// 检查是否启用预加载
|
||||
if (!config.enablePrefetch) {
|
||||
return { success: false, error: 'Prefetch is disabled' };
|
||||
}
|
||||
|
||||
// 检查是否已缓存
|
||||
if (this.isPageCached(key, page)) {
|
||||
return { success: true, data: this.getCachedPage<T>(key, page)?.data };
|
||||
}
|
||||
|
||||
// 检查是否正在加载
|
||||
if (this.isPageLoading(key, page)) {
|
||||
return { success: false, error: 'Page is already loading' };
|
||||
}
|
||||
|
||||
// 尝试获取加载锁
|
||||
if (!this.acquireLock(key, page)) {
|
||||
return { success: false, error: 'Failed to acquire lock' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fetchFunction(page, config.pageSize, null);
|
||||
|
||||
// 缓存数据
|
||||
if (config.enableCache && result.data.length > 0) {
|
||||
this.cachePage(key, page, result.data, result.cursor);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
};
|
||||
} finally {
|
||||
this.releaseLock(key, page);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否应该预加载下一页
|
||||
* @param key 分页键
|
||||
* @param currentItemCount 当前已加载的条目数
|
||||
* @returns 是否应该预加载
|
||||
*/
|
||||
shouldPrefetch(key: string, currentItemCount: number): boolean {
|
||||
const state = this.getState(key);
|
||||
const config = this.getConfig(key);
|
||||
|
||||
if (!config.enablePrefetch) return false;
|
||||
if (!state.hasMore) return false;
|
||||
if (state.isLoading) return false;
|
||||
|
||||
const nextPage = state.currentPage + 1;
|
||||
if (this.isPageLoading(key, nextPage)) return false;
|
||||
if (this.isPageCached(key, nextPage)) return false;
|
||||
|
||||
// 检查是否达到预加载阈值
|
||||
const threshold = state.totalLoaded - config.prefetchThreshold;
|
||||
return currentItemCount >= threshold;
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例
|
||||
export const paginationStateManager = new PaginationStateManager();
|
||||
34
src/infrastructure/pagination/index.ts
Normal file
34
src/infrastructure/pagination/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 分页状态管理模块
|
||||
*
|
||||
* 提供分页状态管理的核心功能,包括:
|
||||
* - PaginationStateManager: 分页状态管理器类
|
||||
* - paginationStateManager: 单例实例
|
||||
* - usePagination: React Hook 用于在组件中使用分页功能
|
||||
* - 类型定义和工具函数
|
||||
*/
|
||||
|
||||
// 导出类型定义
|
||||
export type {
|
||||
PaginationState,
|
||||
PageCache,
|
||||
PaginationConfig,
|
||||
LoadMoreResult,
|
||||
RefreshResult,
|
||||
PrefetchResult,
|
||||
FetchPageFunction,
|
||||
} from './types';
|
||||
|
||||
// 导出工具函数和常量
|
||||
export {
|
||||
DEFAULT_PAGINATION_CONFIG,
|
||||
createInitialPaginationState,
|
||||
createPageCache,
|
||||
isCacheExpired,
|
||||
} from './types';
|
||||
|
||||
// 导出分页状态管理器
|
||||
export {
|
||||
PaginationStateManager,
|
||||
paginationStateManager,
|
||||
} from './PaginationStateManager';
|
||||
182
src/infrastructure/pagination/types.ts
Normal file
182
src/infrastructure/pagination/types.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* 分页状态管理类型定义
|
||||
*
|
||||
* 提供分页相关的接口和类型定义
|
||||
* 用于统一管理分页状态、缓存和配置
|
||||
*/
|
||||
|
||||
/**
|
||||
* 分页状态
|
||||
* 跟踪特定分页键的分页信息
|
||||
*/
|
||||
export interface PaginationState {
|
||||
/** 当前页码(1-based) */
|
||||
currentPage: number;
|
||||
/** 每页大小 */
|
||||
pageSize: number;
|
||||
/** 已加载的总条目数 */
|
||||
totalLoaded: number;
|
||||
/** 是否存在更多数据 */
|
||||
hasMore: boolean;
|
||||
/** 是否正在加载 */
|
||||
isLoading: boolean;
|
||||
/** 最后加载时间戳 */
|
||||
lastLoadTime: number;
|
||||
/** 分页游标(用于基于游标的分页) */
|
||||
cursor?: string | number | null;
|
||||
/** 是否发生过错误 */
|
||||
hasError: boolean;
|
||||
/** 错误信息 */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面缓存
|
||||
* 存储已加载页面的数据
|
||||
*/
|
||||
export interface PageCache<T = any> {
|
||||
/** 页码 */
|
||||
page: number;
|
||||
/** 该页的数据 */
|
||||
data: T[];
|
||||
/** 缓存时间戳 */
|
||||
cachedAt: number;
|
||||
/** 分页游标 */
|
||||
cursor?: string | number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页配置
|
||||
* 配置分页行为
|
||||
*/
|
||||
export interface PaginationConfig {
|
||||
/** 每页默认大小 */
|
||||
pageSize: number;
|
||||
/** 预加载阈值(距离底部多少条时触发预加载) */
|
||||
prefetchThreshold: number;
|
||||
/** 最大缓存页数 */
|
||||
maxCachedPages: number;
|
||||
/** 缓存过期时间(毫秒) */
|
||||
cacheTtl: number;
|
||||
/** 是否启用预加载 */
|
||||
enablePrefetch: boolean;
|
||||
/** 是否启用缓存 */
|
||||
enableCache: boolean;
|
||||
/** 最大重试次数 */
|
||||
maxRetries: number;
|
||||
/** 重试延迟(毫秒) */
|
||||
retryDelay: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载更多结果
|
||||
* loadMore 方法的返回类型
|
||||
*/
|
||||
export interface LoadMoreResult<T = any> {
|
||||
/** 是否成功 */
|
||||
success: boolean;
|
||||
/** 加载的数据 */
|
||||
data: T[];
|
||||
/** 是否有更多数据 */
|
||||
hasMore: boolean;
|
||||
/** 是否来自缓存 */
|
||||
fromCache: boolean;
|
||||
/** 错误信息(如果失败) */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新结果
|
||||
* refresh 方法的返回类型
|
||||
*/
|
||||
export interface RefreshResult<T = any> {
|
||||
/** 是否成功 */
|
||||
success: boolean;
|
||||
/** 刷新后的数据 */
|
||||
data: T[];
|
||||
/** 是否有更多数据 */
|
||||
hasMore: boolean;
|
||||
/** 错误信息(如果失败) */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载结果
|
||||
* prefetch 方法的返回类型
|
||||
*/
|
||||
export interface PrefetchResult<T = any> {
|
||||
/** 是否成功 */
|
||||
success: boolean;
|
||||
/** 预加载的数据 */
|
||||
data?: T[];
|
||||
/** 错误信息(如果失败) */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页数据加载函数
|
||||
* 用于从数据源加载分页数据
|
||||
*/
|
||||
export type FetchPageFunction<T = any> = (
|
||||
page: number,
|
||||
pageSize: number,
|
||||
cursor?: string | number | null
|
||||
) => Promise<{
|
||||
data: T[];
|
||||
hasMore: boolean;
|
||||
cursor?: string | number | null;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* 默认分页配置
|
||||
*/
|
||||
export const DEFAULT_PAGINATION_CONFIG: PaginationConfig = {
|
||||
pageSize: 20,
|
||||
prefetchThreshold: 5,
|
||||
maxCachedPages: 10,
|
||||
cacheTtl: 5 * 60 * 1000, // 5分钟
|
||||
enablePrefetch: true,
|
||||
enableCache: true,
|
||||
maxRetries: 3,
|
||||
retryDelay: 1000,
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始分页状态
|
||||
*/
|
||||
export function createInitialPaginationState(pageSize: number = 20): PaginationState {
|
||||
return {
|
||||
currentPage: 0,
|
||||
pageSize,
|
||||
totalLoaded: 0,
|
||||
hasMore: true,
|
||||
isLoading: false,
|
||||
lastLoadTime: 0,
|
||||
cursor: null,
|
||||
hasError: false,
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建页面缓存
|
||||
*/
|
||||
export function createPageCache<T>(
|
||||
page: number,
|
||||
data: T[],
|
||||
cursor?: string | number | null
|
||||
): PageCache<T> {
|
||||
return {
|
||||
page,
|
||||
data,
|
||||
cachedAt: Date.now(),
|
||||
cursor,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查缓存是否过期
|
||||
*/
|
||||
export function isCacheExpired<T>(cache: PageCache<T>, ttl: number): boolean {
|
||||
return Date.now() - cache.cachedAt > ttl;
|
||||
}
|
||||
289
src/infrastructure/sync/ConnectionStateManager.ts
Normal file
289
src/infrastructure/sync/ConnectionStateManager.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* 连接状态管理器
|
||||
*
|
||||
* 提供连接状态的集中管理,包括:
|
||||
* - 6种连接状态管理(IDLE/CONNECTING/CONNECTED/RECONNECTING/DISCONNECTED/ERROR)
|
||||
* - 状态转换逻辑验证
|
||||
* - 状态订阅机制(监听器模式)
|
||||
* - 重连次数和最后连接时间跟踪
|
||||
* - 单例实例提供
|
||||
*/
|
||||
|
||||
import {
|
||||
ConnectionState,
|
||||
ConnectionStateInfo,
|
||||
ConnectionStateListener,
|
||||
ReconnectConfig,
|
||||
StateTransitions,
|
||||
DEFAULT_STATE_TRANSITIONS,
|
||||
DEFAULT_RECONNECT_CONFIG,
|
||||
getStateDescription,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* 连接状态管理器类
|
||||
*
|
||||
* 管理连接状态的生命周期,提供状态转换、订阅和状态信息查询功能。
|
||||
* 使用单例模式确保全局唯一实例。
|
||||
*/
|
||||
export class ConnectionStateManager {
|
||||
/** 当前连接状态 */
|
||||
private state: ConnectionState = ConnectionState.IDLE;
|
||||
/** 上一次状态 */
|
||||
private previousState: ConnectionState | null = null;
|
||||
/** 状态变更监听器集合 */
|
||||
private listeners: Set<ConnectionStateListener> = new Set();
|
||||
/** 当前重连次数 */
|
||||
private reconnectAttempts = 0;
|
||||
/** 最后成功连接时间 */
|
||||
private lastConnectedTime: number | null = null;
|
||||
/** 状态变更时间戳 */
|
||||
private lastStateChangeTime = Date.now();
|
||||
/** 当前错误信息 */
|
||||
private currentError: Error | null = null;
|
||||
/** 重连配置 */
|
||||
private reconnectConfig: ReconnectConfig;
|
||||
/** 状态转换规则 */
|
||||
private stateTransitions: StateTransitions;
|
||||
|
||||
/**
|
||||
* 创建连接状态管理器实例
|
||||
* @param config 可选的重连配置
|
||||
* @param transitions 可选的状态转换规则
|
||||
*/
|
||||
constructor(
|
||||
config: Partial<ReconnectConfig> = {},
|
||||
transitions: StateTransitions = DEFAULT_STATE_TRANSITIONS
|
||||
) {
|
||||
this.reconnectConfig = { ...DEFAULT_RECONNECT_CONFIG, ...config };
|
||||
this.stateTransitions = transitions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前状态信息
|
||||
* @returns 完整的状态信息对象
|
||||
*/
|
||||
getState(): ConnectionStateInfo {
|
||||
return {
|
||||
state: this.state,
|
||||
timestamp: this.lastStateChangeTime,
|
||||
error: this.currentError || undefined,
|
||||
reconnectAttempts: this.reconnectAttempts,
|
||||
lastConnectedAt: this.lastConnectedTime,
|
||||
description: this.getStatusDescription(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前原始状态
|
||||
* @returns 当前连接状态枚举值
|
||||
*/
|
||||
getRawState(): ConnectionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取上一次状态
|
||||
* @returns 上一次的连接状态,如果没有则为 null
|
||||
*/
|
||||
getPreviousState(): ConnectionState | null {
|
||||
return this.previousState;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置新状态
|
||||
* @param newState 目标状态
|
||||
* @param error 可选的错误信息
|
||||
* @returns 是否成功转换状态
|
||||
*/
|
||||
setState(newState: ConnectionState, error?: Error): boolean {
|
||||
// 检查状态转换是否合法
|
||||
if (!this.canTransitionTo(newState)) {
|
||||
console.warn(
|
||||
`[ConnectionStateManager] 非法状态转换: ${this.state} -> ${newState}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 保存当前状态为上一状态
|
||||
this.previousState = this.state;
|
||||
this.state = newState;
|
||||
this.lastStateChangeTime = Date.now();
|
||||
|
||||
// 更新错误信息
|
||||
if (error) {
|
||||
this.currentError = error;
|
||||
} else if (newState !== ConnectionState.ERROR) {
|
||||
this.currentError = null;
|
||||
}
|
||||
|
||||
// 更新连接时间
|
||||
if (newState === ConnectionState.CONNECTED) {
|
||||
this.lastConnectedTime = Date.now();
|
||||
this.reconnectAttempts = 0;
|
||||
}
|
||||
|
||||
// 通知监听器
|
||||
this.notifyListeners();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以转换到目标状态
|
||||
* @param targetState 目标状态
|
||||
* @returns 是否可以转换
|
||||
*/
|
||||
canTransitionTo(targetState: ConnectionState): boolean {
|
||||
// 相同状态,允许(刷新状态)
|
||||
if (this.state === targetState) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const allowedTransitions = this.stateTransitions[this.state];
|
||||
if (!allowedTransitions) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return allowedTransitions.includes(targetState);
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅状态变更
|
||||
* @param listener 状态变更监听器
|
||||
* @returns 取消订阅函数
|
||||
*/
|
||||
subscribe(listener: ConnectionStateListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
|
||||
// 立即通知一次当前状态
|
||||
listener(this.getState(), null);
|
||||
|
||||
// 返回取消订阅函数
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前监听器数量
|
||||
* @returns 监听器数量
|
||||
*/
|
||||
getListenerCount(): number {
|
||||
return this.listeners.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置重连次数
|
||||
*/
|
||||
resetReconnectAttempts(): void {
|
||||
this.reconnectAttempts = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加重连次数
|
||||
* @returns 增加后的重连次数
|
||||
*/
|
||||
incrementReconnectAttempts(): number {
|
||||
this.reconnectAttempts++;
|
||||
return this.reconnectAttempts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前重连次数
|
||||
* @returns 当前重连次数
|
||||
*/
|
||||
getReconnectAttempts(): number {
|
||||
return this.reconnectAttempts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最大重连次数
|
||||
* @returns 最大重连次数
|
||||
*/
|
||||
getMaxReconnectAttempts(): number {
|
||||
return this.reconnectConfig.maxReconnectAttempts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最后连接时间
|
||||
* @returns 最后成功连接的时间戳,如果没有则为 null
|
||||
*/
|
||||
getLastConnectedTime(): number | null {
|
||||
return this.lastConnectedTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前错误信息
|
||||
* @returns 当前的错误信息,如果没有则为 null
|
||||
*/
|
||||
getCurrentError(): Error | null {
|
||||
return this.currentError;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已连接
|
||||
* @returns 是否处于 CONNECTED 状态
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.state === ConnectionState.CONNECTED;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否正在连接
|
||||
* @returns 是否处于 CONNECTING 或 RECONNECTING 状态
|
||||
*/
|
||||
isConnecting(): boolean {
|
||||
return this.state === ConnectionState.CONNECTING || this.state === ConnectionState.RECONNECTING;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取状态描述
|
||||
* @returns 当前状态的描述字符串
|
||||
*/
|
||||
getStatusDescription(): string {
|
||||
return getStateDescription(
|
||||
this.state,
|
||||
this.reconnectAttempts,
|
||||
this.reconnectConfig.maxReconnectAttempts
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知所有监听器状态变更
|
||||
* @private
|
||||
*/
|
||||
private notifyListeners(): void {
|
||||
const stateInfo = this.getState();
|
||||
const previousInfo = this.previousState !== null
|
||||
? { ...stateInfo, state: this.previousState } as ConnectionStateInfo
|
||||
: null;
|
||||
|
||||
this.listeners.forEach((listener) => {
|
||||
try {
|
||||
listener(stateInfo, previousInfo);
|
||||
} catch (error) {
|
||||
console.error('[ConnectionStateManager] 监听器执行失败:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁状态管理器
|
||||
* 清除所有监听器和状态
|
||||
*/
|
||||
destroy(): void {
|
||||
this.listeners.clear();
|
||||
this.state = ConnectionState.IDLE;
|
||||
this.previousState = null;
|
||||
this.reconnectAttempts = 0;
|
||||
this.currentError = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认单例实例
|
||||
*/
|
||||
export const connectionStateManager = new ConnectionStateManager();
|
||||
|
||||
export default connectionStateManager;
|
||||
20
src/infrastructure/sync/index.ts
Normal file
20
src/infrastructure/sync/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 同步状态管理模块
|
||||
*
|
||||
* 提供连接状态管理的统一入口
|
||||
*/
|
||||
|
||||
// 导出类型定义
|
||||
export {
|
||||
ConnectionState,
|
||||
ConnectionStateInfo,
|
||||
ConnectionStateListener,
|
||||
ReconnectConfig,
|
||||
StateTransitions,
|
||||
DEFAULT_STATE_TRANSITIONS,
|
||||
DEFAULT_RECONNECT_CONFIG,
|
||||
getStateDescription,
|
||||
} from './types';
|
||||
|
||||
// 导出状态管理器
|
||||
export { ConnectionStateManager, connectionStateManager } from './ConnectionStateManager';
|
||||
147
src/infrastructure/sync/types.ts
Normal file
147
src/infrastructure/sync/types.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 同步状态管理类型定义
|
||||
*
|
||||
* 提供连接状态相关的类型、接口和枚举
|
||||
* 用于统一管理 SSE 连接状态
|
||||
*/
|
||||
|
||||
/**
|
||||
* 连接状态枚举
|
||||
* 表示连接生命周期的各个阶段
|
||||
*/
|
||||
export enum ConnectionState {
|
||||
/** 初始状态,未开始连接 */
|
||||
IDLE = 'IDLE',
|
||||
/** 正在建立连接 */
|
||||
CONNECTING = 'CONNECTING',
|
||||
/** 连接已建立 */
|
||||
CONNECTED = 'CONNECTED',
|
||||
/** 连接断开,正在重连 */
|
||||
RECONNECTING = 'RECONNECTING',
|
||||
/** 连接已断开 */
|
||||
DISCONNECTED = 'DISCONNECTED',
|
||||
/** 发生连接错误 */
|
||||
ERROR = 'ERROR',
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接状态信息
|
||||
* 包含完整的状态信息
|
||||
*/
|
||||
export interface ConnectionStateInfo {
|
||||
/** 当前连接状态 */
|
||||
state: ConnectionState;
|
||||
/** 状态变更时间戳 */
|
||||
timestamp: number;
|
||||
/** 错误信息(仅在 ERROR 状态下有效) */
|
||||
error?: Error;
|
||||
/** 当前重连次数(仅在 RECONNECTING 状态下有效) */
|
||||
reconnectAttempts?: number;
|
||||
/** 上次成功连接时间 */
|
||||
lastConnectedAt?: number | null;
|
||||
/** 状态描述(供显示用) */
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接状态监听器类型
|
||||
*/
|
||||
export type ConnectionStateListener = (
|
||||
state: ConnectionStateInfo,
|
||||
previousState: ConnectionStateInfo | null
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* 重连配置接口
|
||||
*/
|
||||
export interface ReconnectConfig {
|
||||
/** 最大重连次数 */
|
||||
maxReconnectAttempts: number;
|
||||
/** 初始重连延迟(毫秒) */
|
||||
initialDelay: number;
|
||||
/** 最大重连延迟(毫秒) */
|
||||
maxDelay: number;
|
||||
/** 延迟倍数(指数退避) */
|
||||
backoffMultiplier: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态转换规则
|
||||
* 定义哪些状态可以转换到哪些状态
|
||||
*/
|
||||
export type StateTransitions = {
|
||||
[key in ConnectionState]?: ConnectionState[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 默认状态转换规则
|
||||
*/
|
||||
export const DEFAULT_STATE_TRANSITIONS: StateTransitions = {
|
||||
[ConnectionState.IDLE]: [ConnectionState.CONNECTING],
|
||||
[ConnectionState.CONNECTING]: [
|
||||
ConnectionState.CONNECTED,
|
||||
ConnectionState.ERROR,
|
||||
ConnectionState.DISCONNECTED,
|
||||
],
|
||||
[ConnectionState.CONNECTED]: [
|
||||
ConnectionState.DISCONNECTED,
|
||||
ConnectionState.ERROR,
|
||||
ConnectionState.RECONNECTING,
|
||||
],
|
||||
[ConnectionState.RECONNECTING]: [
|
||||
ConnectionState.CONNECTED,
|
||||
ConnectionState.ERROR,
|
||||
ConnectionState.DISCONNECTED,
|
||||
],
|
||||
[ConnectionState.DISCONNECTED]: [
|
||||
ConnectionState.CONNECTING,
|
||||
ConnectionState.RECONNECTING,
|
||||
],
|
||||
[ConnectionState.ERROR]: [
|
||||
ConnectionState.CONNECTING,
|
||||
ConnectionState.RECONNECTING,
|
||||
ConnectionState.DISCONNECTED,
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 默认重连配置
|
||||
*/
|
||||
export const DEFAULT_RECONNECT_CONFIG: ReconnectConfig = {
|
||||
maxReconnectAttempts: 20,
|
||||
initialDelay: 1000,
|
||||
maxDelay: 30000,
|
||||
backoffMultiplier: 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取状态的中文描述
|
||||
* @param state 连接状态
|
||||
* @param reconnectAttempts 当前重连次数
|
||||
* @param maxReconnectAttempts 最大重连次数
|
||||
* @returns 状态描述字符串
|
||||
*/
|
||||
export function getStateDescription(
|
||||
state: ConnectionState,
|
||||
reconnectAttempts?: number,
|
||||
maxReconnectAttempts?: number
|
||||
): string {
|
||||
switch (state) {
|
||||
case ConnectionState.IDLE:
|
||||
return '未连接';
|
||||
case ConnectionState.CONNECTING:
|
||||
return '正在连接...';
|
||||
case ConnectionState.CONNECTED:
|
||||
return '已连接';
|
||||
case ConnectionState.RECONNECTING:
|
||||
return reconnectAttempts !== undefined && maxReconnectAttempts !== undefined
|
||||
? `正在重连 (${reconnectAttempts}/${maxReconnectAttempts})`
|
||||
: '正在重连...';
|
||||
case ConnectionState.DISCONNECTED:
|
||||
return '已断开连接';
|
||||
case ConnectionState.ERROR:
|
||||
return '连接错误';
|
||||
default:
|
||||
return '未知状态';
|
||||
}
|
||||
}
|
||||
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
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>
|
||||
);
|
||||
}
|
||||
275
src/navigation/RootNavigator.tsx
Normal file
275
src/navigation/RootNavigator.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* 根导航器
|
||||
* 处理整个应用的顶级导航,包括认证状态切换
|
||||
*/
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, ActivityIndicator, StyleSheet } from 'react-native';
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
|
||||
import type { RootStackParamList } from './types';
|
||||
import { colors } from '../theme';
|
||||
import { navigationService } from '../infrastructure/navigation/navigationService';
|
||||
|
||||
import { AuthNavigator } from './AuthNavigator';
|
||||
import { SimpleMobileTabNavigator } from './SimpleMobileTabNavigator';
|
||||
import { DesktopNavigator } from './DesktopNavigator';
|
||||
|
||||
import { useResponsive } from '../hooks';
|
||||
import { useTotalUnreadCount } from '../stores';
|
||||
|
||||
// 导入全局屏幕组件
|
||||
import { PostDetailScreen } from '../screens/home';
|
||||
import { UserScreen } from '../screens/profile';
|
||||
import FollowListScreen from '../screens/profile/FollowListScreen';
|
||||
import { CreatePostScreen } from '../screens/create';
|
||||
import {
|
||||
ChatScreen,
|
||||
CreateGroupScreen,
|
||||
JoinGroupScreen,
|
||||
GroupInfoScreen,
|
||||
GroupMembersScreen,
|
||||
GroupRequestDetailScreen,
|
||||
GroupInviteDetailScreen,
|
||||
PrivateChatInfoScreen,
|
||||
} from '../screens/message';
|
||||
|
||||
const RootStack = createNativeStackNavigator<RootStackParamList>();
|
||||
|
||||
interface RootNavigatorProps {
|
||||
isAuthenticated: boolean;
|
||||
isInitializing: boolean;
|
||||
}
|
||||
|
||||
// 未认证时可访问的屏幕 - 返回数组而不是 JSX
|
||||
const getPublicScreens = () => [
|
||||
<RootStack.Screen
|
||||
key="PostDetail"
|
||||
name="PostDetail"
|
||||
component={PostDetailScreen}
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: '',
|
||||
headerStyle: { backgroundColor: colors.background.paper },
|
||||
headerTintColor: colors.text.primary,
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
/>,
|
||||
<RootStack.Screen
|
||||
key="UserProfile"
|
||||
name="UserProfile"
|
||||
component={UserScreen}
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: '用户主页',
|
||||
headerStyle: { backgroundColor: colors.background.paper },
|
||||
headerTintColor: colors.text.primary,
|
||||
}}
|
||||
/>,
|
||||
];
|
||||
|
||||
// 认证后可访问的屏幕 - 返回数组而不是 JSX
|
||||
const getAuthenticatedScreens = () => [
|
||||
<RootStack.Screen
|
||||
key="PostDetail"
|
||||
name="PostDetail"
|
||||
component={PostDetailScreen}
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: '',
|
||||
headerStyle: { backgroundColor: colors.background.paper },
|
||||
headerTintColor: colors.text.primary,
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
/>,
|
||||
<RootStack.Screen
|
||||
key="UserProfile"
|
||||
name="UserProfile"
|
||||
component={UserScreen}
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: '用户主页',
|
||||
headerStyle: { backgroundColor: colors.background.paper },
|
||||
headerTintColor: colors.text.primary,
|
||||
}}
|
||||
/>,
|
||||
<RootStack.Screen
|
||||
key="CreatePost"
|
||||
name="CreatePost"
|
||||
component={CreatePostScreen}
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: '发布帖子',
|
||||
headerStyle: { backgroundColor: colors.background.paper },
|
||||
headerTintColor: colors.text.primary,
|
||||
presentation: 'modal',
|
||||
}}
|
||||
/>,
|
||||
<RootStack.Screen
|
||||
key="Chat"
|
||||
name="Chat"
|
||||
component={ChatScreen}
|
||||
options={{
|
||||
headerShown: false,
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
/>,
|
||||
<RootStack.Screen
|
||||
key="FollowList"
|
||||
name="FollowList"
|
||||
component={FollowListScreen}
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: '关注列表',
|
||||
headerStyle: { backgroundColor: colors.background.paper },
|
||||
headerTintColor: colors.text.primary,
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
/>,
|
||||
<RootStack.Screen
|
||||
key="CreateGroup"
|
||||
name="CreateGroup"
|
||||
component={CreateGroupScreen}
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: '创建群聊',
|
||||
headerStyle: { backgroundColor: colors.background.paper },
|
||||
headerTintColor: colors.text.primary,
|
||||
presentation: 'modal',
|
||||
}}
|
||||
/>,
|
||||
<RootStack.Screen
|
||||
key="JoinGroup"
|
||||
name="JoinGroup"
|
||||
component={JoinGroupScreen}
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: '搜索群聊',
|
||||
headerStyle: { backgroundColor: colors.background.paper },
|
||||
headerTintColor: colors.text.primary,
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
/>,
|
||||
<RootStack.Screen
|
||||
key="GroupInfo"
|
||||
name="GroupInfo"
|
||||
component={GroupInfoScreen}
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: '群信息',
|
||||
headerStyle: { backgroundColor: colors.background.paper },
|
||||
headerTintColor: colors.text.primary,
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
/>,
|
||||
<RootStack.Screen
|
||||
key="GroupMembers"
|
||||
name="GroupMembers"
|
||||
component={GroupMembersScreen}
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: '群成员',
|
||||
headerStyle: { backgroundColor: colors.background.paper },
|
||||
headerTintColor: colors.text.primary,
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
/>,
|
||||
<RootStack.Screen
|
||||
key="GroupRequestDetail"
|
||||
name="GroupRequestDetail"
|
||||
component={GroupRequestDetailScreen}
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: '入群审批',
|
||||
headerStyle: { backgroundColor: colors.background.paper },
|
||||
headerTintColor: colors.text.primary,
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
/>,
|
||||
<RootStack.Screen
|
||||
key="GroupInviteDetail"
|
||||
name="GroupInviteDetail"
|
||||
component={GroupInviteDetailScreen}
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: '群聊邀请',
|
||||
headerStyle: { backgroundColor: colors.background.paper },
|
||||
headerTintColor: colors.text.primary,
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
/>,
|
||||
<RootStack.Screen
|
||||
key="PrivateChatInfo"
|
||||
name="PrivateChatInfo"
|
||||
component={PrivateChatInfoScreen}
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: '聊天信息',
|
||||
headerStyle: { backgroundColor: colors.background.paper },
|
||||
headerTintColor: colors.text.primary,
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
/>,
|
||||
];
|
||||
|
||||
export function RootNavigator({ isAuthenticated, isInitializing }: RootNavigatorProps) {
|
||||
const { isMobile } = useResponsive();
|
||||
const unreadCount = useTotalUnreadCount();
|
||||
|
||||
// 设置导航引用
|
||||
const setNavigationRef = (ref: any) => {
|
||||
navigationService.setNavigationRef(ref);
|
||||
};
|
||||
|
||||
// 加载中显示
|
||||
if (isInitializing) {
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavigationContainer ref={setNavigationRef}>
|
||||
<RootStack.Navigator
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
}}
|
||||
>
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<RootStack.Screen name="Main">
|
||||
{() =>
|
||||
isMobile ? (
|
||||
<SimpleMobileTabNavigator />
|
||||
) : (
|
||||
<DesktopNavigator unreadCount={unreadCount} />
|
||||
)
|
||||
}
|
||||
</RootStack.Screen>
|
||||
{getAuthenticatedScreens()}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RootStack.Screen
|
||||
name="Auth"
|
||||
component={AuthNavigator}
|
||||
options={{ headerShown: false }}
|
||||
/>
|
||||
{getPublicScreens()}
|
||||
</>
|
||||
)}
|
||||
</RootStack.Navigator>
|
||||
</NavigationContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
});
|
||||
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;
|
||||
@@ -334,6 +334,9 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
|
||||
// 发布帖子
|
||||
const handlePost = async () => {
|
||||
// 防止重复点击
|
||||
if (posting) return;
|
||||
|
||||
if (!content.trim()) {
|
||||
Alert.alert('错误', '请输入帖子内容');
|
||||
return;
|
||||
@@ -373,7 +376,10 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
message: '投票帖已提交,内容审核中,稍后展示',
|
||||
duration: 2600,
|
||||
});
|
||||
navigation.goBack();
|
||||
navigation.reset({
|
||||
index: 0,
|
||||
routes: [{ name: 'Main' }],
|
||||
});
|
||||
} else {
|
||||
if (isEditMode && editPostID) {
|
||||
const updated = await postService.updatePost(editPostID, {
|
||||
@@ -390,7 +396,10 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
message: '帖子内容已更新',
|
||||
duration: 2200,
|
||||
});
|
||||
navigation.goBack();
|
||||
navigation.reset({
|
||||
index: 0,
|
||||
routes: [{ name: 'Main' }],
|
||||
});
|
||||
} else {
|
||||
// 创建普通帖子
|
||||
await postService.createPost({
|
||||
@@ -404,7 +413,10 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
message: '帖子已提交,内容审核中,稍后展示',
|
||||
duration: 2600,
|
||||
});
|
||||
navigation.goBack();
|
||||
navigation.reset({
|
||||
index: 0,
|
||||
routes: [{ name: 'Main' }],
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -35,6 +35,7 @@ import { HomeStackParamList, RootStackParamList } from '../../navigation/types';
|
||||
import { useResponsive, useResponsiveSpacing } from '../../hooks/useResponsive';
|
||||
import { SearchScreen } from './SearchScreen';
|
||||
import { CreatePostScreen } from '../create/CreatePostScreen';
|
||||
import { navigationService } from '../../infrastructure/navigation/navigationService';
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<HomeStackParamList, 'Home'> & NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
@@ -320,6 +321,7 @@ export const HomeScreen: React.FC = () => {
|
||||
.runOnJS(true)
|
||||
.activeOffsetX([-15, 15])
|
||||
.failOffsetY([-20, 20])
|
||||
.shouldCancelWhenOutside(true)
|
||||
.onEnd((event) => {
|
||||
const now = Date.now();
|
||||
if (now - lastSwipeAtRef.current < SWIPE_COOLDOWN_MS) {
|
||||
@@ -342,12 +344,12 @@ export const HomeScreen: React.FC = () => {
|
||||
|
||||
// 跳转到帖子详情
|
||||
const handlePostPress = (postId: string, scrollToComments: boolean = false) => {
|
||||
navigation.getParent()?.navigate('PostDetail', { postId, scrollToComments });
|
||||
navigationService.navigate('PostDetail', { postId, scrollToComments });
|
||||
};
|
||||
|
||||
// 跳转到用户主页
|
||||
const handleUserPress = (userId: string) => {
|
||||
navigation.getParent()?.navigate('UserProfile', { userId });
|
||||
navigationService.navigate('UserProfile', { userId });
|
||||
};
|
||||
|
||||
// 点赞帖子
|
||||
|
||||
@@ -31,6 +31,7 @@ import { userManager } from '../../stores/userManager';
|
||||
import { Avatar, Text, Button, Loading, Divider } from '../../components/common';
|
||||
import { User } from '../../types';
|
||||
import { RootStackParamList, MessageStackParamList } from '../../navigation/types';
|
||||
import { navigationService } from '../../infrastructure/navigation/navigationService';
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<MessageStackParamList>;
|
||||
type PrivateChatInfoRouteProp = RouteProp<MessageStackParamList, 'PrivateChatInfo'>;
|
||||
@@ -149,9 +150,8 @@ const PrivateChatInfoScreen: React.FC = () => {
|
||||
|
||||
// 查看用户资料
|
||||
const handleViewProfile = () => {
|
||||
// 使用根导航跳转到用户资料页面
|
||||
const rootNavigation = navigation.getParent();
|
||||
rootNavigation?.navigate('UserProfile' as any, { userId });
|
||||
// 使用导航服务跳转到用户资料页面
|
||||
navigationService.navigate('UserProfile', { userId });
|
||||
};
|
||||
|
||||
// 举报/投诉
|
||||
|
||||
@@ -31,6 +31,7 @@ import { groupService } from '../../../../services/groupService';
|
||||
import { userManager } from '../../../../stores/userManager';
|
||||
import { groupManager } from '../../../../stores/groupManager';
|
||||
import { RootStackParamList } from '../../../../navigation/types';
|
||||
import { navigationService } from '../../../../infrastructure/navigation/navigationService';
|
||||
import {
|
||||
GroupMessage,
|
||||
PanelType,
|
||||
@@ -1039,9 +1040,9 @@ export const useChatScreen = () => {
|
||||
// 点击头像跳转到用户主页
|
||||
const handleAvatarPress = useCallback((userId: string) => {
|
||||
if (userId && userId !== currentUserId) {
|
||||
navigation.navigate('UserProfile' as any, { userId: String(userId) });
|
||||
navigationService.navigate('UserProfile', { userId: String(userId) });
|
||||
}
|
||||
}, [navigation, currentUserId]);
|
||||
}, [currentUserId]);
|
||||
|
||||
// 长按头像@用户
|
||||
const handleAvatarLongPress = useCallback((senderId: string, nickname: string) => {
|
||||
@@ -1113,8 +1114,7 @@ export const useChatScreen = () => {
|
||||
conversationId: conversationId || undefined,
|
||||
});
|
||||
} else if (otherUser?.id) {
|
||||
const rootNavigation = navigation.getParent();
|
||||
rootNavigation?.navigate('UserProfile' as any, { userId: String(otherUser.id) });
|
||||
navigationService.navigate('UserProfile', { userId: String(otherUser.id) });
|
||||
}
|
||||
}, [navigation, isGroupChat, routeGroupId, otherUser]);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* 功能:
|
||||
* - 定期后台任务保持 App 在内存中
|
||||
* - 配合 WebSocket 服务保持长连接
|
||||
* - 配合 SSE 服务保持长连接
|
||||
* - 收到消息时触发震动提示
|
||||
*/
|
||||
|
||||
@@ -47,7 +47,7 @@ let appStateSubscription: any = null;
|
||||
// 定义后台任务
|
||||
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
||||
try {
|
||||
// 检查 WebSocket 连接状态
|
||||
// 检查 SSE 连接状态
|
||||
if (!sseService.isConnected()) {
|
||||
await sseService.connect();
|
||||
}
|
||||
@@ -60,7 +60,7 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
||||
}
|
||||
});
|
||||
|
||||
// WebSocket 保活任务
|
||||
// SSE 保活任务
|
||||
TaskManager.defineTask(REALTIME_KEEPALIVE_TASK, async () => {
|
||||
try {
|
||||
if (!sseService.isConnected()) {
|
||||
@@ -174,9 +174,9 @@ async function registerBackgroundTasks(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// 注册 WebSocket 保活任务
|
||||
const isWsKeepaliveRegistered = await TaskManager.isTaskRegisteredAsync(REALTIME_KEEPALIVE_TASK);
|
||||
if (!isWsKeepaliveRegistered) {
|
||||
// 注册 SSE 保活任务
|
||||
const isSseKeepaliveRegistered = await TaskManager.isTaskRegisteredAsync(REALTIME_KEEPALIVE_TASK);
|
||||
if (!isSseKeepaliveRegistered) {
|
||||
await BackgroundFetch.registerTaskAsync(REALTIME_KEEPALIVE_TASK, {
|
||||
minimumInterval: 60, // 1 分钟检查一次
|
||||
stopOnTerminate: false,
|
||||
|
||||
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 './MessageStateManager';
|
||||
import { sseMessageHandler, SSEMessageHandler } from './SSEMessageHandler';
|
||||
import { messageSyncService, MessageSyncService } from './MessageSyncService';
|
||||
import { readReceiptManager, ReadReceiptManager } from './ReadReceiptManager';
|
||||
import { messageRepository } from '../../data/repositories/MessageRepository';
|
||||
import { useAuthStore } from '../authStore';
|
||||
import type { Message, Conversation } from '../../core/entities/Message';
|
||||
import type { ConversationResponse, MessageResponse } from '../../types/dto';
|
||||
|
||||
export type {
|
||||
MessageEventType,
|
||||
MessageEvent,
|
||||
MessageSubscriber,
|
||||
} from './MessageStateManager';
|
||||
|
||||
export class MessageManager {
|
||||
private stateManager: MessageStateManager;
|
||||
private sseHandler: SSEMessageHandler;
|
||||
private syncService: MessageSyncService;
|
||||
private readManager: ReadReceiptManager;
|
||||
|
||||
private initialized: boolean = false;
|
||||
private authUnsubscribe: (() => void) | null = null;
|
||||
private sseUnsubscribe: (() => void) | null = null;
|
||||
private currentUserId: string | null = null;
|
||||
|
||||
constructor() {
|
||||
this.stateManager = messageStateManager;
|
||||
this.sseHandler = sseMessageHandler;
|
||||
this.syncService = messageSyncService;
|
||||
this.readManager = readReceiptManager;
|
||||
|
||||
this.readManager.setStateManager(this.stateManager);
|
||||
this.setupSSEHandlers();
|
||||
this.initAuthListener();
|
||||
}
|
||||
|
||||
private initAuthListener(): void {
|
||||
const authState = useAuthStore.getState();
|
||||
this.currentUserId = authState.currentUser?.id || null;
|
||||
|
||||
if (this.currentUserId && !this.initialized) {
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
this.authUnsubscribe = useAuthStore.subscribe((state) => {
|
||||
const nextUserId = state.currentUser?.id || null;
|
||||
this.currentUserId = nextUserId;
|
||||
|
||||
if (nextUserId && !this.initialized) {
|
||||
this.initialize();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private setupSSEHandlers(): void {
|
||||
this.sseUnsubscribe = this.sseHandler.subscribe((event) => {
|
||||
switch (event.type) {
|
||||
case 'chat_message':
|
||||
case 'group_message':
|
||||
this.handleNewMessage(event.payload);
|
||||
break;
|
||||
case 'read_receipt':
|
||||
case 'group_read_receipt':
|
||||
this.handleReadReceipt(event.payload);
|
||||
break;
|
||||
case 'message_recalled':
|
||||
case 'group_message_recalled':
|
||||
this.handleMessageRecall(event.payload);
|
||||
break;
|
||||
case 'typing':
|
||||
this.handleTypingStatus(event.payload);
|
||||
break;
|
||||
case 'group_notice':
|
||||
this.handleGroupNotice(event.payload);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
try {
|
||||
console.log('[MessageManager] Initializing...');
|
||||
|
||||
this.sseHandler.connect();
|
||||
|
||||
const conversations = await this.syncService.syncConversations();
|
||||
this.stateManager.setConversations(conversations);
|
||||
|
||||
this.initialized = true;
|
||||
console.log('[MessageManager] Initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('[MessageManager] Initialization failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async activateConversation(conversationId: string): Promise<void> {
|
||||
this.stateManager.setCurrentConversation(conversationId);
|
||||
|
||||
const existing = this.stateManager.getMessages(conversationId);
|
||||
if (existing.length === 0) {
|
||||
await this.loadMessages(conversationId);
|
||||
}
|
||||
|
||||
const conversation = this.stateManager.getConversation(conversationId);
|
||||
if (conversation && conversation.unreadCount > 0) {
|
||||
await this.readManager.markAsRead(conversationId, conversation.lastSeq);
|
||||
}
|
||||
}
|
||||
|
||||
deactivateConversation(): void {
|
||||
this.stateManager.setCurrentConversation(null);
|
||||
}
|
||||
|
||||
async loadMessages(conversationId: string): Promise<Message[]> {
|
||||
const localMessages = await this.syncService.loadLocalMessages(conversationId);
|
||||
|
||||
if (localMessages.length > 0) {
|
||||
this.stateManager.setMessages(conversationId, localMessages);
|
||||
}
|
||||
|
||||
const syncResult = await this.syncService.syncMessages(conversationId);
|
||||
|
||||
if (syncResult.messages.length > 0) {
|
||||
this.stateManager.appendMessages(conversationId, syncResult.messages);
|
||||
return [...localMessages, ...syncResult.messages];
|
||||
}
|
||||
|
||||
return localMessages;
|
||||
}
|
||||
|
||||
async loadHistory(conversationId: string): Promise<Message[]> {
|
||||
const messages = this.stateManager.getMessages(conversationId);
|
||||
const oldestSeq = messages.length > 0 ? Math.min(...messages.map(m => m.seq)) : 0;
|
||||
|
||||
const history = await this.syncService.loadHistoryMessages(
|
||||
conversationId,
|
||||
oldestSeq,
|
||||
20
|
||||
);
|
||||
|
||||
if (history.length > 0) {
|
||||
this.stateManager.prependMessages(conversationId, history);
|
||||
}
|
||||
|
||||
return history;
|
||||
}
|
||||
|
||||
async sendMessage(
|
||||
conversationId: string,
|
||||
content: string,
|
||||
type: 'private' | 'group' = 'private'
|
||||
): Promise<Message> {
|
||||
throw new Error('sendMessage not implemented - use existing implementation');
|
||||
}
|
||||
|
||||
private async handleNewMessage(message: Message): Promise<void> {
|
||||
await messageRepository.saveMessage(message, true);
|
||||
|
||||
this.stateManager.addMessage(message.conversationId, message);
|
||||
|
||||
const currentConvId = this.stateManager.getCurrentConversationId();
|
||||
if (currentConvId !== message.conversationId) {
|
||||
const conv = this.stateManager.getConversation(message.conversationId);
|
||||
if (conv) {
|
||||
this.stateManager.updateConversation(message.conversationId, {
|
||||
unreadCount: (conv.unreadCount || 0) + 1,
|
||||
lastSeq: message.seq,
|
||||
lastMessageAt: message.createdAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleReadReceipt(payload: any): void {
|
||||
const { conversationId, lastReadSeq } = payload;
|
||||
|
||||
this.stateManager.updateConversation(conversationId, {
|
||||
unreadCount: 0,
|
||||
});
|
||||
}
|
||||
|
||||
private async handleMessageRecall(payload: any): Promise<void> {
|
||||
const { messageId, conversationId } = payload;
|
||||
|
||||
await messageRepository.updateMessageStatus(messageId, 'recalled', true);
|
||||
|
||||
this.stateManager.updateMessage(conversationId, messageId, {
|
||||
status: 'recalled',
|
||||
});
|
||||
}
|
||||
|
||||
private handleTypingStatus(payload: any): void {
|
||||
const { groupId, userIds } = payload;
|
||||
this.stateManager.setTypingUsers(groupId, userIds);
|
||||
}
|
||||
|
||||
private handleGroupNotice(notice: any): void {
|
||||
console.log('[MessageManager] Group notice:', notice);
|
||||
}
|
||||
|
||||
subscribe(callback: any): () => void {
|
||||
return this.stateManager.subscribe(callback);
|
||||
}
|
||||
|
||||
getConversations(): Conversation[] {
|
||||
return this.stateManager.getConversations();
|
||||
}
|
||||
|
||||
getMessages(conversationId: string): Message[] {
|
||||
return this.stateManager.getMessages(conversationId);
|
||||
}
|
||||
|
||||
getUnreadCount(): { total: number; system: number } {
|
||||
return this.stateManager.getUnreadCount();
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.sseHandler.isConnected();
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.initialized = false;
|
||||
this.stateManager.reset();
|
||||
this.readManager.reset();
|
||||
this.sseHandler.disconnect();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.authUnsubscribe) {
|
||||
this.authUnsubscribe();
|
||||
this.authUnsubscribe = null;
|
||||
}
|
||||
if (this.sseUnsubscribe) {
|
||||
this.sseUnsubscribe();
|
||||
this.sseUnsubscribe = null;
|
||||
}
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
export const messageManager = new MessageManager();
|
||||
export default messageManager;
|
||||
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;
|
||||
isSSEConnected: boolean;
|
||||
currentConversationId: string | null;
|
||||
isLoading: boolean;
|
||||
typingUsersMap: Map<string, string[]>;
|
||||
mutedStatusMap: Map<string, boolean>;
|
||||
}
|
||||
|
||||
export class MessageStateManager {
|
||||
private state: MessageState;
|
||||
private subscribers: Set<MessageSubscriber>;
|
||||
private readStateVersion: number = 0;
|
||||
private pendingReadMap: Map<string, { timestamp: number; version: number }> = new Map();
|
||||
|
||||
constructor() {
|
||||
this.state = {
|
||||
conversations: new Map(),
|
||||
conversationList: [],
|
||||
messagesMap: new Map(),
|
||||
unreadCount: { total: 0, system: 0 },
|
||||
isSSEConnected: false,
|
||||
currentConversationId: null,
|
||||
isLoading: false,
|
||||
typingUsersMap: new Map(),
|
||||
mutedStatusMap: new Map(),
|
||||
};
|
||||
this.subscribers = new Set();
|
||||
}
|
||||
|
||||
// ==================== 状态获取 ====================
|
||||
|
||||
getState(): MessageState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
getConversations(): Conversation[] {
|
||||
return this.state.conversationList;
|
||||
}
|
||||
|
||||
getConversation(id: string): Conversation | undefined {
|
||||
return this.state.conversations.get(id);
|
||||
}
|
||||
|
||||
getMessages(conversationId: string): Message[] {
|
||||
return this.state.messagesMap.get(conversationId) || [];
|
||||
}
|
||||
|
||||
getUnreadCount(): UnreadCount {
|
||||
return this.state.unreadCount;
|
||||
}
|
||||
|
||||
getCurrentConversationId(): string | null {
|
||||
return this.state.currentConversationId;
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.state.isSSEConnected;
|
||||
}
|
||||
|
||||
getTypingUsers(groupId: string): string[] {
|
||||
return this.state.typingUsersMap.get(groupId) || [];
|
||||
}
|
||||
|
||||
getMutedStatus(groupId: string): boolean {
|
||||
return this.state.mutedStatusMap.get(groupId) || false;
|
||||
}
|
||||
|
||||
// ==================== 状态更新 ====================
|
||||
|
||||
setConversations(conversations: Conversation[]): void {
|
||||
this.state.conversations.clear();
|
||||
this.state.conversationList = conversations;
|
||||
|
||||
for (const conv of conversations) {
|
||||
this.state.conversations.set(conv.id, conv);
|
||||
}
|
||||
|
||||
this.updateUnreadCount();
|
||||
this.notify({ type: 'conversations_updated', payload: conversations });
|
||||
}
|
||||
|
||||
addConversation(conversation: Conversation): void {
|
||||
this.state.conversations.set(conversation.id, conversation);
|
||||
this.state.conversationList = Array.from(this.state.conversations.values());
|
||||
this.updateUnreadCount();
|
||||
this.notify({ type: 'conversations_updated', payload: this.state.conversationList });
|
||||
}
|
||||
|
||||
updateConversation(id: string, updates: Partial<Conversation>): void {
|
||||
const existing = this.state.conversations.get(id);
|
||||
if (existing) {
|
||||
const updated = { ...existing, ...updates };
|
||||
this.state.conversations.set(id, updated);
|
||||
this.state.conversationList = Array.from(this.state.conversations.values());
|
||||
this.updateUnreadCount();
|
||||
this.notify({ type: 'conversations_updated', payload: this.state.conversationList });
|
||||
}
|
||||
}
|
||||
|
||||
removeConversation(id: string): void {
|
||||
this.state.conversations.delete(id);
|
||||
this.state.messagesMap.delete(id);
|
||||
this.state.conversationList = Array.from(this.state.conversations.values());
|
||||
this.updateUnreadCount();
|
||||
this.notify({ type: 'conversations_updated', payload: this.state.conversationList });
|
||||
}
|
||||
|
||||
setMessages(conversationId: string, messages: Message[]): void {
|
||||
this.state.messagesMap.set(conversationId, messages);
|
||||
this.notify({ type: 'messages_updated', payload: { conversationId, messages } });
|
||||
}
|
||||
|
||||
addMessage(conversationId: string, message: Message): void {
|
||||
const messages = this.state.messagesMap.get(conversationId) || [];
|
||||
const existingIndex = messages.findIndex(m => m.id === message.id);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
messages[existingIndex] = message;
|
||||
} else {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
this.state.messagesMap.set(conversationId, messages);
|
||||
this.notify({ type: 'messages_updated', payload: { conversationId, messages } });
|
||||
}
|
||||
|
||||
prependMessages(conversationId: string, newMessages: Message[]): void {
|
||||
const existing = this.state.messagesMap.get(conversationId) || [];
|
||||
const merged = [...newMessages, ...existing];
|
||||
const unique = this.deduplicateMessages(merged);
|
||||
this.state.messagesMap.set(conversationId, unique);
|
||||
this.notify({ type: 'messages_updated', payload: { conversationId, messages: unique } });
|
||||
}
|
||||
|
||||
appendMessages(conversationId: string, newMessages: Message[]): void {
|
||||
const existing = this.state.messagesMap.get(conversationId) || [];
|
||||
const merged = [...existing, ...newMessages];
|
||||
const unique = this.deduplicateMessages(merged);
|
||||
this.state.messagesMap.set(conversationId, unique);
|
||||
this.notify({ type: 'messages_updated', payload: { conversationId, messages: unique } });
|
||||
}
|
||||
|
||||
updateMessage(conversationId: string, messageId: string, updates: Partial<Message>): void {
|
||||
const messages = this.state.messagesMap.get(conversationId);
|
||||
if (!messages) return;
|
||||
|
||||
const index = messages.findIndex(m => m.id === messageId);
|
||||
if (index >= 0) {
|
||||
messages[index] = { ...messages[index], ...updates };
|
||||
this.notify({ type: 'messages_updated', payload: { conversationId, messages } });
|
||||
}
|
||||
}
|
||||
|
||||
setUnreadCount(count: UnreadCount): void {
|
||||
this.state.unreadCount = count;
|
||||
this.notify({ type: 'unread_count_updated', payload: count });
|
||||
}
|
||||
|
||||
setSSEConnected(connected: boolean): void {
|
||||
this.state.isSSEConnected = connected;
|
||||
this.notify({ type: 'connection_changed', payload: connected });
|
||||
}
|
||||
|
||||
setCurrentConversation(id: string | null): void {
|
||||
this.state.currentConversationId = id;
|
||||
}
|
||||
|
||||
setLoading(loading: boolean): void {
|
||||
this.state.isLoading = loading;
|
||||
}
|
||||
|
||||
setTypingUsers(groupId: string, userIds: string[]): void {
|
||||
this.state.typingUsersMap.set(groupId, userIds);
|
||||
this.notify({ type: 'typing_status', payload: { groupId, userIds } });
|
||||
}
|
||||
|
||||
setMutedStatus(groupId: string, muted: boolean): void {
|
||||
this.state.mutedStatusMap.set(groupId, muted);
|
||||
}
|
||||
|
||||
// ==================== 已读状态保护 ====================
|
||||
|
||||
beginReadOperation(conversationId: string): number {
|
||||
this.readStateVersion++;
|
||||
this.pendingReadMap.set(conversationId, {
|
||||
timestamp: Date.now(),
|
||||
version: this.readStateVersion,
|
||||
});
|
||||
return this.readStateVersion;
|
||||
}
|
||||
|
||||
endReadOperation(conversationId: string): void {
|
||||
setTimeout(() => {
|
||||
this.pendingReadMap.delete(conversationId);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
isReadOperationPending(conversationId: string): boolean {
|
||||
const record = this.pendingReadMap.get(conversationId);
|
||||
if (!record) return false;
|
||||
return Date.now() - record.timestamp < 5000;
|
||||
}
|
||||
|
||||
getReadStateVersion(): number {
|
||||
return this.readStateVersion;
|
||||
}
|
||||
|
||||
// ==================== 订阅机制 ====================
|
||||
|
||||
subscribe(callback: MessageSubscriber): () => void {
|
||||
this.subscribers.add(callback);
|
||||
return () => this.subscribers.delete(callback);
|
||||
}
|
||||
|
||||
private notify(event: Omit<MessageEvent, 'timestamp'>): void {
|
||||
const fullEvent: MessageEvent = {
|
||||
...event,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
this.subscribers.forEach(callback => {
|
||||
try {
|
||||
callback(fullEvent);
|
||||
} catch (error) {
|
||||
console.error('[MessageStateManager] Subscriber error:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 工具方法 ====================
|
||||
|
||||
private updateUnreadCount(): void {
|
||||
let total = 0;
|
||||
let system = 0;
|
||||
|
||||
for (const conv of this.state.conversations.values()) {
|
||||
total += conv.unreadCount || 0;
|
||||
}
|
||||
|
||||
this.state.unreadCount = { total, system };
|
||||
this.notify({ type: 'unread_count_updated', payload: this.state.unreadCount });
|
||||
}
|
||||
|
||||
private deduplicateMessages(messages: Message[]): Message[] {
|
||||
const seen = new Set<string>();
|
||||
return messages.filter(msg => {
|
||||
if (seen.has(msg.id)) return false;
|
||||
seen.add(msg.id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.state = {
|
||||
conversations: new Map(),
|
||||
conversationList: [],
|
||||
messagesMap: new Map(),
|
||||
unreadCount: { total: 0, system: 0 },
|
||||
isSSEConnected: false,
|
||||
currentConversationId: null,
|
||||
isLoading: false,
|
||||
typingUsersMap: new Map(),
|
||||
mutedStatusMap: new Map(),
|
||||
};
|
||||
this.pendingReadMap.clear();
|
||||
this.readStateVersion = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export const messageStateManager = new MessageStateManager();
|
||||
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();
|
||||
148
src/stores/message/SSEMessageHandler.ts
Normal file
148
src/stores/message/SSEMessageHandler.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* SSE 消息处理器
|
||||
* 只负责处理 SSE 消息,不管理状态
|
||||
*/
|
||||
|
||||
import { sseService, WSChatMessage, WSGroupChatMessage, WSReadMessage, WSGroupReadMessage, WSRecallMessage, WSGroupRecallMessage, WSGroupTypingMessage, WSGroupNoticeMessage, GroupNoticeType, WSMessage } from '../../services/sseService';
|
||||
import type { Message, GroupNotice } from '../../core/entities/Message';
|
||||
|
||||
export type SSEEventType =
|
||||
| 'chat_message'
|
||||
| 'group_message'
|
||||
| 'read_receipt'
|
||||
| 'group_read_receipt'
|
||||
| 'message_recalled'
|
||||
| 'group_message_recalled'
|
||||
| 'typing'
|
||||
| 'group_notice';
|
||||
|
||||
export interface SSEEvent {
|
||||
type: SSEEventType;
|
||||
payload: any;
|
||||
raw: any;
|
||||
}
|
||||
|
||||
export type SSEEventHandler = (event: SSEEvent) => void;
|
||||
|
||||
export class SSEMessageHandler {
|
||||
private unsubscribeFns: Array<() => void> = [];
|
||||
private handlers: Set<SSEEventHandler> = new Set();
|
||||
|
||||
connect(): void {
|
||||
if (this.unsubscribeFns.length > 0) return;
|
||||
|
||||
// 监听私聊消息
|
||||
const unsubChat = sseService.on('chat', (message) => {
|
||||
this.emit('chat_message', this.parseChatMessage(message), message);
|
||||
});
|
||||
|
||||
// 监听群聊消息
|
||||
const unsubGroupMessage = sseService.on('group_message', (message) => {
|
||||
this.emit('group_message', this.parseGroupMessage(message), message);
|
||||
});
|
||||
|
||||
// 监听私聊已读回执
|
||||
const unsubRead = sseService.on('read', (message) => {
|
||||
this.emit('read_receipt', message, message);
|
||||
});
|
||||
|
||||
// 监听群聊已读回执
|
||||
const unsubGroupRead = sseService.on('group_read', (message) => {
|
||||
this.emit('group_read_receipt', message, message);
|
||||
});
|
||||
|
||||
// 监听私聊消息撤回
|
||||
const unsubRecall = sseService.on('recall', (message) => {
|
||||
this.emit('message_recalled', message, message);
|
||||
});
|
||||
|
||||
// 监听群聊消息撤回
|
||||
const unsubGroupRecall = sseService.on('group_recall', (message) => {
|
||||
this.emit('group_message_recalled', message, message);
|
||||
});
|
||||
|
||||
// 监听群聊输入状态
|
||||
const unsubGroupTyping = sseService.on('group_typing', (message) => {
|
||||
this.emit('typing', message, message);
|
||||
});
|
||||
|
||||
// 监听群通知
|
||||
const unsubGroupNotice = sseService.on('group_notice', (message) => {
|
||||
this.emit('group_notice', this.parseGroupNotice(message), message);
|
||||
});
|
||||
|
||||
this.unsubscribeFns = [
|
||||
unsubChat,
|
||||
unsubGroupMessage,
|
||||
unsubRead,
|
||||
unsubGroupRead,
|
||||
unsubRecall,
|
||||
unsubGroupRecall,
|
||||
unsubGroupTyping,
|
||||
unsubGroupNotice,
|
||||
];
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.unsubscribeFns.forEach((fn) => fn());
|
||||
this.unsubscribeFns = [];
|
||||
this.handlers.clear();
|
||||
}
|
||||
|
||||
subscribe(handler: SSEEventHandler): () => void {
|
||||
this.handlers.add(handler);
|
||||
return () => this.handlers.delete(handler);
|
||||
}
|
||||
|
||||
private emit(type: SSEEventType, payload: any, raw: any): void {
|
||||
const event: SSEEvent = { type, payload, raw };
|
||||
this.handlers.forEach(handler => {
|
||||
try {
|
||||
handler(event);
|
||||
} catch (error) {
|
||||
console.error('[SSEMessageHandler] Handler error:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private parseChatMessage(data: WSChatMessage): Message {
|
||||
return {
|
||||
id: data.id || '',
|
||||
conversationId: data.conversation_id || '',
|
||||
senderId: data.sender_id || '',
|
||||
seq: data.seq || 0,
|
||||
segments: data.segments || [],
|
||||
createdAt: data.created_at || new Date().toISOString(),
|
||||
status: 'normal',
|
||||
};
|
||||
}
|
||||
|
||||
private parseGroupMessage(data: WSGroupChatMessage): Message {
|
||||
return {
|
||||
id: data.id || '',
|
||||
conversationId: data.conversation_id || '',
|
||||
senderId: data.sender_id || '',
|
||||
seq: data.seq || 0,
|
||||
segments: data.segments || [],
|
||||
createdAt: data.created_at || new Date().toISOString(),
|
||||
status: 'normal',
|
||||
};
|
||||
}
|
||||
|
||||
private parseGroupNotice(data: WSGroupNoticeMessage): GroupNotice {
|
||||
return {
|
||||
type: data.notice_type || 'member_join',
|
||||
groupId: String(data.group_id || ''),
|
||||
data: data.data || {},
|
||||
timestamp: data.timestamp || Date.now(),
|
||||
messageId: data.message_id,
|
||||
seq: data.seq,
|
||||
};
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return sseService.isConnected();
|
||||
}
|
||||
}
|
||||
|
||||
export const sseMessageHandler = new SSEMessageHandler();
|
||||
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';
|
||||
|
||||
// 导出SSE处理器
|
||||
export { sseMessageHandler, SSEMessageHandler } from './SSEMessageHandler';
|
||||
|
||||
// 导出已读管理器
|
||||
export { readReceiptManager, ReadReceiptManager } from './ReadReceiptManager';
|
||||
@@ -8,7 +8,7 @@
|
||||
*
|
||||
* 架构特点:
|
||||
* - 管理conversations和messages状态
|
||||
* - 统一处理WebSocket消息
|
||||
* - 统一处理SSE消息
|
||||
* - 统一处理本地数据库读写
|
||||
* - 提供订阅机制供React组件使用
|
||||
*/
|
||||
@@ -83,7 +83,7 @@ interface MessageManagerState {
|
||||
systemUnreadCount: number;
|
||||
|
||||
// 连接状态
|
||||
isWebSocketConnected: boolean;
|
||||
isSSEConnected: boolean;
|
||||
|
||||
// 当前活动会话ID(用户正在查看的会话)
|
||||
currentConversationId: string | null;
|
||||
@@ -131,7 +131,7 @@ interface ReadStateRecord {
|
||||
|
||||
class MessageManager {
|
||||
private state: MessageManagerState;
|
||||
private wsUnsubscribe: (() => void) | null = null;
|
||||
private sseUnsubscribe: (() => void) | null = null;
|
||||
private currentUserId: string | null = null;
|
||||
private authUnsubscribe: (() => void) | null = null;
|
||||
private lastReconnectSyncAt: number = 0;
|
||||
@@ -158,7 +158,7 @@ class MessageManager {
|
||||
messagesMap: new Map(),
|
||||
totalUnreadCount: 0,
|
||||
systemUnreadCount: 0,
|
||||
isWebSocketConnected: false,
|
||||
isSSEConnected: false,
|
||||
currentConversationId: null,
|
||||
isLoadingConversations: false,
|
||||
loadingMessagesSet: new Set(),
|
||||
@@ -281,8 +281,8 @@ class MessageManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 初始化WebSocket监听
|
||||
this.initWebSocketListeners();
|
||||
// 2. 初始化SSE监听
|
||||
this.initSSEListeners();
|
||||
|
||||
// 3. 加载会话列表
|
||||
await this.fetchConversations();
|
||||
@@ -320,9 +320,9 @@ class MessageManager {
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.wsUnsubscribe) {
|
||||
this.wsUnsubscribe();
|
||||
this.wsUnsubscribe = null;
|
||||
if (this.sseUnsubscribe) {
|
||||
this.sseUnsubscribe();
|
||||
this.sseUnsubscribe = null;
|
||||
}
|
||||
this.state.subscribers.clear();
|
||||
this.state.isInitialized = false;
|
||||
@@ -330,11 +330,11 @@ class MessageManager {
|
||||
this.activatingConversationTasks.clear();
|
||||
}
|
||||
|
||||
// ==================== WebSocket 处理 ====================
|
||||
// ==================== SSE 处理 ====================
|
||||
|
||||
private initWebSocketListeners(): void {
|
||||
if (this.wsUnsubscribe) {
|
||||
this.wsUnsubscribe();
|
||||
private initSSEListeners(): void {
|
||||
if (this.sseUnsubscribe) {
|
||||
this.sseUnsubscribe();
|
||||
}
|
||||
|
||||
|
||||
@@ -380,7 +380,7 @@ class MessageManager {
|
||||
|
||||
// 监听连接状态
|
||||
sseService.onConnect(() => {
|
||||
this.state.isWebSocketConnected = true;
|
||||
this.state.isSSEConnected = true;
|
||||
this.notifySubscribers({
|
||||
type: 'connection_changed',
|
||||
payload: { connected: true },
|
||||
@@ -403,7 +403,7 @@ class MessageManager {
|
||||
});
|
||||
|
||||
sseService.onDisconnect(() => {
|
||||
this.state.isWebSocketConnected = false;
|
||||
this.state.isSSEConnected = false;
|
||||
this.notifySubscribers({
|
||||
type: 'connection_changed',
|
||||
payload: { connected: false },
|
||||
@@ -585,7 +585,7 @@ class MessageManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理新消息(WebSocket推送)
|
||||
* 处理新消息(SSE推送)
|
||||
* 这是核心方法,必须立即处理并同步到所有订阅者
|
||||
*/
|
||||
private async handleNewMessage(message: (WSChatMessage | WSGroupChatMessage) & { _isAck?: boolean }): Promise<void> {
|
||||
@@ -2014,10 +2014,10 @@ class MessageManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取WebSocket连接状态
|
||||
* 获取SSE连接状态
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.state.isWebSocketConnected;
|
||||
return this.state.isSSEConnected;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2122,7 +2122,7 @@ class MessageManager {
|
||||
|
||||
subscriber({
|
||||
type: 'connection_changed',
|
||||
payload: { connected: this.state.isWebSocketConnected },
|
||||
payload: { connected: this.state.isSSEConnected },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '../services';
|
||||
import { userManager } from './userManager';
|
||||
import { messageManager } from './messageManager';
|
||||
import { createOptimisticUpdaterFactory } from '../utils/optimisticUpdate';
|
||||
|
||||
interface UserState {
|
||||
// 状态
|
||||
@@ -59,504 +60,386 @@ interface UserState {
|
||||
refreshAll: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useUserStore = create<UserState>((set, get) => ({
|
||||
// 初始状态
|
||||
users: [],
|
||||
userCache: {},
|
||||
posts: [],
|
||||
notifications: [],
|
||||
notificationBadge: {
|
||||
total: 0,
|
||||
likes: 0,
|
||||
comments: 0,
|
||||
follows: 0,
|
||||
system: 0,
|
||||
},
|
||||
messageUnreadCount: 0,
|
||||
searchHistory: [],
|
||||
isLoadingPosts: false,
|
||||
isLoadingNotifications: false,
|
||||
export const useUserStore = create<UserState>((set, get) => {
|
||||
// 创建乐观更新器
|
||||
const optimisticUpdater = createOptimisticUpdaterFactory<UserState>(set, get);
|
||||
|
||||
// 获取用户信息
|
||||
fetchUser: async (userId: string) => {
|
||||
// 先检查缓存
|
||||
const cached = get().userCache[userId];
|
||||
if (cached) return cached;
|
||||
return {
|
||||
// 初始状态
|
||||
users: [],
|
||||
userCache: {},
|
||||
posts: [],
|
||||
notifications: [],
|
||||
notificationBadge: {
|
||||
total: 0,
|
||||
likes: 0,
|
||||
comments: 0,
|
||||
follows: 0,
|
||||
system: 0,
|
||||
},
|
||||
messageUnreadCount: 0,
|
||||
searchHistory: [],
|
||||
isLoadingPosts: false,
|
||||
isLoadingNotifications: false,
|
||||
|
||||
// 获取用户信息
|
||||
fetchUser: async (userId: string) => {
|
||||
// 先检查缓存
|
||||
const cached = get().userCache[userId];
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const user = await userManager.getUserById(userId);
|
||||
if (user) {
|
||||
set(state => ({
|
||||
userCache: { ...state.userCache, [userId]: user }
|
||||
}));
|
||||
return user;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取用户信息失败:', error);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
// 获取用户帖子
|
||||
fetchUserPosts: async (userId: string, page = 1) => {
|
||||
try {
|
||||
const response = await postService.getUserPosts(userId, page);
|
||||
const newPosts = response.list;
|
||||
|
||||
try {
|
||||
const user = await userManager.getUserById(userId);
|
||||
if (user) {
|
||||
set(state => ({
|
||||
userCache: { ...state.userCache, [userId]: user }
|
||||
posts: page === 1 ? newPosts : [...state.posts, ...newPosts]
|
||||
}));
|
||||
return user;
|
||||
|
||||
return newPosts;
|
||||
} catch (error) {
|
||||
console.error('获取用户帖子失败:', error);
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取用户信息失败:', error);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
|
||||
// 获取用户帖子
|
||||
fetchUserPosts: async (userId: string, page = 1) => {
|
||||
try {
|
||||
const response = await postService.getUserPosts(userId, page);
|
||||
const newPosts = response.list;
|
||||
// 获取帖子列表(首页)
|
||||
fetchPosts: async (type: 'recommend' | 'follow' | 'hot' | 'latest', page = 1) => {
|
||||
set({ isLoadingPosts: true });
|
||||
|
||||
set(state => ({
|
||||
posts: page === 1 ? newPosts : [...state.posts, ...newPosts]
|
||||
}));
|
||||
try {
|
||||
let response;
|
||||
|
||||
return newPosts;
|
||||
} catch (error) {
|
||||
console.error('获取用户帖子失败:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
switch (type) {
|
||||
case 'recommend':
|
||||
response = await postService.getRecommendedPosts(page);
|
||||
break;
|
||||
case 'follow':
|
||||
response = await postService.getFollowingPosts(page);
|
||||
break;
|
||||
case 'hot':
|
||||
response = await postService.getHotPosts(page);
|
||||
break;
|
||||
case 'latest':
|
||||
default:
|
||||
response = await postService.getLatestPosts(page);
|
||||
break;
|
||||
}
|
||||
|
||||
// 获取帖子列表(首页)
|
||||
fetchPosts: async (type: 'recommend' | 'follow' | 'hot' | 'latest', page = 1) => {
|
||||
set({ isLoadingPosts: true });
|
||||
const newPosts = response.list;
|
||||
|
||||
try {
|
||||
let response;
|
||||
set(state => ({
|
||||
posts: page === 1 ? newPosts : [...state.posts, ...newPosts],
|
||||
isLoadingPosts: false
|
||||
}));
|
||||
|
||||
switch (type) {
|
||||
case 'recommend':
|
||||
response = await postService.getRecommendedPosts(page);
|
||||
break;
|
||||
case 'follow':
|
||||
response = await postService.getFollowingPosts(page);
|
||||
break;
|
||||
case 'hot':
|
||||
response = await postService.getHotPosts(page);
|
||||
break;
|
||||
case 'latest':
|
||||
default:
|
||||
response = await postService.getLatestPosts(page);
|
||||
break;
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('获取帖子列表失败:', error);
|
||||
set({ isLoadingPosts: false });
|
||||
return {
|
||||
list: [],
|
||||
total: 0,
|
||||
page,
|
||||
page_size: 20,
|
||||
total_pages: 0,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
const newPosts = response.list;
|
||||
// 获取通知列表
|
||||
fetchNotifications: async (type?: string, page = 1) => {
|
||||
set({ isLoadingNotifications: true });
|
||||
|
||||
set(state => ({
|
||||
posts: page === 1 ? newPosts : [...state.posts, ...newPosts],
|
||||
isLoadingPosts: false
|
||||
}));
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('获取帖子列表失败:', error);
|
||||
set({ isLoadingPosts: false });
|
||||
return {
|
||||
list: [],
|
||||
total: 0,
|
||||
page,
|
||||
page_size: 20,
|
||||
total_pages: 0,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
// 获取通知列表
|
||||
fetchNotifications: async (type?: string, page = 1) => {
|
||||
set({ isLoadingNotifications: true });
|
||||
|
||||
try {
|
||||
const response = await notificationService.getNotifications(
|
||||
page,
|
||||
20,
|
||||
type as any
|
||||
);
|
||||
|
||||
const newNotifications = response.list;
|
||||
|
||||
set(state => ({
|
||||
notifications: page === 1 ? newNotifications : [...state.notifications, ...newNotifications],
|
||||
isLoadingNotifications: false
|
||||
}));
|
||||
|
||||
return newNotifications;
|
||||
} catch (error) {
|
||||
console.error('获取通知列表失败:', error);
|
||||
set({ isLoadingNotifications: false });
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
// 标记通知为已读
|
||||
markNotificationAsRead: async (notificationId: string) => {
|
||||
try {
|
||||
await notificationService.markAsRead(notificationId);
|
||||
|
||||
set(state => {
|
||||
const notifications = state.notifications.map(n =>
|
||||
n.id === notificationId ? { ...n, isRead: true } : n
|
||||
try {
|
||||
const response = await notificationService.getNotifications(
|
||||
page,
|
||||
20,
|
||||
type as any
|
||||
);
|
||||
|
||||
return {
|
||||
notifications,
|
||||
const newNotifications = response.list;
|
||||
|
||||
set(state => ({
|
||||
notifications: page === 1 ? newNotifications : [...state.notifications, ...newNotifications],
|
||||
isLoadingNotifications: false
|
||||
}));
|
||||
|
||||
return newNotifications;
|
||||
} catch (error) {
|
||||
console.error('获取通知列表失败:', error);
|
||||
set({ isLoadingNotifications: false });
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
// 标记通知为已读
|
||||
markNotificationAsRead: async (notificationId: string) => {
|
||||
try {
|
||||
await notificationService.markAsRead(notificationId);
|
||||
|
||||
set(state => {
|
||||
const notifications = state.notifications.map(n =>
|
||||
n.id === notificationId ? { ...n, isRead: true } : n
|
||||
);
|
||||
|
||||
return {
|
||||
notifications,
|
||||
notificationBadge: {
|
||||
total: notifications.filter(n => !n.isRead).length,
|
||||
likes: notifications.filter(n => n.type === 'like_post' && !n.isRead).length,
|
||||
comments: notifications.filter(n => n.type === 'comment' && !n.isRead).length,
|
||||
follows: notifications.filter(n => n.type === 'follow' && !n.isRead).length,
|
||||
system: notifications.filter(n => n.type === 'system' && !n.isRead).length,
|
||||
}
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('标记通知为已读失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 标记所有通知为已读
|
||||
markAllNotificationsAsRead: async () => {
|
||||
try {
|
||||
await notificationService.markAllAsRead();
|
||||
|
||||
set(state => ({
|
||||
notifications: state.notifications.map(n => ({ ...n, isRead: true })),
|
||||
notificationBadge: {
|
||||
total: notifications.filter(n => !n.isRead).length,
|
||||
likes: notifications.filter(n => n.type === 'like_post' && !n.isRead).length,
|
||||
comments: notifications.filter(n => n.type === 'comment' && !n.isRead).length,
|
||||
follows: notifications.filter(n => n.type === 'follow' && !n.isRead).length,
|
||||
system: notifications.filter(n => n.type === 'system' && !n.isRead).length,
|
||||
total: 0,
|
||||
likes: 0,
|
||||
comments: 0,
|
||||
follows: 0,
|
||||
system: 0,
|
||||
}
|
||||
};
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('标记所有通知为已读失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 获取通知角标
|
||||
fetchNotificationBadge: async () => {
|
||||
try {
|
||||
const badge = await notificationService.getNotificationBadge();
|
||||
set({ notificationBadge: badge });
|
||||
} catch (error) {
|
||||
console.error('获取通知角标失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 设置消息未读数
|
||||
// 注意:未读数现在由 MessageManager 统一管理
|
||||
// 此方法保留用于 TabBar 角标显示,从 MessageManager 同步
|
||||
setMessageUnreadCount: (count: number) => {
|
||||
set({ messageUnreadCount: count });
|
||||
},
|
||||
|
||||
// 从后端拉取最新消息未读数
|
||||
// @deprecated 请使用 messageManager.fetchUnreadCount() 代替
|
||||
// 此方法保留用于向后兼容
|
||||
fetchMessageUnreadCount: async () => {
|
||||
try {
|
||||
await messageManager.fetchUnreadCount();
|
||||
const unread = messageManager.getUnreadCount();
|
||||
set({ messageUnreadCount: unread.total + unread.system });
|
||||
} catch (error) {
|
||||
console.error('获取消息未读数失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 添加搜索历史
|
||||
addSearchHistory: (keyword: string) => {
|
||||
set(state => {
|
||||
const history = [keyword, ...state.searchHistory.filter(k => k !== keyword)].slice(0, 20);
|
||||
return { searchHistory: history };
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('标记通知为已读失败:', error);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// 标记所有通知为已读
|
||||
markAllNotificationsAsRead: async () => {
|
||||
try {
|
||||
await notificationService.markAllAsRead();
|
||||
// 清除搜索历史
|
||||
clearSearchHistory: () => {
|
||||
set({ searchHistory: [] });
|
||||
},
|
||||
|
||||
set(state => ({
|
||||
notifications: state.notifications.map(n => ({ ...n, isRead: true })),
|
||||
notificationBadge: {
|
||||
total: 0,
|
||||
likes: 0,
|
||||
comments: 0,
|
||||
follows: 0,
|
||||
system: 0,
|
||||
}
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('标记所有通知为已读失败:', error);
|
||||
}
|
||||
},
|
||||
// 点赞帖子 - 使用乐观更新工具
|
||||
likePost: async (postId: string) => {
|
||||
const originalPosts = get().posts;
|
||||
|
||||
// 获取通知角标
|
||||
fetchNotificationBadge: async () => {
|
||||
try {
|
||||
const badge = await notificationService.getNotificationBadge();
|
||||
set({ notificationBadge: badge });
|
||||
} catch (error) {
|
||||
console.error('获取通知角标失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 设置消息未读数
|
||||
// 注意:未读数现在由 MessageManager 统一管理
|
||||
// 此方法保留用于 TabBar 角标显示,从 MessageManager 同步
|
||||
setMessageUnreadCount: (count: number) => {
|
||||
set({ messageUnreadCount: count });
|
||||
},
|
||||
|
||||
// 从后端拉取最新消息未读数
|
||||
// @deprecated 请使用 messageManager.fetchUnreadCount() 代替
|
||||
// 此方法保留用于向后兼容
|
||||
fetchMessageUnreadCount: async () => {
|
||||
try {
|
||||
await messageManager.fetchUnreadCount();
|
||||
const unread = messageManager.getUnreadCount();
|
||||
set({ messageUnreadCount: unread.total + unread.system });
|
||||
} catch (error) {
|
||||
console.error('获取消息未读数失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 添加搜索历史
|
||||
addSearchHistory: (keyword: string) => {
|
||||
set(state => {
|
||||
const history = [keyword, ...state.searchHistory.filter(k => k !== keyword)].slice(0, 20);
|
||||
return { searchHistory: history };
|
||||
});
|
||||
},
|
||||
|
||||
// 清除搜索历史
|
||||
clearSearchHistory: () => {
|
||||
set({ searchHistory: [] });
|
||||
},
|
||||
|
||||
// 点赞帖子 - 乐观更新
|
||||
likePost: async (postId: string) => {
|
||||
// 先乐观更新本地状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_liked: true, likes_count: p.likes_count + 1 } : p
|
||||
)
|
||||
}));
|
||||
|
||||
// 调用API
|
||||
try {
|
||||
const updatedPost = await postService.likePost(postId);
|
||||
if (updatedPost) {
|
||||
// 使用后端返回的更新后数据更新状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? updatedPost : p
|
||||
)
|
||||
}));
|
||||
} else {
|
||||
// API 返回失败,回滚状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_liked: false, likes_count: Math.max(0, p.likes_count - 1) } : p
|
||||
)
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('点赞帖子失败:', error);
|
||||
// 失败回滚状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_liked: false, likes_count: Math.max(0, p.likes_count - 1) } : p
|
||||
)
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
// 取消点赞 - 乐观更新
|
||||
unlikePost: async (postId: string) => {
|
||||
// 先乐观更新本地状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_liked: false, likes_count: Math.max(0, p.likes_count - 1) } : p
|
||||
)
|
||||
}));
|
||||
|
||||
// 调用API
|
||||
try {
|
||||
const updatedPost = await postService.unlikePost(postId);
|
||||
if (updatedPost) {
|
||||
// 使用后端返回的更新后数据更新状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? updatedPost : p
|
||||
)
|
||||
}));
|
||||
} else {
|
||||
// API 返回失败,回滚状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_liked: true, likes_count: p.likes_count + 1 } : p
|
||||
)
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('取消点赞帖子失败:', error);
|
||||
// 失败回滚状态
|
||||
// 乐观更新
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_liked: true, likes_count: p.likes_count + 1 } : p
|
||||
)
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
// 收藏帖子 - 乐观更新
|
||||
favoritePost: async (postId: string) => {
|
||||
// 先乐观更新本地状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_favorited: true, favorites_count: p.favorites_count + 1 } : p
|
||||
)
|
||||
}));
|
||||
|
||||
// 调用API
|
||||
try {
|
||||
const updatedPost = await postService.favoritePost(postId);
|
||||
if (updatedPost) {
|
||||
// 使用后端返回的更新后数据更新状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? updatedPost : p
|
||||
)
|
||||
}));
|
||||
} else {
|
||||
// API 返回失败,回滚状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_favorited: false, favorites_count: Math.max(0, p.favorites_count - 1) } : p
|
||||
)
|
||||
}));
|
||||
try {
|
||||
const updatedPost = await postService.likePost(postId);
|
||||
if (updatedPost) {
|
||||
// 使用服务器返回的数据更新
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? updatedPost : p
|
||||
)
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('点赞帖子失败:', error);
|
||||
// 回滚
|
||||
set({ posts: originalPosts });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('收藏帖子失败:', error);
|
||||
// 失败回滚状态
|
||||
},
|
||||
|
||||
// 取消点赞 - 使用乐观更新工具
|
||||
unlikePost: async (postId: string) => {
|
||||
const originalPosts = get().posts;
|
||||
|
||||
// 乐观更新
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_favorited: false, favorites_count: Math.max(0, p.favorites_count - 1) } : p
|
||||
p.id === postId ? { ...p, is_liked: false, likes_count: Math.max(0, p.likes_count - 1) } : p
|
||||
)
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
// 取消收藏 - 乐观更新
|
||||
unfavoritePost: async (postId: string) => {
|
||||
// 先乐观更新本地状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_favorited: false, favorites_count: Math.max(0, p.favorites_count - 1) } : p
|
||||
)
|
||||
}));
|
||||
|
||||
// 调用API
|
||||
try {
|
||||
const updatedPost = await postService.unfavoritePost(postId);
|
||||
if (updatedPost) {
|
||||
// 使用后端返回的更新后数据更新状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? updatedPost : p
|
||||
)
|
||||
}));
|
||||
} else {
|
||||
// API 返回失败,回滚状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_favorited: true, favorites_count: p.favorites_count + 1 } : p
|
||||
)
|
||||
}));
|
||||
try {
|
||||
const updatedPost = await postService.unlikePost(postId);
|
||||
if (updatedPost) {
|
||||
// 使用服务器返回的数据更新
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? updatedPost : p
|
||||
)
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('取消点赞帖子失败:', error);
|
||||
// 回滚
|
||||
set({ posts: originalPosts });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('取消收藏帖子失败:', error);
|
||||
// 失败回滚状态
|
||||
},
|
||||
|
||||
// 收藏帖子 - 使用乐观更新工具
|
||||
favoritePost: async (postId: string) => {
|
||||
const originalPosts = get().posts;
|
||||
|
||||
// 乐观更新
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_favorited: true, favorites_count: p.favorites_count + 1 } : p
|
||||
)
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
// 点赞评论
|
||||
likeComment: async (commentId: string) => {
|
||||
// 先更新本地状态(更新posts中的帖子的评论点赞状态)
|
||||
set(state => ({
|
||||
posts: state.posts.map(p => ({
|
||||
...p,
|
||||
top_comment: p.top_comment && p.top_comment.id === commentId
|
||||
? { ...p.top_comment, is_liked: true, likes_count: p.top_comment.likes_count + 1 }
|
||||
: p.top_comment
|
||||
}))
|
||||
}));
|
||||
try {
|
||||
const updatedPost = await postService.favoritePost(postId);
|
||||
if (updatedPost) {
|
||||
// 使用服务器返回的数据更新
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? updatedPost : p
|
||||
)
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('收藏帖子失败:', error);
|
||||
// 回滚
|
||||
set({ posts: originalPosts });
|
||||
}
|
||||
},
|
||||
|
||||
// 调用API
|
||||
try {
|
||||
await commentService.likeComment(commentId);
|
||||
} catch (error) {
|
||||
console.error('点赞评论失败:', error);
|
||||
// 失败回滚状态
|
||||
// 取消收藏 - 使用乐观更新工具
|
||||
unfavoritePost: async (postId: string) => {
|
||||
const originalPosts = get().posts;
|
||||
|
||||
// 乐观更新
|
||||
set(state => ({
|
||||
posts: state.posts.map(p => ({
|
||||
...p,
|
||||
top_comment: p.top_comment && p.top_comment.id === commentId
|
||||
? { ...p.top_comment, is_liked: false, likes_count: Math.max(0, p.top_comment.likes_count - 1) }
|
||||
: p.top_comment
|
||||
}))
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
// 取消点赞评论
|
||||
unlikeComment: async (commentId: string) => {
|
||||
// 先更新本地状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p => ({
|
||||
...p,
|
||||
top_comment: p.top_comment && p.top_comment.id === commentId
|
||||
? { ...p.top_comment, is_liked: false, likes_count: Math.max(0, p.top_comment.likes_count - 1) }
|
||||
: p.top_comment
|
||||
}))
|
||||
}));
|
||||
|
||||
// 调用API
|
||||
try {
|
||||
await commentService.unlikeComment(commentId);
|
||||
} catch (error) {
|
||||
console.error('取消点赞评论失败:', error);
|
||||
// 失败回滚状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p => ({
|
||||
...p,
|
||||
top_comment: p.top_comment && p.top_comment.id === commentId
|
||||
? { ...p.top_comment, is_liked: true, likes_count: p.top_comment.likes_count + 1 }
|
||||
: p.top_comment
|
||||
}))
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
// 关注用户
|
||||
followUser: async (userId: string) => {
|
||||
// 先更新本地状态
|
||||
set(state => ({
|
||||
users: state.users.map(u =>
|
||||
u.id === userId
|
||||
? { ...u, isFollowing: true, followersCount: u.followers_count + 1 }
|
||||
: u
|
||||
),
|
||||
userCache: Object.fromEntries(
|
||||
Object.entries(state.userCache).map(([id, user]) => [
|
||||
id,
|
||||
id === userId
|
||||
? { ...user, isFollowing: true, followersCount: user.followers_count + 1 }
|
||||
: user
|
||||
])
|
||||
)
|
||||
}));
|
||||
|
||||
// 调用API
|
||||
try {
|
||||
await authService.followUser(userId);
|
||||
} catch (error) {
|
||||
console.error('关注用户失败:', error);
|
||||
// 失败回滚状态
|
||||
set(state => ({
|
||||
users: state.users.map(u =>
|
||||
u.id === userId
|
||||
? { ...u, isFollowing: false, followersCount: Math.max(0, u.followers_count - 1) }
|
||||
: u
|
||||
),
|
||||
userCache: Object.fromEntries(
|
||||
Object.entries(state.userCache).map(([id, user]) => [
|
||||
id,
|
||||
id === userId
|
||||
? { ...user, isFollowing: false, followersCount: Math.max(0, user.followers_count - 1) }
|
||||
: user
|
||||
])
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_favorited: false, favorites_count: Math.max(0, p.favorites_count - 1) } : p
|
||||
)
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
// 取消关注
|
||||
unfollowUser: async (userId: string) => {
|
||||
// 先更新本地状态
|
||||
set(state => ({
|
||||
users: state.users.map(u =>
|
||||
u.id === userId
|
||||
? { ...u, isFollowing: false, followersCount: Math.max(0, u.followers_count - 1) }
|
||||
: u
|
||||
),
|
||||
userCache: Object.fromEntries(
|
||||
Object.entries(state.userCache).map(([id, user]) => [
|
||||
id,
|
||||
id === userId
|
||||
? { ...user, isFollowing: false, followersCount: Math.max(0, user.followers_count - 1) }
|
||||
: user
|
||||
])
|
||||
)
|
||||
}));
|
||||
try {
|
||||
const updatedPost = await postService.unfavoritePost(postId);
|
||||
if (updatedPost) {
|
||||
// 使用服务器返回的数据更新
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? updatedPost : p
|
||||
)
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('取消收藏帖子失败:', error);
|
||||
// 回滚
|
||||
set({ posts: originalPosts });
|
||||
}
|
||||
},
|
||||
|
||||
// 调用API
|
||||
try {
|
||||
await authService.unfollowUser(userId);
|
||||
} catch (error) {
|
||||
console.error('取消关注用户失败:', error);
|
||||
// 失败回滚状态
|
||||
// 点赞评论 - 使用乐观更新工具
|
||||
likeComment: async (commentId: string) => {
|
||||
await optimisticUpdater({
|
||||
key: 'posts',
|
||||
optimisticUpdate: (posts) =>
|
||||
posts.map(p => ({
|
||||
...p,
|
||||
top_comment: p.top_comment && p.top_comment.id === commentId
|
||||
? { ...p.top_comment, is_liked: true, likes_count: p.top_comment.likes_count + 1 }
|
||||
: p.top_comment
|
||||
})),
|
||||
rollbackState: (original) => original,
|
||||
apiCall: () => commentService.likeComment(commentId),
|
||||
errorMessage: '点赞评论失败',
|
||||
});
|
||||
},
|
||||
|
||||
// 取消点赞评论 - 使用乐观更新工具
|
||||
unlikeComment: async (commentId: string) => {
|
||||
await optimisticUpdater({
|
||||
key: 'posts',
|
||||
optimisticUpdate: (posts) =>
|
||||
posts.map(p => ({
|
||||
...p,
|
||||
top_comment: p.top_comment && p.top_comment.id === commentId
|
||||
? { ...p.top_comment, is_liked: false, likes_count: Math.max(0, p.top_comment.likes_count - 1) }
|
||||
: p.top_comment
|
||||
})),
|
||||
rollbackState: (original) => original,
|
||||
apiCall: () => commentService.unlikeComment(commentId),
|
||||
errorMessage: '取消点赞评论失败',
|
||||
});
|
||||
},
|
||||
|
||||
// 关注用户 - 使用乐观更新工具
|
||||
followUser: async (userId: string) => {
|
||||
const originalUsers = get().users;
|
||||
const originalUserCache = get().userCache;
|
||||
|
||||
// 乐观更新 users
|
||||
set(state => ({
|
||||
users: state.users.map(u =>
|
||||
u.id === userId
|
||||
? { ...u, isFollowing: true, followersCount: u.followers_count + 1 }
|
||||
: u
|
||||
),
|
||||
}));
|
||||
|
||||
// 乐观更新 userCache
|
||||
set(state => ({
|
||||
userCache: Object.fromEntries(
|
||||
Object.entries(state.userCache).map(([id, user]) => [
|
||||
id,
|
||||
@@ -566,17 +449,66 @@ export const useUserStore = create<UserState>((set, get) => ({
|
||||
])
|
||||
)
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
// 刷新所有数据
|
||||
refreshAll: async () => {
|
||||
await Promise.all([
|
||||
get().fetchPosts('recommend', 1),
|
||||
get().fetchNotificationBadge(),
|
||||
]);
|
||||
},
|
||||
}));
|
||||
try {
|
||||
await authService.followUser(userId);
|
||||
} catch (error) {
|
||||
console.error('关注用户失败:', error);
|
||||
// 回滚
|
||||
set({
|
||||
users: originalUsers,
|
||||
userCache: originalUserCache
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 取消关注 - 使用乐观更新工具
|
||||
unfollowUser: async (userId: string) => {
|
||||
const originalUsers = get().users;
|
||||
const originalUserCache = get().userCache;
|
||||
|
||||
// 乐观更新 users
|
||||
set(state => ({
|
||||
users: state.users.map(u =>
|
||||
u.id === userId
|
||||
? { ...u, isFollowing: false, followersCount: Math.max(0, u.followers_count - 1) }
|
||||
: u
|
||||
),
|
||||
}));
|
||||
|
||||
// 乐观更新 userCache
|
||||
set(state => ({
|
||||
userCache: Object.fromEntries(
|
||||
Object.entries(state.userCache).map(([id, user]) => [
|
||||
id,
|
||||
id === userId
|
||||
? { ...user, isFollowing: false, followersCount: Math.max(0, user.followers_count - 1) }
|
||||
: user
|
||||
])
|
||||
)
|
||||
}));
|
||||
|
||||
try {
|
||||
await authService.unfollowUser(userId);
|
||||
} catch (error) {
|
||||
console.error('取消关注用户失败:', error);
|
||||
// 回滚
|
||||
set({
|
||||
users: originalUsers,
|
||||
userCache: originalUserCache
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 刷新所有数据
|
||||
refreshAll: async () => {
|
||||
await Promise.all([
|
||||
get().fetchPosts('recommend', 1),
|
||||
get().fetchNotificationBadge(),
|
||||
]);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// 导出selector hooks以优化性能
|
||||
export const usePosts = () => useUserStore((state) => state.posts);
|
||||
|
||||
@@ -672,28 +672,28 @@ export interface GroupAnnouncementListResponse {
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
// ==================== WebSocket Event Types ====================
|
||||
// ==================== SSE Event Types ====================
|
||||
|
||||
// WebSocket 事件类型
|
||||
export type WSEventType = 'message' | 'notice' | 'request' | 'meta';
|
||||
// SSE 事件类型
|
||||
export type SSEEventType = 'message' | 'notice' | 'request' | 'meta';
|
||||
|
||||
// WebSocket 消息详细类型
|
||||
export type WSDetailType = 'private' | 'group' | 'follow' | 'like' | 'comment' | 'request' | 'typing' | 'heartbeat' | 'read';
|
||||
// SSE 消息详细类型
|
||||
export type SSEDetailType = 'private' | 'group' | 'follow' | 'like' | 'comment' | 'request' | 'typing' | 'heartbeat' | 'read';
|
||||
|
||||
// WebSocket 事件消息段(与后端 message 字段一致)
|
||||
export interface WSSegment {
|
||||
// SSE 事件消息段(与后端 message 字段一致)
|
||||
export interface SSESegment {
|
||||
type: string;
|
||||
data: Record<string, any>;
|
||||
}
|
||||
|
||||
// WebSocket 事件(后端推送的事件格式)
|
||||
export interface WSEvent {
|
||||
// SSE 事件(后端推送的事件格式)
|
||||
export interface SSEEvent {
|
||||
id: string; // 事件唯一ID
|
||||
time: number; // 事件时间戳(毫秒)
|
||||
type: WSEventType; // 事件类型: message(消息), notice(通知), request(请求), meta(元事件)
|
||||
detail_type: WSDetailType; // 详细类型: private(私聊), group(群聊), follow(关注), like(点赞)等
|
||||
type: SSEEventType; // 事件类型: message(消息), notice(通知), request(请求), meta(元事件)
|
||||
detail_type: SSEDetailType; // 详细类型: private(私聊), group(群聊), follow(关注), like(点赞)等
|
||||
seq: string; // 消息序号
|
||||
message?: WSSegment[]; // 消息内容数组
|
||||
message?: SSESegment[]; // 消息内容数组
|
||||
conversation_id: string; // 会话ID
|
||||
}
|
||||
|
||||
|
||||
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