From d273569911924a00e5704878421a07b14c12b6be Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Sat, 21 Mar 2026 20:55:36 +0800 Subject: [PATCH] refactor: migrate post operations to use case architecture with differential updates Migrate post-related operations from direct service/store calls to a new use case architecture featuring differential updates. Key changes include: (1) introduce ProcessPostUseCase to handle like, unlike, favorite, unfavorite, delete, and fetch operations with proper state management, (2) add useDifferentialPosts hook for efficient list rendering with minimal re-renders, (3) add PostDiffCalculator and PostUpdateBatcher in infrastructure layer for incremental updates, (4) deprecate NotificationItem component in favor of SystemMessageItem, (5) remove deprecated interactive methods from userStore, (6) add posts_cache table to local datasource, (7) clean up legacy MessageDTO and ConversationDTO types, (8) remove deprecated hook useBreakpointCheck, (9) refactor MessageBubble to use measureInWindow directly for better React Native compatibility --- plans/architecture-comparison-report.md | 394 ++++++++ src/components/business/NotificationItem.tsx | 147 --- src/components/business/index.ts | 1 - src/core/entities/Post.ts | 267 ++++++ src/core/usecases/ProcessPostUseCase.ts | 863 ++++++++++++++++++ src/data/datasources/LocalDataSource.ts | 6 + src/data/repositories/PostRepository.ts | 550 +++++++++++ .../interfaces/IPostRepository.ts | 190 ++++ src/hooks/index.ts | 16 + src/hooks/useDifferentialPosts.ts | 635 +++++++++++++ src/infrastructure/diff/PostDiffCalculator.ts | 430 +++++++++ src/infrastructure/diff/PostUpdateBatcher.ts | 505 ++++++++++ src/infrastructure/diff/index.ts | 46 +- src/infrastructure/diff/postTypes.ts | 325 +++++++ .../hooks/responsive/useBreakpointCheck.ts | 7 - src/screens/home/HomeScreen.tsx | 137 ++- src/screens/home/PostDetailScreen.tsx | 63 +- src/screens/message/MessageListScreen.tsx | 2 +- src/screens/message/NotificationsScreen.tsx | 10 +- .../components/ChatScreen/MessageBubble.tsx | 29 +- .../message/components/ChatScreen/types.ts | 2 - src/screens/profile/ProfileScreen.tsx | 12 +- src/screens/profile/UserScreen.tsx | 10 +- src/services/groupService.ts | 11 - src/services/messageService.ts | 26 - src/services/postService.ts | 42 +- src/stores/messageManagerHooks.ts | 5 +- src/stores/userStore.ts | 177 +--- src/types/dto.ts | 31 - src/types/index.ts | 28 - 30 files changed, 4395 insertions(+), 572 deletions(-) create mode 100644 plans/architecture-comparison-report.md delete mode 100644 src/components/business/NotificationItem.tsx create mode 100644 src/core/entities/Post.ts create mode 100644 src/core/usecases/ProcessPostUseCase.ts create mode 100644 src/data/repositories/PostRepository.ts create mode 100644 src/data/repositories/interfaces/IPostRepository.ts create mode 100644 src/hooks/useDifferentialPosts.ts create mode 100644 src/infrastructure/diff/PostDiffCalculator.ts create mode 100644 src/infrastructure/diff/PostUpdateBatcher.ts create mode 100644 src/infrastructure/diff/postTypes.ts delete mode 100644 src/presentation/hooks/responsive/useBreakpointCheck.ts diff --git a/plans/architecture-comparison-report.md b/plans/architecture-comparison-report.md new file mode 100644 index 0000000..16db566 --- /dev/null +++ b/plans/architecture-comparison-report.md @@ -0,0 +1,394 @@ +# Messages模块与PostService/Manager架构对比分析报告 + +## 一、Messages模块架构分析 + +### 1.1 架构层次结构 + +Messages模块采用了**清晰的分层架构**,各层职责明确: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 表现层 (Screens) │ +│ ChatScreen, MessageListScreen, NotificationsScreen │ +├─────────────────────────────────────────────────────────────┤ +│ Hooks层 │ +│ useDifferentialMessages, useChatScreen │ +├─────────────────────────────────────────────────────────────┤ +│ 用例层 (UseCases) │ +│ ProcessMessageUseCase │ +├─────────────────────────────────────────────────────────────┤ +│ 领域层 (Entities) │ +│ Message, Conversation, GroupNotice │ +├─────────────────────────────────────────────────────────────┤ +│ 仓库层 (Repositories) │ +│ MessageRepository, IMessageRepository │ +├─────────────────────────────────────────────────────────────┤ +│ 数据源层 (DataSources) │ +│ SSEClient, LocalDataSource, ApiDataSource │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 1.2 核心组件详解 + +#### 1.2.1 领域实体层 (Core/Entities) +- **Message.ts**: 定义消息、会话、群通知等核心领域模型 +- 包含工厂函数:`createMessage`, `createConversation` +- 包含业务逻辑函数:`isMessageFromCurrentUser`, `shouldIncrementUnread`, `buildTextContent` + +#### 1.2.2 用例层 (Core/UseCases) +- **ProcessMessageUseCase**: 核心业务逻辑编排器 + - 订阅SSE事件(chat, group_message, read, recall, typing, group_notice等) + - 消息去重处理(processedMessageIds Set) + - 已读状态保护(pendingReadMap + 版本号机制) + - 用户信息缓存与去重请求(pendingUserRequests Map) + - 事件发布订阅模式(subscribers Set) + +#### 1.2.3 仓库层 (Data/Repositories) +- **IMessageRepository**: 定义数据访问接口 + - 消息操作:getMessages, saveMessage, updateMessageStatus, markAsRead + - 会话操作:getConversations, saveConversation, updateUnreadCount + - 同步操作:syncMessages, getServerLastSeq + +- **MessageRepository**: 实现接口 + - 封装SQLite数据库操作 + - 消息与CachedMessage的转换 + +#### 1.2.4 映射器层 (Data/Mappers) +- **MessageMapper**: 数据转换 + - `fromApiResponse`: API响应 → 应用模型 + - `fromDbRecord`: 数据库记录 → 应用模型 + - `toDbRecord`: 应用模型 → 数据库记录 + - `toApiRequest`: 应用模型 → API请求 + - 创建消息片段:`createTextSegment`, `createImageSegment` + +#### 1.2.5 差异计算基础设施 (Infrastructure/Diff) +- **MessageDiffCalculator**: 消息列表差异计算 + - 计算added, updated, deleted, moved, unchanged + - 变化比例检测(changeRatio > 0.5 时触发重置) + - 增量差异计算(基于缓存) + +- **MessageUpdateBatcher**: 批量更新处理器 + - 16ms批量间隔(约60fps) + - 去重和合并更新 + - 100ms最大等待时间 + - 统计信息:totalBatches, totalUpdates, averageBatchSize + +- **types.ts**: 完整的类型定义 + - MessageUpdateType枚举:ADD, UPDATE, DELETE, MOVE, BATCH_* + - DiffResult, DiffConfig等接口 + +#### 1.2.6 Hook层 +- **useDifferentialMessages**: 差异更新Hook + - 集成DiffCalculator和Batcher + - 自动处理批量更新 + - 提供flush, reset, forceUpdate方法 + - 支持配置:enableDiff, enableBatching, maxMessageCount, changeRatioThreshold + +### 1.3 数据流 + +``` +SSE事件 → ProcessMessageUseCase → 事件发布 → useChatScreen → useDifferentialMessages + ↓ ↓ + MessageRepository 差异计算 + 批量处理 + ↓ ↓ + SQLite数据库 优化后的消息列表 +``` + +### 1.4 状态管理方式 + +- **发布-订阅模式**:ProcessMessageUseCase作为事件中心 +- **本地SQLite缓存**:消息持久化存储 +- **内存状态**:通过Hook返回,组件直接消费 +- **差异更新**:通过MessageDiffCalculator + MessageUpdateBatcher减少重渲染 + +--- + +## 二、PostService/Manager架构分析 + +### 2.1 架构层次结构 + +Post模块采用了**扁平化架构**,Service和Manager层职责混合: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 表现层 (Screens) │ +│ HomeScreen, PostDetailScreen, CreatePostScreen │ +├─────────────────────────────────────────────────────────────┤ +│ Hooks层 │ +│ useCursorPagination (通用), usePrefetch │ +├─────────────────────────────────────────────────────────────┤ +│ Store层 (Zustand) │ +│ useUserStore (直接操作状态), postManager (缓存管理) │ +├─────────────────────────────────────────────────────────────┤ +│ Service层 │ +│ postService (API调用 + 状态更新) │ +├─────────────────────────────────────────────────────────────┤ +│ 数据源 │ +│ API (直接调用) │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 2.2 核心组件详解 + +#### 2.2.1 Service层 (Services) +- **postService.ts**: 帖子服务 + - CRUD操作:getPosts, getPost, createPost, updatePost, deletePost + - 互动操作:likePost, unlikePost, favoritePost, unfavoritePost + - 分页支持:传统分页 + 游标分页 + - **问题**:直接操作useUserStore(违反分层原则) + +#### 2.2.2 Manager层 (Stores) +- **postManager.ts**: 帖子缓存管理器 + - 继承CacheBus(发布订阅基类) + - 内存缓存:listCache, detailCache + - 请求去重:pendingRequests Map + - TTL管理:LIST_TTL=30s, DETAIL_TTL=60s + - 后台刷新机制 + +#### 2.2.3 Store层 (Zustand) +- **useUserStore**: 用户状态存储 + - 直接存储posts数组 + - 帖子操作副作用直接修改状态 + - 混合了用户数据和帖子数据 + +#### 2.2.4 映射器层 (Data/Mappers) +- **PostMapper**: 数据转换 + - `fromApiResponse`: API响应 → 应用模型 + - `fromApiResponseList`: 批量转换 + - `toApiRequest`: 应用模型 → API请求 + - 相比MessageMapper缺少数据库记录转换方法 + +### 2.3 数据流 + +``` +HomeScreen → useCursorPagination → postService.getPostsCursor + ↓ + useUserStore.setState(posts) + ↓ + PostCard组件渲染 +``` + +### 2.4 状态管理方式 + +- **Zustand Store**:集中式状态管理 +- **内存缓存**:postManager提供应用级缓存 +- **请求去重**:dedupe方法防止重复请求 +- **直接修改**:postService直接调用useUserStore.setState + +--- + +## 三、主要架构差异 + +### 3.1 分层复杂度 + +| 维度 | Messages模块 | Post模块 | +|------|-------------|---------| +| 层次深度 | 6层(Entity→UseCase→Repository→Mapper→Hook→Screen) | 4层(Service→Store→Hook→Screen) | +| 用例层 | 独立ProcessMessageUseCase | 无 | +| 仓库接口 | IMessageRepository抽象接口 | 无 | +| 基础设施 | Diff模块(Calculator + Batcher) | 无 | + +### 3.2 数据持久化 + +| 维度 | Messages模块 | Post模块 | +|------|-------------|---------| +| 本地存储 | SQLite数据库 | 无 | +| 缓存抽象 | MessageRepository封装 | postManager内存缓存 | +| 离线支持 | 完整 | 不支持 | + +### 3.3 实时更新 + +| 维度 | Messages模块 | Post模块 | +|------|-------------|---------| +| 推送机制 | SSE实时推送 | 轮询/下拉刷新 | +| 事件订阅 | ProcessMessageUseCase发布订阅 | 无 | +| 增量更新 | MessageDiffCalculator | 无 | + +### 3.4 状态管理 + +| 维度 | Messages模块 | Post模块 | +|------|-------------|---------| +| 管理方式 | 发布订阅 + Hook局部状态 | Zustand全局Store | +| 状态来源 | useChatScreen, useDifferentialMessages | useUserStore | +| 批量更新 | MessageUpdateBatcher | 无 | +| 差异检测 | MessageDiffCalculator | 无 | + +### 3.5 依赖方向 + +**Messages模块(正确)**: +``` +Hook → UseCase → Repository → DataSource + ↓ + Entity(不依赖外部) +``` + +**Post模块(问题)**: +``` +Service → Store(循环依赖风险) + ↓ +API直接调用 +``` + +### 3.6 核心问题总结 + +| # | 问题 | Messages | Post | +|---|------|---------|------| +| 1 | 违反分层原则 | 无 | postService直接操作useUserStore | +| 2 | 缺少UseCase层 | ProcessMessageUseCase | 无业务逻辑编排 | +| 3 | 缺少Repository抽象 | IMessageRepository | 无数据访问接口 | +| 4 | 无差异更新 | useDifferentialMessages | 无 | +| 5 | 无批量处理 | MessageUpdateBatcher | 无 | +| 6 | 无本地持久化 | SQLite | 无 | +| 7 | 无实时推送 | SSE | 无 | + +--- + +## 四、对齐建议 + +### 4.1 架构重构目标 + +将Post模块对齐到Messages模块的架构模式: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 表现层 (Screens) │ +│ HomeScreen, PostDetailScreen │ +├─────────────────────────────────────────────────────────────┤ +│ Hooks层 │ +│ useDifferentialPosts (新增) │ +├─────────────────────────────────────────────────────────────┤ +│ 用例层 (UseCases) │ +│ ProcessPostUseCase (新增) │ +├─────────────────────────────────────────────────────────────┤ +│ 领域层 (Entities) │ +│ Post, PostComment (新增) │ +├─────────────────────────────────────────────────────────────┤ +│ 仓库层 (Repositories) │ +│ PostRepository, IPostRepository (新增) │ +├─────────────────────────────────────────────────────────────┤ +│ Service层 │ +│ postService (仅保留API调用,移除状态操作) │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 4.2 具体改造项 + +#### 4.2.1 创建Post领域实体 +```typescript +// src/core/entities/Post.ts +export interface Post { + id: string; + authorId: string; + title: string; + content: string; + images: string[]; + likeCount: number; + commentCount: number; + // ... 其他字段 +} +``` + +#### 4.2.2 创建ProcessPostUseCase +```typescript +// src/core/usecases/ProcessPostUseCase.ts +class ProcessPostUseCase { + // 帖子增删改查 + // 点赞/收藏逻辑 + // 事件发布订阅 +} +``` + +#### 4.2.3 创建IPostRepository接口 +```typescript +// src/data/repositories/interfaces/IPostRepository.ts +interface IPostRepository { + getPosts(type: string, page: number, pageSize: number): Promise; + savePost(post: Post): Promise; + updatePost(postId: string, updates: Partial): Promise; + // ... +} +``` + +#### 4.2.4 创建PostRepository实现 +```typescript +// src/data/repositories/PostRepository.ts +// 封装SQLite操作,实现IPostRepository接口 +``` + +#### 4.2.5 创建useDifferentialPosts Hook +```typescript +// src/hooks/useDifferentialPosts.ts +// 类似useDifferentialMessages,处理帖子列表差异更新 +``` + +#### 4.2.6 改造postService +- 移除对useUserStore的直接依赖 +- 仅保留API调用职责 + +--- + +## 五、架构对比图 + +```mermaid +graph TB + subgraph "Messages模块 [理想架构]" + E1[Entity
Message.ts] + U1[UseCase
ProcessMessageUseCase] + R1[Repository
MessageRepository] + M1[Mapper
MessageMapper] + H1[Hook
useDifferentialMessages] + D1[Diff基础设施
DiffCalculator + Batcher] + S1[(SQLite)] + + E1 --> U1 + U1 --> R1 + U1 --> D1 + R1 --> S1 + M1 --> R1 + D1 --> H1 + H1 --> S1 + end + + subgraph "Post模块 [当前架构]" + E2[PostMapper] + SV2[postService
直接操作Store] + ST2[useUserStore
Zustand] + H2[Hook
useCursorPagination] + PM2[postManager
内存缓存] + + E2 --> SV2 + SV2 --> ST2 + H2 --> SV2 + PM2 --> ST2 + end + + style E1 fill:#90EE90 + style U1 fill:#90EE90 + style R1 fill:#90EE90 + style D1 fill:#90EE90 + style H1 fill:#90EE90 + style S1 fill:#90EE90 + + style SV2 fill:#FFB6C1 + style ST2 fill:#FFB6C1 +``` + +--- + +## 六、结论 + +Messages模块是一个**架构完善**的模块,具备: +1. 清晰的分层架构 +2. 独立的业务逻辑编排层(UseCase) +3. 完整的数据持久化(SQLite) +4. 高效的差异更新机制 +5. 实时事件推送能力 + +Post模块当前存在以下问题: +1. **违反分层原则**:Service直接操作Store +2. **缺少UseCase层**:业务逻辑分散 +3. **无数据抽象**:缺少Repository接口 +4. **无差异更新**:每次全量更新导致性能问题 +5. **无本地持久化**:无法离线使用 + +建议按照对齐建议逐步重构Post模块,使其架构与Messages模块对齐。 diff --git a/src/components/business/NotificationItem.tsx b/src/components/business/NotificationItem.tsx deleted file mode 100644 index b89322a..0000000 --- a/src/components/business/NotificationItem.tsx +++ /dev/null @@ -1,147 +0,0 @@ -/** - * NotificationItem 通知项组件 - * 根据通知类型显示不同图标和内容 - * - * @deprecated 请使用 SystemMessageItem 组件代替 - * 该组件使用旧的 Notification 类型,新功能请使用 SystemMessageItem - */ - -import React from 'react'; -import { View, TouchableOpacity, StyleSheet } from 'react-native'; -import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { formatDistanceToNow } from 'date-fns'; -import { zhCN } from 'date-fns/locale'; -import { colors, spacing, borderRadius } from '../../theme'; -import { Notification, NotificationType } from '../../types'; -import Text from '../common/Text'; -import Avatar from '../common/Avatar'; - -interface NotificationItemProps { - notification: Notification; - onPress: () => void; -} - -// 通知类型到图标和颜色的映射 -const getNotificationIcon = (type: NotificationType): { icon: string; color: string } => { - switch (type) { - case 'like_post': - return { icon: 'heart', color: colors.error.main }; - case 'like_comment': - return { icon: 'heart', color: colors.error.main }; - case 'comment': - return { icon: 'comment', color: colors.info.main }; - case 'reply': - return { icon: 'reply', color: colors.info.main }; - case 'follow': - return { icon: 'account-plus', color: colors.primary.main }; - case 'mention': - return { icon: 'at', color: colors.warning.main }; - case 'system': - return { icon: 'information', color: colors.text.secondary }; - default: - return { icon: 'bell', color: colors.text.secondary }; - } -}; - -const NotificationItem: React.FC = ({ - notification, - onPress, -}) => { - // 格式化时间 - const formatTime = (dateString: string): string => { - try { - return formatDistanceToNow(new Date(dateString), { - addSuffix: true, - locale: zhCN, - }); - } catch { - return ''; - } - }; - - const { icon, color } = getNotificationIcon(notification.type); - - return ( - - {/* 通知图标/头像 */} - - {notification.type === 'follow' && notification.data.userId ? ( - - ) : ( - - - - )} - - - {/* 通知内容 */} - - - - {notification.content} - - - - {formatTime(notification.createdAt)} - - - - {/* 未读标记 */} - {!notification.isRead && } - - ); -}; - -const styles = StyleSheet.create({ - container: { - flexDirection: 'row', - alignItems: 'center', - padding: spacing.lg, - backgroundColor: colors.background.paper, - borderBottomWidth: 1, - borderBottomColor: colors.divider, - }, - unread: { - backgroundColor: colors.primary.light + '10', - }, - iconContainer: { - marginRight: spacing.md, - }, - iconWrapper: { - width: 40, - height: 40, - borderRadius: 20, - justifyContent: 'center', - alignItems: 'center', - }, - content: { - flex: 1, - }, - textContainer: { - marginBottom: spacing.xs, - }, - unreadText: { - fontWeight: '600', - }, - unreadDot: { - width: 8, - height: 8, - borderRadius: 4, - backgroundColor: colors.primary.main, - marginLeft: spacing.sm, - }, -}); - -export default NotificationItem; diff --git a/src/components/business/index.ts b/src/components/business/index.ts index dad9de0..7b319e5 100644 --- a/src/components/business/index.ts +++ b/src/components/business/index.ts @@ -5,7 +5,6 @@ export { default as PostCard } from './PostCard'; export { default as CommentItem } from './CommentItem'; export { default as UserProfileHeader } from './UserProfileHeader'; -export { default as NotificationItem } from './NotificationItem'; export { default as SystemMessageItem } from './SystemMessageItem'; export { default as SearchBar } from './SearchBar'; export { default as TabBar } from './TabBar'; diff --git a/src/core/entities/Post.ts b/src/core/entities/Post.ts new file mode 100644 index 0000000..0d008dc --- /dev/null +++ b/src/core/entities/Post.ts @@ -0,0 +1,267 @@ +/** + * Post Entity - 帖子领域实体 + * 定义帖子的核心属性和行为,不依赖任何外部框架 + */ + +// ==================== 帖子状态枚举 ==================== + +export type PostStatus = 'published' | 'draft' | 'deleted'; + +// ==================== 值对象 ==================== + +/** + * 帖子作者信息 + */ +export interface PostAuthor { + id: string; + username: string; + nickname?: string; + avatar?: string; +} + +/** + * 帖子图片信息 + */ +export interface PostImage { + url: string; + width?: number; + height?: number; + thumbnailUrl?: string; +} + +/** + * 帖子标签 + */ +export interface PostTag { + id: string; + name: string; +} + +// ==================== 主实体 ==================== + +/** + * 帖子实体 + */ +export interface Post { + /** 帖子唯一标识 */ + id: string; + /** 作者ID */ + authorId: string; + /** 作者信息 */ + author?: PostAuthor; + /** 帖子标题 */ + title: string; + /** 帖子内容 */ + content: string; + /** 图片列表 */ + images: PostImage[]; + /** 点赞数 (camelCase) */ + likesCount: number; + /** 评论数 (camelCase) */ + commentsCount: number; + /** 分享数 (camelCase) */ + sharesCount: number; + /** 浏览数 (camelCase) */ + viewsCount: number; + /** 收藏数 (camelCase) */ + favoritesCount: number; + /** 当前用户是否已点赞 (camelCase) */ + isLiked: boolean; + /** 当前用户是否已收藏 (camelCase) */ + isFavorited: boolean; + /** 是否置顶 (camelCase) */ + isPinned: boolean; + /** 帖子状态 */ + status: PostStatus; + /** 所属社区ID */ + communityId?: string; + /** 标签列表 */ + tags: string[]; + /** 创建时间 */ + createdAt: string; + /** 更新时间 */ + updatedAt: string; + + // ==================== 向后兼容字段 (snake_case) ==================== + // 这些字段用于与旧代码兼容,新代码应使用 camelCase 版本 + + /** @deprecated 请使用 likesCount */ + likes_count?: number; + /** @deprecated 请使用 commentsCount */ + comments_count?: number; + /** @deprecated 请使用 favoritesCount */ + favorites_count?: number; + /** @deprecated 请使用 sharesCount */ + shares_count?: number; + /** @deprecated 请使用 viewsCount */ + views_count?: number; + /** @deprecated 请使用 isLiked */ + is_liked?: boolean; + /** @deprecated 请使用 isFavorited */ + is_favorited?: boolean; + /** @deprecated 请使用 isPinned */ + is_pinned?: boolean; + /** @deprecated 请使用 authorId */ + user_id?: string; + /** @deprecated 请使用 createdAt */ + created_at?: string; + /** @deprecated 请使用 updatedAt */ + updated_at?: string; + /** @deprecated 请使用 communityId */ + community_id?: string; + /** @deprecated 请使用 status */ + status_str?: string; + /** 投票帖子标识 (部分旧代码使用) */ + is_vote?: boolean; + /** 锁定状态 (部分旧代码使用) */ + is_locked?: boolean; + /** 置顶评论 (部分旧代码使用) */ + top_comment?: any; +} + +// ==================== 工厂函数 ==================== + +/** + * 创建帖子作者信息 + */ +export const createPostAuthor = (data: Partial): PostAuthor => ({ + id: data.id || '', + username: data.username || '', + nickname: data.nickname, + avatar: data.avatar, +}); + +/** + * 创建帖子图片信息 + */ +export const createPostImage = (data: Partial): PostImage => ({ + url: data.url || '', + width: data.width, + height: data.height, + thumbnailUrl: data.thumbnailUrl, +}); + +/** + * 创建帖子实体 + */ +export const createPost = (data: Partial): Post => ({ + id: data.id || '', + authorId: data.authorId || '', + author: data.author, + title: data.title || '', + content: data.content || '', + images: data.images || [], + likesCount: data.likesCount || 0, + commentsCount: data.commentsCount || 0, + sharesCount: data.sharesCount || 0, + viewsCount: data.viewsCount || 0, + favoritesCount: data.favoritesCount || 0, + isLiked: data.isLiked || false, + isFavorited: data.isFavorited || false, + isPinned: data.isPinned || false, + status: data.status || 'published', + communityId: data.communityId, + tags: data.tags || [], + createdAt: data.createdAt || new Date().toISOString(), + updatedAt: data.updatedAt || new Date().toISOString(), +}); + +// ==================== 类型守卫 ==================== + +/** + * 检查是否为有效的帖子状态 + */ +export const isValidPostStatus = (status: string): status is PostStatus => { + return ['published', 'draft', 'deleted'].includes(status); +}; + +/** + * 检查帖子是否已发布 + */ +export const isPostPublished = (post: Post): boolean => { + return post.status === 'published'; +}; + +/** + * 检查帖子是否已删除 + */ +export const isPostDeleted = (post: Post): boolean => { + return post.status === 'deleted'; +}; + +/** + * 检查帖子是否为草稿 + */ +export const isPostDraft = (post: Post): boolean => { + return post.status === 'draft'; +}; + +// ==================== 辅助函数 ==================== + +/** + * 检查当前用户是否为帖子作者 + */ +export const isPostAuthor = (post: Post, userId: string): boolean => { + return post.authorId === userId; +}; + +/** + * 获取帖子的主要图片(第一张图片) + */ +export const getPostThumbnail = (post: Post): PostImage | undefined => { + return post.images[0]; +}; + +/** + * 检查帖子是否包含图片 + */ +export const hasPostImages = (post: Post): boolean => { + return post.images.length > 0; +}; + +/** + * 获取帖子的显示标题(优先使用标题,否则使用内容摘要) + */ +export const getPostDisplayTitle = (post: Post, maxLength: number = 50): string => { + if (post.title) { + return post.title; + } + if (post.content.length > maxLength) { + return post.content.substring(0, maxLength) + '...'; + } + return post.content; +}; + +/** + * 计算帖子的互动总数 + */ +export const getPostTotalEngagement = (post: Post): number => { + return post.likesCount + post.commentsCount + post.sharesCount + post.favoritesCount; +}; + +/** + * 格式化帖子创建时间为相对时间描述 + */ +export const formatPostTime = (post: Post): string => { + const createdAt = new Date(post.createdAt); + const now = new Date(); + const diffMs = now.getTime() - createdAt.getTime(); + const diffSeconds = Math.floor(diffMs / 1000); + const diffMinutes = Math.floor(diffSeconds / 60); + const diffHours = Math.floor(diffMinutes / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffSeconds < 60) { + return '刚刚'; + } + if (diffMinutes < 60) { + return `${diffMinutes}分钟前`; + } + if (diffHours < 24) { + return `${diffHours}小时前`; + } + if (diffDays < 7) { + return `${diffDays}天前`; + } + return createdAt.toLocaleDateString('zh-CN'); +}; \ No newline at end of file diff --git a/src/core/usecases/ProcessPostUseCase.ts b/src/core/usecases/ProcessPostUseCase.ts new file mode 100644 index 0000000..1a6d775 --- /dev/null +++ b/src/core/usecases/ProcessPostUseCase.ts @@ -0,0 +1,863 @@ +/** + * ProcessPostUseCase - 处理帖子用例 + * 处理帖子的业务逻辑,协调 Repository 和状态管理 + * 纯业务逻辑,无UI依赖 + */ + +import { postRepository } from '../../data/repositories/PostRepository'; +import type { Post } from '../entities/Post'; +import type { + GetPostsParams, + CreatePostData, + UpdatePostData, + PostsResult, + SearchPostsParams, +} from '../../data/repositories/interfaces/IPostRepository'; +import { PaginationStateManager } from '../../infrastructure/pagination/PaginationStateManager'; +import type { PaginationState } from '../../infrastructure/pagination/types'; + +// ==================== 事件类型定义 ==================== + +/** + * 帖子用例事件类型 + */ +export type PostUseCaseEventType = + | 'posts_loaded' + | 'post_created' + | 'post_updated' + | 'post_deleted' + | 'post_liked' + | 'post_unliked' + | 'post_favorited' + | 'post_unfavorited' + | 'loading_changed' + | 'error_occurred' + | 'state_changed'; + +/** + * 帖子用例事件 + */ +export interface PostUseCaseEvent { + type: PostUseCaseEventType; + payload: any; + timestamp: number; +} + +/** + * 事件订阅者类型 + */ +export type PostUseCaseSubscriber = (event: PostUseCaseEvent) => void; + +// ==================== 状态类型定义 ==================== + +/** + * 帖子列表状态 + */ +export interface PostsState { + /** 帖子列表 */ + posts: Post[]; + /** 是否正在加载 */ + isLoading: boolean; + /** 是否正在刷新 */ + isRefreshing: boolean; + /** 是否有更多数据 */ + hasMore: boolean; + /** 错误信息 */ + error: string | null; + /** 当前游标 */ + cursor: string | null; + /** 最后加载时间 */ + lastLoadTime: number | null; +} + +/** + * 帖子详情状态 + */ +export interface PostDetailState { + /** 帖子详情 */ + post: Post | null; + /** 是否正在加载 */ + isLoading: boolean; + /** 错误信息 */ + error: string | null; +} + +// ==================== 默认配置 ==================== + +const DEFAULT_PAGE_SIZE = 20; + +// ==================== ProcessPostUseCase 类 ==================== + +class ProcessPostUseCase { + // 单例实例 + private static instance: ProcessPostUseCase; + + // 订阅者管理 + private subscribers: Set = new Set(); + + // 状态管理 + private postsState: Map = new Map(); + private postDetailState: Map = new Map(); + + // 分页状态管理器 + private paginationManager: PaginationStateManager; + + // 当前用户ID + private currentUserId: string | null = null; + + // 私有构造函数 + private constructor() { + this.paginationManager = new PaginationStateManager(); + } + + /** + * 获取单例实例 + */ + static getInstance(): ProcessPostUseCase { + if (!ProcessPostUseCase.instance) { + ProcessPostUseCase.instance = new ProcessPostUseCase(); + } + return ProcessPostUseCase.instance; + } + + // ==================== 初始化和销毁 ==================== + + /** + * 初始化用例 + * @param currentUserId 当前用户ID + */ + initialize(currentUserId: string | null = null): void { + this.currentUserId = currentUserId; + } + + /** + * 销毁用例 + */ + destroy(): void { + this.subscribers.clear(); + this.postsState.clear(); + this.postDetailState.clear(); + this.paginationManager.clear(); + this.currentUserId = null; + } + + // ==================== 订阅机制 ==================== + + /** + * 订阅事件 + * @param subscriber 订阅者函数 + * @returns 取消订阅函数 + */ + subscribe(subscriber: PostUseCaseSubscriber): () => void { + this.subscribers.add(subscriber); + return () => { + this.subscribers.delete(subscriber); + }; + } + + /** + * 通知所有订阅者 + * @param event 事件对象 + */ + private notifySubscribers(event: PostUseCaseEvent): void { + this.subscribers.forEach((subscriber) => { + try { + subscriber(event); + } catch (error) { + console.error('[ProcessPostUseCase] 订阅者执行失败:', error); + } + }); + } + + /** + * 通知状态变化 + * @param key 列表键 + */ + private notifyStateChanged(key: string): void { + const state = this.getPostsState(key); + this.notifySubscribers({ + type: 'state_changed', + payload: { key, state }, + timestamp: Date.now(), + }); + } + + // ==================== 状态管理 ==================== + + /** + * 获取帖子列表状态 + * @param key 列表键(默认为 'default') + */ + getPostsState(key: string = 'default'): PostsState { + let state = this.postsState.get(key); + if (!state) { + state = { + posts: [], + isLoading: false, + isRefreshing: false, + hasMore: true, + error: null, + cursor: null, + lastLoadTime: null, + }; + this.postsState.set(key, state); + } + return state; + } + + /** + * 获取帖子详情状态 + * @param postId 帖子ID + */ + getPostDetailState(postId: string): PostDetailState { + let state = this.postDetailState.get(postId); + if (!state) { + state = { + post: null, + isLoading: false, + error: null, + }; + this.postDetailState.set(postId, state); + } + return state; + } + + /** + * 更新帖子列表状态 + * @param key 列表键 + * @param partialState 部分状态 + */ + private updatePostsState(key: string, partialState: Partial): void { + const currentState = this.getPostsState(key); + this.postsState.set(key, { + ...currentState, + ...partialState, + }); + this.notifyStateChanged(key); + } + + /** + * 更新帖子详情状态 + * @param postId 帖子ID + * @param partialState 部分状态 + */ + private updatePostDetailState(postId: string, partialState: Partial): void { + const currentState = this.getPostDetailState(postId); + this.postDetailState.set(postId, { + ...currentState, + ...partialState, + }); + } + + // ==================== 帖子列表操作 ==================== + + /** + * 获取帖子列表 + * @param params 查询参数 + * @param key 列表键(用于区分不同的列表) + */ + async fetchPosts(params?: GetPostsParams, key: string = 'default'): Promise { + const state = this.getPostsState(key); + + // 更新加载状态 + this.updatePostsState(key, { isLoading: true, error: null }); + + try { + // 合并分页参数 + const queryParams: GetPostsParams = { + page: params?.page || 1, + pageSize: params?.pageSize || DEFAULT_PAGE_SIZE, + cursor: params?.cursor || state.cursor || undefined, + ...params, + }; + + const result = await postRepository.getPosts(queryParams); + + // 更新状态 + this.updatePostsState(key, { + posts: result.posts, + hasMore: result.hasMore, + cursor: result.nextCursor || null, + isLoading: false, + lastLoadTime: Date.now(), + }); + + // 通知订阅者 + this.notifySubscribers({ + type: 'posts_loaded', + payload: { key, posts: result.posts, hasMore: result.hasMore }, + timestamp: Date.now(), + }); + + return result; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '获取帖子列表失败'; + this.updatePostsState(key, { + isLoading: false, + error: errorMessage, + }); + + this.notifySubscribers({ + type: 'error_occurred', + payload: { key, error: errorMessage }, + timestamp: Date.now(), + }); + + throw error; + } + } + + /** + * 刷新帖子列表 + * @param key 列表键 + */ + async refreshPosts(key: string = 'default'): Promise { + // 更新刷新状态 + this.updatePostsState(key, { isRefreshing: true, error: null }); + + try { + const result = await postRepository.getPosts({ + page: 1, + pageSize: DEFAULT_PAGE_SIZE, + }); + + // 更新状态 + this.updatePostsState(key, { + posts: result.posts, + hasMore: result.hasMore, + cursor: result.nextCursor || null, + isRefreshing: false, + lastLoadTime: Date.now(), + }); + + // 重置分页状态 + this.paginationManager.reset(key); + + return result; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '刷新帖子列表失败'; + this.updatePostsState(key, { + isRefreshing: false, + error: errorMessage, + }); + + throw error; + } + } + + /** + * 加载更多帖子 + * @param key 列表键 + */ + async loadMorePosts(key: string = 'default'): Promise { + const state = this.getPostsState(key); + + // 检查是否还有更多数据 + if (!state.hasMore) { + return { + posts: [], + hasMore: false, + }; + } + + // 检查是否正在加载 + if (state.isLoading) { + return { + posts: [], + hasMore: state.hasMore, + }; + } + + // 更新加载状态 + this.updatePostsState(key, { isLoading: true, error: null }); + + try { + const queryParams: GetPostsParams = { + pageSize: DEFAULT_PAGE_SIZE, + cursor: state.cursor || undefined, + }; + + const result = await postRepository.getPosts(queryParams); + + // 合并新数据 + const newPosts = [...state.posts, ...result.posts]; + + // 更新状态 + this.updatePostsState(key, { + posts: newPosts, + hasMore: result.hasMore, + cursor: result.nextCursor || null, + isLoading: false, + lastLoadTime: Date.now(), + }); + + return { + posts: result.posts, + hasMore: result.hasMore, + nextCursor: result.nextCursor, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '加载更多帖子失败'; + this.updatePostsState(key, { + isLoading: false, + error: errorMessage, + }); + + throw error; + } + } + + // ==================== 帖子详情操作 ==================== + + /** + * 获取单个帖子详情 + * @param id 帖子ID + */ + async fetchPostById(id: string): Promise { + // 更新加载状态 + this.updatePostDetailState(id, { isLoading: true, error: null }); + + try { + const post = await postRepository.getPostById(id); + + // 更新状态 + this.updatePostDetailState(id, { + post, + isLoading: false, + }); + + return post; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '获取帖子详情失败'; + this.updatePostDetailState(id, { + isLoading: false, + error: errorMessage, + }); + + throw error; + } + } + + // ==================== 帖子CRUD操作 ==================== + + /** + * 创建帖子 + * @param data 创建数据 + */ + async createPost(data: CreatePostData): Promise { + try { + const post = await postRepository.createPost(data); + + // 通知订阅者 + this.notifySubscribers({ + type: 'post_created', + payload: { post }, + timestamp: Date.now(), + }); + + // 更新所有列表(新帖子应该出现在列表顶部) + this.postsState.forEach((state, key) => { + this.updatePostsState(key, { + posts: [post, ...state.posts], + }); + }); + + return post; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '创建帖子失败'; + this.notifySubscribers({ + type: 'error_occurred', + payload: { operation: 'create', error: errorMessage }, + timestamp: Date.now(), + }); + throw error; + } + } + + /** + * 更新帖子 + * @param id 帖子ID + * @param data 更新数据 + */ + async updatePost(id: string, data: UpdatePostData): Promise { + try { + const post = await postRepository.updatePost(id, data); + + // 更新详情状态 + this.updatePostDetailState(id, { post }); + + // 更新列表中的帖子 + this.updatePostInLists(id, post); + + // 通知订阅者 + this.notifySubscribers({ + type: 'post_updated', + payload: { postId: id, post }, + timestamp: Date.now(), + }); + + return post; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '更新帖子失败'; + this.notifySubscribers({ + type: 'error_occurred', + payload: { operation: 'update', postId: id, error: errorMessage }, + timestamp: Date.now(), + }); + throw error; + } + } + + /** + * 删除帖子 + * @param id 帖子ID + */ + async deletePost(id: string): Promise { + try { + await postRepository.deletePost(id); + + // 从详情状态中移除 + this.postDetailState.delete(id); + + // 从所有列表中移除 + this.postsState.forEach((state, key) => { + const filteredPosts = state.posts.filter((post) => post.id !== id); + this.updatePostsState(key, { + posts: filteredPosts, + }); + }); + + // 通知订阅者 + this.notifySubscribers({ + type: 'post_deleted', + payload: { postId: id }, + timestamp: Date.now(), + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '删除帖子失败'; + this.notifySubscribers({ + type: 'error_occurred', + payload: { operation: 'delete', postId: id, error: errorMessage }, + timestamp: Date.now(), + }); + throw error; + } + } + + // ==================== 互动操作 ==================== + + /** + * 点赞帖子 + * @param id 帖子ID + */ + async likePost(id: string): Promise { + try { + const post = await postRepository.likePost(id); + + // 更新详情状态 + this.updatePostDetailState(id, { post }); + + // 更新列表中的帖子 + this.updatePostInLists(id, post); + + // 通知订阅者 + this.notifySubscribers({ + type: 'post_liked', + payload: { postId: id, post }, + timestamp: Date.now(), + }); + + return post; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '点赞失败'; + this.notifySubscribers({ + type: 'error_occurred', + payload: { operation: 'like', postId: id, error: errorMessage }, + timestamp: Date.now(), + }); + throw error; + } + } + + /** + * 取消点赞 + * @param id 帖子ID + */ + async unlikePost(id: string): Promise { + try { + const post = await postRepository.unlikePost(id); + + // 更新详情状态 + this.updatePostDetailState(id, { post }); + + // 更新列表中的帖子 + this.updatePostInLists(id, post); + + // 通知订阅者 + this.notifySubscribers({ + type: 'post_unliked', + payload: { postId: id, post }, + timestamp: Date.now(), + }); + + return post; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '取消点赞失败'; + this.notifySubscribers({ + type: 'error_occurred', + payload: { operation: 'unlike', postId: id, error: errorMessage }, + timestamp: Date.now(), + }); + throw error; + } + } + + /** + * 收藏帖子 + * @param id 帖子ID + */ + async favoritePost(id: string): Promise { + try { + const post = await postRepository.favoritePost(id); + + // 更新详情状态 + this.updatePostDetailState(id, { post }); + + // 更新列表中的帖子 + this.updatePostInLists(id, post); + + // 通知订阅者 + this.notifySubscribers({ + type: 'post_favorited', + payload: { postId: id, post }, + timestamp: Date.now(), + }); + + return post; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '收藏失败'; + this.notifySubscribers({ + type: 'error_occurred', + payload: { operation: 'favorite', postId: id, error: errorMessage }, + timestamp: Date.now(), + }); + throw error; + } + } + + /** + * 取消收藏 + * @param id 帖子ID + */ + async unfavoritePost(id: string): Promise { + try { + const post = await postRepository.unfavoritePost(id); + + // 更新详情状态 + this.updatePostDetailState(id, { post }); + + // 更新列表中的帖子 + this.updatePostInLists(id, post); + + // 通知订阅者 + this.notifySubscribers({ + type: 'post_unfavorited', + payload: { postId: id, post }, + timestamp: Date.now(), + }); + + return post; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '取消收藏失败'; + this.notifySubscribers({ + type: 'error_occurred', + payload: { operation: 'unfavorite', postId: id, error: errorMessage }, + timestamp: Date.now(), + }); + throw error; + } + } + + // ==================== 搜索操作 ==================== + + /** + * 搜索帖子 + * @param params 搜索参数 + * @param key 列表键 + */ + async searchPosts(params: SearchPostsParams, key: string = 'search'): Promise { + // 更新加载状态 + this.updatePostsState(key, { isLoading: true, error: null }); + + try { + const result = await postRepository.searchPosts(params); + + // 更新状态 + this.updatePostsState(key, { + posts: result.posts, + hasMore: result.hasMore, + cursor: result.nextCursor || null, + isLoading: false, + lastLoadTime: Date.now(), + }); + + return result; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '搜索帖子失败'; + this.updatePostsState(key, { + isLoading: false, + error: errorMessage, + }); + + throw error; + } + } + + // ==================== 用户帖子操作 ==================== + + /** + * 获取用户发布的帖子 + * @param userId 用户ID + * @param params 查询参数 + * @param key 列表键 + */ + async fetchPostsByUser( + userId: string, + params?: GetPostsParams, + key?: string + ): Promise { + const listKey = key || `user_${userId}`; + + // 更新加载状态 + this.updatePostsState(listKey, { isLoading: true, error: null }); + + try { + const result = await postRepository.getPostsByUser(userId, params); + + // 更新状态 + this.updatePostsState(listKey, { + posts: result.posts, + hasMore: result.hasMore, + cursor: result.nextCursor || null, + isLoading: false, + lastLoadTime: Date.now(), + }); + + return result; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '获取用户帖子失败'; + this.updatePostsState(listKey, { + isLoading: false, + error: errorMessage, + }); + + throw error; + } + } + + // ==================== 辅助方法 ==================== + + /** + * 更新所有列表中的帖子 + * @param postId 帖子ID + * @param updatedPost 更新后的帖子 + */ + private updatePostInLists(postId: string, updatedPost: Post): void { + this.postsState.forEach((state, key) => { + const index = state.posts.findIndex((post) => post.id === postId); + if (index !== -1) { + const newPosts = [...state.posts]; + newPosts[index] = updatedPost; + this.updatePostsState(key, { posts: newPosts }); + } + }); + } + + /** + * 清除指定列表的状态 + * @param key 列表键 + */ + clearPostsState(key: string = 'default'): void { + this.postsState.delete(key); + this.paginationManager.reset(key); + } + + /** + * 清除帖子详情状态 + * @param postId 帖子ID + */ + clearPostDetailState(postId: string): void { + this.postDetailState.delete(postId); + } + + /** + * 清除所有状态 + */ + clearAllStates(): void { + this.postsState.clear(); + this.postDetailState.clear(); + this.paginationManager.clear(); + } + + /** + * 获取当前用户ID + */ + getCurrentUserId(): string | null { + return this.currentUserId; + } + + /** + * 设置当前用户ID + */ + setCurrentUserId(userId: string | null): void { + this.currentUserId = userId; + } + + // ==================== 分页状态管理集成 ==================== + + /** + * 获取分页状态 + * @param key 列表键 + */ + getPaginationState(key: string): PaginationState { + return this.paginationManager.getState(key); + } + + /** + * 重置分页状态 + * @param key 列表键 + */ + resetPagination(key: string): void { + this.paginationManager.reset(key); + } + + /** + * 检查是否正在加载 + * @param key 列表键 + */ + isLoading(key: string = 'default'): boolean { + return this.getPostsState(key).isLoading; + } + + /** + * 检查是否有错误 + * @param key 列表键 + */ + hasError(key: string = 'default'): boolean { + return this.getPostsState(key).error !== null; + } + + /** + * 获取错误信息 + * @param key 列表键 + */ + getError(key: string = 'default'): string | null { + return this.getPostsState(key).error; + } +} + +// ==================== 导出 ==================== + +// 导出单例实例 +export const processPostUseCase = ProcessPostUseCase.getInstance(); +export default processPostUseCase; \ No newline at end of file diff --git a/src/data/datasources/LocalDataSource.ts b/src/data/datasources/LocalDataSource.ts index 1ed7b93..7126779 100644 --- a/src/data/datasources/LocalDataSource.ts +++ b/src/data/datasources/LocalDataSource.ts @@ -132,6 +132,12 @@ export class LocalDataSource implements ILocalDataSource { updatedAt TEXT NOT NULL, PRIMARY KEY (groupId, userId) )`, + // 帖子缓存表 + `CREATE TABLE IF NOT EXISTS posts_cache ( + id TEXT PRIMARY KEY NOT NULL, + data TEXT NOT NULL, + updatedAt TEXT NOT NULL + )`, ]; for (const sql of tables) { diff --git a/src/data/repositories/PostRepository.ts b/src/data/repositories/PostRepository.ts new file mode 100644 index 0000000..5d26cb0 --- /dev/null +++ b/src/data/repositories/PostRepository.ts @@ -0,0 +1,550 @@ +/** + * PostRepository - 帖子仓库实现 + * 实现IPostRepository接口,封装帖子相关的数据访问逻辑 + */ + +import { ApiDataSource, apiDataSource } from '../datasources/ApiDataSource'; +import { LocalDataSource, localDataSource } from '../datasources/LocalDataSource'; +import { PostMapper } from '../mappers/PostMapper'; +import type { Post, PostImage, PostStatus } from '../../core/entities/Post'; +import { createPost } from '../../core/entities/Post'; +import type { + IPostRepository, + GetPostsParams, + CreatePostData, + UpdatePostData, + PostsResult, + SearchPostsParams, +} from './interfaces/IPostRepository'; + +// ==================== 类型定义 ==================== + +/** + * API帖子响应类型 + */ +interface PostApiResponse { + id: number; + user_id: number; + title: string; + content: string; + images?: Array<{ url: string; width?: number; height?: number; thumbnail_url?: string }>; + likes_count: number; + comments_count: number; + shares_count: number; + views_count: number; + favorites_count: number; + is_liked: boolean; + is_favorited: boolean; + is_pinned: boolean; + status: string; + community_id?: string; + tags?: string[]; + created_at: string; + updated_at: string; + author?: { + id: number; + username: string; + nickname?: string; + avatar?: string; + }; +} + +/** + * API帖子列表响应类型 + */ +interface PostsListApiResponse { + posts: PostApiResponse[]; + has_more: boolean; + next_cursor?: string; + total?: number; +} + +/** + * 缓存的帖子数据 + */ +interface CachedPost { + id: string; + data: string; + updatedAt: string; +} + +// ==================== PostRepository 实现 ==================== + +export class PostRepository implements IPostRepository { + private api: ApiDataSource; + private localDb: LocalDataSource; + private memoryCache: Map = new Map(); + private readonly CACHE_TTL = 5 * 60 * 1000; // 5分钟缓存过期时间 + + constructor(api: ApiDataSource, localDb: LocalDataSource) { + this.api = api; + this.localDb = localDb; + } + + // ==================== 私有辅助方法 ==================== + + /** + * 将API响应转换为领域实体 + * 同时设置 camelCase 和 snake_case 字段以保持向后兼容 + */ + private mapToPost(response: PostApiResponse): Post { + const model = PostMapper.fromApiResponse(response as any); + return { + // camelCase 字段(新标准) + id: model.id, + authorId: model.authorId, + author: model.author ? { + id: model.author.id, + username: model.author.username, + nickname: model.author.nickname, + avatar: model.author.avatar, + } : undefined, + title: model.title, + content: model.content, + images: (model.images || []).map(url => ({ + url, + width: undefined, + height: undefined, + })) as PostImage[], + likesCount: model.likeCount, + commentsCount: model.commentCount, + sharesCount: model.shareCount, + viewsCount: model.viewCount, + favoritesCount: model.favoriteCount, + isLiked: model.isLiked, + isFavorited: model.isFavorited, + isPinned: model.isTop, + status: model.status, + communityId: model.communityId, + tags: model.tags || [], + createdAt: model.createdAt.toISOString(), + updatedAt: model.updatedAt.toISOString(), + + // snake_case 字段(向后兼容) + likes_count: model.likeCount, + comments_count: model.commentCount, + favorites_count: model.favoriteCount, + shares_count: model.shareCount, + views_count: model.viewCount, + is_liked: model.isLiked, + is_favorited: model.isFavorited, + is_pinned: model.isTop, + user_id: model.authorId, + created_at: model.createdAt.toISOString(), + updated_at: model.updatedAt.toISOString(), + community_id: model.communityId, + }; + } + + /** + * 从内存缓存获取帖子 + */ + private getFromMemoryCache(id: string): Post | null { + const cached = this.memoryCache.get(id); + if (!cached) return null; + + // 检查缓存是否过期 + if (Date.now() - cached.timestamp > this.CACHE_TTL) { + this.memoryCache.delete(id); + return null; + } + + return cached.post; + } + + /** + * 保存帖子到内存缓存 + */ + private saveToMemoryCache(post: Post): void { + this.memoryCache.set(post.id, { + post, + timestamp: Date.now(), + }); + } + + /** + * 从本地数据库获取缓存的帖子 + */ + private async getFromLocalCache(id: string): Promise { + try { + await this.localDb.initialize(); + const result = await this.localDb.getFirst( + 'SELECT * FROM posts_cache WHERE id = ?', + [id] + ); + + if (!result) return null; + + const post = JSON.parse(result.data) as Post; + return post; + } catch (error) { + console.error('[PostRepository] 获取本地缓存失败:', error); + return null; + } + } + + /** + * 保存帖子到本地缓存 + */ + private async saveToLocalCache(post: Post): Promise { + try { + await this.localDb.initialize(); + await this.localDb.run( + `INSERT OR REPLACE INTO posts_cache (id, data, updatedAt) VALUES (?, ?, ?)`, + [post.id, JSON.stringify(post), new Date().toISOString()] + ); + } catch (error) { + console.error('[PostRepository] 保存本地缓存失败:', error); + } + } + + /** + * 清除帖子的本地缓存 + */ + private async clearLocalCache(id: string): Promise { + try { + await this.localDb.initialize(); + await this.localDb.run('DELETE FROM posts_cache WHERE id = ?', [id]); + } catch (error) { + console.error('[PostRepository] 清除本地缓存失败:', error); + } + } + + /** + * 处理错误 + */ + private handleError(error: unknown, operation: string): never { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error(`[PostRepository] ${operation} 失败:`, error); + throw new Error(`帖子${operation}失败: ${message}`); + } + + // ==================== 帖子查询 ==================== + + /** + * 获取帖子列表 + */ + async getPosts(params?: GetPostsParams): Promise { + try { + const queryParams: Record = {}; + + if (params?.page) queryParams.page = params.page; + if (params?.pageSize) queryParams.page_size = params.pageSize; + if (params?.cursor) queryParams.cursor = params.cursor; + if (params?.post_type) queryParams.tab = params.post_type; + if (params?.communityId) queryParams.community_id = params.communityId; + if (params?.authorId) queryParams.author_id = params.authorId; + if (params?.tags && params.tags.length > 0) queryParams.tags = params.tags.join(','); + if (params?.status) queryParams.status = params.status; + if (params?.sortBy) queryParams.sort_by = params.sortBy; + if (params?.sortOrder) queryParams.sort_order = params.sortOrder; + if (params?.pinnedOnly) queryParams.pinned_only = true; + + const response = await this.api.get('/posts', queryParams); + + const posts = response.posts.map(p => this.mapToPost(p)); + + // 缓存帖子 + posts.forEach(post => { + this.saveToMemoryCache(post); + this.saveToLocalCache(post); + }); + + return { + posts, + hasMore: response.has_more, + nextCursor: response.next_cursor, + total: response.total, + }; + } catch (error) { + this.handleError(error, '获取帖子列表'); + } + } + + /** + * 获取单个帖子详情 + */ + async getPostById(id: string): Promise { + try { + // 1. 先查内存缓存 + const memoryCached = this.getFromMemoryCache(id); + if (memoryCached) { + return memoryCached; + } + + // 2. 查本地数据库缓存 + const localCached = await this.getFromLocalCache(id); + if (localCached) { + this.saveToMemoryCache(localCached); + return localCached; + } + + // 3. 从API获取 + const response = await this.api.get(`/posts/${id}`); + const post = this.mapToPost(response); + + // 更新缓存 + this.saveToMemoryCache(post); + await this.saveToLocalCache(post); + + return post; + } catch (error) { + // 如果API请求失败,尝试返回本地缓存 + const localCached = await this.getFromLocalCache(id); + if (localCached) { + console.warn('[PostRepository] API获取失败,使用本地缓存:', error); + return localCached; + } + this.handleError(error, '获取帖子详情'); + } + } + + /** + * 获取用户发布的帖子列表 + */ + async getPostsByUser(userId: string, params?: GetPostsParams): Promise { + try { + const queryParams: Record = { author_id: userId }; + + if (params?.page) queryParams.page = params.page; + if (params?.pageSize) queryParams.page_size = params.pageSize; + if (params?.cursor) queryParams.cursor = params.cursor; + if (params?.status) queryParams.status = params.status; + if (params?.sortBy) queryParams.sort_by = params.sortBy; + if (params?.sortOrder) queryParams.sort_order = params.sortOrder; + + const response = await this.api.get(`/users/${userId}/posts`, queryParams); + + const posts = response.posts.map(p => this.mapToPost(p)); + + // 缓存帖子 + posts.forEach(post => { + this.saveToMemoryCache(post); + this.saveToLocalCache(post); + }); + + return { + posts, + hasMore: response.has_more, + nextCursor: response.next_cursor, + total: response.total, + }; + } catch (error) { + this.handleError(error, '获取用户帖子列表'); + } + } + + /** + * 搜索帖子 + */ + async searchPosts(params: SearchPostsParams): Promise { + try { + const queryParams: Record = { + keyword: params.keyword, + }; + + if (params.page) queryParams.page = params.page; + if (params.pageSize) queryParams.page_size = params.pageSize; + if (params.scope) queryParams.scope = params.scope; + if (params.communityId) queryParams.community_id = params.communityId; + + const response = await this.api.get('/posts/search', queryParams); + + const posts = response.posts.map(p => this.mapToPost(p)); + + // 缓存帖子 + posts.forEach(post => { + this.saveToMemoryCache(post); + this.saveToLocalCache(post); + }); + + return { + posts, + hasMore: response.has_more, + nextCursor: response.next_cursor, + total: response.total, + }; + } catch (error) { + this.handleError(error, '搜索帖子'); + } + } + + // ==================== 帖子操作 ==================== + + /** + * 创建帖子 + */ + async createPost(data: CreatePostData): Promise { + try { + const requestData = PostMapper.createPostRequest( + data.title, + data.content, + data.images?.map(img => img.url), + data.communityId + ); + + if (data.tags && data.tags.length > 0) { + requestData.tags = data.tags; + } + + if (data.status) { + requestData.status = data.status; + } + + const response = await this.api.post('/posts', requestData); + const post = this.mapToPost(response); + + // 缓存新创建的帖子 + this.saveToMemoryCache(post); + await this.saveToLocalCache(post); + + return post; + } catch (error) { + this.handleError(error, '创建帖子'); + } + } + + /** + * 更新帖子 + */ + async updatePost(id: string, data: UpdatePostData): Promise { + try { + const requestData: Record = {}; + + if (data.title !== undefined) requestData.title = data.title; + if (data.content !== undefined) requestData.content = data.content; + if (data.images !== undefined) requestData.images = data.images.map(img => img.url); + if (data.tags !== undefined) requestData.tags = data.tags; + if (data.status !== undefined) requestData.status = data.status; + if (data.isPinned !== undefined) requestData.is_pinned = data.isPinned; + + const response = await this.api.put(`/posts/${id}`, requestData); + const post = this.mapToPost(response); + + // 更新缓存 + this.saveToMemoryCache(post); + await this.saveToLocalCache(post); + + return post; + } catch (error) { + this.handleError(error, '更新帖子'); + } + } + + /** + * 删除帖子 + */ + async deletePost(id: string): Promise { + try { + await this.api.delete(`/posts/${id}`); + + // 清除缓存 + this.memoryCache.delete(id); + await this.clearLocalCache(id); + } catch (error) { + this.handleError(error, '删除帖子'); + } + } + + // ==================== 互动操作 ==================== + + /** + * 点赞帖子 + */ + async likePost(id: string): Promise { + try { + const response = await this.api.post(`/posts/${id}/like`); + const post = this.mapToPost(response); + + // 更新缓存 + this.saveToMemoryCache(post); + await this.saveToLocalCache(post); + + return post; + } catch (error) { + this.handleError(error, '点赞帖子'); + } + } + + /** + * 取消点赞 + */ + async unlikePost(id: string): Promise { + try { + const response = await this.api.delete(`/posts/${id}/like`); + const post = this.mapToPost(response); + + // 更新缓存 + this.saveToMemoryCache(post); + await this.saveToLocalCache(post); + + return post; + } catch (error) { + this.handleError(error, '取消点赞'); + } + } + + /** + * 收藏帖子 + */ + async favoritePost(id: string): Promise { + try { + const response = await this.api.post(`/posts/${id}/favorite`); + const post = this.mapToPost(response); + + // 更新缓存 + this.saveToMemoryCache(post); + await this.saveToLocalCache(post); + + return post; + } catch (error) { + this.handleError(error, '收藏帖子'); + } + } + + /** + * 取消收藏 + */ + async unfavoritePost(id: string): Promise { + try { + const response = await this.api.delete(`/posts/${id}/favorite`); + const post = this.mapToPost(response); + + // 更新缓存 + this.saveToMemoryCache(post); + await this.saveToLocalCache(post); + + return post; + } catch (error) { + this.handleError(error, '取消收藏'); + } + } + + // ==================== 缓存管理 ==================== + + /** + * 清除所有缓存 + */ + async clearAllCache(): Promise { + this.memoryCache.clear(); + try { + await this.localDb.initialize(); + await this.localDb.run('DELETE FROM posts_cache'); + } catch (error) { + console.error('[PostRepository] 清除所有缓存失败:', error); + } + } + + /** + * 使指定帖子的缓存失效 + */ + async invalidateCache(id: string): Promise { + this.memoryCache.delete(id); + await this.clearLocalCache(id); + } +} + +// ==================== 单例导出 ==================== + +export const postRepository = new PostRepository(apiDataSource, localDataSource); +export default postRepository; diff --git a/src/data/repositories/interfaces/IPostRepository.ts b/src/data/repositories/interfaces/IPostRepository.ts new file mode 100644 index 0000000..c1dc72f --- /dev/null +++ b/src/data/repositories/interfaces/IPostRepository.ts @@ -0,0 +1,190 @@ +/** + * 帖子 Repository 接口 + * 定义帖子数据访问的抽象 + */ + +import type { Post, PostImage, PostStatus } from '../../../core/entities/Post'; + +// ==================== 请求/响应类型 ==================== + +/** + * 获取帖子列表参数 + */ +export interface GetPostsParams { + /** 分页 - 页码(从1开始) */ + page?: number; + /** 分页 - 每页数量 */ + pageSize?: number; + /** 游标 - 用于无限滚动加载 */ + cursor?: string; + /** 帖子类型筛选(可选):recommend, follow, hot, latest */ + post_type?: 'recommend' | 'follow' | 'hot' | 'latest'; + /** 社区ID过滤 */ + communityId?: string; + /** 作者ID过滤 */ + authorId?: string; + /** 标签过滤 */ + tags?: string[]; + /** 状态过滤 */ + status?: PostStatus; + /** 排序字段 */ + sortBy?: 'createdAt' | 'likesCount' | 'commentsCount' | 'viewsCount'; + /** 排序方向 */ + sortOrder?: 'asc' | 'desc'; + /** 是否只获取置顶帖子 */ + pinnedOnly?: boolean; +} + +/** + * 创建帖子数据 + */ +export interface CreatePostData { + /** 帖子标题 */ + title: string; + /** 帖子内容 */ + content: string; + /** 图片列表 */ + images?: PostImage[]; + /** 所属社区ID */ + communityId?: string; + /** 标签列表 */ + tags?: string[]; + /** 帖子状态 */ + status?: PostStatus; +} + +/** + * 更新帖子数据 + */ +export interface UpdatePostData { + /** 帖子标题 */ + title?: string; + /** 帖子内容 */ + content?: string; + /** 图片列表 */ + images?: PostImage[]; + /** 标签列表 */ + tags?: string[]; + /** 帖子状态 */ + status?: PostStatus; + /** 是否置顶 */ + isPinned?: boolean; +} + +/** + * 帖子列表结果 + */ +export interface PostsResult { + /** 帖子列表 */ + posts: Post[]; + /** 是否有更多数据 */ + hasMore: boolean; + /** 下一页游标 */ + nextCursor?: string; + /** 总数(如果支持) */ + total?: number; +} + +/** + * 搜索帖子参数 + */ +export interface SearchPostsParams { + /** 搜索关键词 */ + keyword: string; + /** 分页 - 页码 */ + page?: number; + /** 分页 - 每页数量 */ + pageSize?: number; + /** 搜索范围:标题、内容、或全部 */ + scope?: 'title' | 'content' | 'all'; + /** 社区ID过滤 */ + communityId?: string; +} + +// ==================== Repository 接口 ==================== + +export interface IPostRepository { + // ==================== 帖子查询 ==================== + + /** + * 获取帖子列表 + * @param params 查询参数 + * @returns 帖子列表结果 + */ + getPosts(params?: GetPostsParams): Promise; + + /** + * 获取单个帖子详情 + * @param id 帖子ID + * @returns 帖子实体,不存在则返回null + */ + getPostById(id: string): Promise; + + /** + * 获取用户发布的帖子列表 + * @param userId 用户ID + * @param params 查询参数 + * @returns 帖子列表结果 + */ + getPostsByUser(userId: string, params?: GetPostsParams): Promise; + + /** + * 搜索帖子 + * @param params 搜索参数 + * @returns 帖子列表结果 + */ + searchPosts(params: SearchPostsParams): Promise; + + // ==================== 帖子操作 ==================== + + /** + * 创建帖子 + * @param data 创建帖子数据 + * @returns 创建的帖子实体 + */ + createPost(data: CreatePostData): Promise; + + /** + * 更新帖子 + * @param id 帖子ID + * @param data 更新数据 + * @returns 更新后的帖子实体 + */ + updatePost(id: string, data: UpdatePostData): Promise; + + /** + * 删除帖子 + * @param id 帖子ID + */ + deletePost(id: string): Promise; + + // ==================== 互动操作 ==================== + + /** + * 点赞帖子 + * @param id 帖子ID + * @returns 更新后的帖子实体 + */ + likePost(id: string): Promise; + + /** + * 取消点赞 + * @param id 帖子ID + * @returns 更新后的帖子实体 + */ + unlikePost(id: string): Promise; + + /** + * 收藏帖子 + * @param id 帖子ID + * @returns 更新后的帖子实体 + */ + favoritePost(id: string): Promise; + + /** + * 取消收藏 + * @param id 帖子ID + * @returns 更新后的帖子实体 + */ + unfavoritePost(id: string): Promise; +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index d18b9d8..0b1fd9b 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -95,3 +95,19 @@ export type { UseConnectionStateResult } from './useConnectionState'; export { useMediaCache } from './useMediaCache'; export type { UseMediaCacheReturn } from './useMediaCache'; + +// ==================== 差异更新 Hooks ==================== +export { useDifferentialMessages } from './useDifferentialMessages'; + +export type { + UseDifferentialMessagesOptions, + UseDifferentialMessagesResult, +} from './useDifferentialMessages'; + +export { useDifferentialPosts } from './useDifferentialPosts'; + +export type { + UseDifferentialPostsOptions, + UseDifferentialPostsResult, + DiffUpdatesInfo, +} from './useDifferentialPosts'; diff --git a/src/hooks/useDifferentialPosts.ts b/src/hooks/useDifferentialPosts.ts new file mode 100644 index 0000000..c441c9d --- /dev/null +++ b/src/hooks/useDifferentialPosts.ts @@ -0,0 +1,635 @@ +/** + * 帖子差异更新 Hook + * 接收原始帖子列表,返回优化后的帖子列表,自动处理批量更新 + * 集成 ProcessPostUseCase 进行状态管理 + */ + +import { useState, useRef, useCallback, useEffect, useMemo } from 'react'; +import { + PostIdentifier, + PostUpdate, + PostUpdateType, + PostDiffConfig, + PostDiffResult, +} from '../infrastructure/diff/postTypes'; +import { + PostDiffCalculator, + createPostDiffCalculator, +} from '../infrastructure/diff/PostDiffCalculator'; +import { + PostUpdateBatcher, + PostBatcherOptions, + createPostUpdateBatcher, +} from '../infrastructure/diff/PostUpdateBatcher'; +import { processPostUseCase } from '../core/usecases/ProcessPostUseCase'; +import type { PostUseCaseEvent, PostsState } from '../core/usecases/ProcessPostUseCase'; +import type { Post } from '../core/entities/Post'; + +// ==================== 类型定义 ==================== + +/** + * 差异更新 Hook 配置选项 + */ +export interface UseDifferentialPostsOptions { + /** 差异计算配置 */ + diffConfig?: PostDiffConfig; + /** 批量处理配置 */ + batcherOptions?: PostBatcherOptions; + /** 是否启用差异计算,默认 true */ + enableDiff?: boolean; + /** 是否启用批量处理,默认 true */ + enableBatching?: boolean; + /** 最大帖子数量限制,超出时触发重置 */ + maxPostCount?: number; + /** 变化比例阈值(0-1),超过此值触发重置 */ + changeRatioThreshold?: number; + /** 列表键(用于区分不同的帖子列表) */ + listKey?: string; + /** 是否自动订阅 UseCase 状态变化,默认 true */ + autoSubscribe?: boolean; +} + +/** + * 差异更新信息 + */ +export interface DiffUpdatesInfo { + /** 新增数量 */ + addedCount: number; + /** 更新数量 */ + updatedCount: number; + /** 删除数量 */ + deletedCount: number; + /** 上次更新时间 */ + lastUpdateTime: number; +} + +/** + * 差异更新 Hook 返回值 + */ +export interface UseDifferentialPostsResult { + /** 优化后的帖子列表 */ + posts: T[]; + /** 是否正在加载 */ + loading: boolean; + /** 是否正在刷新 */ + refreshing: boolean; + /** 错误信息 */ + error: string | null; + /** 是否有更多数据 */ + hasMore: boolean; + /** 待处理更新数量 */ + pendingUpdateCount: number; + /** 是否正在处理更新 */ + isProcessing: boolean; + /** 刷新方法 */ + refresh: () => Promise; + /** 加载更多方法 */ + loadMore: () => Promise; + /** 手动刷新批量处理队列 */ + flush: () => void; + /** 重置方法 */ + reset: () => void; + /** 强制更新(跳过批量处理) */ + forceUpdate: (posts: T[]) => void; + /** 获取差异统计信息 */ + getDiffStats: () => { + totalBatches: number; + totalUpdates: number; + averageBatchSize: number; + }; + /** 差异更新信息 */ + diffUpdates: DiffUpdatesInfo; +} + +// ==================== Hook 实现 ==================== + +/** + * 帖子差异更新 Hook + * @param initialPosts 初始帖子列表(可选) + * @param options 配置选项 + * @returns 优化后的帖子列表和相关方法 + */ +export function useDifferentialPosts( + initialPosts: T[] = [], + options: UseDifferentialPostsOptions = {} +): UseDifferentialPostsResult { + const { + diffConfig, + batcherOptions, + enableDiff = true, + enableBatching = true, + maxPostCount = 10000, + changeRatioThreshold = 0.5, + listKey = 'default', + autoSubscribe = true, + } = options; + + // ==================== 状态 ==================== + + const [posts, setPosts] = useState(initialPosts); + const [loading, setLoading] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const [hasMore, setHasMore] = useState(true); + const [isProcessing, setIsProcessing] = useState(false); + const [pendingUpdateCount, setPendingUpdateCount] = useState(0); + const [diffUpdates, setDiffUpdates] = useState({ + addedCount: 0, + updatedCount: 0, + deletedCount: 0, + lastUpdateTime: 0, + }); + + // ==================== Refs ==================== + + const calculatorRef = useRef | null>(null); + const batcherRef = useRef(null); + const previousPostsRef = useRef(initialPosts); + const unsubscribeRef = useRef<(() => void) | null>(null); + + // ==================== 初始化 ==================== + + // 初始化差异计算器 + useEffect(() => { + if (enableDiff) { + calculatorRef.current = createPostDiffCalculator(diffConfig); + } + return () => { + calculatorRef.current = null; + }; + }, [enableDiff, diffConfig]); + + // 初始化批量处理器 + useEffect(() => { + if (enableBatching) { + batcherRef.current = createPostUpdateBatcher(batcherOptions); + + // 订阅批量更新 + const unsubscribe = batcherRef.current.subscribe((updates) => { + handleBatchUpdates(updates); + }); + + return () => { + unsubscribe(); + batcherRef.current?.destroy(); + batcherRef.current = null; + }; + } + }, [enableBatching, batcherOptions]); + + // ==================== 批量更新处理 ==================== + + /** + * 处理批量更新 + */ + const handleBatchUpdates = useCallback((updates: PostUpdate[]) => { + setIsProcessing(true); + + try { + setPosts((currentPosts) => { + let newPosts = [...currentPosts]; + let addedCount = 0; + let updatedCount = 0; + let deletedCount = 0; + + for (const update of updates) { + switch (update.type) { + case PostUpdateType.ADD: { + const addUpdate = update as any; + const index = addUpdate.index ?? newPosts.length; + newPosts.splice(index, 0, addUpdate.post as T); + addedCount++; + break; + } + + case PostUpdateType.BATCH_ADD: { + const batchAddUpdate = update as any; + const postsToAdd = batchAddUpdate.posts || []; + const startIndex = batchAddUpdate.startIndex ?? newPosts.length; + newPosts.splice(startIndex, 0, ...postsToAdd as T[]); + addedCount += postsToAdd.length; + break; + } + + case PostUpdateType.UPDATE: { + const updatePost = update as any; + const index = newPosts.findIndex(p => p.id === updatePost.postId); + if (index !== -1) { + newPosts[index] = { ...newPosts[index], ...updatePost.updates }; + updatedCount++; + } + break; + } + + case PostUpdateType.BATCH_UPDATE: { + const batchUpdate = update as any; + const updatesList = batchUpdate.updates || []; + for (const { postId, changes } of updatesList) { + const index = newPosts.findIndex(p => p.id === postId); + if (index !== -1) { + newPosts[index] = { ...newPosts[index], ...changes }; + updatedCount++; + } + } + break; + } + + case PostUpdateType.DELETE: { + const deleteUpdate = update as any; + const prevLength = newPosts.length; + newPosts = newPosts.filter(p => p.id !== deleteUpdate.postId); + if (newPosts.length < prevLength) { + deletedCount++; + } + break; + } + + case PostUpdateType.BATCH_DELETE: { + const batchDelete = update as any; + const idsToDelete = new Set(batchDelete.postIds || []); + const prevLength = newPosts.length; + newPosts = newPosts.filter(p => !idsToDelete.has(p.id)); + deletedCount += prevLength - newPosts.length; + break; + } + + case PostUpdateType.MOVE: { + const moveUpdate = update as any; + const fromIndex = newPosts.findIndex(p => p.id === moveUpdate.postId); + if (fromIndex !== -1) { + const [movedPost] = newPosts.splice(fromIndex, 1); + newPosts.splice(moveUpdate.toIndex, 0, movedPost); + } + break; + } + + case PostUpdateType.RESET: { + const resetUpdate = update as any; + newPosts = resetUpdate.posts || []; + break; + } + } + } + + // 更新差异统计 + if (addedCount > 0 || updatedCount > 0 || deletedCount > 0) { + setDiffUpdates(prev => ({ + addedCount: prev.addedCount + addedCount, + updatedCount: prev.updatedCount + updatedCount, + deletedCount: prev.deletedCount + deletedCount, + lastUpdateTime: Date.now(), + })); + } + + return newPosts; + }); + } finally { + setIsProcessing(false); + setPendingUpdateCount(batcherRef.current?.getPendingCount() ?? 0); + } + }, []); + + // ==================== 差异计算 ==================== + + /** + * 计算并应用差异 + */ + const calculateAndApplyDiff = useCallback((newPosts: T[]) => { + const previousPosts = previousPostsRef.current; + + // 如果帖子数量变化太大,直接重置 + const previousCount = previousPosts.length; + const currentCount = newPosts.length; + const changeRatio = previousCount === 0 ? 1 : Math.abs(currentCount - previousCount) / previousCount; + + if (changeRatio > changeRatioThreshold) { + setPosts(newPosts); + previousPostsRef.current = newPosts; + return; + } + + // 使用差异计算 + if (enableDiff && calculatorRef.current) { + const diff = calculatorRef.current.calculateDiff(previousPosts, newPosts); + + if (diff.shouldReset) { + setPosts(newPosts); + } else { + // 将差异转换为更新操作 + const updates: PostUpdate[] = []; + + // 处理新增 + if (diff.added.length > 0) { + if (diff.added.length === 1) { + updates.push({ + type: PostUpdateType.ADD, + post: diff.added[0], + timestamp: Date.now(), + }); + } else { + updates.push({ + type: PostUpdateType.BATCH_ADD, + posts: diff.added, + timestamp: Date.now(), + }); + } + } + + // 处理更新 + if (diff.updated.length > 0) { + if (diff.updated.length === 1) { + updates.push({ + type: PostUpdateType.UPDATE, + postId: diff.updated[0].post.id, + updates: diff.updated[0].changes, + timestamp: Date.now(), + }); + } else { + updates.push({ + type: PostUpdateType.BATCH_UPDATE, + updates: diff.updated.map(u => ({ + postId: u.post.id, + changes: u.changes, + })), + timestamp: Date.now(), + }); + } + } + + // 处理删除 + if (diff.deleted.length > 0) { + if (diff.deleted.length === 1) { + updates.push({ + type: PostUpdateType.DELETE, + postId: diff.deleted[0].post.id, + timestamp: Date.now(), + }); + } else { + updates.push({ + type: PostUpdateType.BATCH_DELETE, + postIds: diff.deleted.map(d => d.post.id), + timestamp: Date.now(), + }); + } + } + + // 处理移动 + if (diff.moved.length > 0) { + for (const move of diff.moved) { + updates.push({ + type: PostUpdateType.MOVE, + postId: move.post.id, + toIndex: move.toIndex, + timestamp: Date.now(), + }); + } + } + + // 应用更新 + if (updates.length > 0) { + if (enableBatching && batcherRef.current) { + batcherRef.current.addUpdates(updates); + } else { + handleBatchUpdates(updates); + } + } + } + } else { + // 不使用差异计算,直接设置 + setPosts(newPosts); + } + + // 更新帖子数量限制检查 + if (newPosts.length > maxPostCount) { + console.warn(`[useDifferentialPosts] Post count (${newPosts.length}) exceeds limit (${maxPostCount})`); + } + + previousPostsRef.current = newPosts; + }, [enableDiff, enableBatching, maxPostCount, changeRatioThreshold, handleBatchUpdates]); + + // ==================== UseCase 订阅 ==================== + + /** + * 处理 UseCase 事件 + */ + const handleUseCaseEvent = useCallback((event: PostUseCaseEvent) => { + switch (event.type) { + case 'state_changed': { + const { state } = event.payload as { key: string; state: PostsState }; + if (state) { + setLoading(state.isLoading); + setRefreshing(state.isRefreshing); + setError(state.error); + setHasMore(state.hasMore); + + // 如果帖子列表有变化,进行差异计算 + if (state.posts && state.posts.length > 0) { + calculateAndApplyDiff(state.posts as unknown as T[]); + } + } + break; + } + + case 'posts_loaded': { + const { posts: loadedPosts } = event.payload; + if (loadedPosts) { + calculateAndApplyDiff(loadedPosts as T[]); + } + break; + } + + case 'post_created': { + const { post } = event.payload; + if (post) { + // 新帖子添加到列表开头 + setPosts(prev => [post as T, ...prev]); + setDiffUpdates(prev => ({ + ...prev, + addedCount: prev.addedCount + 1, + lastUpdateTime: Date.now(), + })); + } + break; + } + + case 'post_updated': { + const { post } = event.payload; + if (post) { + setPosts(prev => prev.map(p => p.id === post.id ? { ...p, ...post } as T : p)); + setDiffUpdates(prev => ({ + ...prev, + updatedCount: prev.updatedCount + 1, + lastUpdateTime: Date.now(), + })); + } + break; + } + + case 'post_deleted': { + const { postId } = event.payload; + if (postId) { + setPosts(prev => prev.filter(p => p.id !== postId)); + setDiffUpdates(prev => ({ + ...prev, + deletedCount: prev.deletedCount + 1, + lastUpdateTime: Date.now(), + })); + } + break; + } + + case 'loading_changed': { + const { isLoading } = event.payload; + setLoading(isLoading); + break; + } + + case 'error_occurred': { + const { error: errorMsg } = event.payload; + setError(errorMsg); + break; + } + } + }, [calculateAndApplyDiff]); + + // 订阅 UseCase 状态变化 + useEffect(() => { + if (autoSubscribe) { + unsubscribeRef.current = processPostUseCase.subscribe(handleUseCaseEvent); + + // 初始化时获取当前状态 + const currentState = processPostUseCase.getPostsState(listKey); + if (currentState.posts.length > 0) { + setPosts(currentState.posts as unknown as T[]); + previousPostsRef.current = currentState.posts as unknown as T[]; + } + setLoading(currentState.isLoading); + setRefreshing(currentState.isRefreshing); + setError(currentState.error); + setHasMore(currentState.hasMore); + + return () => { + if (unsubscribeRef.current) { + unsubscribeRef.current(); + unsubscribeRef.current = null; + } + }; + } + }, [autoSubscribe, listKey, handleUseCaseEvent]); + + // ==================== 操作方法 ==================== + + /** + * 刷新帖子列表 + */ + const refresh = useCallback(async () => { + setRefreshing(true); + setError(null); + try { + await processPostUseCase.refreshPosts(listKey); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : '刷新失败'; + setError(errorMsg); + } finally { + setRefreshing(false); + } + }, [listKey]); + + /** + * 加载更多帖子 + */ + const loadMore = useCallback(async () => { + if (hasMore && !loading) { + setLoading(true); + setError(null); + try { + await processPostUseCase.loadMorePosts(listKey); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : '加载更多失败'; + setError(errorMsg); + } finally { + setLoading(false); + } + } + }, [listKey, hasMore, loading]); + + /** + * 手动刷新批量处理队列 + */ + const flush = useCallback(() => { + batcherRef.current?.flush(); + }, []); + + /** + * 重置方法 + */ + const reset = useCallback(() => { + calculatorRef.current?.reset(); + batcherRef.current?.clearPending(); + setPosts([]); + setLoading(false); + setRefreshing(false); + setError(null); + setHasMore(true); + previousPostsRef.current = []; + setDiffUpdates({ + addedCount: 0, + updatedCount: 0, + deletedCount: 0, + lastUpdateTime: 0, + }); + }, []); + + /** + * 强制更新方法 + */ + const forceUpdate = useCallback((newPosts: T[]) => { + setPosts(newPosts); + previousPostsRef.current = newPosts; + }, []); + + /** + * 获取统计信息 + */ + 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 { + posts, + loading, + refreshing, + error, + hasMore, + pendingUpdateCount, + isProcessing, + refresh, + loadMore, + flush, + reset, + forceUpdate, + getDiffStats, + diffUpdates, + }; +} + +export default useDifferentialPosts; diff --git a/src/infrastructure/diff/PostDiffCalculator.ts b/src/infrastructure/diff/PostDiffCalculator.ts new file mode 100644 index 0000000..cc6f8bd --- /dev/null +++ b/src/infrastructure/diff/PostDiffCalculator.ts @@ -0,0 +1,430 @@ +/** + * 帖子差异计算器 + * 计算两个帖子列表之间的差异,支持增量更新 + */ + +import { + PostIdentifier, + PostDiffResult, + PostDiffConfig, + DEFAULT_POST_DIFF_CONFIG, + PostComparator, + PostSorter, + PostDiffStats, +} from './postTypes'; + +/** + * 帖子差异计算器类 + * 用于高效计算帖子列表的变化 + */ +export class PostDiffCalculator { + private config: Required; + private previousPosts: Map = new Map(); + private previousOrder: string[] = []; + private stats: PostDiffStats = { + totalBatches: 0, + totalUpdates: 0, + averageBatchSize: 0, + lastBatchTime: 0, + addedCount: 0, + updatedCount: 0, + deletedCount: 0, + }; + + constructor(config: PostDiffConfig = {}) { + this.config = { ...DEFAULT_POST_DIFF_CONFIG, ...config }; + } + + /** + * 计算两个帖子列表的差异 + * @param oldList 旧帖子列表 + * @param newList 新帖子列表 + * @returns 差异结果 + */ + calculateDiff(oldList: T[], newList: T[]): PostDiffResult { + const result: PostDiffResult = { + added: [], + updated: [], + deleted: [], + moved: [], + unchanged: [], + shouldReset: false, + }; + + // 如果旧列表为空,直接返回新列表作为新增 + if (oldList.length === 0) { + result.added = [...newList]; + this.updateCache(newList); + this.updateStats(0, newList.length, 0); + return result; + } + + // 如果新列表为空,表示全部删除 + if (newList.length === 0) { + result.deleted = oldList.map((post, index) => ({ post, index })); + this.clearCache(); + this.updateStats(0, 0, oldList.length); + return result; + } + + // 检查是否需要重置(变化太大) + const changeRatio = this.calculateChangeRatio(oldList, newList); + if (changeRatio > this.config.resetThreshold) { + result.shouldReset = true; + result.added = [...newList]; + this.updateCache(newList); + this.updateStats(0, newList.length, 0); + return result; + } + + // 构建旧列表的索引映射 + const oldIndexMap = new Map(); + oldList.forEach((post, index) => { + oldIndexMap.set(post.id, index); + }); + + // 构建新列表的索引映射 + const newIndexMap = new Map(); + newList.forEach((post, index) => { + newIndexMap.set(post.id, index); + }); + + // 检测新增、更新和未变更的帖子 + const processedIds = new Set(); + let addedCount = 0; + let updatedCount = 0; + + for (let newIndex = 0; newIndex < newList.length; newIndex++) { + const newPost = newList[newIndex]; + const oldIndex = oldIndexMap.get(newPost.id); + + if (oldIndex === undefined) { + // 新增帖子 + result.added.push(newPost); + addedCount++; + } else { + const oldPost = oldList[oldIndex]; + processedIds.add(newPost.id); + + // 检测是否有更新 + const changes = this.detectChanges(oldPost, newPost); + if (changes && Object.keys(changes).length > 0) { + result.updated.push({ + post: newPost, + index: oldIndex, + changes, + }); + updatedCount++; + } else { + result.unchanged.push(newPost); + } + + // 检测是否移动 + if (oldIndex !== newIndex) { + result.moved.push({ + post: newPost, + fromIndex: oldIndex, + toIndex: newIndex, + }); + } + } + } + + // 检测删除的帖子 + let deletedCount = 0; + for (let oldIndex = 0; oldIndex < oldList.length; oldIndex++) { + const oldPost = oldList[oldIndex]; + if (!processedIds.has(oldPost.id)) { + result.deleted.push({ post: oldPost, index: oldIndex }); + deletedCount++; + } + } + + this.updateCache(newList); + this.updateStats(addedCount, updatedCount, deletedCount); + return result; + } + + /** + * 计算增量差异(基于缓存的上次状态) + * @param newList 新帖子列表 + * @returns 差异结果 + */ + calculateIncrementalDiff(newList: T[]): PostDiffResult { + const oldList = Array.from(this.previousPosts.values()); + // 按照之前的顺序排序 + oldList.sort((a, b) => { + const indexA = this.previousOrder.indexOf(a.id); + const indexB = this.previousOrder.indexOf(b.id); + return indexA - indexB; + }); + return this.calculateDiff(oldList, newList); + } + + /** + * 计算单个帖子的变化 + * @param oldPost 旧帖子 + * @param newPost 新帖子 + * @returns 变化的字段 + */ + detectChanges(oldPost: T, newPost: T): Partial | null { + const changes: Partial = {}; + let hasChanges = false; + + // 如果配置了追踪特定字段,只检测这些字段 + const fieldsToCheck = this.config.detectPartialUpdates + ? this.config.trackedFields + : (Object.keys(newPost) as Array); + + for (const key of fieldsToCheck) { + if (key === 'id') continue; // ID 不变 + + const oldValue = (oldPost as any)[key]; + const newValue = (newPost as any)[key]; + + // 深度比较 + if (this.hasValueChanged(oldValue, newValue)) { + (changes as any)[key] = newValue; + hasChanges = true; + } + } + + return hasChanges ? changes : null; + } + + /** + * 检测值是否变化 + * @param oldValue 旧值 + * @param newValue 新值 + * @returns 是否变化 + */ + private hasValueChanged(oldValue: any, newValue: any): boolean { + // 简单类型直接比较 + if (typeof oldValue !== 'object' || oldValue === null || newValue === null) { + return oldValue !== newValue; + } + + // 数组比较 + if (Array.isArray(oldValue) && Array.isArray(newValue)) { + if (oldValue.length !== newValue.length) return true; + return JSON.stringify(oldValue) !== JSON.stringify(newValue); + } + + // 对象比较 + return JSON.stringify(oldValue) !== JSON.stringify(newValue); + } + + /** + * 计算变化比例 + * @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(p => p.id)); + const newIds = new Set(newList.map(p => p.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 posts 帖子列表 + */ + private updateCache(posts: T[]): void { + this.previousPosts.clear(); + this.previousOrder = []; + + for (const post of posts) { + this.previousPosts.set(post.id, post); + this.previousOrder.push(post.id); + } + } + + /** + * 清除缓存 + */ + private clearCache(): void { + this.previousPosts.clear(); + this.previousOrder = []; + } + + /** + * 更新统计信息 + */ + private updateStats(addedCount: number, updatedCount: number, deletedCount: number): void { + this.stats.totalBatches++; + const totalChanges = addedCount + updatedCount + deletedCount; + this.stats.totalUpdates += totalChanges; + this.stats.averageBatchSize = this.stats.totalUpdates / this.stats.totalBatches; + this.stats.lastBatchTime = Date.now(); + this.stats.addedCount += addedCount; + this.stats.updatedCount += updatedCount; + this.stats.deletedCount += deletedCount; + } + + /** + * 设置配置 + * @param config 配置 + */ + setConfig(config: Partial): void { + this.config = { ...this.config, ...config }; + } + + /** + * 获取当前配置 + * @returns 当前配置 + */ + getConfig(): Required { + return { ...this.config }; + } + + /** + * 重置计算器状态 + */ + reset(): void { + this.clearCache(); + this.stats = { + totalBatches: 0, + totalUpdates: 0, + averageBatchSize: 0, + lastBatchTime: 0, + addedCount: 0, + updatedCount: 0, + deletedCount: 0, + }; + } + + /** + * 获取缓存的帖子数量 + * @returns 缓存数量 + */ + getCachedCount(): number { + return this.previousPosts.size; + } + + /** + * 检查帖子是否在缓存中 + * @param postId 帖子 ID + * @returns 是否存在 + */ + hasPost(postId: string): boolean { + return this.previousPosts.has(postId); + } + + /** + * 获取缓存中的帖子 + * @param postId 帖子 ID + * @returns 帖子或 undefined + */ + getCachedPost(postId: string): T | undefined { + return this.previousPosts.get(postId); + } + + /** + * 获取统计信息 + * @returns 统计信息 + */ + getStats(): PostDiffStats { + return { ...this.stats }; + } + + /** + * 批量检测帖子变化 + * @param posts 要检测的帖子列表 + * @returns 变化的帖子列表 + */ + batchDetectChanges(posts: T[]): Array<{ post: T; changes: Partial }> { + const results: Array<{ post: T; changes: Partial }> = []; + + for (const post of posts) { + const cachedPost = this.previousPosts.get(post.id); + if (cachedPost) { + const changes = this.detectChanges(cachedPost, post); + if (changes && Object.keys(changes).length > 0) { + results.push({ post, changes }); + } + } + } + + return results; + } + + /** + * 比较两个帖子列表是否相同 + * @param list1 列表1 + * @param list2 列表2 + * @returns 是否相同 + */ + areEqual(list1: T[], list2: T[]): boolean { + if (list1.length !== list2.length) return false; + + for (let i = 0; i < list1.length; i++) { + if (list1[i].id !== list2[i].id) return false; + } + + return true; + } + + /** + * 获取两个帖子列表的交集ID + * @param list1 列表1 + * @param list2 列表2 + * @returns 交集ID集合 + */ + getIntersection(list1: T[], list2: T[]): Set { + const ids1 = new Set(list1.map(p => p.id)); + const ids2 = new Set(list2.map(p => p.id)); + const intersection = new Set(); + + ids1.forEach(id => { + if (ids2.has(id)) { + intersection.add(id); + } + }); + + return intersection; + } + + /** + * 获取两个帖子列表的差集ID + * @param list1 列表1 + * @param list2 列表2 + * @returns 差集ID集合(在list1中但不在list2中) + */ + getDifference(list1: T[], list2: T[]): Set { + const ids1 = new Set(list1.map(p => p.id)); + const ids2 = new Set(list2.map(p => p.id)); + const difference = new Set(); + + ids1.forEach(id => { + if (!ids2.has(id)) { + difference.add(id); + } + }); + + return difference; + } +} + +/** + * 创建帖子差异计算器实例的工厂函数 + * @param config 配置 + * @returns PostDiffCalculator 实例 + */ +export function createPostDiffCalculator( + config?: PostDiffConfig +): PostDiffCalculator { + return new PostDiffCalculator(config); +} diff --git a/src/infrastructure/diff/PostUpdateBatcher.ts b/src/infrastructure/diff/PostUpdateBatcher.ts new file mode 100644 index 0000000..dafa49b --- /dev/null +++ b/src/infrastructure/diff/PostUpdateBatcher.ts @@ -0,0 +1,505 @@ +/** + * 帖子更新批量处理器 + * 收集更新操作并按批处理,减少 UI 更新次数 + */ + +import { + PostUpdate, + PostUpdateType, + PostBatchUpdateListener, + PostDiffConfig, + DEFAULT_POST_DIFF_CONFIG, + PostIdentifier, + PostUpdateBatch, +} from './postTypes'; + +/** + * 批量处理器配置选项 + */ +export interface PostBatcherOptions { + /** 批量处理间隔(毫秒),默认 16ms(约 60fps) */ + batchInterval?: number; + /** 最大批量大小,默认 50 */ + maxBatchSize?: number; + /** 是否启用去重,默认 true */ + enableDeduplication?: boolean; + /** 是否合并相同帖子的多次更新,默认 true */ + enableMerge?: boolean; + /** 最大等待时间(毫秒),超过此时间强制刷新,默认 100ms */ + maxWaitTime?: number; + /** 是否自动启动批量处理,默认 true */ + autoStart?: boolean; +} + +/** + * 默认批量处理器配置 + */ +export const DEFAULT_POST_BATCHER_OPTIONS: Required = { + batchInterval: 16, + maxBatchSize: 50, + enableDeduplication: true, + enableMerge: true, + maxWaitTime: 100, + autoStart: true, +}; + +/** + * 帖子更新批量处理器类 + */ +export class PostUpdateBatcher { + /** 待处理的更新队列 */ + private pendingUpdates: Map = new Map(); + /** 批量处理定时器 */ + private batchTimer: ReturnType | null = null; + /** 最大等待时间定时器 */ + private maxWaitTimer: ReturnType | null = null; + /** 监听器集合 */ + private listeners: Set = new Set(); + /** 是否正在批处理中 */ + private isProcessing: boolean = false; + /** 配置选项 */ + private options: Required; + /** 处理器是否已启动 */ + private isStarted: boolean = false; + /** 批量处理统计信息 */ + private stats = { + totalBatches: 0, + totalUpdates: 0, + averageBatchSize: 0, + lastBatchTime: 0, + }; + /** 批次 ID 计数器 */ + private batchIdCounter: number = 0; + + constructor(options: PostBatcherOptions = {}) { + this.options = { ...DEFAULT_POST_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: PostUpdate): 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: PostUpdate[]): void { + for (const update of updates) { + this.addUpdate(update); + } + } + + /** + * 立即刷新所有待处理更新 + * @returns 处理的更新数组 + */ + flush(): PostUpdate[] { + 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; + } + + /** + * 刷新并返回批次对象 + * @returns 批次对象或 null + */ + flushAsBatch(): PostUpdateBatch | null { + const updates = this.flush(); + if (updates.length === 0) return null; + + return { + batchId: `batch_${++this.batchIdCounter}_${Date.now()}`, + updates, + createdAt: Date.now(), + processed: false, + }; + } + + /** + * 订阅批量更新事件 + * @param listener 监听器函数 + * @returns 取消订阅函数 + */ + subscribe(listener: PostBatchUpdateListener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + /** + * 取消订阅 + * @param listener 监听器函数 + */ + unsubscribe(listener: PostBatchUpdateListener): 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: PostUpdate): string { + // 根据更新类型生成唯一键 + const updateType = (update as PostUpdate).type; + switch (updateType) { + case PostUpdateType.ADD: + case PostUpdateType.BATCH_ADD: + return `${updateType}_${(update as any).timestamp}`; + case PostUpdateType.UPDATE: + case PostUpdateType.BATCH_UPDATE: + return `${updateType}_${this.extractPostId(update)}`; + case PostUpdateType.DELETE: + case PostUpdateType.BATCH_DELETE: + return `${updateType}_${this.extractPostId(update)}`; + case PostUpdateType.MOVE: + return `${updateType}_${(update as any).postId}`; + case PostUpdateType.RESET: + return `${updateType}_${(update as any).timestamp}`; + default: + // 兜底处理未知类型 + return `unknown_${Date.now()}_${Math.random()}`; + } + } + + /** + * 提取帖子 ID + * @param update 更新操作 + * @returns 帖子 ID 或空字符串 + */ + private extractPostId(update: PostUpdate): string { + const payload = (update as any).payload; + if (payload) { + if (payload.postId) return payload.postId; + if (payload.postIds) return payload.postIds.join(','); + } + // 直接从更新对象提取 + if ((update as any).postId) return (update as any).postId; + if ((update as any).postIds) return (update as any).postIds.join(','); + return ''; + } + + /** + * 合并更新操作 + * @param existing 已有更新 + * @param incoming 新更新 + * @returns 合并后的更新或 null(如果无法合并) + */ + private mergeUpdates(existing: PostUpdate, incoming: PostUpdate): PostUpdate | null { + // 只有同类型的更新才能合并 + if (existing.type !== incoming.type) { + return null; + } + + switch (existing.type) { + case PostUpdateType.UPDATE: + // 合并更新字段 + return { + ...existing, + payload: { + ...(existing as any).payload, + updates: { + ...(existing as any).payload?.updates, + ...(incoming as any).payload?.updates, + }, + }, + timestamp: incoming.timestamp, + }; + + case PostUpdateType.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.postId); + if (existing) { + existing.changes = { ...existing.changes, ...update.changes }; + } else { + updatesMap.set(update.postId, { ...update }); + } + } + + return { + ...existing, + payload: { + ...(existing as any).payload, + updates: Array.from(updatesMap.values()), + }, + timestamp: incoming.timestamp, + }; + + case PostUpdateType.BATCH_DELETE: + // 合并删除 ID 列表 + const existingIds = new Set((existing as any).payload?.postIds || []); + const incomingIds = (incoming as any).payload?.postIds || []; + for (const id of incomingIds) { + existingIds.add(id); + } + return { + ...existing, + payload: { + ...(existing as any).payload, + postIds: Array.from(existingIds), + }, + 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: PostUpdate[]): 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 post batch update listener:', error); + } + } + } + + // ==================== 便捷方法 ==================== + + /** + * 添加帖子新增操作 + */ + addPost(post: PostIdentifier, index?: number): void { + this.addUpdate({ + type: PostUpdateType.ADD, + post, + index, + timestamp: Date.now(), + }); + } + + /** + * 添加帖子更新操作 + */ + updatePost(postId: string, updates: Partial): void { + this.addUpdate({ + type: PostUpdateType.UPDATE, + postId, + updates, + timestamp: Date.now(), + }); + } + + /** + * 添加帖子删除操作 + */ + deletePost(postId: string): void { + this.addUpdate({ + type: PostUpdateType.DELETE, + postId, + timestamp: Date.now(), + }); + } + + /** + * 批量添加帖子 + */ + batchAddPosts(posts: PostIdentifier[], startIndex?: number): void { + this.addUpdate({ + type: PostUpdateType.BATCH_ADD, + posts, + startIndex, + timestamp: Date.now(), + }); + } + + /** + * 批量更新帖子 + */ + batchUpdatePosts(updates: Array<{ postId: string; changes: Partial }>): void { + this.addUpdate({ + type: PostUpdateType.BATCH_UPDATE, + updates, + timestamp: Date.now(), + }); + } + + /** + * 批量删除帖子 + */ + batchDeletePosts(postIds: string[]): void { + this.addUpdate({ + type: PostUpdateType.BATCH_DELETE, + postIds, + timestamp: Date.now(), + }); + } + + /** + * 重置帖子列表 + */ + resetPosts(posts: PostIdentifier[]): void { + this.addUpdate({ + type: PostUpdateType.RESET, + posts, + timestamp: Date.now(), + }); + } +} + +/** + * 创建帖子批量处理器的工厂函数 + * @param options 配置选项 + * @returns PostUpdateBatcher 实例 + */ +export function createPostUpdateBatcher( + options?: PostBatcherOptions +): PostUpdateBatcher { + return new PostUpdateBatcher(options); +} diff --git a/src/infrastructure/diff/index.ts b/src/infrastructure/diff/index.ts index dfa746a..5b4959d 100644 --- a/src/infrastructure/diff/index.ts +++ b/src/infrastructure/diff/index.ts @@ -1,8 +1,10 @@ /** * 差异更新模块 - * 提供消息列表的增量更新功能,减少 UI 重渲染次数 + * 提供消息列表和帖子列表的增量更新功能,减少 UI 重渲染次数 */ +// ==================== Message 相关 ==================== + // 类型定义 export { MessageUpdateType, @@ -38,3 +40,45 @@ export { BatcherOptions, DEFAULT_BATCHER_OPTIONS, } from './MessageUpdateBatcher'; + +// ==================== Post 相关 ==================== + +// 类型定义 +export { + PostChangeType, + PostUpdateType, + PostIdentifier, + PostChange, + PostDiffResult, + BasePostUpdate, + AddPostUpdate, + UpdatePostUpdate, + DeletePostUpdate, + MovePostUpdate, + BatchAddPostUpdate, + BatchUpdatePostUpdate, + BatchDeletePostUpdate, + ResetPostUpdate, + PostUpdate, + PostUpdateBatch, + PostDiffConfig, + PostBatchUpdateListener, + PostComparator, + PostSorter, + PostDiffStats, + DEFAULT_POST_DIFF_CONFIG, +} from './postTypes'; + +// 差异计算器 +export { + PostDiffCalculator, + createPostDiffCalculator, +} from './PostDiffCalculator'; + +// 批量处理器 +export { + PostUpdateBatcher, + createPostUpdateBatcher, + PostBatcherOptions, + DEFAULT_POST_BATCHER_OPTIONS, +} from './PostUpdateBatcher'; diff --git a/src/infrastructure/diff/postTypes.ts b/src/infrastructure/diff/postTypes.ts new file mode 100644 index 0000000..0968b83 --- /dev/null +++ b/src/infrastructure/diff/postTypes.ts @@ -0,0 +1,325 @@ +/** + * Post差异更新类型定义 + * 用于帖子列表的增量更新,减少 UI 重渲染次数 + */ + +import { Post } from '../../core/entities/Post'; + +// ==================== 变化类型枚举 ==================== + +/** + * 帖子变化类型枚举 + */ +export enum PostChangeType { + /** 新增帖子 */ + ADDED = 'added', + /** 更新帖子 */ + UPDATED = 'updated', + /** 删除帖子 */ + DELETED = 'deleted', +} + +/** + * 帖子更新类型枚举 + */ +export enum PostUpdateType { + /** 添加单条帖子 */ + ADD = 'ADD', + /** 更新单条帖子 */ + UPDATE = 'UPDATE', + /** 删除单条帖子 */ + DELETE = 'DELETE', + /** 移动帖子位置 */ + MOVE = 'MOVE', + /** 批量添加 */ + BATCH_ADD = 'BATCH_ADD', + /** 批量更新 */ + BATCH_UPDATE = 'BATCH_UPDATE', + /** 批量删除 */ + BATCH_DELETE = 'BATCH_DELETE', + /** 重置整个列表 */ + RESET = 'RESET', +} + +// ==================== 基础接口 ==================== + +/** + * 帖子唯一标识接口 + */ +export interface PostIdentifier { + /** 帖子唯一 ID */ + id: string; + /** 帖子创建时间(可选,用于排序) */ + createdAt?: string; + /** 更新时间(可选,用于检测变化) */ + updatedAt?: string; +} + +/** + * 单个帖子变化记录 + */ +export interface PostChange { + /** 变化类型 */ + type: PostChangeType; + /** 帖子数据 */ + post: T; + /** 变化前的索引位置(用于删除和移动) */ + oldIndex?: number; + /** 变化后的索引位置(用于新增和移动) */ + newIndex?: number; + /** 变化的字段(用于更新) */ + changes?: Partial; +} + +// ==================== 差异结果接口 ==================== + +/** + * Post差异计算结果接口 + */ +export interface PostDiffResult { + /** 新增的帖子 */ + added: T[]; + /** 更新的帖子(包含变化详情) */ + updated: Array<{ + post: T; + index: number; + changes: Partial; + }>; + /** 删除的帖子 */ + deleted: Array<{ + post: T; + index: number; + }>; + /** 移动的帖子 */ + moved: Array<{ + post: T; + fromIndex: number; + toIndex: number; + }>; + /** 未变更的帖子 */ + unchanged: T[]; + /** 是否需要重置(变化比例过大时) */ + shouldReset: boolean; +} + +// ==================== 更新操作接口 ==================== + +/** + * 基础帖子更新操作接口 + */ +export interface BasePostUpdate { + /** 更新类型 */ + type: PostUpdateType; + /** 更新时间戳 */ + timestamp: number; + /** 社区 ID(可选) */ + communityId?: string; + /** 载荷数据(可选,用于合并更新) */ + payload?: Record; +} + +/** + * 添加单条帖子更新 + */ +export interface AddPostUpdate extends BasePostUpdate { + type: PostUpdateType.ADD; + /** 帖子数据 */ + post: PostIdentifier; + /** 插入位置索引(可选,默认追加到末尾) */ + index?: number; +} + +/** + * 更新单条帖子更新 + */ +export interface UpdatePostUpdate extends BasePostUpdate { + type: PostUpdateType.UPDATE; + /** 帖子 ID */ + postId: string; + /** 更新的字段 */ + updates: Partial; +} + +/** + * 删除单条帖子更新 + */ +export interface DeletePostUpdate extends BasePostUpdate { + type: PostUpdateType.DELETE; + /** 帖子 ID */ + postId: string; +} + +/** + * 移动帖子更新 + */ +export interface MovePostUpdate extends BasePostUpdate { + type: PostUpdateType.MOVE; + /** 帖子 ID */ + postId: string; + /** 目标位置索引 */ + toIndex: number; +} + +/** + * 批量添加帖子更新 + */ +export interface BatchAddPostUpdate extends BasePostUpdate { + type: PostUpdateType.BATCH_ADD; + /** 帖子列表 */ + posts: PostIdentifier[]; + /** 插入位置索引(可选,默认追加到末尾) */ + startIndex?: number; +} + +/** + * 批量更新帖子更新 + */ +export interface BatchUpdatePostUpdate extends BasePostUpdate { + type: PostUpdateType.BATCH_UPDATE; + /** 批量更新项 */ + updates: Array<{ + postId: string; + changes: Partial; + }>; +} + +/** + * 批量删除帖子更新 + */ +export interface BatchDeletePostUpdate extends BasePostUpdate { + type: PostUpdateType.BATCH_DELETE; + /** 要删除的帖子 ID 列表 */ + postIds: string[]; +} + +/** + * 重置帖子列表更新 + */ +export interface ResetPostUpdate extends BasePostUpdate { + type: PostUpdateType.RESET; + /** 新的帖子列表 */ + posts: PostIdentifier[]; +} + +/** + * 帖子更新联合类型 + */ +export type PostUpdate = + | AddPostUpdate + | UpdatePostUpdate + | DeletePostUpdate + | MovePostUpdate + | BatchAddPostUpdate + | BatchUpdatePostUpdate + | BatchDeletePostUpdate + | ResetPostUpdate; + +// ==================== 批量更新接口 ==================== + +/** + * Post批量更新类型 + */ +export interface PostUpdateBatch { + /** 批次 ID */ + batchId: string; + /** 更新列表 */ + updates: PostUpdate[]; + /** 创建时间戳 */ + createdAt: number; + /** 是否已处理 */ + processed: boolean; +} + +// ==================== 配置接口 ==================== + +/** + * Post差异配置接口 + */ +export interface PostDiffConfig { + /** 批量处理间隔(毫秒),默认 16ms(60fps) */ + batchInterval?: number; + /** 最大批量大小,默认 50 */ + maxBatchSize?: number; + /** 是否启用去重,默认 true */ + enableDeduplication?: boolean; + /** 是否合并相同帖子的多次更新,默认 true */ + enableMerge?: boolean; + /** 帖子唯一标识字段,默认 'id' */ + idField?: string; + /** 排序字段,默认 'createdAt' */ + sortField?: string; + /** 变化比例阈值,超过此值触发重置,默认 0.5 */ + resetThreshold?: number; + /** 是否检测部分字段更新 */ + detectPartialUpdates?: boolean; + /** 需要检测变化的字段列表 */ + trackedFields?: (keyof Post)[]; +} + +/** + * 默认Post差异配置常量 + */ +export const DEFAULT_POST_DIFF_CONFIG: Required = { + batchInterval: 16, + maxBatchSize: 50, + enableDeduplication: true, + enableMerge: true, + idField: 'id', + sortField: 'createdAt', + resetThreshold: 0.5, + detectPartialUpdates: true, + trackedFields: [ + 'likesCount', + 'commentsCount', + 'sharesCount', + 'viewsCount', + 'favoritesCount', + 'isLiked', + 'isFavorited', + 'isPinned', + 'status', + 'title', + 'content', + 'images', + 'tags', + ], +}; + +// ==================== 监听器类型 ==================== + +/** + * 批量更新监听器类型 + */ +export type PostBatchUpdateListener = (updates: PostUpdate[]) => void; + +/** + * Post比较函数类型 + */ +export type PostComparator = (a: T, b: T) => boolean; + +/** + * Post排序函数类型 + */ +export type PostSorter = (a: T, b: T) => number; + +// ==================== 统计接口 ==================== + +/** + * Post差异统计接口 + */ +export interface PostDiffStats { + /** 总批次数 */ + totalBatches: number; + /** 总更新数 */ + totalUpdates: number; + /** 平均批量大小 */ + averageBatchSize: number; + /** 上次批次时间 */ + lastBatchTime: number; + /** 新增帖子数 */ + addedCount: number; + /** 更新帖子数 */ + updatedCount: number; + /** 删除帖子数 */ + deletedCount: number; +} \ No newline at end of file diff --git a/src/presentation/hooks/responsive/useBreakpointCheck.ts b/src/presentation/hooks/responsive/useBreakpointCheck.ts deleted file mode 100644 index 14f0da6..0000000 --- a/src/presentation/hooks/responsive/useBreakpointCheck.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * useBreakpointCheck Hook - * 断点检查 - 提供断点范围检查功能 - * @deprecated 请直接使用 useBreakpoint.ts 中的 hooks - */ - -export { useBreakpointGTE, useBreakpointLT, useBreakpointBetween } from './useBreakpoint'; diff --git a/src/screens/home/HomeScreen.tsx b/src/screens/home/HomeScreen.tsx index 66f37d9..68e3ac6 100644 --- a/src/screens/home/HomeScreen.tsx +++ b/src/screens/home/HomeScreen.tsx @@ -32,8 +32,8 @@ import { PostCard, TabBar, SearchBar } from '../../components/business'; import { Loading, EmptyState, Text, ImageGallery, ImageGridItem, ResponsiveGrid } from '../../components/common'; import { HomeStackParamList, RootStackParamList } from '../../navigation/types'; import { useResponsive, useResponsiveSpacing } from '../../hooks/useResponsive'; -import { useCursorPagination } from '../../hooks/useCursorPagination'; -import { CursorPaginationRequest } from '../../types/dto'; +import { useDifferentialPosts } from '../../hooks/useDifferentialPosts'; +import { processPostUseCase } from '../../core/usecases/ProcessPostUseCase'; import { SearchScreen } from './SearchScreen'; import { CreatePostScreen } from '../create/CreatePostScreen'; import { navigationService } from '../../infrastructure/navigation/navigationService'; @@ -55,7 +55,7 @@ type PostType = 'recommend' | 'follow' | 'hot' | 'latest'; export const HomeScreen: React.FC = () => { const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const { likePost, unlikePost, favoritePost, unfavoritePost, posts: storePosts } = useUserStore(); + const { posts: storePosts } = useUserStore(); const currentUser = useCurrentUser(); // 使用响应式 hook @@ -89,6 +89,15 @@ export const HomeScreen: React.FC = () => { const postIdsRef = useRef>(new Set()); const lastSwipeAtRef = useRef(0); + // 构建一个以 postId 为 key 的 map,用于快速查找 + const postsMap = useMemo(() => { + const map = new Map(); + storePosts.forEach(post => { + map.set(post.id, post); + }); + return map; + }, [storePosts]); + // 获取当前 tab 对应的帖子类型 const getPostType = useCallback((): PostType => { switch (activeIndex) { @@ -100,42 +109,70 @@ export const HomeScreen: React.FC = () => { } }, [activeIndex]); - // 使用游标分页获取帖子列表 + // 使用差异更新 Hook 获取帖子列表 + const listKey = useMemo(() => `home_${getPostType()}`, [getPostType]); + const { - items: posts, - isLoading, - isRefreshing, + posts, + loading: isLoading, + refreshing: isRefreshing, hasMore, error, - loadMore, - refresh, - } = useCursorPagination( - async ({ cursor, pageSize, extraParams }) => { - const params: CursorPaginationRequest = { - cursor, - page_size: pageSize, - post_type: extraParams?.post_type, - }; - const response = await postService.getPostsCursor(params); - return response; - }, - { pageSize: DEFAULT_PAGE_SIZE }, - { post_type: getPostType() } + reset, + } = useDifferentialPosts( + [], + { + listKey, + enableDiff: true, + enableBatching: true, + autoSubscribe: true, + } ); - // Tab 切换时刷新数据 + // 加载更多方法 + const loadMore = useCallback(async () => { + if (hasMore && !isLoading) { + try { + await processPostUseCase.loadMorePosts(listKey); + } catch (err) { + console.error('加载更多失败:', err); + } + } + }, [listKey, hasMore, isLoading]); + + // 刷新方法 - 先获取正确类型的帖子,再刷新 + const refresh = useCallback(async () => { + try { + // 先获取正确类型的帖子 + await processPostUseCase.fetchPosts( + { page: 1, pageSize: DEFAULT_PAGE_SIZE, post_type: getPostType() }, + listKey + ); + } catch (err) { + console.error('刷新失败:', err); + } + }, [listKey, getPostType]); + + // Tab 切换时刷新数据并重置列表 useEffect(() => { + reset(); refresh(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeIndex]); - // 同步 store 中的帖子状态到本地(用于点赞、收藏等状态更新) - useEffect(() => { - if (posts.length === 0) return; - - // 更新 postIdsRef - const currentPostIds = new Set(posts.map(p => p.id)); - postIdsRef.current = currentPostIds; - }, [posts, storePosts]); + // 将获取到的原始帖子与 store 中的状态合并 + // 这样 UI 始终显示的是 store 中的最新状态(包括点赞、收藏等) + const displayPosts = useMemo(() => { + return posts.map(post => { + const storePost = postsMap.get(post.id); + if (storePost) { + // 如果 store 中有这个帖子,使用 store 的最新状态 + return storePost; + } + // 如果 store 中没有这个帖子,使用原始数据 + return post; + }); + }, [posts, postsMap]); // 根据屏幕尺寸确定网格列数 const gridColumns = useMemo(() => { @@ -242,20 +279,28 @@ export const HomeScreen: React.FC = () => { }; // 点赞帖子 - const handleLike = (post: Post) => { - if (post.is_liked) { - unlikePost(post.id); - } else { - likePost(post.id); + const handleLike = async (post: Post) => { + try { + if (post.is_liked) { + await processPostUseCase.unlikePost(post.id); + } else { + await processPostUseCase.likePost(post.id); + } + } catch (error) { + console.error('点赞操作失败:', error); } }; // 收藏帖子 - const handleBookmark = (post: Post) => { - if (post.is_favorited) { - unfavoritePost(post.id); - } else { - favoritePost(post.id); + const handleBookmark = async (post: Post) => { + try { + if (post.is_favorited) { + await processPostUseCase.unfavoritePost(post.id); + } else { + await processPostUseCase.favoritePost(post.id); + } + } catch (error) { + console.error('收藏操作失败:', error); } }; @@ -272,7 +317,7 @@ export const HomeScreen: React.FC = () => { Alert.alert('已复制', '帖子链接已复制到剪贴板'); }; - // 删除帖子 - 由于 posts 来自 Hook,需要刷新列表 + // 删除帖子 - 删除后刷新列表 const handleDeletePost = async (postId: string) => { try { const success = await postService.deletePost(postId); @@ -377,7 +422,7 @@ export const HomeScreen: React.FC = () => { const columnHeights: number[] = Array(gridColumns).fill(0); // 防御性检查:确保 posts 存在且是数组 - if (!posts || !Array.isArray(posts) || posts.length === 0) { + if (!displayPosts || !Array.isArray(displayPosts) || displayPosts.length === 0) { return columns; } @@ -385,7 +430,7 @@ export const HomeScreen: React.FC = () => { const totalGap = (gridColumns - 1) * responsiveGap; const columnWidth = (width - responsivePadding * 2 - totalGap) / gridColumns; - posts.forEach((post) => { + displayPosts.forEach((post) => { const postHeight = estimatePostHeight(post, columnWidth); // 找到当前高度最小的列 @@ -395,7 +440,7 @@ export const HomeScreen: React.FC = () => { }); return columns; - }, [posts, gridColumns, width, responsiveGap, responsivePadding]); + }, [displayPosts, gridColumns, width, responsiveGap, responsivePadding]); // 渲染单列帖子 const renderWaterfallColumn = (column: Post[], columnIndex: number) => ( @@ -461,7 +506,7 @@ export const HomeScreen: React.FC = () => { gap={{ xs: 8, sm: 12, md: 16, lg: 20, xl: 24 }} containerStyle={{ paddingHorizontal: responsivePadding, paddingBottom: 80 }} > - {posts.map(post => { + {displayPosts.map(post => { const authorId = post.author?.id || ''; const isPostAuthor = currentUser?.id === authorId; return ( @@ -507,7 +552,7 @@ export const HomeScreen: React.FC = () => { // 移动端和宽屏都使用单列 FlatList,宽屏下居中显示 return ( item.id} contentContainerStyle={[ diff --git a/src/screens/home/PostDetailScreen.tsx b/src/screens/home/PostDetailScreen.tsx index b04f000..6b9b10d 100644 --- a/src/screens/home/PostDetailScreen.tsx +++ b/src/screens/home/PostDetailScreen.tsx @@ -32,6 +32,7 @@ import { Post, Comment, VoteResultDTO } from '../../types'; import { useUserStore } from '../../stores'; import { useCurrentUser } from '../../stores/authStore'; import { postService, commentService, uploadService, authService, showPrompt, voteService } from '../../services'; +import { processPostUseCase } from '../../core/usecases/ProcessPostUseCase'; import { useCursorPagination } from '../../hooks/useCursorPagination'; import { CommentItem, VoteCard } from '../../components/business'; import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout } from '../../components/common'; @@ -62,15 +63,6 @@ export const PostDetailScreen: React.FC = () => { const responsivePadding = useResponsiveSpacing({ xs: 12, sm: 14, md: 16, lg: 20, xl: 24, '2xl': 28, '3xl': 32, '4xl': 40 }); const responsiveGap = useResponsiveSpacing({ xs: 8, sm: 10, md: 12, lg: 16, xl: 20, '2xl': 24, '3xl': 28, '4xl': 32 }); - const { - likePost, - unlikePost, - favoritePost, - unfavoritePost, - likeComment, - unlikeComment - } = useUserStore(); - const currentUser = useCurrentUser(); const [post, setPost] = useState(null); @@ -144,14 +136,15 @@ export const PostDetailScreen: React.FC = () => { } try { - // 从API获取帖子详情 - const postData = await postService.getPost(postId); + // 使用 ProcessPostUseCase 获取帖子详情 + const postData = await processPostUseCase.fetchPostById(postId); if (postData) { - setPost(postData); + // 类型转换:将 core/entities/Post 转换为 PostDTO 以保持兼容性 + setPost(postData as unknown as Post); // 初始化关注状态 if (postData.author) { - setIsFollowing(postData.author.is_following || false); - setIsFollowingMe(postData.author.is_following_me || false); + setIsFollowing((postData.author as any).is_following || false); + setIsFollowingMe((postData.author as any).is_following_me || false); } // 只在首次加载时记录浏览量 if (recordView && !hasRecordedView.current) { @@ -163,7 +156,7 @@ export const PostDetailScreen: React.FC = () => { } // 如果是投票帖子,立即加载投票数据 - if (postData.is_vote) { + if ((postData as any).is_vote) { setIsVoteLoading(true); try { const voteData = await voteService.getVoteResult(postId); @@ -184,8 +177,8 @@ export const PostDetailScreen: React.FC = () => { setPost(updatedPost); // 初始化关注状态 if (updatedPost.author) { - setIsFollowing(updatedPost.author.is_following || false); - setIsFollowingMe(updatedPost.author.is_following_me || false); + setIsFollowing((updatedPost.author as any).is_following || false); + setIsFollowingMe((updatedPost.author as any).is_following_me || false); } } } @@ -361,10 +354,11 @@ export const PostDetailScreen: React.FC = () => { try { if (oldIsLiked) { - await unlikePost(post.id); + await processPostUseCase.unlikePost(post.id); } else { - await likePost(post.id); + await processPostUseCase.likePost(post.id); } + // UseCase 会更新 store,本地状态已经是乐观更新的,无需再次更新 } catch (error) { console.error('点赞操作失败:', error); // 失败时回滚状态 @@ -374,7 +368,7 @@ export const PostDetailScreen: React.FC = () => { likes_count: oldLikesCount } : null); } - }, [post, likePost, unlikePost]); + }, [post]); // 收藏帖子 const handleFavorite = useCallback(async () => { @@ -393,10 +387,11 @@ export const PostDetailScreen: React.FC = () => { try { if (oldIsFavorited) { - await unfavoritePost(post.id); + await processPostUseCase.unfavoritePost(post.id); } else { - await favoritePost(post.id); + await processPostUseCase.favoritePost(post.id); } + // UseCase 会更新 store,本地状态已经是乐观更新的,无需再次更新 } catch (error) { console.error('收藏操作失败:', error); // 失败时回滚状态 @@ -406,7 +401,7 @@ export const PostDetailScreen: React.FC = () => { favorites_count: oldFavoritesCount } : null); } - }, [post, favoritePost, unfavoritePost]); + }, [post]); // 分享帖子 const handleShare = useCallback(async () => { @@ -527,17 +522,13 @@ export const PostDetailScreen: React.FC = () => { onPress: async () => { setIsDeleting(true); try { - const success = await postService.deletePost(post.id); - if (success) { - Alert.alert('删除成功', '帖子已删除', [ - { - text: '确定', - onPress: () => navigation.goBack(), - }, - ]); - } else { - Alert.alert('删除失败', '删除帖子时发生错误,请稍后重试'); - } + await processPostUseCase.deletePost(post.id); + Alert.alert('删除成功', '帖子已删除', [ + { + text: '确定', + onPress: () => navigation.goBack(), + }, + ]); } catch (error) { console.error('删除帖子失败:', error); Alert.alert('删除失败', '删除帖子时发生错误,请稍后重试'); @@ -817,9 +808,9 @@ export const PostDetailScreen: React.FC = () => { try { if (oldIsLiked) { - await unlikeComment(comment.id); + await commentService.unlikeComment(comment.id); } else { - await likeComment(comment.id); + await commentService.likeComment(comment.id); } } catch (error) { console.error('评论点赞操作失败:', error); diff --git a/src/screens/message/MessageListScreen.tsx b/src/screens/message/MessageListScreen.tsx index 84def36..f63b0de 100644 --- a/src/screens/message/MessageListScreen.tsx +++ b/src/screens/message/MessageListScreen.tsx @@ -265,7 +265,7 @@ export const MessageListScreen: React.FC = () => { }, [isFocused]); // 【新架构】使用focus刷新hook,从ChatScreen返回时自动刷新未读数 - useMessageListRefresh(isFocused); + useMessageListRefresh(); // 同步未读数到userStore(用于TabBar角标显示) useEffect(() => { diff --git a/src/screens/message/NotificationsScreen.tsx b/src/screens/message/NotificationsScreen.tsx index d8ef14b..c239f5f 100644 --- a/src/screens/message/NotificationsScreen.tsx +++ b/src/screens/message/NotificationsScreen.tsx @@ -29,7 +29,8 @@ import { SystemMessageItem } from '../../components/business'; import { Text, EmptyState, ResponsiveContainer } from '../../components/common'; import { useCursorPagination } from '../../hooks/useCursorPagination'; import { RootStackParamList } from '../../navigation/types'; -import { useMessageManagerSystemUnreadCount, useUserStore } from '../../stores'; +import { useMessageManagerSystemUnreadCount } from '../../stores'; +import { messageManager } from '../../stores/messageManager'; const MESSAGE_TYPES = [ { key: 'all', title: '全部' }, @@ -46,7 +47,6 @@ const GROUP_SYSTEM_TYPES = new Set(['group_invite', 'group_join_apply', 'group_j export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack }) => { const isFocused = useIsFocused(); - const fetchMessageUnreadCount = useUserStore(state => state.fetchMessageUnreadCount); const { setSystemUnreadCount, decrementSystemUnreadCount } = useMessageManagerSystemUnreadCount(); const navigation = useNavigation>(); @@ -158,11 +158,11 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack setUnreadCount(0); setSystemUnreadCount(0); // 同步更新全局 TabBar 红点 - fetchMessageUnreadCount(); + messageManager.fetchUnreadCount(); } catch (error) { console.error('一键已读失败:', error); } - }, [refresh, fetchMessageUnreadCount, setSystemUnreadCount]); + }, [refresh, setSystemUnreadCount]); // 页面加载和获得焦点时刷新,并自动标记所有消息为已读 // 使用 ref 防止重复执行,避免无限循环 @@ -281,7 +281,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack } // 更新本地未读数以及全局 TabBar 红点 fetchUnreadCount(); - fetchMessageUnreadCount(); + messageManager.fetchUnreadCount(); // 根据消息类型处理导航 const { system_type, extra_data } = message; diff --git a/src/screens/message/components/ChatScreen/MessageBubble.tsx b/src/screens/message/components/ChatScreen/MessageBubble.tsx index bd1ada6..945460d 100644 --- a/src/screens/message/components/ChatScreen/MessageBubble.tsx +++ b/src/screens/message/components/ChatScreen/MessageBubble.tsx @@ -9,8 +9,6 @@ import { View, TouchableOpacity, Image, - findNodeHandle, - UIManager, GestureResponderEvent, StyleSheet, Dimensions, @@ -219,22 +217,17 @@ export const MessageBubble: React.FC = ({ // 处理长按并获取位置 const handleLongPress = () => { if (bubbleRef.current) { - const node = findNodeHandle(bubbleRef.current); - if (node) { - UIManager.measureInWindow(node, (x, y, width, height) => { - const position: MenuPosition = { - x, - y, - width, - height, - pressX: pressPositionRef.current.x, - pressY: pressPositionRef.current.y, - }; - onLongPress(message, position); - }); - } else { - onLongPress(message); - } + bubbleRef.current.measureInWindow((x, y, width, height) => { + const position: MenuPosition = { + x, + y, + width, + height, + pressX: pressPositionRef.current.x, + pressY: pressPositionRef.current.y, + }; + onLongPress(message, position); + }); } else { onLongPress(message); } diff --git a/src/screens/message/components/ChatScreen/types.ts b/src/screens/message/components/ChatScreen/types.ts index 191e43a..e414537 100644 --- a/src/screens/message/components/ChatScreen/types.ts +++ b/src/screens/message/components/ChatScreen/types.ts @@ -73,8 +73,6 @@ export interface MessageBubbleProps { otherUser: { id?: string; nickname?: string; avatar?: string | null } | null; isGroupChat: boolean; groupMembers: GroupMemberResponse[]; - /** @deprecated 发送者信息现在直接从 message.sender 获取,不再使用 senderCache */ - senderCache?: Map; otherUserLastReadSeq: number; selectedMessageId: string | null; // 消息映射,用于查找被回复的消息 diff --git a/src/screens/profile/ProfileScreen.tsx b/src/screens/profile/ProfileScreen.tsx index 165ebac..28bdc3a 100644 --- a/src/screens/profile/ProfileScreen.tsx +++ b/src/screens/profile/ProfileScreen.tsx @@ -49,7 +49,7 @@ export const ProfileScreen: React.FC = () => { // 使用 any 类型来访问根导航 const rootNavigation = useNavigation(); const { currentUser, updateUser, fetchCurrentUser } = useAuthStore(); - const { followUser, unfollowUser, likePost, unlikePost, favoritePost, unfavoritePost, posts: storePosts } = useUserStore(); + const { followUser, unfollowUser, posts: storePosts } = useUserStore(); // 响应式布局 const { isDesktop, isTablet, width } = useResponsive(); @@ -275,9 +275,9 @@ export const ProfileScreen: React.FC = () => { post={post} onPress={() => handlePostPress(post.id)} onUserPress={() => post.author ? handleUserPress(post.author.id) : () => {}} - onLike={() => post.is_liked ? unlikePost(post.id) : likePost(post.id)} + onLike={() => post.is_liked ? postService.unlikePost(post.id) : postService.likePost(post.id)} onComment={() => handlePostPress(post.id, true)} - onBookmark={() => post.is_favorited ? unfavoritePost(post.id) : favoritePost(post.id)} + onBookmark={() => post.is_favorited ? postService.unfavoritePost(post.id) : postService.favoritePost(post.id)} onShare={() => {}} onDelete={() => handleDeletePost(post.id)} isPostAuthor={isPostAuthor} @@ -315,9 +315,9 @@ export const ProfileScreen: React.FC = () => { post={post} onPress={() => handlePostPress(post.id)} onUserPress={() => post.author ? handleUserPress(post.author.id) : () => {}} - onLike={() => post.is_liked ? unlikePost(post.id) : likePost(post.id)} + onLike={() => post.is_liked ? postService.unlikePost(post.id) : postService.likePost(post.id)} onComment={() => handlePostPress(post.id, true)} - onBookmark={() => post.is_favorited ? unfavoritePost(post.id) : favoritePost(post.id)} + onBookmark={() => post.is_favorited ? postService.unfavoritePost(post.id) : postService.favoritePost(post.id)} onShare={() => {}} onDelete={() => handleDeletePost(post.id)} isPostAuthor={isPostAuthor} @@ -330,7 +330,7 @@ export const ProfileScreen: React.FC = () => { } return null; - }, [loading, activeTab, posts, favorites, currentUser?.id, handlePostPress, handleUserPress, handleDeletePost, unlikePost, likePost, unfavoritePost, favoritePost]); + }, [loading, activeTab, posts, favorites, currentUser?.id, handlePostPress, handleUserPress, handleDeletePost]); // 渲染用户信息头部 - 不随 tab 变化,使用 useMemo 缓存 const renderUserHeader = useMemo(() => ( diff --git a/src/screens/profile/UserScreen.tsx b/src/screens/profile/UserScreen.tsx index 8febb3e..5383caf 100644 --- a/src/screens/profile/UserScreen.tsx +++ b/src/screens/profile/UserScreen.tsx @@ -41,7 +41,7 @@ export const UserScreen: React.FC = () => { // 使用 any 类型来访问根导航 const rootNavigation = useNavigation>(); - const { followUser, unfollowUser, likePost, unlikePost, favoritePost, unfavoritePost, posts: storePosts } = useUserStore(); + const { followUser, unfollowUser, posts: storePosts } = useUserStore(); const currentUser = useCurrentUser(); // 响应式布局 @@ -319,9 +319,9 @@ export const UserScreen: React.FC = () => { post={post} onPress={() => handlePostPress(post.id)} onUserPress={() => post.author ? handleUserPress(post.author.id) : () => {}} - onLike={() => post.is_liked ? unlikePost(post.id) : likePost(post.id)} + onLike={() => post.is_liked ? postService.unlikePost(post.id) : postService.likePost(post.id)} onComment={() => handlePostPress(post.id, true)} - onBookmark={() => post.is_favorited ? unfavoritePost(post.id) : favoritePost(post.id)} + onBookmark={() => post.is_favorited ? postService.unfavoritePost(post.id) : postService.favoritePost(post.id)} onShare={() => {}} onDelete={() => handleDeletePost(post.id)} isPostAuthor={isPostAuthor} @@ -359,9 +359,9 @@ export const UserScreen: React.FC = () => { post={post} onPress={() => handlePostPress(post.id)} onUserPress={() => post.author ? handleUserPress(post.author.id) : () => {}} - onLike={() => post.is_liked ? unlikePost(post.id) : likePost(post.id)} + onLike={() => post.is_liked ? postService.unlikePost(post.id) : postService.likePost(post.id)} onComment={() => handlePostPress(post.id, true)} - onBookmark={() => post.is_favorited ? unfavoritePost(post.id) : favoritePost(post.id)} + onBookmark={() => post.is_favorited ? postService.unfavoritePost(post.id) : postService.favoritePost(post.id)} onShare={() => {}} onDelete={() => handleDeletePost(post.id)} isPostAuthor={isPostAuthor} diff --git a/src/services/groupService.ts b/src/services/groupService.ts index 330193d..4ae3f3d 100644 --- a/src/services/groupService.ts +++ b/src/services/groupService.ts @@ -403,17 +403,6 @@ class GroupService { } } - // ==================== 兼容旧API的方法(将逐步废弃) ==================== - - /** - * @deprecated 使用 setGroupName 代替 - */ - async updateGroup(id: number, data: { name: string }): Promise { - await this.setGroupName(id, data.name); - return this.getGroup(id); - } -} - // 导出群组服务实例 export const groupService = new GroupService(); diff --git a/src/services/messageService.ts b/src/services/messageService.ts index 4c15f1d..844e878 100644 --- a/src/services/messageService.ts +++ b/src/services/messageService.ts @@ -671,32 +671,6 @@ class MessageService { }; } } - - // ==================== 兼容旧API的方法(将逐步废弃) ==================== - - /** - * @deprecated 使用 getConversations 代替 - */ - async getConversation(conversationId: string): Promise { - try { - return await this.getConversationById(conversationId); - } catch (error) { - console.error('获取会话详情失败:', error); - return null; - } - } - - /** - * @deprecated 使用 createConversation(userId: string) 代替 - */ - async createConversationByString(userId: string): Promise { - try { - return await this.createConversation(userId); - } catch (error) { - console.error('创建会话失败:', error); - return null; - } - } } // 导出消息服务实例 diff --git a/src/services/postService.ts b/src/services/postService.ts index 4f81844..c3675c4 100644 --- a/src/services/postService.ts +++ b/src/services/postService.ts @@ -1,11 +1,16 @@ /** * 帖子服务 * 处理帖子的增删改查、点赞、收藏等功能 + * + * @deprecated 此服务已弃用,请使用 ProcessPostUseCase 代替 + * 互动操作(点赞、收藏)已委托给 processPostUseCase */ import { api, PaginatedData } from './api'; import { Post, CreatePostInput } from '../types'; import { CursorPaginationRequest, CursorPaginationResponse } from '../types/dto'; +import { processPostUseCase } from '../core/usecases/ProcessPostUseCase'; +import type { Post as PostEntity } from '../core/entities/Post'; // 帖子列表响应 interface PostListResponse { @@ -129,14 +134,12 @@ class PostService { } // 点赞帖子 + // @deprecated 请使用 processPostUseCase.likePost() 代替 async likePost(postId: string): Promise { try { - const response = await api.post(`/posts/${postId}/like`); - if (response.code === 0) { - return response.data; - } - console.warn('[postService] likePost failed, code:', response.code); - return null; + const post = await processPostUseCase.likePost(postId); + // 转换为旧版 Post 类型以保持向后兼容 + return post as unknown as Post; } catch (error) { console.error('[postService] likePost error:', error); return null; @@ -144,14 +147,11 @@ class PostService { } // 取消点赞帖子 + // @deprecated 请使用 processPostUseCase.unlikePost() 代替 async unlikePost(postId: string): Promise { try { - const response = await api.delete(`/posts/${postId}/like`); - if (response.code === 0) { - return response.data; - } - console.warn('[postService] unlikePost failed, code:', response.code); - return null; + const post = await processPostUseCase.unlikePost(postId); + return post as unknown as Post; } catch (error) { console.error('[postService] unlikePost error:', error); return null; @@ -159,14 +159,11 @@ class PostService { } // 收藏帖子 + // @deprecated 请使用 processPostUseCase.favoritePost() 代替 async favoritePost(postId: string): Promise { try { - const response = await api.post(`/posts/${postId}/favorite`); - if (response.code === 0) { - return response.data; - } - console.warn('[postService] favoritePost failed, code:', response.code); - return null; + const post = await processPostUseCase.favoritePost(postId); + return post as unknown as Post; } catch (error) { console.error('[postService] favoritePost error:', error); return null; @@ -174,14 +171,11 @@ class PostService { } // 取消收藏帖子 + // @deprecated 请使用 processPostUseCase.unfavoritePost() 代替 async unfavoritePost(postId: string): Promise { try { - const response = await api.delete(`/posts/${postId}/favorite`); - if (response.code === 0) { - return response.data; - } - console.warn('[postService] unfavoritePost failed, code:', response.code); - return null; + const post = await processPostUseCase.unfavoritePost(postId); + return post as unknown as Post; } catch (error) { console.error('[postService] unfavoritePost error:', error); return null; diff --git a/src/stores/messageManagerHooks.ts b/src/stores/messageManagerHooks.ts index e9aba4c..ccf36cd 100644 --- a/src/stores/messageManagerHooks.ts +++ b/src/stores/messageManagerHooks.ts @@ -502,10 +502,9 @@ interface UseMessageListRefreshReturn { * 从 ChatScreen 返回时,MessageManager 内存状态已经是最新的(markAsRead 乐观更新), * 无需再请求服务器(避免时序竞争覆盖已读状态)。 * 仅在用户主动下拉刷新时才触发服务器请求。 - * @deprecated isFocused 参数保留仅为向后兼容,不再触发焦点刷新 + * 注意:isFocused 参数已移除,不再触发焦点刷新 */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export function useMessageListRefresh(_isFocused?: boolean): UseMessageListRefreshReturn { +export function useMessageListRefresh(): UseMessageListRefreshReturn { const [isRefreshing, setIsRefreshing] = useState(false); const lastRefreshTimeRef = useRef(0); diff --git a/src/stores/userStore.ts b/src/stores/userStore.ts index 1b06334..d5e9131 100644 --- a/src/stores/userStore.ts +++ b/src/stores/userStore.ts @@ -10,12 +10,10 @@ import { PaginatedData } from '../services/api'; import { authService, postService, - commentService, notificationService, } from '../services'; import { userManager } from './userManager'; import { messageManager } from './messageManager'; -import { createOptimisticUpdaterFactory } from '../utils/optimisticUpdate'; interface UserState { // 状态 @@ -42,17 +40,10 @@ interface UserState { markAllNotificationsAsRead: () => Promise; fetchNotificationBadge: () => Promise; setMessageUnreadCount: (count: number) => void; - fetchMessageUnreadCount: () => Promise; addSearchHistory: (keyword: string) => void; clearSearchHistory: () => void; - // 互动操作 - likePost: (postId: string) => Promise; - unlikePost: (postId: string) => Promise; - favoritePost: (postId: string) => Promise; - unfavoritePost: (postId: string) => Promise; - likeComment: (commentId: string) => Promise; - unlikeComment: (commentId: string) => Promise; + // 关注用户操作(authService 不管理状态,所以保留在 store) followUser: (userId: string) => Promise; unfollowUser: (userId: string) => Promise; @@ -61,9 +52,6 @@ interface UserState { } export const useUserStore = create((set, get) => { - // 创建乐观更新器 - const optimisticUpdater = createOptimisticUpdaterFactory(set, get); - return { // 初始状态 users: [], @@ -252,19 +240,6 @@ export const useUserStore = create((set, get) => { 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 => { @@ -278,153 +253,7 @@ export const useUserStore = create((set, get) => { set({ searchHistory: [] }); }, - // 点赞帖子 - 使用乐观更新工具 - likePost: async (postId: string) => { - const originalPosts = get().posts; - - // 乐观更新 - set(state => ({ - posts: state.posts.map(p => - p.id === postId ? { ...p, is_liked: true, likes_count: p.likes_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 }); - } - }, - - // 取消点赞 - 使用乐观更新工具 - unlikePost: async (postId: string) => { - const originalPosts = get().posts; - - // 乐观更新 - set(state => ({ - posts: state.posts.map(p => - p.id === postId ? { ...p, is_liked: false, likes_count: Math.max(0, p.likes_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 }); - } - }, - - // 收藏帖子 - 使用乐观更新工具 - 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 - ) - })); - - 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 }); - } - }, - - // 取消收藏 - 使用乐观更新工具 - unfavoritePost: 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 - ) - })); - - 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 }); - } - }, - - // 点赞评论 - 使用乐观更新工具 - 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: '取消点赞评论失败', - }); - }, - - // 关注用户 - 使用乐观更新工具 + // 关注用户 - authService 不管理状态,所以由 store 管理 followUser: async (userId: string) => { const originalUsers = get().users; const originalUserCache = get().userCache; @@ -462,7 +291,7 @@ export const useUserStore = create((set, get) => { } }, - // 取消关注 - 使用乐观更新工具 + // 取消关注 - authService 不管理状态,所以由 store 管理 unfollowUser: async (userId: string) => { const originalUsers = get().users; const originalUserCache = get().userCache; diff --git a/src/types/dto.ts b/src/types/dto.ts index ab43a3c..111b7eb 100644 --- a/src/types/dto.ts +++ b/src/types/dto.ts @@ -326,37 +326,6 @@ export interface MessageSyncResponse { has_more: boolean; } -// ==================== Legacy Message DTOs (Deprecated) ==================== - -/** - * @deprecated 使用 MessageResponse 代替 - */ -export interface MessageDTO { - id: string; - conversation_id: string; - sender_id: string; - content: string; - message_type: string; - is_read: boolean; - created_at: string; - sender: UserDTO; -} - -/** - * @deprecated 使用 ConversationResponse 代替 - */ -export interface ConversationDTO { - id: string; - user1_id: string; - user2_id: string; - last_message: MessageDTO | null; - last_message_at: string; - unread_count: number; - participant: UserDTO; - created_at: string; - updated_at: string; -} - // ==================== Auth DTOs ==================== export interface LoginDTO { diff --git a/src/types/index.ts b/src/types/index.ts index d1ee970..08c41cf 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -189,34 +189,6 @@ export type Conversation = ConversationResponse; */ export type Message = MessageResponse; -// ============================================ -// Legacy Message Types (Deprecated) -// ============================================ - -/** - * @deprecated 使用 ConversationResponse 代替 - */ -export interface LegacyConversation { - id: string; - participant: User; - lastMessage: LegacyMessage; - unreadCount: number; - updatedAt: string; -} - -/** - * @deprecated 使用 MessageResponse 代替 - */ -export interface LegacyMessage { - id: string; - conversationId: string; - senderId: string; - content: string; - type: 'text' | 'image' | 'system'; - isRead: boolean; - createdAt: string; -} - // ============================================ // API Response Types // ============================================